- Introduced `MediaImage` entity to manage images for markdown in instructions and news. - Updated `IAppDbContext` and `AppDbContext` to include `MediaImages` DbSet. - Implemented `DeleteMediaImageFilesAsync` method in `FactoryResetCommandHandler` to remove media images during factory reset. - Added new API endpoints for uploading and retrieving media images, enhancing markdown support. - Updated frontend components to utilize the new `MarkdownEditor` for image uploads in instructions and news. - Enhanced documentation to reflect the new media handling features and API specifications.
163 lines
13 KiB
Markdown
163 lines
13 KiB
Markdown
# 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](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`) + метка. Лимит одновременных 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`, а в текст на позицию курсора
|
||
вставляется ``; картинка отдаётся анонимно, поэтому подмена
|
||
компонента `img` в `react-markdown` не нужна. Оформление отрендеренного markdown —
|
||
общий `MARKDOWN_CLASSES` (`shared/lib/markdown.ts`), он же ограничивает картинки по ширине.
|
||
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
|
||
удаление аккаунта с подтверждением (`DeleteAccountSection`).
|
||
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
|
||
пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные `<table>`,
|
||
без TanStack Table. Блокировка пользователя — с подтверждением.
|
||
- **Состояния**: `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](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 обновляется
|
||
без перезагрузки.
|
||
|
||
## Скрипты
|
||
|
||
```bash
|
||
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`).
|