feat: deployment wizard — guided 3-step device setup for standard mode

This commit is contained in:
tian 2026-07-17 16:59:00 +08:00
parent 59a18f3e62
commit 3f3a2a0987
4 changed files with 498 additions and 7 deletions

View File

@ -5,8 +5,8 @@
| # | 事项 | 状态 | 开始 | 完成 |
|---|------|------|------|------|
| 1 | 告警中心增强 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 2 | 仪表盘重做(实时告警流+统计卡片) | 🔄 进行中 | 2026-07-17 | - |
| 3 | 一键配置向导(场景化部署 3 步流程) | ⬜ 待开始 | - | - |
| 2 | 仪表盘重做(实时告警流+统计卡片) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 3 | 一键配置向导(场景化部署 3 步流程) | 🔄 进行中 | 2026-07-17 | - |
| 4 | 基础登录鉴权 | ⬜ 待开始 | - | - |
| 5 | 监控页 HLS.js 本地化 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
@ -32,18 +32,23 @@
## 进行中
### P0-2 仪表盘重做
### P0-3 一键配置向导
| # | 子任务 | 状态 |
|---|--------|------|
| 2.1 | 统计卡片 — 今日告警/未处理告警/在线设备/检测路数 | ✅ |
| 2.2 | SSE 实时告警流 — EventSource 连接 /api/alarms/stream | ✅ |
| 2.3 | 保留原有 — 异常设备列表 + 最近任务 | ✅ |
| 3.1 | 路由 + 3 个 Handler页面、创建设备、下发配置 | ✅ |
| 3.2 | 3 步模板:选设备→配检测→确认下发 | ✅ |
| 3.3 | 内联添加摄像头RTSP URL | ✅ |
| 3.4 | 菜单项(标准模式可见) | ✅ |
## 已完成事项
### 2026-07-17
- **P0-2 仪表盘重做**
- 统计卡片:今日告警/未处理告警/在线设备/检测路数
- SSE 实时告警流EventSource 连接 /api/alarms/stream新告警实时推入
- **P0-1 告警中心增强**
- DB 迁移:`alarm_records` 增加 `status`, `severity`, `acknowledged_at/by`, `resolved_at/by`
- 服务层:`AlarmRecord` 扩展 + SSE 广播 + 确认/关闭/改等级 API
@ -62,4 +67,4 @@
---
**已完成2/1513.3%** | **进行中1P0-2**
**已完成2/1513.3%** | **进行中1P0-3**

View File

@ -109,6 +109,7 @@ type PageData struct {
ConsoleDevices []ConsoleDeviceData
ConsoleVideoSources []service.ConfigVideoSourceAsset
ConsoleAllVideoSources []service.ConfigVideoSourceAsset
WizardVideoSourcesJSON template.HTML
ConsoleUnassignedUnits []service.RecognitionUnitAsset
FaceGalleryPersons []storage.PersonRecord
Templates []service.Template
@ -705,6 +706,10 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/dashboard", u.pageDashboard)
r.Get("/console", u.pageConsole)
r.Post("/console", u.actionConsoleSave)
r.Get("/wizard", u.pageWizard)
r.Get("/wizard/device-info", u.apiWizardDeviceInfo)
r.Post("/wizard/apply", u.actionWizardApply)
r.Post("/wizard/create-source", u.actionWizardCreateSource)
r.Get("/devices", u.pageDevices)
r.Get("/devices/{id}/control", u.pageDeviceControl)
r.Get("/plans", u.redirectPlansToSceneTemplates)
@ -1307,6 +1312,141 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound)
}
func (u *UI) pageWizard(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "部署向导"}
data.Devices = u.registry.GetDevices()
if u.preview != nil {
if sources, err := u.preview.ListVideoSources(); err == nil {
data.ConsoleAllVideoSources = sources
type wizardSource struct {
Name string `json:"name"`
URL string `json:"url"`
}
var list []wizardSource
for _, s := range sources {
list = append(list, wizardSource{Name: s.Name, URL: s.Config.URL})
}
if b, err := json.Marshal(list); err == nil {
data.WizardVideoSourcesJSON = template.HTML(b)
}
}
}
u.render(w, r, "wizard", data)
}
func (u *UI) actionWizardApply(w http.ResponseWriter, r *http.Request) {
if u.autoConfig == nil {
http.Error(w, "not available", http.StatusServiceUnavailable)
return
}
var req struct {
DeviceID string `json:"device_id"`
Features []string `json:"features"`
SourceNames []string `json:"source_names"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if u.preview != nil && len(req.Features) > 0 {
_ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features)
}
result, err := u.autoConfig.BuildPipeline(service.AutoConfigRequest{
DeviceID: req.DeviceID,
Features: req.Features,
SourceNames: req.SourceNames,
}, true)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func (u *UI) actionWizardCreateSource(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "not available", http.StatusServiceUnavailable)
return
}
name := strings.TrimSpace(r.FormValue("name"))
rtspURL := strings.TrimSpace(r.FormValue("url"))
if name == "" || rtspURL == "" {
http.Error(w, "name and url required", http.StatusBadRequest)
return
}
asset := service.ConfigVideoSourceAsset{
Name: name,
SourceType: "rtsp",
Config: service.VideoSourceConfig{
URL: rtspURL,
Resolution: "1920x1080",
FPS: "25",
},
}
if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"name": name})
}
func (u *UI) apiWizardDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
if deviceID == "" {
http.Error(w, "device_id required", http.StatusBadRequest)
return
}
var dev *models.Device
for _, d := range u.registry.GetDevices() {
if d != nil && d.DeviceID == deviceID {
dev = d
break
}
}
if dev == nil {
http.Error(w, "device not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"online": dev.Online,
"device_id": dev.DeviceID,
"name": dev.DisplayName(),
}
if u.preview != nil {
if assignment, _ := u.preview.GetDeviceAssignment(deviceID); assignment != nil {
var sourceNames []string
for _, ref := range assignment.RecognitionUnits {
if unit, _ := u.preview.GetRecognitionUnit(ref); unit != nil {
sourceNames = append(sourceNames, unit.VideoSourceRef)
}
}
resp["sources"] = sourceNames
}
}
if u.agent != nil && dev.Online {
body, code, _ := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil)
if code == 200 {
var capsResp struct {
Capabilities []struct {
Key string `json:"key"`
Label string `json:"label"`
Available bool `json:"available"`
Description string `json:"description"`
} `json:"capabilities"`
}
if json.Unmarshal(body, &capsResp) == nil {
resp["capabilities"] = capsResp.Capabilities
}
}
}
json.NewEncoder(w).Encode(resp)
}
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, ""))
}

View File

@ -21,6 +21,7 @@
<nav class="side-nav" aria-label="主导航">
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
<a href="/ui/console"><span class="nav-icon">{{icon "apply"}}</span><span>管控台</span></a>
<a href="/ui/wizard"><span class="nav-icon">{{icon "control"}}</span><span>部署向导</span></a>
<a href="/ui/alarms"><span class="nav-icon">{{icon "bell"}}</span><span>告警中心</span></a>
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
<a href="/ui/monitor"><span class="nav-icon">{{icon "devices"}}</span><span>视频监控</span></a>

View File

@ -0,0 +1,345 @@
{{define "wizard"}}
<div class="hero-band">
<div>
<div class="crumb">部署</div>
<h2>部署向导</h2>
</div>
</div>
<div class="card" id="wizard-container">
<div class="wizard-stepper" style="display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:20px;padding:0 40px">
<div class="wizard-step active" data-step="1">
<span class="wizard-step-num">1</span>
<span>选择设备</span>
</div>
<div class="wizard-step-line"></div>
<div class="wizard-step" data-step="2">
<span class="wizard-step-num">2</span>
<span>配置检测</span>
</div>
<div class="wizard-step-line"></div>
<div class="wizard-step" data-step="3">
<span class="wizard-step-num">3</span>
<span>确认下发</span>
</div>
</div>
<div id="wizard-step-1" class="wizard-panel">
<h3 style="margin-top:0">选择要部署的设备</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px" id="device-grid">
{{$hasOnline := false}}
{{range .Devices}}
{{if .Online}}
{{$hasOnline = true}}
<div class="wizard-device-card" data-device-id="{{.DeviceID}}" data-device-name="{{.DeviceName}}" data-device-ip="{{.IP}}">
<div class="wizard-device-status online">在线</div>
<div class="wizard-device-name">{{.DeviceName}}</div>
<div class="wizard-device-ip mono small">{{.IP}}</div>
</div>
{{end}}
{{end}}
{{if not $hasOnline}}
<div class="empty-state compact">
<div class="empty-title">暂无在线设备</div>
<div class="muted">请先在"设备"页面发现并添加设备。</div>
</div>
{{end}}
</div>
<div style="margin-top:20px;display:flex;justify-content:flex-end">
<button class="btn primary" id="btn-next-step" disabled>下一步 →</button>
</div>
</div>
<div id="wizard-step-2" class="wizard-panel" style="display:none">
<h3 style="margin-top:0">配置 <span id="step2-device-name">-</span> 的检测任务</h3>
<div id="source-list" style="display:flex;flex-direction:column;gap:12px">
</div>
<div style="margin-top:16px;padding:12px;border:1px dashed var(--border);border-radius:var(--radius)" id="add-source-panel">
<div style="font-size:12px;font-weight:600;margin-bottom:8px">+ 添加摄像头</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" id="new-source-name" placeholder="摄像头名称(如:车间东门)" style="width:160px;font-size:12px">
<input type="text" id="new-source-url" placeholder="RTSP 地址rtsp://..." style="flex:1;min-width:260px;font-size:12px;font-family:monospace">
<button class="btn" id="btn-add-source" style="white-space:nowrap">添加</button>
</div>
</div>
<div style="margin-top:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)" id="feature-selector">
<div style="font-size:12px;font-weight:600;margin-bottom:8px">检测功能</div>
<div id="feature-list" style="display:flex;gap:12px;flex-wrap:wrap">
<span class="muted small">请先选择设备...</span>
</div>
</div>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<button class="btn ghost" id="btn-prev-step">← 上一步</button>
<button class="btn primary" id="btn-goto-step3">下一步 →</button>
</div>
</div>
<div id="wizard-step-3" class="wizard-panel" style="display:none">
<h3 style="margin-top:0">确认部署</h3>
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);margin-bottom:12px">
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">设备</div>
<div style="font-weight:600" id="summary-device">-</div>
</div>
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);margin-bottom:12px">
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">检测摄像头</div>
<div id="summary-sources" style="font-weight:600">-</div>
</div>
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);margin-bottom:12px">
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">检测功能</div>
<div id="summary-features" style="font-weight:600">-</div>
</div>
<div id="deploy-result" style="display:none;margin-top:12px"></div>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<button class="btn ghost" id="btn-prev-step3">← 上一步</button>
<button class="btn primary" id="btn-deploy">确认下发</button>
</div>
</div>
</div>
<style>
.wizard-stepper{margin:0 auto;max-width:500px}
.wizard-step{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);flex-shrink:0}
.wizard-step.active{color:var(--text)}
.wizard-step.done{color:var(--green)}
.wizard-step-num{width:24px;height:24px;border-radius:50%;border:1.5px solid var(--border);display:grid;place-items:center;font-size:11px;font-weight:600}
.wizard-step.active .wizard-step-num{border-color:var(--primary);background:var(--primary-strong);color:#fff}
.wizard-step.done .wizard-step-num{border-color:var(--green);background:var(--green);color:#fff}
.wizard-step-line{flex:1;height:1.5px;background:var(--border);margin:0 8px;min-width:20px}
.wizard-step.done+.wizard-step-line,.wizard-step.active+.wizard-step-line{background:var(--primary)}
.wizard-device-card{padding:14px;border:2px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:border-color .16s ease}
.wizard-device-card:hover{border-color:var(--primary)}
.wizard-device-card.selected{border-color:var(--primary);background:var(--surface-soft)}
.wizard-device-status{font-size:10px;font-weight:600;margin-bottom:6px}
.wizard-device-status.online{color:var(--green)}
.wizard-device-name{font-size:13px;font-weight:600}
.wizard-device-ip{margin-top:4px;color:var(--muted)}
.source-row{display:flex;align-items:center;gap:8px;padding:10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.source-row-name{font-weight:500;font-size:12px;min-width:100px}
.source-row-url{font-size:11px;color:var(--muted);flex:1}
.feature-check{display:flex;align-items:center;gap:6px;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;user-select:none}
.feature-check.enabled{border-color:var(--primary);background:var(--surface-soft)}
.feature-check input{width:14px;height:14px;margin:0}
.feature-check label{font-size:12px;cursor:pointer}
</style>
<script src="/ui/assets/vendor/hls.min.js"></script>
<script>
(function(){
var selectedDevice = null;
var selectedDeviceName = "";
var step1Sources = [];
var step1AllVideoSources = [];
var step1Features = null;
var step2Sources = [];
var step2Features = [];
function loadAllVideoSources() {
return {{.WizardVideoSourcesJSON}} || [];
}
step1AllVideoSources = loadAllVideoSources();
var currentStep = 1;
function setStep(n) {
currentStep = n;
document.querySelectorAll(".wizard-step").forEach(function(el){
var s = parseInt(el.getAttribute("data-step"));
el.classList.remove("active","done");
if (s < n) el.classList.add("done");
if (s === n) el.classList.add("active");
});
document.querySelectorAll(".wizard-panel").forEach(function(el){el.style.display="none"});
document.getElementById("wizard-step-"+n).style.display="";
}
function featuresToLabel(key) {
var map = {face:"人脸识别",shoe:"劳保鞋检测",helmet:"安全帽检测",smoking:"抽烟检测",intrusion:"区域入侵"};
return map[key] || key;
}
document.getElementById("device-grid").addEventListener("click", function(e){
var card = e.target.closest(".wizard-device-card");
if (!card) return;
document.querySelectorAll(".wizard-device-card").forEach(function(c){c.classList.remove("selected")});
card.classList.add("selected");
selectedDevice = card.getAttribute("data-device-id");
selectedDeviceName = card.getAttribute("data-device-name");
document.getElementById("btn-next-step").disabled = false;
});
document.getElementById("btn-next-step").addEventListener("click", function(){
if (!selectedDevice) return;
document.getElementById("step2-device-name").textContent = selectedDeviceName;
setStep(2);
fetch("/ui/wizard/device-info?device_id=" + encodeURIComponent(selectedDevice))
.then(function(r){return r.json()})
.then(function(info){
step1Features = (info.capabilities || []).filter(function(c){return c.available});
var existingSources = info.sources || [];
step2Sources = [];
var sourceNames = [];
existingSources.forEach(function(sn){
var src = step1AllVideoSources.find(function(v){return v.name === sn});
if (src) {
step2Sources.push({name:src.name, url:src.url, existing:true});
sourceNames.push(src.name);
}
});
// Also add unassigned sources
step1AllVideoSources.forEach(function(src){
if (sourceNames.indexOf(src.name) < 0) {
step2Sources.push({name:src.name, url:src.url, existing:false});
sourceNames.push(src.name);
}
});
step2Features = [];
renderStep2();
});
});
function renderStep2() {
var list = document.getElementById("source-list");
list.innerHTML = "";
step2Sources.forEach(function(src, i){
var div = document.createElement("div");
div.className = "source-row";
div.innerHTML =
'<span class="source-row-name">'+src.name+'</span>'+
'<span class="source-row-url mono">'+src.url+'</span>'+
'<label style="font-size:11px;display:flex;align-items:center;gap:4px;cursor:pointer">'+
'<input type="checkbox" class="source-check" data-index="'+i+'" '+(src.existing ? 'checked' : '')+' style="width:14px;height:14px">'+
'启用</label>'+
(!src.existing ? '<button class="btn ghost icon-only" style="min-height:22px;width:22px;height:22px;font-size:11px;color:var(--red)" onclick="removeSource('+i+')">×</button>' : '');
list.appendChild(div);
});
renderFeatureList();
}
function renderFeatureList() {
var features = step1Features || [];
var featDiv = document.getElementById("feature-list");
featDiv.innerHTML = "";
if (features.length === 0) {
featDiv.innerHTML = '<span class="muted small">该设备无可用的检测功能。</span>';
return;
}
features.forEach(function(f){
var label = document.createElement("label");
label.className = "feature-check" + (step2Features.indexOf(f.key) >= 0 ? " enabled" : "");
label.innerHTML =
'<input type="checkbox" value="'+f.key+'" '+(step2Features.indexOf(f.key) >= 0 ? 'checked' : '')+' style="width:14px;height:14px">'+
'<span>'+f.label+'</span>';
label.querySelector("input").addEventListener("change", function(){
var checked = this.checked;
var key = this.value;
if (checked) {
if (step2Features.indexOf(key) < 0) step2Features.push(key);
} else {
step2Features = step2Features.filter(function(k){return k !== key});
}
renderFeatureList();
});
featDiv.appendChild(label);
});
}
document.getElementById("btn-add-source").addEventListener("click", function(){
var nameEl = document.getElementById("new-source-name");
var urlEl = document.getElementById("new-source-url");
var name = nameEl.value.trim();
var url = urlEl.value.trim();
if (!name || !url) return;
var btn = this;
btn.disabled = true;
btn.textContent = "添加中...";
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"}})
.then(function(r){return r.json()})
.then(function(data){
step2Sources.push({name:name, url:url, existing:false});
step1AllVideoSources.push({name:name, url:url});
nameEl.value = "";
urlEl.value = "";
renderStep2();
btn.disabled = false;
btn.textContent = "添加";
})
.catch(function(){
btn.disabled = false;
btn.textContent = "添加";
});
});
window.removeSource = function(i) {
step2Sources.splice(i, 1);
renderStep2();
};
document.getElementById("btn-prev-step").addEventListener("click", function(){setStep(1)});
document.getElementById("btn-prev-step3").addEventListener("click", function(){setStep(2)});
document.getElementById("btn-goto-step3").addEventListener("click", function(){
setStep(3);
var sources = [];
document.querySelectorAll(".source-check:checked").forEach(function(cb){
var idx = parseInt(cb.getAttribute("data-index"));
sources.push(step2Sources[idx].name);
});
document.getElementById("summary-device").textContent = selectedDeviceName;
document.getElementById("summary-sources").textContent = sources.length > 0 ? sources.join("、") : "未选择";
document.getElementById("summary-features").textContent = step2Features.length > 0 ? step2Features.map(featuresToLabel).join("、") : "未选择";
});
document.getElementById("btn-deploy").addEventListener("click", function(){
var sources = [];
document.querySelectorAll(".source-check:checked").forEach(function(cb){
var idx = parseInt(cb.getAttribute("data-index"));
sources.push(step2Sources[idx].name);
});
var btn = this;
btn.disabled = true;
btn.textContent = "下发中...";
fetch("/ui/wizard/apply", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({device_id:selectedDevice, features:step2Features, source_names:sources})
})
.then(function(r){return r.json()})
.then(function(result){
var resultDiv = document.getElementById("deploy-result");
resultDiv.style.display = "";
if (result.error) {
resultDiv.innerHTML = '<div class="error">部署失败:'+result.error+'</div>';
btn.disabled = false;
btn.textContent = "重试";
} else {
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>';
btn.style.display = "none";
currentStep = 3;
document.querySelectorAll(".wizard-step").forEach(function(el){el.classList.add("done")});
}
})
.catch(function(err){
document.getElementById("deploy-result").innerHTML = '<div class="error">请求失败:'+err.message+'</div>';
document.getElementById("deploy-result").style.display = "";
btn.disabled = false;
btn.textContent = "重试";
});
});
})();
</script>
{{end}}