Refactor admin IA around scene templates and assignments

This commit is contained in:
tian 2026-05-03 09:50:46 +08:00
parent 5dba1dcd21
commit 03e0c2f2cc
31 changed files with 4245 additions and 733 deletions

View File

@ -8,6 +8,7 @@ import (
"sort" "sort"
"strings" "strings"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage" "3588AdminBackend/internal/storage"
) )
@ -118,6 +119,62 @@ type ConfigVideoSourceAsset struct {
Raw map[string]any `json:"raw"` Raw map[string]any `json:"raw"`
} }
type RecognitionUnitAsset struct {
Ref string `json:"ref"`
SceneTemplateName string `json:"scene_template_name"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
SiteName string `json:"site_name"`
VideoSourceRef string `json:"video_source_ref"`
OutputChannel string `json:"output_channel"`
RTSPPort string `json:"rtsp_port"`
Description string `json:"description"`
AdvancedParams map[string]any `json:"advanced_params,omitempty"`
}
type DeviceAssignmentAsset struct {
DeviceID string `json:"device_id"`
ProfileName string `json:"profile_name"`
Description string `json:"description"`
RecognitionUnits []string `json:"recognition_units"`
RecognitionCount int `json:"recognition_count"`
}
type DeviceAssignmentBoardStats struct {
TotalUnits int `json:"total_units"`
TotalDevices int `json:"total_devices"`
AssignedUnits int `json:"assigned_units"`
UnassignedUnits int `json:"unassigned_units"`
AverageLoad float64 `json:"average_load"`
OverloadedDevices int `json:"overloaded_devices"`
}
type DeviceAssignmentBoardUnit struct {
Ref string `json:"ref"`
SceneTemplateName string `json:"scene_template_name"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
VideoSourceRef string `json:"video_source_ref"`
OutputChannel string `json:"output_channel"`
}
type DeviceAssignmentBoardCard struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
ProfileName string `json:"profile_name"`
AssignedCount int `json:"assigned_count"`
MaxUnits int `json:"max_units"`
Status string `json:"status"`
Units []DeviceAssignmentBoardUnit `json:"units"`
}
type DeviceAssignmentBoard struct {
MaxUnitsPerDevice int `json:"max_units_per_device"`
Stats DeviceAssignmentBoardStats `json:"stats"`
Cards []DeviceAssignmentBoardCard `json:"cards"`
Unassigned []DeviceAssignmentBoardUnit `json:"unassigned"`
}
type VideoSourceConfig struct { type VideoSourceConfig struct {
URL string `json:"url"` URL string `json:"url"`
Resolution string `json:"resolution"` Resolution string `json:"resolution"`
@ -284,42 +341,27 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
return nil, err return nil, err
} }
queueMap, _ := raw["queue"].(map[string]any) queueMap, _ := raw["queue"].(map[string]any)
instancesRaw, _ := raw["instances"].([]any) units, err := s.ListRecognitionUnits()
instances := make([]ConfigProfileInstanceAsset, 0, len(instancesRaw)) if err != nil {
for _, item := range instancesRaw { return nil, err
instanceMap, _ := item.(map[string]any) }
paramsMap, _ := instanceMap["params"].(map[string]any) instances := make([]ConfigProfileInstanceAsset, 0)
sceneMeta, _ := instanceMap["scene_meta"].(map[string]any) for _, unit := range units {
inputBindings, _ := instanceMap["input_bindings"].(map[string]any) if unit.SceneTemplateName != name {
outputBindings, _ := instanceMap["output_bindings"].(map[string]any) continue
advanced := cloneMap(paramsMap)
for _, key := range []string{
"display_name",
"device_code",
"site_name",
"video_source_ref",
"publish_hls_path",
"publish_rtsp_port",
"publish_rtsp_path",
"channel_no",
} {
delete(advanced, key)
}
if len(advanced) == 0 {
advanced = nil
} }
channel := firstString(unit.OutputChannel, unit.Name)
instances = append(instances, ConfigProfileInstanceAsset{ instances = append(instances, ConfigProfileInstanceAsset{
Name: stringValue(instanceMap["name"]), Name: unit.Name,
Template: stringValue(instanceMap["template"]), Template: stringValue(raw["primary_template_name"]),
VideoSourceRef: bindingField(inputBindings, "video_input_main", "video_source_ref"), VideoSourceRef: unit.VideoSourceRef,
DisplayName: stringValue(sceneMeta["display_name"]), DisplayName: unit.DisplayName,
DeviceCode: stringValue(sceneMeta["device_code"]), SiteName: unit.SiteName,
SiteName: stringValue(sceneMeta["site_name"]), PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
PublishHLSPath: bindingField(outputBindings, "stream_output_main", "publish_hls_path"), PublishRTSPPort: unit.RTSPPort,
PublishRTSPPort: valueString(bindingAny(outputBindings, "stream_output_main", "publish_rtsp_port")), PublishRTSPPath: "/live/" + channel,
PublishRTSPPath: bindingField(outputBindings, "stream_output_main", "publish_rtsp_path"), ChannelNo: channel,
ChannelNo: bindingField(outputBindings, "stream_output_main", "channel_no"), AdvancedParams: cloneMap(unit.AdvancedParams),
AdvancedParams: advanced,
}) })
} }
return &ConfigProfileAsset{ return &ConfigProfileAsset{
@ -334,6 +376,294 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
}, nil }, nil
} }
func (s *ConfigPreviewService) DeleteProfileAsset(name string) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
if err := validateConfigName(name); err != nil {
return err
}
units, err := s.ListRecognitionUnits()
if err != nil {
return err
}
for _, unit := range units {
if unit.SceneTemplateName == name {
return fmt.Errorf("场景模板 %q 下仍有识别单元,无法删除", name)
}
}
return s.assets.DeleteProfile(name)
}
func recognitionUnitRef(profileName string, unitName string) string {
return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName)
}
func parseRecognitionUnitRef(ref string) (string, string, error) {
parts := strings.SplitN(strings.TrimSpace(ref), "::", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref)
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil
}
func (s *ConfigPreviewService) ListRecognitionUnits() ([]RecognitionUnitAsset, error) {
if s == nil || s.assets == nil {
return nil, fmt.Errorf("asset repository is not configured")
}
records, err := s.assets.ListRecognitionUnits()
if err != nil {
return nil, err
}
items := make([]RecognitionUnitAsset, 0, len(records))
for _, record := range records {
item, err := recognitionUnitFromRecord(record)
if err != nil {
continue
}
items = append(items, *item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].SceneTemplateName != items[j].SceneTemplateName {
return items[i].SceneTemplateName < items[j].SceneTemplateName
}
return items[i].Name < items[j].Name
})
return items, nil
}
func (s *ConfigPreviewService) GetRecognitionUnit(ref string) (*RecognitionUnitAsset, error) {
sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
if err != nil {
return nil, err
}
record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
if err != nil {
return nil, err
}
if record == nil {
return nil, os.ErrNotExist
}
return recognitionUnitFromRecord(*record)
}
func (s *ConfigPreviewService) SaveRecognitionUnit(unit RecognitionUnitAsset, originalRef string) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
sceneTemplate := strings.TrimSpace(unit.SceneTemplateName)
if err := validateConfigName(sceneTemplate); err != nil {
return fmt.Errorf("invalid scene template: %w", err)
}
unitName := strings.TrimSpace(unit.Name)
if err := validateConfigName(unitName); err != nil {
return fmt.Errorf("invalid recognition unit name: %w", err)
}
if strings.TrimSpace(unit.VideoSourceRef) == "" {
return fmt.Errorf("video source is required")
}
originalTemplate := sceneTemplate
originalName := unitName
if strings.TrimSpace(originalRef) != "" {
if p, n, err := parseRecognitionUnitRef(originalRef); err == nil {
originalTemplate = p
originalName = n
}
}
if _, err := s.GetProfileEditor(sceneTemplate); err != nil {
return err
}
if sceneTemplate != originalTemplate {
if _, err := s.GetProfileEditor(originalTemplate); err != nil {
return err
}
}
if sceneTemplate != originalTemplate || unitName != originalName {
existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName)
if err != nil {
return err
}
if existing != nil {
return fmt.Errorf("duplicate recognition unit name: %s", unitName)
}
}
if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil {
return err
}
if sceneTemplate != originalTemplate || unitName != originalName {
if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil {
return err
}
}
return nil
}
func (s *ConfigPreviewService) loadRecognitionUnitEditors(sceneTemplateName string, templateName string) ([]ConfigProfileInstanceEditor, string, string, error) {
units, err := s.ListRecognitionUnits()
if err != nil {
return nil, "", "", err
}
instances := make([]ConfigProfileInstanceEditor, 0)
siteName := ""
deviceCode := ""
for _, unit := range units {
if unit.SceneTemplateName != sceneTemplateName {
continue
}
instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName))
if siteName == "" && strings.TrimSpace(unit.SiteName) != "" {
siteName = strings.TrimSpace(unit.SiteName)
}
if deviceCode == "" {
if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" {
deviceCode = code
}
}
}
return instances, siteName, deviceCode, nil
}
func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName string) ConfigProfileInstanceEditor {
channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
return ConfigProfileInstanceEditor{
Name: strings.TrimSpace(unit.Name),
Template: templateName,
VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef),
DisplayName: strings.TrimSpace(unit.DisplayName),
SiteName: strings.TrimSpace(unit.SiteName),
ChannelNo: channel,
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
PublishRTSPPort: rtspPort,
PublishRTSPPath: "/live/" + channel,
InputBindings: map[string]InputBindingEditor{
"video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)},
},
OutputBindings: map[string]OutputBindingEditor{
"stream_output_main": {
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
PublishRTSPPort: rtspPort,
PublishRTSPPath: "/live/" + channel,
ChannelNo: channel,
},
},
AdvancedParams: cloneMap(unit.AdvancedParams),
}
}
func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitRecord {
channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
if channel == "" {
channel = strings.TrimSpace(unit.Name)
}
rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
raw := map[string]any{
"name": strings.TrimSpace(unit.Name),
"scene_meta": map[string]any{
"display_name": strings.TrimSpace(unit.DisplayName),
"site_name": strings.TrimSpace(unit.SiteName),
},
"input_bindings": map[string]any{
"video_input_main": map[string]any{
"video_source_ref": strings.TrimSpace(unit.VideoSourceRef),
},
},
"output_bindings": map[string]any{
"stream_output_main": map[string]any{
"publish_hls_path": "./web/hls/" + channel + "/index.m3u8",
"publish_rtsp_port": rtspPort,
"publish_rtsp_path": "/live/" + channel,
"channel_no": channel,
},
},
}
if params := cloneMap(unit.AdvancedParams); len(params) > 0 {
raw["params"] = params
}
body, _ := marshalConfigJSON(raw)
return storage.RecognitionUnitRecord{
SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName),
Name: strings.TrimSpace(unit.Name),
DisplayName: strings.TrimSpace(unit.DisplayName),
SiteName: strings.TrimSpace(unit.SiteName),
VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef),
OutputChannel: channel,
RTSPPort: rtspPort,
Description: strings.TrimSpace(unit.Description),
BodyJSON: string(body),
}
}
func recognitionUnitFromRecord(record storage.RecognitionUnitRecord) (*RecognitionUnitAsset, error) {
raw := map[string]any{}
if strings.TrimSpace(record.BodyJSON) != "" {
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
return nil, err
}
}
sceneMeta, _ := raw["scene_meta"].(map[string]any)
params, _ := raw["params"].(map[string]any)
advanced := cloneMap(params)
if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" {
advanced["device_code"] = deviceCode
}
return &RecognitionUnitAsset{
Ref: recognitionUnitRef(record.SceneTemplateName, record.Name),
SceneTemplateName: strings.TrimSpace(record.SceneTemplateName),
Name: strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)),
DisplayName: strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)),
SiteName: strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)),
VideoSourceRef: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)),
OutputChannel: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)),
RTSPPort: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)),
Description: strings.TrimSpace(record.Description),
AdvancedParams: advanced,
}, nil
}
func bindingStringFromAnyMap(raw map[string]any, bindingKey string, slot string, field string) string {
bindings, _ := raw[bindingKey].(map[string]any)
if bindings == nil {
return ""
}
return bindingStringFromBindings(bindings, slot, field)
}
func bindingStringFromBindings(bindings map[string]any, slot string, field string) string {
entry, _ := bindings[slot].(map[string]any)
return strings.TrimSpace(stringAny(entry[field]))
}
func (e *ConfigProfileEditor) RawTemplateName() string {
if e == nil {
return ""
}
if strings.TrimSpace(e.PrimaryTemplateName) != "" {
return strings.TrimSpace(e.PrimaryTemplateName)
}
return firstProfileTemplate(e.Instances)
}
func (s *ConfigPreviewService) DeleteRecognitionUnit(ref string) error {
sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
if err != nil {
return err
}
if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil {
return err
} else if len(refs) > 0 {
return fmt.Errorf("识别单元 %q 已被设备分配引用:%s", unitName, strings.Join(refs, ", "))
}
item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
if err != nil {
return err
}
if item == nil {
return os.ErrNotExist
}
return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName)
}
func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) { func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) {
sources, err := s.ListSources() sources, err := s.ListSources()
if err != nil { if err != nil {
@ -372,6 +702,43 @@ func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset
}, nil }, nil
} }
func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw map[string]any) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
name := strings.TrimSpace(asset.Name)
if err := validateConfigName(name); err != nil {
return err
}
if raw == nil {
raw = map[string]any{}
}
raw["name"] = name
raw["description"] = strings.TrimSpace(asset.Description)
body, err := marshalConfigJSON(raw)
if err != nil {
return err
}
return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body))
}
func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
if err := validateConfigName(name); err != nil {
return err
}
refs, err := s.profileNamesReferencingOverlay(name)
if err != nil {
return err
}
if len(refs) > 0 {
return fmt.Errorf("调试参数 %q 已被场景模板引用:%s", name, strings.Join(refs, ", "))
}
return s.assets.DeleteOverlay(name)
}
func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) { func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) {
if s == nil || s.assets == nil { if s == nil || s.assets == nil {
return nil, nil return nil, nil
@ -493,7 +860,7 @@ func (s *ConfigPreviewService) DeleteVideoSource(name string) error {
return err return err
} }
if len(refs) > 0 { if len(refs) > 0 {
return fmt.Errorf("视频源 %q 已被场景配置引用:%s", name, strings.Join(refs, ", ")) return fmt.Errorf("视频源 %q 已被场景模板引用:%s", name, strings.Join(refs, ", "))
} }
return s.assets.DeleteVideoSource(name) return s.assets.DeleteVideoSource(name)
} }
@ -539,6 +906,428 @@ func (s *ConfigPreviewService) DeleteIntegrationService(name string) error {
return s.assets.DeleteIntegrationService(name) return s.assets.DeleteIntegrationService(name)
} }
func (s *ConfigPreviewService) ListDeviceAssignments() ([]DeviceAssignmentAsset, error) {
if s == nil || s.assets == nil {
return nil, nil
}
records, err := s.assets.ListDeviceAssignments()
if err != nil {
return nil, err
}
items := make([]DeviceAssignmentAsset, 0, len(records))
for _, record := range records {
item, err := deviceAssignmentFromRecord(record)
if err != nil {
continue
}
items = append(items, *item)
}
sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID })
return items, nil
}
func DefaultMaxUnitsPerDevice() int {
return 4
}
func (s *ConfigPreviewService) BuildDeviceAssignmentBoard(devices []*models.Device, maxUnitsPerDevice int) (*DeviceAssignmentBoard, error) {
assignments, err := s.ListDeviceAssignments()
if err != nil {
return nil, err
}
units, err := s.ListRecognitionUnits()
if err != nil {
return nil, err
}
return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil
}
func BuildAutoDeviceAssignments(devices []*models.Device, units []RecognitionUnitAsset, maxUnitsPerDevice int) []DeviceAssignmentAsset {
maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
type card struct {
deviceID string
deviceName string
profile string
refs []string
}
cards := make([]*card, 0, len(devices))
for _, dev := range devices {
if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
continue
}
cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()})
}
sort.Slice(cards, func(i, j int) bool {
return cards[i].deviceID < cards[j].deviceID
})
sortedUnits := append([]RecognitionUnitAsset(nil), units...)
sort.Slice(sortedUnits, func(i, j int) bool {
if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName {
return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName
}
return sortedUnits[i].Name < sortedUnits[j].Name
})
for _, unit := range sortedUnits {
var target *card
for _, candidate := range cards {
if candidate.profile != "" && candidate.profile != unit.SceneTemplateName {
continue
}
if len(candidate.refs) >= maxUnitsPerDevice {
continue
}
if target == nil || len(candidate.refs) < len(target.refs) {
target = candidate
}
}
if target == nil {
continue
}
if target.profile == "" {
target.profile = unit.SceneTemplateName
}
target.refs = append(target.refs, unit.Ref)
}
out := make([]DeviceAssignmentAsset, 0, len(cards))
for _, card := range cards {
if len(card.refs) == 0 {
continue
}
out = append(out, DeviceAssignmentAsset{
DeviceID: card.deviceID,
ProfileName: card.profile,
RecognitionUnits: append([]string(nil), card.refs...),
RecognitionCount: len(card.refs),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID })
return out
}
func BuildDeviceAssignmentBoardData(devices []*models.Device, assignments []DeviceAssignmentAsset, units []RecognitionUnitAsset, maxUnitsPerDevice int) *DeviceAssignmentBoard {
maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
unitMap := make(map[string]RecognitionUnitAsset, len(units))
for _, unit := range units {
unitMap[unit.Ref] = unit
}
deviceByID := map[string]*models.Device{}
for _, dev := range devices {
if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
continue
}
deviceByID[strings.TrimSpace(dev.DeviceID)] = dev
}
assignmentByDevice := map[string]DeviceAssignmentAsset{}
for _, assignment := range assignments {
assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment
}
deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice))
seenDeviceIDs := map[string]struct{}{}
for deviceID := range deviceByID {
seenDeviceIDs[deviceID] = struct{}{}
deviceIDs = append(deviceIDs, deviceID)
}
for deviceID := range assignmentByDevice {
if _, ok := seenDeviceIDs[deviceID]; ok {
continue
}
seenDeviceIDs[deviceID] = struct{}{}
deviceIDs = append(deviceIDs, deviceID)
}
sort.Strings(deviceIDs)
assignedRefs := map[string]struct{}{}
cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs))
for _, deviceID := range deviceIDs {
assignment := assignmentByDevice[deviceID]
card := DeviceAssignmentBoardCard{
DeviceID: deviceID,
DeviceName: boardDeviceName(deviceByID[deviceID], deviceID),
ProfileName: strings.TrimSpace(assignment.ProfileName),
MaxUnits: maxUnitsPerDevice,
}
for _, ref := range assignment.RecognitionUnits {
unit, ok := unitMap[ref]
if !ok {
continue
}
card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit))
assignedRefs[ref] = struct{}{}
}
card.AssignedCount = len(card.Units)
card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice)
cards = append(cards, card)
}
sort.Slice(cards, func(i, j int) bool {
li := boardStatusRank(cards[i].Status)
lj := boardStatusRank(cards[j].Status)
if li != lj {
return li < lj
}
if cards[i].AssignedCount != cards[j].AssignedCount {
return cards[i].AssignedCount > cards[j].AssignedCount
}
return cards[i].DeviceID < cards[j].DeviceID
})
unassigned := make([]DeviceAssignmentBoardUnit, 0)
for _, unit := range units {
if _, ok := assignedRefs[unit.Ref]; ok {
continue
}
unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit))
}
sort.Slice(unassigned, func(i, j int) bool {
if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName {
return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName
}
return unassigned[i].Name < unassigned[j].Name
})
stats := DeviceAssignmentBoardStats{
TotalUnits: len(units),
TotalDevices: len(deviceIDs),
AssignedUnits: len(assignedRefs),
UnassignedUnits: len(unassigned),
}
if stats.TotalDevices > 0 {
stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices)
}
for _, card := range cards {
if card.Status == "full" {
stats.OverloadedDevices++
}
}
return &DeviceAssignmentBoard{
MaxUnitsPerDevice: maxUnitsPerDevice,
Stats: stats,
Cards: cards,
Unassigned: unassigned,
}
}
func deviceAssignmentFromRecord(record storage.DeviceAssignmentRecord) (*DeviceAssignmentAsset, error) {
raw := map[string]any{}
if strings.TrimSpace(record.BodyJSON) != "" {
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
return nil, err
}
}
unitRefs := make([]string, 0)
if items, ok := raw["recognition_units"].([]any); ok {
for _, item := range items {
if v := strings.TrimSpace(stringValue(item)); v != "" {
unitRefs = append(unitRefs, v)
}
}
}
return &DeviceAssignmentAsset{
DeviceID: record.DeviceID,
ProfileName: firstString(record.ProfileName, stringValue(raw["profile_name"])),
Description: firstString(record.Description, stringValue(raw["description"])),
RecognitionUnits: unitRefs,
RecognitionCount: len(unitRefs),
}, nil
}
func normalizeMaxUnitsPerDevice(v int) int {
if v < 1 {
return DefaultMaxUnitsPerDevice()
}
if v > 8 {
return 8
}
return v
}
func boardDeviceName(dev *models.Device, fallback string) string {
if dev == nil {
return fallback
}
return firstString(strings.TrimSpace(dev.DisplayName()), fallback)
}
func boardUnitFromRecognitionUnit(unit RecognitionUnitAsset) DeviceAssignmentBoardUnit {
return DeviceAssignmentBoardUnit{
Ref: unit.Ref,
SceneTemplateName: unit.SceneTemplateName,
Name: unit.Name,
DisplayName: unit.DisplayName,
VideoSourceRef: unit.VideoSourceRef,
OutputChannel: firstString(unit.OutputChannel, unit.Name),
}
}
func boardCardStatus(count int, max int) string {
if count <= 0 {
return "idle"
}
if count >= max {
return "full"
}
if count*2 >= max {
return "busy"
}
return "low"
}
func boardStatusRank(status string) int {
switch status {
case "full":
return 0
case "busy":
return 1
case "low":
return 2
case "idle":
return 3
default:
return 4
}
}
func (s *ConfigPreviewService) GetDeviceAssignment(deviceID string) (*DeviceAssignmentAsset, error) {
if s == nil || s.assets == nil {
return nil, fmt.Errorf("asset repository is not configured")
}
record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID))
if err != nil {
return nil, err
}
if record == nil {
return nil, os.ErrNotExist
}
return deviceAssignmentFromRecord(*record)
}
func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
deviceID := strings.TrimSpace(asset.DeviceID)
if deviceID == "" {
return fmt.Errorf("device id is required")
}
if strings.TrimSpace(asset.ProfileName) == "" {
return fmt.Errorf("scene template is required")
}
seen := map[string]struct{}{}
for _, ref := range asset.RecognitionUnits {
profileName, _, err := parseRecognitionUnitRef(ref)
if err != nil {
return err
}
if profileName != asset.ProfileName {
return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName)
}
if _, ok := seen[ref]; ok {
return fmt.Errorf("duplicate recognition unit: %s", ref)
}
seen[ref] = struct{}{}
}
raw := map[string]any{
"device_id": deviceID,
"profile_name": strings.TrimSpace(asset.ProfileName),
"description": strings.TrimSpace(asset.Description),
"recognition_units": asset.RecognitionUnits,
}
body, err := marshalConfigJSON(raw)
if err != nil {
return err
}
return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body))
}
func (s *ConfigPreviewService) SaveDeviceAssignmentBoard(assignments map[string][]string) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
existing, err := s.ListDeviceAssignments()
if err != nil {
return err
}
existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing))
for _, item := range existing {
existingByDevice[item.DeviceID] = item
}
for deviceID, refs := range assignments {
deviceID = strings.TrimSpace(deviceID)
if deviceID == "" {
continue
}
cleanRefs := make([]string, 0, len(refs))
seen := map[string]struct{}{}
for _, ref := range refs {
ref = strings.TrimSpace(ref)
if ref == "" {
continue
}
if _, ok := seen[ref]; ok {
continue
}
seen[ref] = struct{}{}
cleanRefs = append(cleanRefs, ref)
}
if len(cleanRefs) == 0 {
if _, ok := existingByDevice[deviceID]; ok {
if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil {
return err
}
}
continue
}
firstUnit, err := s.GetRecognitionUnit(cleanRefs[0])
if err != nil {
return err
}
profileName := firstUnit.SceneTemplateName
for _, ref := range cleanRefs[1:] {
unit, err := s.GetRecognitionUnit(ref)
if err != nil {
return err
}
if unit.SceneTemplateName != profileName {
return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID)
}
}
description := existingByDevice[deviceID].Description
if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{
DeviceID: deviceID,
ProfileName: profileName,
Description: description,
RecognitionUnits: cleanRefs,
}); err != nil {
return err
}
}
return nil
}
func (s *ConfigPreviewService) DeleteDeviceAssignment(deviceID string) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID))
}
func (s *ConfigPreviewService) deviceAssignmentsReferencingRecognitionUnit(ref string) ([]string, error) {
items, err := s.ListDeviceAssignments()
if err != nil {
return nil, err
}
out := make([]string, 0)
for _, item := range items {
for _, unitRef := range item.RecognitionUnits {
if unitRef == ref {
out = append(out, item.DeviceID)
break
}
}
}
sort.Strings(out)
return out, nil
}
func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error { func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error {
if s == nil || s.assets == nil { if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured") return fmt.Errorf("asset repository is not configured")
@ -609,7 +1398,7 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
if s == nil || s.assets == nil { if s == nil || s.assets == nil {
return nil, nil return nil, nil
} }
records, err := s.assets.ListProfiles() units, err := s.ListRecognitionUnits()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -617,28 +1406,23 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
if name == "" { if name == "" {
return nil, nil return nil, nil
} }
seen := map[string]struct{}{}
refs := make([]string, 0) refs := make([]string, 0)
for _, record := range records { for _, unit := range units {
var raw map[string]any record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name)
if err != nil || record == nil {
continue
}
raw := map[string]any{}
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
continue continue
} }
if raw == nil { for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
continue if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name {
} if _, ok := seen[unit.SceneTemplateName]; !ok {
instances, _ := raw["instances"].([]any) seen[unit.SceneTemplateName] = struct{}{}
for _, item := range instances { refs = append(refs, unit.SceneTemplateName)
instanceMap, _ := item.(map[string]any)
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
found := false
for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
if strings.TrimSpace(bindingField(serviceBindings, slot, "service_ref")) == name {
refs = append(refs, record.Name)
found = true
break
} }
}
if found {
break break
} }
} }
@ -648,6 +1432,32 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
} }
func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) { func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) {
if s == nil || s.assets == nil {
return nil, nil
}
units, err := s.ListRecognitionUnits()
if err != nil {
return nil, err
}
name = strings.TrimSpace(name)
if name == "" {
return nil, nil
}
seen := map[string]struct{}{}
refs := make([]string, 0)
for _, unit := range units {
if strings.TrimSpace(unit.VideoSourceRef) == name {
if _, ok := seen[unit.SceneTemplateName]; !ok {
seen[unit.SceneTemplateName] = struct{}{}
refs = append(refs, unit.SceneTemplateName)
}
}
}
sort.Strings(refs)
return refs, nil
}
func (s *ConfigPreviewService) profileNamesReferencingOverlay(name string) ([]string, error) {
if s == nil || s.assets == nil { if s == nil || s.assets == nil {
return nil, nil return nil, nil
} }
@ -665,11 +1475,8 @@ func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) (
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
continue continue
} }
instances, _ := raw["instances"].([]any) for _, overlay := range profileOverlayNames(raw) {
for _, item := range instances { if strings.TrimSpace(overlay) == name {
instanceMap, _ := item.(map[string]any)
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
if strings.TrimSpace(bindingField(inputBindings, "video_input_main", "video_source_ref")) == name {
refs = append(refs, record.Name) refs = append(refs, record.Name)
break break
} }

View File

@ -8,6 +8,7 @@ import (
"testing" "testing"
"3588AdminBackend/internal/config" "3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage" "3588AdminBackend/internal/storage"
) )
@ -112,7 +113,7 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_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", "profiles", "local_3588_test.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{ mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{
"description": "debug overlay", "description": "启用人脸识别和陌生候选调试日志,用于联调和测试。",
"instance_overrides": { "instance_overrides": {
"*": {"override": {}}, "*": {"override": {}},
"cam1": {"override": {}} "cam1": {"override": {}}
@ -138,6 +139,9 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" { if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" {
t.Fatalf("unexpected targets: %#v", item.OverrideTargets) t.Fatalf("unexpected targets: %#v", item.OverrideTargets)
} }
if item.Description != "启用人脸识别和陌生候选调试日志,用于联调和测试。" {
t.Fatalf("expected localized overlay description, got %#v", item.Description)
}
} }
func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
@ -225,12 +229,9 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
Name: "local_3588_test", Name: "local_3588_test",
BusinessName: "A厂区视觉识别", BusinessName: "A厂区视觉识别",
Description: "test profile", Description: "test profile",
OverlayName: "face_debug",
DeviceCode: "rk3588-a-001", DeviceCode: "rk3588-a-001",
SiteName: "A厂区", SiteName: "A厂区",
Queue: ConfigProfileQueueEditor{
Size: "8",
Strategy: "drop_oldest",
},
Instances: []ConfigProfileInstanceEditor{ Instances: []ConfigProfileInstanceEditor{
{ {
Name: "cam1", Name: "cam1",
@ -269,6 +270,9 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
if doc["business_name"] != "A厂区视觉识别" { if doc["business_name"] != "A厂区视觉识别" {
t.Fatalf("unexpected business name: %#v", doc) t.Fatalf("unexpected business name: %#v", doc)
} }
if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" {
t.Fatalf("unexpected overlays doc: %#v", doc["overlays"])
}
queue, _ := doc["queue"].(map[string]any) queue, _ := doc["queue"].(map[string]any)
if queue["size"] != 8 || queue["strategy"] != "drop_oldest" { if queue["size"] != 8 || queue["strategy"] != "drop_oldest" {
t.Fatalf("unexpected queue doc: %#v", queue) t.Fatalf("unexpected queue doc: %#v", queue)
@ -353,6 +357,45 @@ func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) {
} }
} }
func TestBuildProfileDocumentDefaultsStreamOutputFromInstanceName(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{})
doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
Name: "line_a",
Instances: []ConfigProfileInstanceEditor{{
Name: "cam7",
Template: "std_workshop_face_recognition_shoe_alarm",
DisplayName: "B厂区通道7",
InputBindings: map[string]InputBindingEditor{
"video_input_main": {VideoSourceRef: "gate_cam_07"},
},
OutputBindings: map[string]OutputBindingEditor{
"stream_output_main": {
PublishRTSPPort: "8558",
},
},
}},
})
if err != nil {
t.Fatalf("BuildProfileDocument: %v", err)
}
instances, _ := doc["instances"].([]map[string]any)
inst := instances[0]
outputBindings, _ := inst["output_bindings"].(map[string]any)
streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
if streamOutput["publish_hls_path"] != "./web/hls/cam7/index.m3u8" {
t.Fatalf("expected default hls path, got %#v", streamOutput)
}
if streamOutput["publish_rtsp_path"] != "/live/cam7" {
t.Fatalf("expected default rtsp path, got %#v", streamOutput)
}
if streamOutput["channel_no"] != "cam7" {
t.Fatalf("expected default channel no, got %#v", streamOutput)
}
if streamOutput["publish_rtsp_port"] != 8558 {
t.Fatalf("expected explicit rtsp port to be preserved, got %#v", streamOutput)
}
}
func TestListVideoSources(t *testing.T) { func TestListVideoSources(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil { if err != nil {
@ -410,7 +453,7 @@ func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{}, repo) svc := NewConfigPreviewService(&config.Config{}, repo)
err = svc.DeleteVideoSource("gate_cam_01") err = svc.DeleteVideoSource("gate_cam_01")
if err == nil || !strings.Contains(err.Error(), "已被场景配置引用") { if err == nil || !strings.Contains(err.Error(), "已被场景模板引用") {
t.Fatalf("expected referenced delete to be blocked, got %v", err) t.Fatalf("expected referenced delete to be blocked, got %v", err)
} }
} }
@ -821,7 +864,7 @@ func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T)
if err != nil { if err != nil {
t.Fatalf("GetProfile: %v", err) 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"`)) { if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`)) {
t.Fatalf("expected updated profile refs, got %#v", profile) t.Fatalf("expected updated profile refs, got %#v", profile)
} }
} }
@ -876,7 +919,7 @@ func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) {
} }
} }
func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) { func TestImportStandardTemplatesFromDirSyncsExisting(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`) mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`)
mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`) mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`)
@ -894,8 +937,8 @@ func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ImportStandardTemplatesFromDir: %v", err) t.Fatalf("ImportStandardTemplatesFromDir: %v", err)
} }
if imported != 1 { if imported != 2 {
t.Fatalf("expected one imported standard template, got %d", imported) t.Fatalf("expected two synced standard templates, got %d", imported)
} }
face, err := repo.GetTemplate("std_face_recognition_stream") face, err := repo.GetTemplate("std_face_recognition_stream")
if err != nil || face == nil { if err != nil || face == nil {
@ -908,7 +951,161 @@ func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) {
if err != nil || serviceRecord == nil { if err != nil || serviceRecord == nil {
t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err) t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err)
} }
if serviceRecord.Description != "existing service" { if serviceRecord.Description != "standard service" {
t.Fatalf("expected existing template preserved, got %#v", serviceRecord) t.Fatalf("expected existing template to sync from directory, got %#v", serviceRecord)
}
if !strings.Contains(serviceRecord.BodyJSON, `"description": "standard service"`) && !strings.Contains(serviceRecord.BodyJSON, `"description":"standard service"`) {
t.Fatalf("expected synced template body, got %#v", serviceRecord)
}
}
func TestBuildDeviceAssignmentBoardDataSummarizesLoad(t *testing.T) {
devices := []*models.Device{
{DeviceID: "edge-01", DeviceName: "设备一"},
{DeviceID: "edge-02", DeviceName: "设备二"},
{DeviceID: "edge-03", DeviceName: "设备三"},
{DeviceID: "edge-04", DeviceName: "设备四"},
}
units := []RecognitionUnitAsset{
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1"},
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2"},
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3", VideoSourceRef: "vs3"},
{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4", VideoSourceRef: "vs4"},
{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5", VideoSourceRef: "vs5"},
{Ref: "scene_a::cam6", SceneTemplateName: "scene_a", Name: "cam6", VideoSourceRef: "vs6"},
}
assignments := []DeviceAssignmentAsset{
{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
{DeviceID: "edge-02", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam4"}, RecognitionCount: 1},
{DeviceID: "edge-03", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam5", "scene_a::cam6"}, RecognitionCount: 2},
}
board := BuildDeviceAssignmentBoardData(devices, assignments, units, 4)
if board.Stats.TotalUnits != 6 || board.Stats.TotalDevices != 4 {
t.Fatalf("unexpected totals: %#v", board.Stats)
}
if board.Stats.AssignedUnits != 6 || board.Stats.UnassignedUnits != 0 {
t.Fatalf("unexpected assignment counts: %#v", board.Stats)
}
if board.Stats.OverloadedDevices != 0 {
t.Fatalf("expected no full devices in this dataset, got %#v", board.Stats)
}
if len(board.Cards) != 4 {
t.Fatalf("expected four device cards, got %#v", board.Cards)
}
if board.Cards[0].DeviceID != "edge-01" || board.Cards[0].Status != "busy" {
t.Fatalf("expected >50%% loaded device to sort first, got %#v", board.Cards)
}
if board.Cards[1].DeviceID != "edge-03" || board.Cards[1].Status != "busy" {
t.Fatalf("expected 50%% loaded device to count as busy, got %#v", board.Cards)
}
if board.Cards[3].Status != "idle" {
t.Fatalf("expected idle device card, got %#v", board.Cards[3])
}
}
func TestBuildDeviceAssignmentBoardDataTreatsOverMaxAsFull(t *testing.T) {
devices := []*models.Device{{DeviceID: "edge-01", DeviceName: "设备一"}}
units := []RecognitionUnitAsset{
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
}
assignments := []DeviceAssignmentAsset{
{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
}
board := BuildDeviceAssignmentBoardData(devices, assignments, units, 2)
if board.Cards[0].Status != "full" {
t.Fatalf("expected over-max card to collapse into full, got %#v", board.Cards[0])
}
if board.Stats.OverloadedDevices != 1 {
t.Fatalf("expected full-device counter to include over-max card, got %#v", board.Stats)
}
}
func TestBuildAutoDeviceAssignmentsBalancesUnits(t *testing.T) {
devices := []*models.Device{
{DeviceID: "edge-01"},
{DeviceID: "edge-02"},
{DeviceID: "edge-03"},
}
units := []RecognitionUnitAsset{
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4"},
{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5"},
}
assignments := BuildAutoDeviceAssignments(devices, units, 2)
if len(assignments) != 3 {
t.Fatalf("expected three populated device assignments, got %#v", assignments)
}
counts := map[string]int{}
total := 0
for _, item := range assignments {
counts[item.DeviceID] = len(item.RecognitionUnits)
total += len(item.RecognitionUnits)
if item.ProfileName != "scene_a" {
t.Fatalf("expected scene template preserved, got %#v", item)
}
if len(item.RecognitionUnits) > 2 {
t.Fatalf("expected max two units per device, got %#v", item)
}
}
if total != 5 {
t.Fatalf("expected all units assigned, got %#v", assignments)
}
if counts["edge-01"] != 2 || counts["edge-02"] != 2 || counts["edge-03"] != 1 {
t.Fatalf("expected balanced distribution, got %#v", counts)
}
}
func TestSaveDeviceAssignmentBoardPersistsAssignments(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.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam1",
VideoSourceRef: "vs1",
OutputChannel: "cam1",
RTSPPort: "8555",
BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam1: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam2",
VideoSourceRef: "vs2",
OutputChannel: "cam2",
RTSPPort: "8555",
BodyJSON: `{"name":"cam2","input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam2: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
if err := svc.SaveDeviceAssignmentBoard(map[string][]string{
"edge-01": []string{"scene_a::cam1", "scene_a::cam2"},
"edge-02": []string{},
}); err != nil {
t.Fatalf("SaveDeviceAssignmentBoard: %v", err)
}
item, err := svc.GetDeviceAssignment("edge-01")
if err != nil {
t.Fatalf("GetDeviceAssignment: %v", err)
}
if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 2 {
t.Fatalf("unexpected saved assignment: %#v", item)
} }
} }

View File

@ -41,6 +41,7 @@ type ConfigPreviewRequest struct {
Overlays []string Overlays []string
ConfigID string ConfigID string
ConfigVersion string ConfigVersion string
DeviceID string
} }
type ConfigPreviewResult struct { type ConfigPreviewResult struct {
@ -120,18 +121,31 @@ func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewR
if err != nil { if err != nil {
return nil, err return nil, err
} }
profileRaw, profilePath, err := s.readAssetJSON("profiles", req.Profile) profilePath := repoAssetPath("profiles", req.Profile)
if err != nil { profileRaw := map[string]any{}
return nil, err if strings.TrimSpace(req.Profile) != "" {
editor, err := s.GetProfileEditor(req.Profile)
if err != nil {
return nil, err
}
profileRaw, err = s.BuildProfileDocument(*editor)
if err != nil {
return nil, err
}
} }
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays)) selectedOverlays := req.Overlays
for _, overlay := range req.Overlays { if len(selectedOverlays) == 0 {
selectedOverlays = profileOverlayNames(profileRaw)
}
overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays))
for _, overlay := range selectedOverlays {
raw, path, err := s.readAssetJSON("overlays", overlay) raw, path, err := s.readAssetJSON("overlays", overlay)
if err != nil { if err != nil {
return nil, err return nil, err
} }
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
} }
req.Overlays = append([]string(nil), selectedOverlays...)
return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays) return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays)
} }
@ -155,17 +169,87 @@ func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, r
if err != nil { if err != nil {
return nil, err return nil, err
} }
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays)) selectedOverlays := req.Overlays
for _, overlay := range req.Overlays { if len(selectedOverlays) == 0 {
selectedOverlays = profileOverlayNames(doc)
}
overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays))
for _, overlay := range selectedOverlays {
raw, path, err := s.readAssetJSON("overlays", overlay) raw, path, err := s.readAssetJSON("overlays", overlay)
if err != nil { if err != nil {
return nil, err return nil, err
} }
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
} }
req.Overlays = append([]string(nil), selectedOverlays...)
return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays) return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays)
} }
func (s *ConfigPreviewService) BuildProfileEditorForDeviceAssignment(deviceID string) (*ConfigProfileEditor, *DeviceAssignmentAsset, error) {
assignment, err := s.GetDeviceAssignment(deviceID)
if err != nil {
return nil, nil, err
}
editor, err := s.GetProfileEditor(assignment.ProfileName)
if err != nil {
return nil, nil, err
}
if len(assignment.RecognitionUnits) == 0 {
editor.Instances = nil
return editor, assignment, nil
}
selected := make(map[string]struct{}, len(assignment.RecognitionUnits))
for _, ref := range assignment.RecognitionUnits {
selected[ref] = struct{}{}
}
filtered := make([]ConfigProfileInstanceEditor, 0, len(assignment.RecognitionUnits))
for _, inst := range editor.Instances {
if _, ok := selected[recognitionUnitRef(editor.Name, inst.Name)]; ok {
filtered = append(filtered, inst)
}
}
editor.Instances = filtered
return editor, assignment, nil
}
func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string) (*ConfigPreviewResult, error) {
editor, assignment, err := s.BuildProfileEditorForDeviceAssignment(deviceID)
if err != nil {
return nil, err
}
if len(editor.Instances) == 0 {
return nil, fmt.Errorf("设备 %s 还没有分配识别单元", deviceID)
}
templateName := firstProfileTemplate(editor.Instances)
if strings.TrimSpace(templateName) == "" {
templateName = editor.RawTemplateName()
}
return s.RenderProfileEditor(*editor, ConfigPreviewRequest{
Template: templateName,
Profile: assignment.ProfileName,
ConfigID: "assignment_" + deviceID,
ConfigVersion: time.Now().Format("20060102.150405"),
DeviceID: deviceID,
})
}
func profileOverlayNames(raw map[string]any) []string {
items, _ := raw["overlays"].([]any)
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if v := stringAny(item); strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
}
if len(out) == 0 {
return nil
}
return out
}
func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) { func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) {
if err := validateConfigName(req.Template); err != nil { if err := validateConfigName(req.Template); err != nil {
return nil, fmt.Errorf("invalid template: %w", err) return nil, fmt.Errorf("invalid template: %w", err)
@ -190,12 +274,16 @@ func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templa
if err != nil { if err != nil {
return nil, err return nil, err
} }
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, map[string]any{ metadata := map[string]any{
"config_id": req.ConfigID, "config_id": req.ConfigID,
"config_version": req.ConfigVersion, "config_version": req.ConfigVersion,
"rendered_at": time.Now().Format(time.RFC3339), "rendered_at": time.Now().Format(time.RFC3339),
"rendered_by": "managerd", "rendered_by": "managerd",
}) }
if strings.TrimSpace(req.DeviceID) != "" {
metadata["device_id"] = strings.TrimSpace(req.DeviceID)
}
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -203,14 +291,14 @@ func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templa
if err != nil { if err != nil {
return nil, err return nil, err
} }
metadata, _ := doc["metadata"].(map[string]any) renderedMetadata, _ := doc["metadata"].(map[string]any)
sum := sha256.Sum256(body) sum := sha256.Sum256(body)
return &ConfigPreviewResult{ return &ConfigPreviewResult{
Request: req, Request: req,
Root: previewRenderRoot(templatePath, profilePath), Root: previewRenderRoot(templatePath, profilePath),
Sha256: hex.EncodeToString(sum[:]), Sha256: hex.EncodeToString(sum[:]),
Size: len(body), Size: len(body),
Metadata: metadata, Metadata: renderedMetadata,
JSON: string(body), JSON: string(body),
}, nil }, nil
} }

View File

@ -327,6 +327,79 @@ func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
} }
} }
func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
"name":"std_service_test_stream",
"template":{
"nodes":[
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}
],
"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.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
"name":"line_a",
"business_name":"Line A",
"overlays":["night_relaxed"],
"instances":[
{
"name":"cam1",
"template":"std_service_test_stream",
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}
}
]
}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil {
t.Fatalf("SaveOverlay: %v", err)
}
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsFromMediaRepo(t, svc)
result, err := svc.Render(ConfigPreviewRequest{
Template: "std_service_test_stream",
Profile: "line_a",
ConfigID: "preview",
ConfigVersion: "v1",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
var doc map[string]any
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
t.Fatalf("unmarshal render result: %v", err)
}
metadata, _ := doc["metadata"].(map[string]any)
names, _ := metadata["overlays"].([]any)
if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
t.Fatalf("expected profile overlay metadata, got %#v", metadata["overlays"])
}
instances, _ := doc["instances"].([]any)
instance, _ := instances[0].(map[string]any)
params, _ := instance["params"].(map[string]any)
if got := boolValue(params["debug"], false); !got {
t.Fatalf("expected profile overlay params to merge into runtime instance, got %#v", instance)
}
}
func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) { func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{}) svc := NewConfigPreviewService(&config.Config{})
err := svc.SaveProfileEditor(ConfigProfileEditor{ err := svc.SaveProfileEditor(ConfigProfileEditor{

View File

@ -10,8 +10,10 @@ import (
type ConfigProfileEditor struct { type ConfigProfileEditor struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
PrimaryTemplateName string `json:"primary_template_name"`
BusinessName string `json:"business_name"` BusinessName string `json:"business_name"`
Description string `json:"description"` Description string `json:"description"`
OverlayName string `json:"overlay_name"`
DeviceCode string `json:"device_code"` DeviceCode string `json:"device_code"`
SiteName string `json:"site_name"` SiteName string `json:"site_name"`
Queue ConfigProfileQueueEditor `json:"queue"` Queue ConfigProfileQueueEditor `json:"queue"`
@ -24,6 +26,13 @@ type ConfigProfileQueueEditor struct {
Strategy string `json:"strategy"` Strategy string `json:"strategy"`
} }
func DefaultConfigProfileQueue() ConfigProfileQueueEditor {
return ConfigProfileQueueEditor{
Size: "8",
Strategy: "drop_oldest",
}
}
type InputBindingEditor struct { type InputBindingEditor struct {
VideoSourceRef string `json:"video_source_ref"` VideoSourceRef string `json:"video_source_ref"`
} }
@ -62,63 +71,20 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit
return nil, err return nil, err
} }
queueMap, _ := raw["queue"].(map[string]any) queueMap, _ := raw["queue"].(map[string]any)
instancesRaw, _ := raw["instances"].([]any) templateName := stringValue(raw["primary_template_name"])
instances := make([]ConfigProfileInstanceEditor, 0, len(instancesRaw)) instances, siteName, deviceCode, err := s.loadRecognitionUnitEditors(name, templateName)
deviceCode := "" if err != nil {
siteName := "" return nil, err
for _, item := range instancesRaw {
instanceMap, _ := item.(map[string]any)
paramsMap, _ := instanceMap["params"].(map[string]any)
sceneMeta, _ := instanceMap["scene_meta"].(map[string]any)
inputBindings := parseInputBindingEditors(instanceMap["input_bindings"])
serviceBindings := parseServiceBindingEditors(instanceMap["service_bindings"])
outputBindings := parseOutputBindingEditors(instanceMap["output_bindings"])
if deviceCode == "" {
deviceCode = firstString(sceneMeta["device_code"], stringValue(paramsMap["device_code"]))
}
if siteName == "" {
siteName = firstString(sceneMeta["site_name"], stringValue(paramsMap["site_name"]))
}
advanced := cloneMap(paramsMap)
for _, key := range []string{
"display_name",
"device_code",
"site_name",
"video_source_ref",
"rtsp_url",
"publish_hls_path",
"publish_rtsp_port",
"publish_rtsp_path",
"channel_no",
} {
delete(advanced, key)
}
if len(advanced) == 0 {
advanced = nil
}
instances = append(instances, ConfigProfileInstanceEditor{
Name: stringValue(instanceMap["name"]),
Template: stringValue(instanceMap["template"]),
VideoSourceRef: inputBindingRef(inputBindings, "video_input_main"),
DisplayName: stringValue(sceneMeta["display_name"]),
SiteName: stringValue(sceneMeta["site_name"]),
PublishHLSPath: outputBindingValue(outputBindings, "stream_output_main", "publish_hls_path"),
PublishRTSPPort: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_port"),
PublishRTSPPath: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_path"),
ChannelNo: outputBindingValue(outputBindings, "stream_output_main", "channel_no"),
InputBindings: inputBindings,
ServiceBindings: serviceBindings,
OutputBindings: outputBindings,
AdvancedParams: advanced,
})
} }
return &ConfigProfileEditor{ return &ConfigProfileEditor{
Name: firstString(raw["name"], name), Name: firstString(raw["name"], name),
Path: path, Path: path,
BusinessName: stringValue(raw["business_name"]), PrimaryTemplateName: stringValue(raw["primary_template_name"]),
Description: stringValue(raw["description"]), BusinessName: stringValue(raw["business_name"]),
DeviceCode: deviceCode, Description: stringValue(raw["description"]),
SiteName: siteName, OverlayName: firstOverlayName(raw),
DeviceCode: deviceCode,
SiteName: siteName,
Queue: ConfigProfileQueueEditor{ Queue: ConfigProfileQueueEditor{
Size: valueString(queueMap["size"]), Size: valueString(queueMap["size"]),
Strategy: stringValue(queueMap["strategy"]), Strategy: stringValue(queueMap["strategy"]),
@ -137,9 +103,6 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
return nil, fmt.Errorf("invalid profile name: %w", err) return nil, fmt.Errorf("invalid profile name: %w", err)
} }
if len(editor.Instances) == 0 {
return nil, fmt.Errorf("at least one instance is required")
}
seen := map[string]struct{}{} seen := map[string]struct{}{}
instances := make([]map[string]any, 0, len(editor.Instances)) instances := make([]map[string]any, 0, len(editor.Instances))
for _, inst := range editor.Instances { for _, inst := range editor.Instances {
@ -202,25 +165,28 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
} }
instances = append(instances, instance) instances = append(instances, instance)
} }
if len(instances) == 0 {
return nil, fmt.Errorf("at least one active instance is required")
}
doc := map[string]any{ doc := map[string]any{
"name": name, "name": name,
"instances": instances, "instances": instances,
} }
setString(doc, "business_name", editor.BusinessName) setString(doc, "business_name", editor.BusinessName)
setString(doc, "description", editor.Description) setString(doc, "description", editor.Description)
if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" {
doc["overlays"] = []any{overlayName}
}
normalizedQueue := editor.Queue
if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" {
normalizedQueue = DefaultConfigProfileQueue()
}
queue := map[string]any{} queue := map[string]any{}
if size := strings.TrimSpace(editor.Queue.Size); size != "" { if size := strings.TrimSpace(normalizedQueue.Size); size != "" {
value, err := strconv.Atoi(size) value, err := strconv.Atoi(size)
if err != nil { if err != nil {
return nil, fmt.Errorf("queue size must be a number") return nil, fmt.Errorf("queue size must be a number")
} }
queue["size"] = value queue["size"] = value
} }
setString(queue, "strategy", editor.Queue.Strategy) setString(queue, "strategy", normalizedQueue.Strategy)
if len(queue) > 0 { if len(queue) > 0 {
doc["queue"] = queue doc["queue"] = queue
} }
@ -229,7 +195,7 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
} }
func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) error { func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) error {
doc, err := s.BuildProfileDocument(editor) doc, err := s.buildSceneTemplateDocument(editor)
if err != nil { if err != nil {
return err return err
} }
@ -238,9 +204,13 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
return err return err
} }
if s != nil && s.assets != nil { if s != nil && s.assets != nil {
return s.assets.SaveProfile( templateName := strings.TrimSpace(editor.PrimaryTemplateName)
strings.TrimSpace(editor.Name), if templateName == "" {
firstProfileTemplate(editor.Instances), templateName = firstProfileTemplate(editor.Instances)
}
return s.assets.SaveProfile(
strings.TrimSpace(editor.Name),
templateName,
strings.TrimSpace(editor.BusinessName), strings.TrimSpace(editor.BusinessName),
strings.TrimSpace(editor.Description), strings.TrimSpace(editor.Description),
string(body), string(body),
@ -249,12 +219,59 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
return fmt.Errorf("asset repository is not configured") return fmt.Errorf("asset repository is not configured")
} }
func (s *ConfigPreviewService) buildSceneTemplateDocument(editor ConfigProfileEditor) (map[string]any, error) {
name := strings.TrimSpace(editor.Name)
if name == "" {
return nil, fmt.Errorf("scene template name is required")
}
if err := validateConfigName(name); err != nil {
return nil, fmt.Errorf("invalid scene template name: %w", err)
}
doc := map[string]any{
"name": name,
}
setString(doc, "primary_template_name", editor.PrimaryTemplateName)
setString(doc, "business_name", editor.BusinessName)
setString(doc, "description", editor.Description)
setString(doc, "site_name", editor.SiteName)
if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" {
doc["overlays"] = []any{overlayName}
}
normalizedQueue := editor.Queue
if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" {
normalizedQueue = DefaultConfigProfileQueue()
}
queue := map[string]any{}
if size := strings.TrimSpace(normalizedQueue.Size); size != "" {
value, err := strconv.Atoi(size)
if err != nil {
return nil, fmt.Errorf("queue size must be a number")
}
queue["size"] = value
}
setString(queue, "strategy", normalizedQueue.Strategy)
if len(queue) > 0 {
doc["queue"] = queue
}
return doc, nil
}
func setString(m map[string]any, key string, value string) { func setString(m map[string]any, key string, value string) {
if strings.TrimSpace(value) != "" { if strings.TrimSpace(value) != "" {
m[key] = strings.TrimSpace(value) m[key] = strings.TrimSpace(value)
} }
} }
func firstOverlayName(raw map[string]any) string {
items, _ := raw["overlays"].([]any)
for _, item := range items {
if v := stringValue(item); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func marshalConfigJSON(doc map[string]any) ([]byte, error) { func marshalConfigJSON(doc map[string]any) ([]byte, error) {
body, err := json.MarshalIndent(doc, "", " ") body, err := json.MarshalIndent(doc, "", " ")
if err != nil { if err != nil {
@ -390,6 +407,9 @@ func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]an
func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) { func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) {
out := map[string]any{} out := map[string]any{}
for key, value := range inst.OutputBindings { for key, value := range inst.OutputBindings {
if key == "stream_output_main" {
value = applyDefaultStreamOutputBinding(inst.Name, value)
}
entry, err := outputBindingEntry(value) entry, err := outputBindingEntry(value)
if err != nil { if err != nil {
return nil, err return nil, err
@ -399,12 +419,12 @@ func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]an
} }
} }
if _, ok := out["stream_output_main"]; !ok { if _, ok := out["stream_output_main"]; !ok {
entry, err := outputBindingEntry(OutputBindingEditor{ entry, err := outputBindingEntry(applyDefaultStreamOutputBinding(inst.Name, OutputBindingEditor{
PublishHLSPath: inst.PublishHLSPath, PublishHLSPath: inst.PublishHLSPath,
PublishRTSPPort: inst.PublishRTSPPort, PublishRTSPPort: inst.PublishRTSPPort,
PublishRTSPPath: inst.PublishRTSPPath, PublishRTSPPath: inst.PublishRTSPPath,
ChannelNo: inst.ChannelNo, ChannelNo: inst.ChannelNo,
}) }))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -418,6 +438,23 @@ func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]an
return out, nil return out, nil
} }
func applyDefaultStreamOutputBinding(instanceName string, value OutputBindingEditor) OutputBindingEditor {
name := strings.TrimSpace(instanceName)
if name == "" {
return value
}
if strings.TrimSpace(value.PublishHLSPath) == "" {
value.PublishHLSPath = "./web/hls/" + name + "/index.m3u8"
}
if strings.TrimSpace(value.PublishRTSPPath) == "" {
value.PublishRTSPPath = "/live/" + name
}
if strings.TrimSpace(value.ChannelNo) == "" {
value.ChannelNo = name
}
return value
}
func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) { func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) {
entry := map[string]any{} entry := map[string]any{}
setString(entry, "publish_hls_path", value.PublishHLSPath) setString(entry, "publish_hls_path", value.PublishHLSPath)

View File

@ -52,13 +52,6 @@ func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int,
if !isStandardTemplateName(name) { if !isStandardTemplateName(name) {
return imported, fmt.Errorf("%s: standard template name must start with std_", path) return imported, fmt.Errorf("%s: standard template name must start with std_", path)
} }
existing, err := repo.GetTemplate(name)
if err != nil {
return imported, err
}
if existing != nil {
continue
}
if strings.TrimSpace(stringValue(raw["source"])) == "" { if strings.TrimSpace(stringValue(raw["source"])) == "" {
raw["source"] = "standard" raw["source"] = "standard"
} }
@ -66,6 +59,15 @@ func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int,
if err != nil { if err != nil {
return imported, err return imported, err
} }
existing, err := repo.GetTemplate(name)
if err != nil {
return imported, err
}
if existing != nil &&
strings.TrimSpace(existing.Description) == strings.TrimSpace(stringValue(raw["description"])) &&
strings.TrimSpace(existing.BodyJSON) == strings.TrimSpace(string(body)) {
continue
}
if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil { if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil {
return imported, err return imported, err
} }

View File

@ -200,6 +200,23 @@ func extractConfigPayload(payload any) (any, error) {
return payload, nil return payload, nil
} }
func extractConfigPayloadForDevice(payload any, deviceID string) (any, error) {
if payload == nil {
return nil, fmt.Errorf("payload is required")
}
if m, ok := payload.(map[string]any); ok {
if configs, exists := m["configs"]; exists {
if byDevice, ok := configs.(map[string]any); ok {
if v, ok := byDevice[deviceID]; ok {
return v, nil
}
return nil, fmt.Errorf("device assignment config is missing for %s", deviceID)
}
}
}
return extractConfigPayload(payload)
}
func optionalConfigRequestBody(payload any) (io.Reader, int64, error) { func optionalConfigRequestBody(payload any) (io.Reader, int64, error) {
if payload == nil { if payload == nil {
return nil, 0, nil return nil, 0, nil
@ -254,7 +271,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
switch task.Type { switch task.Type {
case "config_apply": case "config_apply":
cfgPayload, err := extractConfigPayload(task.Payload) cfgPayload, err := extractConfigPayloadForDevice(task.Payload, did)
if err != nil { if err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return return
@ -399,7 +416,7 @@ func (s *TaskService) persistConfigState(task *models.Task, did string) {
if s == nil || s.stateRepo == nil || task == nil || task.Type != "config_apply" { if s == nil || s.stateRepo == nil || task == nil || task.Type != "config_apply" {
return return
} }
meta := taskPayloadMetadata(task.Payload) meta := taskPayloadMetadataForDevice(task.Payload, did)
overlaysJSON := "[]" overlaysJSON := "[]"
if len(meta.Overlays) > 0 { if len(meta.Overlays) > 0 {
if body, err := json.Marshal(meta.Overlays); err == nil { if body, err := json.Marshal(meta.Overlays); err == nil {
@ -413,7 +430,7 @@ func (s *TaskService) appendAuditLog(task *models.Task, did string, status model
if s == nil || s.auditRepo == nil || task == nil { if s == nil || s.auditRepo == nil || task == nil {
return return
} }
meta := taskPayloadMetadata(task.Payload) meta := taskPayloadMetadataForDevice(task.Payload, did)
details := map[string]any{ details := map[string]any{
"task_id": task.ID, "task_id": task.ID,
"type": task.Type, "type": task.Type,
@ -449,13 +466,21 @@ type taskMetadata struct {
ConfigVersion string ConfigVersion string
} }
func taskPayloadMetadata(payload any) taskMetadata { func taskPayloadMetadataForDevice(payload any, deviceID string) taskMetadata {
var out taskMetadata var out taskMetadata
root, ok := payload.(map[string]any) root, ok := payload.(map[string]any)
if !ok { if !ok {
return out return out
} }
configRoot, ok := root["config"].(map[string]any) var configRoot map[string]any
if rawConfigs, ok := root["configs"].(map[string]any); ok {
if rawConfig, ok := rawConfigs[deviceID].(map[string]any); ok {
configRoot = rawConfig
}
}
if configRoot == nil {
configRoot, ok = root["config"].(map[string]any)
}
if !ok { if !ok {
return out return out
} }

View File

@ -38,6 +38,29 @@ type VideoSourceRecord struct {
UpdatedAt string UpdatedAt string
} }
type DeviceAssignmentRecord struct {
DeviceID string
ProfileName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type RecognitionUnitRecord struct {
SceneTemplateName string
Name string
DisplayName string
SiteName string
VideoSourceRef string
OutputChannel string
RTSPPort string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type AssetsRepo struct { type AssetsRepo struct {
db *sql.DB db *sql.DB
} }
@ -108,6 +131,44 @@ ON CONFLICT(name) DO UPDATE SET
return err return err
} }
func (r *AssetsRepo) SaveDeviceAssignment(deviceID string, profileName string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO device_assignments(device_id, profile_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM device_assignments WHERE device_id = ?), ?), ?)
ON CONFLICT(device_id) DO UPDATE SET
profile_name=excluded.profile_name,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, deviceID, profileName, description, bodyJSON, deviceID, now, now)
return err
}
func (r *AssetsRepo) SaveRecognitionUnit(record RecognitionUnitRecord) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
ON CONFLICT(scene_template_name, name) DO UPDATE SET
display_name=excluded.display_name,
site_name=excluded.site_name,
video_source_ref=excluded.video_source_ref,
output_channel=excluded.output_channel,
rtsp_port=excluded.rtsp_port,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, record.SceneTemplateName, record.Name, record.DisplayName, record.SiteName, record.VideoSourceRef, record.OutputChannel, record.RTSPPort, record.Description, record.BodyJSON, record.SceneTemplateName, record.Name, now, now)
return err
}
func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) { func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) {
return r.listAssets("templates") return r.listAssets("templates")
} }
@ -170,6 +231,55 @@ ORDER BY updated_at DESC, name ASC
return out, rows.Err() return out, rows.Err()
} }
func (r *AssetsRepo) ListDeviceAssignments() ([]DeviceAssignmentRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT device_id, profile_name, description, body_json, created_at, updated_at
FROM device_assignments
ORDER BY updated_at DESC, device_id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DeviceAssignmentRecord
for rows.Next() {
var item DeviceAssignmentRecord
if err := rows.Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) ListRecognitionUnits() ([]RecognitionUnitRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
FROM recognition_units
ORDER BY scene_template_name ASC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RecognitionUnitRecord
for rows.Next() {
var item RecognitionUnitRecord
if err := rows.Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) { func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) {
return r.getAsset("templates", name) return r.getAsset("templates", name)
} }
@ -220,10 +330,52 @@ WHERE name = ?
return &item, nil return &item, nil
} }
func (r *AssetsRepo) GetDeviceAssignment(deviceID string) (*DeviceAssignmentRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item DeviceAssignmentRecord
err := r.db.QueryRow(`
SELECT device_id, profile_name, description, body_json, created_at, updated_at
FROM device_assignments
WHERE device_id = ?
`, deviceID).Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) GetRecognitionUnit(sceneTemplateName string, name string) (*RecognitionUnitRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item RecognitionUnitRecord
err := r.db.QueryRow(`
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
FROM recognition_units
WHERE scene_template_name = ? AND name = ?
`, sceneTemplateName, name).Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) DeleteTemplate(name string) error { func (r *AssetsRepo) DeleteTemplate(name string) error {
return r.deleteAsset("templates", name) return r.deleteAsset("templates", name)
} }
func (r *AssetsRepo) DeleteProfile(name string) error {
return r.deleteAsset("profiles", name)
}
func (r *AssetsRepo) DeleteIntegrationService(name string) error { func (r *AssetsRepo) DeleteIntegrationService(name string) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil return nil
@ -240,6 +392,26 @@ func (r *AssetsRepo) DeleteVideoSource(name string) error {
return err return err
} }
func (r *AssetsRepo) DeleteDeviceAssignment(deviceID string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM device_assignments WHERE device_id = ?`, deviceID)
return err
}
func (r *AssetsRepo) DeleteRecognitionUnit(sceneTemplateName string, name string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ? AND name = ?`, sceneTemplateName, name)
return err
}
func (r *AssetsRepo) DeleteOverlay(name string) error {
return r.deleteAsset("overlays", name)
}
func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error { func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil return nil
@ -289,9 +461,9 @@ WHERE name = ?
rows, err := tx.Query(` rows, err := tx.Query(`
SELECT name, description, business_name, body_json SELECT name, description, business_name, body_json
FROM profiles FROM scene_templates
WHERE primary_template_name = ? OR body_json LIKE ? WHERE primary_template_name = ? OR body_json LIKE ?
`, oldName, "%\"template\":\""+oldName+"\"%") `, oldName, "%\"primary_template_name\": \""+oldName+"\"%")
if err != nil { if err != nil {
return err return err
} }
@ -323,7 +495,7 @@ WHERE primary_template_name = ? OR body_json LIKE ?
} }
for _, item := range updates { for _, item := range updates {
if _, err := tx.Exec(` if _, err := tx.Exec(`
UPDATE profiles UPDATE scene_templates
SET primary_template_name = ?, body_json = ?, updated_at = ? SET primary_template_name = ?, body_json = ?, updated_at = ?
WHERE name = ? WHERE name = ?
`, newName, item.bodyJSON, now, item.name); err != nil { `, newName, item.bodyJSON, now, item.name); err != nil {
@ -331,6 +503,14 @@ WHERE name = ?
} }
} }
if _, err := tx.Exec(`
UPDATE recognition_units
SET body_json = REPLACE(body_json, ?, ?), updated_at = ?
WHERE body_json LIKE ?
`, `"`+"template"+`": "`+oldName+`"`, `"`+"template"+`": "`+newName+`"`, now, "%\"template\": \""+oldName+"\"%"); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil { if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil {
return err return err
} }
@ -358,22 +538,115 @@ ON CONFLICT(name) DO UPDATE SET
`, record.Name, record.Description, record.BodyJSON, record.Name, now, now) `, record.Name, record.Description, record.BodyJSON, record.Name, now, now)
return err return err
case "profiles": case "profiles":
_, err := r.db.Exec(` normalized, units, replaceUnits, err := normalizeProfileRecord(record)
INSERT INTO profiles(name, primary_template_name, business_name, description, body_json, created_at, updated_at) if err != nil {
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM profiles WHERE name = ?), ?), ?) return err
}
tx, err := r.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(`
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM scene_templates WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
primary_template_name=excluded.primary_template_name, primary_template_name=excluded.primary_template_name,
business_name=excluded.business_name, business_name=excluded.business_name,
description=excluded.description, description=excluded.description,
body_json=excluded.body_json, body_json=excluded.body_json,
updated_at=excluded.updated_at updated_at=excluded.updated_at
`, record.Name, record.TemplateName, record.BusinessName, record.Description, record.BodyJSON, record.Name, now, now) `, normalized.Name, normalized.TemplateName, normalized.BusinessName, normalized.Description, normalized.BodyJSON, normalized.Name, now, now)
return err if err != nil {
return err
}
if replaceUnits {
if _, err := tx.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil {
return err
}
for _, unit := range units {
if _, err := tx.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
ON CONFLICT(scene_template_name, name) DO UPDATE SET
display_name=excluded.display_name,
site_name=excluded.site_name,
video_source_ref=excluded.video_source_ref,
output_channel=excluded.output_channel,
rtsp_port=excluded.rtsp_port,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, unit.SceneTemplateName, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, unit.Description, unit.BodyJSON, unit.SceneTemplateName, unit.Name, now, now); err != nil {
return err
}
}
}
return tx.Commit()
default: default:
return nil return nil
} }
} }
func normalizeProfileRecord(record AssetRecord) (AssetRecord, []RecognitionUnitRecord, bool, error) {
raw := map[string]any{}
if strings.TrimSpace(record.BodyJSON) != "" {
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
return AssetRecord{}, nil, false, err
}
}
if raw == nil {
raw = map[string]any{}
}
_, hasInstances := raw["instances"]
if !hasInstances {
return record, nil, false, nil
}
templateDoc, units, err := splitLegacyProfileDocument(struct {
Name string
TemplateName string
BusinessName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}{
Name: record.Name,
TemplateName: record.TemplateName,
BusinessName: record.BusinessName,
Description: record.Description,
BodyJSON: record.BodyJSON,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
})
if err != nil {
return AssetRecord{}, nil, false, err
}
body, err := json.MarshalIndent(templateDoc, "", " ")
if err != nil {
return AssetRecord{}, nil, false, err
}
normalized := record
normalized.BodyJSON = string(append(body, '\n'))
outUnits := make([]RecognitionUnitRecord, 0, len(units))
for _, unit := range units {
outUnits = append(outUnits, RecognitionUnitRecord{
SceneTemplateName: record.Name,
Name: unit.Name,
DisplayName: unit.DisplayName,
SiteName: unit.SiteName,
VideoSourceRef: unit.VideoSourceRef,
OutputChannel: unit.OutputChannel,
RTSPPort: unit.RTSPPort,
Description: record.Description,
BodyJSON: unit.BodyJSON,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
})
}
return normalized, outUnits, true, nil
}
func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) { func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, nil return nil, nil
@ -386,7 +659,7 @@ ORDER BY updated_at DESC, name ASC
if table == "profiles" { if table == "profiles" {
query = ` query = `
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM profiles FROM scene_templates
ORDER BY updated_at DESC, name ASC ORDER BY updated_at DESC, name ASC
` `
} }
@ -419,7 +692,7 @@ WHERE name = ?
if table == "profiles" { if table == "profiles" {
query = ` query = `
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM profiles FROM scene_templates
WHERE name = ? WHERE name = ?
` `
} }
@ -439,6 +712,9 @@ func (r *AssetsRepo) deleteAsset(table string, name string) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil return nil
} }
if table == "profiles" {
table = "scene_templates"
}
_, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name) _, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name)
return err return err
} }
@ -449,6 +725,10 @@ func rewriteProfileTemplateRefs(bodyJSON string, oldName string, newName string)
return "", false, err return "", false, err
} }
changed := false changed := false
if strings.TrimSpace(valueString(raw["primary_template_name"])) == oldName {
raw["primary_template_name"] = newName
changed = true
}
instances, _ := raw["instances"].([]any) instances, _ := raw["instances"].([]any)
for _, item := range instances { for _, item := range instances {
instanceMap, _ := item.(map[string]any) instanceMap, _ := item.(map[string]any)

View File

@ -78,7 +78,7 @@ func TestAssetsRepoRenameTemplateUpdatesProfileReferences(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetProfile: %v", err) 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"`) { if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`) {
t.Fatalf("expected profile template ref updated, got %#v", profile) t.Fatalf("expected profile template ref updated, got %#v", profile)
} }
} }

View File

@ -2,6 +2,7 @@ package storage
import ( import (
"database/sql" "database/sql"
"sort"
"time" "time"
) )
@ -74,3 +75,32 @@ WHERE device_id = ?
} }
return &item, nil return &item, nil
} }
func (r *DeviceConfigStateRepo) ListByProfileName(profileName string) ([]DeviceConfigStateRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at
FROM device_config_state
WHERE profile_name = ?
ORDER BY device_id ASC
`, profileName)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DeviceConfigStateRecord
for rows.Next() {
var item DeviceConfigStateRecord
if err := rows.Scan(&item.DeviceID, &item.TemplateName, &item.ProfileName, &item.OverlaysJSON, &item.ConfigID, &item.ConfigVersion, &item.LastAppliedTaskID, &item.UpdatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID })
return items, nil
}

View File

@ -2,7 +2,10 @@ package storage
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"strings"
"time"
) )
const schema001 = ` const schema001 = `
@ -52,6 +55,39 @@ CREATE TABLE IF NOT EXISTS video_sources (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS scene_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
primary_template_name TEXT NOT NULL DEFAULT '',
business_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recognition_units (
id INTEGER PRIMARY KEY,
scene_template_name TEXT NOT NULL,
name TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
video_source_ref TEXT NOT NULL DEFAULT '',
output_channel TEXT NOT NULL DEFAULT '',
rtsp_port TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(scene_template_name, name)
);
CREATE TABLE IF NOT EXISTS device_assignments (
device_id TEXT PRIMARY KEY,
profile_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY, device_id TEXT PRIMARY KEY,
hostname TEXT NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '',
@ -109,7 +145,10 @@ func migrate(db *sql.DB) error {
if _, err := db.Exec(schema001); err != nil { if _, err := db.Exec(schema001); err != nil {
return err return err
} }
return migrateProfilePrimaryTemplateName(db) if err := migrateProfilePrimaryTemplateName(db); err != nil {
return err
}
return migrateProfilesToSceneTemplates(db)
} }
func migrateProfilePrimaryTemplateName(db *sql.DB) error { func migrateProfilePrimaryTemplateName(db *sql.DB) error {
@ -185,3 +224,204 @@ func hasColumn(db *sql.DB, table string, column string) (bool, error) {
} }
return false, rows.Err() return false, rows.Err()
} }
func migrateProfilesToSceneTemplates(db *sql.DB) error {
if db == nil {
return nil
}
var count int
if err := db.QueryRow(`SELECT COUNT(1) FROM scene_templates`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
rows, err := db.Query(`
SELECT name, primary_template_name, business_name, description, body_json, created_at, updated_at
FROM profiles
ORDER BY created_at ASC, name ASC
`)
if err != nil {
return err
}
defer rows.Close()
type legacyProfile struct {
Name string
TemplateName string
BusinessName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
legacy := make([]legacyProfile, 0)
for rows.Next() {
var item legacyProfile
if err := rows.Scan(&item.Name, &item.TemplateName, &item.BusinessName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return err
}
legacy = append(legacy, item)
}
if err := rows.Err(); err != nil {
return err
}
if len(legacy) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
now := time.Now().Format(time.RFC3339)
for _, item := range legacy {
templateDoc, units, err := splitLegacyProfileDocument(item)
if err != nil {
return err
}
templateBody, err := json.MarshalIndent(templateDoc, "", " ")
if err != nil {
return err
}
createdAt := firstNonEmpty(item.CreatedAt, now)
updatedAt := firstNonEmpty(item.UpdatedAt, createdAt)
if _, err := tx.Exec(`
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO NOTHING
`, item.Name, item.TemplateName, item.BusinessName, item.Description, string(append(templateBody, '\n')), createdAt, updatedAt); err != nil {
return err
}
for _, unit := range units {
if _, err := tx.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(scene_template_name, name) DO NOTHING
`, item.Name, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, item.Description, unit.BodyJSON, createdAt, updatedAt); err != nil {
return err
}
}
}
return tx.Commit()
}
type migratedRecognitionUnit struct {
Name string
DisplayName string
SiteName string
VideoSourceRef string
OutputChannel string
RTSPPort string
BodyJSON string
}
func splitLegacyProfileDocument(item struct {
Name string
TemplateName string
BusinessName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}) (map[string]any, []migratedRecognitionUnit, error) {
raw := map[string]any{}
if strings.TrimSpace(item.BodyJSON) != "" {
if err := json.Unmarshal([]byte(item.BodyJSON), &raw); err != nil {
return nil, nil, err
}
}
if raw == nil {
raw = map[string]any{}
}
raw["name"] = item.Name
if strings.TrimSpace(item.TemplateName) != "" {
raw["primary_template_name"] = item.TemplateName
}
if strings.TrimSpace(item.BusinessName) != "" {
raw["business_name"] = item.BusinessName
}
if strings.TrimSpace(item.Description) != "" {
raw["description"] = item.Description
}
var units []migratedRecognitionUnit
if instances, ok := raw["instances"].([]any); ok {
for _, entry := range instances {
inst, _ := entry.(map[string]any)
if inst == nil {
continue
}
unitName := strings.TrimSpace(stringAny(inst["name"]))
if unitName == "" {
continue
}
sceneMeta, _ := inst["scene_meta"].(map[string]any)
inputBindings, _ := inst["input_bindings"].(map[string]any)
outputBindings, _ := inst["output_bindings"].(map[string]any)
videoSourceRef := strings.TrimSpace(bindingStringFromAny(inputBindings, "video_input_main", "video_source_ref"))
outputChannel := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "channel_no"))
if outputChannel == "" {
outputChannel = unitName
}
rtspPort := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "publish_rtsp_port"))
if rtspPort == "" {
rtspPort = "8555"
}
unitRaw := cloneAnyMap(inst)
body, err := json.MarshalIndent(unitRaw, "", " ")
if err != nil {
return nil, nil, err
}
units = append(units, migratedRecognitionUnit{
Name: unitName,
DisplayName: strings.TrimSpace(stringAny(sceneMeta["display_name"])),
SiteName: strings.TrimSpace(stringAny(sceneMeta["site_name"])),
VideoSourceRef: videoSourceRef,
OutputChannel: outputChannel,
RTSPPort: rtspPort,
BodyJSON: string(append(body, '\n')),
})
}
}
delete(raw, "instances")
return raw, units, nil
}
func bindingStringFromAny(bindings map[string]any, slot string, field string) string {
entry, _ := bindings[slot].(map[string]any)
return strings.TrimSpace(stringAny(entry[field]))
}
func stringAny(v any) string {
switch vv := v.(type) {
case string:
return vv
case float64:
return fmt.Sprintf("%v", vv)
default:
return ""
}
}
func cloneAnyMap(in map[string]any) map[string]any {
if len(in) == 0 {
return map[string]any{}
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,9 @@
--danger-soft:#242424; --danger-soft:#242424;
--danger-soft-hover:#303030; --danger-soft-hover:#303030;
--danger-soft-text:#dddddd; --danger-soft-text:#dddddd;
--assignment-low-bg:#24190d;
--assignment-busy-bg:#3a2812;
--assignment-full-bg:#4b3110;
--teal:#dddddd; --teal:#dddddd;
--green:#66c98f; --green:#66c98f;
--amber:#d8a657; --amber:#d8a657;
@ -61,6 +64,9 @@ body[data-theme="blue-light"]{
--danger-soft:#f0dada; --danger-soft:#f0dada;
--danger-soft-hover:#e8caca; --danger-soft-hover:#e8caca;
--danger-soft-text:#a43f3f; --danger-soft-text:#a43f3f;
--assignment-low-bg:#f4ead8;
--assignment-busy-bg:#f0dcc0;
--assignment-full-bg:#eccb96;
--teal:#2d6fb5; --teal:#2d6fb5;
--green:#245f9f; --green:#245f9f;
--amber:#9b650e; --amber:#9b650e;
@ -95,6 +101,9 @@ body[data-theme="graphite-gold"]{
--danger-soft:#301d1c; --danger-soft:#301d1c;
--danger-soft-hover:#3b2423; --danger-soft-hover:#3b2423;
--danger-soft-text:#d66a63; --danger-soft-text:#d66a63;
--assignment-low-bg:#332514;
--assignment-busy-bg:#3f3015;
--assignment-full-bg:#57401a;
--teal:#d3a84f; --teal:#d3a84f;
--green:#f0cf7a; --green:#f0cf7a;
--amber:#d79a3b; --amber:#d79a3b;
@ -196,7 +205,18 @@ th{background:var(--surface-soft);font-size:12px;font-weight:600;color:var(--mut
.table-wrap td a{color:var(--table-link)} .table-wrap td a{color:var(--table-link)}
.table-wrap td a:hover{color:var(--text)} .table-wrap td a:hover{color:var(--text)}
tbody tr:hover{background:var(--surface-soft)} tbody tr:hover{background:var(--surface-soft)}
tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary);outline-offset:-1px} tbody tr.selected{background:var(--selected-row)}
tbody tr.selected td{color:var(--text)}
tbody tr.selected .mono,
tbody tr.selected a,
tbody tr.selected strong{color:var(--text)}
.checkbox-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
.checkbox-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.checkbox-card input{margin:0 0 4px}
.checkbox-card-title{font-size:13px}
.checkbox-card-meta{font-size:12px;color:var(--muted)}
tbody tr[data-profile-row],
tbody tr[data-nav-row]{cursor:pointer}
.device-cell{display:flex;align-items:center;gap:12px;min-width:220px} .device-cell{display:flex;align-items:center;gap:12px;min-width:220px}
.device-avatar{width:32px;height:32px;border-radius:var(--radius);background:var(--surface-strong);color:var(--primary);display:grid;place-items:center} .device-avatar{width:32px;height:32px;border-radius:var(--radius);background:var(--surface-strong);color:var(--primary);display:grid;place-items:center}
@ -266,6 +286,20 @@ tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary
.info-list>div{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)} .info-list>div{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.info-list span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)} .info-list span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)}
.info-list strong{display:block;font-size:13px;font-weight:400;line-height:1.45} .info-list strong{display:block;font-size:13px;font-weight:400;line-height:1.45}
.detail-sheet{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.detail-item{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.detail-item.full{grid-column:1/-1}
.detail-item span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)}
.detail-item strong{display:block;font-size:13px;font-weight:400;line-height:1.45;color:var(--text)}
.form-state-hint{margin-top:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.editor-state.readonly .section-title{padding-bottom:8px;border-bottom:1px solid var(--border)}
.editor-state.editing{border-color:var(--border-strong);box-shadow:0 0 0 1px rgba(255,255,255,.03),var(--shadow)}
.editor-state.editing .section-title{padding-bottom:10px;border-bottom:1px solid var(--border-strong)}
.editor-state.editing .field-grid label>span{color:var(--text)}
.editor-state.editing .field-grid input,
.editor-state.editing .field-grid select,
.editor-state.editing .field-grid textarea{border-color:var(--border-strong);background:var(--surface)}
.editor-state.editing .profile-instance-grid{margin-top:16px}
.editable-line{display:flex;align-items:center;justify-content:space-between;gap:8px} .editable-line{display:flex;align-items:center;justify-content:space-between;gap:8px}
.editable-line strong{margin:0;flex:1 1 auto} .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{display:inline-flex;align-items:center;justify-content:center;padding:0;min-width:28px;width:28px;height:28px}
@ -329,6 +363,45 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
.select-cell{width:52px;text-align:center;vertical-align:middle} .select-cell{width:52px;text-align:center;vertical-align:middle}
.select-cell input[type=checkbox]{width:16px;height:16px;margin:0;accent-color:var(--primary)} .select-cell input[type=checkbox]{width:16px;height:16px;margin:0;accent-color:var(--primary)}
.assignment-board-page .section-title{margin-bottom:10px}
.assignment-kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}
.assignment-kpi{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.assignment-kpi span{display:block;font-size:12px;color:var(--muted)}
.assignment-kpi strong{display:block;margin-top:6px;font-size:18px;font-weight:400;color:var(--text)}
.assignment-action-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}
.assignment-slider{display:flex;align-items:center;gap:10px;min-width:260px}
.assignment-slider span{font-size:12px;color:var(--muted)}
.assignment-slider strong{font-size:12px;font-weight:500;color:var(--text)}
.assignment-slider input[type=range]{width:180px}
.assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px}
.assignment-device-card{padding:10px 11px;border:1px solid var(--border);border-radius:var(--radius);background:var(--assignment-low-bg)}
.assignment-device-card.state-idle{background:var(--surface-soft)}
.assignment-device-card.state-low{background:var(--assignment-low-bg)}
.assignment-device-card.state-busy{background:var(--assignment-busy-bg)}
.assignment-device-card.state-full{background:var(--assignment-full-bg)}
.assignment-device-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:8px}
.assignment-device-head h3{margin:0;font-size:13px;font-weight:500;color:var(--text)}
.assignment-device-head .mono{display:block;margin-top:4px;font-size:11px;color:var(--muted)}
.assignment-device-metrics{display:flex;align-items:center;gap:8px}
.assignment-device-metrics strong{font-size:12px;font-weight:500;color:var(--text)}
.assignment-chip-list{display:flex;flex-wrap:wrap;gap:6px;min-height:24px}
.assignment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
.assignment-chip-unassigned{background:var(--surface-strong)}
.assignment-chip-text{font-size:11px;color:var(--text)}
.assignment-chip-remove{border:0;background:transparent;color:var(--muted);padding:0;line-height:1;cursor:pointer}
.assignment-chip-remove:hover{color:var(--text)}
.assignment-device-add{display:flex;gap:6px;align-items:center;margin-top:10px}
.assignment-device-add select{flex:1 1 auto;padding:7px 8px;border:1px solid var(--border);border-radius:var(--radius);background:var(--input-bg);color:var(--text);font:inherit}
.assignment-unassigned{margin-top:16px;padding-top:4px}
@media (max-width:1400px){
.assignment-board-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:900px){
.assignment-board-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (max-width:1024px){ @media (max-width:1024px){
.app-shell{grid-template-columns:1fr} .app-shell{grid-template-columns:1fr}
.sidebar{position:relative;height:auto} .sidebar{position:relative;height:auto}
@ -338,4 +411,6 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
.stats,.detail-grid,.device-selector-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr} .stats,.detail-grid,.device-selector-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr}
.hero-band{flex-direction:column;align-items:flex-start} .hero-band{flex-direction:column;align-items:flex-start}
.batch-toolbar{flex-direction:column} .batch-toolbar{flex-direction:column}
.assignment-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}
.assignment-board-grid{grid-template-columns:1fr}
} }

View File

@ -5,6 +5,15 @@
<div> <div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数列表</span></h2> <h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数列表</span></h2>
</div> </div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/overlays?new=1">{{icon "apply"}}<span>新增调试参数</span></a>
{{if .AssetOverlay}}
<a class="btn secondary" href="/ui/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/delete">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -17,7 +26,7 @@
</thead> </thead>
<tbody> <tbody>
{{range .AssetOverlays}} {{range .AssetOverlays}}
<tr> <tr data-nav-row data-nav-href="/ui/assets/overlays?name={{.Name}}"{{if and $.AssetOverlay (eq $.AssetOverlay.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/ui/assets/overlays?name={{.Name}}">{{.Name}}</a></td> <td><a class="mono" href="/ui/assets/overlays?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td> <td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</td> <td>{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</td>
@ -31,10 +40,20 @@
</div> </div>
{{if .AssetOverlay}} {{if .AssetOverlay}}
<div class="card"> <form method="post" action="/ui/assets/overlays">
<div class="card editor-state {{if .AssetOverlayEditing}}editing{{else}}readonly{{end}}">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数详情</span></h2> <h2 class="title-with-icon">{{icon "overlay"}}<span>{{if .AssetOverlayEditing}}调试参数编辑{{else}}调试参数详情{{end}}{{if .AssetOverlay.Name}} · {{.AssetOverlay.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetOverlayEditing}}
<span class="pill run">编辑模式</span>
<span>当前调试参数正在编辑,保存后生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前调试参数为只读展示。</span>
{{end}}
</div>
</div> </div>
<div class="actions compact"> <div class="actions compact">
<button <button
@ -42,23 +61,39 @@
class="btn secondary js-export-json" class="btn secondary js-export-json"
data-export-url="/ui/assets/overlays/{{.AssetOverlay.Name}}/export" data-export-url="/ui/assets/overlays/{{.AssetOverlay.Name}}/export"
data-default-filename="{{.AssetOverlay.Name}}.json" data-default-filename="{{.AssetOverlay.Name}}.json"
>{{icon "apply"}}<span>另存为 JSON</span></button> >{{icon "apply"}}<span>导出为 JSON</span></button>
</div> </div>
</div> </div>
<div class="info-list"> {{if .AssetOverlayEditing}}
<div><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div> <div class="field-grid">
<div><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div> <label><span>调试参数名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetOverlay.Name}}" autofocus /></label>
<div class="full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div> <label><span>目标数量</span><input value="{{.AssetOverlay.OverrideTargetNum}}" readonly /></label>
<div class="full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div> <label class="full"><span>描述</span><input name="description" value="{{.AssetOverlay.Description}}" /></label>
<div class="full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div> <label class="full"><span>调试参数 JSON<span class="required-mark">*</span></span><textarea class="code-input" name="json">{{.OverlayDraftJSON}}</textarea></label>
</div> </div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/overlays{{if .AssetOverlay.Name}}?name={{.AssetOverlay.Name}}{{end}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
<div class="detail-item"><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
<div class="detail-item full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div>
</div>
{{end}}
</div> </div>
{{if not .AssetOverlayEditing}}
<details class="card collapsible"> <details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary> <summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetOverlay.Raw}}</pre> <pre>{{json .AssetOverlay.Raw}}</pre>
</details> </details>
{{end}} {{end}}
</form>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}} {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}} {{template "asset_tabs_end" .}}
{{end}} {{end}}

View File

@ -4,6 +4,15 @@
<div> <div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2> <h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2>
</div> </div>
<div class="actions compact">
<a class="btn secondary" href="/ui/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
{{if .SelectedProfile}}
<a class="btn secondary" href="/ui/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/ui/plans/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景配置吗?');">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -12,19 +21,17 @@
<th>场景配置</th> <th>场景配置</th>
<th>描述</th> <th>描述</th>
<th>视频通道</th> <th>视频通道</th>
<th>队列</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .AssetProfiles}} {{range .AssetProfiles}}
<tr> <tr data-profile-row data-profile-href="/ui/plans?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
<td><a class="mono" href="/ui/plans?name={{.Name}}">{{.Name}}</a></td> <td><a class="mono" href="/ui/plans?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td> <td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{len .Instances}}</td> <td>{{len .Instances}}</td>
<td class="mono">{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}</td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景配置</div></div></td></tr> <tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有场景配置</div></div></td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
@ -32,32 +39,62 @@
</div> </div>
{{if .AssetProfileEditor}} {{if .AssetProfileEditor}}
<form method="post" action="/ui/plans/{{.AssetProfileEditor.Name}}"> {{if .AssetProfileEditing}}
<form method="post" action="{{.AssetProfileFormAction}}">
<input type="hidden" id="active-instance-input" name="active_instance" value="{{.ActiveInstanceIndex}}" /> <input type="hidden" id="active-instance-input" name="active_instance" value="{{.ActiveInstanceIndex}}" />
<div class="card"> {{end}}
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置</span></h2> <h2 class="title-with-icon">{{icon "profile"}}<span>场景配置{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetProfileEditing}}
<span class="pill run">编辑模式</span>
<span>正在编辑场景配置</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
{{end}}
</div>
</div> </div>
<div class="actions compact"> <div class="actions compact">
{{if .AssetProfileEditing}}
<button type="submit">{{icon "apply"}}<span>保存场景配置</span></button> <button type="submit">{{icon "apply"}}<span>保存场景配置</span></button>
<a class="btn secondary" href="/ui/plans?name={{.AssetProfileEditor.Name}}">{{icon "close"}}<span>取消</span></a> <a class="btn secondary" href="/ui/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
<button <button
type="button" type="button"
class="btn secondary js-export-json" class="btn secondary js-export-json"
data-export-url="/ui/plans/{{.AssetProfileEditor.Name}}/export" data-export-url="/ui/plans/{{.AssetProfileEditor.Name}}/export"
data-default-filename="{{.AssetProfileEditor.Name}}.json" data-default-filename="{{.AssetProfileEditor.Name}}.json"
>{{icon "apply"}}<span>另存为 JSON</span></button> >{{icon "apply"}}<span>导出为 JSON</span></button>
</div> </div>
</div> </div>
{{if .AssetProfileEditing}}
<div class="field-grid"> <div class="field-grid">
<label><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label> <label><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if .AssetProfileEditing}}{{if not .SelectedProfile}}autofocus{{end}}{{else}}readonly{{end}} /></label>
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label> <label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label> <label>
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label> <span>调试参数</span>
<label><span>队列大小</span><input class="mono" name="queue_size" value="{{.AssetProfileEditor.Queue.Size}}" /></label> <select name="overlay_name" {{if not .AssetProfileEditing}}disabled{{end}}>
<label><span>队列策略</span><input name="queue_strategy" value="{{.AssetProfileEditor.Queue.Strategy}}" /></label> <option value="">不使用</option>
{{range .AssetOverlays}}
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.OverlayName .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
{{end}}
</select>
</label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
</div> </div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>场景名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>业务名称</span><strong>{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>调试参数</span><strong>{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}</strong></div>
<div class="detail-item"><span>站点名</span><strong>{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div> </div>
<div class="card"> <div class="card">
@ -67,7 +104,9 @@
</div> </div>
<div class="actions compact"> <div class="actions compact">
<span class="pill">{{len .AssetProfileEditor.Instances}} 路</span> <span class="pill">{{len .AssetProfileEditor.Instances}} 路</span>
{{if .AssetProfileEditing}}
<button class="btn secondary" type="submit" name="add_instance" value="1">{{icon "apply"}}<span>新增通道</span></button> <button class="btn secondary" type="submit" name="add_instance" value="1">{{icon "apply"}}<span>新增通道</span></button>
{{end}}
</div> </div>
</div> </div>
<table> <table>
@ -132,7 +171,9 @@
<td> <td>
<div class="actions compact"> <div class="actions compact">
<button type="button" class="btn ghost js-open-instance-editor" data-instance-index="{{$i}}" data-target="profile-instance-{{$i}}">编辑</button> <button type="button" class="btn ghost js-open-instance-editor" data-instance-index="{{$i}}" data-target="profile-instance-{{$i}}">编辑</button>
{{if $.AssetProfileEditing}}
<button class="btn secondary" type="submit" name="remove_instance" value="{{$i}}">删除</button> <button class="btn secondary" type="submit" name="remove_instance" value="{{$i}}">删除</button>
{{end}}
</div> </div>
</td> </td>
</tr> </tr>
@ -142,25 +183,34 @@
</div> </div>
{{if .AssetProfileEditor.Instances}} {{if .AssetProfileEditor.Instances}}
<div class="card"> <div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "device"}}<span>通道详情</span></h2> <h2 class="title-with-icon">{{icon "device"}}<span>通道详情{{if gt (len .AssetProfileEditor.Instances) 0}}{{with index .AssetProfileEditor.Instances .ActiveInstanceIndex}}{{if .Name}} · {{.Name}}{{end}}{{end}}{{end}}</span></h2>
<div class="form-hint">当前通道的修改会在点击“保存场景配置”后统一生效。</div> <div class="form-hint form-state-hint">
{{if .AssetProfileEditing}}
<span class="pill run">编辑模式</span>
<span>当前通道的修改会在点击“保存场景配置”后统一生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前通道详情为只读展示。</span>
{{end}}
</div>
</div> </div>
</div> </div>
{{range $i, $inst := .AssetProfileEditor.Instances}} {{range $i, $inst := .AssetProfileEditor.Instances}}
{{$template := index $.AssetTemplateMap $inst.Template}} {{$template := index $.AssetTemplateMap $inst.Template}}
<section id="profile-instance-{{$i}}" class="profile-instance-editor" data-instance-editor="{{$i}}" {{if ne $.ActiveInstanceIndex $i}}hidden{{end}}> <section id="profile-instance-{{$i}}" class="profile-instance-editor" data-instance-editor="{{$i}}" {{if ne $.ActiveInstanceIndex $i}}hidden{{end}}>
{{if $.AssetProfileEditing}}
<div class="field-grid profile-instance-grid"> <div class="field-grid profile-instance-grid">
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" /> <input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
<label><span>通道名<span class="required-mark">*</span></span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label> <label><span>通道名<span class="required-mark">*</span></span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" {{if not $.AssetProfileEditing}}readonly{{end}} /></label>
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label> <label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" {{if not $.AssetProfileEditing}}readonly{{end}} /></label>
<label><span>模板</span><input class="mono" value="{{$inst.Template}}" readonly /></label> <label><span>模板</span><input class="mono" value="{{$inst.Template}}" readonly /></label>
{{range $slot := $template.Slots.Inputs}} {{range $slot := $template.Slots.Inputs}}
<label> <label>
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span> <span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
<select name="instances[{{$i}}].input_bindings.{{$slot.Name}}.video_source_ref"> <select name="instances[{{$i}}].input_bindings.{{$slot.Name}}.video_source_ref" {{if not $.AssetProfileEditing}}disabled{{end}}>
<option value="">未选择</option> <option value="">未选择</option>
{{range $.AssetVideoSources}} {{range $.AssetVideoSources}}
<option value="{{.Name}}" {{if eq (inputBindingRef $inst.InputBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option> <option value="{{.Name}}" {{if eq (inputBindingRef $inst.InputBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option>
@ -171,7 +221,7 @@
{{range $slot := $template.Slots.Services}} {{range $slot := $template.Slots.Services}}
<label> <label>
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span> <span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
<select name="instances[{{$i}}].service_bindings.{{$slot.Name}}.service_ref"> <select name="instances[{{$i}}].service_bindings.{{$slot.Name}}.service_ref" {{if not $.AssetProfileEditing}}disabled{{end}}>
<option value="">未选择</option> <option value="">未选择</option>
{{range $.AssetIntegrations}}{{if eq .Type $slot.Type}} {{range $.AssetIntegrations}}{{if eq .Type $slot.Type}}
<option value="{{.Name}}" {{if eq (serviceBindingRef $inst.ServiceBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option> <option value="{{.Name}}" {{if eq (serviceBindingRef $inst.ServiceBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
@ -180,21 +230,44 @@
</label> </label>
{{end}} {{end}}
{{range $slot := $template.Slots.Outputs}} {{range $slot := $template.Slots.Outputs}}
<label><span>{{$slot.Description}} HLS 路径</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_hls_path" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_hls_path"}}" /></label> <label>
<label><span>{{$slot.Description}} RTSP 路径</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_path" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_path"}}" /></label> <span>{{$slot.Description}} RTSP 端口</span>
<label><span>{{$slot.Description}} RTSP 端口</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_port" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}" /></label> <input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_port" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}" {{if not $.AssetProfileEditing}}readonly{{end}} />
<label><span>{{$slot.Description}} 通道号</span><input name="instances[{{$i}}].output_bindings.{{$slot.Name}}.channel_no" value="{{outputBindingValue $inst.OutputBindings $slot.Name "channel_no"}}" /></label> </label>
{{end}} {{end}}
<details style="grid-column:1/-1;margin-top:0.25rem">
<summary style="cursor:pointer;font-size:0.875rem;font-weight:500;color:var(--text-secondary)">高级设置 JSON</summary>
<textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input" style="margin-top:0.5rem;width:100%;box-sizing:border-box">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea>
</details>
</div> </div>
{{else}}
<div class="detail-grid profile-instance-grid detail-sheet">
<div class="detail-item"><span>通道名</span><strong class="mono">{{$inst.Name}}</strong></div>
<div class="detail-item"><span>通道显示名</span><strong>{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>模板</span><strong class="mono">{{$inst.Template}}</strong></div>
{{range $slot := $template.Slots.Inputs}}
<div class="detail-item">
<span>{{$slot.Description}}</span>
<strong class="mono">{{if inputBindingRef $inst.InputBindings $slot.Name}}{{inputBindingRef $inst.InputBindings $slot.Name}}{{else}}-{{end}}</strong>
</div>
{{end}}
{{range $slot := $template.Slots.Services}}
<div class="detail-item">
<span>{{$slot.Description}}</span>
<strong>{{if serviceBindingRef $inst.ServiceBindings $slot.Name}}{{serviceBindingRef $inst.ServiceBindings $slot.Name}}{{else}}-{{end}}</strong>
</div>
{{end}}
{{range $slot := $template.Slots.Outputs}}
<div class="detail-item">
<span>{{$slot.Description}} RTSP 端口</span>
<strong class="mono">{{if outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{else}}-{{end}}</strong>
</div>
{{end}}
</div>
{{end}}
</section> </section>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
{{if .AssetProfileEditing}}
</form> </form>
{{end}}
<details class="card collapsible"> <details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary> <summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>

View File

@ -4,10 +4,21 @@
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "template"}}<span>识别模板列表</span></h2> <h2 class="title-with-icon">{{icon "template"}}<span>识别模板列表</span></h2>
<div class="form-hint">标准模板应作为基线,复制后再做定制化修改。空白模板仅用于高级场景。</div> <div class="form-hint">标准模板应作为基线,复制后再做定制化修改。</div>
</div> </div>
<div class="actions compact"> <div class="actions compact">
<a class="btn ghost" href="/ui/assets/templates?mode=blank">{{icon "add"}}<span>新建空白模板</span></a> {{if .AssetTemplate}}
{{if .AssetTemplate.ReadOnly}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/clone">
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准模板</span></button>
</form>
{{else}}
<a class="btn secondary" href="/ui/assets/templates?name={{.AssetTemplate.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
<button type="submit" class="btn secondary">删除</button>
</form>
{{end}}
{{end}}
</div> </div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
@ -22,7 +33,7 @@
</thead> </thead>
<tbody> <tbody>
{{range .AssetTemplates}} {{range .AssetTemplates}}
<tr> <tr data-nav-row data-nav-href="/ui/assets/templates?name={{.Name}}"{{if and $.AssetTemplate (eq $.AssetTemplate.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/ui/assets/templates?name={{.Name}}">{{.Name}}</a></td> <td><a class="mono" href="/ui/assets/templates?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td> <td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{.NodeCount}}节点 / {{.EdgeCount}}连线</td> <td>{{.NodeCount}}节点 / {{.EdgeCount}}连线</td>
@ -36,70 +47,57 @@
</div> </div>
</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}} {{if .AssetTemplate}}
<div class="card"> <div class="card editor-state {{if .AssetTemplateEditing}}editing{{else}}readonly{{end}}">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板详情</span></h2> <h2 class="title-with-icon">{{icon "template"}}<span>{{if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetTemplateEditing}}
<span class="pill run">编辑模式</span>
<span>当前模板基础信息正在编辑,结构修改请进入可视化编辑。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前模板为只读详情展示。</span>
{{end}}
</div>
</div> </div>
<div class="actions compact"> <div class="actions compact">
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else}}可视化编辑{{end}}</span></a> <a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">{{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 <button
type="button" type="button"
class="btn secondary js-export-json" class="btn secondary js-export-json"
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export" data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
data-default-filename="{{.AssetTemplate.Name}}.json" data-default-filename="{{.AssetTemplate.Name}}.json"
>{{icon "apply"}}<span>另存为 JSON</span></button> >{{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> </div>
{{if .AssetTemplateEditing}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename">
<div class="field-grid">
<label><span>模板名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetTemplate.Name}}" class="mono" autofocus /></label>
<label><span>模板类型</span><input value="{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}" readonly /></label>
<label class="full"><span>模板描述</span><input name="description" value="{{.AssetTemplate.Description}}" /></label>
</div>
<div class="detail-sheet">
<div class="detail-item"><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
<div class="detail-item"><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
<div class="detail-item"><span>输入槽位</span><strong>{{if .AssetTemplate.Slots.Inputs}}{{len .AssetTemplate.Slots.Inputs}}{{else}}0{{end}}</strong></div>
<div class="detail-item"><span>服务槽位</span><strong>{{if .AssetTemplate.Slots.Services}}{{len .AssetTemplate.Slots.Services}}{{else}}0{{end}}</strong></div>
<div class="detail-item"><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/templates?name={{.AssetTemplate.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}
<div class="info-list"> <div class="info-list">
<div> <div>
<span>模板名</span> <span>模板名</span>
{{if .AssetTemplate.ReadOnly}}
<strong class="mono">{{.AssetTemplate.Name}}</strong> <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>
<div><span>模板类型</span><strong>{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}</strong></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 class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
@ -110,23 +108,11 @@
<div><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div> <div><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
<div class="full"> <div class="full">
<span>描述</span> <span>描述</span>
{{if .AssetTemplate.ReadOnly}}
<strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong> <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>
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div> <div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div> </div>
{{end}}
</div> </div>
{{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}} {{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}}

View File

@ -61,7 +61,7 @@
</thead> </thead>
<tbody> <tbody>
{{range .AssetIntegrations}} {{range .AssetIntegrations}}
<tr> <tr data-nav-row data-nav-href="/ui/assets/integrations?name={{.Name}}"{{if and $.AssetIntegration (eq $.AssetIntegration.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/ui/assets/integrations?name={{.Name}}">{{.Name}}</a></td> <td><a class="mono" href="/ui/assets/integrations?name={{.Name}}">{{.Name}}</a></td>
<td>{{.TypeLabel}}</td> <td>{{.TypeLabel}}</td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td> <td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
@ -78,50 +78,77 @@
</div> </div>
{{if .AssetIntegration}} {{if .AssetIntegration}}
<form method="post" action="/ui/assets/integrations"> <form method="post" action="/ui/assets/integrations">
<div class="card"> <div class="card editor-state {{if .AssetIntegrationEditing}}editing{{else}}readonly{{end}}">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "service"}}<span>{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}</span></h2> <h2 class="title-with-icon">{{icon "service"}}<span>{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}{{if .AssetIntegration.Name}} · {{.AssetIntegration.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetIntegrationEditing}}
<span class="pill run">编辑模式</span>
<span>当前第三方服务正在编辑,保存后生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前第三方服务为只读展示。</span>
{{end}}
</div>
</div> </div>
</div> </div>
{{if .AssetIntegrationEditing}}
<div class="field-grid"> <div class="field-grid">
<label><span>服务名称</span><input name="name" value="{{.AssetIntegration.Name}}" {{if .AssetIntegrationEditing}}autofocus{{else}}readonly{{end}} /></label> <label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" autofocus /></label>
<label> <label>
<span>服务类型</span> <span>服务类型<span class="required-mark">*</span></span>
<select name="type" {{if not .AssetIntegrationEditing}}disabled{{end}}> <select name="type">
<option value="object_storage" {{if eq .AssetIntegration.Type "object_storage"}}selected{{end}}>对象存储</option> <option value="object_storage" {{if eq .AssetIntegration.Type "object_storage"}}selected{{end}}>对象存储</option>
<option value="token_service" {{if eq .AssetIntegration.Type "token_service"}}selected{{end}}>认证服务</option> <option value="token_service" {{if eq .AssetIntegration.Type "token_service"}}selected{{end}}>认证服务</option>
<option value="alarm_service" {{if eq .AssetIntegration.Type "alarm_service"}}selected{{end}}>告警服务</option> <option value="alarm_service" {{if eq .AssetIntegration.Type "alarm_service"}}selected{{end}}>告警服务</option>
</select> </select>
</label> </label>
<label><span>描述</span><input name="description" value="{{.AssetIntegration.Description}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>描述</span><input name="description" value="{{.AssetIntegration.Description}}" /></label>
<label><span>启用</span><select name="enabled" {{if not .AssetIntegrationEditing}}disabled{{end}}><option value="1" {{if .AssetIntegration.Enabled}}selected{{end}}>启用</option><option value="0" {{if not .AssetIntegration.Enabled}}selected{{end}}>停用</option></select></label> <label><span>启用</span><select name="enabled"><option value="1" {{if .AssetIntegration.Enabled}}selected{{end}}>启用</option><option value="0" {{if not .AssetIntegration.Enabled}}selected{{end}}>停用</option></select></label>
</div> </div>
<div class="field-grid"> <div class="field-grid">
<label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" /></label>
<label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" /></label>
<label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" /></label>
<label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" /></label>
</div> </div>
<div class="field-grid"> <div class="field-grid">
<label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.GetTokenURL}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.GetTokenURL}}{{end}}" /></label>
<label><span>认证用户名</span><input name="username" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Username}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>认证用户名</span><input name="username" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Username}}{{end}}" /></label>
<label><span>认证密码</span><input name="password" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Password}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>认证密码</span><input name="password" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Password}}{{end}}" /></label>
<label><span>认证租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.TenantCode}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>认证租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.TenantCode}}{{end}}" /></label>
</div> </div>
<div class="field-grid"> <div class="field-grid">
<label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" /></label>
<label><span>告警用户名</span><input name="alarm_username" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Username}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>告警用户名</span><input name="alarm_username" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Username}}{{end}}" /></label>
<label><span>告警密码</span><input name="alarm_password" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Password}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>告警密码</span><input name="alarm_password" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Password}}{{end}}" /></label>
<label><span>告警租户编码</span><input class="mono" name="alarm_tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label> <label><span>告警租户编码</span><input class="mono" name="alarm_tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" /></label>
</div> </div>
{{if .AssetIntegrationEditing}}
<div class="actions"> <div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button> <button type="submit">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>服务名称</span><strong class="mono">{{.AssetIntegration.Name}}</strong></div>
<div class="detail-item"><span>服务类型</span><strong>{{.AssetIntegration.TypeLabel}}</strong></div>
<div class="detail-item"><span>描述</span><strong>{{if .AssetIntegration.Description}}{{.AssetIntegration.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>启用状态</span><strong>{{if .AssetIntegration.Enabled}}启用{{else}}停用{{end}}</strong></div>
<div class="detail-item"><span>对象存储地址</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Endpoint}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Bucket</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Bucket}}{{.AssetIntegration.ObjectStorage.Bucket}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Access Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.AccessKey}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Secret Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.SecretKey}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Token 获取地址</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.GetTokenURL}}{{.AssetIntegration.TokenService.GetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>认证用户名</span><strong>{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.Username}}{{.AssetIntegration.TokenService.Username}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>认证租户编码</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.TenantCode}}{{.AssetIntegration.TokenService.TenantCode}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>消息上报地址</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.PutMessageURL}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>告警用户名</span><strong>{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.Username}}{{.AssetIntegration.AlarmService.Username}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>告警租户编码</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.TenantCode}}{{.AssetIntegration.AlarmService.TenantCode}}{{else}}-{{end}}</strong></div>
</div> </div>
{{end}} {{end}}
</div> </div>
@ -158,7 +185,7 @@
</thead> </thead>
<tbody> <tbody>
{{range .AssetVideoSources}} {{range .AssetVideoSources}}
<tr> <tr data-nav-row data-nav-href="/ui/assets/video-sources?name={{.Name}}"{{if and $.AssetVideoSource (eq $.AssetVideoSource.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/ui/assets/video-sources?name={{.Name}}">{{.Name}}</a></td> <td><a class="mono" href="/ui/assets/video-sources?name={{.Name}}">{{.Name}}</a></td>
<td>{{.SourceTypeLabel}}</td> <td>{{.SourceTypeLabel}}</td>
<td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td> <td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td>
@ -176,41 +203,65 @@
</div> </div>
{{if .AssetVideoSource}} {{if .AssetVideoSource}}
<form method="post" action="/ui/assets/video-sources"> <form method="post" action="/ui/assets/video-sources">
<div class="card"> <div class="card editor-state {{if .AssetVideoSourceEditing}}editing{{else}}readonly{{end}}">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "device"}}<span>{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}</span></h2> <h2 class="title-with-icon">{{icon "device"}}<span>{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}{{if .AssetVideoSource.Name}} · {{.AssetVideoSource.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetVideoSourceEditing}}
<span class="pill run">编辑模式</span>
<span>当前视频源正在编辑,保存后生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前视频源为只读展示。</span>
{{end}}
</div>
</div> </div>
</div> </div>
{{if .AssetVideoSourceEditing}}
<div class="field-grid"> <div class="field-grid">
<label><span>视频源名称</span><input name="name" value="{{.AssetVideoSource.Name}}" {{if .AssetVideoSourceEditing}}autofocus{{else}}readonly{{end}} /></label> <label><span>视频源名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetVideoSource.Name}}" autofocus /></label>
<label> <label>
<span>类型</span> <span>类型<span class="required-mark">*</span></span>
<select name="source_type" {{if not .AssetVideoSourceEditing}}disabled{{end}}> <select name="source_type">
<option value="rtsp" {{if eq .AssetVideoSource.SourceType "rtsp"}}selected{{end}}>RTSP</option> <option value="rtsp" {{if eq .AssetVideoSource.SourceType "rtsp"}}selected{{end}}>RTSP</option>
<option value="rtmp" {{if eq .AssetVideoSource.SourceType "rtmp"}}selected{{end}}>RTMP</option> <option value="rtmp" {{if eq .AssetVideoSource.SourceType "rtmp"}}selected{{end}}>RTMP</option>
<option value="file" {{if eq .AssetVideoSource.SourceType "file"}}selected{{end}}>文件</option> <option value="file" {{if eq .AssetVideoSource.SourceType "file"}}selected{{end}}>文件</option>
<option value="usb_camera" {{if eq .AssetVideoSource.SourceType "usb_camera"}}selected{{end}}>USB 摄像头</option> <option value="usb_camera" {{if eq .AssetVideoSource.SourceType "usb_camera"}}selected{{end}}>USB 摄像头</option>
</select> </select>
</label> </label>
<label><span>区域</span><input name="area" value="{{.AssetVideoSource.Area}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>区域</span><input name="area" value="{{.AssetVideoSource.Area}}" /></label>
<label><span>描述</span><input name="description" value="{{.AssetVideoSource.Description}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>描述</span><input name="description" value="{{.AssetVideoSource.Description}}" /></label>
</div> </div>
<div class="field-grid"> <div class="field-grid">
<label class="full"><span>输入地址</span><input class="mono" name="url" value="{{.AssetVideoSource.Config.URL}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label class="full"><span>输入地址<span class="required-mark">*</span></span><input class="mono" name="url" value="{{.AssetVideoSource.Config.URL}}" /></label>
<label><span>标准分辨率</span><input name="resolution" value="{{.AssetVideoSource.Config.Resolution}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>标准分辨率</span><input name="resolution" value="{{.AssetVideoSource.Config.Resolution}}" /></label>
<label><span>像素尺寸</span><input class="mono" name="frame_size" value="{{.AssetVideoSource.Config.FrameSize}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>像素尺寸</span><input class="mono" name="frame_size" value="{{.AssetVideoSource.Config.FrameSize}}" /></label>
<label><span>帧率</span><input class="mono" name="fps" value="{{.AssetVideoSource.Config.FPS}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>帧率</span><input class="mono" name="fps" value="{{.AssetVideoSource.Config.FPS}}" /></label>
<label><span>视频格式</span><input name="video_format" value="{{.AssetVideoSource.Config.VideoFormat}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>视频格式</span><input name="video_format" value="{{.AssetVideoSource.Config.VideoFormat}}" /></label>
<label><span>焦距</span><input name="focal_length" value="{{.AssetVideoSource.Config.FocalLength}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>焦距</span><input name="focal_length" value="{{.AssetVideoSource.Config.FocalLength}}" /></label>
<label><span>安装高度</span><input name="mount_height" value="{{.AssetVideoSource.Config.MountHeight}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>安装高度</span><input name="mount_height" value="{{.AssetVideoSource.Config.MountHeight}}" /></label>
<label><span>安装角度</span><input name="mount_angle" value="{{.AssetVideoSource.Config.MountAngle}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label> <label><span>安装角度</span><input name="mount_angle" value="{{.AssetVideoSource.Config.MountAngle}}" /></label>
</div> </div>
{{if .AssetVideoSourceEditing}}
<div class="actions"> <div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button> <button type="submit">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a> <a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
</div> </div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>视频源名称</span><strong class="mono">{{.AssetVideoSource.Name}}</strong></div>
<div class="detail-item"><span>类型</span><strong>{{.AssetVideoSource.SourceTypeLabel}}</strong></div>
<div class="detail-item"><span>区域</span><strong>{{if .AssetVideoSource.Area}}{{.AssetVideoSource.Area}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>描述</span><strong>{{if .AssetVideoSource.Description}}{{.AssetVideoSource.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>输入地址</span><strong class="mono">{{if .AssetVideoSource.Config.URL}}{{.AssetVideoSource.Config.URL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>标准分辨率</span><strong>{{if .AssetVideoSource.Config.Resolution}}{{.AssetVideoSource.Config.Resolution}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>像素尺寸</span><strong class="mono">{{if .AssetVideoSource.Config.FrameSize}}{{.AssetVideoSource.Config.FrameSize}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>帧率</span><strong class="mono">{{if .AssetVideoSource.Config.FPS}}{{.AssetVideoSource.Config.FPS}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>视频格式</span><strong>{{if .AssetVideoSource.Config.VideoFormat}}{{.AssetVideoSource.Config.VideoFormat}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>焦距</span><strong>{{if .AssetVideoSource.Config.FocalLength}}{{.AssetVideoSource.Config.FocalLength}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>安装高度</span><strong>{{if .AssetVideoSource.Config.MountHeight}}{{.AssetVideoSource.Config.MountHeight}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>安装角度</span><strong>{{if .AssetVideoSource.Config.MountAngle}}{{.AssetVideoSource.Config.MountAngle}}{{else}}-{{end}}</strong></div>
</div>
{{end}} {{end}}
</div> </div>
</form> </form>

View File

@ -4,49 +4,19 @@
<div class="card"> <div class="card">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "preview"}}<span>预览</span></h2> <h2 class="title-with-icon">{{icon "preview"}}<span>设备分配预览</span></h2>
</div> </div>
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
</div> </div>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview"> <form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview">
<div class="field-grid"> <div class="info-list">
<label><span>模板</span> <div><span>设备</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
<select name="template"> <div><span>场景模板</span><strong class="mono">{{if .DeviceAssignment}}{{.DeviceAssignment.ProfileName}}{{else}}-{{end}}</strong></div>
{{range .ConfigSources.Templates}} <div><span>识别单元</span><strong>{{if .DeviceAssignment}}{{.DeviceAssignment.RecognitionCount}} 路{{else}}0 路{{end}}</strong></div>
<option value="{{.Name}}" {{if eq .Name $.SelectedTemplate}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>业务配置</span>
<select name="profile">
{{range .ConfigSources.Profiles}}
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" /></label>
<label><span>config_version</span><input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" /></label>
<div class="full">
<span class="muted small">调试参数</span>
<div class="actions" style="margin-top:6px">
{{range .ConfigSources.Overlays}}
<label class="btn ghost">
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
{{.Name}}
</label>
{{end}}
</div>
</div>
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit">生成预览</button> <button type="submit">生成预览</button>
{{if .ConfigPreview}}
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate">上传为候选配置</button>
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">应用候选配置</button>
<input type="hidden" name="json" value="{{.ConfigPreview.JSON}}" />
{{end}}
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a> <a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
</div> </div>
</form> </form>
@ -64,7 +34,7 @@
<div><span>版本</span><strong class="mono">{{index .ConfigPreview.Metadata "config_version"}}</strong></div> <div><span>版本</span><strong class="mono">{{index .ConfigPreview.Metadata "config_version"}}</strong></div>
<div><span>业务名称</span><strong>{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}</strong></div> <div><span>业务名称</span><strong>{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}</strong></div>
<div><span>模板</span><strong>{{index .ConfigPreview.Metadata "template"}}</strong></div> <div><span>模板</span><strong>{{index .ConfigPreview.Metadata "template"}}</strong></div>
<div><span>业务配置</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div> <div><span>场景模板</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div> <div><span>调试参数</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div> <div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div> <div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
@ -77,37 +47,6 @@
</details> </details>
{{end}} {{end}}
{{if and (eq .ResultTitle "应用候选配置结果") .ConfigStatus}}
<div class="card">
<h2>应用结果摘要</h2>
<div class="row" style="margin-top:10px">
<div>
<div class="muted small">当前运行</div>
<div class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}} / {{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}{{else}}未标记{{end}}</div>
<div class="muted small mono" style="margin-top:6px">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">sha: {{shortHash .ConfigStatus.Sha256}}</div>
</div>
<div>
<div class="muted small">上一份配置</div>
<div class="mono">{{if and .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Exists .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}} / {{if .ConfigStatus.PreviousConfig.Metadata.ConfigVersion}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigVersion}}{{else}}未标记{{end}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">{{if and .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">sha: {{if .ConfigStatus.PreviousConfig}}{{shortHash .ConfigStatus.PreviousConfig.Sha256}}{{end}}</div>
</div>
<div>
<div class="muted small">候选配置</div>
<div>{{if and .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}<span class="pill">仍存在</span>{{else}}<span class="pill ok">已清空</span>{{end}}</div>
</div>
<div>
<div class="muted small">视觉服务</div>
<div>{{if .ConfigStatus.MediaServer.Running}}<span class="pill ok">运行中</span>{{else}}<span class="pill bad">未运行</span>{{end}}</div>
</div>
</div>
{{if and .ConfigStatus.PreviousConfig .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256 (eq .ConfigStatus.Metadata.ConfigID .ConfigStatus.PreviousConfig.Metadata.ConfigID) (eq .ConfigStatus.Metadata.ConfigVersion .ConfigStatus.PreviousConfig.Metadata.ConfigVersion) (ne .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256)}}
<div class="muted small" style="margin-top:10px">当前运行与上一份配置回滚点的 <span class="mono">config_id/config_version</span> 相同,但文件内容不同,请以调试参数和 <span class="mono">sha</span> 为准。</div>
{{end}}
</div>
{{end}}
{{if .RawText}} {{if .RawText}}
<div class="card"> <div class="card">
<h2>{{if .ResultTitle}}{{.ResultTitle}}{{else}}执行结果{{end}}</h2> <h2>{{if .ResultTitle}}{{.ResultTitle}}{{else}}执行结果{{end}}</h2>

View File

@ -49,7 +49,7 @@
<tr> <tr>
<td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td> <td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td>
<td> <td>
{{if eq .Type "config_apply"}}下发识别配置 {{if eq .Type "config_apply"}}下发设备分配
{{else if eq .Type "reload"}}重载识别服务 {{else if eq .Type "reload"}}重载识别服务
{{else if eq .Type "rollback"}}回滚识别配置 {{else if eq .Type "rollback"}}回滚识别配置
{{else if eq .Type "media_start"}}启动视频分析服务 {{else if eq .Type "media_start"}}启动视频分析服务

View File

@ -5,11 +5,11 @@
<div class="section-title"> <div class="section-title">
<div> <div>
<h2>设备工作台</h2> <h2>设备工作台</h2>
<div class="muted small">单设备查看、服务控制、配应用、模型资源和日志指标都收敛在这里完成。</div> <div class="muted small">单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。</div>
</div> </div>
<div class="actions"> <div class="actions">
<a class="btn ghost" href="/ui/devices">返回设备列表</a> <a class="btn ghost" href="/ui/devices">返回设备列表</a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览场景配置</span></a> <a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览设备分配</span></a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a> <a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
</div> </div>
</div> </div>
@ -33,7 +33,7 @@
<div class="actions compact"> <div class="actions compact">
<a class="btn ghost" href="#device-overview">概览</a> <a class="btn ghost" href="#device-overview">概览</a>
<a class="btn ghost" href="#device-runtime">运行与服务</a> <a class="btn ghost" href="#device-runtime">运行与服务</a>
<a class="btn ghost" href="#device-config">场景配置</a> <a class="btn ghost" href="#device-config">设备分配</a>
<a class="btn ghost" href="#device-models">模型与资源</a> <a class="btn ghost" href="#device-models">模型与资源</a>
<a class="btn ghost" href="#device-observability">日志与指标</a> <a class="btn ghost" href="#device-observability">日志与指标</a>
</div> </div>
@ -54,7 +54,7 @@
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div> <div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
<div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div> <div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div>
<div><span>版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div> <div><span>版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
<div><span>当前业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div> <div><span>当前场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>通道名</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}</strong></div> <div><span>通道名</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}</strong></div>
</div> </div>
</div> </div>
@ -71,7 +71,7 @@
<div><span>配置 ID</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</strong></div> <div><span>配置 ID</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</strong></div>
<div><span>配置版本</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</strong></div> <div><span>配置版本</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
<div><span>模板</span><strong>{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div> <div><span>模板</span><strong>{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
<div><span>业务配置</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div> <div><span>场景模板</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div> <div><span>调试参数</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
<div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div> <div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div>
<div><span>配置 SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div> <div><span>配置 SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div>
@ -82,7 +82,7 @@
<div><span>配置 ID</span><strong class="mono">{{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}</strong></div> <div><span>配置 ID</span><strong class="mono">{{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}</strong></div>
<div><span>配置版本</span><strong class="mono">{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}</strong></div> <div><span>配置版本</span><strong class="mono">{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
<div><span>模板</span><strong>{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}</strong></div> <div><span>模板</span><strong>{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}</strong></div>
<div><span>业务配置</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div> <div><span>场景模板</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div> <div><span>调试参数</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
<div><span>最近下发任务</span><strong class="mono">{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}</strong></div> <div><span>最近下发任务</span><strong class="mono">{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}</strong></div>
</div> </div>
@ -129,34 +129,29 @@
<section class="panel-block" id="device-config"> <section class="panel-block" id="device-config">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h3 class="title-with-icon">{{icon "apply"}}<span>场景配置</span></h3> <h3 class="title-with-icon">{{icon "apply"}}<span>设备分配</span></h3>
</div> </div>
</div> </div>
<form id="device-scene-apply-form" method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply" class="scene-config-form"> {{if .DeviceAssignment}}
<div class="field-grid">
<label class="full"><span>选择场景配置</span>
<select name="profile">
{{range .AssetProfiles}}
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
{{end}}
</select>
</label>
</div>
{{if .AssetProfile}}
<details class="scene-summary-details"> <details class="scene-summary-details">
<summary>查看所选场景配置详情</summary> <summary>查看当前设备分配详情</summary>
<div class="info-list compact-list"> <div class="info-list compact-list">
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div> <div><span>场景模板</span><strong class="mono">{{.DeviceAssignment.ProfileName}}</strong></div>
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div> <div><span>识别单元</span><strong>{{.DeviceAssignment.RecognitionCount}} 路</strong></div>
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div> <div><span>说明</span><strong>{{if .DeviceAssignment.Description}}{{.DeviceAssignment.Description}}{{else}}-{{end}}</strong></div>
<div><span>说明</span><strong>{{if .AssetProfile.Description}}{{.AssetProfile.Description}}{{else}}-{{end}}</strong></div>
</div> </div>
</details> </details>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有设备分配</div>
<div class="muted">请先到“设备分配”页面为这台设备指定识别单元。</div>
</div>
{{end}} {{end}}
</form>
<div class="actions scene-actions-row"> <div class="actions scene-actions-row">
<button type="submit" form="device-scene-apply-form" class="primary">下发场景配置</button> <form method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply">
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览场景配置</span></a> <button type="submit" class="primary">下发当前设备分配</button>
</form>
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action"> <form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" /> <input type="hidden" name="action" value="rollback" />
<input type="hidden" name="return_to" value="config" /> <input type="hidden" name="return_to" value="config" />
@ -174,7 +169,7 @@
</div> </div>
<div class="info-list compact-list"> <div class="info-list compact-list">
<div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div> <div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div>
<div><span>人脸库</span><strong>通过场景配置与基础配置维护</strong></div> <div><span>人脸库</span><strong>通过资源管理与基础配置维护</strong></div>
</div> </div>
<div class="actions"> <div class="actions">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload"> <form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload">

View File

@ -0,0 +1,372 @@
{{define "device_assignments"}}
<div class="card assignment-board-page">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>设备分配</span></h2>
</div>
</div>
{{if .DeviceAssignmentBoard}}
<div class="assignment-kpis">
<div class="assignment-kpi">
<span>识别单元</span>
<strong>{{.DeviceAssignmentBoard.Stats.TotalUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>设备</span>
<strong>{{.DeviceAssignmentBoard.Stats.TotalDevices}}</strong>
</div>
<div class="assignment-kpi">
<span>已分配</span>
<strong>{{.DeviceAssignmentBoard.Stats.AssignedUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>未分配</span>
<strong>{{.DeviceAssignmentBoard.Stats.UnassignedUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>平均负载</span>
<strong>{{printf "%.1f" .DeviceAssignmentBoard.Stats.AverageLoad}}</strong>
</div>
<div class="assignment-kpi">
<span>满载设备</span>
<strong>{{.DeviceAssignmentBoard.Stats.OverloadedDevices}}</strong>
</div>
</div>
<form method="post" action="/ui/device-assignments" id="assignment-board-form">
<input type="hidden" name="board_state_json" id="assignment-board-state" value="" />
<div class="assignment-action-bar">
<label class="assignment-slider">
<span>每台最多</span>
<input type="range" min="1" max="8" step="1" value="{{.MaxUnitsPerDevice}}" id="max-units-range" name="max_units_per_device" />
<strong id="max-units-value">{{.MaxUnitsPerDevice}} 路/台</strong>
</label>
<div class="actions compact">
<button type="button" class="btn secondary" id="auto-assign-btn">自动平均分配</button>
<button type="button" class="btn secondary" id="clear-assign-btn">清空分配</button>
<button type="submit">保存设备分配</button>
</div>
</div>
<div id="assignment-feedback" class="form-hint"></div>
<div class="assignment-board-grid" id="assignment-cards">
{{range .DeviceAssignmentBoard.Cards}}
<section class="assignment-device-card state-{{.Status}}" data-device-card="{{.DeviceID}}">
<div class="assignment-device-head">
<div>
<h3>{{.DeviceName}}</h3>
<span class="mono">{{.DeviceID}}</span>
</div>
<div class="assignment-device-metrics">
<strong>{{.AssignedCount}} / {{.MaxUnits}}</strong>
<span class="pill">{{.Status}}</span>
</div>
</div>
<div class="assignment-chip-list" data-assigned-list="{{.DeviceID}}">
{{range .Units}}
<span class="assignment-chip" data-unit-ref="{{.Ref}}">
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
<button type="button" class="assignment-chip-remove" data-remove-unit="{{.Ref}}">×</button>
</span>
{{end}}
</div>
{{if lt .AssignedCount .MaxUnits}}
<div class="assignment-device-add">
<select data-add-select="{{.DeviceID}}">
<option value="">添加识别单元</option>
</select>
<button type="button" class="btn secondary" data-add-button="{{.DeviceID}}">加入</button>
</div>
{{end}}
</section>
{{end}}
</div>
<section class="assignment-unassigned">
<div class="section-title compact">
<div>
<h3>未分配识别单元</h3>
</div>
</div>
<div class="assignment-chip-list" id="assignment-unassigned-list">
{{range .DeviceAssignmentBoard.Unassigned}}
<span class="assignment-chip assignment-chip-unassigned" data-unit-ref="{{.Ref}}">
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
</span>
{{else}}
<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>
{{end}}
</div>
</section>
</form>
<script type="application/json" id="assignment-board-seed">{{.DeviceAssignmentBoardJSON}}</script>
<script>
(() => {
const seedEl = document.getElementById('assignment-board-seed');
if (!seedEl) return;
const seed = JSON.parse(seedEl.textContent || '{}');
const state = {
max: Number(seed.max_units_per_device || {{.MaxUnitsPerDevice}}),
units: {},
devices: {},
meta: {}
};
(seed.cards || []).forEach(card => {
state.meta[card.device_id] = {
device_id: card.device_id,
device_name: card.device_name,
max_units: card.max_units || state.max
};
state.devices[card.device_id] = (card.units || []).map(unit => {
state.units[unit.ref] = unit;
return unit.ref;
});
});
(seed.unassigned || []).forEach(unit => {
state.units[unit.ref] = unit;
});
const maxRange = document.getElementById('max-units-range');
const maxValue = document.getElementById('max-units-value');
const feedback = document.getElementById('assignment-feedback');
const hiddenState = document.getElementById('assignment-board-state');
const cardsContainer = document.getElementById('assignment-cards');
const unassignedContainer = document.getElementById('assignment-unassigned-list');
const form = document.getElementById('assignment-board-form');
function unitLabel(unit) {
return unit.display_name || unit.name;
}
function statusFor(count) {
if (count <= 0) return 'idle';
if (count >= state.max) return 'full';
if (count * 2 >= state.max) return 'busy';
return 'low';
}
function statusRank(status) {
return { full: 0, busy: 1, low: 2, idle: 3 }[status] ?? 4;
}
function allRefs() {
return Object.keys(state.units).sort((a, b) => {
const ua = state.units[a];
const ub = state.units[b];
if ((ua.scene_template_name || '') !== (ub.scene_template_name || '')) {
return (ua.scene_template_name || '').localeCompare(ub.scene_template_name || '');
}
return (ua.name || '').localeCompare(ub.name || '');
});
}
function assignedRefSet() {
const seen = new Set();
Object.values(state.devices).forEach(refs => refs.forEach(ref => seen.add(ref)));
return seen;
}
function unassignedRefs() {
const assigned = assignedRefSet();
return allRefs().filter(ref => !assigned.has(ref));
}
function currentProfile(deviceID) {
const refs = state.devices[deviceID] || [];
if (!refs.length) return '';
const unit = state.units[refs[0]];
return unit ? (unit.scene_template_name || '') : '';
}
function moveToUnassigned(ref) {
Object.keys(state.devices).forEach(deviceID => {
state.devices[deviceID] = (state.devices[deviceID] || []).filter(item => item !== ref);
});
}
function addToDevice(deviceID, ref) {
if (!deviceID || !ref) return;
moveToUnassigned(ref);
if (!state.devices[deviceID]) state.devices[deviceID] = [];
state.devices[deviceID].push(ref);
}
function clearAssignments() {
Object.keys(state.devices).forEach(deviceID => {
state.devices[deviceID] = [];
});
}
function autoAssign() {
clearAssignments();
const deviceIDs = Object.keys(state.devices).sort();
const units = allRefs().map(ref => state.units[ref]);
units.forEach(unit => {
let target = null;
deviceIDs.forEach(deviceID => {
const refs = state.devices[deviceID] || [];
const profile = currentProfile(deviceID);
if (profile && profile !== unit.scene_template_name) return;
if (refs.length >= state.max) return;
if (!target || refs.length < (state.devices[target] || []).length) {
target = deviceID;
}
});
if (target) {
state.devices[target].push(unit.ref);
}
});
const remaining = unassignedRefs().length;
feedback.textContent = `已按每台最多 ${state.max} 路完成自动分配${remaining > 0 ? `,剩余 ${remaining} 路未分配。` : '。'}`;
render();
}
function syncHiddenState() {
hiddenState.value = JSON.stringify({ devices: state.devices });
}
function renderStats() {
const assigned = assignedRefSet();
const totalUnits = allRefs().length;
const totalDevices = Object.keys(state.devices).length;
const average = totalDevices ? (assigned.size / totalDevices).toFixed(1) : '0.0';
const full = Object.keys(state.devices).filter(deviceID => statusFor((state.devices[deviceID] || []).length) === 'full').length;
const values = document.querySelectorAll('.assignment-kpi strong');
if (values.length >= 6) {
values[0].textContent = totalUnits;
values[1].textContent = totalDevices;
values[2].textContent = assigned.size;
values[3].textContent = totalUnits - assigned.size;
values[4].textContent = average;
values[5].textContent = full;
}
}
function renderCards() {
const cards = Object.keys(state.devices).map(deviceID => {
const refs = state.devices[deviceID] || [];
return {
deviceID,
deviceName: (state.meta[deviceID] && state.meta[deviceID].device_name) || deviceID,
refs,
status: statusFor(refs.length)
};
}).sort((a, b) => {
const rankDiff = statusRank(a.status) - statusRank(b.status);
if (rankDiff !== 0) return rankDiff;
if (b.refs.length !== a.refs.length) return b.refs.length - a.refs.length;
return a.deviceID.localeCompare(b.deviceID);
});
cardsContainer.innerHTML = '';
cards.forEach(card => {
const section = document.createElement('section');
section.className = `assignment-device-card state-${card.status}`;
const currentTemplate = currentProfile(card.deviceID);
const availableRefs = unassignedRefs().filter(ref => {
const unit = state.units[ref];
return !currentTemplate || unit.scene_template_name === currentTemplate;
});
const addControls = card.refs.length < state.max ? `
<div class="assignment-device-add">
<select data-add-select="${card.deviceID}">
<option value="">添加识别单元</option>
${availableRefs.map(ref => `<option value="${ref}">${unitLabel(state.units[ref])}</option>`).join('')}
</select>
<button type="button" class="btn secondary" data-add-button="${card.deviceID}">加入</button>
</div>
` : '';
section.innerHTML = `
<div class="assignment-device-head">
<div>
<h3>${card.deviceName}</h3>
<span class="mono">${card.deviceID}</span>
</div>
<div class="assignment-device-metrics">
<strong>${card.refs.length} / ${state.max}</strong>
<span class="pill">${card.status}</span>
</div>
</div>
<div class="assignment-chip-list"></div>
${addControls}
`;
const chipList = section.querySelector('.assignment-chip-list');
card.refs.forEach(ref => {
const unit = state.units[ref];
const chip = document.createElement('span');
chip.className = 'assignment-chip';
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span><button type="button" class="assignment-chip-remove" data-remove-unit="${ref}">×</button>`;
chipList.appendChild(chip);
});
cardsContainer.appendChild(section);
});
}
function renderUnassigned() {
const refs = unassignedRefs();
if (!refs.length) {
unassignedContainer.innerHTML = '<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>';
return;
}
unassignedContainer.innerHTML = '';
refs.forEach(ref => {
const unit = state.units[ref];
const chip = document.createElement('span');
chip.className = 'assignment-chip assignment-chip-unassigned';
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span>`;
unassignedContainer.appendChild(chip);
});
}
function render() {
maxValue.textContent = `${state.max} 路/台`;
renderStats();
renderCards();
renderUnassigned();
syncHiddenState();
}
maxRange.addEventListener('input', () => {
state.max = Number(maxRange.value || state.max);
render();
});
document.addEventListener('click', (event) => {
const remove = event.target.closest('[data-remove-unit]');
if (remove) {
moveToUnassigned(remove.getAttribute('data-remove-unit'));
feedback.textContent = '';
render();
return;
}
const add = event.target.closest('[data-add-button]');
if (add) {
const deviceID = add.getAttribute('data-add-button');
const select = document.querySelector(`[data-add-select="${deviceID}"]`);
if (select && select.value) {
addToDevice(deviceID, select.value);
feedback.textContent = '';
render();
}
return;
}
});
document.getElementById('auto-assign-btn').addEventListener('click', autoAssign);
document.getElementById('clear-assign-btn').addEventListener('click', () => {
clearAssignments();
feedback.textContent = '已清空当前页面中的设备分配。';
render();
});
form.addEventListener('submit', syncHiddenState);
render();
})();
</script>
{{else}}
<div class="empty-state compact"><div class="empty-title">暂无可用的设备分配数据</div></div>
{{end}}
</div>
{{end}}

View File

@ -27,23 +27,13 @@
<div class="card"> <div class="card">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "config"}}<span>下发场景配置</span></h2> <h2 class="title-with-icon">{{icon "config"}}<span>下发设备分配</span></h2>
<div class="muted">先选择一份已有场景配置,再为已选设备创建下发任务。</div> <div class="muted">按每台设备当前保存的设备分配,批量创建下发任务。</div>
</div> </div>
</div> </div>
<form method="post" action="/ui/devices/batch-config"> <form method="post" action="/ui/devices/batch-config">
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}} {{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
<div class="field-grid">
<label class="full"><span>场景配置</span>
<select name="profile">
{{range .AssetProfiles}}
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
{{end}}
</select>
</label>
</div>
<div class="actions"> <div class="actions">
<button type="submit" class="primary">创建下发任务</button> <button type="submit" class="primary">创建下发任务</button>
</div> </div>
@ -53,50 +43,34 @@
<div class="card"> <div class="card">
<div class="section-title"> <div class="section-title">
<div> <div>
<h2 class="title-with-icon">{{icon "preview"}}<span>场景配置摘要</span></h2> <h2 class="title-with-icon">{{icon "preview"}}<span>设备分配摘要</span></h2>
</div> </div>
</div> </div>
{{if .AssetProfile}} {{if .DeviceAssignments}}
<div class="info-list"> <div class="table-wrap">
<div><span>场景配置</span><strong>{{.AssetProfile.Name}}</strong></div>
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div>
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div>
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div>
{{with index .AssetProfile.Instances 0}}
<div><span>首个通道</span><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</strong></div>
{{end}}
{{if .AssetProfile.Description}}
<div class="full"><span>说明</span><strong>{{.AssetProfile.Description}}</strong></div>
{{end}}
</div>
{{if .AssetProfile.Instances}}
<div class="table-wrap" style="margin-top:14px">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>通道</th> <th>设备</th>
<th>显示名称</th> <th>场景模板</th>
<th>站点</th> <th>识别单元</th>
<th>视频源</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .AssetProfile.Instances}} {{range .DeviceAssignments}}
<tr> <tr>
<td class="mono">{{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}</td> <td class="mono">{{.DeviceID}}</td>
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}</td> <td class="mono">{{if .ProfileName}}{{.ProfileName}}{{else}}-{{end}}</td>
<td>{{if .SiteName}}{{.SiteName}}{{else}}-{{end}}</td> <td>{{.RecognitionCount}} 路</td>
<td class="mono">{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
{{end}}
{{else}} {{else}}
<div class="empty-state"> <div class="empty-state">
<div class="empty-title">还没有可用场景配置</div> <div class="empty-title">还没有可用设备分配</div>
<div class="muted">请先到场景配置中创建配置,再回来下发。</div> <div class="muted">请先到设备分配中完成设备与识别单元绑定,再回来下发。</div>
</div> </div>
{{end}} {{end}}
</div> </div>

View File

@ -3,7 +3,7 @@
<div class="section-title"> <div class="section-title">
<div> <div>
<h2>单设备配置已并入设备详情</h2> <h2>单设备配置已并入设备详情</h2>
<div class="muted small">配置查看、服务控制、候选配置应用和回滚,都已经收敛到设备详情工作台。</div> <div class="muted small">配置查看、服务控制和回滚,都已经收敛到设备详情工作台。</div>
</div> </div>
<a class="btn ghost" href="/ui/devices">去设备列表</a> <a class="btn ghost" href="/ui/devices">去设备列表</a>
</div> </div>

View File

@ -38,8 +38,8 @@
</div> </div>
<div class="info-list compact-list"> <div class="info-list compact-list">
<div><span>当前模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div> <div><span>当前模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
<div><span>业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div> <div><span>场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
<div><span>候选配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}} / {{.ConfigStatus.Candidate.Metadata.ConfigVersion}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div> <div><span>待应用配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}} / {{.ConfigStatus.Candidate.Metadata.ConfigVersion}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
</div> </div>
</section> </section>
@ -56,7 +56,7 @@
<div class="actions"> <div class="actions">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply"> <form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
<input type="hidden" name="return_to" value="config" /> <input type="hidden" name="return_to" value="config" />
<button type="submit" class="primary">应用候选配置</button> <button type="submit" class="primary">应用待应用配置</button>
</form> </form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action"> <form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" /> <input type="hidden" name="action" value="rollback" />

View File

@ -42,9 +42,9 @@
<button type="submit" name="action" value="media_restart" class="primary">重启服务</button> <button type="submit" name="action" value="media_restart" class="primary">重启服务</button>
<button type="submit" name="action" value="media_start" class="secondary">启动服务</button> <button type="submit" name="action" value="media_start" class="secondary">启动服务</button>
<button type="submit" name="action" value="media_stop" class="danger">停止服务</button> <button type="submit" name="action" value="media_stop" class="danger">停止服务</button>
<button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前场景配置:{{.ReloadSummary}}")'{{end}}>重载配置</button> <button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前运行配置:{{.ReloadSummary}}")'{{end}}>重载运行配置</button>
<button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版场景配置:{{.RollbackSummary}}")'{{end}}>回滚配置</button> <button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版运行配置:{{.RollbackSummary}}")'{{end}}>回滚运行配置</button>
<a class="btn secondary" href="{{.BatchConfigURL}}">下发场景配置</a> <a class="btn secondary" href="{{.BatchConfigURL}}">下发设备分配</a>
<a class="btn secondary" href="/ui/devices">清空选择</a> <a class="btn secondary" href="/ui/devices">清空选择</a>
</div> </div>
</div> </div>

View File

@ -22,7 +22,9 @@
<div class="nav-section">主模块</div> <div class="nav-section">主模块</div>
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a> <a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a> <a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
<a href="/ui/plans"><span class="nav-icon">{{icon "profile"}}</span><span>场景配置</span></a> <a href="/ui/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景模板</span></a>
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>识别单元</span></a>
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>设备分配</span></a>
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>基础配置</span></a> <a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>基础配置</span></a>
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a> <a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a>
<details class="nav-group" id="system-nav-group"> <details class="nav-group" id="system-nav-group">
@ -251,6 +253,24 @@
return; return;
} }
const profileRow = event.target.closest("[data-profile-row]");
if (profileRow && !event.target.closest("button, a, input, select, textarea")) {
const href = profileRow.getAttribute("data-profile-href");
if (href) {
window.location.href = href;
}
return;
}
const navRow = event.target.closest("[data-nav-row]");
if (navRow && !event.target.closest("button, a, input, select, textarea")) {
const href = navRow.getAttribute("data-nav-href");
if (href) {
window.location.href = href;
}
return;
}
const instanceEditorToggle = event.target.closest(".js-open-instance-editor"); const instanceEditorToggle = event.target.closest(".js-open-instance-editor");
if (instanceEditorToggle) { if (instanceEditorToggle) {
event.preventDefault(); event.preventDefault();

View File

@ -8,7 +8,7 @@
</div> </div>
<div class="model-summary"> <div class="model-summary">
<div class="summary-item"><div class="summary-label">模型目录</div><div class="summary-value">统一管理</div><div class="summary-hint">检测 / 识别模型统一发布</div></div> <div class="summary-item"><div class="summary-label">模型目录</div><div class="summary-value">统一管理</div><div class="summary-hint">检测 / 识别模型统一发布</div></div>
<div class="summary-item"><div class="summary-label">发布版本</div><div class="summary-value">当前版本</div><div class="summary-hint">按场景配置引用生效</div></div> <div class="summary-item"><div class="summary-label">发布版本</div><div class="summary-value">当前版本</div><div class="summary-hint">按场景模板与识别单元引用生效</div></div>
<div class="summary-item"><div class="summary-label">设备版本状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备版本覆盖</div></div> <div class="summary-item"><div class="summary-label">设备版本状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备版本覆盖</div></div>
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">统一管理</div><div class="summary-hint">在人脸库资源中维护</div></div> <div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">统一管理</div><div class="summary-hint">在人脸库资源中维护</div></div>
</div> </div>

View File

@ -0,0 +1,107 @@
{{define "recognition_units"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "device"}}<span>识别单元列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/recognition-units?new=1">{{icon "apply"}}<span>新增识别单元</span></a>
{{if .SelectedRecognitionUnit}}
<a class="btn secondary" href="/ui/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
<form method="post" action="/ui/recognition-units/delete" onsubmit="return confirm('确认删除这个识别单元吗?');">
<input type="hidden" name="ref" value="{{.SelectedRecognitionUnit}}" />
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>识别单元</th>
<th>场景模板</th>
<th>视频源</th>
<th>输出频道号</th>
</tr>
</thead>
<tbody>
{{range .RecognitionUnits}}
<tr data-nav-row data-nav-href="/ui/recognition-units?ref={{.Ref}}" {{if eq $.SelectedRecognitionUnit .Ref}}class="selected"{{end}}>
<td><a class="mono" href="/ui/recognition-units?ref={{.Ref}}">{{.Name}}</a>{{if .DisplayName}}<div class="stacked-meta"><span>{{.DisplayName}}</span></div>{{end}}</td>
<td class="mono">{{.SceneTemplateName}}</td>
<td class="mono">{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}</td>
<td class="mono">{{if .OutputChannel}}{{.OutputChannel}}{{else}}-{{end}}</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有识别单元</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .RecognitionUnit}}
<form method="post" action="/ui/recognition-units">
<input type="hidden" name="original_ref" value="{{.SelectedRecognitionUnit}}" />
<div class="card editor-state {{if .RecognitionUnitEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "device"}}<span>识别单元{{if .RecognitionUnit.Name}} · {{.RecognitionUnit.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .RecognitionUnitEditing}}
<span class="pill run">编辑模式</span>
<span>一路视频对应一个识别单元,由设备分配决定最终在哪台设备上运行。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
{{end}}
</div>
</div>
<div class="actions compact">
{{if .RecognitionUnitEditing}}
<button type="submit">{{icon "apply"}}<span>保存识别单元</span></button>
<a class="btn secondary" href="/ui/recognition-units{{if .SelectedRecognitionUnit}}?ref={{.SelectedRecognitionUnit}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
</div>
</div>
{{if .RecognitionUnitEditing}}
<div class="field-grid">
<label><span>识别单元名称<span class="required-mark">*</span></span><input name="name" value="{{.RecognitionUnit.Name}}" {{if not .SelectedRecognitionUnit}}autofocus{{end}} /></label>
<label>
<span>场景模板<span class="required-mark">*</span></span>
<select name="scene_template_name">
{{range .AssetProfiles}}
<option value="{{.Name}}" {{if eq $.RecognitionUnit.SceneTemplateName .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>通道显示名</span><input name="display_name" value="{{.RecognitionUnit.DisplayName}}" /></label>
<label><span>站点名</span><input name="site_name" value="{{.RecognitionUnit.SiteName}}" /></label>
<label>
<span>视频源<span class="required-mark">*</span></span>
<select name="video_source_ref">
<option value="">未选择</option>
{{range .AssetVideoSources}}
<option value="{{.Name}}" {{if eq $.RecognitionUnit.VideoSourceRef .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option>
{{end}}
</select>
</label>
<label><span>输出频道号<span class="required-mark">*</span></span><input name="output_channel" value="{{.RecognitionUnit.OutputChannel}}" /></label>
<label><span>RTSP 端口</span><input class="mono" name="rtsp_port" value="{{.RecognitionUnit.RTSPPort}}" /></label>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>识别单元名称</span><strong class="mono">{{if .RecognitionUnit.Name}}{{.RecognitionUnit.Name}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>场景模板</span><strong class="mono">{{if .RecognitionUnit.SceneTemplateName}}{{.RecognitionUnit.SceneTemplateName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>通道显示名</span><strong>{{if .RecognitionUnit.DisplayName}}{{.RecognitionUnit.DisplayName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>站点名</span><strong>{{if .RecognitionUnit.SiteName}}{{.RecognitionUnit.SiteName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>视频源</span><strong class="mono">{{if .RecognitionUnit.VideoSourceRef}}{{.RecognitionUnit.VideoSourceRef}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>输出频道号</span><strong class="mono">{{if .RecognitionUnit.OutputChannel}}{{.RecognitionUnit.OutputChannel}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>RTSP 端口</span><strong class="mono">{{if .RecognitionUnit.RTSPPort}}{{.RecognitionUnit.RTSPPort}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div>
</form>
{{end}}
{{end}}

View File

@ -10,7 +10,7 @@
<div class="summary-item"><div class="summary-label">人脸库版本</div><div class="summary-value">统一管理</div><div class="summary-hint">由平台集中维护与发布</div></div> <div class="summary-item"><div class="summary-label">人脸库版本</div><div class="summary-value">统一管理</div><div class="summary-hint">由平台集中维护与发布</div></div>
<div class="summary-item"><div class="summary-label">通用资源</div><div class="summary-value">统一管理</div><div class="summary-hint">标定、字典与业务资源统一维护</div></div> <div class="summary-item"><div class="summary-label">通用资源</div><div class="summary-value">统一管理</div><div class="summary-hint">标定、字典与业务资源统一维护</div></div>
<div class="summary-item"><div class="summary-label">设备资源状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备资源版本覆盖</div></div> <div class="summary-item"><div class="summary-label">设备资源状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备资源版本覆盖</div></div>
<div class="summary-item"><div class="summary-label">同步方式</div><div class="summary-value">任务下发</div><div class="summary-hint">场景配置、模型同步一致</div></div> <div class="summary-item"><div class="summary-label">同步方式</div><div class="summary-value">任务下发</div><div class="summary-hint">设备分配、模型同步一致</div></div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,105 @@
{{define "scene_templates"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景模板</span></a>
{{if .SelectedProfile}}
<a class="btn secondary" href="/ui/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景模板吗?');">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>场景模板</th>
<th>识别模板</th>
<th>调试参数</th>
<th>识别单元</th>
</tr>
</thead>
<tbody>
{{range .AssetProfiles}}
<tr data-nav-row data-nav-href="/ui/scene-templates?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
<td><a class="mono" href="/ui/scene-templates?name={{.Name}}">{{.Name}}</a></td>
<td>{{if index .Raw "primary_template_name"}}{{index .Raw "primary_template_name"}}{{else if .Instances}}{{(index .Instances 0).Template}}{{else}}-{{end}}</td>
<td>{{if $.AssetProfileEditor}}{{if and (eq $.AssetProfileEditor.Name .Name) $.AssetProfileEditor.OverlayName}}{{$.AssetProfileEditor.OverlayName}}{{else}}-{{end}}{{else}}-{{end}}</td>
<td>{{len .Instances}}</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景模板</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .AssetProfileEditor}}
{{if .AssetProfileEditing}}<form method="post" action="{{.AssetProfileFormAction}}">{{end}}
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetProfileEditing}}
<span class="pill run">编辑模式</span>
<span>当前内容只包含模板级信息,识别单元请到“识别单元”页面维护。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
{{end}}
</div>
</div>
<div class="actions compact">
{{if .AssetProfileEditing}}
<button type="submit">{{icon "apply"}}<span>保存场景模板</span></button>
<a class="btn secondary" href="/ui/scene-templates{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
<button type="button" class="btn secondary js-export-json" data-export-url="/ui/scene-templates/{{.AssetProfileEditor.Name}}/export" data-default-filename="{{.AssetProfileEditor.Name}}.json">{{icon "apply"}}<span>导出为 JSON</span></button>
</div>
</div>
{{if .AssetProfileEditing}}
<div class="field-grid">
<label><span>场景模板名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if not .SelectedProfile}}autofocus{{end}} /></label>
<label><span>识别模板<span class="required-mark">*</span></span>
<select name="primary_template_name">
{{range .AssetTemplates}}
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.PrimaryTemplateName .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
<label>
<span>调试参数</span>
<select name="overlay_name">
<option value="">不使用</option>
{{range .AssetOverlays}}
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.OverlayName .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
{{end}}
</select>
</label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>场景模板名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>识别模板</span><strong class="mono">{{if .AssetProfileEditor.PrimaryTemplateName}}{{.AssetProfileEditor.PrimaryTemplateName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>业务名称</span><strong>{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>调试参数</span><strong>{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}</strong></div>
<div class="detail-item"><span>站点名</span><strong>{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>识别单元</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
<div class="detail-item full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div>
{{if .AssetProfileEditing}}</form>{{end}}
{{end}}
{{end}}

File diff suppressed because it is too large Load Diff