276 lines
14 KiB
HTML
276 lines
14 KiB
HTML
{{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">+ 从未分配添加</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="feature_{{$cd.Device.DeviceID}}" 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>
|
||
</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">+ 分配到设备</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]');
|
||
})();
|
||
</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}}
|