safesight-control/internal/web/ui/templates/console.html
tian 25cf456047 feat: console one-click auto-config pipeline
管控台一键自动配置:勾选检测功能 + 分配视频源 → 自动生成配置并下发

新增:
- FeatureRegistry: 功能→模板映射与选择算法
- template_composer: 多模板 DAG 浅合并
- AutoConfigService: 完整流水线
- console.html: 视频源分配交互

修改:
- actionConsoleSave: stub → 完整自动配置编排
- pageConsole: 修复功能勾选回显 + 内存指标解析
- main.go: 注入 AutoConfigService
- .gitignore: /managerd 只匹配根目录二进制
2026-05-12 13:26:49 +08:00

346 lines
18 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 class="muted small">⬤ 上次下发成功</span>
<form method="post" action="/ui/console" style="display:inline">
<button class="btn" type="submit" style="padding:4px 14px;font-size:12px">保存并下发</button>
</form>
</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">
{{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}}></td>
<td class="console-feature-label">{{$f.Label}}</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>
</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="/ui/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('/ui/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 = '/ui/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);
}
})();
</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}}