feat: rewire primary UI navigation

This commit is contained in:
tian 2026-04-27 10:32:45 +08:00
parent ca004e07a7
commit 661b0b0afd
5 changed files with 155 additions and 49 deletions

View File

@ -299,7 +299,9 @@ func tablerIconSVG(name string) string {
"preview": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5c-7.633 0 -9 7 -9 7s1.367 7 9 7s9 -7 9 -7s-1.367 -7 -9 -7"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/></svg>`,
"apply": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 12l3 3l7 -7"/><path d="M21 12c0 4.97 -4.03 9 -9 9s-9 -4.03 -9 -9s4.03 -9 9 -9"/></svg>`,
"service": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13h5"/><path d="M12 16v5"/><path d="M16 4l0 5"/><path d="M20 8h-5"/><path d="M4 9h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/><path d="M9 4h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 15h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M9 13h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 4h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/></svg>`,
"task": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8"/><path d="M20 12v7a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1h9"/></svg>`,
"result": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
"logs": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
"meta": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M7 6h10"/><path d="M5 8v9"/><path d="M7 19h10"/><path d="M17 8v9"/></svg>`,
"template": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/></svg>`,
"profile": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M12 3c2.755 0 5.455 .638 7.407 1.758a2 2 0 0 1 1.002 1.737v11.01a2 2 0 0 1 -1.002 1.737c-1.952 1.12 -4.652 1.758 -7.407 1.758s-5.455 -.638 -7.407 -1.758a2 2 0 0 1 -1.002 -1.737v-11.01a2 2 0 0 1 1.002 -1.737c1.952 -1.12 4.652 -1.758 7.407 -1.758z"/></svg>`,
@ -335,12 +337,10 @@ func (u *UI) Routes() (chi.Router, error) {
}))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/devices", http.StatusFound)
http.Redirect(w, r, "/ui/dashboard", http.StatusFound)
})
r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/devices", http.StatusFound)
})
r.Get("/dashboard", u.pageDashboard)
r.Get("/devices", u.pageDevices)
r.Get("/devices/{id}/control", u.pageDeviceControl)
r.Get("/assets", u.pageAssets)
@ -354,6 +354,7 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/audit", u.pageAudit)
r.Get("/system", u.pageSystem)
r.Get("/device-config", u.pageDeviceConfig)
r.Get("/device-config/{id}", u.pageDeviceConfigDetail)
r.Get("/devices-add", u.pageDeviceAdd)
r.Post("/devices-add", u.actionDeviceAdd)
r.Post("/devices/batch-action", u.actionDevicesBatchAction)
@ -442,7 +443,31 @@ func (u *UI) ensureDevicesLoaded() {
}
func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/devices", http.StatusFound)
data := u.deviceOverviewPageData(r, nil, "")
if u.tasks != nil {
for _, task := range u.tasks.ListTasks() {
switch task.Status {
case models.TaskRunning:
data.RunningTaskCount++
case models.TaskFailed:
data.FailedTaskCount++
case models.TaskSuccess:
data.SuccessTaskCount++
}
}
}
data.Title = "总览"
data.Tasks = nil
if u.tasks != nil {
data.Tasks = u.tasks.ListTasks()
}
data.AttentionDevices = nil
for _, dev := range data.Devices {
if dev != nil && !dev.Online {
data.AttentionDevices = append(data.AttentionDevices, dev)
}
}
u.render(w, r, "dashboard", data)
}
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
@ -454,7 +479,21 @@ func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/assets", http.StatusFound)
u.ensureDevicesLoaded()
u.render(w, r, "device_config", PageData{
Title: "配置管理",
Devices: u.registry.GetDevices(),
})
}
func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
u.render(w, r, "device_control", u.deviceConfigWorkspacePageData(dev))
}
func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) {
@ -647,12 +686,7 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
u.render(w, r, "device_control", u.deviceControlPageData(dev))
http.Redirect(w, r, "/ui/device-config/"+url.PathEscape(id), http.StatusFound)
}
func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
@ -692,8 +726,8 @@ func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil)
msg := fmt.Sprintf("%s %s -> %d", method, path, code)
returnTo := strings.TrimSpace(r.FormValue("return_to"))
if returnTo == "control" {
data := u.deviceControlPageData(dev)
if returnTo == "control" || returnTo == "config" {
data := u.deviceConfigWorkspacePageData(dev)
data.Message = msg
data.RawText = string(body)
data.ResultTitle = "执行结果摘要"
@ -1016,7 +1050,7 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "diagnostics", PageData{Title: "日志分析", Devices: u.registry.GetDevices()})
u.render(w, r, "diagnostics", PageData{Title: "诊断", Devices: u.registry.GetDevices()})
}
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
@ -1024,7 +1058,7 @@ func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/audit", http.StatusFound)
http.Redirect(w, r, "/ui/diagnostics", http.StatusFound)
}
func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) {
@ -1038,6 +1072,19 @@ func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) {
func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("templates")
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
if item, err := u.preview.GetTemplateAsset(name); err == nil {
data.AssetTemplate = item
} else if data.Error == "" {
data.Error = err.Error()
}
} else if len(data.AssetTemplates) > 0 {
if item, err := u.preview.GetTemplateAsset(data.AssetTemplates[0].Name); err == nil {
data.AssetTemplate = item
} else if data.Error == "" {
data.Error = err.Error()
}
}
u.render(w, r, "asset_templates", data)
}
@ -1049,13 +1096,28 @@ func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
data.Title = "模板详情"
data.AssetTemplate = item
u.render(w, r, "asset_template", data)
u.render(w, r, "asset_templates", data)
}
func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("profiles")
selected := strings.TrimSpace(r.URL.Query().Get("name"))
if selected == "" && len(data.AssetProfiles) > 0 {
selected = data.AssetProfiles[0].Name
}
if selected != "" {
editor, err := u.preview.GetProfileEditor(selected)
if err == nil {
data.AssetProfileEditor = editor
data.SelectedProfile = editor.Name
if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
data.SelectedTemplate = editor.Instances[0].Template
}
} else if data.Error == "" {
data.Error = err.Error()
}
}
u.render(w, r, "asset_profiles", data)
}
@ -1066,8 +1128,8 @@ func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
data.Title = "业务配置编辑"
u.render(w, r, "asset_profile", data)
data.Title = "识别配置"
u.render(w, r, "asset_profiles", data)
}
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
@ -1079,7 +1141,7 @@ func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
}
if err := u.preview.SaveProfileEditor(editor); err != nil {
data.Error = err.Error()
u.render(w, r, "asset_profile", data)
u.render(w, r, "asset_profiles", data)
return
}
if editor.Name != name {
@ -1087,11 +1149,24 @@ func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
} else {
data.Message = "业务配置已保存"
}
u.render(w, r, "asset_profile", data)
u.render(w, r, "asset_profiles", data)
}
func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("overlays")
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
if item, err := u.preview.GetOverlayAsset(name); err == nil {
data.AssetOverlay = item
} else if data.Error == "" {
data.Error = err.Error()
}
} else if len(data.AssetOverlays) > 0 {
if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil {
data.AssetOverlay = item
} else if data.Error == "" {
data.Error = err.Error()
}
}
u.render(w, r, "asset_overlays", data)
}
@ -1103,14 +1178,13 @@ func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
data.Title = "配置叠加项详情"
data.AssetOverlay = item
u.render(w, r, "asset_overlay", data)
u.render(w, r, "asset_overlays", data)
}
func (u *UI) assetPageData(tab string) PageData {
data := PageData{
Title: "配置资产",
Title: "识别配置",
AssetTab: tab,
}
if u.preview == nil {
@ -1388,8 +1462,8 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req
}
returnTo := strings.TrimSpace(r.FormValue("return_to"))
var data PageData
if returnTo == "control" {
data = u.deviceControlPageData(dev)
if returnTo == "control" || returnTo == "config" {
data = u.deviceConfigWorkspacePageData(dev)
} else {
data = u.configPreviewPageData(dev)
}
@ -1401,11 +1475,7 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`))
data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code)
data.RawText = prettyJSON(body)
if returnTo == "control" {
data.ResultTitle = "应用候选配置结果"
} else {
data.ResultTitle = "应用候选配置结果"
}
data.ResultTitle = "应用候选配置结果"
if err != nil {
data.Error = err.Error()
} else {
@ -1417,7 +1487,7 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req
data.ConfigStatusErr = ""
}
}
if returnTo == "control" {
if returnTo == "control" || returnTo == "config" {
u.render(w, r, "device_control", data)
return
}
@ -1460,6 +1530,12 @@ func (u *UI) deviceControlPageData(dev *models.Device) PageData {
return data
}
func (u *UI) deviceConfigWorkspacePageData(dev *models.Device) PageData {
data := u.deviceControlPageData(dev)
data.Title = "配置管理"
return data
}
func (u *UI) listTemplatesSafe() ([]service.Template, error) {
if u.templates == nil {
return nil, nil

View File

@ -2,17 +2,17 @@
<div class="card">
<div class="section-title">
<div>
<h2>视觉节点态势</h2>
<div class="muted small">面向多台视觉识别边缘节点的运行总览</div>
<h2>全局 KPI</h2>
<div class="muted small">只看 fleet 级运行态势、最近任务和需要关注的异常设备</div>
</div>
<div class="actions">
<a class="btn ghost" href="/ui/devices">设备列表</a>
<a class="btn ghost" href="/ui/device-config">配置管理</a>
<a class="btn ghost" href="/ui/tasks">任务</a>
</div>
</div>
<div class="stats">
<div class="stat"><div class="k">设备总数</div><div class="v">{{.DeviceCount}}</div><div class="hint">已纳管的边缘设备</div></div>
<div class="stat"><div class="k">在线节点</div><div class="v">{{.OnlineCount}}</div><div class="hint">可执行识别任务</div></div>
<div class="stat"><div class="k">在线</div><div class="v">{{.OnlineCount}} / {{.DeviceCount}}</div><div class="hint">在线设备占比</div></div>
<div class="stat"><div class="k">离线节点</div><div class="v">{{.OfflineCount}}</div><div class="hint">需要检查网络或设备服务</div></div>
<div class="stat"><div class="k">执行中任务</div><div class="v">{{.RunningTaskCount}}</div><div class="hint">正在下发或控制节点</div></div>
</div>
@ -33,9 +33,9 @@
<div class="muted small">按常见现场操作顺序进入对应页面。</div>
<div class="actions" style="margin-top:8px">
<a class="btn ghost" href="/ui/devices">查看设备列表</a>
<a class="btn ghost" href="/ui/device-config">配置管理</a>
<a class="btn ghost" href="/ui/recognition">配置识别方案</a>
<a class="btn ghost" href="/ui/logs">日志分析</a>
<a class="btn ghost" href="/ui/tasks">批量任务</a>
<a class="btn ghost" href="/ui/diagnostics">诊断</a>
</div>
</div>
</div>
@ -59,7 +59,7 @@
</div>
<div class="card">
<h2>需要关注的节点</h2>
<h2>异常设备</h2>
<div class="table-wrap" style="margin-top:10px">
<table>
<thead>

View File

@ -3,12 +3,18 @@
<div class="section-title">
<div>
<h2>诊断工作台</h2>
<div class="muted small">按设备进入诊断日志、运行指标和高级接口排查</div>
<div class="muted small">诊断域集中承载日志分析、系统状态、审计记录和高级排障入口</div>
</div>
<a class="btn ghost" href="/ui/api">高级调试</a>
</div>
</div>
<div class="stats">
<div class="stat"><div class="k">日志分析</div><div class="v">Logs</div><div class="hint">按设备查看诊断日志和运行指标</div></div>
<div class="stat"><div class="k">系统状态</div><div class="v">System</div><div class="hint">查看发现、健康和接口状态</div></div>
<div class="stat"><div class="k">审计记录</div><div class="v">Audit</div><div class="hint">追踪任务与关键操作</div></div>
</div>
<div class="card">
<h2>日志分析</h2>
<div class="table-wrap" style="margin-top:10px">
@ -40,4 +46,21 @@
</table>
</div>
</div>
<div class="row">
<div class="card">
<h2>系统状态</h2>
<div class="muted small">查看平台健康、接口和发现能力。</div>
<div class="actions" style="margin-top:12px">
<a class="btn ghost" href="/ui/system">进入系统状态</a>
</div>
</div>
<div class="card">
<h2>审计记录</h2>
<div class="muted small">统一查看任务执行和关键操作留痕。</div>
<div class="actions" style="margin-top:12px">
<a class="btn ghost" href="/ui/audit">进入审计记录</a>
</div>
</div>
</div>
{{end}}

View File

@ -20,13 +20,14 @@
</div>
<nav class="side-nav" aria-label="主导航">
<div class="nav-section">主模块</div>
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>配置管理</span></a>
<a href="/ui/system"><span class="nav-icon">{{icon "system"}}</span><span>系统状态</span></a>
<a href="/ui/audit"><span class="nav-icon">{{icon "audit"}}</span><span>操作审计</span></a>
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>识别配置</span></a>
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a>
<a href="/ui/diagnostics"><span class="nav-icon">{{icon "logs"}}</span><span>诊断</span></a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-note">统一设备管理、配置管理与操作留痕</div>
<div class="sidebar-note">总览 fleet 状态,进入设备工作台,统一查看任务与诊断</div>
</div>
</aside>
<div class="workspace">

View File

@ -1,6 +1,7 @@
{{define "tasks"}}
<div class="card">
<h2>创建任务</h2>
<h2>批量操作</h2>
<div class="muted small">任务域负责批量下发、批量重启、批量回滚和执行历史。</div>
<form method="post" action="/ui/tasks" style="margin-top:10px">
<div class="row">
<div>
@ -30,7 +31,7 @@
</div>
<div class="card">
<h2>任务列表</h2>
<h2>执行历史</h2>
<div class="table-wrap" style="margin-top:10px">
<table>
<thead>
@ -58,7 +59,12 @@
</div>
<div class="card">
<h2>高级入口</h2>
<div><a href="/ui/api">高级调试</a></div>
<h2>常用动作</h2>
<div class="actions" style="margin-top:8px">
<span class="pill">批量下发</span>
<span class="pill">批量重启</span>
<span class="pill">批量回滚</span>
<a class="btn ghost" href="/ui/api">高级调试</a>
</div>
</div>
{{end}}