mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-14 16:53:08 +00:00
feat(remnawave): GeoCheck ноды в админке — запуск проверки и просмотр отчёта
Кнопка в карточке ноды открывает модалку: выбор маршрута (по умолчанию / IP-адрес / интерфейс) с подсказками из данных ноды, ожидание, затем отчёт картинкой либо тем же отчётом в JSON. Есть копирование JSON, скачивание SVG, повтор проверки и полноэкранный режим. Кнопка показывается только для узлов 3.3.0+ (versions.node) и только для подключённых — иначе админ упирался бы в ошибку панели. Отчёт вставляется как <img src="data:...">, а не сырым SVG в разметку: он несёт встроенный моноширинный шрифт и <style>, на которых держится выравнивание колонок, и санитайзер их выбрасывает. В <img> SVG рисуется в изолированном контексте без скриптов и внешних запросов — и безопаснее, и вернее по виду. Зум и панорамирование — react-zoom-pan-pinch с той же конфигурацией, что в самой панели Remnawave. Своя реализация обязательна: index.html задаёт user-scalable=no, поэтому браузерного пинча нет ни в мобильном вебе, ни в Mini App. Колесо намеренно не масштабирует, иначе прокрутка над отчётом превращается в зум. В отличие от панели стартовый масштаб считается от читаемой ширины: на телефоне «вписано по ширине» превращает отчёт в серую рябь. На 390px это 2.42x с панорамированием, на десктопе — 1x. При смене ширины (полный экран, поворот) масштаб пересчитывается. Опрос задачи с потолком в три минуты — иначе забытая открытой модалка опрашивала бы панель бесконечно. Пока проверка идёт, модалка не закрывается: задача уже поставлена, терять её результат молча хуже. Заодно в AdminRemnawave.tsx осели автоправки biome (import type, Math.pow -> **) — файл всё равно правился, они механические. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
120
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal file
120
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
|
||||
import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons';
|
||||
|
||||
/**
|
||||
* Ширина в CSS-пикселях, при которой моноширинный отчёт ещё читается.
|
||||
* Отчёт всегда заметно шире телефона, поэтому «вписать по ширине» на узком
|
||||
* экране превращает его в серую рябь. Стартуем с масштаба, дающего примерно
|
||||
* такую ширину, а вписать целиком всегда можно кнопкой сброса и жестом.
|
||||
*/
|
||||
const READABLE_WIDTH = 760;
|
||||
|
||||
/** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */
|
||||
const SCALE_MIN = 1;
|
||||
const SCALE_MAX = 8;
|
||||
|
||||
interface GeoCheckImageViewerProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
/** Смена режима меняет доступную ширину — просмотрщик пересобирается. */
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Просмотр широкого SVG-отчёта: зум, перетаскивание, пинч, двойной тап.
|
||||
*
|
||||
* Зум свой, а не браузерный: в `index.html` приложения стоит
|
||||
* `user-scalable=no`, поэтому нативного пинча нет ни в мобильном вебе, ни в
|
||||
* Mini App. Библиотека берёт на себя все жесты и работает одинаково от мыши,
|
||||
* пальца и трекпада — так же это сделано в самой панели Remnawave.
|
||||
*
|
||||
* Колесо намеренно не масштабирует (`wheelDisabled`): иначе обычная прокрутка
|
||||
* над отчётом превращалась бы в зум.
|
||||
*/
|
||||
export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
|
||||
observer.observe(host);
|
||||
setWidth(host.clientWidth);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Масштаб 1 = вписано по ширине. На широком экране этого достаточно, на
|
||||
// узком — поднимаем до читаемого и позволяем панорамировать.
|
||||
const initialScale = width > 0 ? Math.min(SCALE_MAX, Math.max(1, READABLE_WIDTH / width)) : 1;
|
||||
|
||||
const controlButton =
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg text-dark-200 transition-colors hover:bg-dark-100/10 active:scale-95';
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className="relative h-full">
|
||||
{width > 0 && (
|
||||
<TransformWrapper
|
||||
// Пересобираем, когда меняется нужный стартовый масштаб: полный экран,
|
||||
// поворот телефона, разворот окна. Ключ огрублён до десятых — пока
|
||||
// отчёт и так вписывается (масштаб 1), перетаскивание окна мышью
|
||||
// ничего не сбрасывает.
|
||||
key={`${fullscreen}-${Math.round(initialScale * 10)}`}
|
||||
centerOnInit={false}
|
||||
disablePadding
|
||||
doubleClick={{ mode: 'toggle' }}
|
||||
initialScale={initialScale}
|
||||
maxScale={SCALE_MAX}
|
||||
minScale={SCALE_MIN}
|
||||
trackPadPanning={{ disabled: false }}
|
||||
wheel={{ wheelDisabled: true }}
|
||||
>
|
||||
{({ resetTransform, zoomIn, zoomOut }) => (
|
||||
<>
|
||||
<TransformComponent
|
||||
contentStyle={{ width: '100%' }}
|
||||
// touch-action: none — все жесты обрабатывает библиотека,
|
||||
// иначе браузер перехватит их под прокрутку.
|
||||
wrapperClass="!h-full !w-full cursor-grab touch-none active:cursor-grabbing"
|
||||
>
|
||||
<img alt={alt} className="block w-full select-none" draggable={false} src={src} />
|
||||
</TransformComponent>
|
||||
|
||||
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-xl border border-dark-100/10 bg-dark-950/70 p-1 shadow-lg backdrop-blur-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut()}
|
||||
className={controlButton}
|
||||
aria-label={t('admin.remnawave.geoCheck.zoomOut', 'Zoom out')}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn()}
|
||||
className={controlButton}
|
||||
aria-label={t('admin.remnawave.geoCheck.zoomIn', 'Zoom in')}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span aria-hidden className="mx-0.5 h-5 w-px bg-dark-100/15" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetTransform()}
|
||||
className={controlButton}
|
||||
aria-label={t('admin.remnawave.geoCheck.zoomReset', 'Reset zoom')}
|
||||
>
|
||||
<ResetIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user