feat: deployment wizard — guided 3-step device setup for standard mode
This commit is contained in:
parent
59a18f3e62
commit
3f3a2a0987
19
docs/实施跟踪.md
19
docs/实施跟踪.md
@ -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/15(13.3%)** | **进行中:1(P0-2)**
|
||||
**已完成:2/15(13.3%)** | **进行中:1(P0-3)**
|
||||
|
||||
@ -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, ""))
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
345
internal/web/ui/templates/wizard.html
Normal file
345
internal/web/ui/templates/wizard.html
Normal 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}}
|
||||
Loading…
Reference in New Issue
Block a user