From 6dac1ed2bde3eddf631beb4394feac35039da959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 5 Jul 2026 06:55:17 +0300 Subject: [PATCH 1/2] =?UTF-8?q?BUR-58=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE-=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 22 +++++ package.json | 1 + src/app/router.tsx | 2 + src/components/CameraView.tsx | 69 ++++++++++++++ src/components/CameraViewsContainer.tsx | 7 ++ src/entities/camera/api.ts | 7 ++ src/entities/camera/types.ts | 9 ++ src/features/edge-detail/EdgeVideoPage.tsx | 27 ++++++ .../edge-detail/components/EdgeSidebar.tsx | 10 ++- .../edge-detail/components/VideoView.tsx | 46 ++++++++++ src/features/edge-detail/types.ts | 2 +- src/styles/detail.css | 1 + src/styles/detail/video.css | 90 +++++++++++++++++++ 13 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/components/CameraView.tsx create mode 100644 src/components/CameraViewsContainer.tsx create mode 100644 src/entities/camera/api.ts create mode 100644 src/entities/camera/types.ts create mode 100644 src/features/edge-detail/EdgeVideoPage.tsx create mode 100644 src/features/edge-detail/components/VideoView.tsx create mode 100644 src/styles/detail/video.css diff --git a/package-lock.json b/package-lock.json index 7f87ab3..39131cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "echarts-for-react": "^3.0.2", "keycloak-js": "^26.2.3", "lucide-react": "^0.468.0", + "mpegts.js": "^1.8.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.18.0" @@ -1234,6 +1235,12 @@ "react": "^15.0.0 || >=16.0.0" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2014,6 +2021,16 @@ "node": "*" } }, + "node_modules/mpegts.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz", + "integrity": "sha512-ZtujqtmTjWgcDDkoOnLvrOKUTO/MKgLHM432zGDI8oPaJ0S+ebPxg1nEpDpLw6I7KmV/GZgUIrfbWi3qqEircg==", + "license": "Apache-2.0", + "dependencies": { + "es6-promise": "^4.2.5", + "webworkify-webpack": "github:xqq/webworkify-webpack" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2554,6 +2571,11 @@ } } }, + "node_modules/webworkify-webpack": { + "version": "2.1.5", + "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ccd0f6b..3e24b83 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "echarts-for-react": "^3.0.2", "keycloak-js": "^26.2.3", "lucide-react": "^0.468.0", + "mpegts.js": "^1.8.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.18.0" diff --git a/src/app/router.tsx b/src/app/router.tsx index 0878910..27340db 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-route import { EdgeCurrentPage } from '../features/edge-detail/EdgeCurrentPage'; import { EdgeDetailPage } from '../features/edge-detail/EdgeDetailPage'; import { EdgeHistoryPage } from '../features/edge-detail/EdgeHistoryPage'; +import { EdgeVideoPage } from '../features/edge-detail/EdgeVideoPage'; import { EdgesDashboard } from '../features/edges-dashboard/EdgesDashboard'; function DashboardRoute() { @@ -19,6 +20,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> diff --git a/src/components/CameraView.tsx b/src/components/CameraView.tsx new file mode 100644 index 0000000..d95c6bd --- /dev/null +++ b/src/components/CameraView.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; + +type CameraViewProps = { + title?: string; + wsUrl: string; +}; + +export function CameraView({ title = 'Камера', wsUrl }: CameraViewProps) { + const videoRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + const video = videoRef.current; + let player: import('mpegts.js').default.Player | null = null; + let cancelled = false; + + if (!video) { + return; + } + + const clearError = () => setError(null); + video.addEventListener('playing', clearError); + setError(null); + + void import('mpegts.js').then(({ default: mpegts }) => { + if (cancelled) { + return; + } + + if (!mpegts.isSupported()) { + setError('Браузер не поддерживает MPEG-TS поток'); + return; + } + + player = mpegts.createPlayer( + { type: 'mpegts', isLive: true, url: wsUrl }, + { liveBufferLatencyChasing: true, liveBufferLatencyMaxLatency: 1.5 }, + ); + + player.attachMediaElement(video); + player.load(); + + const playResult = player.play(); + if (playResult instanceof Promise) { + void playResult.then(() => setError(null)).catch(() => setError('Не удалось запустить воспроизведение')); + } + }); + + return () => { + cancelled = true; + video.removeEventListener('playing', clearError); + player?.pause(); + player?.unload(); + player?.detachMediaElement(); + player?.destroy(); + }; + }, [wsUrl]); + + return ( +
+
+ ); +} diff --git a/src/components/CameraViewsContainer.tsx b/src/components/CameraViewsContainer.tsx new file mode 100644 index 0000000..ab583fc --- /dev/null +++ b/src/components/CameraViewsContainer.tsx @@ -0,0 +1,7 @@ +import type { PropsWithChildren } from 'react'; + +type CameraViewsContainerProps = PropsWithChildren; + +export function CameraViewsContainer({ children }: CameraViewsContainerProps) { + return
{children}
; +} diff --git a/src/entities/camera/api.ts b/src/entities/camera/api.ts new file mode 100644 index 0000000..ddc54db --- /dev/null +++ b/src/entities/camera/api.ts @@ -0,0 +1,7 @@ +import { getJson } from '../../shared/api/http'; +import { cloudApiUrl } from '../../shared/config/env'; +import type { CameraResponse } from './types'; + +export function getCameras(edge: string): Promise { + return getJson(cloudApiUrl, '/camera', { edge }); +} diff --git a/src/entities/camera/types.ts b/src/entities/camera/types.ts new file mode 100644 index 0000000..11e8d78 --- /dev/null +++ b/src/entities/camera/types.ts @@ -0,0 +1,9 @@ +export type CameraItem = { + protocol: string; + source: string; +}; + +export type CameraResponse = { + edge: string; + items: CameraItem[]; +}; diff --git a/src/features/edge-detail/EdgeVideoPage.tsx b/src/features/edge-detail/EdgeVideoPage.tsx new file mode 100644 index 0000000..0222f90 --- /dev/null +++ b/src/features/edge-detail/EdgeVideoPage.tsx @@ -0,0 +1,27 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'react-router-dom'; +import { getCameras } from '../../entities/camera/api'; +import { EdgePageLayout } from './components/EdgePageLayout'; +import { VideoView } from './components/VideoView'; + +export function EdgeVideoPage() { + const { edgeId = '' } = useParams(); + const cameras = useQuery({ + queryKey: ['camera', edgeId], + queryFn: () => getCameras(edgeId), + enabled: Boolean(edgeId), + }); + const cameraItems = useMemo(() => cameras.data?.items ?? [], [cameras.data?.items]); + + return ( + void cameras.refetch()} + > + + + ); +} diff --git a/src/features/edge-detail/components/EdgeSidebar.tsx b/src/features/edge-detail/components/EdgeSidebar.tsx index c7d7b7f..874fa90 100644 --- a/src/features/edge-detail/components/EdgeSidebar.tsx +++ b/src/features/edge-detail/components/EdgeSidebar.tsx @@ -1,4 +1,4 @@ -import { Activity, BarChart3, Gauge, Menu, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; +import { Activity, BarChart3, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Video } from 'lucide-react'; import type { DetailView } from '../types'; type EdgeSidebarProps = { @@ -56,6 +56,14 @@ export function EdgeSidebar({ collapsed, edgePath, view, onNavigate, onToggleCol Показатели + ); diff --git a/src/features/edge-detail/components/VideoView.tsx b/src/features/edge-detail/components/VideoView.tsx new file mode 100644 index 0000000..13e2a5d --- /dev/null +++ b/src/features/edge-detail/components/VideoView.tsx @@ -0,0 +1,46 @@ +import { Video } from 'lucide-react'; +import { CameraView } from '../../../components/CameraView'; +import { CameraViewsContainer } from '../../../components/CameraViewsContainer'; +import type { CameraItem } from '../../../entities/camera/types'; + +type VideoViewProps = { + cameras: CameraItem[]; + error: unknown; + isError: boolean; + loading: boolean; +}; + +function createWsUrl(camera: CameraItem): string { + const protocol = camera.protocol.replace(/:\/?\/?$/, ''); + const source = camera.source.replace(/^\/\//, ''); + return `${protocol}://${source}`; +} + +export function VideoView({ cameras, error, isError, loading }: VideoViewProps) { + return ( +
+
+
+ + +

Видеопотоки буровой

+
+
+ + {loading ?
Загрузка камер...
: null} + {isError ?
Не удалось загрузить камеры: {String(error)}
: null} + {!loading && !isError && cameras.length === 0 ?
Камеры для этой буровой не настроены
: null} + + {!loading && !isError && cameras.length > 0 ? ( + + {cameras.map((camera, index) => { + const wsUrl = createWsUrl(camera); + return ; + })} + + ) : null} +
+ ); +} diff --git a/src/features/edge-detail/types.ts b/src/features/edge-detail/types.ts index f73f671..8f21f7c 100644 --- a/src/features/edge-detail/types.ts +++ b/src/features/edge-detail/types.ts @@ -1 +1 @@ -export type DetailView = 'overview' | 'archive' | 'indicators'; +export type DetailView = 'overview' | 'archive' | 'indicators' | 'video'; diff --git a/src/styles/detail.css b/src/styles/detail.css index 83e9a4c..e9f77a7 100644 --- a/src/styles/detail.css +++ b/src/styles/detail.css @@ -2,4 +2,5 @@ @import './detail/overview.css'; @import './detail/indicators.css'; @import './detail/archive.css'; +@import './detail/video.css'; @import './detail/electrical.css'; diff --git a/src/styles/detail/video.css b/src/styles/detail/video.css new file mode 100644 index 0000000..c0c9692 --- /dev/null +++ b/src/styles/detail/video.css @@ -0,0 +1,90 @@ +.video-section { + min-height: 0; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: + radial-gradient(circle at 12% 8%, rgba(255, 255, 255, 0.08), transparent 38%), + linear-gradient(145deg, rgba(17, 24, 39, 0.9), rgba(2, 6, 23, 0.78)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 18px 36px rgba(0, 0, 0, 0.28); +} + +.camera-views-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.camera-view { + position: relative; + overflow: hidden; + min-width: 0; + aspect-ratio: 16 / 9; + border: 1px solid rgba(88, 103, 121, 0.42); + border-radius: 8px; + background: #000; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 10px 22px rgba(0, 0, 0, 0.32); +} + +.camera-view__video { + width: 100%; + height: 100%; + display: block; + background: #000; + object-fit: contain; +} + +.camera-view__caption { + position: absolute; + left: 10px; + right: 10px; + bottom: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 6px; + background: rgba(2, 6, 23, 0.78); + color: #cbd5e1; +} + +.camera-view__caption strong, +.camera-view__caption span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.camera-view__caption strong { + flex: 0 0 auto; + color: #f8fafc; + font-size: 0.82rem; +} + +.camera-view__caption span { + font-size: 0.72rem; +} + +.camera-view__error { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 18px; + background: rgba(2, 6, 23, 0.82); + color: #fca5a5; + text-align: center; +} + +@media (max-width: 900px) { + .camera-views-grid { + grid-template-columns: 1fr; + } +} -- 2.49.1 From 4d31b6d3be39da0c3785bc06f7698b3229a6bb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 5 Jul 2026 07:03:41 +0300 Subject: [PATCH 2/2] =?UTF-8?q?BUR-58=20=D1=81=D0=B4=D0=B2=D0=B8=D0=BD?= =?UTF-8?q?=D1=83=D0=BB=20=D0=BF=D0=BB=D0=B0=D1=88=D0=BA=D1=83=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D0=B5=20=D0=BD=D0=B8?= =?UTF-8?q?=D0=B6=D0=B5,=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=BB=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BB=D0=B5=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/styles/detail/video.css | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/styles/detail/video.css b/src/styles/detail/video.css index c0c9692..7d4ddc3 100644 --- a/src/styles/detail/video.css +++ b/src/styles/detail/video.css @@ -21,10 +21,11 @@ position: relative; overflow: hidden; min-width: 0; - aspect-ratio: 16 / 9; border: 1px solid rgba(88, 103, 121, 0.42); border-radius: 8px; - background: #000; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 28%), + #000; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 10px 22px rgba(0, 0, 0, 0.32); @@ -32,25 +33,21 @@ .camera-view__video { width: 100%; - height: 100%; + aspect-ratio: 16 / 9; display: block; background: #000; object-fit: contain; } .camera-view__caption { - position: absolute; - left: 10px; - right: 10px; - bottom: 10px; display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 10px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: 6px; - background: rgba(2, 6, 23, 0.78); + min-height: 38px; + padding: 0 10px; + border-top: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(2, 6, 23, 0.88); color: #cbd5e1; } -- 2.49.1