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

382 lines
17 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">在线{{if index $.WizardStale .DeviceID}}<span style="color:var(--amber);margin-left:4px">⚠ 需更新</span>{{end}}</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("/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.display || ch.name, url: ch.rtsp_url || ""}});
renderAssignedSources();
step2Features = [];
var caps = (info.capabilities || []).filter(function(c){return c.available});
// Pre-check features already enabled on this device
var enabled = info.enabled_features || [];
caps.forEach(function(c){ if (enabled.indexOf(c.key) >= 0) step2Features.push(c.key); });
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("/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("/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></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}}