safesight/control/internal/web/ui/templates/monitor.html
tian 9e473871ce fix(control): 看板/监控 HLS 错误回调丢失状态导致原因不显示
HLS fatal 错误回调把 status 传 null,hlsError 分支拿不到
last_error,落入'请检查视频源'兜底文案。
修复: tile/card 记住最近状态(_sourceStatus),错误回调复用它;
监控页 hlsError 也显示实际原因(有数据时)。
2026-08-02 13:40:17 +08:00

144 lines
6.0 KiB
HTML

{{define "monitor"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>视频监控</span></h2>
<div class="form-hint">所有在线设备的实时画面</div>
</div>
<div class="actions compact">
<button class="btn ghost" type="button" onclick="loadAll()">刷新</button>
</div>
</div>
</div>
<div id="video-wall" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-top:16px">
<div class="card muted" style="text-align:center;grid-column:1/-1">加载中…</div>
</div>
<script src="/assets/vendor/hls.min.js"></script>
<script>
function loadAll() {
var wall = document.getElementById("video-wall");
wall.innerHTML = '<div class="card muted" style="text-align:center;grid-column:1/-1">加载中…</div>';
var devices = [
{{range .Devices}}{{if .Online}}
{id:"{{.DeviceID}}",name:"{{.DisplayName}}"},
{{end}}{{end}}
];
if (devices.length === 0) {
wall.innerHTML = '<div class="card muted" style="text-align:center;grid-column:1/-1">暂无在线设备</div>';
return;
}
var promises = devices.map(function(dev) {
return fetch('/api/monitor/channels?device_id=' + encodeURIComponent(dev.id))
.then(function(r) { return r.json(); })
.then(function(data) { return (data.channels||[]).map(function(ch) { ch._dev = dev.name; ch._devId = dev.id; return ch; }); })
.catch(function() { return []; });
});
Promise.all(promises).then(function(results) {
var all = [];
results.forEach(function(chs) { all = all.concat(chs); });
if (all.length === 0) {
wall.innerHTML = '<div class="card muted" style="text-align:center;grid-column:1/-1">没有可预览的通道</div>';
return;
}
var cols = all.length <= 2 ? all.length : 2;
wall.style.gridTemplateColumns = "repeat(" + cols + ",minmax(0,1fr))";
var html = "";
all.forEach(function(ch, i) {
var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
html += '<div class="card" style="padding:8px;position:relative" data-device-id="' + ch._devId + '" data-channel="' + esc(ch.name) + '">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
html += '<span style="font-size:12px;font-weight:500">' + esc(ch._dev) + ' · ' + esc(ch.name) + '</span>';
html += '</div>';
if (ch.hls_url) {
html += '<video id="v' + i + '" controls autoplay muted style="width:100%;max-height:340px;background:#000;position:relative;z-index:1"></video>';
} else {
html += '<div class="muted" style="height:200px;display:flex;align-items:center;justify-content:center">无预览地址</div>';
}
html += '</div>';
});
wall.innerHTML = html;
// 视频源状态覆盖层(监控页看的是 HLS 输出,标题用输出层面措辞,原因标注根因)
function applyStatus(card, status, hlsError) {
card._sourceStatus = status; // 记住最近状态,供 HLS 错误回调取用
var overlay = card.querySelector('.video-status-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'video-status-overlay';
card.appendChild(overlay);
}
if (hlsError) {
var why = (status && status.last_error) ? ('视频源异常:' + esc(status.last_error)) : 'HLS 流不可用,请检查视频源';
overlay.innerHTML = '<div class="video-status-text offline">无视频输出</div><div class="video-status-detail">' + why + '</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>' : '<div class="video-status-detail">视频源离线</div>');
overlay.style.display = 'flex';
}
// 拉取各设备视频源状态
var statusByDev = {};
devices.forEach(function(dev) {
fetch('/api/monitor/video-status?device_id=' + encodeURIComponent(dev.id))
.then(function(r) { return r.json(); })
.then(function(data) {
var byName = {};
(data.sources || []).forEach(function(s) { byName[s.graph_name] = s; });
statusByDev[dev.id] = byName;
all.forEach(function(ch) {
if (ch._devId !== dev.id) return;
var card = wall.querySelector('[data-device-id="' + ch._devId + '"][data-channel="' + cssEscape(ch.name) + '"]');
if (card) applyStatus(card, byName[ch.name], false);
});
})
.catch(function() {});
});
// Initialize HLS players
all.forEach(function(ch, i) {
if (!ch.hls_url || !ch._devId) return;
var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var video = document.getElementById('v' + i);
if (!video) return;
if (Hls.isSupported()) {
var hls = new Hls();
hls.on(Hls.Events.ERROR, function(evt, data) {
if (data.fatal) {
var card = video.closest('.card');
if (card) applyStatus(card, card._sourceStatus, true);
}
});
hls.loadSource(proxyUrl);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = ch.hls_url;
}
});
});
}
function cssEscape(s) { return String(s).replace(/["\\]/g, '\\$&'); }
function esc(s) { return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
loadAll();
</script>
{{end}}