From 2bb8ed1cdb0055da22a481f86b24ad234c4aa9c0 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sun, 2 Aug 2026 12:49:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(control):=20=E8=BF=90=E8=A1=8C=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF/=E8=A7=86=E9=A2=91=E7=9B=91=E6=8E=A7=E9=A1=B5?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=BA=90=E7=8A=B6=E6=80=81=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /api/monitor/video-status 接口(复用 FetchVideoSourceStatus) - console.html: 设备视图+视频视图视频窗口正中显示状态覆盖层 离线(红字+原因)/连接中/在线(隐藏)/状态未知 - monitor.html: 视频监控页同样处理,HLS 播放失败显示'无视频输出' - HLS fatal 错误兜底提示(即使状态接口不可用) --- control/internal/web/ui.go | 21 +++++++ control/internal/web/ui/assets/style.css | 11 ++++ .../internal/web/ui/templates/console.html | 57 +++++++++++++++++ .../internal/web/ui/templates/monitor.html | 62 ++++++++++++++++++- 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/control/internal/web/ui.go b/control/internal/web/ui.go index e5ecf40..4946bf2 100644 --- a/control/internal/web/ui.go +++ b/control/internal/web/ui.go @@ -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/") diff --git a/control/internal/web/ui/assets/style.css b/control/internal/web/ui/assets/style.css index b36a477..a632d3b 100644 --- a/control/internal/web/ui/assets/style.css +++ b/control/internal/web/ui/assets/style.css @@ -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} diff --git a/control/internal/web/ui/templates/console.html b/control/internal/web/ui/templates/console.html index 1fc9cbd..a3f5033 100644 --- a/control/internal/web/ui/templates/console.html +++ b/control/internal/web/ui/templates/console.html @@ -181,6 +181,40 @@ }); }); + function esc(s) { return (s||"").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 = '
无视频输出
HLS 流不可用,请检查视频源
'; + overlay.style.display = 'flex'; + return; + } + if (!status) { + overlay.innerHTML = '
状态未知
'; + overlay.style.display = 'flex'; + return; + } + if (status.streaming && status.input_fps > 0) { + overlay.style.display = 'none'; // 有画面,隐藏覆盖层 + return; + } + if (status.streaming) { + overlay.innerHTML = '
连接中
'; + overlay.style.display = 'flex'; + return; + } + overlay.innerHTML = '
离线
' + + (status.last_error ? '
' + esc(status.last_error) + '
' : ''); + 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(){}); diff --git a/control/internal/web/ui/templates/monitor.html b/control/internal/web/ui/templates/monitor.html index 31df65e..036a134 100644 --- a/control/internal/web/ui/templates/monitor.html +++ b/control/internal/web/ui/templates/monitor.html @@ -50,18 +50,69 @@ function loadAll() { var html = ""; all.forEach(function(ch, i) { var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8'; - html += '
'; + html += '
'; html += '
'; html += '' + esc(ch._dev) + ' · ' + esc(ch.name) + ''; html += '
'; if (ch.hls_url) { - html += ''; + html += ''; } else { html += '
无预览地址
'; } html += '
'; }); 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 = '
无视频输出
HLS 流不可用,请检查视频源
'; + overlay.style.display = 'flex'; + return; + } + if (!status) { + overlay.innerHTML = '
状态未知
'; + overlay.style.display = 'flex'; + return; + } + if (status.streaming && status.input_fps > 0) { + overlay.style.display = 'none'; + return; + } + if (status.streaming) { + overlay.innerHTML = '
连接中
'; + overlay.style.display = 'flex'; + return; + } + overlay.innerHTML = '
离线
' + + (status.last_error ? '
' + esc(status.last_error) + '
' : ''); + 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,"""); } loadAll();