fix: normalize percent-encoding case in serveFacePhoto via PathUnescape; remove /ui prefix globally

This commit is contained in:
tian 2026-07-20 15:25:29 +08:00
parent 32fcd43801
commit b7757c7387
40 changed files with 316 additions and 312 deletions

View File

@ -96,7 +96,7 @@ func main() {
})
r.Get("/openapi.json", api.OpenAPI)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui", http.StatusFound)
http.Redirect(w, r, "/dashboard", http.StatusFound)
})
previewSvc := service.NewConfigPreviewService(cfg, assetsRepo)
@ -118,7 +118,7 @@ func main() {
if err != nil {
log.Fatalf("failed to init ui routes: %v", err)
}
r.Mount("/ui", http.StripPrefix("/ui", uiRouter))
r.Mount("/", uiRouter)
ui.RegisterAlarmRoutes(r)
// API Routes

View File

@ -721,7 +721,7 @@ func (u *UI) Routes() (chi.Router, error) {
}))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/dashboard", http.StatusFound)
http.Redirect(w, r, "/dashboard", http.StatusFound)
})
r.Get("/dashboard", u.pageDashboard)
@ -919,7 +919,7 @@ func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
// First-time setup: if no recognition units exist, guide to wizard.
if u.preview != nil {
if units, err := u.preview.ListRecognitionUnits(); err == nil && len(units) == 0 {
http.Redirect(w, r, "/ui/wizard", http.StatusFound)
http.Redirect(w, r, "/wizard", http.StatusFound)
return
}
}
@ -1261,7 +1261,7 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
return
}
if u.autoConfig == nil || u.preview == nil {
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound)
http.Redirect(w, r, "/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound)
return
}
@ -1309,7 +1309,7 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
}
if len(requests) == 0 {
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("未选择检测功能"), http.StatusFound)
http.Redirect(w, r, "/console?msg="+url.QueryEscape("未选择检测功能"), http.StatusFound)
return
}
@ -1334,18 +1334,18 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
}
if len(taskIDs) == 1 {
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound)
http.Redirect(w, r, "/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound)
return
}
if len(taskIDs) > 1 {
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound)
http.Redirect(w, r, "/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound)
return
}
if len(errs) > 0 {
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound)
http.Redirect(w, r, "/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound)
http.Redirect(w, r, "/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound)
}
func (u *UI) pageWizard(w http.ResponseWriter, r *http.Request) {
@ -1534,7 +1534,7 @@ func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) {
func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
http.Redirect(w, r, "/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
}
func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) {
@ -1566,7 +1566,7 @@ func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) {
LastSeenMs: time.Now().UnixMilli(),
}
u.registry.UpdateDevice(dev)
http.Redirect(w, r, "/ui/devices", http.StatusFound)
http.Redirect(w, r, "/devices", http.StatusFound)
}
func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) {
@ -1628,7 +1628,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
http.Redirect(w, r, "/tasks/"+task.ID, http.StatusFound)
}
func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
@ -1678,7 +1678,7 @@ func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
http.Redirect(w, r, "/tasks/"+task.ID, http.StatusFound)
}
func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) {
@ -1738,7 +1738,7 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
http.Redirect(w, r, "/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
}
func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
@ -2114,7 +2114,7 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
http.Redirect(w, r, "/tasks/"+task.ID, http.StatusFound)
}
func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
@ -2138,11 +2138,11 @@ func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/assets/templates", http.StatusFound)
http.Redirect(w, r, "/assets/templates", http.StatusFound)
}
func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
http.Redirect(w, r, "/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}
func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
@ -2251,7 +2251,7 @@ func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) {
@ -2336,12 +2336,12 @@ func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) {
if len(deviceIDs) > 0 {
payload := map[string]any{"model_name": name}
task, _ := u.tasks.CreateTask("model_sync_all", deviceIDs, payload)
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
return
}
}
http.Redirect(w, r, "/ui/models?msg="+url.QueryEscape("模型 "+name+" 上传成功"), http.StatusFound)
http.Redirect(w, r, "/models?msg="+url.QueryEscape("模型 "+name+" 上传成功"), http.StatusFound)
}
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
@ -2625,15 +2625,15 @@ func (u *UI) actionResourceSync(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/assets", http.StatusFound)
http.Redirect(w, r, "/assets", http.StatusFound)
}
func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/diagnostics", http.StatusFound)
http.Redirect(w, r, "/diagnostics", http.StatusFound)
}
func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) {
@ -2744,7 +2744,7 @@ func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), status)
return
}
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&edit=1&msg="+urlQueryEscape("模板已创建"), http.StatusFound)
http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(name)+"&edit=1&msg="+urlQueryEscape("模板已创建"), http.StatusFound)
}
func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) {
@ -2759,18 +2759,18 @@ func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) {
return
}
if !item.ReadOnly {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound)
http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound)
return
}
targetName, err := u.nextTemplateCloneName(item.Name)
if err != nil {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
cloneSource := url.QueryEscape(sourceName)
cloneName := url.QueryEscape(targetName)
cloneDesc := url.QueryEscape(item.Description)
http.Redirect(w, r, "/ui/assets/templates?clone_source="+cloneSource+"&clone_name="+cloneName+"&clone_desc="+cloneDesc, http.StatusFound)
http.Redirect(w, r, "/assets/templates?clone_source="+cloneSource+"&clone_name="+cloneName+"&clone_desc="+cloneDesc, http.StatusFound)
}
func (u *UI) nextTemplateCloneName(sourceName string) (string, error) {
@ -2930,7 +2930,7 @@ func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request
if targetName != name {
message = "模板已保存,名称已更新"
}
http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound)
http.Redirect(w, r, "/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound)
}
func (u *UI) actionAssetTemplateRename(w http.ResponseWriter, r *http.Request) {
@ -2980,10 +2980,10 @@ func (u *UI) actionAssetTemplateRename(w http.ResponseWriter, r *http.Request) {
err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n")
}
if err != nil {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存,名称已更新"), http.StatusFound)
http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存,名称已更新"), http.StatusFound)
}
func (u *UI) actionAssetTemplateDelete(w http.ResponseWriter, r *http.Request) {
@ -2993,10 +2993,10 @@ func (u *UI) actionAssetTemplateDelete(w http.ResponseWriter, r *http.Request) {
return
}
if err := u.preview.DeleteTemplateAsset(name); err != nil {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound)
http.Redirect(w, r, "/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound)
}
func (u *UI) apiGraphNodeTypes(w http.ResponseWriter, r *http.Request) {
@ -3057,7 +3057,7 @@ func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
}
data.AssetProfileEditor = editor
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/create"
data.AssetProfileFormAction = "/scene-templates/create"
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0)
u.render(w, r, "scene_templates", data)
return
@ -3068,7 +3068,7 @@ func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
data.AssetProfileEditor = editor
data.SelectedProfile = editor.Name
data.AssetProfileEditing = editMode
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(editor.Name)
data.AssetProfileFormAction = "/scene-templates/" + url.PathEscape(editor.Name)
if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
data.SelectedTemplate = editor.Instances[0].Template
}
@ -3083,7 +3083,7 @@ func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
}
data.AssetProfileEditor = editor
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/create"
data.AssetProfileFormAction = "/scene-templates/create"
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0)
}
u.render(w, r, "scene_templates", data)
@ -3102,7 +3102,7 @@ func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) {
}
data.Title = "场景"
data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
data.AssetProfileFormAction = "/scene-templates/" + url.PathEscape(name)
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
u.render(w, r, "scene_templates", data)
}
@ -3149,7 +3149,7 @@ func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name
}
data.AssetProfileEditor = &editor
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/create"
data.AssetProfileFormAction = "/scene-templates/create"
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
} else {
editor, data, err = u.profileEditorActionData(r, name)
@ -3158,7 +3158,7 @@ func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name
return
}
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
data.AssetProfileFormAction = "/scene-templates/" + url.PathEscape(name)
}
if err := u.preview.SaveProfileEditor(editor); err != nil {
data.Error = err.Error()
@ -3171,7 +3171,7 @@ func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name
if strings.TrimSpace(name) != "" && editor.Name != name {
msg = "场景已保存,名称已更新"
}
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound)
http.Redirect(w, r, "/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound)
}
func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
@ -3183,7 +3183,7 @@ func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
if u.stateRepo != nil {
items, err := u.stateRepo.ListByProfileName(name)
if err != nil {
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
http.Redirect(w, r, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
if len(items) > 0 {
@ -3192,15 +3192,15 @@ func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
deviceIDs = append(deviceIDs, item.DeviceID)
}
msg := fmt.Sprintf("场景 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、"))
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound)
http.Redirect(w, r, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound)
return
}
}
if err := u.preview.DeleteProfileAsset(name); err != nil {
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
http.Redirect(w, r, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound)
http.Redirect(w, r, "/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound)
}
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
@ -3208,7 +3208,7 @@ func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) {
target := "/ui/scene-templates"
target := "/scene-templates"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
@ -3216,11 +3216,11 @@ func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request
}
func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
http.Redirect(w, r, "/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}
func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) {
target := "/ui/scene-templates"
target := "/scene-templates"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
@ -3228,7 +3228,7 @@ func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Reques
}
func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request) {
target := "/ui/scene-templates/" + url.PathEscape(chi.URLParam(r, "name"))
target := "/scene-templates/" + url.PathEscape(chi.URLParam(r, "name"))
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
@ -3294,7 +3294,7 @@ func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) {
return
}
ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name)
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound)
http.Redirect(w, r, "/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound)
}
func serviceRecognitionUnitRef(profileName string, unitName string) string {
@ -3304,10 +3304,10 @@ func serviceRecognitionUnitRef(profileName string, unitName string) string {
func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request) {
ref := strings.TrimSpace(r.FormValue("ref"))
if err := u.preview.DeleteRecognitionUnit(ref); err != nil {
http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound)
http.Redirect(w, r, "/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound)
http.Redirect(w, r, "/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound)
}
func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) {
@ -3389,10 +3389,10 @@ func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request)
return
}
if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil {
http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound)
http.Redirect(w, r, "/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound)
http.Redirect(w, r, "/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound)
}
func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device {
@ -3438,10 +3438,10 @@ func filterPreviewDeviceAssignments(assignments map[string][]string) map[string]
func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(chi.URLParam(r, "id"))
if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil {
http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound)
http.Redirect(w, r, "/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound)
http.Redirect(w, r, "/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound)
}
func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) {
@ -3509,7 +3509,7 @@ func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request)
u.render(w, r, "assets", data)
return
}
http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
}
func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) {
@ -3519,10 +3519,10 @@ func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request
}
name := chi.URLParam(r, "name")
if err := u.preview.DeleteVideoSource(name); err != nil {
http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound)
}
func (u *UI) actionVideoSourceCSVTemplate(w http.ResponseWriter, r *http.Request) {
@ -3540,12 +3540,12 @@ func (u *UI) actionVideoSourceCSVImport(w http.ResponseWriter, r *http.Request)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("文件解析失败"), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("文件解析失败"), http.StatusFound)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("请选择文件"), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("请选择文件"), http.StatusFound)
return
}
defer file.Close()
@ -3554,11 +3554,11 @@ func (u *UI) actionVideoSourceCSVImport(w http.ResponseWriter, r *http.Request)
reader.LazyQuotes = true
records, err := reader.ReadAll()
if err != nil {
http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("CSV 解析失败: "+err.Error()), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("CSV 解析失败: "+err.Error()), http.StatusFound)
return
}
if len(records) < 2 {
http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("CSV 文件为空或无数据行"), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("CSV 文件为空或无数据行"), http.StatusFound)
return
}
@ -3621,7 +3621,7 @@ func (u *UI) actionVideoSourceCSVImport(w http.ResponseWriter, r *http.Request)
}
msg := fmt.Sprintf("导入完成:%d 个视频源,%d 条跳过", imported, skipped)
http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape(msg), http.StatusFound)
http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape(msg), http.StatusFound)
}
func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
@ -3692,7 +3692,7 @@ func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request)
}
var raw map[string]any
if err := json.Unmarshal([]byte(configRaw), &raw); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape("自定义配置 JSON 格式不正确:"+err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
http.Redirect(w, r, "/assets/integrations?error="+urlQueryEscape("自定义配置 JSON 格式不正确:"+err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
return
}
asset.Raw = raw
@ -3706,10 +3706,10 @@ func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request)
asset.AlarmService.TenantCode = strings.TrimSpace(r.FormValue("tenant_code"))
}
if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
http.Redirect(w, r, "/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
http.Redirect(w, r, "/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
}
func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) {
@ -3719,10 +3719,10 @@ func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request
}
name := chi.URLParam(r, "name")
if err := u.preview.DeleteIntegrationService(name); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
http.Redirect(w, r, "/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound)
http.Redirect(w, r, "/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound)
}
func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) {
@ -3833,7 +3833,7 @@ func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "asset_overlays", data)
return
}
http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound)
http.Redirect(w, r, "/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound)
}
func (u *UI) actionAssetOverlayClone(w http.ResponseWriter, r *http.Request) {
@ -3848,17 +3848,17 @@ func (u *UI) actionAssetOverlayClone(w http.ResponseWriter, r *http.Request) {
return
}
if !item.ReadOnly {
http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound)
http.Redirect(w, r, "/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound)
return
}
targetName, err := u.nextOverlayCloneName(item.Name)
if err != nil {
http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
http.Redirect(w, r, "/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
cloneSource := url.QueryEscape(sourceName)
cloneName := url.QueryEscape(targetName)
http.Redirect(w, r, "/ui/assets/overlays?new=1&clone_source="+cloneSource+"&clone_name="+cloneName, http.StatusFound)
http.Redirect(w, r, "/assets/overlays?new=1&clone_source="+cloneSource+"&clone_name="+cloneName, http.StatusFound)
}
func (u *UI) nextOverlayCloneName(sourceName string) (string, error) {
@ -3890,10 +3890,10 @@ func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) {
}
name := chi.URLParam(r, "name")
if err := u.preview.DeleteOverlayAsset(name); err != nil {
http.Redirect(w, r, "/ui/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
http.Redirect(w, r, "/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound)
http.Redirect(w, r, "/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound)
}
func (u *UI) assetPageData(tab string) PageData {
@ -4125,7 +4125,7 @@ func (u *UI) actionSystemDBRestore(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound)
http.Redirect(w, r, "/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound)
}
func urlQueryEscape(s string) string {
@ -4265,15 +4265,15 @@ func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, er
}
func (u *UI) loadConfigUIData(dev *models.Device) PageData {
schemaBody, schemaCode, schemaErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/schema", nil)
stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/state", nil)
schemaBody, schemaCode, schemaErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/schema", nil)
stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/state", nil)
faceBody, faceCode, faceErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/face-gallery", nil)
data := PageData{
Title: "高级识别配置",
Device: dev,
SchemaJSON: fmt.Sprintf("GET /v1/config/ui/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)),
StateJSON: fmt.Sprintf("GET /v1/config/ui/state -> %d\n%s", stateCode, prettyJSON(stateBody)),
SchemaJSON: fmt.Sprintf("GET /v1/config/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)),
StateJSON: fmt.Sprintf("GET /v1/config/state -> %d\n%s", stateCode, prettyJSON(stateBody)),
FaceGalleryJSON: fmt.Sprintf("GET /v1/face-gallery -> %d\n%s", faceCode, prettyJSON(faceBody)),
RawJSON: strings.TrimSpace(prettyJSON(stateBody)),
}
@ -4563,7 +4563,7 @@ func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "device", data)
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
http.Redirect(w, r, "/tasks/"+task.ID, http.StatusFound)
}
func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string {
@ -4918,8 +4918,8 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs
FoundCount: attention,
SelectedDeviceIDs: selectedIDs,
SelectedQuery: selectedQueryString(selectedIDs),
SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs),
BatchConfigURL: selectedURL("/ui/devices/batch-config", selectedIDs),
SelectedDevicesURL: selectedURL("/devices", selectedIDs),
BatchConfigURL: selectedURL("/devices/batch-config", selectedIDs),
ReloadSummary: batchActionSummary(rows, selectedIDs, "reload"),
RollbackSummary: batchActionSummary(rows, selectedIDs, "rollback"),
}
@ -5121,9 +5121,9 @@ func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) {
return
}
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/plan", []byte(raw))
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/plan", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/ui/plan -> %d", code)
data.Message = fmt.Sprintf("POST /v1/config/plan -> %d", code)
data.RawText = prettyJSON(body)
data.RawJSON = raw
if err != nil {
@ -5152,9 +5152,9 @@ func (u *UI) actionDeviceConfigUIApply(w http.ResponseWriter, r *http.Request) {
return
}
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/apply", []byte(raw))
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/apply", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/ui/apply -> %d", code)
data.Message = fmt.Sprintf("POST /v1/config/apply -> %d", code)
data.RawText = prettyJSON(body)
data.RawJSON = raw
if err != nil {
@ -5305,7 +5305,7 @@ func (u *UI) actionFaceGalleryAdd(w http.ResponseWriter, r *http.Request) {
pid, _ := repo.FindOrCreatePerson(name)
_ = repo.AddPhoto(pid, name+"/"+hdr.Filename)
}
http.Redirect(w, r, "/ui/face-gallery?msg=ok", http.StatusFound)
http.Redirect(w, r, "/face-gallery?msg=ok", http.StatusFound)
}
func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) {
@ -5327,7 +5327,7 @@ func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) {
output, err := builder.Build()
if err != nil {
msg := fmt.Sprintf("生成失败: %v", err)
http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
http.Redirect(w, r, "/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
return
}
// Parse build report for quality diagnostics.
@ -5406,7 +5406,7 @@ func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) {
}
}
http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
http.Redirect(w, r, "/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
}
func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) {
@ -5470,7 +5470,7 @@ func (u *UI) actionFaceGallerySync(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) {
@ -5482,7 +5482,7 @@ func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath)
repo.DeletePerson(id)
}
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
http.Redirect(w, r, "/face-gallery", http.StatusFound)
}
@ -5524,7 +5524,7 @@ func (u *UI) actionFaceGalleryImport(w http.ResponseWriter, r *http.Request) {
}
}
}
http.Redirect(w, r, fmt.Sprintf("/ui/face-gallery?msg=已导入 %d 张照片", count), http.StatusFound)
http.Redirect(w, r, fmt.Sprintf("/face-gallery?msg=已导入 %d 张照片", count), http.StatusFound)
}
func (u *UI) rebuildFaceGallery() {
@ -5565,6 +5565,10 @@ func personNameFromPath(path string) string {
func (u *UI) serveFacePhoto(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "*")
// URL-decode to normalize case-insensitive percent-encoding
if decoded, err := url.PathUnescape(path); err == nil {
path = decoded
}
if path == "" || strings.Contains(path, "..") {
http.Error(w, "invalid", http.StatusBadRequest)
return
@ -5583,7 +5587,7 @@ func (u *UI) actionFaceGalleryRename(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(u.dbPath) != "" {
storage.NewFaceGalleryRepo(u.dbPath).RenamePerson(id, name)
}
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
http.Redirect(w, r, "/face-gallery", http.StatusFound)
}
func (u *UI) actionFaceGalleryAddPhoto(w http.ResponseWriter, r *http.Request) {
@ -5594,7 +5598,7 @@ func (u *UI) actionFaceGalleryAddPhoto(w http.ResponseWriter, r *http.Request) {
if err != nil { http.Error(w, "photo required", http.StatusBadRequest); return }
defer file.Close()
// Get person name for directory
if strings.TrimSpace(u.dbPath) == "" { http.Redirect(w, r, "/ui/face-gallery", http.StatusFound); return }
if strings.TrimSpace(u.dbPath) == "" { http.Redirect(w, r, "/face-gallery", http.StatusFound); return }
repo := storage.NewFaceGalleryRepo(u.dbPath)
persons, _ := repo.ListPersons()
var personName string
@ -5608,7 +5612,7 @@ func (u *UI) actionFaceGalleryAddPhoto(w http.ResponseWriter, r *http.Request) {
defer f.Close()
io.Copy(f, file)
repo.AddPhoto(id, personName+"/"+hdr.Filename)
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
http.Redirect(w, r, "/face-gallery", http.StatusFound)
}
func (u *UI) actionFaceGalleryDeletePhoto(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id"))

View File

@ -1,6 +1,6 @@
{{define "alarms"}}
<div class="card">
<form method="get" action="/ui/alarms" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;justify-content:space-between;flex-wrap:wrap">
<form method="get" action="/alarms" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;justify-content:space-between;flex-wrap:wrap">
<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="date" name="from" value="{{.SelectedFrom}}" style="width:130px;font-size:12px">
<span class="muted small"></span>
@ -22,10 +22,10 @@
</select>
{{end}}
<button class="btn" style="font-size:12px;padding:4px 12px">筛选</button>
<a href="/ui/alarms" class="btn ghost" style="font-size:11px">今天</a>
<a href="/ui/alarms?from={{.WeekStart}}&to={{.WeekEnd}}" class="btn ghost" style="font-size:11px">本周</a>
<a href="/ui/alarms?from={{.MonthStart}}&to={{.MonthEnd}}" class="btn ghost" style="font-size:11px">本月</a>
<a href="/ui/alarms?from=2020-01-01&to={{.SelectedTo}}" class="btn ghost" style="font-size:11px">全部</a>
<a href="/alarms" class="btn ghost" style="font-size:11px">今天</a>
<a href="/alarms?from={{.WeekStart}}&to={{.WeekEnd}}" class="btn ghost" style="font-size:11px">本周</a>
<a href="/alarms?from={{.MonthStart}}&to={{.MonthEnd}}" class="btn ghost" style="font-size:11px">本月</a>
<a href="/alarms?from=2020-01-01&to={{.SelectedTo}}" class="btn ghost" style="font-size:11px">全部</a>
{{if .AlarmRecords}}
<a class="btn ghost js-export-csv" style="font-size:11px;margin-left:8px">导出 CSV</a>
{{end}}
@ -59,7 +59,7 @@
<tr id="alarm-{{.ID}}">
<td class="mono small">{{.Timestamp}}</td>
<td>
<a href="/ui/devices/{{.DeviceID}}">{{shortID .DeviceID}}</a>
<a href="/devices/{{.DeviceID}}">{{shortID .DeviceID}}</a>
</td>
<td>{{.Channel}}</td>
<td>
@ -99,19 +99,19 @@
<span class="muted small" style="margin-right:8px">{{.TotalAlarmCount}} 条,{{.TotalAlarmPages}} 页</span>
{{$from := $.SelectedFrom}}{{$to := $.SelectedTo}}{{$pp := $.PerPage}}{{$dev := $.SelectedAlarmDevice}}{{$rt := $.SelectedAlarmRuleType}}
{{if gt .CurrentPage 1}}
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">«</a>
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{sub .CurrentPage 1}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px"></a>
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">«</a>
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{sub .CurrentPage 1}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px"></a>
{{end}}
{{range .PageNumbers}}
{{if eq . -1}}
<span class="muted small" style="padding:2px 4px"></span>
{{else}}
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.}}&device={{$dev}}&rule_type={{$rt}}" class="btn {{if eq . $.CurrentPage}}primary{{else}}ghost{{end}}" style="font-size:11px;padding:2px 8px">{{.}}</a>
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.}}&device={{$dev}}&rule_type={{$rt}}" class="btn {{if eq . $.CurrentPage}}primary{{else}}ghost{{end}}" style="font-size:11px;padding:2px 8px">{{.}}</a>
{{end}}
{{end}}
{{if lt .CurrentPage .TotalAlarmPages}}
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{add .CurrentPage 1}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px"></a>
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.TotalAlarmPages}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">»</a>
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{add .CurrentPage 1}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px"></a>
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.TotalAlarmPages}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">»</a>
{{end}}
</div>
{{end}}
@ -128,7 +128,7 @@
(function(){
function postAlarmAction(id, action, body) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "/ui/alarms/" + id + "/" + action);
xhr.open("POST", "/alarms/" + id + "/" + action);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onload = function() {
if (xhr.status === 204) {

View File

@ -6,7 +6,7 @@
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{.AssetOverlay.Name}}</span></h2>
</div>
<a class="btn secondary" href="/ui/assets/overlays">返回调试参数列表</a>
<a class="btn secondary" href="/assets/overlays">返回调试参数列表</a>
</div>
<div class="info-list">
<div><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>

View File

@ -9,12 +9,12 @@
<div class="actions compact">
{{if .AssetOverlay}}
{{if .AssetOverlay.ReadOnly}}
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/clone">
<form method="post" action="/assets/overlays/{{.AssetOverlay.Name}}/clone">
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准调试参数</span></button>
</form>
{{else}}
<a class="btn secondary" href="/ui/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/delete" onsubmit="return confirm('确认删除这个调试参数吗?');">
<a class="btn secondary" href="/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/assets/overlays/{{.AssetOverlay.Name}}/delete" onsubmit="return confirm('确认删除这个调试参数吗?');">
<button type="submit" class="btn secondary">删除</button>
</form>
{{end}}
@ -32,8 +32,8 @@
</thead>
<tbody>
{{range .AssetOverlays}}
<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>
<tr data-nav-row data-nav-href="/assets/overlays?name={{.Name}}"{{if and $.AssetOverlay (eq $.AssetOverlay.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/assets/overlays?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{if .ReadOnly}}标准参数{{else}}用户参数{{end}}</td>
</tr>
@ -64,13 +64,13 @@
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/ui/assets/overlays/{{.AssetOverlay.Name}}/export"
data-export-url="/assets/overlays/{{.AssetOverlay.Name}}/export"
data-default-filename="{{.AssetOverlay.Name}}.json"
>{{icon "apply"}}<span>导出为 JSON</span></button>
</div>
</div>
{{if .AssetOverlayEditing}}
<form method="post" action="/ui/assets/overlays">
<form method="post" action="/assets/overlays">
<div class="field-grid">
<label><span>调试参数名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetOverlay.Name}}" autofocus /></label>
<label class="full"><span>描述</span><input name="description" value="{{.AssetOverlay.Description}}" /></label>
@ -78,7 +78,7 @@
</div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/overlays">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/assets/overlays">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}

View File

@ -5,10 +5,10 @@
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
<a class="btn secondary" href="/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('确认删除这个场景配置吗?');">
<a class="btn secondary" href="/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/plans/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景配置吗?');">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
@ -25,8 +25,8 @@
</thead>
<tbody>
{{range .AssetProfiles}}
<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>
<tr data-profile-row data-profile-href="/plans?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
<td><a class="mono" href="/plans?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{len .Instances}}</td>
</tr>
@ -60,12 +60,12 @@
<div class="actions compact">
{{if .AssetProfileEditing}}
<button type="submit">{{icon "apply"}}<span>保存场景配置</span></button>
<a class="btn secondary" href="/ui/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/ui/plans/{{.AssetProfileEditor.Name}}/export"
data-export-url="/plans/{{.AssetProfileEditor.Name}}/export"
data-default-filename="{{.AssetProfileEditor.Name}}.json"
>{{icon "apply"}}<span>导出为 JSON</span></button>
</div>

View File

@ -6,7 +6,7 @@
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>{{.AssetTemplate.Name}}</span></h2>
</div>
<a class="btn secondary" href="/ui/assets/templates">返回模板列表</a>
<a class="btn secondary" href="/assets/templates">返回模板列表</a>
</div>
<div class="info-list">
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>

View File

@ -8,16 +8,16 @@
</div>
<div class="actions compact">
{{if not .AssetTemplate.ReadOnly}}
<form class="graph-save-form" method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">
<form class="graph-save-form" method="post" action="/assets/templates/{{.AssetTemplate.Name}}/graph">
<input type="hidden" name="json" />
{{if .TemplateCloneSource}}<input type="hidden" name="clone_source" value="{{.TemplateCloneSource}}" />{{end}}
<button type="submit" class="primary">{{icon "apply"}}<span>保存模板</span></button>
</form>
{{end}}
{{if .AssetTemplate.ReadOnly}}
<a class="btn secondary" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
<a class="btn secondary" href="/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
{{end}}
<a class="btn ghost" href="/ui/assets/templates{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}&clone_name={{.AssetTemplate.Name}}{{else}}?name={{.AssetTemplate.Name}}{{end}}">返回模板</a>
<a class="btn ghost" href="/assets/templates{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}&clone_name={{.AssetTemplate.Name}}{{else}}?name={{.AssetTemplate.Name}}{{end}}">返回模板</a>
</div>
</div>
@ -28,7 +28,7 @@
<div class="graph-editor" data-template-name="{{.AssetTemplate.Name}}">
<aside class="graph-sidebar">
<h3>节点库</h3>
<div class="graph-node-palette-list" data-catalog-url="/ui/api/graph-node-types"></div>
<div class="graph-node-palette-list" data-catalog-url="/api/graph-node-types"></div>
</aside>
<section class="graph-canvas-wrap">
@ -71,7 +71,7 @@
</div>
<template id="graph-template-json">{{rawHTML .RawJSON}}</template>
<script src="/ui/assets/graph_editor.js"></script>
<script src="/assets/graph_editor.js"></script>
</div>
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -10,12 +10,12 @@
{{if .AssetTemplate}}
{{if .TemplateCloneSource}}
{{else if .AssetTemplate.ReadOnly}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/clone">
<form method="post" action="/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('确认删除这个用户模板吗?');">
<a class="btn secondary" href="/assets/templates?name={{.AssetTemplate.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
<button type="submit" class="btn secondary">删除</button>
</form>
{{end}}
@ -34,8 +34,8 @@
</thead>
<tbody>
{{range .AssetTemplates}}
<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>
<tr data-nav-row data-nav-href="/assets/templates?name={{.Name}}"{{if and $.AssetTemplate (eq $.AssetTemplate.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/assets/templates?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{.NodeCount}}节点 / {{.EdgeCount}}连线</td>
<td>{{if .ReadOnly}}标准模板{{else}}用户模板{{end}}</td>
@ -64,19 +64,19 @@
</div>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}{{end}}">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else if .TemplateCloneSource}}进入可视化编辑{{else}}可视化编辑{{end}}</span></a>
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}/graph{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}{{end}}">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else if .TemplateCloneSource}}进入可视化编辑{{else}}可视化编辑{{end}}</span></a>
{{if not .TemplateCloneSource}}
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
data-export-url="/assets/templates/{{.AssetTemplate.Name}}/export"
data-default-filename="{{.AssetTemplate.Name}}.json"
>{{icon "apply"}}<span>导出为 JSON</span></button>
{{end}}
</div>
</div>
{{if .AssetTemplateEditing}}
<form method="post" action="{{if .TemplateCloneSource}}/ui/assets/templates/create{{else}}/ui/assets/templates/{{.AssetTemplate.Name}}/rename{{end}}">
<form method="post" action="{{if .TemplateCloneSource}}/assets/templates/create{{else}}/assets/templates/{{.AssetTemplate.Name}}/rename{{end}}">
<div class="field-grid">
<label><span>模板名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetTemplate.Name}}" class="mono" autofocus /></label>
<label><span>模板类型</span><input value="{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}" readonly /></label>
@ -98,7 +98,7 @@
{{end}}
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>{{if .TemplateCloneSource}}创建模板{{else}}保存{{end}}</span></button>
<a class="btn secondary" href="/ui/assets/templates">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/assets/templates">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}

View File

@ -2,19 +2,19 @@
<div class="card-tabs asset-tab-wrap">
<ul class="nav nav-tabs asset-tabs" role="tablist" aria-label="配置管理页面">
<li class="nav-item" role="presentation">
<a href="/ui/assets" class="nav-link{{if eq .AssetTab "overview"}} active{{end}}" role="tab" {{if eq .AssetTab "overview"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>总览</a>
<a href="/assets" class="nav-link{{if eq .AssetTab "overview"}} active{{end}}" role="tab" {{if eq .AssetTab "overview"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>总览</a>
</li>
<li class="nav-item" role="presentation">
<a href="/ui/assets/video-sources" class="nav-link{{if eq .AssetTab "video-sources"}} active{{end}}" role="tab" {{if eq .AssetTab "video-sources"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>视频源</a>
<a href="/assets/video-sources" class="nav-link{{if eq .AssetTab "video-sources"}} active{{end}}" role="tab" {{if eq .AssetTab "video-sources"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>视频源</a>
</li>
<li class="nav-item" role="presentation">
<a href="/ui/assets/templates" class="nav-link{{if eq .AssetTab "templates"}} active{{end}}" role="tab" {{if eq .AssetTab "templates"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>识别模板</a>
<a href="/assets/templates" class="nav-link{{if eq .AssetTab "templates"}} active{{end}}" role="tab" {{if eq .AssetTab "templates"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>识别模板</a>
</li>
<li class="nav-item" role="presentation">
<a href="/ui/assets/integrations" class="nav-link{{if eq .AssetTab "integrations"}} active{{end}}" role="tab" {{if eq .AssetTab "integrations"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>第三方服务</a>
<a href="/assets/integrations" class="nav-link{{if eq .AssetTab "integrations"}} active{{end}}" role="tab" {{if eq .AssetTab "integrations"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>第三方服务</a>
</li>
<li class="nav-item" role="presentation">
<a href="/ui/assets/overlays" class="nav-link{{if eq .AssetTab "overlays"}} active{{end}}" role="tab" {{if eq .AssetTab "overlays"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>调试参数</a>
<a href="/assets/overlays" class="nav-link{{if eq .AssetTab "overlays"}} active{{end}}" role="tab" {{if eq .AssetTab "overlays"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>调试参数</a>
</li>
</ul>
<div class="tab-content">
@ -38,10 +38,10 @@
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/integrations?new=1">{{icon "apply"}}<span>新增服务</span></a>
<a class="btn secondary" href="/assets/integrations?new=1">{{icon "apply"}}<span>新增服务</span></a>
{{if .AssetIntegration.Name}}
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/integrations/{{.AssetIntegration.Name}}/delete">
<a class="btn secondary" href="/assets/integrations?name={{.AssetIntegration.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/assets/integrations/{{.AssetIntegration.Name}}/delete">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
@ -61,8 +61,8 @@
</thead>
<tbody>
{{range .AssetIntegrations}}
<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>
<tr data-nav-row data-nav-href="/assets/integrations?name={{.Name}}"{{if and $.AssetIntegration (eq $.AssetIntegration.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/assets/integrations?name={{.Name}}">{{.Name}}</a></td>
<td>{{.TypeLabel}}</td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td class="mono">{{if .AddressSummary}}{{.AddressSummary}}{{else}}-{{end}}</td>
@ -77,7 +77,7 @@
</div>
</div>
{{if .AssetIntegration}}
<form method="post" action="/ui/assets/integrations">
<form method="post" action="/assets/integrations">
<div class="card editor-state {{if .AssetIntegrationEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
@ -136,7 +136,7 @@
<div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
@ -183,17 +183,17 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
<h2 class="title-with-icon">{{icon "device"}}<span>视频源列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/video-sources/template" title="下载 CSV 模板">{{icon "apply"}}<span>模板下载</span></a>
<a class="btn secondary" href="/assets/video-sources/template" title="下载 CSV 模板">{{icon "apply"}}<span>模板下载</span></a>
<button class="btn secondary" type="button" onclick="document.getElementById('csv-import-input').click()">{{icon "apply"}}<span>CSV 导入</span></button>
<a class="btn secondary" href="/ui/assets/video-sources?new=1">{{icon "apply"}}<span>新增视频源</span></a>
<a class="btn secondary" href="/assets/video-sources?new=1">{{icon "apply"}}<span>新增视频源</span></a>
{{if .AssetVideoSource.Name}}
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/video-sources/{{.AssetVideoSource.Name}}/delete">
<a class="btn secondary" href="/assets/video-sources?name={{.AssetVideoSource.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/assets/video-sources/{{.AssetVideoSource.Name}}/delete">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
<form id="csv-import-form" method="post" action="/ui/assets/video-sources/import" enctype="multipart/form-data" style="display:none">
<form id="csv-import-form" method="post" action="/assets/video-sources/import" enctype="multipart/form-data" style="display:none">
<input type="file" id="csv-import-input" name="file" accept=".csv" onchange="this.form.submit()">
</form>
</div>
@ -212,8 +212,8 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</thead>
<tbody>
{{range .AssetVideoSources}}
<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>
<tr data-nav-row data-nav-href="/assets/video-sources?name={{.Name}}"{{if and $.AssetVideoSource (eq $.AssetVideoSource.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/assets/video-sources?name={{.Name}}">{{.Name}}</a></td>
<td>{{.SourceTypeLabel}}</td>
<td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td>
<td class="mono">{{if .Config.URL}}{{.Config.URL}}{{else}}-{{end}}</td>
@ -229,7 +229,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
</div>
{{if .AssetVideoSource}}
<form method="post" action="/ui/assets/video-sources">
<form method="post" action="/assets/video-sources">
<div class="card editor-state {{if .AssetVideoSourceEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
@ -272,7 +272,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="actions">
<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="/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
@ -326,7 +326,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetTemplates}}
<a class="asset-row asset-link" href="/ui/assets/templates/{{.Name}}">
<a class="asset-row asset-link" href="/assets/templates/{{.Name}}">
<span class="mono">{{.Name}}</span>
<span class="muted small">{{.NodeCount}}节点 / {{.EdgeCount}}连线</span>
</a>
@ -346,7 +346,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetVideoSources}}
<a class="asset-row asset-link" href="/ui/assets/video-sources?name={{.Name}}">
<a class="asset-row asset-link" href="/assets/video-sources?name={{.Name}}">
<span class="mono">{{.Name}}</span>
<span class="muted small">{{if .Area}}{{.Area}} / {{end}}{{if .Config.Resolution}}{{.Config.Resolution}}{{else}}未设置分辨率{{end}}</span>
</a>
@ -366,7 +366,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetIntegrations}}
<a class="asset-row asset-link" href="/ui/assets/integrations">
<a class="asset-row asset-link" href="/assets/integrations">
<span>{{.Name}}</span>
<span class="muted small">{{if .AddressSummary}}{{.AddressSummary}}{{else}}{{.TypeLabel}}{{end}}</span>
</a>
@ -386,7 +386,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetOverlays}}
<a class="asset-row asset-link" href="/ui/assets/overlays/{{.Name}}">
<a class="asset-row asset-link" href="/assets/overlays/{{.Name}}">
<span>{{.Name}}</span>
<span class="muted small">{{.OverrideTargetNum}} 个目标</span>
</a>

View File

@ -2,7 +2,7 @@
{{template "device_nav" .}}
<div class="card">
<h2>识别方案配置</h2>
<div class="muted small">节点:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
<div class="muted small">节点:<a class="mono" href="/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
</div>
<div class="card config-wizard">
@ -40,8 +40,8 @@
<h2 id="cfg-status-title">加载中</h2>
<div class="muted small" id="cfg-status-msg">正在读取配置结构和当前状态...</div>
<div class="actions" id="cfg-help" style="display:none;margin-top:10px">
<a href="/ui/devices">去设备列表重新扫描</a>
<a href="/ui/devices/{{.Device.DeviceID}}/config-ui">打开高级 JSON</a>
<a href="/devices">去设备列表重新扫描</a>
<a href="/devices/{{.Device.DeviceID}}/config-ui">打开高级 JSON</a>
</div>
</div>
@ -135,7 +135,7 @@
const idFromPath = (() => {
try{
const parts = String(location.pathname || '').split('/');
// /ui/devices/{id}/config-friendly
// /devices/{id}/config-friendly
if(parts.length > 3 && parts[1] === 'ui' && parts[2] === 'devices') return decodeURIComponent(parts[3] || '');
}catch(e){}
return '';
@ -412,8 +412,8 @@
setStatus('加载中', '正在读取配置结构和当前状态...');
let schema, state;
try{
const schemaUrl = `${apiBase}/config/ui/schema`;
const stateUrl = `${apiBase}/config/ui/state`;
const schemaUrl = `${apiBase}/config/schema`;
const stateUrl = `${apiBase}/config/state`;
const [s1, s2] = await Promise.all([fetch(schemaUrl), fetch(stateUrl)]);
const t1 = await s1.text();
const t2 = await s2.text();
@ -514,7 +514,7 @@
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/plan`, payload);
const out = await postJson(`${apiBase}/config/plan`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){
@ -530,7 +530,7 @@
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/apply`, payload);
const out = await postJson(`${apiBase}/config/apply`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){

View File

@ -8,7 +8,7 @@
</div>
</div>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview">
<form method="post" action="/devices/{{.Device.DeviceID}}/config-preview">
<div class="info-list">
<div><span>设备</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
<div><span>场景模板</span><strong class="mono">{{if .DeviceAssignment}}{{.DeviceAssignment.ProfileName}}{{else}}-{{end}}</strong></div>
@ -17,7 +17,7 @@
<div class="actions">
<button type="submit">生成预览</button>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
</div>
</form>
</div>

View File

@ -2,7 +2,7 @@
{{template "device_nav" .}}
<div class="card">
<h2>高级识别配置</h2>
<div class="muted small">节点:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
<div class="muted small">节点:<a class="mono" href="/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
</div>
<div class="card advanced">
@ -21,11 +21,11 @@
<div class="card">
<h2>编辑目标识别配置</h2>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-ui/plan">
<form method="post" action="/devices/{{.Device.DeviceID}}/config-ui/plan">
<textarea name="json" spellcheck="false">{{if .RawJSON}}{{.RawJSON}}{{else}}{"instances":[]}{{end}}</textarea>
<div class="actions" style="margin-top:10px">
<button type="submit">预览变更</button>
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-ui/apply">部署到设备</button>
<button type="submit" formaction="/devices/{{.Device.DeviceID}}/config-ui/apply">部署到设备</button>
</div>
</form>
</div>
@ -34,14 +34,14 @@
<h2>人脸库</h2>
<pre>{{.FaceGalleryJSON}}</pre>
<div class="row" style="margin-top:10px; gap:16px">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/upload" enctype="multipart/form-data" class="row">
<form method="post" action="/devices/{{.Device.DeviceID}}/face-gallery/upload" enctype="multipart/form-data" class="row">
<div>
<div class="muted small">人脸库文件</div>
<input type="file" name="file" />
</div>
<div style="align-self:end"><button type="submit">上传人脸库</button></div>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload" style="align-self:end">
<form method="post" action="/devices/{{.Device.DeviceID}}/face-gallery/reload" style="align-self:end">
<button type="submit">重新加载</button>
</form>
</div>

View File

@ -8,7 +8,7 @@
<div class="muted small">每次下发配置时自动保存的快照,可对比任意两个版本。</div>
</div>
<div class="actions">
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">← 返回设备</a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}">← 返回设备</a>
</div>
</div>
@ -89,8 +89,8 @@
document.getElementById("label-b").textContent = labelB;
Promise.all([
fetch("/ui/devices/{{.Device.DeviceID}}/config-versions/"+a).then(function(r){return r.json()}),
fetch("/ui/devices/{{.Device.DeviceID}}/config-versions/"+b).then(function(r){return r.json()})
fetch("/devices/{{.Device.DeviceID}}/config-versions/"+a).then(function(r){return r.json()}),
fetch("/devices/{{.Device.DeviceID}}/config-versions/"+b).then(function(r){return r.json()})
]).then(function(results){
var linesA = formatJSON(results[0].config_json).split("\n");
var linesB = formatJSON(results[1].config_json).split("\n");

View File

@ -21,7 +21,7 @@
<div class="tab-content" style="padding:0">
<div id="console-panel-device" class="card tab-pane active show" style="padding:16px;margin:0;border-top-left-radius:0">
<form id="console-form" method="post" action="/ui/console" style="display:contents">
<form id="console-form" method="post" action="/console" style="display:contents">
{{range $i, $cd := .ConsoleDevices}}
<div class="console-device-row">
@ -152,7 +152,7 @@
</div><!-- tab-content -->
</div><!-- card-tabs -->
<script src="/ui/assets/vendor/hls.min.js"></script>
<script src="/assets/vendor/hls.min.js"></script>
<script>
(function() {
// Tab switching
@ -193,14 +193,14 @@
var deviceIds = Object.keys(deviceChannels);
deviceIds.forEach(function(did) {
fetch('/ui/api/monitor/channels?device_id=' + encodeURIComponent(did))
fetch('/api/monitor/channels?device_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.channels) return;
data.channels.forEach(function(ch) {
var video = deviceChannels[did][ch.name];
if (!video) return;
var proxyUrl = '/ui/hls/' + encodeURIComponent(did) + '/hls/' + encodeURIComponent(ch.name) + '/index.m3u8';
var proxyUrl = '/hls/' + encodeURIComponent(did) + '/hls/' + encodeURIComponent(ch.name) + '/index.m3u8';
if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(proxyUrl);
@ -386,7 +386,7 @@
var msg = location.search.match(/[?&]msg=([^&]+)/);
if (m) {
document.getElementById('console-status').innerHTML = '⏳ 任务 ' + m[1].substring(0,8) + ' 执行中,' + (msg ? decodeURIComponent(msg[1]) : '页面将自动刷新...');
setTimeout(function(){ location.href = '/ui/console'; }, 8000);
setTimeout(function(){ location.href = '/console'; }, 8000);
} else if (msg) {
document.getElementById('console-status').innerHTML = decodeURIComponent(msg[1]);
setTimeout(function(){ document.getElementById('console-status').innerHTML = ''; }, 5000);

View File

@ -32,11 +32,11 @@
<div class="card" style="border-left:3px solid var(--red);min-height:200px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h3 class="title-with-icon" style="margin:0">{{icon "bell"}}<span>实时告警</span></h3>
<a class="btn ghost" href="/ui/alarms" style="font-size:11px">查看全部 →</a>
<a class="btn ghost" href="/alarms" style="font-size:11px">查看全部 →</a>
</div>
<div id="live-alarms" style="display:flex;flex-direction:column;gap:6px">
{{range .AlarmRecords}}
<div class="live-alarm-card" onclick="location.href='/ui/alarms'" style="padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);cursor:pointer;display:flex;align-items:center;gap:10px">
<div class="live-alarm-card" onclick="location.href='/alarms'" style="padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);cursor:pointer;display:flex;align-items:center;gap:10px">
<span class="pill {{severityClass .Severity}}" style="flex-shrink:0">{{severityLabel .Severity}}</span>
<span style="font-weight:500;font-size:12px">{{ruleLabel .RuleName}}</span>
<span class="muted small mono" style="margin-left:auto;flex-shrink:0">{{shortID .DeviceID}}</span>
@ -60,7 +60,7 @@
{{range .AttentionDevices}}
<div style="padding:8px 10px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)">
<span class="pill bad">离线</span>
<a class="mono" href="/ui/devices/{{.DeviceID}}" style="font-size:12px">{{.DeviceName}}</a>
<a class="mono" href="/devices/{{.DeviceID}}" style="font-size:12px">{{.DeviceName}}</a>
<span class="muted small" style="margin-left:auto">{{ago .LastSeenMs}}</span>
</div>
{{end}}
@ -76,7 +76,7 @@
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2 style="margin:0">最近任务</h2>
<a class="btn ghost" href="/ui/tasks" style="font-size:11px">更多 →</a>
<a class="btn ghost" href="/tasks" style="font-size:11px">更多 →</a>
</div>
<div class="table-wrap" style="margin-top:10px">
<table>
@ -85,7 +85,7 @@
{{range $i, $t := .Tasks}}
{{if lt $i 5}}
<tr>
<td><a class="mono" href="/ui/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
<td><a class="mono" href="/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
<td>{{taskActionLabel $t.Type}}</td>
<td><span class="{{taskStatusClass $t.Status}}">{{taskStatusLabel $t.Status}}</span></td>
<td class="muted small">{{$t.CreatedAt}}</td>
@ -114,7 +114,7 @@
var card = document.createElement("div");
card.className = "live-alarm-card";
card.style.cssText = "padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);cursor:pointer;display:flex;align-items:center;gap:10px;animation:fadeIn .3s ease";
card.onclick = function(){ location.href="/ui/alarms"; };
card.onclick = function(){ location.href="/alarms"; };
var sevMap = {critical:"严重",warning:"警告",info:"信息"};
var sevClassMap = {critical:"bad",warning:"warn",info:"ok"};

View File

@ -8,10 +8,10 @@
<div class="muted small">单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。</div>
</div>
<div class="actions">
<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-versions"><span>配置历史</span></a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/devices">返回设备列表</a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/config-preview"><span>预览设备分配</span></a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/config-versions"><span>配置历史</span></a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
</div>
</div>
<div class="summary-strip control-summary">
@ -109,17 +109,17 @@
<div><span>媒体服务</span><strong>{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}</strong></div>
</div>
<div class="actions service-actions-row">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_start" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="secondary">启动服务</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_restart" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="primary">重启服务</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_stop" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="danger">停止服务</button>
@ -149,11 +149,11 @@
</div>
{{end}}
<div class="actions scene-actions-row">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply">
<form method="post" action="/devices/{{.Device.DeviceID}}/plan-apply">
<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">
<a class="btn secondary" href="/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="secondary">回滚上一版</button>
@ -166,17 +166,17 @@
<div>
<h3 class="title-with-icon">{{icon "assets"}}<span>模型与资源</span></h3>
</div>
<a class="btn secondary" href="/ui/models">进入模型管理</a>
<a class="btn secondary" href="/models">进入模型管理</a>
</div>
<div class="info-list compact-list">
<div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div>
<div><span>人脸库</span><strong>通过资源管理与基础配置维护</strong></div>
</div>
<div class="actions">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload">
<form method="post" action="/devices/{{.Device.DeviceID}}/face-gallery/reload">
<button type="submit" class="secondary">重载人脸库</button>
</form>
<a class="btn ghost" href="/ui/assets">查看基础配置</a>
<a class="btn ghost" href="/assets">查看基础配置</a>
</div>
</section>
@ -187,9 +187,9 @@
</div>
</div>
<div class="actions stack">
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/graphs">运行指标</a>
<a class="btn ghost" href="/ui/api">高级调试</a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/graphs">运行指标</a>
<a class="btn ghost" href="/api">高级调试</a>
</div>
{{if .ResultTitle}}<div class="muted small" style="margin-top:12px">{{.ResultTitle}}</div>{{end}}
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}

View File

@ -1,7 +1,7 @@
{{define "device_add"}}
<div class="card">
<h2>新增设备</h2>
<form method="post" action="/ui/devices-add" style="margin-top:16px">
<form method="post" action="/devices-add" style="margin-top:16px">
<div class="row" style="gap:16px">
<div>
<div class="muted small">节点标识 <span class="muted">*</span></div>
@ -31,7 +31,7 @@
</div>
<div style="margin-top:16px">
<button type="submit">新增设备</button>
<a href="/ui/devices" style="margin-left:12px">返回设备列表</a>
<a href="/devices" style="margin-left:12px">返回设备列表</a>
</div>
</form>
</div>

View File

@ -34,7 +34,7 @@
</div>
</div>
<form method="post" action="/ui/device-assignments" id="assignment-board-form">
<form method="post" action="/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">

View File

@ -6,7 +6,7 @@
</div>
<div class="actions compact">
<a class="btn secondary" href="{{.SelectedDevicesURL}}#batch-config">返回设备列表</a>
<a class="btn secondary" href="/ui/devices">重新选择</a>
<a class="btn secondary" href="/devices">重新选择</a>
</div>
</div>
<div class="info-list">
@ -32,7 +32,7 @@
</div>
</div>
<form method="post" action="/ui/devices/batch-config">
<form method="post" action="/devices/batch-config">
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
<div class="actions">
<button type="submit" class="primary">创建下发任务</button>

View File

@ -5,7 +5,7 @@
<h2>单设备配置已并入设备详情</h2>
<div class="muted small">配置查看、服务控制和回滚,都已经收敛到设备详情工作台。</div>
</div>
<a class="btn ghost" href="/ui/devices">去设备列表</a>
<a class="btn ghost" href="/devices">去设备列表</a>
</div>
</div>
@ -13,7 +13,7 @@
<h2>设备入口</h2>
<div class="device-selector-grid" style="margin-top:12px">
{{range .Devices}}
<a class="selector-card" href="/ui/devices/{{.DeviceID}}#device-config">
<a class="selector-card" href="/devices/{{.DeviceID}}#device-config">
<div class="selector-head">
<div>
<div class="selector-title">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</div>

View File

@ -10,7 +10,7 @@
{{if .Device.Online}}<span class="pill ok">可操作</span>{{else}}<span class="pill bad">设备离线</span>{{end}}
</div>
<div class="actions" style="margin-bottom:12px">
<a class="btn secondary" href="/ui/device-config">返回设备选择</a>
<a class="btn secondary" href="/device-config">返回设备选择</a>
</div>
<div class="summary-strip control-summary">
@ -34,7 +34,7 @@
<div>
<h3 class="title-with-icon">{{icon "preview"}}<span>配置预览</span></h3>
</div>
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开预览器</span></a>
<a class="btn secondary" href="/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开预览器</span></a>
</div>
<div class="info-list compact-list">
<div><span>当前模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
@ -54,11 +54,11 @@
<div><span>上一份配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{else}}-{{end}}</strong></div>
</div>
<div class="actions">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
<form method="post" action="/devices/{{.Device.DeviceID}}/config-candidate/apply">
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="primary">应用待应用配置</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="secondary">回滚到上一份</button>
@ -73,17 +73,17 @@
</div>
</div>
<div class="actions stack">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_start" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="secondary">启动服务</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_restart" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="primary">重启服务</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_stop" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="danger">停止服务</button>

View File

@ -32,7 +32,7 @@
</div>
</div>
<form method="post" action="/ui/devices/batch-action">
<form method="post" action="/devices/batch-action">
{{if .SelectedDeviceIDs}}
<div class="batch-toolbar" id="batch-config">
<div>
@ -45,7 +45,7 @@
<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>
<a class="btn secondary" href="{{.BatchConfigURL}}">下发设备分配</a>
<a class="btn secondary" href="/ui/devices">清空选择</a>
<a class="btn secondary" href="/devices">清空选择</a>
</div>
</div>
{{end}}
@ -109,7 +109,7 @@
</td>
<td>
<div class="actions">
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
</div>
</td>
</tr>

View File

@ -79,15 +79,15 @@
{{range .Devices}}
<tr>
<td>
<a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
<a class="mono" href="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
<td>
<div class="actions">
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/graphs">运行指标</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/graphs">运行指标</a>
</div>
</td>
</tr>

View File

@ -22,9 +22,9 @@
<div class="person-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-top:12px">
{{range .FaceGalleryPersons}}
<div class="card person-card" data-name="{{.Name}}" style="padding:8px;max-width:200px">
<div class="photo-viewer" data-photos='[{{range $i,$p := .Photos}}{{if $i}},{{end}}"/ui/face-photo/{{$p}}"{{end}}]' style="width:100%;aspect-ratio:3/4;background:var(--surface-soft);border-radius:var(--radius);overflow:hidden;position:relative">
<div class="photo-viewer" data-photos='[{{range $i,$p := .Photos}}{{if $i}},{{end}}"/face-photo/{{$p}}"{{end}}]' style="width:100%;aspect-ratio:3/4;background:var(--surface-soft);border-radius:var(--radius);overflow:hidden;position:relative">
{{if .Photos}}
<img src="/ui/face-photo/{{index .Photos 0}}" style="width:100%;height:100%;object-fit:cover" id="img-{{.ID}}" />
<img src="/face-photo/{{index .Photos 0}}" style="width:100%;height:100%;object-fit:cover" id="img-{{.ID}}" />
{{if gt .PhotoCount 1}}
<span class="btn-icon ghost nav-btn" id="prev-{{.ID}}" style="position:absolute;left:2px;top:50%;transform:translateY(-50%);font-size:10px;opacity:0.7;display:none" onclick="navPhoto({{.ID}},-1)">&#9664;</span>
<span class="btn-icon ghost nav-btn" id="next-{{.ID}}" style="position:absolute;right:2px;top:50%;transform:translateY(-50%);font-size:10px;opacity:0.7" onclick="navPhoto({{.ID}},1)">&#9654;</span>
@ -36,8 +36,8 @@
<div style="margin-top:6px;display:flex;justify-content:space-between;align-items:center">
<div><span style="font-weight:500;font-size:13px">{{.Name}}</span> <span class="muted small">{{.PhotoCount}}张</span>{{$quality := index $.FaceGalleryQuality .Name}}{{if $quality}} <span class="pill ok" style="font-size:10px">{{$quality}}通过</span>{{end}}</div>
<div class="actions compact" style="gap:2px">
<span class="btn-icon ghost edit-btn" title="编辑" data-id="{{.ID}}" data-name="{{.Name}}" data-photos='[{{$person := .}}{{range $i,$p := .Photos}}{{if $i}},{{end}}{"p":"/ui/face-photo/{{$p}}","id":{{index $person.PhotoIDs $i}}}{{end}}]'>&#9998;</span>
<form method="post" action="/ui/face-gallery/delete" style="display:inline" onsubmit="return confirm('确定删除 {{.Name}}')">
<span class="btn-icon ghost edit-btn" title="编辑" data-id="{{.ID}}" data-name="{{.Name}}" data-photos='[{{$person := .}}{{range $i,$p := .Photos}}{{if $i}},{{end}}{"p":"/face-photo/{{$p}}","id":{{index $person.PhotoIDs $i}}}{{end}}]'>&#9998;</span>
<form method="post" action="/face-gallery/delete" style="display:inline" onsubmit="return confirm('确定删除 {{.Name}}')">
<input type="hidden" name="id" value="{{.ID}}">
<span class="btn-icon ghost" style="color:var(--danger-soft-text)" title="删除" onclick="this.closest('form').requestSubmit()">&#10005;</span>
</form>
@ -60,7 +60,7 @@
<div class="form-hint">将人脸库下发到 Edge 设备SHA256 比对显示同步状态。</div>
</div>
<div class="actions compact">
<form method="post" action="/ui/face-gallery/sync">
<form method="post" action="/face-gallery/sync">
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
<button class="btn" type="submit">同步到全部设备</button>
</form>
@ -90,7 +90,7 @@
</td>
{{end}}
<td>
<form method="post" action="/ui/face-gallery/sync" style="display:inline">
<form method="post" action="/face-gallery/sync" style="display:inline">
<input type="hidden" name="device_id" value="{{.DeviceID}}">
<button class="btn ghost small" type="submit">同步</button>
</form>
@ -118,16 +118,16 @@ function navPhoto(id,dir){
document.getElementById('next-'+id).style.display=d.idx===d.photos.length-1?'none':'';
}
function showImport(){
document.getElementById('modal-body').innerHTML='<h3>批量导入</h3><div class="form-hint" style="margin-bottom:12px">选择按姓名分目录的照片根目录</div><form method="post" action="/ui/face-gallery/import" enctype="multipart/form-data"><label class="full" style="margin-bottom:12px;display:block"><span>选择照片目录</span><input type="file" name="photos" webkitdirectory multiple required /></label><div class="actions"><button class="btn" type="submit">导入</button></div></form>';
document.getElementById('modal-body').innerHTML='<h3>批量导入</h3><div class="form-hint" style="margin-bottom:12px">选择按姓名分目录的照片根目录</div><form method="post" action="/face-gallery/import" enctype="multipart/form-data"><label class="full" style="margin-bottom:12px;display:block"><span>选择照片目录</span><input type="file" name="photos" webkitdirectory multiple required /></label><div class="actions"><button class="btn" type="submit">导入</button></div></form>';
document.getElementById('modal').style.display='flex';
}
function showAdd(){
document.getElementById('modal-body').innerHTML='<h3>新增人员</h3><form method="post" action="/ui/face-gallery/add" enctype="multipart/form-data"><div class="field-grid"><label><span>姓名</span><input type="text" name="name" required /></label><label><span>照片</span><input type="file" name="photo" accept="image/*" required /></label></div><div class="actions"><button class="btn" type="submit">新增</button></div></form>';
document.getElementById('modal-body').innerHTML='<h3>新增人员</h3><form method="post" action="/face-gallery/add" enctype="multipart/form-data"><div class="field-grid"><label><span>姓名</span><input type="text" name="name" required /></label><label><span>照片</span><input type="file" name="photo" accept="image/*" required /></label></div><div class="actions"><button class="btn" type="submit">新增</button></div></form>';
document.getElementById('modal').style.display='flex';
}
function showEdit(id,name,photos){
var h='<h3>编辑人员</h3>';
h+='<form method="post" action="/ui/face-gallery/rename" style="margin-bottom:12px;display:flex;gap:8px;align-items:flex-end">';
h+='<form method="post" action="/face-gallery/rename" style="margin-bottom:12px;display:flex;gap:8px;align-items:flex-end">';
h+='<input type="hidden" name="id" value="'+id+'"><label style="flex:1"><span class="small muted">姓名</span><input type="text" name="name" value="'+esc(name)+'" required /></label>';
h+='<button class="btn" type="submit">保存</button></form>';
h+='<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px">';
@ -137,7 +137,7 @@ function showEdit(id,name,photos){
h+='</div>';
});
h+='</div>';
h+='<form method="post" action="/ui/face-gallery/add-photo" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:flex-end">';
h+='<form method="post" action="/face-gallery/add-photo" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:flex-end">';
h+='<input type="hidden" name="id" value="'+id+'"><label style="flex:1"><span class="small muted">添加照片</span><input type="file" name="photo" accept="image/*" /></label>';
h+='<button class="btn ghost" type="submit">上传</button></form>';
document.getElementById('modal-body').innerHTML=h;
@ -145,7 +145,7 @@ function showEdit(id,name,photos){
}
function delPhoto(id){
if(!confirm('确认删除这张照片?'))return;
var f=document.createElement('form');f.method='post';f.action='/ui/face-gallery/delete-photo';
var f=document.createElement('form');f.method='post';f.action='/face-gallery/delete-photo';
var i=document.createElement('input');i.type='hidden';i.name='id';i.value=id;f.appendChild(i);
document.body.appendChild(f);f.submit();
}

View File

@ -5,9 +5,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{.Title}}</title>
<link rel="stylesheet" href="/ui/assets/vendor/tabler.min.css" />
<link rel="stylesheet" href="/ui/assets/style.css?v=ia12" />
<link rel="stylesheet" href="/ui/assets/graph_editor.css?v=20260720-ia01" />
<link rel="stylesheet" href="/assets/vendor/tabler.min.css" />
<link rel="stylesheet" href="/assets/style.css?v=ia12" />
<link rel="stylesheet" href="/assets/graph_editor.css?v=20260720-ia01" />
</head>
<body data-theme="blue-dark">
<div class="app-shell">
@ -19,35 +19,35 @@
</div>
</div>
<nav class="side-nav" aria-label="主导航">
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>首页</span></a>
<a href="/ui/alarms"><span class="nav-icon">{{icon "bell"}}</span><span>告警中心</span></a>
<a href="/ui/monitor"><span class="nav-icon">{{icon "preview"}}</span><span>视频监控</span></a>
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备管理</span></a>
<a href="/ui/console"><span class="nav-icon">{{icon "apply"}}</span><span>运行看板</span></a>
<a href="/ui/face-gallery"><span class="nav-icon">{{icon "profile"}}</span><span>人脸库</span></a>
<a href="/ui/wizard"><span class="nav-icon">{{icon "control"}}</span><span>部署向导</span></a>
<a href="/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>首页</span></a>
<a href="/alarms"><span class="nav-icon">{{icon "bell"}}</span><span>告警中心</span></a>
<a href="/monitor"><span class="nav-icon">{{icon "preview"}}</span><span>视频监控</span></a>
<a href="/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备管理</span></a>
<a href="/console"><span class="nav-icon">{{icon "apply"}}</span><span>运行看板</span></a>
<a href="/face-gallery"><span class="nav-icon">{{icon "profile"}}</span><span>人脸库</span></a>
<a href="/wizard"><span class="nav-icon">{{icon "control"}}</span><span>部署向导</span></a>
<details class="nav-group" id="system-nav-group">
<summary>
<span class="nav-icon">{{icon "system"}}</span>
<span>系统设置</span>
</summary>
<div class="nav-group-items">
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
<a class="nav-subitem" href="/ui/tasks"><span class="nav-icon nav-subicon">{{icon "task"}}</span><span>任务中心</span></a>
<a class="nav-subitem" href="/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
<a class="nav-subitem" href="/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
<a class="nav-subitem" href="/tasks"><span class="nav-icon nav-subicon">{{icon "task"}}</span><span>任务中心</span></a>
<details class="nav-subgroup expert-only" id="detection-config-group">
<summary>
<span class="nav-icon nav-subicon">{{icon "config"}}</span>
<span>检测配置</span>
</summary>
<div class="nav-subgroup-items">
<a class="nav-subitem" href="/ui/scene-templates"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>场景模板</span></a>
<a class="nav-subitem" href="/ui/recognition-units"><span class="nav-icon nav-subicon">{{icon "device"}}</span><span>检测通道</span></a>
<a class="nav-subitem" href="/ui/device-assignments"><span class="nav-icon nav-subicon">{{icon "apply"}}</span><span>通道部署</span></a>
<a class="nav-subitem" href="/scene-templates"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>场景模板</span></a>
<a class="nav-subitem" href="/recognition-units"><span class="nav-icon nav-subicon">{{icon "device"}}</span><span>检测通道</span></a>
<a class="nav-subitem" href="/device-assignments"><span class="nav-icon nav-subicon">{{icon "apply"}}</span><span>通道部署</span></a>
</div>
</details>
<a class="nav-subitem expert-only" href="/ui/assets"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资产管理</span></a>
<a class="nav-subitem expert-only" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
<a class="nav-subitem expert-only" href="/assets"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资产管理</span></a>
<a class="nav-subitem expert-only" href="/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
</div>
</details>
</nav>
@ -77,12 +77,12 @@
<button type="button" data-theme-option="graphite-gold">石墨金色</button>
</div>
</div>
<a class="topbar-icon-btn" href="/ui/alarms" aria-label="告警中心" title="告警中心" id="topbar-alarm-btn">
<a class="topbar-icon-btn" href="/alarms" aria-label="告警中心" title="告警中心" id="topbar-alarm-btn">
{{icon "bell"}}
<span class="topbar-dot" id="alarm-dot" {{if eq .UnacknowledgedAlarmCount 0}}hidden{{end}} aria-hidden="true"></span>
<span class="topbar-badge" id="alarm-badge" {{if eq .UnacknowledgedAlarmCount 0}}hidden{{end}}>{{if gt .UnacknowledgedAlarmCount 99}}99+{{else}}{{.UnacknowledgedAlarmCount}}{{end}}</span>
</a>
<a class="topbar-icon-btn" href="/ui/system" aria-label="系统" title="系统">
<a class="topbar-icon-btn" href="/system" aria-label="系统" title="系统">
{{icon "system"}}
</a>
</div>
@ -95,7 +95,7 @@
</div>
</div>
<div id="toast-container" class="toast-container"></div>
<script src="/ui/assets/vendor/tabler.min.js"></script>
<script src="/assets/vendor/tabler.min.js"></script>
<script>
(function () {
const themeKey = "3588-admin-theme";
@ -340,16 +340,16 @@
if (systemNavGroup) {
const path = window.location.pathname || "";
if (
path === "/ui/system" ||
path === "/ui/diagnostics" ||
path === "/ui/tasks" ||
path === "/ui/scene-templates" ||
path === "/ui/recognition-units" ||
path === "/ui/device-assignments" ||
path === "/ui/assets" ||
path === "/ui/models" ||
path === "/ui/audit" ||
path === "/ui/api"
path === "/system" ||
path === "/diagnostics" ||
path === "/tasks" ||
path === "/scene-templates" ||
path === "/recognition-units" ||
path === "/device-assignments" ||
path === "/assets" ||
path === "/models" ||
path === "/audit" ||
path === "/api"
) {
systemNavGroup.open = true;
}
@ -358,9 +358,9 @@
if (detectionGroup) {
const path = window.location.pathname || "";
if (
path === "/ui/scene-templates" ||
path === "/ui/recognition-units" ||
path === "/ui/device-assignments"
path === "/scene-templates" ||
path === "/recognition-units" ||
path === "/device-assignments"
) {
detectionGroup.open = true;
}

View File

@ -5,7 +5,7 @@
<h2>日志分析</h2>
<div class="muted small">按设备查看诊断日志、运行指标,并进入高级调试。</div>
</div>
<a class="btn ghost" href="/ui/api">高级调试</a>
<a class="btn ghost" href="/api">高级调试</a>
</div>
<div class="model-summary">
<div class="summary-item"><div class="summary-label">日志筛选</div><div class="summary-value">设备/数量</div><div class="summary-hint">首版按设备和 limit 查看</div></div>
@ -23,13 +23,13 @@
<tbody>
{{range .Devices}}
<tr>
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
<td><a class="mono" href="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
<td>
<div class="actions">
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/graphs">运行指标</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/graphs">运行指标</a>
</div>
</td>
</tr>

View File

@ -8,7 +8,7 @@
</div>
<div class="actions compact">
<button class="btn secondary" type="button" onclick="showUpload()">上传新模型</button>
<form method="post" action="/ui/models/sync">
<form method="post" action="/models/sync">
{{if $board}}{{range $board.Rows}}{{if .NeedsSync}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}{{end}}
<input type="hidden" name="action" value="model_sync_all">
<button class="btn" type="submit">更新全部模型</button>
@ -132,7 +132,7 @@
function showUpload(){
document.getElementById('modal-body').innerHTML=
'<h3>上传新模型</h3>'+
'<form method="post" action="/ui/models/upload" enctype="multipart/form-data">'+
'<form method="post" action="/models/upload" enctype="multipart/form-data">'+
'<div class="field-grid">'+
'<label><span>模型名称</span><input type="text" name="name" required placeholder="如 face_det_v2" /></label>'+
'<label><span>模型类型</span><select name="model_type">'+

View File

@ -15,7 +15,7 @@
<div class="card muted" style="text-align:center;grid-column:1/-1">加载中…</div>
</div>
<script src="/ui/assets/vendor/hls.min.js"></script>
<script src="/assets/vendor/hls.min.js"></script>
<script>
function loadAll() {
var wall = document.getElementById("video-wall");
@ -32,7 +32,7 @@ function loadAll() {
}
var promises = devices.map(function(dev) {
return fetch('/ui/api/monitor/channels?device_id=' + encodeURIComponent(dev.id))
return fetch('/api/monitor/channels?device_id=' + encodeURIComponent(dev.id))
.then(function(r) { return r.json(); })
.then(function(data) { return (data.channels||[]).map(function(ch) { ch._dev = dev.name; ch._devId = dev.id; return ch; }); })
.catch(function() { return []; });
@ -49,7 +49,7 @@ function loadAll() {
wall.style.gridTemplateColumns = "repeat(" + cols + ",minmax(0,1fr))";
var html = "";
all.forEach(function(ch, i) {
var proxyUrl = '/ui/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
html += '<div class="card" style="padding:8px">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
html += '<span style="font-size:12px;font-weight:500">' + esc(ch._dev) + ' · ' + esc(ch.name) + '</span>';
@ -65,7 +65,7 @@ function loadAll() {
// Initialize HLS players
all.forEach(function(ch, i) {
if (!ch.hls_url || !ch._devId) return;
var proxyUrl = '/ui/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var video = document.getElementById('v' + i);
if (!video) return;
if (Hls.isSupported()) {

View File

@ -5,7 +5,7 @@
<h2>识别配置</h2>
<div class="muted small">管理识别方案、视频通道、模板参数和推理配置。</div>
</div>
<a class="btn ghost" href="/ui/templates">模板库</a>
<a class="btn ghost" href="/templates">模板库</a>
</div>
<div class="model-summary">
<div class="summary-item"><div class="summary-label">识别方案</div><div class="summary-value">按设备配置</div><div class="summary-hint">进入单台设备维护方案</div></div>
@ -23,13 +23,13 @@
<tbody>
{{range .Devices}}
<tr>
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
<td><a class="mono" href="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
<td>
<div class="actions">
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-friendly">编辑视频通道</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-ui">高级 JSON</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/config-friendly">编辑视频通道</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/config-ui">高级 JSON</a>
</div>
</td>
</tr>

View File

@ -5,10 +5,10 @@
<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>
<a class="btn secondary" href="/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('确认删除这个检测通道吗?');">
<a class="btn secondary" href="/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
<form method="post" action="/recognition-units/delete" onsubmit="return confirm('确认删除这个检测通道吗?');">
<input type="hidden" name="ref" value="{{.SelectedRecognitionUnit}}" />
<button class="btn secondary" type="submit">删除</button>
</form>
@ -27,8 +27,8 @@
</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>
<tr data-nav-row data-nav-href="/recognition-units?ref={{.Ref}}" {{if eq $.SelectedRecognitionUnit .Ref}}class="selected"{{end}}>
<td><a class="mono" href="/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>
@ -42,7 +42,7 @@
</div>
{{if .RecognitionUnit}}
<form method="post" action="/ui/recognition-units">
<form method="post" action="/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">
@ -61,7 +61,7 @@
<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>
<a class="btn secondary" href="/recognition-units{{if .SelectedRecognitionUnit}}?ref={{.SelectedRecognitionUnit}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
</div>
</div>

View File

@ -7,7 +7,7 @@
<div class="form-hint">统一维护标准资源,设备侧通过任务同步。当前支持人脸库等资源类型。</div>
</div>
<div class="actions compact">
<form method="post" action="/ui/resources/sync">
<form method="post" action="/resources/sync">
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
<input type="hidden" name="action" value="resource_sync_all">
<button class="btn" type="submit">同步全部资源</button>

View File

@ -5,10 +5,10 @@
<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>
<a class="btn secondary" href="/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('确认删除这个场景吗?');">
<a class="btn secondary" href="/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景吗?');">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
@ -26,8 +26,8 @@
</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>
<tr data-nav-row data-nav-href="/scene-templates?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
<td><a class="mono" href="/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>
@ -59,9 +59,9 @@
<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>
<a class="btn secondary" href="/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>
<button type="button" class="btn secondary js-export-json" data-export-url="/scene-templates/{{.AssetProfileEditor.Name}}/export" data-default-filename="{{.AssetProfileEditor.Name}}.json">{{icon "apply"}}<span>导出为 JSON</span></button>
</div>
</div>

View File

@ -7,12 +7,12 @@
<div><span>数据库文件</span><strong class="mono">{{if .DBPath}}{{.DBPath}}{{else}}未配置{{end}}</strong></div>
</div>
<div class="actions" style="margin-top:12px">
<button type="button" class="btn ghost js-export-db" data-export-url="/ui/system/db-backup" data-default-filename="app.db">备份数据库</button>
<button type="button" class="btn ghost js-export-db" data-export-url="/system/db-backup" data-default-filename="app.db">备份数据库</button>
</div>
</div>
<div class="card">
<h2 class="title-with-icon">{{icon "apply"}}<span>数据恢复</span></h2>
<form method="post" action="/ui/system/db-restore" enctype="multipart/form-data">
<form method="post" action="/system/db-restore" enctype="multipart/form-data">
<div class="field-grid">
<label class="full"><span>选择备份文件</span><input type="file" name="file" accept=".db,application/octet-stream" required /></label>
</div>

View File

@ -1,7 +1,7 @@
{{define "task"}}
<div class="card">
<div class="actions">
<a class="btn ghost" href="/ui/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
<a class="btn ghost" href="/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
</div>
</div>

View File

@ -10,7 +10,7 @@
{{range .Tasks}}
<tr>
<td>
<div><a class="mono" href="/ui/tasks/{{.ID}}">{{shortID .ID}}</a></div>
<div><a class="mono" href="/tasks/{{.ID}}">{{shortID .ID}}</a></div>
<div class="muted small">{{taskActionLabel .Type}}</div>
</td>
<td>

View File

@ -1,6 +1,6 @@
{{define "template"}}
<div class="card">
<div><a href="/ui/templates">返回识别配置模板</a></div>
<div><a href="/templates">返回识别配置模板</a></div>
</div>
<div class="card">

View File

@ -9,7 +9,7 @@
<tbody>
{{range .Templates}}
<tr>
<td><a class="mono" href="/ui/templates/{{.Name}}">{{.Name}}</a></td>
<td><a class="mono" href="/templates/{{.Name}}">{{.Name}}</a></td>
<td><pre style="margin:0">{{json .Schema}}</pre></td>
</tr>
{{end}}

View File

@ -196,7 +196,7 @@
renderAssignedSources();
renderFeatureList();
fetch("/ui/wizard/device-info?device_id=" + encodeURIComponent(selectedDevice))
fetch("/wizard/device-info?device_id=" + encodeURIComponent(selectedDevice))
.then(function(r){return r.json()})
.then(function(info){
var channels = info.channels || [];
@ -309,7 +309,7 @@
var formData = new URLSearchParams();
formData.append("name", name);
formData.append("url", url);
fetch("/ui/wizard/create-source", {method:"POST",body:formData,headers:{"Content-Type":"application/x-www-form-urlencoded"}})
fetch("/wizard/create-source", {method:"POST",body:formData,headers:{"Content-Type":"application/x-www-form-urlencoded"}})
.then(function(r){return r.json()})
.then(function(data){
step2Added.push({name: name, url: url});
@ -344,7 +344,7 @@
var btn = this;
btn.disabled = true;
btn.textContent = "下发中...";
fetch("/ui/wizard/apply", {
fetch("/wizard/apply", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({device_id:selectedDevice, features:step2Features, source_names:sourceNames})
@ -361,7 +361,7 @@
resultDiv.innerHTML = '<div class="card" style="border-left:3px solid var(--green)"><div class="metric-label">'+
'<span class="status-dot ok"></span><strong>部署成功</strong></div>'+
'<div class="muted small" style="margin-top:6px">模板:'+result.template_name+' | 检测通道:'+result.units_created+' 个</div>'+
'<a href="/ui/alarms" class="btn primary" style="margin-top:8px;font-size:11px">查看告警</a></div>';
'<a href="/alarms" class="btn primary" style="margin-top:8px;font-size:11px">查看告警</a></div>';
btn.style.display = "none";
currentStep = 3;
document.querySelectorAll(".wizard-step").forEach(function(el){el.classList.add("done")});

Binary file not shown.