合并模型管理分支,修改启动脚本,增加了managerd.bat和managerd.ps1,删除了restart.bat

This commit is contained in:
tian 2026-05-06 11:45:26 +08:00
parent 1c5ce424e1
commit 2304516dbc
11 changed files with 410 additions and 107 deletions

View File

@ -25,6 +25,29 @@ managerd
managerd path/to/managerd.json
```
在当前仓库的 Windows 开发环境中,推荐使用统一脚本入口:
```bat
scripts\managerd.bat build
scripts\managerd.bat start
scripts\managerd.bat stop
scripts\managerd.bat restart
scripts\managerd.bat status
```
各动作说明:
- `build`: 编译 `.\cmd\managerd`,生成根目录下的 `managerd.exe`
- `start`: 启动当前仓库中的 `managerd.exe`,并检查 `/health`
- `stop`: 停止当前仓库对应的 `managerd.exe`
- `restart`: 先停止再启动
- `status`: 查看进程状态与健康检查结果
脚本实际入口文件:
- `scripts/managerd.bat`
- `scripts/managerd.ps1`
程序启动后:
- `GET /` 会重定向到 `/ui`

View File

@ -1190,7 +1190,7 @@ func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
devices := u.registry.GetDevices()
selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"]))
data := PageData{
Title: "任务",
Title: "任务中心",
Tasks: u.tasks.ListTasks(),
Devices: devices,
SelectedDeviceIDs: selectedIDs,
@ -1201,7 +1201,7 @@ func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) taskPageData(task *models.Task) PageData {
data := PageData{Title: "任务详情", Task: task}
data := PageData{Title: "任务中心", Task: task}
if task == nil {
return data
}
@ -1260,13 +1260,13 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
if err != nil {
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
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)
@ -1301,6 +1301,7 @@ func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()}
for _, dev := range data.Devices {
if dev == nil {
@ -1376,7 +1377,7 @@ func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "resources", PageData{Title: "资源管理", Devices: u.registry.GetDevices()})
u.render(w, r, "resources", PageData{Title: "资源状态", Devices: u.registry.GetDevices()})
}
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
@ -1570,7 +1571,7 @@ func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
if u.preview == nil {
data.Error = "基础配置服务未初始化"
data.Error = "配置中心服务未初始化"
u.render(w, r, "asset_templates", data)
return
}
@ -1760,7 +1761,7 @@ func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) {
func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("")
data.Title = "场景模板"
data.Title = "场景"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
@ -1821,7 +1822,7 @@ func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
data.Title = "场景模板"
data.Title = "场景"
data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
@ -1856,7 +1857,7 @@ func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name
)
if strings.TrimSpace(name) == "" {
data = u.assetPageData("")
data.Title = "场景模板"
data.Title = "场景"
_ = r.ParseForm()
editor = service.ConfigProfileEditor{
Name: strings.TrimSpace(r.FormValue("profile_name")),
@ -1883,14 +1884,14 @@ func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name
}
if err := u.preview.SaveProfileEditor(editor); err != nil {
data.Error = err.Error()
data.Title = "场景模板"
data.Title = "场景"
data.AssetProfileEditing = true
u.render(w, r, "scene_templates", data)
return
}
msg := "场景模板已保存"
msg := "场景已保存"
if strings.TrimSpace(name) != "" && editor.Name != name {
msg = "场景模板已保存,名称已更新"
msg = "场景已保存,名称已更新"
}
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound)
}
@ -1912,7 +1913,7 @@ func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
for _, item := range items {
deviceIDs = append(deviceIDs, item.DeviceID)
}
msg := fmt.Sprintf("场景模板 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、"))
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)
return
}
@ -1921,7 +1922,7 @@ func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/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, "/ui/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound)
}
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
@ -1958,7 +1959,7 @@ func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request)
func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("")
data.Title = "识别单元"
data.Title = "视频通道"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
@ -2006,7 +2007,7 @@ func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) {
originalRef := strings.TrimSpace(r.FormValue("original_ref"))
if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil {
data := u.assetPageData("")
data.Title = "识别单元"
data.Title = "视频通道"
data.Error = err.Error()
data.RecognitionUnit = &asset
data.RecognitionUnitEditing = true
@ -2015,7 +2016,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, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound)
}
func serviceRecognitionUnitRef(profileName string, unitName string) string {
@ -2028,12 +2029,12 @@ func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/ui/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, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound)
}
func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("")
data.Title = "设备分配"
data.Title = "通道部署"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
@ -2063,7 +2064,7 @@ func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request)
assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json")))
if err != nil {
data := u.assetPageData("")
data.Title = "设备分配"
data.Title = "通道部署"
u.ensureDevicesLoaded()
data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices())
data.Error = err.Error()
@ -2087,7 +2088,7 @@ func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("设备分配已保存"), http.StatusFound)
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound)
}
func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device {
@ -2136,12 +2137,12 @@ func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, "/ui/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, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound)
}
func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("video-sources")
data.Title = "基础配置"
data.Title = "配置中心"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
@ -2196,7 +2197,7 @@ func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request)
}
if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
data := u.assetPageData("video-sources")
data.Title = "基础配置"
data.Title = "配置中心"
data.Error = err.Error()
data.AssetVideoSource = &asset
data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType)
@ -2222,7 +2223,7 @@ func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request
func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("integrations")
data.Title = "基础配置"
data.Title = "配置中心"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
@ -2381,7 +2382,7 @@ func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) {
raw := map[string]any{}
if err := json.Unmarshal([]byte(rawText), &raw); err != nil {
data := u.assetPageData("overlays")
data.Title = "基础配置"
data.Title = "配置中心"
data.Error = "调试参数 JSON 格式不正确:" + err.Error()
data.AssetOverlayEditing = true
data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
@ -2392,7 +2393,7 @@ func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) {
asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
if err := u.preview.SaveOverlayAsset(asset, raw); err != nil {
data := u.assetPageData("overlays")
data.Title = "基础配置"
data.Title = "配置中心"
data.Error = err.Error()
data.AssetOverlayEditing = true
data.AssetOverlay = &asset
@ -2418,11 +2419,11 @@ func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) {
func (u *UI) assetPageData(tab string) PageData {
data := PageData{
Title: "基础配置",
Title: "配置中心",
AssetTab: tab,
}
if u.preview == nil {
data.Error = "基础配置服务未初始化"
data.Error = "配置中心服务未初始化"
return data
}
sources, err := u.preview.ListSources()
@ -3055,7 +3056,7 @@ func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) {
}
data := u.deviceDetailPageData(dev)
if data.DeviceAssignment == nil {
data.Error = "请先到设备分配中为该设备指定识别单元"
data.Error = "请先到通道部署中为该设备指定视频通道"
u.render(w, r, "device", data)
return
}

View File

@ -2,14 +2,14 @@
<div class="card assignment-board-page">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>设备分配</span></h2>
<h2 class="title-with-icon">{{icon "devices"}}<span>通道部署</span></h2>
</div>
</div>
{{if .DeviceAssignmentBoard}}
<div class="assignment-kpis">
<div class="assignment-kpi">
<span>识别单元</span>
<span>视频通道</span>
<strong>{{.DeviceAssignmentBoard.Stats.TotalUnits}}</strong>
</div>
<div class="assignment-kpi">
@ -45,7 +45,7 @@
<div class="actions compact">
<button type="button" class="btn secondary" id="auto-assign-btn">自动平均分配</button>
<button type="button" class="btn secondary" id="clear-assign-btn">清空分配</button>
<button type="submit">保存设备分配</button>
<button type="submit">保存通道部署</button>
</div>
</div>
@ -75,7 +75,7 @@
{{if lt .AssignedCount .MaxUnits}}
<div class="assignment-device-add">
<select data-add-select="{{.DeviceID}}">
<option value="">添加识别单元</option>
<option value="">添加视频通道</option>
</select>
<button type="button" class="btn secondary" data-add-button="{{.DeviceID}}">加入</button>
</div>
@ -87,7 +87,7 @@
<section class="assignment-unassigned">
<div class="section-title compact">
<div>
<h3>分配识别单元</h3>
<h3>部署视频通道</h3>
</div>
</div>
<div class="assignment-chip-list" id="assignment-unassigned-list">
@ -273,7 +273,7 @@
const addControls = card.refs.length < state.max ? `
<div class="assignment-device-add">
<select data-add-select="${card.deviceID}">
<option value="">添加识别单元</option>
<option value="">添加视频通道</option>
${availableRefs.map(ref => `<option value="${ref}">${unitLabel(state.units[ref])}</option>`).join('')}
</select>
<button type="button" class="btn secondary" data-add-button="${card.deviceID}">加入</button>
@ -358,7 +358,7 @@
document.getElementById('auto-assign-btn').addEventListener('click', autoAssign);
document.getElementById('clear-assign-btn').addEventListener('click', () => {
clearAssignments();
feedback.textContent = '已清空当前页面中的设备分配。';
feedback.textContent = '已清空当前页面中的通道部署。';
render();
});
form.addEventListener('submit', syncHiddenState);
@ -366,7 +366,7 @@
})();
</script>
{{else}}
<div class="empty-state compact"><div class="empty-title">暂无可用的设备分配数据</div></div>
<div class="empty-state compact"><div class="empty-title">暂无可用的通道部署数据</div></div>
{{end}}
</div>
{{end}}

View File

@ -22,11 +22,11 @@
<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/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景模板</span></a>
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>识别单元</span></a>
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>设备分配</span></a>
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>基础配置</span></a>
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a>
<a href="/ui/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景</span></a>
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>视频通道</span></a>
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>通道部署</span></a>
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>配置中心</span></a>
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务中心</span></a>
<details class="nav-group" id="system-nav-group">
<summary>
<span class="nav-icon">{{icon "system"}}</span>
@ -34,7 +34,7 @@
</summary>
<div class="nav-group-items">
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</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/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
</div>

View File

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

View File

@ -2,7 +2,7 @@
<div class="card">
<div class="section-title">
<div>
<h2>资源管理</h2>
<h2>资源状态</h2>
<div class="muted small">统一维护人脸库与通用资源,设备侧只显示当前版本与同步状态。</div>
</div>
</div>

View File

@ -2,13 +2,13 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板列表</span></h2>
<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="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景</span></a>
{{if .SelectedProfile}}
<a class="btn secondary" href="/ui/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景模板吗?');">
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景吗?');">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
@ -18,10 +18,10 @@
<table>
<thead>
<tr>
<th>场景模板</th>
<th>场景</th>
<th>识别模板</th>
<th>调试参数</th>
<th>识别单元</th>
<th>视频通道</th>
</tr>
</thead>
<tbody>
@ -33,7 +33,7 @@
<td>{{len .Instances}}</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景模板</div></div></td></tr>
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景</div></div></td></tr>
{{end}}
</tbody>
</table>
@ -45,11 +45,11 @@
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetProfileEditing}}
<span class="pill run">编辑模式</span>
<span>当前内容只包含模板级信息,识别单元请到“识别单元”页面维护。</span>
<span>当前内容只包含场景级信息,视频通道请到“视频通道”页面维护。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
@ -58,7 +58,7 @@
</div>
<div class="actions compact">
{{if .AssetProfileEditing}}
<button type="submit">{{icon "apply"}}<span>保存场景模板</span></button>
<button type="submit">{{icon "apply"}}<span>保存场景</span></button>
<a class="btn secondary" href="/ui/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>
@ -67,7 +67,7 @@
{{if .AssetProfileEditing}}
<div class="field-grid">
<label><span>场景模板名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if not .SelectedProfile}}autofocus{{end}} /></label>
<label><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if not .SelectedProfile}}autofocus{{end}} /></label>
<label><span>识别模板<span class="required-mark">*</span></span>
<select name="primary_template_name">
{{range .AssetTemplates}}
@ -90,12 +90,12 @@
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>场景模板名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>场景名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>识别模板</span><strong class="mono">{{if .AssetProfileEditor.PrimaryTemplateName}}{{.AssetProfileEditor.PrimaryTemplateName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>业务名称</span><strong>{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>调试参数</span><strong>{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}</strong></div>
<div class="detail-item"><span>站点名</span><strong>{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>识别单元</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
<div class="detail-item"><span>视频通道</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
<div class="detail-item full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
</div>
{{end}}

View File

@ -8,6 +8,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net"
"net/http"
@ -18,6 +19,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
@ -133,7 +135,11 @@ func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
for _, want := range []string{
"视觉识别运维平台",
"总览",
"任务",
"场景",
"视频通道",
"通道部署",
"配置中心",
"任务中心",
"系统管理",
"<h1>设备</h1>",
} {
@ -1914,6 +1920,107 @@ func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
}
}
func TestUI_ModelsPageLoadsDevicesBeforeBuildingStatusBoard(t *testing.T) {
modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models/status":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123}]}`))
default:
http.NotFound(w, r)
}
}))
defer modelServer.Close()
host, portText, err := net.SplitHostPort(strings.TrimPrefix(modelServer.Listener.Addr().String(), "[::]"))
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
agentPort, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
discoveryConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 35689})
if err != nil {
t.Fatalf("ListenUDP: %v", err)
}
defer discoveryConn.Close()
done := make(chan struct{})
go func() {
defer close(done)
buf := make([]byte, 2048)
_ = discoveryConn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, addr, err := discoveryConn.ReadFromUDP(buf)
if err != nil {
return
}
text := strings.TrimSpace(string(buf[:n]))
lines := strings.SplitN(text, "\n", 3)
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "RK3588SYS_DISCOVERY_V1" {
return
}
var req struct {
ReqID string `json:"req_id"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &req); err != nil {
return
}
reply := fmt.Sprintf("RK3588SYS_DISCOVERY_V1\n{\"type\":\"discover_reply\",\"req_id\":%q,\"device_id\":\"edge-01\",\"device_name\":\"入口识别节点\",\"ip\":%q,\"agent_port\":%d,\"media_port\":9000}\n", req.ReqID, host, agentPort)
_, _ = discoveryConn.WriteToUDP([]byte(reply), addr)
}()
cfg := &config.Config{
Concurrency: 1,
OfflineAfterMs: 1000000,
DiscoveryTimeoutMs: 300,
DiscoveryPort: 35689,
}
dbPath := filepath.Join(t.TempDir(), "models.db")
store, err := storage.OpenSQLite(dbPath)
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
discovery := service.NewDiscoveryService(cfg, reg)
agent := service.NewAgentClient(cfg)
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(discovery, reg, agent, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
modelsRepo := storage.NewModelsRepo(store.DB())
if err := modelsRepo.Save(storage.StandardModelRecord{
Name: "face_det_scrfd_500m_640_rk3588",
FileName: "face_det_scrfd_500m_640_rk3588.rknn",
Version: "v1",
SHA256: "sha-1",
SizeBytes: 123,
ModelType: "face_detection",
}); err != nil {
t.Fatalf("Save: %v", err)
}
ui.SetDBPath(dbPath)
req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
rr := httptest.NewRecorder()
ui.pageModels(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"入口识别节点", "设备模型状态", "一致"} {
if !strings.Contains(body, want) {
t.Fatalf("expected model page to contain %q after lazy device load, got:\n%s", want, body)
}
}
if strings.Contains(body, "暂无设备模型状态") {
t.Fatalf("expected model page to build status board after lazy device load, got:\n%s", body)
}
<-done
}
func TestUI_ActionModelSyncCreatesTask(t *testing.T) {
ui := newTestUI(t)
form := url.Values{}
@ -1938,7 +2045,7 @@ func TestUI_ResourcesPageShowsUnifiedResourceStatus(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/resources")
for _, want := range []string{"资源管理", "人脸库版本", "设备资源状态", "入口识别节点"} {
for _, want := range []string{"资源状态", "人脸库版本", "设备资源状态", "入口识别节点"} {
if !strings.Contains(html, want) {
t.Fatalf("expected resources HTML to contain %q", want)
}
@ -2197,7 +2304,7 @@ func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
ui.pageAssets(rr, req)
body := rr.Body.String()
for _, want := range []string{"基础配置", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
for _, want := range []string{"配置中心", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected assets HTML to contain %q", want)
}
@ -3264,7 +3371,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
t.Fatalf("expected audit HTML to contain %q", want)
}
}
for _, forbidden := range []string{"框架版", "后续", "任务中心", "节点执行情况", `disabled`} {
for _, forbidden := range []string{"框架版", "后续", "节点执行情况", `disabled`} {
if strings.Contains(rrAudit.Body.String(), forbidden) {
t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden)
}
@ -3282,7 +3389,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
t.Fatalf("expected tasks HTML to contain %q", want)
}
}
for _, forbidden := range []string{"任务中心", "节点执行情况", "创建任务", "<h2>目标设备</h2>", "高级参数JSON", `name="device_ids"`, `name="payload_json"`} {
for _, forbidden := range []string{"节点执行情况", "创建任务", "<h2>目标设备</h2>", "高级参数JSON", `name="device_ids"`, `name="payload_json"`} {
if strings.Contains(rrTasks.Body.String(), forbidden) {
t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden)
}
@ -3299,7 +3406,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
t.Fatalf("expected task detail HTML to contain %q", want)
}
}
for _, forbidden := range []string{"任务中心", "返回操作审计"} {
for _, forbidden := range []string{"返回操作审计"} {
if strings.Contains(rrTaskConfig.Body.String(), forbidden) {
t.Fatalf("task detail HTML should not contain %q", forbidden)
}
@ -3363,7 +3470,7 @@ func TestUI_DeviceAssignmentsPageShowsBoard(t *testing.T) {
ui.pageDeviceAssignments(rr, req)
body := rr.Body.String()
for _, want := range []string{"设备分配", "识别单元", "未分配", "每台最多", "自动平均分配", "保存设备分配", "东门入口", "西门入口"} {
for _, want := range []string{"通道部署", "视频通道", "未分配", "每台最多", "自动平均分配", "保存通道部署", "东门入口", "西门入口"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device assignment board to contain %q, got:\n%s", want, body)
}

5
scripts/managerd.bat Normal file
View File

@ -0,0 +1,5 @@
@echo off
setlocal
chcp 65001 >nul
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0managerd.ps1" %*
exit /b %ERRORLEVEL%

197
scripts/managerd.ps1 Normal file
View File

@ -0,0 +1,197 @@
param(
[Parameter(Position = 0)]
[string]$Action = "help"
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$rootDir = (Resolve-Path (Join-Path $scriptDir "..")).Path
$exePath = Join-Path $rootDir "managerd.exe"
$configPath = Join-Path $rootDir "managerd.json"
$logPath = Join-Path $rootDir "managerd.local.log"
$errLogPath = Join-Path $rootDir "managerd.local.err.log"
function Write-Info {
param([string]$Message)
Write-Host "[managerd] $Message"
}
function Get-ManagerdProcess {
$all = Get-CimInstance Win32_Process -Filter "name = 'managerd.exe'"
$matched = $all | Where-Object { $_.ExecutablePath -eq $exePath }
return $matched | Select-Object -First 1
}
function Get-HealthInfo {
$listen = "127.0.0.1:18080"
if (Test-Path $configPath) {
$cfg = Get-Content -Raw $configPath | ConvertFrom-Json
if ($cfg.listen -and -not [string]::IsNullOrWhiteSpace([string]$cfg.listen)) {
$listen = [string]$cfg.listen
}
}
$parts = $listen.Split(":")
$hostName = $parts[0]
$port = $parts[-1]
if ([string]::IsNullOrWhiteSpace($hostName) -or $hostName -in @("0.0.0.0", "::")) {
$hostName = "127.0.0.1"
}
$url = "http://{0}:{1}/health" -f $hostName, $port
try {
$resp = Invoke-WebRequest -UseBasicParsing $url -TimeoutSec 3
if ($resp.StatusCode -eq 200 -and $resp.Content -match "ok") {
return [pscustomobject]@{ State = "ok"; Url = $url }
}
return [pscustomobject]@{ State = "bad"; Url = $url }
} catch {
return [pscustomobject]@{ State = "down"; Url = $url }
}
}
function Wait-ForHealthOk {
param([int]$Retries = 8)
for ($i = 0; $i -lt $Retries; $i++) {
$health = Get-HealthInfo
if ($health.State -eq "ok") {
return $true
}
Start-Sleep -Seconds 1
}
return $false
}
function Wait-ForStop {
param([int]$Retries = 8)
for ($i = 0; $i -lt $Retries; $i++) {
if (-not (Get-ManagerdProcess)) {
return $true
}
Start-Sleep -Seconds 1
}
return $false
}
function Show-Health {
$health = Get-HealthInfo
switch ($health.State) {
"ok" { Write-Info ("健康检查: ok ({0})" -f $health.Url) }
"bad" { Write-Info ("健康检查: 响应异常 ({0})" -f $health.Url) }
default { Write-Info ("健康检查: down ({0})" -f $health.Url) }
}
}
function Invoke-Build {
Write-Info "编译 managerd.exe ..."
Push-Location $rootDir
try {
& go build -o $exePath .\cmd\managerd
if ($LASTEXITCODE -ne 0) {
throw "go build failed with exit code $LASTEXITCODE"
}
} finally {
Pop-Location
}
Write-Info "编译完成: $exePath"
}
function Invoke-Start {
if (-not (Test-Path $exePath)) {
throw "未找到 managerd.exe请先执行 build"
}
$proc = Get-ManagerdProcess
if ($proc) {
Write-Info ("已在运行PID={0}" -f $proc.ProcessId)
Show-Health
return
}
Write-Info "启动 managerd.exe ..."
Start-Process -FilePath $exePath `
-ArgumentList $configPath `
-WorkingDirectory $rootDir `
-RedirectStandardOutput $logPath `
-RedirectStandardError $errLogPath `
-WindowStyle Hidden
$null = Wait-ForHealthOk
$proc = Get-ManagerdProcess
if (-not $proc) {
throw "进程未启动成功"
}
Write-Info ("已启动PID={0}" -f $proc.ProcessId)
Show-Health
}
function Invoke-Stop {
$proc = Get-ManagerdProcess
if (-not $proc) {
Write-Info "当前未运行"
return
}
Write-Info ("停止 managerd.exePID={0} ..." -f $proc.ProcessId)
Stop-Process -Id $proc.ProcessId -Force
if (-not (Wait-ForStop)) {
throw "进程停止超时"
}
Write-Info "已停止"
}
function Invoke-Restart {
Invoke-Stop
Invoke-Start
}
function Show-Status {
$proc = Get-ManagerdProcess
if ($proc) {
Write-Info ("进程状态: running (PID={0})" -f $proc.ProcessId)
} else {
Write-Info "进程状态: stopped"
}
Show-Health
}
function Show-Usage {
@(
"用法:"
" managerd.bat build"
" managerd.bat start"
" managerd.bat stop"
" managerd.bat restart"
" managerd.bat status"
" managerd.bat help"
""
"说明:"
" build 编译 .\cmd\managerd 到 managerd.exe"
" start 启动现有 managerd.exe并做健康检查"
" stop 停止当前仓库对应的 managerd.exe"
" restart 先 stop 再 start"
" status 查看进程状态与 /health"
" help 显示帮助"
) | ForEach-Object { Write-Host $_ }
}
try {
switch ($Action.ToLowerInvariant()) {
"build" { Invoke-Build }
"start" { Invoke-Start }
"stop" { Invoke-Stop }
"restart" { Invoke-Restart }
"status" { Show-Status }
"help" { Show-Usage }
default {
Write-Host "未知动作: $Action"
Show-Usage
exit 1
}
}
} catch {
Write-Error $_
exit 1
}

View File

@ -1,30 +0,0 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0.."
echo ^> 编译 managerd.exe ...
go build -o managerd.exe ./cmd/managerd
if %ERRORLEVEL% neq 0 (
echo 编译失败,请检查代码错误
pause
exit /b 1
)
echo ^> 停止正在运行的 managerd.exe ...
taskkill /f /im managerd.exe 2>nul
echo ^> 启动 managerd.exe ...
start /b "" managerd.exe
echo ^> 等待启动 ...
timeout /t 2 /nobreak >nul
echo ^> 检查进程状态 ...
tasklist /fi "imagename eq managerd.exe" 2>nul | findstr /i managerd >nul
if %ERRORLEVEL% equ 0 (
echo managerd 已启动
) else (
echo 启动失败
pause
exit /b 1
)