safesight/control/internal/web/ui/templates/console.html
tian 2bb8ed1cdb feat(control): 运行看板/视频监控页视频源状态覆盖层
- 新增 /api/monitor/video-status 接口(复用 FetchVideoSourceStatus)
- console.html: 设备视图+视频视图视频窗口正中显示状态覆盖层
  离线(红字+原因)/连接中/在线(隐藏)/状态未知
- monitor.html: 视频监控页同样处理,HLS 播放失败显示'无视频输出'
- HLS fatal 错误兜底提示(即使状态接口不可用)
2026-08-02 12:49:22 +08:00

588 lines
29 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" data-device-id="{{$cd.Device.DeviceID}}">
<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}} · {{$cd.Device.IP}}</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" onclick="event.preventDefault();showDevicePicker('{{$s.Name}}');"> 分配到设备</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 esc(s) { return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
// 视频源状态覆盖层:离线红字/连接中/状态未知
function applySourceStatus(tile, status, hlsError) {
var overlay = tile.querySelector('.video-status-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'video-status-overlay';
tile.appendChild(overlay);
}
if (hlsError) {
overlay.innerHTML = '<div class="video-status-text offline">无视频输出</div><div class="video-status-detail">HLS 流不可用,请检查视频源</div>';
overlay.style.display = 'flex';
return;
}
if (!status) {
overlay.innerHTML = '<div class="video-status-text unknown">状态未知</div>';
overlay.style.display = 'flex';
return;
}
if (status.streaming && status.input_fps > 0) {
overlay.style.display = 'none'; // 有画面,隐藏覆盖层
return;
}
if (status.streaming) {
overlay.innerHTML = '<div class="video-status-text connecting">连接中</div>';
overlay.style.display = 'flex';
return;
}
overlay.innerHTML = '<div class="video-status-text offline">离线</div>' +
(status.last_error ? '<div class="video-status-detail">' + esc(status.last_error) + '</div>' : '');
overlay.style.display = 'flex';
}
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');
// 初始:状态未知(等状态接口返回后更新)
applySourceStatus(tile, null, false);
});
var deviceIds = Object.keys(deviceChannels);
deviceIds.forEach(function(did) {
// 拉取视频源运行状态(按通道名匹配 graph 名)
fetch('/api/monitor/video-status?device_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(data) {
var byName = {};
(data.sources || []).forEach(function(s) { byName[s.graph_name] = s; });
Object.keys(deviceChannels[did]).forEach(function(ch) {
var tile = Array.prototype.find.call(document.querySelectorAll(selector), function(t) {
return t.getAttribute('data-device-id') === did && t.getAttribute('data-channel') === ch;
});
if (tile) applySourceStatus(tile, byName[ch], false);
});
})
.catch(function() {});
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.on(Hls.Events.ERROR, function(evt, data) {
if (data.fatal) {
var tile = video.closest('.preview-tile, .video-card-preview');
if (tile) applySourceStatus(tile, null, true);
}
});
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 overlay = document.createElement('div');
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.addEventListener('click',function(){overlay.remove()});
var box = document.createElement('div');
box.style.cssText = 'background:var(--surface);border-radius:8px;padding:20px;min-width:300px;max-width:400px';
box.addEventListener('click',function(e){e.stopPropagation()});
box.innerHTML = '<div style="font-size:14px;font-weight:600;margin-bottom:12px">为设备添加视频源 (' + visible.length + '路可用)</div>' +
'<div style="max-height:60vh;overflow-y:auto;margin-bottom:0"></div>';
var list = box.querySelector('div:last-child');
visible.forEach(function(name) {
var item = document.createElement('div');
item.textContent = name;
item.style.cssText = 'padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:2px;font-size:13px';
item.addEventListener('mouseover',function(){item.style.background='var(--surface-soft)'});
item.addEventListener('mouseout',function(){item.style.background='transparent'});
item.addEventListener('click',function(){
addSourceToDeviceForm(deviceID, name);
overlay.remove();
});
list.appendChild(item);
});
var footer = document.createElement('div');
footer.style.cssText = 'margin-top:12px;text-align:right';
footer.innerHTML = '<button class="btn" style="font-size:12px">取消</button>';
footer.querySelector('button').addEventListener('click',function(){overlay.remove()});
box.appendChild(footer);
overlay.appendChild(box);
document.body.appendChild(overlay);
};
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);
// Visual badge on device row
var badge = document.createElement('span');
badge.className = 'console-source-badge';
badge.textContent = sourceName;
badge.style.cssText = 'display:inline-block;background:var(--primary-soft);color:var(--primary-strong);padding:1px 6px;border-radius:3px;font-size:10px;margin:2px 4px 2px 0';
badge.setAttribute('data-source', sourceName);
var header = row.querySelector('.console-header');
if (header) header.appendChild(badge);
// Mark source card as pending instead of hiding
var card = document.querySelector('.console-source-assign[data-source="' + sourceName + '"]');
if (card) {
var sc = card.closest('.console-source-card');
if (sc) {
sc.style.opacity = '0.5';
sc.style.borderStyle = 'dashed';
}
}
}
function showDevicePicker(sourceName) {
var rows = document.querySelectorAll('.console-device-row');
if (rows.length === 0) { alert('暂无可分配设备'); return; }
var overlay = document.createElement('div');
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.addEventListener('click',function(){overlay.remove()});
var box = document.createElement('div');
box.style.cssText = 'background:var(--surface);border-radius:8px;padding:20px;min-width:300px;max-width:400px';
box.addEventListener('click',function(e){e.stopPropagation()});
box.innerHTML = '<div style="font-size:14px;font-weight:600;margin-bottom:12px">分配 "' + sourceName + '" 到设备</div>' +
'<div style="max-height:60vh;overflow-y:auto;margin-bottom:0"></div>';
var list = box.querySelector('div:last-child');
rows.forEach(function(row) {
var did = row.getAttribute('data-device-id');
var nameEl = row.querySelector('.console-device-name');
var dname = nameEl ? nameEl.textContent : did;
var metaEl = row.querySelector('.console-device-meta');
var ip = metaEl ? (metaEl.textContent.match(/[\d.]+/) || [''])[0] : '';
var item = document.createElement('div');
item.style.cssText = 'padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:2px;font-size:13px';
item.innerHTML = dname + ' <span style="font-size:11px;color:var(--muted)">' + (ip || did) + '</span>';
item.addEventListener('mouseover',function(){item.style.background='var(--surface-soft)'});
item.addEventListener('mouseout',function(){item.style.background='transparent'});
item.addEventListener('click',function(){
addSourceToDeviceForm(did, sourceName);
overlay.remove();
});
list.appendChild(item);
});
var footer = document.createElement('div');
footer.style.cssText = 'margin-top:12px;text-align:right';
footer.innerHTML = '<button class="btn" style="font-size:12px">取消</button>';
footer.querySelector('button').addEventListener('click',function(){overlay.remove()});
box.appendChild(footer);
overlay.appendChild(box);
document.body.appendChild(overlay);
}
// ---- 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 = '';
var dirtyDevs = [];
document.querySelectorAll('.console-device-row').forEach(function(row) {
var did = row.getAttribute('data-device-id');
var feats = [];
row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) { feats.push(cb.value); });
if (feats.sort().join(',') === (initialFeatures[did]||[]).sort().join(',')) {
// Check sources too - device may be dirty from source changes only
var srcs = [];
row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) { srcs.push(h.value); });
if (srcs.sort().join(',') === (initialSources[did]||[]).sort().join(',')) return;
}
dirtyDevs.push(did);
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>';
feats.forEach(function(f) {
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + f + '</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() {
if (dirtyDevs.length === 0) { overlay.remove(); return; }
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'dirty_device_ids';
input.value = dirtyDevs.join(',');
document.getElementById('console-form').appendChild(input);
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}}