380 lines
17 KiB
HTML
380 lines
17 KiB
HTML
{{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 style="font-size:12px;font-weight:600;margin-bottom:8px">已配置的摄像头</div>
|
||
<div id="assigned-sources" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px">
|
||
<div class="empty-state compact" id="no-assigned-sources">
|
||
<div class="muted">该设备尚未配置摄像头。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="padding:12px;border:1px dashed var(--border);border-radius:var(--radius);margin-bottom:16px">
|
||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">+ 添加摄像头</div>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
||
<select id="existing-source-select" style="width:200px;font-size:12px">
|
||
<option value="">选择已有视频源...</option>
|
||
{{range .ConsoleAllVideoSources}}
|
||
<option value="{{.Name}}|{{.Config.URL}}">{{.Name}}</option>
|
||
{{end}}
|
||
</select>
|
||
<button class="btn" id="btn-add-existing-source" style="white-space:nowrap">添加此摄像头</button>
|
||
</div>
|
||
<div style="color:var(--muted);font-size:11px;margin:8px 0">或手动输入新摄像头</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="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)">
|
||
<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" id="feature-placeholder">加载设备能力中...</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>
|
||
(function(){
|
||
var selectedDevice = null;
|
||
var selectedDeviceName = "";
|
||
var allVideoSources = [];
|
||
var step2Assigned = [];
|
||
var step2Added = [];
|
||
var step2Features = [];
|
||
|
||
function loadAllVideoSources() {
|
||
return {{.WizardVideoSourcesJSON}} || [];
|
||
}
|
||
allVideoSources = 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);
|
||
|
||
document.getElementById("feature-placeholder").textContent = "加载设备能力中...";
|
||
document.getElementById("feature-placeholder").style.display = "";
|
||
step2Assigned = [];
|
||
step2Added = [];
|
||
step2Features = [];
|
||
renderAssignedSources();
|
||
renderFeatureList();
|
||
|
||
fetch("/ui/wizard/device-info?device_id=" + encodeURIComponent(selectedDevice))
|
||
.then(function(r){return r.json()})
|
||
.then(function(info){
|
||
var channels = info.channels || [];
|
||
step2Assigned = channels.map(function(ch){return {name: ch.name, url: ch.hls_url || ""}});
|
||
renderAssignedSources();
|
||
|
||
step2Features = [];
|
||
var caps = (info.capabilities || []).filter(function(c){return c.available});
|
||
document.getElementById("feature-placeholder").style.display = "none";
|
||
renderFeatures(caps);
|
||
})
|
||
.catch(function(){
|
||
document.getElementById("feature-placeholder").textContent = "无法获取设备信息";
|
||
});
|
||
});
|
||
|
||
function renderAssignedSources() {
|
||
var container = document.getElementById("assigned-sources");
|
||
var emptyEl = document.getElementById("no-assigned-sources");
|
||
var allSources = step2Assigned.concat(step2Added);
|
||
|
||
var existingCards = container.querySelectorAll(".assigned-source-card");
|
||
existingCards.forEach(function(c){c.remove()});
|
||
|
||
allSources.forEach(function(src){
|
||
var div = document.createElement("div");
|
||
div.className = "assigned-source-card source-row";
|
||
div.innerHTML =
|
||
'<span class="source-row-name">'+src.name+'</span>'+
|
||
'<span class="source-row-url mono">'+src.url+'</span>'+
|
||
'<button class="btn ghost icon-only" style="min-height:22px;width:22px;height:22px;font-size:12px;color:var(--red);flex-shrink:0" data-remove="'+src.name+'">×</button>';
|
||
container.appendChild(div);
|
||
});
|
||
|
||
if (allSources.length > 0) {
|
||
if (emptyEl) emptyEl.style.display = "none";
|
||
} else {
|
||
if (emptyEl) emptyEl.style.display = "";
|
||
}
|
||
}
|
||
|
||
document.getElementById("assigned-sources").addEventListener("click", function(e){
|
||
var btn = e.target.closest("[data-remove]");
|
||
if (!btn) return;
|
||
var name = btn.getAttribute("data-remove");
|
||
step2Assigned = step2Assigned.filter(function(s){return s.name !== name});
|
||
step2Added = step2Added.filter(function(s){return s.name !== name});
|
||
renderAssignedSources();
|
||
});
|
||
|
||
function renderFeatures(caps) {
|
||
var featDiv = document.getElementById("feature-list");
|
||
featDiv.innerHTML = "";
|
||
if (!caps || caps.length === 0) {
|
||
featDiv.innerHTML = '<span class="muted small">该设备无可用的检测功能。</span>';
|
||
return;
|
||
}
|
||
caps.forEach(function(f){
|
||
var enabled = step2Features.indexOf(f.key) >= 0;
|
||
var label = document.createElement("label");
|
||
label.className = "feature-check" + (enabled ? " enabled" : "");
|
||
label.innerHTML =
|
||
'<input type="checkbox" value="'+f.key+'" '+(enabled?'checked':'')+' style="width:14px;height:14px">'+
|
||
'<span>'+f.label+'</span>';
|
||
label.querySelector("input").addEventListener("change", function(){
|
||
var key = this.value;
|
||
if (this.checked) {
|
||
if (step2Features.indexOf(key) < 0) step2Features.push(key);
|
||
} else {
|
||
step2Features = step2Features.filter(function(k){return k !== key});
|
||
}
|
||
renderFeatures(caps);
|
||
});
|
||
featDiv.appendChild(label);
|
||
});
|
||
}
|
||
|
||
function renderFeatureList() {
|
||
var featDiv = document.getElementById("feature-list");
|
||
featDiv.innerHTML = '<span class="muted small" id="feature-placeholder">加载设备能力中...</span>';
|
||
}
|
||
|
||
document.getElementById("btn-add-existing-source").addEventListener("click", function(){
|
||
var sel = document.getElementById("existing-source-select");
|
||
var val = sel.value;
|
||
if (!val) return;
|
||
var parts = val.split("|");
|
||
var name = parts[0];
|
||
var url = parts.slice(1).join("|");
|
||
if (step2Assigned.find(function(s){return s.name===name}) || step2Added.find(function(s){return s.name===name})) {
|
||
return;
|
||
}
|
||
step2Added.push({name: name, url: url});
|
||
sel.value = "";
|
||
renderAssignedSources();
|
||
});
|
||
|
||
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;
|
||
if (step2Assigned.find(function(s){return s.name===name}) || step2Added.find(function(s){return s.name===name})) {
|
||
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){
|
||
step2Added.push({name: name, url: url});
|
||
allVideoSources.push({name: name, url: url});
|
||
nameEl.value = "";
|
||
urlEl.value = "";
|
||
renderAssignedSources();
|
||
btn.disabled = false;
|
||
btn.textContent = "添加";
|
||
})
|
||
.catch(function(){
|
||
btn.disabled = false;
|
||
btn.textContent = "添加";
|
||
});
|
||
});
|
||
|
||
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 allSources = step2Assigned.concat(step2Added);
|
||
var sourceNames = allSources.map(function(s){return s.name});
|
||
document.getElementById("summary-device").textContent = selectedDeviceName;
|
||
document.getElementById("summary-sources").textContent = sourceNames.length > 0 ? sourceNames.join("、") : "未选择";
|
||
document.getElementById("summary-features").textContent = step2Features.length > 0 ? step2Features.map(featuresToLabel).join("、") : "未选择";
|
||
});
|
||
|
||
document.getElementById("btn-deploy").addEventListener("click", function(){
|
||
var allSources = step2Assigned.concat(step2Added);
|
||
var sourceNames = allSources.map(function(s){return s.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:sourceNames})
|
||
})
|
||
.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}} |