diff --git a/README.md b/README.md index 43934ae..9714826 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - карта зон и камер; - отдельные страницы аналитики зоны, камеры и запуска распознавания; - оценка качества распознавания и просмотр feedback; -- кеширование просмотренных снимков камеры до обновления страницы; +- независимое кеширование последнего кадра, последней детекции и размеченного снимка до обновления страницы; - переключение снимков и обновление текущего кадра в полноэкранном просмотре. ### Пользователи @@ -70,8 +70,8 @@ - автоматическое определение размера изображения; - вертикальная прокрутка списка камер; - Яндекс.Карта с выбором и позиционированием камеры; -- кеширование обычного и размеченного snapshot; -- переключение между текущим кадром и распознанными автомобилями; +- кеширование свежего кадра, кадра последней детекции и размеченного snapshot; +- переключение между текущим кадром, последним распознаванием и визуализацией распознавания; - полноэкранный просмотр снимков; - переход к настройке и разметке конкретной камеры; - массовая активация, деактивация и удаление выбранных камер. @@ -288,6 +288,14 @@ Production-образ собирает приложение на Node.js и от - `/health`; - `/version`. +Для `/cameras/{camera_id}/snapshot` поддерживаются три режима: + +- без query-параметров — свежий кадр из видеопотока; +- `last_detection=true` — кадр, сохранённый в момент последней детекции; +- `annotated=true&fallback_to_raw=true` — визуализация распознавания с возможным fallback на обычный кадр. + +Обычный кадр и кадр последней детекции требуют `cameras.view`. Для визуализации распознавания backend дополнительно проверяет `admin.monitoring.view`. + Основные analytics endpoints: - `/admin/analytics/summary`; @@ -305,47 +313,6 @@ Production-образ собирает приложение на Node.js и от Защищённые запросы отправляются с bearer-токеном текущей сессии. Сессия сохраняется в `localStorage`, переживает обновление страницы и валидируется через `/auth/me`. -## Smoke-проверка - -Перед проверкой должны быть запущены PostgreSQL, миграции, backend и frontend. - -1. Войти обычным пользователем и администратором. -2. Обновить страницу и убедиться, что сессия сохранилась. -3. Проверить доступность разделов согласно permissions. -4. Открыть обзор и проверить сводные показатели и зоны внимания. -5. Открыть аналитику: - - изменить период и детализацию; - - выбрать зоны и камеры; - - проверить ручное и автоматическое обновление; - - выбрать срез прогноза; - - проверить tooltips графиков; - - открыть аналитику зоны и камеры; - - проверить таблицу состояния всех зон. -6. Открыть камеру: - - проверить фильтры; - - создать или отредактировать камеру; - - переключить обычный и размеченный snapshot; - - открыть snapshot на весь экран; - - отметить камеру на карте. -7. Перейти в разметку: - - создать зону; - - переместить вершины и линии; - - переместить и масштабировать кадр; - - открыть fullscreen и проверить центрирование; - - изменить геометрию зоны на карте; - - сохранить изменения. -8. Проверить фильтры, редактирование и bulk actions камер, зон, пользователей и партнёров. -9. Проверить добавление пользователя в партнёра и изменение доступов. -10. Проверить реестр источников. -11. Проверить профиль и восстановление пароля. -12. Выполнить production-сборку: - -```bash -npm run build -``` - -Сборка должна завершиться без ошибок TypeScript и Vite. - ## Правила разработки - сохранять существующие API-контракты и permission checks; diff --git a/src/api/cameras.ts b/src/api/cameras.ts index 3420de5..d52e1e7 100644 --- a/src/api/cameras.ts +++ b/src/api/cameras.ts @@ -84,9 +84,22 @@ export type CameraSnapshot = { export type CameraSnapshotOptions = { annotated?: boolean; + last_detection?: boolean; fallback_to_raw?: boolean; }; +export type CameraSnapshotMode = 'latest' | 'detection' | 'annotated'; + +export function cameraSnapshotOptions(mode: CameraSnapshotMode): CameraSnapshotOptions | undefined { + if (mode === 'detection') { + return { last_detection: true }; + } + if (mode === 'annotated') { + return { annotated: true, fallback_to_raw: true }; + } + return undefined; +} + function formatBBox(bbox?: CameraBBox | string) { if (!bbox) return undefined; if (typeof bbox === 'string') return bbox; @@ -140,6 +153,7 @@ export const camerasApi = { async getSnapshot(cameraId: number, options?: CameraSnapshotOptions): Promise { const query = buildQuery({ annotated: options?.annotated, + last_detection: options?.last_detection, fallback_to_raw: options?.fallback_to_raw }); const { blob, headers } = await requestBlob(`/cameras/${encodeURIComponent(cameraId)}/snapshot${query}`); diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 678b216..2ebfa16 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -8,6 +8,7 @@ import { useFeedbackStore } from '@/feedback/feedbackStore'; import { useSessionStore } from '@/auth/sessionStore'; import { fitYandexMap, yandexPoint, type YandexPoint } from '@/maps/yandex'; import { useYandexMap } from '@/maps/useYandexMap'; +import { cameraSnapshotOptions, type CameraSnapshotMode } from '@/api/cameras'; function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number { return typeof latitude === 'number' @@ -22,8 +23,8 @@ type SnapshotState = { data?: CameraSnapshot; }; -function snapshotCacheKey(cameraId: number, annotated: boolean) { - return `${cameraId}:${annotated ? 'annotated' : 'frame'}`; +function snapshotCacheKey(cameraId: number, mode: CameraSnapshotMode) { + return `${cameraId}:${mode}`; } function revokeSnapshotData(data?: CameraSnapshot) { @@ -48,6 +49,21 @@ type CameraSaveState = { error?: string; }; +const SNAPSHOT_MODE_CONTENT: Record = { + latest: { + title: 'Последний снимок', + description: 'Свежий кадр из видеопотока' + }, + detection: { + title: 'Последнее распознавание', + description: 'Кадр, сохранённый в момент последней детекции' + }, + annotated: { + title: 'С разметкой', + description: 'Последняя визуализация распознавания' + } +}; + function formatDate(dateStr?: string): string { if (!dateStr) return '—'; try { @@ -286,6 +302,7 @@ export default function CamerasPage() { const notifySuccess = useFeedbackStore(state => state.success); const confirmAction = useFeedbackStore(state => state.confirm); const currentPartnerId = useSessionStore(state => state.currentPartnerId); + const canViewAnnotatedSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); const [cameras, setCameras] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -306,7 +323,7 @@ export default function CamerasPage() { const snapshotCacheAliveRef = useRef(true); const [snapshot, setSnapshot] = useState({ loading: false }); const [snapshotReloadKey, setSnapshotReloadKey] = useState(0); - const [snapshotAnnotated, setSnapshotAnnotated] = useState(true); + const [snapshotMode, setSnapshotMode] = useState('latest'); const [isSnapshotFullscreen, setIsSnapshotFullscreen] = useState(false); const [editor, setEditor] = useState(null); const [saveState, setSaveState] = useState({ loading: false }); @@ -335,8 +352,14 @@ export default function CamerasPage() { }; }, []); - function fetchSnapshot(cameraId: number, annotated: boolean) { - const key = snapshotCacheKey(cameraId, annotated); + useEffect(() => { + if (!canViewAnnotatedSnapshot && snapshotMode === 'annotated') { + setSnapshotMode('latest'); + } + }, [canViewAnnotatedSnapshot, snapshotMode]); + + function fetchSnapshot(cameraId: number, mode: CameraSnapshotMode) { + const key = snapshotCacheKey(cameraId, mode); const cached = snapshotCacheRef.current.get(key); if (cached) return Promise.resolve(cached); @@ -344,10 +367,7 @@ export default function CamerasPage() { if (inFlight) return inFlight; let request: Promise; - request = api.getSnapshot(cameraId, { - annotated, - fallback_to_raw: true - }).then(data => { + request = api.getSnapshot(cameraId, cameraSnapshotOptions(mode)).then(data => { if (!snapshotCacheAliveRef.current) { revokeSnapshotData(data); return data; @@ -374,16 +394,9 @@ export default function CamerasPage() { return request; } - function prefetchOtherSnapshotMode(cameraId: number, annotated: boolean) { - const otherMode = !annotated; - const key = snapshotCacheKey(cameraId, otherMode); - if (snapshotCacheRef.current.has(key) || snapshotRequestsRef.current.has(key)) return; - fetchSnapshot(cameraId, otherMode).catch(() => undefined); - } - function refreshCurrentSnapshot() { if (selectedCamera) { - const key = snapshotCacheKey(selectedCamera.camera_id, snapshotAnnotated); + const key = snapshotCacheKey(selectedCamera.camera_id, snapshotMode); const cached = snapshotCacheRef.current.get(key); revokeSnapshotData(cached); snapshotCacheRef.current.delete(key); @@ -476,20 +489,18 @@ export default function CamerasPage() { return; } - const key = snapshotCacheKey(selectedCamera.camera_id, snapshotAnnotated); + const key = snapshotCacheKey(selectedCamera.camera_id, snapshotMode); const cached = snapshotCacheRef.current.get(key); if (cached) { setSnapshot({ loading: false, data: cached }); - prefetchOtherSnapshotMode(selectedCamera.camera_id, snapshotAnnotated); return; } setSnapshot({ loading: true }); try { - const data = await fetchSnapshot(selectedCamera.camera_id, snapshotAnnotated); + const data = await fetchSnapshot(selectedCamera.camera_id, snapshotMode); if (!cancelled) { setSnapshot({ loading: false, data }); - prefetchOtherSnapshotMode(selectedCamera.camera_id, snapshotAnnotated); } } catch (e: any) { if (!cancelled) { @@ -502,7 +513,7 @@ export default function CamerasPage() { return () => { cancelled = true; }; - }, [selectedCamera?.camera_id, snapshotAnnotated, snapshotReloadKey]); + }, [selectedCamera?.camera_id, snapshotMode, snapshotReloadKey]); useEffect(() => { if (!selectedCamera) { @@ -938,27 +949,38 @@ export default function CamerasPage() {
-

{snapshotAnnotated ? 'Распознанные автомобили' : 'Текущий кадр'}

-
- {snapshotAnnotated ? 'Разметка включена' : 'Разметка скрыта'} -
+

{SNAPSHOT_MODE_CONTENT[snapshotMode].title}

+
{SNAPSHOT_MODE_CONTENT[snapshotMode].description}
-
+
+ {canViewAnnotatedSnapshot && ( + + )}
{snapshot.data?.image_url && ( - - + + + {canViewAnnotatedSnapshot && ( + + )}
); } diff --git a/src/styles.css b/src/styles.css index a133b84..868ade4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1650,9 +1650,13 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } border: 1px solid var(--border); border-radius: 8px; background: #eef6f0; - min-width: 176px; + min-width: min(100%, 360px); max-width: 100%; - flex: 1 1 190px; + flex: 1 1 360px; +} + +.snapshot-mode-toggle.three-options { + grid-template-columns: repeat(3, minmax(0, 1fr)); } .snapshot-mode-option {