> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/ru/architecture/frontend.md).

# Фронтенд

Всё в `frontend/` — это SPA на Vue 3, использующее `<script setup>`, Pinia, vue-router в режиме истории HTML5, vue-i18n и Tailwind CSS v4 поверх скопированных примитивов shadcn-vue. Без TypeScript.

`App.vue` — это тонкая оболочка: провайдер подсказок, хост тостов, запрос на установку PWA, тема и `<router-view>`. Здесь же ровно один раз инициализируются два конвейера, основанных на событиях.

## Маршрутизация

`frontend/router/index.js` объявляет четыре реальных маршрута и catch-all:

| Путь               | Компонент               | Примечания                                                                         |
| ------------------ | ----------------------- | ---------------------------------------------------------------------------------- |
| `/`                | `Home.vue`              | Импортируется сразу — страница входа по умолчанию                                  |
| `/tools/:slug`     | `StandaloneTool.vue`    | Полноценная страница для одного инструмента, доступная для публикации и индексации |
| `/privacy`         | `PrivacyPolicy.vue`     |                                                                                    |
| `/r/:id`           | `report/ReportPage.vue` | Общий диагностический отчёт только для чтения, noindex                             |
| `/:pathMatch(.*)*` | —                       | Перенаправляет на `/`                                                              |

Всё, кроме `Home` лениво импортируется, чтобы не попадать в бандл главной страницы. `scrollBehavior` намеренно **не** прокручивает страницу, когда на том же пути меняется только query — именно так происходит открытие и закрытие панели инструментов.

У продвинутых инструментов есть второй вход: на главной странице, `?tool=<slug>` открывает тот же компонент в нижней выдвижной панели. Оба входа рендерят один и тот же `.vue` файл; отличается только оболочка.

## Реестр инструментов

`frontend/data/tools.js` — это единый источник истины для продвинутых инструментов. Он экспортирует один упорядоченный массив и карту поиска:

```js
export const ADVANCED_TOOLS = [
  { slug: 'whois', emoji: '📓', titleKey: 'whois.Title',
    noteKey: 'advancedtools.Whois',
    component: () => import('@/components/advanced-tools/Whois.vue') },
  // …
];

export const TOOL_BY_SLUG = new Map(ADVANCED_TOOLS.map((t) => [t.slug, t]));
```

Поля записи:

| Поле                   | Значение                                                                 |
| ---------------------- | ------------------------------------------------------------------------ |
| `slug`                 | Стабильный идентификатор, используемый и `?tool=<slug>` и `/tools/:slug` |
| `emoji`                | Значок на карточке и в заголовке панели                                  |
| `titleKey` / `noteKey` | ключи i18n для заголовка и однострочного описания                        |
| `component`            | Ленивый `import()` файла инструмента `.vue` файл                         |
| `requiresOriginalSite` | Необязательный флаг; если опущен, инструмент общедоступен                |

От него зависят три потребителя, поэтому обычно вся работа сводится к добавлению записи:

* **`Advanced.vue`** преобразует массив в сетку карточек (`карточки`), фильтрует по `configs.originalSite` (`enabledCards`), и определяет активный инструмент панели с помощью `TOOL_BY_SLUG.get(route.query.tool)`. Разрешённый ленивый компонент кэшируется в `Map` чтобы повторные рендеры не перемонтировали уже запущенный инструмент. Обычный щелчок левой кнопкой по карточке вызывает `router.push({ path: '/', query: { tool } })`; щелчки с модификаторами и средней кнопкой передаются в `<a href>` поэтому отдельная страница открывается в новой вкладке.
* **`StandaloneTool.vue`** определяет `TOOL_BY_SLUG.get(route.params.slug)`, оборачивает его в `defineAsyncComponent`, задаёт для каждого инструмента локализованные заголовок, описание и канонический URL через `use-document-meta.js`, и перенаправляет на `/` при неизвестном slug. Обратите внимание: у самого роутера есть только один динамический маршрут — разрешение делает реестр, а не сгенерированная таблица маршрутов.
* **`Nav.vue`** выводит те же инструменты в навигации, применяя тот же `requiresOriginalSite` фильтр.

{% hint style="info" %}
`requiresOriginalSite: true` скрывает инструмент на self-hosted-экземплярах, потому что ему нужен приватный API IPCheck.ing и вошедший в систему пользователь. Флаг вычисляется по `store.configs.originalSite`, который бэкенд выводит из referer запроса — см. [Функции, связанные с IPCheck.ing](/developer/ru/configuration/features-tied-to-ipcheck-ing.md).
{% endhint %}

Другие реестры лежат рядом с ним в `data/`: `sections.js` (идентификаторы разделов главной страницы, которые управляют навигацией, отслеживанием прокрутки и состоянием загрузки), `ip-databases.js` (источники геолокации, между которыми могут переключаться пользователи), `achievements.js` и `achievement-rules.js`, `changelog.json`, и `default-preferences.js`.

## Хранилище Pinia

`frontend/store.js` определяет одно хранилище, `main`. Оно хранит общее состояние между компонентами, а не детальные данные отдельных компонентов:

* **Сессия / авторизация** — `user`, `isSignedIn`, `isFireBaseSet`, а также действия входа, выхода и слушателя авторизации.
* **Флаги функций бэкенда** — `configs`, заполняемые в режиме fire-and-forget через `fetchConfigs()` из `/api/configs`. Компоненты читают его реактивно, поэтому первый рендер никогда не ждёт этого запроса. После получения флаги также определяют доступность каждого источника геолокации и, если сохранённая настройка источника больше не сконфигурирована, переносят её на ближайший доступный.
* **Настройки пользователя** — `userPreferences`, загружаемые из и записываемые обратно в `localStorage`, с миграцией со старых ключей.
* **Состояние страницы** — `mountingStatus` и `loadingStatus` (по одному флагу на раздел из `data/sections.js`), `currentSection`, `isMobile`, `isDarkMode`, `openSheet`, toast `alert` слот.
* **Собранные IP-адреса** — `allIPs`, массив `{ ip, country, location, asn, org }` объединённых из нескольких компонентов через `updateAllIPs()`; более поздние источники заполняют поля, оставленные пустыми более ранними. Его читают селекторы Globalping и рекордер истории IP.
* **Источники геолокации** — `ipDBs` (из `data/ip-databases.js`) вместе с `activeSources` геттером; `enabled` вычисляется только из конфигурации и никогда не переключается из-за ошибок во время выполнения.
* **Достижения** — `userAchievements` плюс конвейер обновления с одним слотом (`triggerUpdateAchievements` / `achievementToUpdate`) `User.vue` наблюдает за ним и сообщает о нём бэкенду.

Объекты состояния, которые нельзя разделять между экземплярами хранилища, создаются фабриками (`createInitialIpDBs()`, `createMountingStatus()`, …), а не литералами на уровне модуля.

## Шина app-events

`frontend/utils/app-events.js` — это около 30 строк: `Map` сопоставление имени события с `Set` множеством обработчиков, `onAppEvent(event, handler)` возвращающая функцию отмены подписки, и `emitAppEvent(event, payload)`. Отправка событий происходит в режиме fire-and-forget, а исключение в обработчике перехватывается, так что один сломанный подписчик не может сломать эмиттер. Он ничего не импортирует из Vue, поэтому его могут использовать и утилиты, и обычные модули.

Компоненты отправляют доменные события **безусловно** — «тест скорости завершён», «запрос whois выполнен» — и не знают, кто их слушает. По шине работают два конвейера, оба инициализируются один раз в `App.vue`.

```mermaid
flowchart TD
    C["Компоненты (SpeedTest, Whois, IpInfos, …)"]
    BUS["utils/app-events.js"]
    AE["use-achievement-engine.js"]
    RC["use-report-collector.js"]
    SE["sentry-init.js"]
    ST["Слот хранилища Pinia → User.vue → бэкенд"]
    SN["Снимки отчёта → диалог публикации + /r/:id"]

    C -->|"emitAppEvent('speedtest:finished', {...})"| BUS
    BUS --> AE --> ST
    BUS --> RC --> SN
    BUS --> SE
```

### Движок достижений

`data/achievement-rules.js` сопоставляет события со slug достижений. Каждое правило имеет вид `{ event, slug, when? }`, где `when` — чистый предикат над payload:

```js
{ event: 'speedtest:finished', slug: 'RapidPace', when: (p) => p.downloadSpeed >= 500 },
```

`composables/use-achievement-engine.js` подписывает по одному слушателю на правило и отвечает за все сквозные проверки: отбрасывает события, когда посетитель не вошёл в систему, пропускает уже разблокированные достижения, вычисляет `when`, затем ставит slug в очередь. Хранилище держит по одному достижению за раз, поэтому одновременные разблокировки (один тест скорости может пересечь три порога) отправляются с интервалом в 2 секунды, и каждая перепроверяется в момент отправки на случай, если она уже была разблокирована, пока ждала в очереди.

Добавить достижение значит: запись в `data/achievements.js`, правило в `data/achievement-rules.js`, и новое доменное событие только если подходящего ещё нет. Компоненты при этом никогда не затрагиваются.

### Сборщик отчётов

Публикуемый диагностический отчёт идёт по той же шине. Каждый тест «моя сеть» отправляет `<domain>:finished` со своим полным структурированным результатом. `composables/use-report-collector.js` прогоняет каждый payload через свой builder в `utils/report-builders.js`, хранит последний снимок для каждого раздела (последний побеждает) и предоставляет их только для чтения диалогу публикации и `/r/:id` странице. `common/report-schema.js`, тот же модуль, по которому бэкенд валидирует загрузки.

{% hint style="warning" %}
Builders работают в мягком режиме ошибок: неизвестное значение тихо удаляет поле. Если вы меняете семантику результата теста, обновите whitelist его builder'а и enum схемы в том же изменении, иначе поле тихо исчезнет из отчётов вместо того, чтобы вызвать ошибку.
{% endhint %}

## Последовательность запуска

`frontend/main.js` держит критический путь коротким. Он создаёт app, Pinia, i18n и router, а затем пропускает первый рендер только после трёх вещей, выполняемых параллельно: слушателя авторизации (**только** для посетителя, у которого `auth-hint` флаг говорит, что он был вошедшим в систему), `store.loadPreferences()`, и `loadActiveLocaleMessages()`. `Всё остальное — fire-and-forget либо отложено до после mount —`, аналитика, коллекция флагов-иконок (сотни КБ) и Sentry.

Он также регистрирует `vite:preloadError` слушатель на этапе выполнения модуля: после деплоя страница, загруженная из старой сборки, не сможет лениво импортировать хешированный chunk, которого уже не существует, поэтому приложение перезагрузится один раз (с блокировкой по timestamp на вкладку, которая предотвращает цикл перезагрузок, когда настоящая причина — офлайн-сеть).

## Локали по требованию

`frontend/locales/i18n.js` создаёт экземпляр i18n с **пустыми** сообщениями и картой загрузчиков динамических импортов (`en`, `zh`, `fr`, `ru`). За одну загрузку страницы активна только одна локаль — смена языка сохраняет выбор и перезапускает приложение — поэтому `loadActiveLocaleMessages()` загружает активную локаль плюс английский как запасной, а `main.js` ожидает его перед монтированием. Раннее включение всех четырёх в бандл стоило бы примерно 44 КБ gzipped мёртвого веса.

Тот же принцип относится и к подпакам: наборы данных security-checklist в `locales/security-checklist/` подгружаются этим инструментом по требованию, а не в пути запуска.

После загрузки сообщений, `updateMeta()` устанавливает `document.documentElement.lang` (с `zh` объявленным как `zh-CN`), заголовок страницы и метатеги keywords / description. Переопределения для отдельных страниц берутся из `composables/use-document-meta.js`.

## Динамическая инициализация, зависящая от окружения

Две необязательные интеграции ограничиваются на этапе сборки, поэтому self-hosted-развёртывание без них поставляется **никакого** связанного кода вообще.

* **Sentry** — зависит от `VITE_SENTRY_DSN_FRONTEND`. Без DSN `sentry-init.js` никогда не импортируется, и SDK не попадает в бандл. При наличии DSN chunk загружается после mount — или немедленно, если срабатывает ошибка до инициализации. Небольшой буфер перехватывает необработанные ошибки и отклонения до init и сбрасывает их после.
* **Firebase Auth** — зависит от `VITE_FIREBASE_API_KEY` + `VITE_FIREBASE_AUTH_DOMAIN` + `VITE_FIREBASE_PROJECT_ID`. `firebase-init.js` загружает `firebase/app` и `firebase/auth` при первом `loadFirebaseAuth()` вызове — при запуске уже вошедшего пользователя, клике входа или фоновой проверке — и запоминает результат. Посетитель, который никогда не входит в систему, никогда не скачивает SDK.

<details>

<summary>Как auth-hint выбирает путь запуска</summary>

`utils/auth-hint.js` хранит флаг, описывающий, был ли последний сеанс авторизован. При запуске `main.js` он читает его:

* `'1'` — загрузить Firebase и дождаться слушателя авторизации перед первым рендером, чтобы первый аутентифицированный запрос уже нёс токен.
* `'0'` — монтироваться немедленно; SDK не загружается до клика входа.
* `null` (первый визит после появления флага или очищенного хранилища) — монтироваться немедленно, затем проверить авторизацию в фоне через 3 секунды, чтобы следующий запуск пошёл точно по нужному пути.

</details>

Код приложения никогда не должен импортировать `@sentry/vue` напрямую — статический импорт снова втянул бы SDK в основной бандл. Явные сигналы вместо этого проходят через шину событий; `sentry-init.js` подписывается на `ip-source:exhausted` (карточка IP, у которой вся цепочка источников не сработала) так же, как движок достижений подписывается на свои события. Конфигурация хранится в [Мониторинг ошибок](/developer/ru/configuration/error-monitoring.md).

## Размещение вспомогательных функций

| Нужно                               | Помещается в                                             |
| ----------------------------------- | -------------------------------------------------------- |
| Реактивность Vue или жизненный цикл | `composables/` как `useXxx`                              |
| Ничего специфичного для Vue         | `utils/` (никогда `use-` с префиксом)                    |
| поддержка shadcn (`cn()`)           | `lib/`                                                   |
| Что-то, что нужно и бэкенду тоже    | `common/`, повторно экспортируется через мост в `utils/` |

Правила написания этих файлов описаны в [Правилах кодирования](/developer/ru/development/coding-conventions.md); область тестирования указана в [Тестировании](/developer/ru/development/testing.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/ru/architecture/frontend.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
