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

464 lines
24 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 "console"}}
<div class="stats">
<div class="stat">设备 <strong>{{len .ConsoleDevices}}</strong></div>
<div class="stat">视频源 <strong>{{len .ConsoleAllVideoSources}}</strong></div>
<div class="stat">未分配 <strong>{{len .ConsoleVideoSources}}</strong></div>
</div>
<div class="card-tabs console-tabs-wrap" style="margin-top:16px">
<ul class="nav nav-tabs" role="tablist" style="display:flex;align-items:center">
<li class="nav-item" role="presentation">
<a class="nav-link active console-tab" data-panel="device" role="tab" aria-selected="true">📋 设备视图</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link console-tab" data-panel="video" role="tab" aria-selected="false">🎥 视频视图</a>
</li>
<li style="margin-left:auto;display:flex;gap:10px;align-items:center;padding:0 12px">
<span id="console-status" class="muted small"></span>
<button id="console-save-btn" class="btn" type="button" disabled style="padding:4px 14px;font-size:12px;opacity:0.5">保存并下发</button>
</li>
</ul>
<div class="tab-content" style="padding:0">
<div id="console-panel-device" class="card tab-pane active show" style="padding:16px;margin:0;border-top-left-radius:0">
<form id="console-form" method="post" action="/console" style="display:contents">
{{range $i, $cd := .ConsoleDevices}}
<div class="console-device-row">
<div class="console-header">
<span class="status-dot {{if $cd.Device.Online}}ok{{else}}bad{{end}}"></span>
<span class="console-device-name">{{$cd.Device.DisplayName}}</span>
<span class="console-device-meta">{{$cd.Device.IP}} | {{len $cd.Channels}}/{{$cd.MaxUnits}} 路</span>
<span class="console-cam-add" style="margin-top:0;padding-top:0;font-size:11px;cursor:pointer" onclick="showUnassignedForDevice('{{$cd.Device.DeviceID}}')"> 从未分配添加</span>
</div>
<div class="console-body">
<!-- LEFT: Preview tiles in one row -->
<div class="console-preview">
{{if $cd.Channels}}
{{range $ch := $cd.Channels}}
<div class="preview-tile" data-device-id="{{$cd.Device.DeviceID}}" data-channel="{{$ch.Name}}">
<video class="console-video" muted playsinline preload="none"></video>
<span class="preview-label">{{$ch.Display}}</span>
</div>
{{end}}
{{else}}
<div class="preview-tile empty">
</div>
{{end}}
{{$remaining := sub $cd.MaxUnits (len $cd.Channels)}}
{{if gt $remaining 0}}
{{range $j := loop $remaining}}
<div class="preview-tile empty"></div>
{{end}}
{{end}}
</div>
<!-- RIGHT: Features and Cameras side by side -->
<div class="console-controls">
<div class="console-section">
<div class="console-section-title">检测功能</div>
<table class="console-feature-table">
{{range $f := $cd.Features}}
<tr>
<td class="console-feature-check"><input type="checkbox" name="device_{{$cd.Device.DeviceID}}_feature" value="{{$f.Key}}"{{if $f.Enabled}} checked{{end}}{{if not $f.Available}} disabled{{end}}></td>
<td class="console-feature-label{{if $f.Conflict}} text-danger{{else if not $f.Available}} text-muted{{end}}" title="{{$f.Description}}{{if $f.Conflict}} (设备已不支持){{else if not $f.Available}} (设备不支持){{end}}">
{{$f.Label}}{{if $f.Conflict}} ⚠{{end}}
</td>
</tr>
{{end}}
</table>
</div>
<div class="console-section">
<div class="console-section-title">设备负载</div>
<div class="console-metrics">
<div class="metric-row" style="font-size:9px"><span>CPU</span><span>{{printf "%.0f%%" $cd.CPUUsage}}</span></div>
<div class="console-metric-bar"><div style="height:100%;border-radius:2px;width:{{printf "%.0f%%" $cd.CPUUsage}};background:{{if gt $cd.CPUUsage 80.0}}var(--bar-red){{else if gt $cd.CPUUsage 50.0}}var(--bar-amber){{else}}var(--bar-green){{end}}"></div></div>
<div class="metric-row" style="font-size:9px"><span>内存</span><span>{{printf "%.0f%%" $cd.MemUsage}}</span></div>
<div class="console-metric-bar"><div style="height:100%;border-radius:2px;width:{{printf "%.0f%%" $cd.MemUsage}};background:{{if gt $cd.MemUsage 80.0}}var(--bar-red){{else if gt $cd.MemUsage 50.0}}var(--bar-amber){{else}}var(--bar-green){{end}}"></div></div>
<div class="metric-row" style="font-size:9px"><span>NPU</span><span>{{printf "%.0f%%" $cd.NPUUsage}}</span></div>
<div class="console-metric-bar"><div style="height:100%;border-radius:2px;width:{{printf "%.0f%%" $cd.NPUUsage}};background:{{if gt $cd.NPUUsage 80.0}}var(--bar-red){{else if gt $cd.NPUUsage 50.0}}var(--bar-amber){{else}}var(--bar-green){{end}}"></div></div>
<div class="metric-row" style="font-size:9px"><span>磁盘</span><span>{{printf "%.0f%%" $cd.DiskUsage}}</span></div>
<div class="console-metric-bar"><div style="height:100%;border-radius:2px;width:{{printf "%.0f%%" $cd.DiskUsage}};background:{{if gt $cd.DiskUsage 80.0}}var(--bar-red){{else if gt $cd.DiskUsage 50.0}}var(--bar-amber){{else}}var(--bar-green){{end}}"></div></div>
<div class="metric-row" style="font-size:9px"><span>温度</span><span>{{printf "%.0f°C" $cd.Temperature}}</span></div>
<div class="console-metric-bar"><div style="height:100%;border-radius:2px;width:{{printf "%.0f%%" $cd.Temperature}};background:{{if gt $cd.Temperature 60.0}}var(--bar-red){{else if gt $cd.Temperature 40.0}}var(--bar-amber){{else}}var(--bar-green){{end}}"></div></div>
</div>
</div>
</div>
<!-- hidden: assigned video sources for this device -->
{{range $ch := $cd.Channels}}
<input type="hidden" name="device_{{$cd.Device.DeviceID}}_source" value="{{$ch.SourceName}}">
{{end}}
</div>
</div>
{{else}}
<div class="empty-state" style="padding:0;border:none;background:transparent">
<div class="empty-title">暂无可管理设备</div>
<div class="muted">请先在"设备"页添加或发现设备。</div>
</div>
{{end}}
<!-- Unassigned pool -->
<div style="margin-top:16px">
<div class="section-title">
<div>
<h3 class="title-with-icon">{{icon "device"}}<span>未分配视频源({{len .ConsoleVideoSources}}路)</span></h3>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:8px">
{{range $s := .ConsoleVideoSources}}
<div class="console-source-card">
<div class="console-source-name">{{$s.Name}}</div>
<div class="console-source-meta">{{if $s.Area}}{{$s.Area}} · {{end}}{{$s.SourceSummary}}</div>
<div class="console-source-assign" data-source="{{$s.Name}}" onclick="assignSourceToDevice('{{$s.Name}}')"> 分配到设备</div>
</div>
{{else}}
<span class="muted small">暂无未分配视频源</span>
{{end}}
</div>
</div>
</form>
</div><!-- end device panel -->
<div id="console-panel-video" class="card tab-pane" style="padding:16px;margin:0;border-top-left-radius:0">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">
{{range $cd := $.ConsoleDevices}}
{{range $ch := $cd.Channels}}
<div class="video-card">
<div class="video-card-preview" data-device-id="{{$cd.Device.DeviceID}}" data-channel="{{$ch.Name}}">
<video class="video-card-video" muted playsinline preload="none"></video>
</div>
<div class="video-card-info">
<div class="video-card-name">{{$ch.SourceName}}</div>
<div class="video-card-device">{{$cd.Device.DisplayName}}</div>
</div>
</div>
{{end}}
{{end}}
{{range $s := $.ConsoleVideoSources}}
<div class="video-card" style="border:1px dashed #555">
<div style="background:#000;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;overflow:hidden">
<span style="color:#666;font-size:12px">⏹ {{$s.Name}}</span>
</div>
<div style="padding:8px 10px">
<div style="font-size:13px;font-weight:500;margin-bottom:2px">{{$s.Name}}</div>
<a href="#" style="font-size:11px;color:var(--primary);text-decoration:none"> 分配到设备</a>
</div>
</div>
{{end}}
</div>
</div>
</div><!-- tab-content -->
</div><!-- card-tabs -->
<script src="/assets/vendor/hls.min.js"></script>
<script>
(function() {
// Tab switching
document.querySelectorAll('.console-tab').forEach(function(tab) {
tab.addEventListener('click', function(e) {
e.preventDefault();
document.querySelectorAll('.console-tab').forEach(function(t) {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
document.querySelectorAll('.tab-pane').forEach(function(p) {
p.classList.remove('active', 'show');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
var panel = document.getElementById('console-panel-' + tab.getAttribute('data-panel'));
if (panel) {
panel.classList.add('active', 'show');
// Initialize video view players on first switch
if (tab.getAttribute('data-panel') === 'video' && !tab._videoInit) {
tab._videoInit = true;
initVideoPlayers('.video-card-preview[data-device-id]');
}
}
});
});
function initVideoPlayers(selector) {
var tiles = document.querySelectorAll(selector);
var deviceChannels = {};
tiles.forEach(function(tile) {
var did = tile.getAttribute('data-device-id');
var ch = tile.getAttribute('data-channel');
if (!did || !ch) return;
if (!deviceChannels[did]) deviceChannels[did] = {};
deviceChannels[did][ch] = tile.querySelector('video');
});
var deviceIds = Object.keys(deviceChannels);
deviceIds.forEach(function(did) {
fetch('/api/monitor/channels?device_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.channels) return;
data.channels.forEach(function(ch) {
var video = deviceChannels[did][ch.name];
if (!video) return;
var proxyUrl = '/hls/' + encodeURIComponent(did) + '/hls/' + encodeURIComponent(ch.name) + '/index.m3u8';
if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(proxyUrl);
hls.attachMedia(video);
video.play().catch(function(){});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = proxyUrl;
video.play().catch(function(){});
}
});
})
.catch(function() {});
});
}
// Init device view players immediately
initVideoPlayers('.preview-tile[data-device-id]');
// Source assignment
window.assignSourceToDevice = function(sourceName) {
// Check if a specific device context exists
var ctx = this._assignContext;
if (ctx && ctx.deviceID) {
addSourceToDeviceForm(ctx.deviceID, sourceName);
document.querySelector('.source-assign-overlay').remove();
return;
}
showDevicePicker(sourceName);
};
window.showUnassignedForDevice = function(deviceID) {
var cards = document.querySelectorAll('.console-source-card');
var visible = [];
cards.forEach(function(card) {
if (card.style.display !== 'none') {
var nameEl = card.querySelector('.console-source-name');
if (nameEl) visible.push(nameEl.textContent.trim());
}
});
if (visible.length === 0) { alert('暂无未分配视频源'); return; }
var html = '<div class="source-assign-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center" onclick="this.remove()"><div style="background:var(--surface);border-radius:8px;padding:20px;min-width:300px" onclick="event.stopPropagation()"><div style="font-size:14px;font-weight:600;margin-bottom:12px">为设备添加视频源</div>';
visible.forEach(function(name) {
html += '<div style="padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:4px" onmouseover="this.style.background=\'var(--surface-soft)\'" onmouseout="this.style.background=\'transparent\'" onclick="addSourceToDeviceForm(\'' + deviceID + '\', \'' + name + '\');this.closest(\'.source-assign-overlay\').remove()">' + name + '</div>';
});
html += '<div style="margin-top:12px;text-align:right"><button class="btn" style="font-size:12px" onclick="this.closest(\'.source-assign-overlay\').remove()">取消</button></div></div></div>';
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
};
function addSourceToDeviceForm(deviceID, sourceName) {
var row = document.querySelector('.console-device-row[data-device-id="' + deviceID + '"]');
if (!row) return;
var existing = row.querySelector('input[name="device_' + deviceID + '_source"][value="' + sourceName + '"]');
if (existing) return;
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'device_' + deviceID + '_source';
input.value = sourceName;
row.appendChild(input);
// Hide source card
var card = document.querySelector('.console-source-assign[data-source="' + sourceName + '"]');
if (card) {
var sc = card.closest('.console-source-card');
if (sc) sc.style.display = 'none';
}
}
function showDevicePicker(sourceName) {
var rows = document.querySelectorAll('.console-device-row');
if (rows.length === 0) { alert('暂无可分配设备'); return; }
var html = '<div class="source-assign-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center" onclick="this.remove()"><div style="background:var(--surface);border-radius:8px;padding:20px;min-width:300px;max-width:400px" onclick="event.stopPropagation()"><div style="font-size:14px;font-weight:600;margin-bottom:12px">分配 \'' + sourceName + '\' 到设备</div>';
rows.forEach(function(row) {
var did = row.getAttribute('data-device-id');
var nameEl = row.querySelector('.console-device-name');
var dname = nameEl ? nameEl.textContent : did;
html += '<div style="padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:4px" onmouseover="this.style.background=\'var(--surface-soft)\'" onmouseout="this.style.background=\'transparent\'" onclick="addSourceToDeviceForm(\'' + did + '\', \'' + sourceName + '\');this.closest(\'.source-assign-overlay\').remove()">' + dname + ' <span style="font-size:11px;color:var(--muted)">' + did + '</span></div>';
});
html += '<div style="margin-top:12px;text-align:right"><button class="btn" style="font-size:12px" onclick="this.closest(\'.source-assign-overlay\').remove()">取消</button></div></div></div>';
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
}
// ---- dirty state tracking ----
var initialFeatures = {};
var initialSources = {};
document.querySelectorAll('.console-device-row').forEach(function(row) {
var did = row.getAttribute('data-device-id');
initialFeatures[did] = [];
initialSources[did] = [];
row.querySelectorAll('input[type=checkbox]').forEach(function(cb) {
if (cb.checked) initialFeatures[did].push(cb.value);
});
row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) {
initialSources[did].push(h.value);
});
});
function isDirty() {
var rows = document.querySelectorAll('.console-device-row');
for (var i = 0; i < rows.length; i++) {
var did = rows[i].getAttribute('data-device-id');
var feats = [];
rows[i].querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) { feats.push(cb.value); });
if (feats.sort().join(',') !== (initialFeatures[did]||[]).sort().join(',')) return true;
var srcs = [];
rows[i].querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) { srcs.push(h.value); });
if (srcs.sort().join(',') !== (initialSources[did]||[]).sort().join(',')) return true;
}
return false;
}
function updateSaveButton() {
var btn = document.getElementById('console-save-btn');
if (isDirty()) {
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
} else {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'default';
}
}
// Listen for changes
document.querySelectorAll('.console-device-row input[type=checkbox]').forEach(function(cb) {
cb.addEventListener('change', updateSaveButton);
});
// Override addSourceToDeviceForm to also trigger dirty check
var _origAddSource = addSourceToDeviceForm;
addSourceToDeviceForm = function(did, src) {
_origAddSource(did, src);
updateSaveButton();
};
// Confirmation dialog (one-shot listener using {once:true})
var saveBtn = document.getElementById('console-save-btn');
var confirmHandler = function() {
if (!isDirty()) return;
var summary = '';
document.querySelectorAll('.console-device-row').forEach(function(row) {
var did = row.getAttribute('data-device-id');
var nameEl = row.querySelector('.console-device-name');
var dname = nameEl ? nameEl.textContent : did;
summary += '<div style="font-weight:600;margin:8px 0 4px">' + dname + '</div>';
row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) {
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + cb.value + '</div>';
});
row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) {
summary += '<div style="font-size:12px;padding-left:12px;color:var(--muted)">🎥 ' + h.value + '</div>';
});
});
if (!summary) { alert('未选择任何检测功能或视频源'); return; }
var hasFeature = false;
document.querySelectorAll('.console-device-row input[type=checkbox]:checked').forEach(function() { hasFeature = true; });
if (!hasFeature) { alert('请至少选择一项检测功能'); return; }
var overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
overlay.innerHTML = '<div style="background:var(--surface);border-radius:8px;padding:24px;min-width:360px;max-width:500px" onclick="event.stopPropagation()">' +
'<div style="font-size:16px;font-weight:600;margin-bottom:4px">确认下发配置</div>' +
'<div style="font-size:12px;color:var(--muted);margin-bottom:16px">以下配置将被渲染并下发到设备edge-server 将热更新。</div>' +
summary +
'<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">' +
'<button class="btn secondary" style="font-size:12px" onclick="this.closest(\'.confirm-overlay\').remove()">取消</button>' +
'<button class="btn" style="font-size:12px" id="confirm-deploy-btn">确认下发</button>' +
'</div></div>';
overlay.addEventListener('click', function() { overlay.remove(); });
document.body.appendChild(overlay);
document.getElementById('confirm-deploy-btn').addEventListener('click', function() {
overlay.remove();
document.getElementById('console-form').submit();
}, {once: true});
};
saveBtn.addEventListener('click', confirmHandler);
// Show status from URL param, then clear the param so refresh doesn't repeat it.
(function() {
var m = location.search.match(/[?&]task=([^&]+)/);
var msg = location.search.match(/[?&]msg=([^&]+)/);
if (m) {
document.getElementById('console-status').innerHTML = '⏳ 任务 ' + m[1].substring(0,8) + ' 执行中,' + (msg ? decodeURIComponent(msg[1]) : '页面将自动刷新...');
setTimeout(function(){ location.href = '/console'; }, 8000);
} else if (msg) {
document.getElementById('console-status').innerHTML = decodeURIComponent(msg[1]);
setTimeout(function(){ document.getElementById('console-status').innerHTML = ''; }, 5000);
}
// Clear URL params so refresh doesn't re-show stale messages.
if (location.search) {
var url = new URL(location.href);
url.searchParams.delete('msg');
url.searchParams.delete('task');
url.searchParams.delete('error');
history.replaceState(null, '', url.href);
}
})();
})();
</script>
<style>
.console-section-title { font-size: 10px; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .8px; font-weight: 600; }
.console-section { background: var(--surface-soft); border-radius: 6px; padding: 10px 12px; width: 50%; overflow: hidden; }
.console-device-row { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 12px; }
.console-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.console-device-name { font-weight: 600; font-size: 14px; }
.console-device-meta { font-size: 11px; color: var(--muted); }
.console-body { display: flex; gap: 14px; min-height: 80px; }
/* Preview: single row, 4 equal tiles */
.console-preview { flex: 1; min-width: 350px; background: #0d0d0d; border-radius: 6px; padding: 4px; display: flex; gap: 4px; }
.preview-tile { background: #000; border-radius: 4px; width: 25%; aspect-ratio: 16/9; position: relative; overflow: hidden; }
.preview-tile.empty { background: #080808; }
.preview-tile video { width: 100%; height: 100%; object-fit: cover; display: block; }
.preview-label { position: absolute; bottom: 3px; left: 4px; background: rgba(0,0,0,0.7); color: #aaa; padding: 1px 5px; border-radius: 3px; font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - 8px); pointer-events: none; z-index: 1; }
/* Controls: side by side, 50/50 */
.console-controls { width: 460px; flex-shrink: 0; display: flex; gap: 10px; }
.console-section { background: var(--surface-soft); border-radius: 6px; padding: 10px 12px; width: 50%; overflow: hidden; }
.console-feature-table { width: 100%; border-collapse: collapse; border: none; background: transparent; }
.console-feature-table tr { border: none; }
.console-feature-table td { border: none; padding: 2px 0; vertical-align: middle; background: transparent; }
.console-feature-check { width: 18px; }
.console-feature-check input { margin: 0; display: block; }
.console-feature-label { font-size: 12px; white-space: nowrap; }
.console-cam-add { margin-top: auto; padding-top: 6px; font-size: 11px; color: var(--primary); cursor: pointer; display: inline-flex; align-items: center; gap: 3px; }
/* Metrics in console */
.console-metrics { display: flex; flex-direction: column; gap: 2px; }
.metric-row { display: flex; justify-content: space-between; font-size: 11px; padding: 1px 0; }
.console-controls { --bar-green: #66c98f; --bar-amber: #d8a657; --bar-red: #e46f72; }
.console-metric-bar { height: 3px; background: #c0c0c0; margin-bottom: 1px; border-radius: 2px; overflow: hidden; }
/* Console tabs flush */
.card-tabs.console-tabs-wrap .nav-tabs { margin-bottom: 0; }
.card-tabs.console-tabs-wrap .tab-content { padding: 0; }
.card-tabs.console-tabs-wrap .tab-content > .card { margin-top: -1px; border-top-left-radius: 0; border-top-right-radius: 0; }
.card-tabs.console-tabs-wrap .nav-tabs .nav-link { padding: 8px 20px; font-size: 13px; }
.console-source-card { background: var(--surface-soft); border: 1px dashed var(--border); border-radius: 8px; padding: 12px; width: 180px; }
.console-source-name { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
.console-source-meta { font-size: 11px; color: var(--muted); }
.console-source-assign { margin-top: 6px; font-size: 11px; color: var(--primary); cursor: pointer; }
.video-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.video-card-preview { background: #000; aspect-ratio: 16/9; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; }
.video-card-preview video { width: 100%; height: 100%; object-fit: cover; display: block; }
.video-card-info { padding: 8px 10px; }
.video-card-name { font-size: 13px; font-weight: 500; }
.video-card-device { font-size: 11px; color: var(--primary); margin-top: 2px; }
.video-card-device.muted { color: var(--muted); }
</style>
{{end}}