Files
PnvPanel/docs/frontend.md
T
mrleo1nidandClaude Opus 5.5 bb5e59904f
CI / Backend (build + test) (push) Failing after 1m36s
CI / Frontend (lint + typecheck + build) (push) Successful in 41s
Add inbound country flags and node load in location picker
- Inbound.CountryCode (ISO alpha-2), set on publish; flag shown before
  the location name (SVG via country-flag-icons on the site, emoji in bot)
- Node.Capacity (optional, not a limit); available inbounds report load
  percent of capacity or active config count, sorted least loaded first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:45:17 +03:00

14 KiB
Raw Blame History

Frontend

SPA на React 19 + Vite + TypeScript. Общается с бэком по REST (JWT Bearer) и получает живые обновления по SignalR.

Раздача из единого контейнера. В проде собранный фронт (dist/) кладётся в wwwroot ASP.NET Core и раздаётся тем же приложением (SPA-fallback на index.html). Фронт и бек — один origin, база API — относительный /api, SignalR — /hubs/panel. В dev Vite-сервер проксирует /api и /hubs на бэкенд (vite.config.ts, цель — http://localhost:8080 по умолчанию, переопределяется VITE_API_TARGET). Детали упаковки — architecture.md.

Стек

Задача Выбор
Сборка/dev Vite (Rolldown-based)
Язык TypeScript (strict)
Данные с сервера TanStack Query
Роутинг TanStack Router (файловый, src/routes/, кодогенерация routeTree.gen.ts)
UI-компоненты shadcn-стиль поверх Radix (@radix-ui/react-*) + Tailwind CSS v4
Иконки lucide-react
Клиентский стейт Zustand — только auth-стор (features/auth/store.ts); тема — React Context + localStorage, не Zustand
Формы react-hook-form + zod
Realtime @microsoft/signalr
QR-коды qrcode.react (рендерит QR из готовой строки на клиенте)
Markdown react-markdown + remark-gfm (новости, инструкции; без rehype-raw — сырой HTML не рендерится)
Типы API openapi-typescript (pnpm gen:api) — генерирует schema.gen.ts для сверки; фичи импортируют руками написанный shared/api/types.ts
i18n react-i18next (RU + EN)
Линт oxlint (не ESLint)
Пакетный менеджер pnpm

Установлены, но не используются: recharts (админская статистика — карточки с цифрами, без графиков), @tanstack/react-table (админские таблицы написаны руками, без TanStack Table).

Структура

Фактическая структура (frontend/src/):

frontend/
  src/
    main.tsx             # точка входа: QueryClientProvider > ThemeProvider > ToastProvider > RealtimeProvider > RouterProvider
    router.tsx            # createRouter из routeTree.gen.ts
    routeTree.gen.ts      # сгенерировано @tanstack/router-plugin, не редактируется руками
    index.css             # Tailwind v4 (@import), без отдельной styles/-папки
    routes/                # файловый роутинг TanStack Router
      __root.tsx            # шапка (лого, нав, переключатель языка/темы), Outlet
      index.tsx, login.tsx, register.tsx, dashboard.tsx, instructions.tsx, news.tsx, settings.tsx
      admin.tsx              # layout админки (вкладки) + Outlet
      admin/
        index.tsx, activation.tsx, users.tsx, roles.tsx, nodes.tsx, apps.tsx, news.tsx, audit.tsx
    features/
      auth/                 # api.ts, store.ts (zustand), guards.ts, LoginForm.tsx, RegisterForm.tsx
      activation/            # api.ts, ActivationGate.tsx (экран "запросить активацию")
      configs/                # api.ts, ConfigCard.tsx, CreateConfigDialog.tsx, SubscriptionCard.tsx
      apps/                   # api.ts, AppsCatalog.tsx (для /instructions)
      news/                   # api.ts, NewsFeed.tsx (для /news)
      telegram/                # api.ts, TelegramLoginButton.tsx
      settings/                # ChangePasswordForm.tsx, TelegramLinkCard.tsx, DeleteAccountSection.tsx
      admin/
        users/, roles/, activation/, nodes/, inbounds/, apps/, news/, audit/, stats/   # api.ts + диалоги CRUD в каждой
    theme/
      ThemeProvider.tsx     # React Context + localStorage (`pnv-theme`), НЕ zustand
    shared/
      api/
        client.ts            # apiRequest(), HttpError, access-token в памяти модуля, 401 → silent refresh → повтор
        types.ts              # руками написанные типы ответов/запросов (сверены со schema.gen.ts)
        schema.gen.ts         # генерируется pnpm gen:api, не импортируется напрямую фичами
      lib/
        i18n.ts, cn.ts, format.ts
      realtime/
        connection.ts, RealtimeProvider.tsx
      ui/
        button.tsx, input.tsx, textarea.tsx, label.tsx, card.tsx, dialog.tsx, select.tsx, badge.tsx,
        progress.tsx, toast-store.tsx, toaster.tsx
  index.html
  vite.config.ts
  package.json

Нет app/-папки с провайдерами (они прямо в main.tsx), нет shared/config/ (переменные окружения читаются точечно через import.meta.env), нет styles/ (один index.css с Tailwind).

Дизайн / UX

  • Тема оформления: светлая/тёмная/системная (переключатель в шапке). Реализация — класс dark на <html> + Tailwind, выбор в localStorage (ThemeProvider, React Context).
  • Дашборд пользователя (/dashboard): карточки конфигов (протокол, локация, использованный трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает GET .../link по клику, не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для неактивированного — ActivationGate вместо дашборда.
  • Создание конфига: диалог — выбор инбаунда (по displayName, с флагом страны) + метка. Флаги — SVG из country-flag-icons (курируемый список в shared/lib/countries.ts; emoji-флаги Windows не рисует), в Telegram-боте — emoji. У каждой локации — загрузка её ноды: «светофор» + % от Node.Capacity (<50 / <80 / ≥80), либо число конфигов на сервере, если ёмкость не задана; порядок — с сервера, от менее загруженных. Лимит одновременных IP (limitIp в 3x-ui) выставляется автоматически по квоте роли пользователя (AppRole.MaxIpLimit) — в форме создания не настраивается. После успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
  • Страница инструкций (/instructions): статичные шаги + каталог приложений (GET /api/apps), сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает ссылку на скачивание.
  • Лента новостей (/news): хронологический список постов админа (заголовок + Markdown-тело, рендерится через react-markdown + remark-gfm), пагинация (GET /api/news), живое обновление по SignalR (newsPublished, широковещательно всем). Админка (/admin/news): CRUD, обычный <textarea> с переключателем предпросмотра Markdown вместо WYSIWYG-редактора.
  • Markdown-редактор админки (features/admin/media/MarkdownEditor.tsx) — общий для новостей и инструкций (интро + вкладки): текст, предпросмотр и загрузка картинок (кнопка, вставка из буфера, drag&drop). Файл уходит в POST /api/admin/media/images, а в текст на позицию курсора вставляется ![имя](/api/media/images/{id}); картинка отдаётся анонимно, поэтому подмена компонента img в react-markdown не нужна. Оформление отрендеренного markdown — общий MARKDOWN_CLASSES (shared/lib/markdown.ts), он же ограничивает картинки по ширине.
  • Настройки (/settings): смена пароля, привязка/отвязка Telegram (TelegramLinkCard), удаление аккаунта с подтверждением (DeleteAccountSection).
  • Админка (/admin/*): вкладки — обзор (карточки статистики, без графиков), запросы активации, пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные <table>, без TanStack Table. Блокировка пользователя — с подтверждением.
  • Пользователи (/admin/users): колонка «Конфигов» показывает квоту (configQuota, ∞ для безлимита) и рядом имя тарифа либо «своя квота». В UserManageDialog — блок квоты: выбор каталожного тарифа (применяется сразу, как смена роли) либо своё число конфигов через PATCH /api/admin/users/{id}/plan. Подпись явно говорит, что админская смена идёт без доплаты и что при понижении уже созданные конфиги не отзываются.
  • Состояния: isLoading/isError/пусто различаются явно везде (ошибка сети не выглядит как «пусто» — паттерн закреплён после находки в ActivationGate, распространён на все admin-списки).

Работа с API

  • shared/api/client.ts: apiRequest<T>() оборачивает fetch, подставляет access-token в Authorization; на 401 — один прозрачный POST /api/auth/refresh и повтор запроса; при неуспехе рефреша — setUnauthorizedHandler колбэк (разлогин).
  • Запросы/мутации — через TanStack Query, ключи по фиче, инвалидация после мутаций.
  • Типы — из shared/api/types.ts (см. таблицу стека выше и tech-stack.md).

Авторизация на клиенте

  • Access-token — в памяти (модуль client.ts, не React state и не localStorage) — переживает ре-рендеры, но не пережить reload (тогда его молча восстанавливает silent refresh по cookie).
  • Refresh-token — httpOnly Secure cookie (pnv_refresh_token, Path=/api/auth), JS его не видит.
  • Стор авторизации (features/auth/store.ts, Zustand) хранит текущего пользователя/isActivated. Guard-хуки useRequireAuth()/useRequireGuest()/useRequireAdmin() (features/auth/guards.ts) редиректят через useNavigate() в useEffect, если условие не выполнено.
  • Вход — по username (email не используется). «Забыли пароль?»: при привязанном Telegram — вход через бота и смена пароля в настройках; иначе — обратиться к админу.

Вход и привязка через Telegram

  • «Войти через Telegram» (TelegramLoginButton, на /login): POST .../login-request → показывает deepLink как QR (qrcode.react), поллит GET .../login-request/{id} до Approved/Rejected/Expired. При Approved — сохраняет accessToken в память клиента (refresh уже пришёл в cookie от бэка) и редиректит в панель.
  • «Привязать Telegram» (TelegramLinkCard, в настройках): POST .../link-token → QR из deepLink; статус обновляется поллингом. Отвязка — POST .../unlink.

Realtime

  • Одно SignalR-подключение к /hubs/panel с JWT (RealtimeProvider, shared/realtime/connection.ts).
  • Обработчики configTrafficUpdated/configStatusChanged/nodeStatusChanged/activationRequested/ userActivated/newsPublished точечно инвалидируют/обновляют кэш TanStack Query — UI обновляется без перезагрузки.

Скрипты

pnpm dev            # dev-сервер Vite
pnpm build           # tsc -b && vite build (прод-сборка)
pnpm preview         # предпросмотр prod-сборки
pnpm lint            # oxlint
pnpm typecheck       # tsc -b
pnpm gen:api         # openapi-typescript по /openapi/v1.json живого бэкенда -> schema.gen.ts

pnpm gen:api требует запущенный бэкенд на http://localhost:8080 (или docker compose up).