safesight-control/internal/web/ui/templates/wizard.html

345 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{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}}