Commit 609efc1c by James Yuan

feat: 固化 IPC 独立 IP 摄像台账并完善网关/大屏预览

保存 cam-142…156 直连配置与 MediaMTX 流映射,按 IP 解析流 ID,静默 JPEG 降级预览。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 92bab587
# MediaMTX + ffmpeg:海康 H.265 需转码为 H.264 才能被 Chrome WebRTC 播放
FROM bluenviron/mediamtx:1 AS mtx
FROM alpine:3.20
RUN apk add --no-cache ffmpeg ca-certificates \
&& mkdir -p /mediamtx
COPY --from=mtx /mediamtx /mediamtx
COPY mediamtx.yml /mediamtx.yml
ENTRYPOINT ["/mediamtx"]
CMD ["/mediamtx.yml"]
...@@ -23,14 +23,17 @@ services: ...@@ -23,14 +23,17 @@ services:
container_name: jh-lab-mediamtx container_name: jh-lab-mediamtx
restart: unless-stopped restart: unless-stopped
environment: environment:
MTX_WEBRTCICEHOSTNAT1TO1IPS: ${WEBRTC_ICE_HOST:-192.168.32.110} MTX_WEBRTCICEHOSTNAT1TO1IPS: ${WEBRTC_ICE_HOST:-192.168.10.107}
MTX_WEBRTCICETCPMUTUALLISTENINGPORT: "8189" MTX_WEBRTCICETCPMUTUALLISTENINGPORT: "8189"
volumes:
- ./mediamtx.yml:/mediamtx.yml:ro
ports: ports:
- "${MEDIAMTX_RTSP_PORT:-8554}:8554" - "${MEDIAMTX_RTSP_PORT:-8554}:8554"
- "${MEDIAMTX_HLS_PORT:-8888}:8888" - "${MEDIAMTX_HLS_PORT:-8888}:8888"
- "${MEDIAMTX_WEBRTC_PORT:-8889}:8889" - "${MEDIAMTX_WEBRTC_PORT:-8889}:8889"
- "${MEDIAMTX_WEBRTC_UDP:-8189}:8189/udp" - "${MEDIAMTX_WEBRTC_UDP:-8189}:8189/udp"
- "${MEDIAMTX_WEBRTC_TCP:-8189}:8189" - "${MEDIAMTX_WEBRTC_TCP:-8189}:8189"
- "9997:9997"
server: server:
image: jh-lab-server:prod image: jh-lab-server:prod
......
{ {
"listen": "0.0.0.0", "listen": "0.0.0.0",
"port": 38080, "port": 38080,
"platformUrl": "http://127.0.0.1:3001", "platformUrl": "http://192.168.10.107:3001",
"pollMs": 2000, "pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889", "mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1, "modbusAddressBase": 1,
...@@ -58,7 +58,9 @@ ...@@ -58,7 +58,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-143", "id": "cam-143",
...@@ -68,7 +70,9 @@ ...@@ -68,7 +70,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-144", "id": "cam-144",
...@@ -78,7 +82,9 @@ ...@@ -78,7 +82,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-145", "id": "cam-145",
...@@ -88,7 +94,9 @@ ...@@ -88,7 +94,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-146", "id": "cam-146",
...@@ -98,7 +106,9 @@ ...@@ -98,7 +106,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-147", "id": "cam-147",
...@@ -108,7 +118,9 @@ ...@@ -108,7 +118,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-148", "id": "cam-148",
...@@ -118,7 +130,9 @@ ...@@ -118,7 +130,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-149", "id": "cam-149",
...@@ -128,7 +142,9 @@ ...@@ -128,7 +142,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-150", "id": "cam-150",
...@@ -138,7 +154,9 @@ ...@@ -138,7 +154,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-151", "id": "cam-151",
...@@ -148,7 +166,9 @@ ...@@ -148,7 +166,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-152", "id": "cam-152",
...@@ -158,7 +178,9 @@ ...@@ -158,7 +178,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-153", "id": "cam-153",
...@@ -168,7 +190,9 @@ ...@@ -168,7 +190,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-154", "id": "cam-154",
...@@ -178,7 +202,9 @@ ...@@ -178,7 +202,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-155", "id": "cam-155",
...@@ -188,7 +214,9 @@ ...@@ -188,7 +214,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-156", "id": "cam-156",
...@@ -198,7 +226,9 @@ ...@@ -198,7 +226,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102"
} }
], ],
"doors": [ "doors": [
......
@echo off @echo off
cd /d %~dp0 setlocal
cd /d "%~dp0"
title JhLab Gateway
REM stop old process
taskkill /F /IM JhLabGateway.exe >nul 2>nul taskkill /F /IM JhLabGateway.exe >nul 2>nul
timeout /t 1 /nobreak >nul ping 127.0.0.1 -n 2 >nul
start "" /B JhLabGateway.exe config.json
echo Gateway started: http://127.0.0.1:38080/ui/ REM open admin UI after a short delay (gateway still starting)
start "" cmd /c "ping 127.0.0.1 -n 5 >nul & start http://127.0.0.1:38080/ui/"
echo.
echo JhLab Gateway starting...
echo Admin UI: http://127.0.0.1:38080/ui/
echo Close THIS window to stop the gateway process.
echo.
REM foreground: closing this console ends JhLabGateway.exe
JhLabGateway.exe config.json
set ERR=%ERRORLEVEL%
echo.
echo Gateway exited. code=%ERR%
pause
endlocal
# MediaMTX — each IPC independent IP (substream 102)
webrtc: yes
webrtcAddress: :8889
rtsp: yes
rtspAddress: :8554
hls: yes
hlsAddress: :8888
api: yes
apiAddress: :9997
paths:
cam-142:
source: rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-143:
source: rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-144:
source: rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-145:
source: rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-146:
source: rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-147:
source: rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-148:
source: rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-149:
source: rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-150:
source: rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-151:
source: rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-152:
source: rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-153:
source: rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-154:
source: rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-155:
source: rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-156:
source: rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
{ {
"source": "澳门镜湖医院分子实验室项目技术交底表.xls", "source": "gateway/win-protocol/config.json + 交底表 IPC 独立 IP",
"cameras": [ "cameras": [
{ {
"id": "cam-142", "id": "cam-142",
...@@ -9,7 +9,9 @@ ...@@ -9,7 +9,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-143", "id": "cam-143",
...@@ -19,7 +21,9 @@ ...@@ -19,7 +21,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-144", "id": "cam-144",
...@@ -29,7 +33,9 @@ ...@@ -29,7 +33,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-145", "id": "cam-145",
...@@ -39,7 +45,9 @@ ...@@ -39,7 +45,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-146", "id": "cam-146",
...@@ -49,7 +57,9 @@ ...@@ -49,7 +57,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-147", "id": "cam-147",
...@@ -59,7 +69,9 @@ ...@@ -59,7 +69,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-148", "id": "cam-148",
...@@ -69,7 +81,9 @@ ...@@ -69,7 +81,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-149", "id": "cam-149",
...@@ -79,7 +93,9 @@ ...@@ -79,7 +93,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-150", "id": "cam-150",
...@@ -89,7 +105,9 @@ ...@@ -89,7 +105,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-151", "id": "cam-151",
...@@ -99,7 +117,9 @@ ...@@ -99,7 +117,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-152", "id": "cam-152",
...@@ -109,7 +129,9 @@ ...@@ -109,7 +129,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-153", "id": "cam-153",
...@@ -119,7 +141,9 @@ ...@@ -119,7 +141,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-154", "id": "cam-154",
...@@ -129,7 +153,9 @@ ...@@ -129,7 +153,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-155", "id": "cam-155",
...@@ -139,7 +165,9 @@ ...@@ -139,7 +165,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-156", "id": "cam-156",
...@@ -149,7 +177,9 @@ ...@@ -149,7 +177,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102"
} }
], ],
"doors": [ "doors": [
......
{
"note": "IPC 独立 IP 摄像台账(cam-142…156);与网关 config.json / MediaMTX 流名对齐",
"updatedAt": "2026-11-02",
"devices": [
{
"id": "85ac83ed-89de-46b1-881d-9376e639e595",
"name": "试剂准备间",
"ip": "192.168.10.142",
"port": 8000,
"channel": 10,
"deviceType": "ipc",
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102",
"location": "试剂准备间",
"remark": "交底表 · 海康威视 · 192.168.10.142:8000",
"scenePoint": {
"x": 133.5430677376102,
"y": 22.197197787207845,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "38975b17-d33e-48ed-ad6c-9b7d6f80798f",
"name": "后走廊",
"ip": "192.168.10.143",
"port": 8000,
"channel": 15,
"deviceType": "ipc",
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102",
"location": "后走廊",
"remark": "交底表 · 海康威视 · 192.168.10.143:8000",
"scenePoint": {
"x": 133.54302627339493,
"y": 22.197266211012888,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "3a09dc9c-e9b8-4c74-9706-6f69aae32019",
"name": "样本制备区2房前面",
"ip": "192.168.10.144",
"port": 8000,
"channel": 2,
"deviceType": "ipc",
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102",
"location": "样本制备区2房前面",
"remark": "交底表 · 海康威视 · 192.168.10.144:8000",
"scenePoint": {
"x": 133.54304285908103,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "932f3870-ec55-462e-993e-31cf5590ef0f",
"name": "样本制备区1房后面",
"ip": "192.168.10.145",
"port": 8000,
"channel": 9,
"deviceType": "ipc",
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102",
"location": "样本制备区1房后面",
"remark": "交底表 · 海康威视 · 192.168.10.145:8000",
"scenePoint": {
"x": 133.5430552983456,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "2ed69680-d341-49b3-9ddb-192941c94e43",
"name": "文库制备2房后面",
"ip": "192.168.10.146",
"port": 8000,
"channel": 3,
"deviceType": "ipc",
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102",
"location": "文库制备2房后面",
"remark": "交底表 · 海康威视 · 192.168.10.146:8000",
"scenePoint": {
"x": 133.5430179805519,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "155186dc-fe9a-4f38-b10a-a77fdea9713b",
"name": "文库制备2房前面",
"ip": "192.168.10.147",
"port": 8000,
"channel": 5,
"deviceType": "ipc",
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102",
"location": "文库制备2房前面",
"remark": "交底表 · 海康威视 · 192.168.10.147:8000",
"scenePoint": {
"x": 133.5430179805519,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "fb15759f-b4d2-4ad7-a40c-4e790f81510c",
"name": "测序区后",
"ip": "192.168.10.148",
"port": 8000,
"channel": 7,
"deviceType": "ipc",
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102",
"location": "测序区后",
"remark": "交底表 · 海康威视 · 192.168.10.148:8000",
"scenePoint": {
"x": 133.5430055412873,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "a21fb190-754b-4d37-9178-2f1dbed34aff",
"name": "文库制备区1房后面",
"ip": "192.168.10.149",
"port": 8000,
"channel": 14,
"deviceType": "ipc",
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102",
"location": "文库制备区1房后面",
"remark": "交底表 · 海康威视 · 192.168.10.149:8000",
"scenePoint": {
"x": 133.54303041981646,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "d7a8b714-fff9-4846-9377-febf33a6c1e2",
"name": "收样咨询室",
"ip": "192.168.10.150",
"port": 8000,
"channel": 1,
"deviceType": "ipc",
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102",
"location": "收样咨询室",
"remark": "交底表 · 海康威视 · 192.168.10.150:8000",
"scenePoint": {
"x": 133.54307022546308,
"y": 22.197145275915602,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "95bfee9f-7e42-4321-b5ea-59ebc48e9c06",
"name": "生信分析/报告室",
"ip": "192.168.10.151",
"port": 8000,
"channel": 8,
"deviceType": "ipc",
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102",
"location": "生信分析/报告室",
"remark": "交底表 · 海康威视 · 192.168.10.151:8000",
"scenePoint": {
"x": 133.54307022546308,
"y": 22.197224838479606,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "d4f38476-01ef-483e-be75-f6f38efd533b",
"name": "样本制备区2房后面",
"ip": "192.168.10.152",
"port": 8000,
"channel": 4,
"deviceType": "ipc",
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102",
"location": "样本制备区2房后面",
"remark": "交底表 · 海康威视 · 192.168.10.152:8000",
"scenePoint": {
"x": 133.54304285908103,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "af0b0ea0-4ae8-4fcd-a5eb-58d1d061241c",
"name": "前走廊",
"ip": "192.168.10.153",
"port": 8000,
"channel": 6,
"deviceType": "ipc",
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102",
"location": "前走廊",
"remark": "交底表 · 海康威视 · 192.168.10.153:8000",
"scenePoint": {
"x": 133.54304866407116,
"y": 22.197266211012888,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "4fc7975e-0f81-476b-995d-a9b3fad8e337",
"name": "样本制备区1房前面",
"ip": "192.168.10.154",
"port": 8000,
"channel": 12,
"deviceType": "ipc",
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102",
"location": "样本制备区1房前面",
"remark": "交底表 · 海康威视 · 192.168.10.154:8000",
"scenePoint": {
"x": 133.5430552983456,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "3aa27733-4114-4345-80a0-4b90ea3fd6ca",
"name": "测序区前",
"ip": "192.168.10.155",
"port": 8000,
"channel": 13,
"deviceType": "ipc",
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102",
"location": "测序区前",
"remark": "交底表 · 海康威视 · 192.168.10.155:8000",
"scenePoint": {
"x": 133.5430055412873,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "b6d76901-23e7-4df4-bc23-12f88c0cd956",
"name": "文库制备区1房前面",
"ip": "192.168.10.156",
"port": 8000,
"channel": 11,
"deviceType": "ipc",
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102",
"location": "文库制备区1房前面",
"remark": "交底表 · 海康威视 · 192.168.10.156:8000",
"scenePoint": {
"x": 133.54303041981646,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
}
]
}
...@@ -4,29 +4,47 @@ project(JhLabGateway LANGUAGES CXX) ...@@ -4,29 +4,47 @@ project(JhLabGateway LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
# ---- HCNetSDK(仅 MSVC x64 推荐启用)---- # ---- HCNetSDK:MSVC 链 .lib;MinGW 可用 gendef/dlltool 生成的 .a ----
option(JHLAB_ENABLE_HCNETSDK "Link Hikvision HCNetSDK" OFF) option(JHLAB_ENABLE_HCNETSDK "Link Hikvision HCNetSDK" OFF)
set(JHLAB_HCNETSDK_ROOT "" CACHE PATH "HCNetSDK package root (contains incCn/ and lib/win64/)") set(JHLAB_HCNETSDK_ROOT "" CACHE PATH "HCNetSDK package root (contains incCn/ and lib/win64/)")
if(NOT JHLAB_HCNETSDK_ROOT) if(NOT JHLAB_HCNETSDK_ROOT)
# win-protocol → gateway → web(repo) → code → 项目根/设备对接 foreach(_hik_cand
set(_hik_cand
"${CMAKE_SOURCE_DIR}/../../../../设备对接/HCNetSDKV6.1.10.10_build20250123_Win64_ZH_demosign/HCNetSDKV6.1.10.10_build20250123_Win64_ZH" "${CMAKE_SOURCE_DIR}/../../../../设备对接/HCNetSDKV6.1.10.10_build20250123_Win64_ZH_demosign/HCNetSDKV6.1.10.10_build20250123_Win64_ZH"
"D:/build/sdk"
"${CMAKE_SOURCE_DIR}/../sdk"
) )
if(EXISTS "${_hik_cand}/incCn/HCNetSDK.h") if(EXISTS "${_hik_cand}/incCn/HCNetSDK.h")
set(JHLAB_HCNETSDK_ROOT "${_hik_cand}" CACHE PATH "HCNetSDK package root" FORCE) set(JHLAB_HCNETSDK_ROOT "${_hik_cand}" CACHE PATH "HCNetSDK package root" FORCE)
break()
endif() endif()
endforeach()
endif() endif()
if(MSVC AND JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/incCn/HCNetSDK.h" set(_hik_have_headers FALSE)
AND EXISTS "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.lib") set(_hik_have_msvc_lib FALSE)
set(_hik_have_dll FALSE)
if(JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/incCn/HCNetSDK.h")
set(_hik_have_headers TRUE)
endif()
if(JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.lib")
set(_hik_have_msvc_lib TRUE)
endif()
if(JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.dll")
set(_hik_have_dll TRUE)
endif()
if(MSVC AND _hik_have_headers AND _hik_have_msvc_lib)
set(JHLAB_ENABLE_HCNETSDK ON) set(JHLAB_ENABLE_HCNETSDK ON)
message(STATUS "HCNetSDK enabled: ${JHLAB_HCNETSDK_ROOT}") message(STATUS "HCNetSDK enabled (MSVC): ${JHLAB_HCNETSDK_ROOT}")
elseif(MINGW AND _hik_have_headers AND _hik_have_dll)
set(JHLAB_ENABLE_HCNETSDK ON)
message(STATUS "HCNetSDK enabled (MinGW import lib): ${JHLAB_HCNETSDK_ROOT}")
elseif(JHLAB_ENABLE_HCNETSDK) elseif(JHLAB_ENABLE_HCNETSDK)
message(WARNING "JHLAB_ENABLE_HCNETSDK=ON but SDK not found / not MSVC — building stub") message(WARNING "JHLAB_ENABLE_HCNETSDK=ON but SDK headers/libs missing — building stub")
set(JHLAB_ENABLE_HCNETSDK OFF) set(JHLAB_ENABLE_HCNETSDK OFF)
else() else()
message(STATUS "HCNetSDK stub mode (set JHLAB_HCNETSDK_ROOT + MSVC to enable)") message(STATUS "HCNetSDK stub mode (provide JHLAB_HCNETSDK_ROOT + MSVC/.lib or MinGW/DLL)")
endif() endif()
add_executable(JhLabGateway add_executable(JhLabGateway
...@@ -39,8 +57,29 @@ target_include_directories(JhLabGateway PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/thir ...@@ -39,8 +57,29 @@ target_include_directories(JhLabGateway PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/thir
if(JHLAB_ENABLE_HCNETSDK) if(JHLAB_ENABLE_HCNETSDK)
target_compile_definitions(JhLabGateway PRIVATE JHLAB_HAS_HCNETSDK) target_compile_definitions(JhLabGateway PRIVATE JHLAB_HAS_HCNETSDK)
target_include_directories(JhLabGateway PRIVATE "${JHLAB_HCNETSDK_ROOT}/incCn") target_include_directories(JhLabGateway PRIVATE "${JHLAB_HCNETSDK_ROOT}/incCn")
if(MSVC)
target_link_directories(JhLabGateway PRIVATE "${JHLAB_HCNETSDK_ROOT}/lib/win64") target_link_directories(JhLabGateway PRIVATE "${JHLAB_HCNETSDK_ROOT}/lib/win64")
target_link_libraries(JhLabGateway PRIVATE HCNetSDK) target_link_libraries(JhLabGateway PRIVATE HCNetSDK)
elseif(MINGW)
set(_hik_implib_dir "${CMAKE_BINARY_DIR}/hcnet_implib")
file(MAKE_DIRECTORY "${_hik_implib_dir}")
set(_hik_dll "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.dll")
set(_hik_def "${_hik_implib_dir}/HCNetSDK.def")
set(_hik_a "${_hik_implib_dir}/libHCNetSDK.a")
find_program(GENDEF_EXE gendef REQUIRED)
find_program(DLLTOOL_EXE dlltool REQUIRED)
add_custom_command(
OUTPUT "${_hik_a}"
COMMAND "${GENDEF_EXE}" - "${_hik_dll}" > "${_hik_def}"
COMMAND "${DLLTOOL_EXE}" -d "${_hik_def}" -l "${_hik_a}" -D HCNetSDK.dll
DEPENDS "${_hik_dll}"
COMMENT "Generating MinGW import library for HCNetSDK.dll"
VERBATIM
)
add_custom_target(hcnet_implib DEPENDS "${_hik_a}")
add_dependencies(JhLabGateway hcnet_implib)
target_link_libraries(JhLabGateway PRIVATE "${_hik_a}")
endif()
endif() endif()
if(WIN32) if(WIN32)
......
@echo off
setlocal EnableExtensions
REM MinGW + HCNetSDK on Windows (no VS required)
REM Expect:
REM D:\build\tools\mingw64\bin\g++.exe
REM D:\build\tools\cmake\...\bin\cmake.exe
REM D:\build\sdk\incCn + lib\win64
REM D:\build\win-protocol (source)
set ROOT=D:\build
set SRC=%ROOT%\win-protocol
set SDK=%ROOT%\sdk
set MINGW=%ROOT%\tools\mingw64\bin
set CMAKE=%ROOT%\tools\cmake\cmake-3.30.5-windows-x86_64\bin\cmake.exe
if not exist "%MINGW%\g++.exe" (
echo [ERR] MinGW not found: %MINGW%\g++.exe
echo Place extracted mingw64 toolchain under D:\build\tools\mingw64
exit /b 1
)
if not exist "%CMAKE%" (
echo [ERR] cmake not found: %CMAKE%
exit /b 1
)
if not exist "%SDK%\incCn\HCNetSDK.h" (
echo [ERR] HCNetSDK headers missing: %SDK%\incCn
exit /b 1
)
if not exist "%SDK%\lib\win64\HCNetSDK.dll" (
echo [ERR] HCNetSDK.dll missing
exit /b 1
)
set PATH=%MINGW%;%PATH%
cd /d "%SRC%"
echo === cmake configure MinGW + HCNetSDK ===
if exist build-mingw-hcnet rmdir /s /q build-mingw-hcnet
"%CMAKE%" -S . -B build-mingw-hcnet -G "MinGW Makefiles" ^
-DCMAKE_BUILD_TYPE=Release ^
-DCMAKE_C_COMPILER=%MINGW%\gcc.exe ^
-DCMAKE_CXX_COMPILER=%MINGW%\g++.exe ^
-DJHLAB_HCNETSDK_ROOT=%SDK% ^
-DJHLAB_ENABLE_HCNETSDK=ON
if errorlevel 1 exit /b 1
echo === build ===
"%CMAKE%" --build build-mingw-hcnet -j %NUMBER_OF_PROCESSORS%
if errorlevel 1 exit /b 1
echo === result ===
dir /b build-mingw-hcnet\JhLabGateway.exe
echo DONE
endlocal
{ {
"listen": "0.0.0.0", "listen": "0.0.0.0",
"port": 38080, "port": 38080,
"platformUrl": "http://192.168.32.122:3001", "platformUrl": "http://192.168.10.107:3001",
"pollMs": 2000, "pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889", "mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1, "modbusAddressBase": 1,
...@@ -58,7 +58,9 @@ ...@@ -58,7 +58,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-143", "id": "cam-143",
...@@ -68,7 +70,9 @@ ...@@ -68,7 +70,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-144", "id": "cam-144",
...@@ -78,7 +82,9 @@ ...@@ -78,7 +82,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-145", "id": "cam-145",
...@@ -88,7 +94,9 @@ ...@@ -88,7 +94,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-146", "id": "cam-146",
...@@ -98,7 +106,9 @@ ...@@ -98,7 +106,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-147", "id": "cam-147",
...@@ -108,7 +118,9 @@ ...@@ -108,7 +118,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-148", "id": "cam-148",
...@@ -118,7 +130,9 @@ ...@@ -118,7 +130,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-149", "id": "cam-149",
...@@ -128,7 +142,9 @@ ...@@ -128,7 +142,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-150", "id": "cam-150",
...@@ -138,7 +154,9 @@ ...@@ -138,7 +154,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-151", "id": "cam-151",
...@@ -148,7 +166,9 @@ ...@@ -148,7 +166,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-152", "id": "cam-152",
...@@ -158,7 +178,9 @@ ...@@ -158,7 +178,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-153", "id": "cam-153",
...@@ -168,7 +190,9 @@ ...@@ -168,7 +190,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-154", "id": "cam-154",
...@@ -178,7 +202,9 @@ ...@@ -178,7 +202,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-155", "id": "cam-155",
...@@ -188,7 +214,9 @@ ...@@ -188,7 +214,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102"
}, },
{ {
"id": "cam-156", "id": "cam-156",
...@@ -198,7 +226,9 @@ ...@@ -198,7 +226,9 @@
"username": "admin", "username": "admin",
"password": "Hk223366", "password": "Hk223366",
"channel": 1, "channel": 1,
"enabled": true "enabled": true,
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102"
} }
], ],
"doors": [ "doors": [
......
' Hidden autostart helper (no console window).
' Prefer start-gateway.bat when you want close-window-to-exit behavior.
Set s = CreateObject("WScript.Shell")
s.CurrentDirectory = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
s.Run "cmd /c taskkill /F /IM JhLabGateway.exe >nul 2>nul", 0, True
s.Run "JhLabGateway.exe config.json", 0, False
...@@ -27,6 +27,22 @@ ...@@ -27,6 +27,22 @@
namespace { namespace {
/** 供控制台关闭 / 管理台「退出网关」停止 listen */
httplib::Server* gHttpServer = nullptr;
#ifdef _WIN32
BOOL WINAPI gatewayConsoleCtrlHandler(DWORD type) {
if (type == CTRL_C_EVENT || type == CTRL_CLOSE_EVENT || type == CTRL_BREAK_EVENT ||
type == CTRL_LOGOFF_EVENT || type == CTRL_SHUTDOWN_EVENT) {
if (gHttpServer) gHttpServer->stop();
// CLOSE 需尽快返回,否则系统强杀
Sleep(200);
return TRUE;
}
return FALSE;
}
#endif
struct PointCfg { struct PointCfg {
std::string id; std::string id;
std::string itemId; std::string itemId;
...@@ -1525,6 +1541,17 @@ int main(int argc, char** argv) { ...@@ -1525,6 +1541,17 @@ int main(int argc, char** argv) {
respondCameraPreview(req.matches[1], res); respondCameraPreview(req.matches[1], res);
}); });
/** 管理台主动退出:停止 HTTP,进程随后结束(配合前台 start-gateway.bat) */
svr.Post("/admin/api/shutdown", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
res.set_content("{\"ok\":true,\"message\":\"gateway stopping\"}",
"application/json; charset=utf-8");
std::thread([] {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if (gHttpServer) gHttpServer->stop();
}).detach();
});
int port = 0; int port = 0;
std::string host; std::string host;
{ {
...@@ -1554,9 +1581,18 @@ int main(int argc, char** argv) { ...@@ -1554,9 +1581,18 @@ int main(int argc, char** argv) {
std::cout << "[gateway] HCNetSDK CAM: " << (hik_cam::sdkLinked() ? "linked" : "stub") std::cout << "[gateway] HCNetSDK CAM: " << (hik_cam::sdkLinked() ? "linked" : "stub")
<< " camsLoggedIn=" << hik_cam::loggedInCount() << " camsLoggedIn=" << hik_cam::loggedInCount()
<< " mediaWhepBase=" << (g.mediaWhepBase.empty() ? "(unset)" : g.mediaWhepBase) << "\n"; << " mediaWhepBase=" << (g.mediaWhepBase.empty() ? "(unset)" : g.mediaWhepBase) << "\n";
gHttpServer = &svr;
#ifdef _WIN32
// 关闭控制台窗口 / Ctrl+C 时停止服务并退出进程(必须用 WINAPI 回调,勿用 C++ lambda)
SetConsoleCtrlHandler(gatewayConsoleCtrlHandler, TRUE);
#endif
if (!svr.listen(host.c_str(), port)) { if (!svr.listen(host.c_str(), port)) {
std::cerr << "[gateway] failed to bind\n"; std::cerr << "[gateway] failed to bind\n";
gHttpServer = nullptr;
return 1; return 1;
} }
gHttpServer = nullptr;
return 0; return 0;
} }
@echo off
setlocal
cd /d "%~dp0"
title JhLab Gateway
REM stop old process
taskkill /F /IM JhLabGateway.exe >nul 2>nul
ping 127.0.0.1 -n 2 >nul
REM open admin UI after a short delay (gateway still starting)
start "" cmd /c "ping 127.0.0.1 -n 5 >nul & start http://127.0.0.1:38080/ui/"
echo.
echo JhLab Gateway starting...
echo Admin UI: http://127.0.0.1:38080/ui/
echo Close THIS window to stop the gateway process.
echo.
REM foreground: closing this console ends JhLabGateway.exe
JhLabGateway.exe config.json
set ERR=%ERRORLEVEL%
echo.
echo Gateway exited. code=%ERR%
pause
endlocal
...@@ -235,6 +235,21 @@ async function boot() { ...@@ -235,6 +235,21 @@ async function boot() {
localStorage.removeItem('gw_token'); localStorage.removeItem('gw_token');
location.replace('/ui/login.html'); location.replace('/ui/login.html');
}; };
document.getElementById('btnShutdown').onclick = async () => {
if (!confirm('确认退出并结束网关进程?')) return;
try {
await api('/admin/api/shutdown', { method: 'POST', body: '{}' });
banner('网关正在退出…', true);
} catch (e) {
// 进程已退出时请求可能失败,视为成功
banner('网关已退出(或连接已断开)', true);
}
setTimeout(() => {
try {
window.close();
} catch (_) {}
}, 500);
};
document.getElementById('btnRefreshHealth').onclick = () => loadHealth().catch((e) => banner(e.message)); document.getElementById('btnRefreshHealth').onclick = () => loadHealth().catch((e) => banner(e.message));
document.getElementById('btnReloadDevices').onclick = () => loadConfig().catch((e) => banner(e.message)); document.getElementById('btnReloadDevices').onclick = () => loadConfig().catch((e) => banner(e.message));
document.getElementById('btnRefreshPreview').onclick = () => document.getElementById('btnRefreshPreview').onclick = () =>
......
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
<button type="button" data-page="deviceEdit">设备配置</button> <button type="button" data-page="deviceEdit">设备配置</button>
<button type="button" data-page="cameras">摄像头预览</button> <button type="button" data-page="cameras">摄像头预览</button>
</nav> </nav>
<button class="btn ghost" type="button" id="btnLogout">退出</button> <button class="btn ghost" type="button" id="btnLogout">退出登录</button>
<button class="btn danger" type="button" id="btnShutdown" title="结束网关进程">退出网关</button>
</header> </header>
<p class="msg" id="banner"></p> <p class="msg" id="banner"></p>
......
...@@ -9,15 +9,24 @@ export function resolveWhepBase(): string { ...@@ -9,15 +9,24 @@ export function resolveWhepBase(): string {
return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '') return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '')
} }
/** 将平台设备映射为媒体流 ID(优先 streamId,与网关/MediaMTX 对齐) */ /** 将平台设备映射为媒体流 ID(与网关/MediaMTX 的 cam-142… 对齐) */
export function deviceStreamId(device: { export function deviceStreamId(device: {
id: string id: string
channel?: number channel?: number
deviceType?: string deviceType?: string
streamId?: string streamId?: string
ip?: string
}): string { }): string {
if (device.streamId && String(device.streamId).trim()) return String(device.streamId).trim() // 现场 IP 末段优先(MediaMTX path / 网关 cameras[].id)
const ip = String(device.ip || '').trim()
const ipHit = ip.match(/^192\.168\.10\.(\d+)$/)
if (ipHit) return `cam-${ipHit[1]}`
if (/^(cam|nvr)-\d+$/i.test(device.id)) return device.id
if (device.streamId && /^((cam|nvr)-\d+)$/i.test(String(device.streamId).trim())) {
return String(device.streamId).trim()
}
if (device.deviceType === 'nvr-ch' && device.channel) return `nvr-${device.channel}` if (device.deviceType === 'nvr-ch' && device.channel) return `nvr-${device.channel}`
if (device.streamId && String(device.streamId).trim()) return String(device.streamId).trim()
if (/^(cam|nvr)-/i.test(device.id)) return device.id if (/^(cam|nvr)-/i.test(device.id)) return device.id
return `cam-${device.id}` return `cam-${device.id}`
} }
......
...@@ -13,7 +13,7 @@ try { ...@@ -13,7 +13,7 @@ try {
} }
import { migrate } from './db/migrate.js' import { migrate } from './db/migrate.js'
import { pingDb } from './db/pool.js' import { pingDb } from './db/pool.js'
import { setPgEnabled } from './db/state.js' import { isPgEnabled, setPgEnabled } from './db/state.js'
import { seedAdminIfNeeded } from './users/store.js' import { seedAdminIfNeeded } from './users/store.js'
import { ensureBillingSeed } from './billing/store.js' import { ensureBillingSeed } from './billing/store.js'
import { ensureSettingsSeed } from './settings/store.js' import { ensureSettingsSeed } from './settings/store.js'
...@@ -42,19 +42,58 @@ import { alarmsRouter } from './routes/alarms.js' ...@@ -42,19 +42,58 @@ import { alarmsRouter } from './routes/alarms.js'
import { weatherRouter } from './routes/weather.js' import { weatherRouter } from './routes/weather.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js' import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** 启动时等待 PG 就绪(避免 crash-recovery 窗口内永久降级,导致历史读写全停) */
async function waitForPostgres(maxAttempts = 30, delayMs = 2000): Promise<boolean> {
for (let i = 1; i <= maxAttempts; i++) {
if (await pingDb()) return true
console.warn(`[db] PostgreSQL 未就绪,重试 ${i}/${maxAttempts}…`)
if (i < maxAttempts) await sleep(delayMs)
}
return false
}
async function enablePostgres() {
setPgEnabled(true)
await migrate()
console.log('[db] PostgreSQL 已连接,采集数据将落库')
}
/** 启动降级后后台探活;库恢复后自动迁库并恢复历史读写(不改采集循环) */
function startPostgresReconnectLoop() {
const delayMs = Math.max(5_000, Number(process.env.PG_RECONNECT_MS || 15_000))
const timer = setInterval(() => {
void (async () => {
if (isPgEnabled()) return
if (!(await pingDb())) return
try {
await enablePostgres()
console.log('[db] PostgreSQL 已恢复,历史读写已重新启用')
} catch (e) {
setPgEnabled(false)
console.warn('[db] PostgreSQL 恢复失败:', e instanceof Error ? e.message : e)
}
})()
}, delayMs)
timer.unref?.()
}
async function bootstrap() { async function bootstrap() {
const pgOk = await pingDb() let pgOk = await waitForPostgres()
setPgEnabled(pgOk) setPgEnabled(pgOk)
if (pgOk) { if (pgOk) {
await migrate() await enablePostgres()
console.log('[db] PostgreSQL 已连接,采集数据将落库')
} else { } else {
console.warn( console.warn(
'[db] PostgreSQL 未连接,已降级为本地文件/内存模式(登录可用)。\n' + '[db] PostgreSQL 未连接,已降级为本地文件/内存模式(登录可用)。\n' +
` 请启动数据库后重启服务,例如: docker compose up -d db\n` + ` 将后台重试连接;也可手动: docker compose up -d db && docker restart jh-lab-server\n` +
` 当前 DATABASE_URL=${config.databaseUrl.replace(/:[^:@/]+@/, ':****@')}`, ` 当前 DATABASE_URL=${config.databaseUrl.replace(/:[^:@/]+@/, ':****@')}`,
) )
startPostgresReconnectLoop()
} }
await seedAdminIfNeeded() await seedAdminIfNeeded()
...@@ -108,7 +147,7 @@ async function bootstrap() { ...@@ -108,7 +147,7 @@ async function bootstrap() {
res.json({ res.json({
ok: true, ok: true,
mode: config.dataMode, mode: config.dataMode,
database: pgOk ? 'postgres' : 'fallback', database: isPgEnabled() ? 'postgres' : 'fallback',
gatewayUrl: config.gatewayUrl || null, gatewayUrl: config.gatewayUrl || null,
viewportHint: 'glb|ue-stream configured on web', viewportHint: 'glb|ue-stream configured on web',
}) })
...@@ -146,7 +185,7 @@ async function bootstrap() { ...@@ -146,7 +185,7 @@ async function bootstrap() {
server.listen(config.port, () => { server.listen(config.port, () => {
console.log(`[server] http://localhost:${config.port}`) console.log(`[server] http://localhost:${config.port}`)
console.log(`[server] dataMode=${config.dataMode}`) console.log(`[server] dataMode=${config.dataMode}`)
console.log(`[server] database=${pgOk ? 'postgres' : 'fallback(file/memory)'}`) console.log(`[server] database=${isPgEnabled() ? 'postgres' : 'fallback(file/memory)'}`)
console.log(`[server] default admin: ${config.adminUsername} / ${config.adminPassword}`) console.log(`[server] default admin: ${config.adminUsername} / ${config.adminPassword}`)
}) })
} }
......
...@@ -82,6 +82,20 @@ videoRouter.get('/devices', (_req, res) => { ...@@ -82,6 +82,20 @@ videoRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() }) res.json({ devices: listDevices() })
}) })
function findVideoDevice(idOrKey: string) {
const key = String(idOrKey || '').trim()
const devices = listDevices()
let device = devices.find((d) => d.id === key || d.streamId === key)
if (!device && /^cam-\d+$/i.test(key)) {
const octet = key.slice(4)
device = devices.find((d) => String(d.ip || '').endsWith(`.${octet}`))
}
if (!device) {
device = devices.find((d) => d.name === key || d.location === key)
}
return { devices, device, key }
}
videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => { videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try { try {
const body = req.body || {} const body = req.body || {}
...@@ -151,10 +165,9 @@ videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res ...@@ -151,10 +165,9 @@ videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res
/** JPEG 抓图:反代网关 /cameras/:id/preview.jpg(WebRTC 未就绪时的降级) */ /** JPEG 抓图:反代网关 /cameras/:id/preview.jpg(WebRTC 未就绪时的降级) */
videoRouter.get('/devices/:id/preview.jpg', async (req, res) => { videoRouter.get('/devices/:id/preview.jpg', async (req, res) => {
const devices = listDevices() const { device, key } = findVideoDevice(req.params.id)
const device = devices.find((d) => d.id === req.params.id) const streamKey = device ? deviceStreamId(device) : key
const key = device ? deviceStreamId(device) : req.params.id const result = await fetchCameraPreviewJpeg(streamKey)
const result = await fetchCameraPreviewJpeg(key)
if (!result.ok && result.body.length === 0) { if (!result.ok && result.body.length === 0) {
res.status(502).json({ ok: false, message: result.message || '抓图失败' }) res.status(502).json({ ok: false, message: result.message || '抓图失败' })
return return
...@@ -166,15 +179,14 @@ videoRouter.get('/devices/:id/preview.jpg', async (req, res) => { ...@@ -166,15 +179,14 @@ videoRouter.get('/devices/:id/preview.jpg', async (req, res) => {
/** /**
* WebRTC WHEP:浏览器 POST application/sdp(offer),返回 answer SDP * WebRTC WHEP:浏览器 POST application/sdp(offer),返回 answer SDP
* 媒体面由 Windows 网关 / ZLMediaKit 提供 * 媒体面由 Windows 网关 / MediaMTX 提供。
* 大屏匿名可播;写操作仍需登录。
*/ */
videoRouter.post( videoRouter.post(
'/devices/:id/whep', '/devices/:id/whep',
authRequired,
expressSdp, expressSdp,
async (req, res) => { async (req, res) => {
const devices = listDevices() const { device } = findVideoDevice(req.params.id)
const device = devices.find((d) => d.id === req.params.id)
if (!device) { if (!device) {
res.status(404).json({ ok: false, message: '设备不存在' }) res.status(404).json({ ok: false, message: '设备不存在' })
return return
......
...@@ -482,16 +482,48 @@ function looksLikeRawId(s: string): boolean { ...@@ -482,16 +482,48 @@ function looksLikeRawId(s: string): boolean {
return false return false
} }
function findCameraSensor(marker: SceneMarkerHit, markerId: string | null): SensorSnapshot | null {
const sensors = realtime.snapshot.sensors || []
const keys = [marker.tag1, markerId, marker.id]
.map((k) => String(k || '').replace(/^sm:sensor:/i, '').trim())
.filter(Boolean)
for (const key of keys) {
const hit = sensors.find(
(s) => s.category === 'camera' && (s.id === key || s.code === key || s.videoDeviceId === key),
)
if (hit) return hit
}
const name = (marker.name || '').trim()
if (name && !looksLikeRawId(name)) {
return (
sensors.find(
(s) =>
s.category === 'camera' &&
(s.name === name || s.name.includes(name) || name.includes(s.name)),
) || null
)
}
return null
}
function findCamera(marker: SceneMarkerHit, markerId: string | null): VideoDevice | null { function findCamera(marker: SceneMarkerHit, markerId: string | null): VideoDevice | null {
// 引擎回调里 id/name/tag1 都可能是设备 id // 引擎回调里 id/name/tag1 都可能是设备 id
const keys = [markerId, marker.id, marker.tag1, marker.tag2, marker.name] const keys = [markerId, marker.id, marker.tag1, marker.tag2, marker.name]
.map(normalizeCamKey) .map(normalizeCamKey)
.filter(Boolean) .filter(Boolean)
for (const id of keys) { for (const id of keys) {
const hit = cameras.value.find((d) => d.id === id) const hit = cameras.value.find(
(d) => d.id === id || d.streamId === id || (d.ip && `cam-${d.ip.split('.').pop()}` === id),
)
if (hit) return hit if (hit) return hit
} }
const label = (marker.name || '').trim() // 大屏设施 Marker:sensors.videoDeviceId → 视频台账
const sensor = findCameraSensor(marker, markerId)
if (sensor?.videoDeviceId) {
const byVid = cameras.value.find((d) => d.id === sensor.videoDeviceId)
if (byVid) return byVid
}
const label = (sensor?.name || marker.name || '').trim()
if (label && !looksLikeRawId(label)) { if (label && !looksLikeRawId(label)) {
const byName = cameras.value.find((d) => d.name === label || d.location === label) const byName = cameras.value.find((d) => d.name === label || d.location === label)
if (byName) return byName if (byName) return byName
...@@ -533,16 +565,25 @@ function onMarkerClick(payload: { ...@@ -533,16 +565,25 @@ function onMarkerClick(payload: {
if (!markerId && !marker.id && !marker.tag0 && !marker.name) return if (!markerId && !marker.id && !marker.tag0 && !marker.name) return
const cam = findCamera(marker, markerId) const cam = findCamera(marker, markerId)
const camSensor = findCameraSensor(marker, markerId)
const category = categoryOfMarker(marker) const category = categoryOfMarker(marker)
nearHit.value = marker nearHit.value = marker
if (category === 'camera' || cam) { if (category === 'camera' || cam || camSensor) {
nearKind.value = 'video' nearKind.value = 'video'
nearLines.value = [] nearLines.value = []
nearSensorId.value = null nearSensorId.value = camSensor?.id || null
nearDeviceId.value = cam?.id || null // 优先视频台账 id;否则用传感器绑定的 videoDeviceId / 稳定 cam-* code
openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw, { nearDeviceId.value =
center: true, cam?.id ||
}) camSensor?.videoDeviceId ||
(camSensor?.code && /^cam-\d+$/i.test(camSensor.code) ? camSensor.code : null) ||
null
openNearDialog(
resolveNearTitle(cam, marker, markerId) || camSensor?.name || t('screen.camPreview'),
markerId || marker.id,
raw,
{ center: true },
)
return return
} }
const light = findLightSensor(marker, markerId) const light = findLightSensor(marker, markerId)
...@@ -578,7 +619,7 @@ const bridge = useRtengineBridge({ ...@@ -578,7 +619,7 @@ const bridge = useRtengineBridge({
}) })
const modePoisEnabled = computed(() => props.layout === 'fill' && useCloudFrame.value) const modePoisEnabled = computed(() => props.layout === 'fill' && useCloudFrame.value)
const { syncModePois, syncModePoisRebuild } = useScreenModePois(bridge, modePoisEnabled, cloudUrl) const { syncModePoisRebuild } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled) const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled)
function cameraSceneSig(list: VideoDevice[]) { function cameraSceneSig(list: VideoDevice[]) {
......
...@@ -10,19 +10,39 @@ const props = defineProps<{ ...@@ -10,19 +10,39 @@ const props = defineProps<{
const auth = useAuthStore() const auth = useAuthStore()
const videoEl = ref<HTMLVideoElement | null>(null) const videoEl = ref<HTMLVideoElement | null>(null)
const status = ref<'idle' | 'connecting' | 'playing' | 'error'>('idle') const jpegEl = ref<HTMLImageElement | null>(null)
const errorMsg = ref('') const status = ref<'idle' | 'connecting' | 'playing' | 'jpeg'>('idle')
const jpegUrl = ref('')
let pc: RTCPeerConnection | null = null let pc: RTCPeerConnection | null = null
let whepResource: string | null = null let whepResource: string | null = null
let jpegTimer: number | null = null
function stopJpeg() {
if (jpegTimer != null) {
window.clearInterval(jpegTimer)
jpegTimer = null
}
jpegUrl.value = ''
}
function startJpegFallback(_reason?: string) {
stopJpeg()
const tick = () => {
jpegUrl.value = `/api/video/devices/${encodeURIComponent(props.deviceId)}/preview.jpg?t=${Date.now()}`
}
tick()
jpegTimer = window.setInterval(tick, 1200)
status.value = 'jpeg'
}
async function stop() { async function stop() {
stopJpeg()
try { try {
if (whepResource && auth.token) { if (whepResource) {
await fetch(whepResource, { const headers: Record<string, string> = {}
method: 'DELETE', if (auth.token) headers.Authorization = `Bearer ${auth.token}`
headers: { Authorization: `Bearer ${auth.token}` }, await fetch(whepResource, { method: 'DELETE', headers }).catch(() => undefined)
}).catch(() => undefined)
} }
} finally { } finally {
whepResource = null whepResource = null
...@@ -37,7 +57,6 @@ async function start() { ...@@ -37,7 +57,6 @@ async function start() {
await stop() await stop()
if (!props.deviceId) return if (!props.deviceId) return
status.value = 'connecting' status.value = 'connecting'
errorMsg.value = ''
pc = new RTCPeerConnection({ pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
...@@ -45,6 +64,7 @@ async function start() { ...@@ -45,6 +64,7 @@ async function start() {
pc.addTransceiver('video', { direction: 'recvonly' }) pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' }) pc.addTransceiver('audio', { direction: 'recvonly' })
pc.ontrack = (ev) => { pc.ontrack = (ev) => {
stopJpeg()
if (!videoEl.value) return if (!videoEl.value) return
videoEl.value.srcObject = ev.streams[0] || new MediaStream([ev.track]) videoEl.value.srcObject = ev.streams[0] || new MediaStream([ev.track])
void videoEl.value.play().catch(() => undefined) void videoEl.value.play().catch(() => undefined)
...@@ -53,8 +73,9 @@ async function start() { ...@@ -53,8 +73,9 @@ async function start() {
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
if (!pc) return if (!pc) return
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
status.value = 'error' if (status.value === 'playing') {
errorMsg.value = 'WebRTC 连接中断' startJpegFallback('WebRTC 连接中断,已降级抓图')
}
} }
} }
...@@ -64,13 +85,15 @@ async function start() { ...@@ -64,13 +85,15 @@ async function start() {
await waitIceGathering(pc) await waitIceGathering(pc)
const path = props.whepPath || `/api/video/devices/${encodeURIComponent(props.deviceId)}/whep` const path = props.whepPath || `/api/video/devices/${encodeURIComponent(props.deviceId)}/whep`
const res = await fetch(path, { const headers: Record<string, string> = {
method: 'POST',
headers: {
'Content-Type': 'application/sdp', 'Content-Type': 'application/sdp',
Accept: 'application/sdp', Accept: 'application/sdp',
Authorization: `Bearer ${auth.token}`, }
}, // 大屏匿名可播;管理后台有 token 时一并带上
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const res = await fetch(path, {
method: 'POST',
headers,
body: pc.localDescription?.sdp || offer.sdp || '', body: pc.localDescription?.sdp || offer.sdp || '',
}) })
const loc = res.headers.get('Location') const loc = res.headers.get('Location')
...@@ -87,11 +110,10 @@ async function start() { ...@@ -87,11 +110,10 @@ async function start() {
throw new Error(msg) throw new Error(msg)
} }
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }) await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
} catch (e) { } catch {
status.value = 'error' pc?.close()
errorMsg.value = e instanceof Error ? e.message : 'WebRTC 启动失败' pc = null
await stop() startJpegFallback()
status.value = 'error'
} }
} }
...@@ -125,17 +147,14 @@ defineExpose({ restart: start, stop }) ...@@ -125,17 +147,14 @@ defineExpose({ restart: start, stop })
<template> <template>
<div class="webrtc-player" :class="status"> <div class="webrtc-player" :class="status">
<video ref="videoEl" class="video" autoplay playsinline muted /> <video v-show="status === 'playing'" ref="videoEl" class="video" autoplay playsinline muted />
<div v-if="status !== 'playing'" class="overlay"> <img
<template v-if="status === 'connecting'">正在建立 WebRTC…</template> v-show="status === 'jpeg'"
<template v-else-if="status === 'error'"> ref="jpegEl"
<div>WebRTC 未就绪</div> class="video jpeg"
<div class="sub">{{ errorMsg }}</div> :src="jpegUrl"
<button type="button" class="btn" @click="start">重试</button> alt=""
</template> />
<template v-else>空闲</template>
</div>
<div v-else class="live-tag"><i /><span>WebRTC · LIVE</span></div>
</div> </div>
</template> </template>
...@@ -155,53 +174,7 @@ defineExpose({ restart: start, stop }) ...@@ -155,53 +174,7 @@ defineExpose({ restart: start, stop })
object-fit: contain; object-fit: contain;
background: #000; background: #000;
} }
.overlay { .jpeg {
position: absolute; object-fit: contain;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #c9d7f0;
font-size: 14px;
background: rgba(5, 10, 20, 0.72);
text-align: center;
padding: 16px;
}
.sub {
font-size: 12px;
color: #8fa3c4;
max-width: 80%;
line-height: 1.5;
word-break: break-all;
}
.btn {
border: 1px solid rgba(61, 139, 253, 0.45);
background: rgba(61, 139, 253, 0.2);
color: #fff;
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
}
.live-tag {
position: absolute;
top: 12px;
left: 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
color: #9dffc0;
}
.live-tag i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #3dd68c;
box-shadow: 0 0 8px #3dd68c;
} }
</style> </style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment