Refine backend UI and fix batch config selection

This commit is contained in:
tian 2026-04-20 10:13:16 +08:00
parent ce09c2c7d4
commit d772882ba3
21 changed files with 227 additions and 147 deletions

View File

@ -73,6 +73,64 @@
- 查看、管理、审计分开
- 风格统一、简洁,不做普通 CRUD 表单后台的拼贴感
## 官方模板参考入口
后续所有 UI 细节实现,除了参考 ThingsBoard 这类整体风格方向,还必须优先参考当前后台实际采用的 Tabler 官方文档与示例页面。
统一参考入口:
- Tabler UI 官方文档首页:
<https://docs.tabler.io/ui>
- Tabler Cards 组件文档:
<https://docs.tabler.io/ui/components/cards>
这两个页面的作用不同:
- `https://docs.tabler.io/ui`
用来查看官方组件目录、整体视觉语言和页面级组织方式。
- `https://docs.tabler.io/ui/components/cards`
用来查看卡片、标签页卡片、卡片内容分区等具体结构,尤其适合作为详情页、控制页、标签页容器的直接参考。
## 官方示例优先规则
后续实现 UI 时,必须遵守这条规则:
1. 先看 Tabler 官方页面的实际效果
2. 再看官方示例代码结构
3. 能直接复用官方结构时,不允许先自定义一个“看起来差不多”的版本
4. 只有官方没有提供合适结构时,才允许做小范围本地样式补充
这条规则尤其适用于下面这些高频区域:
- tabs / nav-tabs
- card-tabs
- card + card-body / card-header
- 表格工具栏
- 按钮层级
- 详情页与控制页的内容容器
例如:
- 如果要做标签页,就优先参考官方 `card-tabs + nav nav-tabs + tab-content + card tab-pane` 结构
- 如果要做卡片分区,就优先参考官方 `card / card-header / card-body` 结构
- 如果要做按钮、表单、tab 组合,不应先手写一套近似样式,再去慢慢调整
## 文档使用约定
以后只要文档里写到“按模板实现”“使用官方组件结构”“参考 Tabler 示例”,默认就是指向上面这两个官方入口。
也就是说,后续如果要继续调整:
- 设备详情页
- 设备控制页
- 配置页
- 审计页
- 任务页
都应该先打开官方页面看示例效果和代码,再决定如何落地,而不是只凭组件名称或经验去猜结构。
## UI 视觉系统约束
这次重设计不仅要重做信息架构,也要约束 UI 本身的表现方式,避免后续页面继续变成“每页一个风格”。

View File

@ -10,6 +10,7 @@ type Device struct {
MediaPort int `json:"media_port"`
DeviceName string `json:"device_name"`
Version string `json:"version"`
BuildID string `json:"build_id,omitempty"`
GitSha string `json:"git_sha"`
UptimeSec int64 `json:"uptime_sec,omitempty"`
LastSeenMs int64 `json:"last_seen_ms"`

View File

@ -124,6 +124,7 @@ func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) {
AgentPort int `json:"agent_port"`
MediaPort int `json:"media_port"`
Version string `json:"version"`
BuildID string `json:"build_id"`
GitSha string `json:"git_sha"`
UptimeSec int64 `json:"uptime_sec"`
}
@ -142,6 +143,7 @@ func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) {
AgentPort: reply.AgentPort,
MediaPort: reply.MediaPort,
Version: reply.Version,
BuildID: reply.BuildID,
GitSha: reply.GitSha,
UptimeSec: reply.UptimeSec,
}

View File

@ -64,6 +64,8 @@ type PageData struct {
SelectedDeviceIDs []string
SelectedDevices []*models.Device
SelectedQuery string
SelectedDevicesURL string
BatchConfigURL string
RawJSON string
RawText string
@ -1329,6 +1331,14 @@ func selectedQueryString(ids []string) string {
return values.Encode()
}
func selectedURL(path string, ids []string) string {
query := selectedQueryString(ids)
if query == "" {
return path
}
return path + "?" + query
}
func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMsg string) PageData {
u.ensureDevicesLoaded()
devices := u.registry.GetDevices()
@ -1364,17 +1374,19 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs
}
selectedIDs = filterSelectedDeviceIDs(devices, selectedIDs)
data := PageData{
Title: "设备",
Devices: devices,
DeviceRows: rows,
DeviceCount: len(devices),
OnlineCount: online,
OfflineCount: len(devices) - online,
RunningTaskCount: 0,
FailedTaskCount: failedTasks,
FoundCount: attention,
SelectedDeviceIDs: selectedIDs,
SelectedQuery: selectedQueryString(selectedIDs),
Title: "设备",
Devices: devices,
DeviceRows: rows,
DeviceCount: len(devices),
OnlineCount: online,
OfflineCount: len(devices) - online,
RunningTaskCount: 0,
FailedTaskCount: failedTasks,
FoundCount: attention,
SelectedDeviceIDs: selectedIDs,
SelectedQuery: selectedQueryString(selectedIDs),
SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs),
BatchConfigURL: selectedURL("/ui/devices/batch-config", selectedIDs),
}
if errMsg != "" {
data.Error = errMsg

View File

@ -9,7 +9,15 @@
--border-strong:#d1d5db;
--text:#111827;
--muted:#6b7280;
--primary:#2563eb;
--primary:#475569;
--primary-strong:#334155;
--primary-strong-hover:#273444;
--button-soft:#e5e7eb;
--button-soft-hover:#dbe1e8;
--button-soft-text:#374151;
--danger-soft:#f3e8e8;
--danger-soft-hover:#eadbdb;
--danger-soft-text:#7c4a4a;
--teal:#0f766e;
--green:#15803d;
--amber:#b45309;
@ -57,9 +65,14 @@ main{padding:24px 28px 36px;max-width:1440px}
.small{font-size:12px}
.btn,button,input,select,textarea{font:inherit}
.btn,button{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px 11px;border-radius:8px;border:1px solid var(--border-strong);background:#fff;color:var(--text);cursor:pointer;font-size:12px;font-weight:500}
.btn.primary,button[type=submit]{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.ghost{background:#fff;color:#374151}
.btn,button{display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:5px 10px;border-radius:7px;border:1px solid var(--border-strong);background:var(--button-soft);color:var(--button-soft-text);cursor:pointer;font-size:11px;font-weight:500;line-height:1.15;min-height:32px;transition:background-color .16s ease,border-color .16s ease,color .16s ease,box-shadow .16s ease}
.btn:hover,button:hover{background:var(--button-soft-hover);border-color:#c3ccd6}
.btn.primary,button.primary{background:var(--primary-strong);border-color:var(--primary-strong);color:#f8fafc}
.btn.primary:hover,button.primary:hover{background:var(--primary-strong-hover);border-color:var(--primary-strong-hover)}
.btn.secondary,button.secondary,.btn.ghost{background:var(--button-soft);border-color:var(--border-strong);color:var(--button-soft-text)}
.btn.secondary:hover,button.secondary:hover,.btn.ghost:hover{background:var(--button-soft-hover);border-color:#c3ccd6}
.btn.danger,button.danger{background:var(--danger-soft);border-color:#e7d5d5;color:var(--danger-soft-text)}
.btn.danger:hover,button.danger:hover{background:var(--danger-soft-hover);border-color:#dbc4c4}
button:disabled,.btn:disabled{opacity:.55;cursor:not-allowed}
input,select,textarea{width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--border-strong);background:#fff;color:var(--text);font-size:12px}
textarea{min-height:140px;line-height:1.55}
@ -109,12 +122,17 @@ tbody tr:hover{background:#f9fafb}
.actions{display:flex;flex-wrap:wrap;gap:8px}
.actions.compact{gap:6px}
.actions.compact .btn,.actions.compact button{padding:5px 9px;font-size:11px}
.actions.compact .btn,.actions.compact button{padding:4px 8px;font-size:11px;line-height:1.1;min-height:28px}
.btn .ui-icon{width:14px;height:14px}
.stack{flex-direction:column;align-items:flex-start}
.device-context-head{display:flex;align-items:center;gap:12px}
.device-context-icon{width:34px;height:34px;border-radius:8px;background:#e5eefc;color:#1d4ed8;display:grid;place-items:center}
.device-context-icon .ui-icon{width:18px;height:18px}
.device-tab-wrap{margin-top:16px}
.device-tab-card{margin-top:-1px;padding:0}
.device-panel-body{padding:16px}
.device-panel-body>.card,.device-panel-body>details.card{margin:0 0 16px}
.device-panel-body>.card:last-child,.device-panel-body>details.card:last-child{margin-bottom:0}
.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.quad-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
@ -151,7 +169,14 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back
.row{display:flex;gap:12px;flex-wrap:wrap}
.row>*{flex:1 1 220px}
.subnav{display:flex;gap:8px;flex-wrap:wrap}
.subnav a{display:inline-flex;align-items:center;padding:7px 10px;border:1px solid var(--border);border-radius:999px;background:#fff;color:#374151;font-size:12px;font-weight:500}
.device-tab-wrap{margin-top:16px}
.device-tabs .nav-link{font-size:12px;font-weight:500;color:#64748b}
.device-tabs .nav-link:hover{color:#334155}
.device-tabs .nav-link.active{color:#111827}
.device-tab-card{margin-top:0}
.device-panel-body{padding:16px}
.device-panel-body>.card,.device-panel-body>details.card{margin:0 0 16px}
.device-panel-body>.card:last-child,.device-panel-body>details.card:last-child{margin-bottom:0}
.ui-icon{display:block;flex:0 0 auto}
.batch-toolbar{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:14px 16px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft);margin:0 0 12px}

View File

@ -1,7 +1,6 @@
{{define "api"}}
<div class="card">
<h2>高级调试</h2>
<div class="muted">用于工程排障和接口验证。普通运维流程建议优先使用设备列表、配置管理和识别配置页面。</div>
</div>
<div class="card">
@ -75,7 +74,6 @@
<div class="card">
<h2>任务实时事件</h2>
<div class="muted small">用于观察批量任务在各节点上的实时执行状态。</div>
<div class="actions" style="margin-top:8px">
<button type="button" onclick="connectSSE()">连接</button>
<button type="button" onclick="disconnectSSE()">断开</button>
@ -86,7 +84,6 @@
<div class="card">
<h2>模型上传调试</h2>
<div class="muted small">向指定节点上传检测、识别或人脸模型文件。</div>
<form id="uploadForm" class="row" style="margin-top:8px" onsubmit="return uploadModel()">
<div>
<div class="muted small">模型名称</div>

View File

@ -1,17 +1,8 @@
{{define "audit"}}
<section class="hero-band">
<div>
<div class="eyebrow">操作审计</div>
<h2>所有关键操作的统一留痕入口</h2>
<div class="muted">这里只看谁做了什么、对哪台设备做的、结果如何。</div>
</div>
</section>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "audit"}}<span>审计记录</span></h2>
<div class="muted small">当前展示后台已记录的任务与设备操作结果。</div>
</div>
</div>
<div class="table-wrap">

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="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
</div>
<div class="card config-wizard">
@ -14,7 +14,6 @@
</div>
<div class="wizard-panel" data-panel="node">
<h2>选择节点</h2>
<div class="muted small">当前目标节点已经确定。后续步骤会基于该节点读取配置结构和当前状态。</div>
<div class="drawer-grid" style="margin-top:12px">
<div><div class="muted small">节点名称</div><div>{{.Device.DeviceName}}</div></div>
<div><div class="muted small">节点标识</div><div class="mono">{{.Device.DeviceID}}</div></div>
@ -25,17 +24,14 @@
</div>
<div class="wizard-panel" data-panel="channels">
<h2>编辑视频通道</h2>
<div class="muted small">在下方通道编辑器中维护识别模板、视频流地址、模型路径和推理参数。</div>
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('cfg-editor').scrollIntoView({block:'start'}); activateWizardStep('channels')">前往通道编辑器</button></div>
</div>
<div class="wizard-panel" data-panel="preview">
<h2>预览变更</h2>
<div class="muted small">点击“预览变更”后,会显示设备端生成的变更计划。</div>
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('btn-plan').click()">前往预览变更</button></div>
</div>
<div class="wizard-panel" data-panel="deploy">
<h2>部署结果</h2>
<div class="muted small">点击“部署到设备”后,执行结果会显示在页面底部。</div>
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('cfg-result').scrollIntoView({block:'start'})">查看部署结果</button></div>
</div>
</div>

View File

@ -5,15 +5,10 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "preview"}}<span>配置预览</span></h2>
<div class="muted small">选择模板、Profile 和 Overlay生成可上传的候选配置。</div>
</div>
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
</div>
<div class="muted small" style="margin-bottom:12px">
<span class="mono">config_id</span> 是配置名,默认会带上 <span class="mono">device_id</span>(当前设备:<span class="mono">{{.Device.DeviceID}}</span><span class="mono">config_version</span> 表示本次生成版本;<span class="mono">SHA256</span> 是最终文件内容指纹。
</div>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview">
<div class="field-grid">
<label><span>模板</span>
@ -62,7 +57,6 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "config"}}<span>预览摘要</span></h2>
<div class="muted small">这份预览还没有写入设备,上传后才会成为候选配置。</div>
</div>
</div>
<div class="info-list">

View File

@ -2,12 +2,11 @@
{{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="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
</div>
<div class="card advanced">
<h2>配置结构与当前状态</h2>
<div class="muted small">展示设备端返回的识别配置结构和当前运行状态。</div>
<div class="row" style="margin-top:10px; gap:16px">
<div style="flex:1">
<div class="muted small">配置结构</div>
@ -22,7 +21,6 @@
<div class="card">
<h2>编辑目标识别配置</h2>
<div class="muted small">先预览变更,再部署到设备。</div>
<form method="post" action="/ui/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">
@ -34,7 +32,6 @@
<div class="card">
<h2>人脸库</h2>
<div class="muted small">上传人脸库文件并让设备端重新加载,用于人脸识别场景。</div>
<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">

View File

@ -1,12 +1,12 @@
{{define "device"}}
{{template "device_nav" .}}
{{template "device_header" .}}
{{template "device_tabs" .}}
<div class="detail-grid">
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "status"}}<span>设备状态</span></h2>
<div class="muted small">当前设备连接、版本与服务入口。</div>
</div>
{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}
</div>
@ -17,7 +17,7 @@
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
<div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div>
<div><span>版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
<div><span>Git SHA</span><strong class="mono">{{if .Device.GitSha}}{{.Device.GitSha}}{{else}}-{{end}}</strong></div>
<div><span>Build ID</span><strong class="mono">{{if .Device.BuildID}}{{.Device.BuildID}}{{else}}-{{end}}</strong></div>
<div><span>主机名</span><strong>{{if .Device.Hostname}}{{.Device.Hostname}}{{else}}-{{end}}</strong></div>
</div>
</div>
@ -26,7 +26,6 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "config"}}<span>当前运行配置</span></h2>
<div class="muted small">当前服务正在使用的配置摘要。</div>
</div>
{{if .ConfigStatus}}{{if .ConfigStatus.OK}}<span class="pill ok">已读取</span>{{else}}<span class="pill warn">异常</span>{{end}}{{else}}<span class="pill bad">未读取</span>{{end}}
</div>
@ -54,7 +53,6 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overview"}}<span>最近状态摘要</span></h2>
<div class="muted small">放常用参考信息,不把技术细节堆在主区域。</div>
</div>
</div>
<div class="summary-strip">
@ -75,8 +73,8 @@
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>技术信息</span></summary>
<div class="muted small">低频技术信息默认收起。</div>
<pre>{{json .Device}}</pre>
{{if .ConfigStatusText}}<pre>{{.ConfigStatusText}}</pre>{{end}}
</details>
{{template "device_panel_end" .}}
{{end}}

View File

@ -1,7 +1,6 @@
{{define "device_add"}}
<div class="card">
<h2>新增设备</h2>
<div class="muted small">用于局域网扫描失败时手动纳管视觉识别边缘节点。</div>
<form method="post" action="/ui/devices-add" style="margin-top:16px">
<div class="row" style="gap:16px">
<div>

View File

@ -1,21 +1,12 @@
{{define "device_batch_config"}}
<section class="hero-band">
<div>
<div class="eyebrow">批量配置</div>
<h2>用模板化配置驱动一批设备</h2>
<div class="muted">先确认目标设备再选择模板、Profile 和 Overlay生成后直接进入批量下发任务。</div>
</div>
</section>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>已选设备</span></h2>
<div class="muted small">已选 {{len .SelectedDeviceIDs}} 台设备,将按当前选择顺序创建任务。</div>
</div>
<div class="actions compact">
<a class="btn ghost" href="/ui/devices?{{.SelectedQuery}}#batch-config">返回设备列表</a>
<a class="btn ghost" href="/ui/devices">重新选择</a>
<a class="btn secondary" href="{{.SelectedDevicesURL}}#batch-config">返回设备列表</a>
<a class="btn secondary" href="/ui/devices">重新选择</a>
</div>
</div>
<div class="info-list">
@ -37,7 +28,6 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "config"}}<span>批量配置</span></h2>
<div class="muted small">保持模板化配置路线,不在这里直接维护完整 JSON。</div>
</div>
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
</div>
@ -65,7 +55,7 @@
<span class="muted small">Overlay</span>
<div class="actions" style="margin-top:6px">
{{range .ConfigSources.Overlays}}
<label class="btn ghost">
<label class="btn secondary">
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
{{.Name}}
</label>
@ -75,7 +65,7 @@
</div>
<div class="actions">
<button type="submit">创建批量下发任务</button>
<button type="submit" class="primary">创建批量下发任务</button>
</div>
</form>
</div>
@ -84,7 +74,6 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "preview"}}<span>预览摘要</span></h2>
<div class="muted small">{{if .ConfigPreview}}默认只展示配置生成关键信息。完整 JSON 在下方折叠区。{{else}}先选择模板化参数并提交,页面会在这里展示配置生成关键信息。{{end}}</div>
</div>
</div>
<div class="info-list">

View File

@ -1,11 +1,11 @@
{{define "device_control"}}
{{template "device_nav" .}}
{{template "device_header" .}}
{{template "device_tabs" .}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "control"}}<span>设备控制</span></h2>
<div class="muted small">服务动作、候选配置应用和回滚都在这里完成。</div>
</div>
{{if .Device.Online}}<span class="pill ok">可操作</span>{{else}}<span class="pill bad">设备离线</span>{{end}}
</div>
@ -30,9 +30,8 @@
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "preview"}}<span>配置预览</span></h3>
<div class="muted small">进入预览器生成、上传候选配置。</div>
</div>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开预览器</span></a>
<a class="btn secondary" href="/ui/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>
@ -45,7 +44,6 @@
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "apply"}}<span>配置应用</span></h3>
<div class="muted small">应用候选配置,或回滚到上一份配置回滚点。</div>
</div>
</div>
<div class="info-list compact-list">
@ -55,12 +53,12 @@
<div class="actions">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
<input type="hidden" name="return_to" value="control" />
<button type="submit">应用候选配置</button>
<button type="submit" class="primary">应用候选配置</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" />
<input type="hidden" name="return_to" value="control" />
<button type="submit" class="btn ghost">回滚到上一份</button>
<button type="submit" class="secondary">回滚到上一份</button>
</form>
</div>
</section>
@ -69,29 +67,23 @@
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "service"}}<span>服务控制</span></h3>
<div class="muted small">启动、停止、重启和重载当前服务。</div>
</div>
</div>
<div class="actions stack">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_start" />
<input type="hidden" name="return_to" value="control" />
<button type="submit">启动服务</button>
<button type="submit" class="secondary">启动服务</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_restart" />
<input type="hidden" name="return_to" value="control" />
<button type="submit">重启服务</button>
<button type="submit" class="primary">重启服务</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_stop" />
<input type="hidden" name="return_to" value="control" />
<button type="submit" class="btn ghost">停止服务</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="reload" />
<input type="hidden" name="return_to" value="control" />
<button type="submit" class="btn ghost">重载服务</button>
<button type="submit" class="danger">停止服务</button>
</form>
</div>
</section>
@ -100,7 +92,6 @@
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "result"}}<span>执行结果摘要</span></h3>
<div class="muted small">操作结果只在这里汇总,不在多个信息窗重复显示。</div>
</div>
</div>
{{if .ResultTitle}}<div class="muted small" style="margin-bottom:8px">{{.ResultTitle}}</div>{{end}}
@ -124,4 +115,5 @@
<div><span>当前版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
</div>
</details>
{{template "device_panel_end" .}}
{{end}}

View File

@ -3,7 +3,6 @@
<div class="card">
<h2>诊断日志</h2>
<div class="muted small" style="margin-bottom:10px">用于查看设备端最近诊断日志。</div>
<pre>{{.RawText}}</pre>
</div>
{{end}}

View File

@ -1,18 +1,41 @@
{{define "device_nav"}}
{{define "device_header"}}
<div class="card device-context">
<div class="section-title">
<div class="device-context-head">
<div class="device-context-icon">{{icon "device"}}</div>
<div class="crumb">当前设备</div>
<div>
<div class="crumb">当前设备</div>
<h2>{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</h2>
<div class="muted small mono">{{.Device.DeviceID}} · {{.Device.IP}}:{{.Device.AgentPort}}</div>
</div>
</div>
</div>
<div class="subnav device-tabs">
<a href="/ui/devices/{{.Device.DeviceID}}">设备详情</a>
<a href="/ui/devices/{{.Device.DeviceID}}/control">设备控制</a>
</div>
</div>
{{end}}
{{define "device_nav"}}
{{template "device_header" .}}
{{template "device_tabs" .}}
{{template "device_panel_end" .}}
{{end}}
{{define "device_tabs"}}
<div class="card-tabs device-tab-wrap">
<ul class="nav nav-tabs device-tabs" role="tablist" aria-label="设备页面">
<li class="nav-item" role="presentation">
<a href="/ui/devices/{{.Device.DeviceID}}" class="nav-link{{if eq .Title "设备详情"}} active{{end}}" role="tab" {{if eq .Title "设备详情"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>设备详情</a>
</li>
<li class="nav-item" role="presentation">
<a href="/ui/devices/{{.Device.DeviceID}}/control" class="nav-link{{if eq .Title "设备控制"}} active{{end}}" role="tab" {{if eq .Title "设备控制"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>设备控制</a>
</li>
</ul>
<div class="tab-content">
<div class="card tab-pane active show device-tab-card">
<div class="card-body device-panel-body">
{{end}}
{{define "device_panel_end"}}
</div>
</div>
</div>
{{end}}

View File

@ -1,12 +1,4 @@
{{define "devices"}}
<section class="hero-band">
<div>
<div class="eyebrow">设备总览</div>
<h2>多台视觉识别设备的统一运维视图</h2>
<div class="muted">先看状态,再决定操作。默认只展示值班和维护最需要的信息。</div>
</div>
</section>
<div class="stats">
<div class="stat accent-teal">
<div class="k metric-label">{{icon "devices"}}<span>设备总数</span></div>
@ -34,7 +26,6 @@
<div class="section-title">
<div>
<h2>设备列表</h2>
<div class="muted small">设备名优先,技术字段降级显示。默认只展示状态判断需要的信息。</div>
</div>
<div class="table-tools">
<input id="device-filter" placeholder="搜索设备名、ID 或 IP" />
@ -46,15 +37,13 @@
<div class="batch-toolbar" id="batch-config">
<div>
<div class="batch-toolbar-count">已选 {{len .SelectedDeviceIDs}} 台</div>
<div class="muted small">选择后可以对这批设备统一执行服务操作,或进入模板化批量配置。</div>
</div>
<div class="actions compact">
<button type="submit" name="action" value="media_restart">重启服务</button>
<button type="submit" name="action" value="media_start">启动服务</button>
<button type="submit" name="action" value="media_stop">停止服务</button>
<button type="submit" name="action" value="reload">重载服务</button>
<a class="btn ghost" href="/ui/devices/batch-config?{{.SelectedQuery}}">批量配置</a>
<a class="btn ghost" href="/ui/devices">清空选择</a>
<div class="actions">
<button type="submit" name="action" value="media_restart" class="primary">重启服务</button>
<button type="submit" name="action" value="media_start" class="secondary">启动服务</button>
<button type="submit" name="action" value="media_stop" class="danger">停止服务</button>
<a class="btn secondary" href="{{.BatchConfigURL}}">批量配置</a>
<a class="btn secondary" href="/ui/devices">清空选择</a>
</div>
</div>
{{end}}
@ -85,7 +74,7 @@
{{if .Device.Hostname}}<span>{{.Device.Hostname}}</span>{{end}}
<span class="mono">{{.Device.IP}}</span>
{{if .Device.Version}}<span class="mono">{{.Device.Version}}</span>{{end}}
{{if .Device.GitSha}}<span class="mono">#{{shortHash .Device.GitSha}}</span>{{end}}
{{if .Device.BuildID}}<span class="mono">#{{shortHash .Device.BuildID}}</span>{{end}}
</div>
</div>
</div>
@ -102,22 +91,18 @@
<span class="pill bad">未知</span>
{{end}}
</div>
<div class="muted small">心跳 {{ago .Device.LastSeenMs}}</div>
</div>
</td>
<td>
<div class="config-inline">
{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}
<div class="mono">{{.ConfigStatus.Metadata.ConfigID}}</div>
<div class="muted small mono">{{.ConfigStatus.Metadata.ConfigVersion}}</div>
{{if .ConfigStatus.Metadata.Overlays}}<div class="muted small">{{range $i, $overlay := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$overlay}}{{end}}</div>{{end}}
{{else if .ConfigStatusErr}}
<div class="muted small">未取到配置摘要</div>
{{else}}
<div class="muted small">暂无配置摘要</div>
{{end}}
</div>
<div class="device-id-inline muted small mono" title="{{.Device.DeviceID}}">{{shortHash .Device.DeviceID}}</div>
</td>
<td>
<div class="actions">

View File

@ -1,16 +1,7 @@
{{define "system"}}
<section class="hero-band">
<div>
<div class="eyebrow">系统</div>
<h2>平台状态与访问入口</h2>
<div class="muted">只放发现、认证、健康和接口入口,不承载日常设备操作。</div>
</div>
</section>
<div class="detail-grid">
<div class="card">
<h2 class="title-with-icon">{{icon "discovery"}}<span>设备发现与注册</span></h2>
<div class="muted small">当前后台通过 UDP 广播发现设备,也支持手动纳管。</div>
<div class="info-list">
<div><span>当前设备数</span><strong>{{len .Devices}}</strong></div>
<div><span>发现方式</span><strong>UDP 广播发现</strong></div>
@ -20,7 +11,6 @@
<div class="card">
<h2 class="title-with-icon">{{icon "shield"}}<span>Agent 访问策略</span></h2>
<div class="muted small">后台通过统一令牌访问设备,页面默认不展示敏感值。</div>
<div class="info-list">
<div><span>访问模型</span><strong>统一令牌</strong></div>
<div><span>更新方式</span><strong>部署脚本托管</strong></div>
@ -32,7 +22,6 @@
<div class="detail-grid">
<div class="card">
<h2 class="title-with-icon">{{icon "heartbeat"}}<span>后台健康</span></h2>
<div class="muted small">后台自身的健康检查与接口说明入口。</div>
<div class="info-list compact-list">
<div><span>健康检查</span><strong class="mono">/health</strong></div>
<div><span>接口文档</span><strong class="mono">/openapi.json</strong></div>
@ -45,7 +34,6 @@
<div class="card">
<h2 class="title-with-icon">{{icon "tech"}}<span>当前纳管设备</span></h2>
<div class="muted small">只展示系统层需要的设备接入信息。</div>
<div class="info-list compact-list">
<div><span>在线设备</span><strong>{{len .Devices}}</strong></div>
<div><span>接入端口</span><strong class="mono">9100 / 9000</strong></div>

View File

@ -1,9 +1,6 @@
{{define "tasks"}}
<div class="card">
<h2>创建任务</h2>
<div class="muted small">
用于向多台边缘节点下发识别配置、重载服务、回滚配置或控制视频分析服务。
</div>
<form method="post" action="/ui/tasks" style="margin-top:10px">
<div class="row">
<div>
@ -62,6 +59,6 @@
<div class="card">
<h2>高级入口</h2>
<div class="muted">需要排查接口或实时事件时,可以进入 <a href="/ui/api">高级调试</a></div>
<div><a href="/ui/api">高级调试</a></div>
</div>
{{end}}

View File

@ -1,7 +1,6 @@
{{define "templates"}}
<div class="card">
<h2>识别配置模板</h2>
<div class="muted small">用于管理视频通道、检测模型和识别方案的模板定义。</div>
<div class="table-wrap" style="margin-top:10px">
<table>
<thead>

View File

@ -203,11 +203,17 @@ func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载服务", "批量配置", "清空选择"} {
for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "批量配置", "清空选择"} {
if !strings.Contains(body, want) {
t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body)
}
}
if strings.Contains(body, "重载服务") {
t.Fatalf("device overview batch controls should not contain reload action")
}
if !strings.Contains(body, `href="/ui/devices/batch-config?selected=edge-01&amp;selected=edge-02"`) {
t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body)
}
}
func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
@ -230,7 +236,6 @@ func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
"Profile",
"Overlay",
"已选设备",
"已选 2 台设备",
"入口识别节点",
"辅助节点",
"预览摘要",
@ -242,6 +247,9 @@ func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body)
}
}
if strings.Contains(body, "已选 2 台设备") {
t.Fatalf("batch config page should not contain explanatory selected-count copy")
}
}
func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) {
@ -499,11 +507,16 @@ func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"设备", "设备总览", "多台视觉识别设备的统一运维视图", "设备总数", "在线设备", "离线设备", "失败操作", "设备列表"} {
for _, want := range []string{"设备", "设备总数", "在线设备", "离线设备", "失败操作", "设备列表"} {
if !strings.Contains(body, want) {
t.Fatalf("expected dashboard HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"设备总览", "多台视觉识别设备的统一运维视图", "先看状态,再决定操作。默认只展示值班和维护最需要的信息。"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device page should not contain explanatory copy %q", forbidden)
}
}
}
func TestUI_DevicePageIncludesFilterAndControlEntry(t *testing.T) {
@ -561,7 +574,7 @@ func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
agent := service.NewAgentClient(cfg)
reg := service.NewRegistryService(cfg, agent)
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: false, Version: "1.0.0", GitSha: "5c04681"})
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: false, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"})
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(nil, reg, agent, tasks, nil)
if err != nil {
@ -576,7 +589,7 @@ func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"在线", "运行中", "local_3588_face_debug", "20260419.104457", "face_debug, production_quiet"} {
for _, want := range []string{"在线", "运行中", "local_3588_face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device overview HTML to contain %q, got:\n%s", want, body)
}
@ -584,6 +597,14 @@ func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(t *testing.T) {
if strings.Contains(body, "待在详情页查看") {
t.Fatalf("device overview should show live config summary instead of placeholder text")
}
for _, forbidden := range []string{"20260419.104457", "face_debug, production_quiet"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not expose extra config details %q in main list", forbidden)
}
}
if strings.Contains(body, "心跳") {
t.Fatalf("device overview should not show heartbeat text in status cell")
}
}
func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) {
@ -616,7 +637,7 @@ func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
agent := service.NewAgentClient(cfg)
reg := service.NewRegistryService(cfg, agent)
reg.UpdateDevice(&models.Device{DeviceID: "d12a4719c91641df91b76ab271280797", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "0.1.0", GitSha: "5c04681"})
reg.UpdateDevice(&models.Device{DeviceID: "d12a4719c91641df91b76ab271280797", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "0.1.0", BuildID: "20260419.151628", GitSha: "5c04681"})
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(nil, reg, agent, tasks, nil)
if err != nil {
@ -628,16 +649,24 @@ func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) {
ui.pageDevices(rr, req)
body := rr.Body.String()
for _, want := range []string{"设备", "状态", "当前配置", "操作", "orangepi5plus", "0.1.0"} {
for _, want := range []string{"设备", "状态", "当前配置", "操作", "orangepi5plus", "0.1.0", "#2026041"} {
if !strings.Contains(body, want) {
t.Fatalf("expected compact overview HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"#5c04681", "Git SHA"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not expose git sha marker %q", forbidden)
}
}
for _, forbidden := range []string{"最后心跳", "版本</th>", "在线</th>", "服务</th>"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not keep verbose column header %q", forbidden)
}
}
if strings.Contains(body, "心跳") {
t.Fatalf("device overview compact view should not render heartbeat text")
}
}
func TestUI_DeviceDetailIncludesTabs(t *testing.T) {
@ -1119,16 +1148,23 @@ func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) {
body := rr.Body.String()
for _, want := range []string{
"config_id</span> 是配置名,默认会带上 <span class=\"mono\">device_id",
"config_version</span> 表示本次生成版本",
"device_id",
"edge-01",
"SHA256</span> 是最终文件内容指纹",
"config_id",
"config_version",
"SHA256",
"Overlay",
"face_debug",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected preview explanation HTML to contain %q, got:\n%s", want, body)
t.Fatalf("expected config preview HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{
"是配置名,默认会带上",
"表示本次生成版本",
"是最终文件内容指纹",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("config preview should not contain explanatory copy %q", forbidden)
}
}
}
@ -1376,7 +1412,6 @@ func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) {
`action="/ui/devices/edge-01/action"`,
`action="/ui/devices/edge-01/config-candidate/apply"`,
`href="/ui/devices/edge-01/config-preview"`,
"重载服务",
"回滚到上一份",
"打开预览器",
} {
@ -1384,6 +1419,9 @@ func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) {
t.Fatalf("expected device control HTML to contain %q", want)
}
}
if strings.Contains(body, "重载服务") {
t.Fatalf("device control page should not contain reload action")
}
for _, forbidden := range []string{"框架版", "结构样机", "待接入", `disabled>`, `value="workshop_face_shoe_alarm" disabled`} {
if strings.Contains(body, forbidden) {
t.Fatalf("device control page should not contain placeholder marker %q", forbidden)
@ -1532,8 +1570,8 @@ func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
reg := service.NewRegistryService(cfg, nil)
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0", GitSha: "5c04681"})
reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", GitSha: "8eaf213"})
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"})
reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", BuildID: "20260419.151900", GitSha: "8eaf213"})
tasks := service.NewTaskService(cfg, nil, reg)
taskConfig, err := tasks.CreateTask("config_apply", []string{"edge-01", "edge-02"}, map[string]any{"config": map[string]any{}})
if err != nil {
@ -1614,12 +1652,12 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
rrSystem := httptest.NewRecorder()
ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil))
for _, want := range []string{"系统", "平台状态与访问入口", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} {
for _, want := range []string{"系统", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} {
if !strings.Contains(rrSystem.Body.String(), want) {
t.Fatalf("expected system HTML to contain %q", want)
}
}
for _, forbidden := range []string{"框架版", "后续"} {
for _, forbidden := range []string{"框架版", "后续", "平台状态与访问入口"} {
if strings.Contains(rrSystem.Body.String(), forbidden) {
t.Fatalf("system HTML should not contain placeholder marker %q", forbidden)
}