feat(control): 运行看板/视频监控页视频源状态覆盖层
- 新增 /api/monitor/video-status 接口(复用 FetchVideoSourceStatus) - console.html: 设备视图+视频视图视频窗口正中显示状态覆盖层 离线(红字+原因)/连接中/在线(隐藏)/状态未知 - monitor.html: 视频监控页同样处理,HLS 播放失败显示'无视频输出' - HLS fatal 错误兜底提示(即使状态接口不可用)
This commit is contained in:
parent
c75228c88d
commit
2bb8ed1cdb
@ -757,6 +757,7 @@ func (u *UI) RegisterAlarmRoutes(r chi.Router) {
|
||||
r.Get("/api/alarms/daily-count", u.apiAlarmDailyCount)
|
||||
r.Get("/api/alarms/export", u.apiAlarmExport)
|
||||
r.Get("/api/monitor/channels", u.apiMonitorChannels)
|
||||
r.Get("/api/monitor/video-status", u.apiMonitorVideoStatus)
|
||||
r.Get("/api/device-metrics", u.apiDeviceMetrics)
|
||||
}
|
||||
|
||||
@ -5981,6 +5982,26 @@ func (u *UI) apiMonitorChannels(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func (u *UI) apiMonitorVideoStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.URL.Query().Get("device_id")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "missing device_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dev, ok := u.findDevice(deviceID)
|
||||
if !ok {
|
||||
http.Error(w, "device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
sources, err := service.FetchVideoSourceStatus(u.agent, dev)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"sources": sources})
|
||||
}
|
||||
|
||||
func (u *UI) proxyHLS(w http.ResponseWriter, r *http.Request) {
|
||||
// URL format: /hls/{deviceID}/{hls_path}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/hls/")
|
||||
|
||||
@ -484,3 +484,14 @@ body:not([data-mode=expert]) .expert-only{display:none}
|
||||
.video-source-item{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.video-source-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
|
||||
.video-source-error{margin-top:6px;padding:6px 8px;border-radius:var(--radius);background:rgba(220,53,69,.08);color:var(--danger);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* 视频源状态覆盖层(运行看板/视频监控) */
|
||||
.video-status-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(0,0,0,.55);color:#fff;z-index:5;pointer-events:none;border-radius:inherit}
|
||||
.video-status-text{font-size:20px;font-weight:700;letter-spacing:2px}
|
||||
.video-status-text.online{color:#2fb344}
|
||||
.video-status-text.offline{color:#ff5c5c}
|
||||
.video-status-text.connecting{color:#ffb020}
|
||||
.video-status-text.unknown{color:#aaa}
|
||||
.video-status-detail{font-size:11px;color:#d8d8d8;max-width:92%;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:6px;padding:0 8px}
|
||||
.preview-tile,.video-card-preview{position:relative}
|
||||
.video-card-preview video,.preview-tile video{position:relative;z-index:1}
|
||||
|
||||
@ -181,6 +181,40 @@
|
||||
});
|
||||
});
|
||||
|
||||
function esc(s) { return (s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
||||
|
||||
// 视频源状态覆盖层:离线红字/连接中/状态未知
|
||||
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 = {};
|
||||
@ -190,10 +224,27 @@
|
||||
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) {
|
||||
@ -204,6 +255,12 @@
|
||||
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(){});
|
||||
|
||||
@ -50,18 +50,69 @@ function loadAll() {
|
||||
var html = "";
|
||||
all.forEach(function(ch, i) {
|
||||
var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
|
||||
html += '<div class="card" style="padding:8px">';
|
||||
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"></video>';
|
||||
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;
|
||||
// 视频源状态覆盖层
|
||||
function esc2(s) { return esc(s); }
|
||||
function applyStatus(card, status, hlsError) {
|
||||
var overlay = card.querySelector('.video-status-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'video-status-overlay';
|
||||
card.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';
|
||||
}
|
||||
|
||||
// 拉取各设备视频源状态
|
||||
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;
|
||||
@ -70,6 +121,12 @@ function loadAll() {
|
||||
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, null, true);
|
||||
}
|
||||
});
|
||||
hls.loadSource(proxyUrl);
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
@ -78,6 +135,7 @@ function loadAll() {
|
||||
});
|
||||
});
|
||||
}
|
||||
function cssEscape(s) { return String(s).replace(/["\\]/g, '\\$&'); }
|
||||
function esc(s) { return (s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
||||
loadAll();
|
||||
</script>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user