Compare commits
2 Commits
f8f100633f
...
f5181ef8a0
| Author | SHA1 | Date | |
|---|---|---|---|
| f5181ef8a0 | |||
| da37322232 |
@@ -0,0 +1,51 @@
|
|||||||
|
# Общее описание
|
||||||
|
|
||||||
|
Платформа для грантов предназначена для упрощения и автоматизации процесса распределения грантовых
|
||||||
|
средств. Она предоставляет удобный инструмент как для организаций, желающих вложиться в общественно
|
||||||
|
полезные проекты, так и для участников, нуждающихся в финансировании. Основные задачи платформы:
|
||||||
|
|
||||||
|
- **Прозрачность:** Платформа обеспечивает открытый процесс подачи заявок, их оценки и выбора
|
||||||
|
победителей.
|
||||||
|
|
||||||
|
- **Доступность:** Участники могут легко находить актуальные конкурсы и подавать заявки, а
|
||||||
|
компании — запускать собственные грантовые программы.
|
||||||
|
|
||||||
|
- **Эффективность:** Процесс отбора и реализации проектов автоматизирован, что экономит время
|
||||||
|
участников и организаторов.
|
||||||
|
|
||||||
|
## Основные пользователи платформы:
|
||||||
|
|
||||||
|
- **Компании:** Организуют конкурсы, финансируют проекты.
|
||||||
|
|
||||||
|
- **Участники:** Представляют свои проекты, чтобы получить финансирование. При подаче заявки
|
||||||
|
участники выбирают категорию, что влияет на структуру заявки и условия участия.
|
||||||
|
|
||||||
|
- **Модераторы:** Проверяют заявки, следят за корректностью информации.
|
||||||
|
|
||||||
|
- **Эксперты:** Оценивают проекты, прошедшие модерацию, с учетом их категории.
|
||||||
|
|
||||||
|
- **Комиссия:** Принимает окончательные решения о финансировании.
|
||||||
|
|
||||||
|
- **Администраторы:** Управляют платформой и обеспечивают её стабильную работу, включая создание и
|
||||||
|
настройку категорий.
|
||||||
|
|
||||||
|
## Основные этапы работы платформы:
|
||||||
|
|
||||||
|
1. Регистрация пользователей (компании, организации, волонтеры).
|
||||||
|
|
||||||
|
2. Публикация конкурсов
|
||||||
|
|
||||||
|
3. Сбор заявок. На этапе подачи заявки пользователь выбирает категорию, что определяет дальнейшую
|
||||||
|
структуру заявки и условия участия.
|
||||||
|
|
||||||
|
4. Модерация заявок и внесение исправлений участниками.
|
||||||
|
|
||||||
|
5. Оценка проектов экспертами с учетом специфики категории.
|
||||||
|
|
||||||
|
6. Выбор победителей комиссией.
|
||||||
|
|
||||||
|
7. Реализация проектов победителями, включая публикацию отчетов.
|
||||||
|
|
||||||
|
Платформа также включает инструменты для контроля исполнения проектов, анализа их результатов и
|
||||||
|
формирования отчетности. Это позволяет компаниям видеть, как эффективно используются их средства, а
|
||||||
|
участникам — демонстрировать успешность своих инициатив.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Основные функции платформы
|
||||||
|
|
||||||
|
## Для участников
|
||||||
|
|
||||||
|
- **Поиск конкурсов:** Участники могут просматривать доступные конкурсы.
|
||||||
|
|
||||||
|
- **Подача заявок:** Удобный интерфейс для создания и отправки заявок на участие в конкурсе.
|
||||||
|
|
||||||
|
- **Управление проектами:** Ведение проектов, отслеживание их статуса и предоставление отчетности.
|
||||||
|
|
||||||
|
- **Финансовая отчетность:** Предоставление данных о расходах по проекту через встроенные формы.
|
||||||
|
|
||||||
|
## Для организаторов
|
||||||
|
|
||||||
|
- **Создание конкурсов:** Возможность настроить параметры конкурса (цели, требования, сроки и
|
||||||
|
др.).
|
||||||
|
|
||||||
|
- **Управление заявками:** Просмотр всех поступивших заявок, их модерация и одобрение.
|
||||||
|
|
||||||
|
- **Работа с экспертами:** Назначение экспертов для оценки заявок, управление их доступами.
|
||||||
|
|
||||||
|
- **Мониторинг реализации проектов:** Контроль выполнения грантовых обязательств победителей.
|
||||||
|
|
||||||
|
## Для экспертов
|
||||||
|
|
||||||
|
- **Оценка заявок:** Просмотр и оценивание заявок участников по заданным критериям. Возможность
|
||||||
|
оставлять комментарии и замечания.
|
||||||
|
|
||||||
|
## Для модераторов
|
||||||
|
|
||||||
|
- **Проверка заявок:** Проверка заявок участников на соответствие требованиям конкурса.
|
||||||
|
|
||||||
|
- **Коммуникация с участниками:** Возможность запрашивать доработки заявок и уведомлять участников
|
||||||
|
об изменениях статуса.
|
||||||
|
|
||||||
|
## Для администраторов платформы
|
||||||
|
|
||||||
|
- **Управление пользователями:** Добавление, редактирование и удаление пользователей.
|
||||||
|
|
||||||
|
- **Мониторинг активности:** Анализ активности на платформе, выявление проблемных мест.
|
||||||
|
|
||||||
|
- **Настройка глобальных параметров:** Конфигурация технических аспектов работы системы.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Роли пользователей
|
||||||
|
|
||||||
|
## Глобальные роли
|
||||||
|
|
||||||
|
Эти роли может выдать только `root` пользователь:
|
||||||
|
|
||||||
|
- `root` Максимальный уровень доступа. С этим уровнем доступа можно обходить некоторые системы
|
||||||
|
сайта. Предполагается, что этот пользователь знает, что делает.
|
||||||
|
- `support` Имеет доступ к большому количеству данных и функций, может влиять на них, но с
|
||||||
|
ограничениями в критически важных областях.
|
||||||
|
|
||||||
|
## Сотрудники конкурса
|
||||||
|
|
||||||
|
Эти роли могут быть выданы как на отдельные конкурсы, так и глобально на все конкурсы:
|
||||||
|
|
||||||
|
- `admin` Администратор конкурса. Может настраивать конкурс и сотрудников, имеет права `moderator`
|
||||||
|
- `moderator` Просмотр и модерирование проектов, просмотр статистики конкурса, возможность
|
||||||
|
блокировки пользователей.
|
||||||
|
- `expert` Оценка проектов без доступа к настройкам или модерации.
|
||||||
|
|
||||||
|
## Сотрудники организатора
|
||||||
|
|
||||||
|
Эти роли выдаются только на конкретные организации:
|
||||||
|
|
||||||
|
- `orgAdminRole` Руководитель организации. Может настраивать организацию и её сотрудников.
|
||||||
|
- `orgMemberRole` Ответственный за заполнение проектов и отчетов.
|
||||||
|
- `orgReportedRole` Публикация новостей от имени организации.
|
||||||
|
- `orgReaderRole` Подписчик, имеет только права на просмотр информации.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Разделы сайта
|
||||||
|
|
||||||
|
## Кабинет участника
|
||||||
|
|
||||||
|
Доступен всем авторизованным пользователям.
|
||||||
|
|
||||||
|
Предназначен для сотрудников организаций, участвующих в конкурсах.
|
||||||
|
|
||||||
|
### Точка входа `/cabinet`
|
||||||
|
|
||||||
|
- `/cabinet/projects` Текущие проекты
|
||||||
|
- `/cabinet/projects/create` Создание нового проекта
|
||||||
|
- `/cabinet/orgs` Список организаций, в которых пользователь является сотрудником
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Управление конкурсом
|
||||||
|
|
||||||
|
Имеют доступ только сотрудники конкурса, `admin` и `support`.
|
||||||
|
|
||||||
|
### Точка входа `/contests`
|
||||||
|
|
||||||
|
- `/contests` выбор конкурса, отображает те конкурсы, в которых пользователь является сотрудником
|
||||||
|
- `/contests/[contestId]` Панель управления конкурсом. Во вложенных страницах можно управлять
|
||||||
|
проектами, заявками, периодами и областями конкурса
|
||||||
|
- `/contests/[contestId]/moderation` Модерация заявок
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Администрирование сайта
|
||||||
|
|
||||||
|
Имеют доступ только `admin` и `support`.
|
||||||
|
|
||||||
|
### Точка входа `/admin`
|
||||||
|
|
||||||
|
- `/admin` Панель администратора
|
||||||
|
|
||||||
|
### Управление пользователями
|
||||||
|
|
||||||
|
- `/admin/users` Список и поиск пользователей
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Общие страницы
|
||||||
|
|
||||||
|
На такие страницы может попасть любой пользователь, если есть разрешение.
|
||||||
|
|
||||||
|
### Авторизация и регистрация
|
||||||
|
|
||||||
|
- `/auth/login` Страница авторизации
|
||||||
|
- `/auth/reg` Страница регистрации
|
||||||
|
- `/profile` Страница профиля текущего пользователя
|
||||||
|
|
||||||
|
### Пользователи
|
||||||
|
|
||||||
|
- `/users/[userId]` Страница отдельного пользователя
|
||||||
|
|
||||||
|
### Проекты
|
||||||
|
|
||||||
|
- `/projects/[projectId]` Просмотр и редактирование проекта
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Процесс работы'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Настройка и подключение лендингов'
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Создание конкурса
|
||||||
|
|
||||||
|
Доступно создание конкурса с нуля или путём копирования ранее проведённого конкурса.
|
||||||
|
|
||||||
|
## Создание черновика конкурса
|
||||||
|
|
||||||
|
Создание черновика конкурса требует указания следующих сведений:
|
||||||
|
|
||||||
|
- **Название конкурса**
|
||||||
|
- **Администратор конкурса**
|
||||||
|
|
||||||
|
После создания черновика назначенный администратор получит доступ к панели управления конкурса и к
|
||||||
|
мастеру настройки.
|
||||||
|
|
||||||
|
## Мастер настройки конкурса
|
||||||
|
|
||||||
|
Мастер настройки обеспечивает последовательность действий и визуальный контроль прогресса:
|
||||||
|
|
||||||
|
- **Статус модулей**. Индикация того, какие модули уже настроены, а какие остаются в работе.
|
||||||
|
- **Краткая информация**. Краткое описание каждого модуля и текущий статус настройки.
|
||||||
|
- **Переход к настройке**. Быстрый переход к настройке конкретного модуля.
|
||||||
|
|
||||||
|
> **Важно:** Запуск конкурса становится доступным только после того, как все модули будут отмечены
|
||||||
|
> как "настроенные". После старта мастер настройки завершает работу и закрывается.
|
||||||
|
|
||||||
|
## Варианты создания конкурса
|
||||||
|
|
||||||
|
### Настройка с нуля
|
||||||
|
|
||||||
|
Для некоторых модулей мастер может предоставлять инструменты для быстрого первого заполнения,
|
||||||
|
позволяя задать основные параметры и оставить детали на более поздний этап. Если такие инструменты
|
||||||
|
недоступны, настройка осуществляется стандартными средствами модуля.
|
||||||
|
|
||||||
|
### Копирование существующего конкурса
|
||||||
|
|
||||||
|
Для некоторых модулей мастер может предложить выбор элементов для переноса из ранее проведённого
|
||||||
|
конкурса. Это ускоряет первоначальную конфигурацию. Если инструмент копирования недоступен,
|
||||||
|
применяются обычные средства модуля для настройки существующего конкурса.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Категории проектов в конкурсах
|
||||||
|
|
||||||
|
Категории позволяют учитывать специфику проектов и их участников. Пользователи сами выбирают
|
||||||
|
категорию, в которой хотят участвовать и заполняют соответствующую форму заявки.
|
||||||
|
|
||||||
|
#### Примеры категорий и их отличий:
|
||||||
|
|
||||||
|
- **Волонтерские проекты:** Ограничения по срокам проведения, максимальная сумма грантов,
|
||||||
|
упрощенные формы заявки.
|
||||||
|
|
||||||
|
- **Школьные проекты:** Участниками могут быть только группы школьников, дополнительные требования
|
||||||
|
для школ.
|
||||||
|
|
||||||
|
- **Проекты организаций:** Более сложные формы заявки, большие максимальные суммы грантов.
|
||||||
|
|
||||||
|
### Функционал категорий
|
||||||
|
|
||||||
|
- **Создание и управление категориями:**
|
||||||
|
|
||||||
|
- Только администраторы конкурса могут управлять категориями.
|
||||||
|
- Администраторы могут добавлять, изменять и удалять категории.
|
||||||
|
|
||||||
|
- **Параметры категории:**
|
||||||
|
|
||||||
|
- Условия участия (например, ограничения по типу участников, возрасту, региону).
|
||||||
|
- Формы заявки (разделы, поля, инструкции).
|
||||||
|
- Параметры конкурсов (сроки подачи, суммы грантов).
|
||||||
|
- И так далее.
|
||||||
|
|
||||||
|
- **Гибкость изменения:** Возможность адаптации категорий для конкретных конкурсов.
|
||||||
|
|
||||||
|
### Технические особенности
|
||||||
|
|
||||||
|
- У категорий нет версионирования.
|
||||||
|
- Изменять категории можно в любой момент до завершения конкурса.
|
||||||
|
- Категория относится только к одному конкурсу.
|
||||||
|
- Участники должны видеть только public категории.
|
||||||
|
- Участник не может сменить категории после ее выбора.
|
||||||
|
- Проект может относиться только к одной категории.
|
||||||
|
|
||||||
|
### Отчетность по категориям
|
||||||
|
|
||||||
|
В разделе аналитики предоставляется возможность фильтровать заявки и результаты по категориям,
|
||||||
|
анализировать успешность проектов в каждой категории и их соответствие целям конкурсов.
|
||||||
|
|
||||||
|
### Доступность категорий
|
||||||
|
|
||||||
|
Для управления доступностью категории для создания проектов с этой категорией используется поле
|
||||||
|
`access`.
|
||||||
|
|
||||||
|
Любую категорию можно включить или выключить для создания нового проекта с этой категорией.
|
||||||
|
|
||||||
|
- `enabled: true` Участники могут создавать проекты в этой категории.
|
||||||
|
- `enabled: false` Участники не могут создавать проекты в этой категории.
|
||||||
|
|
||||||
|
Флаг `manualControl` управляет способом изменения поля `enabled`:
|
||||||
|
|
||||||
|
- `true` - включается и выключается только вручную.
|
||||||
|
- `false` - включается и выключается автоматически, на основании других настроек категории.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Функционал категорий
|
||||||
|
|
||||||
|
Категории предоставляют гибкость настройки условий для разных типов проектов. Каждая категория может
|
||||||
|
изменять следующие параметры:
|
||||||
|
|
||||||
|
- Максимальная сумма гранта
|
||||||
|
- Максимальный фонд гранта
|
||||||
|
- Условия участия (текст и документ)
|
||||||
|
- Требования к участникам (текст и документ)
|
||||||
|
- Сроки подачи заявок для данной категории
|
||||||
|
- Направления проектов
|
||||||
|
- Форма заявки
|
||||||
|
- Список документов, которые могут потребоваться участникам от организатора конкурса
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Категории проектов'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Конкурсы'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Общая схема взаимодействия модулей
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
Contest ||--o{ Project : "Проект участвует в конкурсе"
|
||||||
|
|
||||||
|
Organizator ||--o{ Contest : "Организатор может создать конкурс"
|
||||||
|
Organizator ||--o{ Project : "Организатор может создать проект"
|
||||||
|
|
||||||
|
Project ||--o{ Event: "Мероприятие проходит в рамках проекта"
|
||||||
|
Project ||--o{ News: "У проекта есть новости"
|
||||||
|
Project ||--o{ Application: "Данные проекта изменяются через заявки"
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Организации и волонтеры'
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Организаторы проектов
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class Organizator {
|
||||||
|
<<interface>>
|
||||||
|
id: pk
|
||||||
|
type: "Organization" | "Volunteers"
|
||||||
|
title: string
|
||||||
|
leader: OrgParticipant
|
||||||
|
}
|
||||||
|
|
||||||
|
class Organization {
|
||||||
|
type: "Organization"
|
||||||
|
urData: UrData
|
||||||
|
recvezits: any
|
||||||
|
}
|
||||||
|
Organization --|> Organizator
|
||||||
|
|
||||||
|
class Volunteers {
|
||||||
|
type: "Volunteers"
|
||||||
|
}
|
||||||
|
Volunteers --|> Organizator
|
||||||
|
```
|
||||||
|
|
||||||
|
- В случае волонтеров названием организатора будет являться ФИО руководителя (предварительно)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Общая последовательность работы над проектом
|
||||||
|
|
||||||
|
- Любое изменение проекта проходит через создание заявки на изменение проекта
|
||||||
|
- Изменение в проект вносится только после одобрения заявки модератором
|
||||||
|
- При создании нового проекта, проект создается со статусом `Draft`, без создания заявки
|
||||||
|
- Проект начинает участвовать в конкурсе только после одобрения заявки модератором
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User as Пользователь
|
||||||
|
participant Application
|
||||||
|
actor Moderation as Модератор
|
||||||
|
participant Project
|
||||||
|
|
||||||
|
Note over User, Project: Создание проекта
|
||||||
|
|
||||||
|
User->>+Project: Создание черновика проекта
|
||||||
|
|
||||||
|
Note over User, Project: Заявка на участие в конкурсе
|
||||||
|
|
||||||
|
User->>Application: Заполнение заявки
|
||||||
|
Application->>Moderation: Отправка на модерацию
|
||||||
|
Moderation-->>Application: Возврат на доработку
|
||||||
|
User-->>Application: Исправление заявки
|
||||||
|
Application-->>Moderation: Повторная оправка на модерацию
|
||||||
|
Moderation->>+Project: Проект учавствует в конкурсе
|
||||||
|
|
||||||
|
Note over User, Project: Проект профинансирован
|
||||||
|
|
||||||
|
loop
|
||||||
|
Note over User, Project: Заявка на изменение проекта
|
||||||
|
User->>Application: Заполнение заявки
|
||||||
|
Application->>Moderation: Отправка на модерацию
|
||||||
|
Moderation-->>Application: Возврат на доработку
|
||||||
|
User-->>Application: Исправление заявки
|
||||||
|
Application-->>Moderation: Повторная оправка на модерацию
|
||||||
|
Moderation->>+Project: Применение изменений на проект
|
||||||
|
end
|
||||||
|
|
||||||
|
User->Project: Завершение работы над проектом
|
||||||
|
|
||||||
|
deactivate Project
|
||||||
|
deactivate Project
|
||||||
|
deactivate Project
|
||||||
|
```
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Спецификация
|
||||||
|
|
||||||
|
## Исходные требования
|
||||||
|
|
||||||
|
- Все изменения в проект (не черновик) вносятся только после прохождения модерации
|
||||||
|
- Вся история изменения проекта должна храниться столько же, сколько и сам проект
|
||||||
|
|
||||||
|
## Проект
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Draft
|
||||||
|
Draft --> Rejected : Заявка отклонена модераторами
|
||||||
|
Draft --> Accepted : Заявка одобрена модераторами
|
||||||
|
Accepted --> Evaluating
|
||||||
|
Evaluating --> Awarding
|
||||||
|
|
||||||
|
note left of Evaluating
|
||||||
|
Оценка проекта
|
||||||
|
end note
|
||||||
|
|
||||||
|
Awarding --> Finalists : Проект не получил финансирование
|
||||||
|
Awarding --> Funded : Проект победил
|
||||||
|
|
||||||
|
note left of Awarding
|
||||||
|
Выбор победителей
|
||||||
|
end note
|
||||||
|
|
||||||
|
Rejected --> [*]
|
||||||
|
Finalists --> [*]
|
||||||
|
Funded --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Проект со статусами `Draft` и `Rejected` не участвует в конкурсе
|
||||||
|
- Проект со статусами `Draft` и `Rejected` не видны никому, кроме участников организации и
|
||||||
|
модераторов
|
||||||
|
- Проект с остальными статусами участвует в конкурсе
|
||||||
|
- Отклоненный проект не участвует в конкурсе и не может быть восстановлен, только создан новый
|
||||||
|
проект
|
||||||
|
- Проект может быть удален авторами в любой любой момент до завершения конкурса без указания
|
||||||
|
причины, за исключением статуса `Funded`, т.к. этот проект уже направляется на финансирование
|
||||||
|
(не показано на схеме)
|
||||||
|
- Проект может быть отклонен организаторами конкурса в любой любой момент до завершения конкурса с
|
||||||
|
указанием причины отклонения (не показано на схеме, это техническая возможность, для этого
|
||||||
|
должны быть веские основания)
|
||||||
|
|
||||||
|
## Заявка
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Draft
|
||||||
|
|
||||||
|
Draft --> Moderating: Отправка на модерацию
|
||||||
|
|
||||||
|
Moderating --> Rejected: Отклонена модераторами
|
||||||
|
Rejected --> [*]
|
||||||
|
|
||||||
|
Moderating --> Accepted: Одобрена модераторами
|
||||||
|
Accepted --> [*]
|
||||||
|
|
||||||
|
Moderating --> Returned: Возврат на доработку
|
||||||
|
Returned --> Draft: Исправление заявки
|
||||||
|
|
||||||
|
Draft --> Deleted: Удаление заявки автором
|
||||||
|
Deleted --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
- У проекта одновременно может быть несколько заявок в работе
|
||||||
|
- У проекта со статусом НЕ `Draft` обязательно должна быть заявка со статусом `Accepted`, притом
|
||||||
|
только одна
|
||||||
|
- Заявка со статусами `Draft`, `Rejected`, `Deleted` не видна никому, кроме участников организации
|
||||||
|
и модераторов
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Ревизии заявки
|
||||||
|
|
||||||
|
Сама ревизия хранит только отличия от предыдущей ревизии, кто и когда внес изменения. При
|
||||||
|
необходимости можно просмотреть историю изменений заявки и откатиться к предыдущим версиям.
|
||||||
|
|
||||||
|
## Исходные требования
|
||||||
|
|
||||||
|
- Все изменения изменения заявки должны быть сохранены
|
||||||
|
- Исключить потерю данных при редактировании заявки несколькими пользователями
|
||||||
|
- Возможность отката к предыдущим версиям заявки
|
||||||
|
- Возможность просмотра истории изменений заявки
|
||||||
|
- Возможность формирования графика интенсивности работы над заявкой
|
||||||
|
|
||||||
|
## Реализация
|
||||||
|
|
||||||
|
Над заявкой могут производится различные действия, которые влияют на ее состояние и данные. При этом
|
||||||
|
разные действия могут вносить разные изменения в данные заявки. Потому в ревизии есть отдельные поля
|
||||||
|
`action` и `payloadType`, которые позволяют определить тип действия и тип изменений в данных заявки.
|
||||||
|
|
||||||
|
Тип данных в поле `payload` зависит от значения поля `payloadType` и должно обрабатываться
|
||||||
|
соответствующим образом.
|
||||||
|
|
||||||
|
Такая структура позволяет легко добавлять новые типы действий и изменений в заявке.
|
||||||
|
|
||||||
|
## Заявка
|
||||||
|
|
||||||
|
Для получения актуальной версии заявки необходимо применить все ревизии в порядке их создания.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Проекты и заявки'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Заявки (Requests)'
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Участники проектов
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class User {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
class Contest {
|
||||||
|
id: pk
|
||||||
|
status: ContestStatus
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Organizator {
|
||||||
|
<<interface>>
|
||||||
|
id: pk
|
||||||
|
type: Organization | Volunteers
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Project {
|
||||||
|
id: pk
|
||||||
|
title: string
|
||||||
|
contest: Contest
|
||||||
|
org: Organizator
|
||||||
|
}
|
||||||
|
Project "*" --* "1" Organizator
|
||||||
|
Project "*" --* "1" Contest
|
||||||
|
|
||||||
|
class ContestMember {
|
||||||
|
id: pk
|
||||||
|
contest: Contest
|
||||||
|
role: ContestRole
|
||||||
|
roleTitle: string
|
||||||
|
}
|
||||||
|
ContestMember "*" --* "1" Contest
|
||||||
|
ContestMember "*" --o "1" User
|
||||||
|
|
||||||
|
class OrgMember {
|
||||||
|
id: pk
|
||||||
|
org: Organizator
|
||||||
|
role: OrgRole
|
||||||
|
roleTitle: string
|
||||||
|
}
|
||||||
|
OrgMember "*" --* "1" Organizator
|
||||||
|
OrgMember "*" --o "1" User
|
||||||
|
|
||||||
|
class ProjectMember {
|
||||||
|
id: pk
|
||||||
|
project: Project
|
||||||
|
role: ProjectRole
|
||||||
|
roleTitle: string
|
||||||
|
}
|
||||||
|
ProjectMember "*" --* "1" Project
|
||||||
|
ProjectMember "*" --o "1" User
|
||||||
|
|
||||||
|
class Participant {
|
||||||
|
id: pk
|
||||||
|
fio
|
||||||
|
phone
|
||||||
|
birthYear
|
||||||
|
user?: User
|
||||||
|
}
|
||||||
|
Participant "1" --o "0..1" User
|
||||||
|
|
||||||
|
class OrgParticipant {
|
||||||
|
id: pk
|
||||||
|
org: Organizator
|
||||||
|
participant: Participant
|
||||||
|
roleTitle: string
|
||||||
|
}
|
||||||
|
OrgParticipant "*" --* "1" Organizator
|
||||||
|
OrgParticipant "*" --* "1" Participant
|
||||||
|
|
||||||
|
class ProjectParticipant {
|
||||||
|
id: pk
|
||||||
|
project: Project
|
||||||
|
participant: Participant
|
||||||
|
roleTitle: string
|
||||||
|
}
|
||||||
|
ProjectParticipant "*" --* "1" Project
|
||||||
|
ProjectParticipant "*" --* "1" Participant
|
||||||
|
```
|
||||||
|
|
||||||
|
- Заявка превращается в конкурс (предположительно) после апрува модератором
|
||||||
|
|
||||||
|
### Различные типы участников
|
||||||
|
|
||||||
|
| Поле | Имя | Ключевые (руководитель, бухгалтер) | Участник проекта организации | Участник проекта волонтеров |
|
||||||
|
| ------------------------------ | -------------- | ---------------------------------- | ---------------------------- | --------------------------- |
|
||||||
|
| Фамилия | firstName | + | + | + |
|
||||||
|
| Имя | lastName | + | + | + |
|
||||||
|
| Отчество | patronymic | + | + | + |
|
||||||
|
| Должность | position | + | + | - |
|
||||||
|
| Телефон | phone | + | - | + |
|
||||||
|
| E-mail | email | + | - | + |
|
||||||
|
| Год рождения | birthYear | - | + | - |
|
||||||
|
| Зона ответственности в проекте | responsibility | - | + | - |
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Конкурсы, проекты и заявки
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
direction BT
|
||||||
|
class Organizator {
|
||||||
|
<<interface>>
|
||||||
|
id: pk
|
||||||
|
type: Organization | Volunteers
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Contest {
|
||||||
|
id: pk
|
||||||
|
status: ContestStatus
|
||||||
|
title: string
|
||||||
|
logo: string
|
||||||
|
description: string
|
||||||
|
totalBudget: number
|
||||||
|
receiptStartedAt: number
|
||||||
|
receiptEndedAt: number
|
||||||
|
ratingStartedAt?: number
|
||||||
|
ratingEndedAt?: number
|
||||||
|
publicResultsAt: number
|
||||||
|
workStartedAt: number
|
||||||
|
workEndedAt: number
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
org: Organizator
|
||||||
|
}
|
||||||
|
Contest "*" --* "1" Organizator
|
||||||
|
|
||||||
|
class Activity {
|
||||||
|
id: pk
|
||||||
|
title: string
|
||||||
|
parent?: Activity
|
||||||
|
}
|
||||||
|
Activity "*" --* "1" Contest
|
||||||
|
Activity "1" --* "0..1" Activity
|
||||||
|
|
||||||
|
class Project {
|
||||||
|
id: pk
|
||||||
|
title: string
|
||||||
|
contest: Contest
|
||||||
|
activity: Activity
|
||||||
|
org: Organizator
|
||||||
|
leader: ProjectParticipant
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
Project "*" --* "1" Organizator
|
||||||
|
Project "*" --* "1" Contest
|
||||||
|
Project "*" --o "1" Activity
|
||||||
|
|
||||||
|
class OrgParticipant {
|
||||||
|
id: pk
|
||||||
|
org: Organizator
|
||||||
|
fio
|
||||||
|
roleTitle: string
|
||||||
|
}
|
||||||
|
OrgParticipant "*" --* "1" Organizator
|
||||||
|
|
||||||
|
class ProjectParticipant {
|
||||||
|
id: pk
|
||||||
|
project: Project
|
||||||
|
fio
|
||||||
|
roleTitle: string
|
||||||
|
}
|
||||||
|
ProjectParticipant "*" --* "1" Project
|
||||||
|
|
||||||
|
class Event {
|
||||||
|
id: pk
|
||||||
|
status: EventStatus
|
||||||
|
project: Project
|
||||||
|
title: string
|
||||||
|
logo
|
||||||
|
description: string
|
||||||
|
startsOn: Date
|
||||||
|
endsOn: Date
|
||||||
|
createdAt: timestamp
|
||||||
|
updatedAt: timestamp
|
||||||
|
}
|
||||||
|
Event "*" --* "1" Project
|
||||||
|
|
||||||
|
class News {
|
||||||
|
id
|
||||||
|
status: EventStatus
|
||||||
|
project: Project
|
||||||
|
title: string
|
||||||
|
logo
|
||||||
|
description: string
|
||||||
|
date: Date
|
||||||
|
createdAt: timestamp
|
||||||
|
updatedAt: timestamp
|
||||||
|
}
|
||||||
|
News "*" --* "1" Project
|
||||||
|
```
|
||||||
|
|
||||||
|
- Заявка превращается в конкурс (предположительно) после апрува модератором
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Заявки
|
||||||
|
|
||||||
|
## Состояния заявки
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Draft
|
||||||
|
Draft --> Autosave
|
||||||
|
Autosave --> Draft: Обновление черновика
|
||||||
|
Draft --> Moderating: Отправлено на модерацию
|
||||||
|
Moderating --> Returned: на доработку
|
||||||
|
Returned --> Draft: исправление
|
||||||
|
Moderating --> Rejected: заявка отклонена
|
||||||
|
Moderating --> Accepted: заявка принята
|
||||||
|
Accepted --> Archived: обновление заявки принято
|
||||||
|
Rejected --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Autosave` отдельная запись с указанием на черновик, при сохранении обновляет черновик
|
||||||
|
- `Accepted` и `Archived` обязаны иметь верное значение `projectId` т.к. при принятии заявки
|
||||||
|
создается проект и дальнейшие действия ведутся над проектом
|
||||||
|
- Заявки в статусе отличном от `Accepted` и `Archived` могут иметь `projectId` только если это
|
||||||
|
заявка на обновление проекта
|
||||||
|
- Отклоненная заявка не может быть подана повторно
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Введение
|
||||||
|
|
||||||
|
Данный модуль обеспечивает экспертную оценку заявок. Он автоматизирует распределение заявок между
|
||||||
|
экспертами, сбор и анализ оценок, а также формирование итогового рейтинга проектов.
|
||||||
|
|
||||||
|
## Общий процесс оценки проектов
|
||||||
|
|
||||||
|
1. Организатор настраивает форму оценки и критерии.
|
||||||
|
2. Система назначает экспертов на проекты.
|
||||||
|
3. Эксперты заполняют форму оценки.
|
||||||
|
4. Итоговые оценки агрегируются для формирования рейтинга.
|
||||||
|
|
||||||
|
## Роли участников
|
||||||
|
|
||||||
|
- **Организатор конкурса** – настраивает критерии, назначает экспертов, контролирует процесс.
|
||||||
|
- **Эксперт** – оценивает проекты по заданным критериям.
|
||||||
|
- **Платформа** – автоматически распределяет проекты, фиксирует оценки, собирает данные.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Создание формы оценки
|
||||||
|
|
||||||
|
## Назначение и общие принципы
|
||||||
|
|
||||||
|
Так как эксперты оценивают проекты по заранее настроенным критериям, требуется конструктор формы,
|
||||||
|
которую будут заполнять эксперты. Организатор конкурса настраивает критерии оценки до начала
|
||||||
|
конкурса. После начала оценки структура формы не может быть изменена.
|
||||||
|
|
||||||
|
## Типы критериев
|
||||||
|
|
||||||
|
- **Выбор из фиксированного списка ответов** (каждый вариант имеет скрытый для эксперта вес).
|
||||||
|
- **Текстовое поле** с настройками ограничений по длине.
|
||||||
|
|
||||||
|
Так же критерию можно добавить описание и подсказку.
|
||||||
|
|
||||||
|
## Группировка критериев
|
||||||
|
|
||||||
|
- Критерии разделены на группы.
|
||||||
|
- Некоторые критерии могут быть необязательными.
|
||||||
|
- Если для группы критериев требуется комментарий, то его нужно добавить в схему.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Назначение экспертов
|
||||||
|
|
||||||
|
## Принципы назначения
|
||||||
|
|
||||||
|
Платформа для грантов должна распределять проекты среди экспертов для независимой оценки. Процесс
|
||||||
|
назначения проектов должен учитывать:
|
||||||
|
|
||||||
|
- Автоматическое и ручное распределение.
|
||||||
|
- Обеспечение равномерной нагрузки на экспертов.
|
||||||
|
- Возможность перераспределения проектов в случае отказа эксперта.
|
||||||
|
|
||||||
|
## Ручное назначение
|
||||||
|
|
||||||
|
Организатор может вручную назначать экспертов и корректировать автоматическое распределение.
|
||||||
|
|
||||||
|
## Автоматическое назначение
|
||||||
|
|
||||||
|
Система может:
|
||||||
|
|
||||||
|
- Находить и назначать на проект наименее загруженного эксперта.
|
||||||
|
- Балансировать указанный проект.
|
||||||
|
- Балансировать все проекты категории.
|
||||||
|
|
||||||
|
Балансировать проект - доназначать экспертов до нужного количества, если сейчас их меньше, чем надо.
|
||||||
|
|
||||||
|
Обработка ошибок:
|
||||||
|
|
||||||
|
- Если найти и назначить наименее загруженного **эксперта** не удаётся, пользователю предлагается
|
||||||
|
**назначить его вручную**.
|
||||||
|
- Если для балансировки **проекта** не хватает экспертов, операция **отменяется**.
|
||||||
|
- Если для балансировки какого-то из проектов **категории** не хватает экспертов, этот **проект
|
||||||
|
помечается** `unassessable` и балансировка категории продолжается.
|
||||||
|
|
||||||
|
## Обработка отказов
|
||||||
|
|
||||||
|
Если эксперт отказывается от оценки, система автоматически переназначает проект другому эксперту.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Оценка проектов экспертами
|
||||||
|
|
||||||
|
## Процесс оценки
|
||||||
|
|
||||||
|
- Эксперт оценивает проект независимо, не видя оценок других экспертов.
|
||||||
|
- Черновики сохраняются автоматически.
|
||||||
|
- Эксперт может редактировать оценку, пока она не отправлена.
|
||||||
|
- После отправки оценку изменить нельзя.
|
||||||
|
|
||||||
|
## Форма
|
||||||
|
|
||||||
|
- Обязательное заполнение всех критериев.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Анализ ревью
|
||||||
|
|
||||||
|
После того как эксперты отправляют свои оценки, система автоматически анализирует их, вычисляет
|
||||||
|
средний балл проекта и определяет, завершена ли экспертиза.
|
||||||
|
|
||||||
|
Это происходит при каждом изменении ревью: при завершении, отклонении или отказе от ревью.
|
||||||
|
|
||||||
|
## Средний балл
|
||||||
|
|
||||||
|
Средний балл проекта рассчитывается как среднее арифметическое баллов всех завершённых ревью,
|
||||||
|
округлённое до целого. Незавершённые, отклонённые и удалённые ревью в расчёте не участвуют. Если ни
|
||||||
|
одно ревью ещё не завершено, средний балл не отображается.
|
||||||
|
|
||||||
|
## Завершение экспертизы
|
||||||
|
|
||||||
|
Экспертиза проекта считается завершённой, когда выполнены два условия одновременно:
|
||||||
|
|
||||||
|
- Все назначенные эксперты завершили свои ревью (не считая отклонённых и удалённых).
|
||||||
|
- Количество завершённых ревью не меньше минимально необходимого количества, заданного в
|
||||||
|
настройках номинации.
|
||||||
|
|
||||||
|
## Спорные оценки
|
||||||
|
|
||||||
|
Когда эксперты расходятся во мнениях слишком сильно, система помечает проект как спорный. Это
|
||||||
|
позволяет организатору обратить внимание на такие проекты и при необходимости назначить
|
||||||
|
дополнительных экспертов.
|
||||||
|
|
||||||
|
### Как определяется спорность
|
||||||
|
|
||||||
|
Проверка спорности происходит, когда набрано ровно минимально необходимое количество ревью. Система
|
||||||
|
сравнивает разброс оценок — разницу между максимальным и минимальным баллом среди всех завершённых
|
||||||
|
ревью — с допустимым порогом, заданным в настройках номинации в процентах от шкалы оценки.
|
||||||
|
|
||||||
|
- Если разброс превышает порог — проект помечается как спорный и экспертиза не завершается, чтобы
|
||||||
|
организатор мог назначить дополнительного эксперта.
|
||||||
|
- Если разброс в пределах порога — проект не спорный, экспертиза завершается.
|
||||||
|
|
||||||
|
### Дополнительные ревью
|
||||||
|
|
||||||
|
Когда организатор назначает дополнительного эксперта на спорный проект и тот завершает ревью,
|
||||||
|
количество ревью превышает минимально необходимое. В этом случае:
|
||||||
|
|
||||||
|
- Проект остаётся помеченным как спорный (метка не снимается автоматически).
|
||||||
|
- Экспертиза завершается, когда все назначенные эксперты завершили свои ревью.
|
||||||
|
- Модератор должен вручную снять метку спорности, если считает, что дополнительное ревью разрешило
|
||||||
|
спор.
|
||||||
|
|
||||||
|
### При недостатке ревью
|
||||||
|
|
||||||
|
Если количество завершённых ревью меньше минимально необходимого (например, ревью было отклонено),
|
||||||
|
метка спорности снимается и экспертиза остаётся незавершённой.
|
||||||
|
|
||||||
|
## Пересчёт проектов всей номинации
|
||||||
|
|
||||||
|
При изменении настроек оценки в номинации запускается пересчёт всех проектов в номинации.
|
||||||
|
|
||||||
|
При пересчёте:
|
||||||
|
|
||||||
|
- Затрагиваются только проекты с незавершённой или завершённой экспертизой.
|
||||||
|
- Метка спорности сбрасывается у всех проектов и вычисляется заново.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Оценка проектов'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Отчетность'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Отчетность
|
||||||
|
|
||||||
|
Пользователи, получившие гранты должны предоставить отчеты по расходу полученных средств
|
||||||
|
|
||||||
|
Отчетности всего две
|
||||||
|
|
||||||
|
## Финансовый отчет
|
||||||
|
|
||||||
|
Сколько было потрачено средств, на что, с комментариями и прикреплением документов
|
||||||
|
|
||||||
|
## Аналитический отчет
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Продуктовые модули'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Гайдлайны'
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Модальное окно
|
||||||
|
|
||||||
|
Модальное окно — диалоговый элемент интерфейса, который появляется поверх страницы и блокирует
|
||||||
|
доступ к её основному содержимому.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
Используйте модальные окна для:
|
||||||
|
|
||||||
|
- подтверждения действий,
|
||||||
|
- отображения ошибок,
|
||||||
|
- вывода небольших форм (до 10 полей), связанных с локальными действиями — например, настройками
|
||||||
|
или созданием объекта.
|
||||||
|
|
||||||
|
Не используйте модальные окна для больших форм (> 15 полей) и действий, требующих длительного
|
||||||
|
непрерывного взаимодействия пользователя с интерфейсом.
|
||||||
|
|
||||||
|
## Принцип работы
|
||||||
|
|
||||||
|
Модальное окно отображается поверх страницы с затемнением фона. Это помогает сфокусировать внимание
|
||||||
|
пользователя на локальном действии, сохранив контекст.
|
||||||
|
|
||||||
|
Модальное окно:
|
||||||
|
|
||||||
|
- перехватывает фокус внутри себя,
|
||||||
|
- восстанавливает фокус после закрытия (свойство `restoreFocus`).
|
||||||
|
|
||||||
|
## Состав модального окна
|
||||||
|
|
||||||
|
Модальное окно может включать:
|
||||||
|
|
||||||
|
- **заголовок** (обязателен),
|
||||||
|
- **кнопку закрытия**,
|
||||||
|
- **иконку статуса**,
|
||||||
|
- **контент**,
|
||||||
|
- **подвал с действиями**.
|
||||||
|
|
||||||
|
## Компоненты модальных окон
|
||||||
|
|
||||||
|
### `NoticeDialog`
|
||||||
|
|
||||||
|
Используется для отображения информационных сообщений:
|
||||||
|
|
||||||
|
- успешные действия,
|
||||||
|
- ошибки,
|
||||||
|
- предупреждения,
|
||||||
|
- служебные уведомления.
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
|
||||||
|
- одна кнопка действия («ОК» или «Закрыть»),
|
||||||
|
- иконка и цвет определяют тип сообщения,
|
||||||
|
- контент — опционален,
|
||||||
|
- кнопка получает фокус.
|
||||||
|
|
||||||
|
Типы сообщений:
|
||||||
|
|
||||||
|
- успех — зелёное оформление `variant=success` и позитивная иконка,
|
||||||
|
- ошибка — красное оформление `variant=danger` и иконка ошибки,
|
||||||
|
- предупреждение — жёлтое оформление `variant=warning` и иконка предупреждения,
|
||||||
|
- информация — синее оформление `variant=info` и информационная иконка.
|
||||||
|
|
||||||
|
### `ActionDialog`
|
||||||
|
|
||||||
|
Используется для модальных окон, которые последовательно проходят следующие состояния:
|
||||||
|
|
||||||
|
- подтверждение действия,
|
||||||
|
- выполнение асинхронной операции,
|
||||||
|
- отображение статуса выполнения,
|
||||||
|
- сообщение об успехе или ошибке.
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
|
||||||
|
- объединяет несколько состояний в одном окне,
|
||||||
|
- обеспечивает единый UX-паттерн для типовых async-сценариев,
|
||||||
|
- используется вместо самостоятельной реализации поведения, если сценарий вписывается в типовой
|
||||||
|
flow.
|
||||||
|
|
||||||
|
### `Dialog`
|
||||||
|
|
||||||
|
Базовый компонент модального окна. Предназначен для:
|
||||||
|
|
||||||
|
- подтверждения действий,
|
||||||
|
- ввода дополнительной информации,
|
||||||
|
- отображения сценариев, не охваченных `NoticeDialog` и `ActionDialog`.
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
|
||||||
|
- может содержать любое количество кнопок,
|
||||||
|
- может быть гибко настроен под различные сценарии,
|
||||||
|
- требует самостоятельной реализации логики поведения.
|
||||||
|
|
||||||
|
## Диалоговые окна
|
||||||
|
|
||||||
|
### Заголовок
|
||||||
|
|
||||||
|
Заголовок должен быть кратким (1–3 слова) и отражать суть действия или процесса:
|
||||||
|
|
||||||
|
- Создание проекта
|
||||||
|
- Редактирование события
|
||||||
|
|
||||||
|
Для окон, требующих подтверждения:
|
||||||
|
|
||||||
|
- Удалить проект?
|
||||||
|
- Выйти без сохранения?
|
||||||
|
|
||||||
|
### Действия
|
||||||
|
|
||||||
|
В диалоговых окнах обычно две кнопки:
|
||||||
|
|
||||||
|
- **Основная кнопка** — подтверждает действие («Сохранить», «Удалить»). Основная кнопка должна
|
||||||
|
быть в фокусе.
|
||||||
|
- **Кнопка отмены** — закрывает окно без выполнения действия («Отменить»).
|
||||||
|
|
||||||
|
> **Правило:** чем правее кнопка — тем менее важное и менее частое действие. Вот обновлённая краткая
|
||||||
|
> версия с переносом правил подтверждения в конец:
|
||||||
|
|
||||||
|
## Именование компонентов
|
||||||
|
|
||||||
|
Для модальных окон используется единый принцип именования, который отражает тип действия и
|
||||||
|
обеспечивает предсказуемость.
|
||||||
|
|
||||||
|
### Обычные модальные окна
|
||||||
|
|
||||||
|
Используется паттерн:
|
||||||
|
|
||||||
|
> **`<Entity><Action>Modal`**
|
||||||
|
|
||||||
|
Применяется для создания, редактирования, просмотра или выполнения безопасных действий.
|
||||||
|
|
||||||
|
Где:
|
||||||
|
|
||||||
|
- **Entity** — сущность: `Project`, `Review`, `Expert`, …
|
||||||
|
- **Action** — действие в глагольной форме: `Create`, `Edit`, `Assign`, `View`, …
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `ProjectCreateModal`
|
||||||
|
- `CategoryEditModal`
|
||||||
|
- `ReviewDetailsModal`
|
||||||
|
- `ExpertAssignModal`
|
||||||
|
|
||||||
|
### Подтверждение действий
|
||||||
|
|
||||||
|
Для модальных окон, которые подтверждают действие, добавляется префикс:
|
||||||
|
|
||||||
|
> **`<Entity><Action>ConfirmModal`**
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `ProjectDeleteConfirmModal`
|
||||||
|
- `ExpertUnassignConfirmModal`
|
||||||
|
- `ReviewRejectConfirmModal`
|
||||||
|
|
||||||
|
### Преимущества схемы
|
||||||
|
|
||||||
|
- структурированность,
|
||||||
|
- предсказуемость,
|
||||||
|
- понятные правила,
|
||||||
|
- единообразие в кодовой базе.
|
||||||
|
|
||||||
|
## Закрытие модального окна
|
||||||
|
|
||||||
|
Клик по иконке закрытия, нажатие _Esc_ или клик вне модального окна — **эквивалентные действия**,
|
||||||
|
приводящие к отмене и закрытию.
|
||||||
|
|
||||||
|
Исключения:
|
||||||
|
|
||||||
|
- важные процессы, которые нельзя прервать случайно;
|
||||||
|
- формы ввода данных, во избежание потери изменений, если эти изменения критичны.
|
||||||
|
|
||||||
|
В таких случаях:
|
||||||
|
|
||||||
|
- окно может не закрываться через Esc/клик вне области;
|
||||||
|
- перед закрытием нужно уточнить: сохранить изменения или выйти.
|
||||||
|
|
||||||
|
## Асинхронные действия
|
||||||
|
|
||||||
|
Используйте `ActionDialog`, если:
|
||||||
|
|
||||||
|
- нужно подтвердить действие,
|
||||||
|
- выполнить асинхронную операцию,
|
||||||
|
- показать прогресс,
|
||||||
|
- отобразить успех или ошибку.
|
||||||
|
|
||||||
|
Для особых сценариев допускается использовать обычный `Dialog`, но:
|
||||||
|
|
||||||
|
- во время выполнения действия кнопки должны быть заблокированы,
|
||||||
|
- окно не должно закрываться до завершения операции,
|
||||||
|
- ошибка должна быть отображена в понятной форме.
|
||||||
|
|
||||||
|
## Dialog vs Modal
|
||||||
|
|
||||||
|
> Кратко: **Dialog — технология, Modal — сценарий продукта.**
|
||||||
|
|
||||||
|
- **`Dialog`** используется только для инфраструктурных UI-компонентов дизайн-системы (например,
|
||||||
|
`Dialog`, `ActionDialog`, `NoticeDialog`). Это строительные блоки, не связанные с доменными
|
||||||
|
сущностями.
|
||||||
|
|
||||||
|
- **`Modal`** используется для прикладных модальных окон, связанных с реальными пользовательскими
|
||||||
|
сценариями (`ProjectCreateModal`, `ConfirmProjectDeleteModal`, `ReviewDetailsModal`). Такие
|
||||||
|
компоненты используют `Dialog` внутри, но представляют собой доменные элементы интерфейса.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Быстрый старт
|
||||||
|
|
||||||
|
## Репозиторий
|
||||||
|
|
||||||
|
Настраиваем [ssh ключи в gitea](https://git.jt4d.ru/user/settings/keys), клонируем репозиторий и
|
||||||
|
ставим зависимости. В проекте используется пакетный менеджер yarn
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.jt4d.ru:2222/1vit/more.git
|
||||||
|
cd more
|
||||||
|
yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
Установка может занять несколько минут.
|
||||||
|
|
||||||
|
## Настройка БД
|
||||||
|
|
||||||
|
Рекомендуется устанавливать всё через docker чтобы избежать проблем с зависимостями и сделать
|
||||||
|
окружение более повторяемым.
|
||||||
|
|
||||||
|
Клонируем [репозиторий с docker-compose](https://git.jt4d.ru/1vit/pgsql) и выполняем
|
||||||
|
`docker compose up`. Если команда не найдена, ставим compose
|
||||||
|
[пакетом](https://docs.docker.com/compose/install/linux/#install-using-the-repository) или
|
||||||
|
[из исходников](https://docs.docker.com/compose/install/linux/#install-the-plugin-manually).
|
||||||
|
|
||||||
|
Потом создаём в корне проекта (основного репозитория) файл `.env` (пример в `.env.example`). Он
|
||||||
|
используется для подключения к основной БД.
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=1vit_more
|
||||||
|
DB_PASSWORD=password
|
||||||
|
DB_DATABASE=1vit_more
|
||||||
|
```
|
||||||
|
|
||||||
|
Ещё рекомендуется создать `.env.test` (пример в `.env.test.example`), он будет использоваться для
|
||||||
|
тестов с использованием БД. Если файл не найден, будет перезаписываться основная база.
|
||||||
|
|
||||||
|
Проверяем подключение `yarn db:show` и накатываем миграции (создание структуры данных, schema)
|
||||||
|
`yarn db:migrate`.
|
||||||
|
|
||||||
|
Далее нужно заполнить БД демо-данными при помощи команды `yarn test-data:db-restore`.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
| тип сервера | команда |
|
||||||
|
| ------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| Полный dev сервер с БД | `yarn start` |
|
||||||
|
| Сторибук (для тестирования отдельных компонентов) | `yarn storybook` |
|
||||||
|
| Jest (unit-тесты) | `yarn test:unit` или `yarn test:unit path/to/file` |
|
||||||
|
| Jest (db-тесты) | `yarn test:db`, см. [доку про дб тесты](/docs/developing/database) |
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
При каждом `git push` запускается сборка приложения на
|
||||||
|
[Gitea Actions](https://git.jt4d.ru/1vit/more/actions).
|
||||||
|
|
||||||
|
Стенд для ветки доступен по адресу `http://<branch-name>.<STAGING_HOST>`.
|
||||||
|
|
||||||
|
Подробнее о системе деплоя, тегах и продакшн-деплое — в [разделе «Поставка»](/docs/deploy/ci-cd).
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Ветки и пулл-реквесты
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
Для работы над задачей создаётся ветка с названием формата `i<номер_issue>-<краткое_описание>`.
|
||||||
|
Краткое описание это БУКВАЛЬНО 1-2 слова. Примеры:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
i472-statements
|
||||||
|
i343-project-page
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
Сразу после загрузки (`git push`) ветки рекомендуется создавать ей pull request и привязать его к
|
||||||
|
задаче
|
||||||
|
|
||||||
|
Описание PR должно иметь вид список изменений - пустая строка - вид отношения к задаче и
|
||||||
|
\#номер_задачи, пример:
|
||||||
|
|
||||||
|
```md
|
||||||
|
- cover `viewRoot` flag in `users.dal` with db tests
|
||||||
|
- add `viewRole.findOne` test group
|
||||||
|
|
||||||
|
Closes #381
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```md
|
||||||
|
- добавил тесты на наличие буквы, цифры, спецсимвола и на повторяемость.
|
||||||
|
- улучшен алгоритм генерации пароля
|
||||||
|
|
||||||
|
Closes #433
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```md
|
||||||
|
- rename statements-period.types
|
||||||
|
- fix DateView stories
|
||||||
|
|
||||||
|
Related to #472
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Загружаем (`git push`) ветку
|
||||||
|
2. Создаём pull request с названием `WIP: <название_ветки>`
|
||||||
|
3. Добавляем описание
|
||||||
|
4. Добавляем PR в зависимости задачи (Dependencies справа)
|
||||||
|
|
||||||
|
Когда ветка готова, отправляем на ревью (Reviewers справа), убираем префикс `WIP` из названия и
|
||||||
|
перемещаем в столбец `To review` на [доске](http://git.arswarog.ru/1vit/more/projects/1).
|
||||||
|
|
||||||
|
## Доска проекта
|
||||||
|
|
||||||
|
Статус задач можно отслеживать на [доске проекта](http://git.arswarog.ru/1vit/more/projects/1). Pull
|
||||||
|
request на доску не добавляем, там должны быть только задачи. PR будет отображаться под задачей если
|
||||||
|
его добавить в зависимости.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Файловая структура проекта
|
||||||
|
|
||||||
|
## Фронтенд
|
||||||
|
|
||||||
|
### Гостевой фронтенд
|
||||||
|
|
||||||
|
Располагается в `/src/visitor`
|
||||||
|
|
||||||
|
- Лендинг на главной странице
|
||||||
|
- Страница организатора
|
||||||
|
- Страница проекта
|
||||||
|
|
||||||
|
Все они имеют уникальный дизайн (на `scss`), и могут быть быстро изменены
|
||||||
|
|
||||||
|
#### Ограничения
|
||||||
|
|
||||||
|
Может обращаться только к `common`
|
||||||
|
|
||||||
|
### Клиентский фронтенд
|
||||||
|
|
||||||
|
Располагается в `/src/client`
|
||||||
|
|
||||||
|
- регистрация
|
||||||
|
- авторизация
|
||||||
|
- личный кабинет
|
||||||
|
- создание заявок
|
||||||
|
- панель эксперта
|
||||||
|
- панель модератора
|
||||||
|
- панель админа
|
||||||
|
|
||||||
|
#### Ограничения
|
||||||
|
|
||||||
|
Может обращаться только к `common`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Бэкенд
|
||||||
|
|
||||||
|
Располагается в папке `/src/server`
|
||||||
|
|
||||||
|
### Ограничения
|
||||||
|
|
||||||
|
Может обращаться только к `common` и `client`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Общие типы и функции
|
||||||
|
|
||||||
|
Располагаются в папке `/src/common`
|
||||||
|
|
||||||
|
### Ограничения
|
||||||
|
|
||||||
|
Не может обращаться ни к каким другим частям, т.к. является корнем
|
||||||
|
|
||||||
|
## Демо данные
|
||||||
|
|
||||||
|
Располагаются в папке `/src/common`
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- Файл с этими данными должен называться `<название>.demo.ts`, где `<название>` - это название
|
||||||
|
типа во множественном числе
|
||||||
|
- Переменная с демо данными должна начинаться с `demo` и иметь вид `demo<название>`
|
||||||
|
- Переменные с вариантами данных одного типа должны начинаться с `demo<название>` и иметь вид
|
||||||
|
`demo<название><название варианта>`
|
||||||
|
- Если демо данных одного типа больше одного значения, то эти значения располагаются в этом же
|
||||||
|
файле
|
||||||
|
|
||||||
|
Рекомендация:
|
||||||
|
|
||||||
|
В названии константы указывать её тип (`demoEvent: IEvent`).
|
||||||
|
|
||||||
|
### Пример
|
||||||
|
|
||||||
|
Пример организации демо данных участников:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/// file: src/demoData/participants.ts
|
||||||
|
|
||||||
|
export const demoParticipantBase: IBaseParticipant = { ... }
|
||||||
|
|
||||||
|
export const demoParticipantKey: IKeyParticipant = { ... }
|
||||||
|
|
||||||
|
export const demoParticipants: IParticipant[] = [
|
||||||
|
demoParticipantBase,
|
||||||
|
demoParticipantKey,
|
||||||
|
]
|
||||||
|
```
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Схема зависимостей модулей
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
|
||||||
|
Applications
|
||||||
|
Applications <|-- Evaluation
|
||||||
|
|
||||||
|
Auth
|
||||||
|
|
||||||
|
Client
|
||||||
|
|
||||||
|
Contests
|
||||||
|
Contests : ContestsService
|
||||||
|
Contests : ContestAreasService
|
||||||
|
Contests <|-- Requests
|
||||||
|
Contests <|-- Applications
|
||||||
|
Contests <|-- Evaluation
|
||||||
|
|
||||||
|
Events
|
||||||
|
Events : EventsService
|
||||||
|
Events <|-- Requests
|
||||||
|
|
||||||
|
note for Events "Events должен\n зависеть от Requests,\n а не наоборот"
|
||||||
|
|
||||||
|
Expenses
|
||||||
|
Expenses : ExpensesService
|
||||||
|
Expenses <|-- Statements
|
||||||
|
|
||||||
|
Export
|
||||||
|
Export : ExportService
|
||||||
|
Export <|-- Statements
|
||||||
|
Export <|-- Requests
|
||||||
|
|
||||||
|
Files
|
||||||
|
Files : FilesService
|
||||||
|
|
||||||
|
Organizators
|
||||||
|
Organizators : OrganizatorsService
|
||||||
|
Organizators <|-- Applications
|
||||||
|
Organizators <|-- Contests
|
||||||
|
Organizators <|-- Requests
|
||||||
|
|
||||||
|
Profile
|
||||||
|
|
||||||
|
Requests
|
||||||
|
Requests : RequestsService
|
||||||
|
Requests <|-- Expenses
|
||||||
|
Requests <|-- Statements
|
||||||
|
|
||||||
|
Statements
|
||||||
|
Statements : StatementsPeriodsService
|
||||||
|
|
||||||
|
Users
|
||||||
|
Users : UsersService
|
||||||
|
Users <|-- Client
|
||||||
|
Users <|-- Profile
|
||||||
|
```
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Тестирование с использованием реального бэкенда
|
||||||
|
|
||||||
|
Поднимается полноценнный nest сервер, затем для каждого теста БД сбрасывается и восстанавливается из
|
||||||
|
снапшота (`/test-data/db/`)
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
Для каждого прогона
|
||||||
|
|
||||||
|
- создаётся база тестовая данных
|
||||||
|
- запускаются тесты с бэком
|
||||||
|
- тестовая база удаляется
|
||||||
|
|
||||||
|
Для CI/CD отдельных настроек не требуется
|
||||||
|
|
||||||
|
### Настройка
|
||||||
|
|
||||||
|
Настройки для тестов хранятся в файле `.env.test`. Пример файла:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_USERNAME=test_user
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
DB_DATABASE=test_db
|
||||||
|
```
|
||||||
|
|
||||||
|
Все недостающие значения будут взяты из `.env`.
|
||||||
|
|
||||||
|
### Локальный запуск
|
||||||
|
|
||||||
|
При запуске тестов можно использовать команды
|
||||||
|
|
||||||
|
- `yarn test:db` запустит только тесты с БД
|
||||||
|
- `yarn test` запустит все тесты в проекте
|
||||||
|
|
||||||
|
### Написание тестов
|
||||||
|
|
||||||
|
Тесты должны иметь расширение `.db-spec.ts`.
|
||||||
|
|
||||||
|
Для создания и запуска бэка используется утилита `createTestingBackend()`. В возвращаемом ей
|
||||||
|
интерфейсе есть:
|
||||||
|
|
||||||
|
- метод `resetState()` - нужно запускать перед каждым тестом
|
||||||
|
- метод `destroy()` - вызывать в конце группы тестов
|
||||||
|
- поле `http` - результат вызова `INestApplication.getHttpServer()`.
|
||||||
|
|
||||||
|
Также есть утилита `authenticate`, которую можно использовать для входа.
|
||||||
|
|
||||||
|
Пример управления состоянием:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let app: ITestingBackend;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestingBackend();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await app.resetState();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример теста:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('при создании AppForm имеет статус draft и не удалена', async () => {
|
||||||
|
const api = supertest(app.http);
|
||||||
|
|
||||||
|
const response = await api
|
||||||
|
.post('/v1/contests/1/forms')
|
||||||
|
.set('Cookie', await authenticate(api, 'admin'))
|
||||||
|
.send(exampleValidForm)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
draft: true,
|
||||||
|
deleted: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование изолированной части бэкенда
|
||||||
|
|
||||||
|
Для unit-тестов определённой части бэкенда можно использовать утилиту `createTestingHarness(app)`.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [UsersDal, TimeService, createMockConfigProvider(authConfigToken)],
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forRoot({
|
||||||
|
...testOrmCredentials,
|
||||||
|
entities: [User, Contest, AuthToken],
|
||||||
|
synchronize: false,
|
||||||
|
logging: false,
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forFeature([User]),
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const harness = await createTestingHarness(app);
|
||||||
|
harness.resetState(); // имеет тот же интерфейс, что createTestingBackend
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Разработчику'
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Введение
|
||||||
|
|
||||||
|
## Зачем нужно логирование
|
||||||
|
|
||||||
|
### Цель
|
||||||
|
|
||||||
|
Логирование — это ключевой инструмент диагностики и анализа поведения системы. Оно фиксирует
|
||||||
|
**действия, ошибки и события**, позволяя:
|
||||||
|
|
||||||
|
- анализировать поведение приложения;
|
||||||
|
- находить причины ошибок и сбоев;
|
||||||
|
- отслеживать бизнес-процессы (например, запуск конкурса или назначение эксперта);
|
||||||
|
- проводить аудит действий пользователей;
|
||||||
|
- контролировать производительность и стабильность работы.
|
||||||
|
|
||||||
|
Хорошее логирование даёт **контекст и доказательства** — кто, когда и что сделал, с каким
|
||||||
|
результатом.
|
||||||
|
|
||||||
|
### Основные задачи логирования
|
||||||
|
|
||||||
|
Логирование решает как **технические**, так и **организационные** задачи.
|
||||||
|
|
||||||
|
**Технические:**
|
||||||
|
|
||||||
|
- **Диагностика:** восстановление хода событий при сбое.
|
||||||
|
- **Аналитика:** понимание того, как система используется.
|
||||||
|
- **Поддержка:** ускорение поиска причин ошибок в эксплуатации.
|
||||||
|
|
||||||
|
**Организационные:**
|
||||||
|
|
||||||
|
- **Аудит:** фиксация действий пользователей и администраторов.
|
||||||
|
- **Безопасность:** выявление попыток несанкционированного доступа.
|
||||||
|
|
||||||
|
## Уровни логирования
|
||||||
|
|
||||||
|
В системе используется библиотека **Pino**, интегрированная через `nestjs-pino`. Она поддерживает
|
||||||
|
стандартные уровни логирования, отражающие важность события.
|
||||||
|
|
||||||
|
| Уровень | Метод | Когда использовать | Пример |
|
||||||
|
| --------- | --------- | --------------------------------------------------- | -------------------------------- |
|
||||||
|
| **TRACE** | `trace()` | Максимальная детализация, пошаговая трассировка | Проверка цепочки вызовов |
|
||||||
|
| **DEBUG** | `debug()` | Отладочная информация о логике работы | Вывод промежуточных данных |
|
||||||
|
| **INFO** | `log()` | Обычные события нормальной работы | Создание проекта |
|
||||||
|
| **WARN** | `warn()` | Нежелательные, но некритичные ситуации | Отказ в доступе |
|
||||||
|
| **ERROR** | `error()` | Ошибки, требующие внимания и реакции | Исключение при сохранении |
|
||||||
|
| **FATAL** | `fatal()` | Критические сбои, приводящие к остановке приложения | Потеря соединения с базой данных |
|
||||||
|
|
||||||
|
> 💡 Все уровни логов фиксируются одинаково по структуре данных — различается только их
|
||||||
|
> **важность**.
|
||||||
|
|
||||||
|
## Различие между dev и prod
|
||||||
|
|
||||||
|
Логирование настроено так, чтобы быть **удобным в разработке** и **эффективным в продакшене**.
|
||||||
|
|
||||||
|
| Среда | Формат | Уровень по умолчанию | Особенности |
|
||||||
|
| --------------- | ----------------- | -------------------- | --------------------------------------------------------------------- |
|
||||||
|
| **Development** | человеко-читаемый | `debug` | Цвета, отступы, подробные данные — удобно для чтения в консоли. |
|
||||||
|
| **Production** | JSON | `info` | Машиночитаемый формат для централизованной агрегации и анализа логов. |
|
||||||
|
|
||||||
|
> Состав данных в логе всегда одинаков — меняется только способ отображения. Уровень логирования
|
||||||
|
> можно переопределить через переменные окружения.
|
||||||
|
|
||||||
|
Таким образом:
|
||||||
|
|
||||||
|
- в **разработке** акцент на удобство восприятия и диагностику;
|
||||||
|
- в **продакшене** — на структурированные данные и интеграцию с системами мониторинга (например,
|
||||||
|
Loki или ELK).
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Подключение и использование
|
||||||
|
|
||||||
|
## Получение логгера
|
||||||
|
|
||||||
|
Логгер внедряется в любой сервис, контроллер или компонент через **dependency injection**. После
|
||||||
|
внедрения требуется указать **контекст**, чтобы логи было проще анализировать.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectService {
|
||||||
|
constructor(private readonly logger: Logger) {
|
||||||
|
this.logger.setContext('Projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(data: CreateProjectDto) {
|
||||||
|
this.logger.log('Project created', { project: 42, user: data.ownerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Контекст логгера
|
||||||
|
|
||||||
|
Контекст указывает, **к какому модулю или бизнес-процессу относится лог**. Он помогает группировать
|
||||||
|
события и быстрее понимать источник при анализе системы.
|
||||||
|
|
||||||
|
Контекст задаётся вручную при инициализации логгера:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
constructor(private readonly logger: Logger) {
|
||||||
|
this.logger.setContext('Evaluation');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правила именования контекста
|
||||||
|
|
||||||
|
Хорошие контексты делают логи самодокументируемыми. Чтобы они оставались единообразными, следуйте
|
||||||
|
правилам:
|
||||||
|
|
||||||
|
1. **Основой контекста является название модуля**, отражающее область бизнес-логики. Примеры:
|
||||||
|
|
||||||
|
- `Evaluation` — процесс оценки заявок;
|
||||||
|
- `Contests` — управление конкурсами;
|
||||||
|
- `Users` — работа с пользователями.
|
||||||
|
|
||||||
|
2. **При необходимости уточнения** добавляется второй уровень через двоеточие:
|
||||||
|
|
||||||
|
- `Evaluation:ExpertAssignment` — назначение экспертов;
|
||||||
|
- `Applications:Moderation` — модерация проектов.
|
||||||
|
|
||||||
|
3. **Технические роли** (`Service`, `Controller` и т. п.) в названии **не используются**, так как
|
||||||
|
контекст должен отражать **бизнес-смысл**, а не структуру кода.
|
||||||
|
|
||||||
|
> 💬 Контекст — часть “языка логирования” проекта. Он должен быть **стабильным, понятным и
|
||||||
|
> единообразным** во всех модулях.
|
||||||
|
|
||||||
|
## Контекст запроса
|
||||||
|
|
||||||
|
При логировании внутри HTTP-запроса система автоматически добавляет поля:
|
||||||
|
|
||||||
|
- `user` — идентификатор пользователя (если авторизован);
|
||||||
|
- `url` — адрес текущего запроса.
|
||||||
|
|
||||||
|
Это позволяет связывать бизнес-события с конкретными действиями пользователей и упрощает анализ
|
||||||
|
логов.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
Настройки логирования задаются через `.env` или переменные окружения.
|
||||||
|
|
||||||
|
| Переменная | Назначение | Пример значения |
|
||||||
|
| ----------------- | -------------------------------------------------------- | -------------------------------- |
|
||||||
|
| `LOG_LEVEL` | Минимальный уровень логирования. | `debug`, `info`, `warn`, `error` |
|
||||||
|
| `LOG_PRETTY` | Включает человеко-читаемый формат (обычно в dev). | `true` |
|
||||||
|
| `LOG_SILENT_HTTP` | Отключает автоматические HTTP-логи (актуально в тестах). | `true` |
|
||||||
|
|
||||||
|
Если переменные не заданы, применяются значения по умолчанию:
|
||||||
|
|
||||||
|
| Окружение | Значения по умолчанию |
|
||||||
|
| --------- | ------------------------------------ |
|
||||||
|
| **dev** | `LOG_LEVEL=debug`, `LOG_PRETTY=true` |
|
||||||
|
| **prod** | `LOG_LEVEL=info`, `LOG_PRETTY=false` |
|
||||||
|
|
||||||
|
> 🔧 Уровень логирования можно переопределить без релиза — через переменные окружения при запуске
|
||||||
|
> приложения.
|
||||||
|
|
||||||
|
## Чек-лист перед ревью
|
||||||
|
|
||||||
|
- [ ] Логгер подключён через dependency injection.
|
||||||
|
- [ ] Контекст задан явно через `setContext()`.
|
||||||
|
- [ ] Уровень логирования соответствует окружению.
|
||||||
|
- [ ] Логи не содержат чувствительных данных.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Автоматическое логирование
|
||||||
|
|
||||||
|
Автоматическое логирование фиксирует системные события **без участия разработчика**. Оно
|
||||||
|
обеспечивает единый формат сообщений и полное покрытие ключевых операций платформы —
|
||||||
|
**HTTP-запросов**, **проверок доступа (RBAC)** и **ошибок**.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Цели автоматического логирования:
|
||||||
|
|
||||||
|
- Сократить количество ручных логов в коде.
|
||||||
|
- Обеспечить единообразие сообщений и уровней логирования.
|
||||||
|
- Повысить наблюдаемость и удобство диагностики.
|
||||||
|
- Позволить анализировать производительность и отказы без изменения бизнес-логики.
|
||||||
|
|
||||||
|
> 💡 Автоматические логи всегда присутствуют и формируются системой независимо от действий
|
||||||
|
> разработчика.
|
||||||
|
|
||||||
|
## Источники автоматических логов
|
||||||
|
|
||||||
|
| Тип события | Источник | Что логируется |
|
||||||
|
| --------------------------- | ---------------------------- | -------------------------------------------------------------- |
|
||||||
|
| **HTTP-запросы** | middleware `pino-http` | Каждый завершённый запрос с кодом ответа и временем обработки. |
|
||||||
|
| **Проверки доступа (RBAC)** | модуль `RbacModule` | Успешные и неуспешные проверки разрешений. |
|
||||||
|
| **Ошибки и исключения** | глобальный `ExceptionFilter` | Необработанные ошибки приложения и системные сбои. |
|
||||||
|
|
||||||
|
Все эти события регистрируются автоматически и не требуют дополнительного кода в модулях.
|
||||||
|
|
||||||
|
## HTTP-запросы
|
||||||
|
|
||||||
|
### Общие принципы
|
||||||
|
|
||||||
|
- Каждый HTTP-запрос логируется **после завершения обработки**.
|
||||||
|
- Лог формируется middleware `pino-http` на основе данных ответа.
|
||||||
|
- Разработчику не нужно добавлять логи вручную — они создаются автоматически.
|
||||||
|
|
||||||
|
Каждая запись содержит:
|
||||||
|
|
||||||
|
- HTTP-метод (`GET`, `POST`, `PATCH` и т. д.);
|
||||||
|
- путь запроса (`/api/v1/...`);
|
||||||
|
- код ответа (`200`, `403`, `500` и т. д.);
|
||||||
|
- время выполнения (в миллисекундах);
|
||||||
|
- идентификатор пользователя (`user`, если известен).
|
||||||
|
|
||||||
|
### Уровни логирования
|
||||||
|
|
||||||
|
| Категория ответа | Пример кода | Уровень |
|
||||||
|
| ---------------- | ----------- | ------- |
|
||||||
|
| Успешный ответ | `2xx` | `info` |
|
||||||
|
| Ошибка клиента | `4xx` | `info` |
|
||||||
|
| Ошибка сервера | `5xx` | `error` |
|
||||||
|
|
||||||
|
> Уровень `warn` для HTTP-логов не используется — это предотвращает появление ложных предупреждений
|
||||||
|
> при корректных отказах.
|
||||||
|
|
||||||
|
### Задачи HTTP-логов
|
||||||
|
|
||||||
|
- Отслеживание активности API.
|
||||||
|
- Измерение времени обработки запросов.
|
||||||
|
- Анализ причин ошибок и неудачных вызовов.
|
||||||
|
- Связь действий пользователей с конкретными маршрутами.
|
||||||
|
|
||||||
|
## Проверки доступа (RBAC)
|
||||||
|
|
||||||
|
### Общие принципы
|
||||||
|
|
||||||
|
Все проверки доступа логируются **автоматически** при вызовах методов `check*` и `accessDenied`. Это
|
||||||
|
обеспечивает аудит всех случаев — и разрешённых, и запрещённых действий.
|
||||||
|
|
||||||
|
### Уровни логирования
|
||||||
|
|
||||||
|
| Событие | Уровень | Что фиксируется |
|
||||||
|
| --------------- | ------- | -------------------------------------------------- |
|
||||||
|
| Отказ в доступе | `warn` | Попытка выполнить действие без нужного разрешения. |
|
||||||
|
| Доступ разрешён | `debug` | Успешная проверка разрешения. |
|
||||||
|
|
||||||
|
> Такое распределение уровней помогает разделять реальные проблемы (отказы) и штатные сценарии
|
||||||
|
> (успешные проверки).
|
||||||
|
|
||||||
|
## Ошибки и исключения
|
||||||
|
|
||||||
|
### Общие принципы
|
||||||
|
|
||||||
|
Глобальный `ExceptionFilter` автоматически логирует все необработанные ошибки:
|
||||||
|
|
||||||
|
- `error` — ошибки приложения (например, неверные данные или сбой бизнес-логики);
|
||||||
|
- `fatal` — критические сбои, приводящие к остановке приложения (например, невозможность
|
||||||
|
подключиться к базе данных).
|
||||||
|
|
||||||
|
### Структура записи
|
||||||
|
|
||||||
|
Каждая запись об ошибке включает:
|
||||||
|
|
||||||
|
- `trace` — стек вызовов;
|
||||||
|
- `context` — модуль, где произошла ошибка;
|
||||||
|
- `user` и `url` — если ошибка возникла в контексте HTTP-запроса.
|
||||||
|
|
||||||
|
> Это обеспечивает точную локализацию проблем и упрощает разбор инцидентов.
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
- Не дублируйте автоматические логи вручную.
|
||||||
|
- Если требуется зафиксировать **дополнительный контекст**, добавляйте отдельный лог рядом с
|
||||||
|
бизнес-событием — но не повторяйте уже зарегистрированные HTTP-запросы или проверки доступа.
|
||||||
|
- При анализе проблем **начинайте с автоматических логов** — они формируются всегда и имеют единый
|
||||||
|
формат.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Ручное логирование и бизнес-события
|
||||||
|
|
||||||
|
Автоматическое логирование фиксирует системные события (HTTP-запросы, проверки доступа, ошибки), но
|
||||||
|
не отражает **внутреннюю бизнес-логику** приложения.
|
||||||
|
|
||||||
|
Ручное логирование используется для фиксации действий и изменений, важных **с точки зрения
|
||||||
|
продукта** — того, что видит или делает пользователь.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Ручные логи применяются для описания событий, которые отражают состояние системы и бизнес-процессы:
|
||||||
|
|
||||||
|
- создание, изменение и удаление сущностей;
|
||||||
|
- начало и завершение процессов;
|
||||||
|
- изменение статуса или состояния;
|
||||||
|
- ошибки бизнес-операций;
|
||||||
|
- любые другие действия, значимые для анализа и аудита.
|
||||||
|
|
||||||
|
> Такое логирование помогает понять, **что происходило внутри системы**, даже без включённой отладки
|
||||||
|
> или доступа к базе данных.
|
||||||
|
|
||||||
|
## Правила оформления логов
|
||||||
|
|
||||||
|
### Общие принципы
|
||||||
|
|
||||||
|
Сообщения должны быть:
|
||||||
|
|
||||||
|
- **краткими** — не длиннее одной фразы;
|
||||||
|
- **человеко-читаемыми** — понятными без знания кода;
|
||||||
|
- **однозначными** — описывать факт, а не процесс;
|
||||||
|
- **на английском**, **в прошедшем времени**, **без артиклей и пунктуации**.
|
||||||
|
|
||||||
|
Если операция завершилась неудачей, сообщение должно оканчиваться на `failed`.
|
||||||
|
|
||||||
|
Логирование выполняется **после завершения** операции — независимо от результата.
|
||||||
|
|
||||||
|
### Примеры сообщений
|
||||||
|
|
||||||
|
**Успешные операции:**
|
||||||
|
|
||||||
|
- `Project created`
|
||||||
|
- `Contest launched`
|
||||||
|
- `Review assigned`
|
||||||
|
- `Evaluation completed`
|
||||||
|
|
||||||
|
**Ошибки и неудачи:**
|
||||||
|
|
||||||
|
- `Project update failed`
|
||||||
|
- `File upload failed`
|
||||||
|
- `Review submission failed`
|
||||||
|
|
||||||
|
**Неправильные формулировки:**
|
||||||
|
|
||||||
|
- `Creating project...` — отражает процесс, а не факт.
|
||||||
|
- `Contest ${id} launched` — содержит шаблон и динамику.
|
||||||
|
- `Project created successfully!` — избыточно и разговорно.
|
||||||
|
|
||||||
|
### Дополнительные данные
|
||||||
|
|
||||||
|
Контекст события передаётся отдельным объектом (`details`). Включайте только данные, которые
|
||||||
|
действительно помогают при анализе.
|
||||||
|
|
||||||
|
**Рекомендации:**
|
||||||
|
|
||||||
|
- Всегда указывайте **идентификаторы** (`project`, `contest`, `user` и т. д.).
|
||||||
|
- В идентификаторах избегайте суфиксов `id` (пример: `project` вместо `projectId`).
|
||||||
|
- Добавляйте **причину** (`reason`) при ошибках или отказах.
|
||||||
|
- Указывайте **новое состояние** (`status`, `result`) при изменениях.
|
||||||
|
- Не включайте чувствительные данные — токены, e-mail, пароли, персональные сведения.
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
logger.log('Project created', { project: 42, user: 7 });
|
||||||
|
logger.error('Project update failed', { project: 42, reason: 'invalid_status' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Бизнес-события
|
||||||
|
|
||||||
|
### Что считается бизнес-событием
|
||||||
|
|
||||||
|
Бизнес-события описывают **действия и изменения на уровне предметной области**, например:
|
||||||
|
|
||||||
|
- `Contest launched` — запуск конкурса;
|
||||||
|
- `Project submitted` — заявка подана;
|
||||||
|
- `Review assigned` — эксперт назначен;
|
||||||
|
- `Evaluation completed` — оценка завершена.
|
||||||
|
|
||||||
|
> Все бизнес-события логируются на уровне `info` (`logger.log()`), а ошибки бизнес-операций — на
|
||||||
|
> уровне `error`.
|
||||||
|
|
||||||
|
### Длительные процессы
|
||||||
|
|
||||||
|
Для долгих операций допускается логирование **двух фаз**:
|
||||||
|
|
||||||
|
- начало — `<Action> started`;
|
||||||
|
- завершение — `<Action>` (применяется обычные правила оформления сообщений).
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
logger.log('File export started', { contest: 3 });
|
||||||
|
logger.log('File exported', { contest: 3, durationMs: 14250 });
|
||||||
|
logger.error('File export failed', { contest: 3, reason: 'timeout' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Такой подход позволяет отслеживать прогресс и измерять длительность операций.
|
||||||
|
|
||||||
|
### Зачем нужны бизнес-события
|
||||||
|
|
||||||
|
- Повышают прозрачность бизнес-процессов.
|
||||||
|
- Используются при анализе и аудите.
|
||||||
|
- Позволяют отследить жизненный цикл сущностей — от создания до завершения.
|
||||||
|
- Упрощают поиск причин ошибок на уровне сценариев пользователей.
|
||||||
|
|
||||||
|
## Чек-лист перед ревью
|
||||||
|
|
||||||
|
- [ ] Контекст логгера задан.
|
||||||
|
- [ ] Сообщение короткое, на английском и в прошедшем времени.
|
||||||
|
- [ ] В `details` указаны идентификаторы и причина (если применимо).
|
||||||
|
- [ ] Уровень логирования выбран корректно (`info` или `error`).
|
||||||
|
- [ ] Лог не дублирует автоматические записи (HTTP, RBAC).
|
||||||
|
- [ ] Отсутствуют конфиденциальные данные.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Тестирование логирования
|
||||||
|
|
||||||
|
## Подключение
|
||||||
|
|
||||||
|
Для тестов следует подключать специальный модуль — **`LocalLoggerModule.forTesting()`**. Он
|
||||||
|
сохраняет логи в памяти и предоставляет к ним доступ через `TestingLogger`.
|
||||||
|
|
||||||
|
Это позволяет:
|
||||||
|
|
||||||
|
- перехватывать все вызовы логгера внутри тестируемого кода;
|
||||||
|
- сохранять их в память вместо вывода в консоль;
|
||||||
|
- проверять, какие именно логи были собраны за время теста.
|
||||||
|
|
||||||
|
Пример подключения через NestJS `TestingModule`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let logger: TestingLogger;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [LocalLoggerModule.forTesting()],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
logger = module.get(TestingLogger);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
После такого подключения любые вызовы вида:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.logger.log('Project created');
|
||||||
|
this.logger.error('Project update failed');
|
||||||
|
```
|
||||||
|
|
||||||
|
будут сохранены внутри `TestingLogger`, и вы сможете проверить их в тестах при помощи матчеров:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
logger.withMessage('Project created').withLevel('info').toBeLoggedOnce();
|
||||||
|
logger.withMessage('Project update failed').withLevel('error').toBeLogged();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Цепочка фильтров
|
||||||
|
|
||||||
|
Методы фильтрации (`withMessage`, `withLevel`, `withContext`, `withDetails`) можно вызывать
|
||||||
|
цепочкой, для более точного сопоставления логов:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
logger
|
||||||
|
.withMessage('Project created')
|
||||||
|
.withLevel('info')
|
||||||
|
.withContext('Projects')
|
||||||
|
.withDetails({ user: 5 })
|
||||||
|
.toBeLoggedOnce();
|
||||||
|
```
|
||||||
|
|
||||||
|
Метод `withDetails` поддерживает частичное сравнение объекта, а также может проверять поля `level`,
|
||||||
|
`context` и `message`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
logger
|
||||||
|
.withMessage('Project created')
|
||||||
|
.withDetails({
|
||||||
|
level: 'info',
|
||||||
|
context: 'Projects',
|
||||||
|
user: 5,
|
||||||
|
})
|
||||||
|
.toBeLoggedOnce();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Просмотр найденных записей
|
||||||
|
|
||||||
|
Если нужно отладить фильтр, и просмотреть, какие записи были найдены по текущему фильтру, можно
|
||||||
|
использовать метод `showLogs()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
logger.withLevel('error').showLogs().toBeLogged();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Методы проверки
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| -------------------- | --------------------------------------------------------- |
|
||||||
|
| `toBeLogged()` | Проверяет, что хотя бы одна запись соответствует фильтру. |
|
||||||
|
| `toBeNotLogged()` | Проверяет, что таких записей нет. |
|
||||||
|
| `toBeLoggedOnce()` | Проверяет, что запись встречается ровно один раз. |
|
||||||
|
| `toBeLoggedTimes(n)` | Проверяет, что запись встречается `n` раз. |
|
||||||
|
|
||||||
|
Все методы выбрасывают ошибку, если условие не выполняется.
|
||||||
|
|
||||||
|
> **Важно:** Методы проверки **обязательно** должны вызываться, иначе проверки так и не будет.
|
||||||
|
|
||||||
|
### Группирование проверок
|
||||||
|
|
||||||
|
Если в тесте нужно проверить несколько событий с общим набором полей, можно использовать такой
|
||||||
|
приём:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const rbacLogger = logger.withContext('RBAC');
|
||||||
|
|
||||||
|
rbacLogger.withMessage('Access granted').toBeLoggedTimes(2);
|
||||||
|
|
||||||
|
rbacLogger.withMessage('Access denied').toBeLoggedOnce();
|
||||||
|
```
|
||||||
|
|
||||||
|
Это удобно для группировки проверок по контексту или другим общим параметрам.
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Проверка бизнес-события
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('должен логировать создание проекта', () => {
|
||||||
|
service.createProject({ user: 7 });
|
||||||
|
|
||||||
|
logger
|
||||||
|
.withMessage('Project created')
|
||||||
|
.withLevel('info')
|
||||||
|
.withDetails({ user: 7 })
|
||||||
|
.toBeLoggedOnce();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка ошибок
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('должен логировать ошибку при невалидном статусе', () => {
|
||||||
|
service.updateProjectStatus(42, 'invalid');
|
||||||
|
|
||||||
|
logger.withMessage('Project update failed').withLevel('warn').toBeLogged();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка отсутствия логов
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('не должен логировать ничего при успешном выполнении без событий', () => {
|
||||||
|
service.doNothing();
|
||||||
|
|
||||||
|
logger.any().toBeNotLogged();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Очистка
|
||||||
|
|
||||||
|
```ts
|
||||||
|
beforeEach(() => {
|
||||||
|
logger.clear();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Используйте `clear()` для сброса состояния между тестами, если экземпляр `TestingLogger`
|
||||||
|
переиспользуется в нескольких сценариях.
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
- `TestingLogger` предназначен **только для тестов** — в рабочем коде используется обычный
|
||||||
|
`Logger`.
|
||||||
|
- Проверяйте **факт логирования**, а не реализацию — тесты должны оставаться устойчивыми к
|
||||||
|
внутренним изменениям.
|
||||||
|
- Используйте `toBeNotLogged()` для сценариев, где событие **не должно** быть зафиксировано.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Логирование'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Технические модули'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Auth модуль и gateway'
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Принцип и сценарии
|
||||||
|
|
||||||
|
## Глоссарий
|
||||||
|
|
||||||
|
- **гость** не аутентифицированный посетитель
|
||||||
|
- **пользователь** аутентифицированный посетитель
|
||||||
|
|
||||||
|
## Назначение и требования к токенам
|
||||||
|
|
||||||
|
### Access token
|
||||||
|
|
||||||
|
- Короткоживущий многоразовый токен
|
||||||
|
- JWT
|
||||||
|
- За счет короткого времени жизни дополнительной проверки не требуется
|
||||||
|
|
||||||
|
### Refresh token
|
||||||
|
|
||||||
|
- Предназначен для одноразового получения нового комплекта токенов
|
||||||
|
- Токен должен храниться в базе данных и содержать следующую информацию:
|
||||||
|
- Разрешение на генерацию токена
|
||||||
|
- предыдущий refresh token
|
||||||
|
- аутентификация пользователя
|
||||||
|
- регистрация пользователя
|
||||||
|
- Т.к. токен одноразовый и периодически обновляется, то нельзя использовать sessionStorage для его
|
||||||
|
хранения
|
||||||
|
|
||||||
|
## Технические сценарии
|
||||||
|
|
||||||
|
### Гость на сайте
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant G as Gateway
|
||||||
|
participant S as Server
|
||||||
|
|
||||||
|
C->>G: Request without access token
|
||||||
|
G->>S: Request without user info
|
||||||
|
S->>G: Response
|
||||||
|
G->>C: Response without tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Гость логинится
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant G as Gateway
|
||||||
|
participant S as Server
|
||||||
|
|
||||||
|
C->>S: Credentials
|
||||||
|
Note over S: Check credentials
|
||||||
|
alt valid and user active
|
||||||
|
S->>C: access_token, refresh_token
|
||||||
|
else invalid or user not active
|
||||||
|
S-->>C: 422 error
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Гость регистрируется
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant G as Gateway
|
||||||
|
participant S as Server
|
||||||
|
|
||||||
|
C->>S: Credentials
|
||||||
|
Note over S: Check for no access token
|
||||||
|
Note over S: Check credentials
|
||||||
|
alt valid and user active
|
||||||
|
S->>C: Congratulation page with redirect on timeout to login
|
||||||
|
else invalid or user not active
|
||||||
|
S-->>C: 422 error
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пользователь выполняет запрос с корректным токеном
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant G as Gateway
|
||||||
|
participant S as Server
|
||||||
|
|
||||||
|
C->>G: Request with access token
|
||||||
|
Note over G: Check access token
|
||||||
|
G->>S: Request with session data
|
||||||
|
alt session data changed
|
||||||
|
S->>G: Change sesstion data
|
||||||
|
G->>C: Response with new access token
|
||||||
|
else no sesstion changed
|
||||||
|
S->>C: Response
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пользователь выполняет запрос с некорректным токеном
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant G as Gateway
|
||||||
|
participant S as Server
|
||||||
|
|
||||||
|
C->>G: Request with access and refresh tokens
|
||||||
|
Note over G: Check access token
|
||||||
|
Note over G: Access token invalid
|
||||||
|
Note over G: Check refresh token
|
||||||
|
alt refresh token valid, user is active
|
||||||
|
G->>S: Request with sesstion data
|
||||||
|
S->>G: Response with/without session data
|
||||||
|
Note over G: Create new tokens with session data
|
||||||
|
G->>C: Response with new access and refresh tokens
|
||||||
|
else refresh token expared
|
||||||
|
G-->>C: 401 Token expired
|
||||||
|
else refresh token already used
|
||||||
|
Note left of G: Leak warning
|
||||||
|
G-->>C: 419 Token already used, leak warning
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `refresh token` уже был ранее использован, то это может означать что токен ранее утек, потому
|
||||||
|
пользователю надо об этом сообщить, и, возможно заблокировать все refresh token'ы, выпущенные
|
||||||
|
благодаря потенциально утекшему
|
||||||
|
|
||||||
|
### Пользователь обновляет токены
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant G as Gateway
|
||||||
|
participant S as Server
|
||||||
|
|
||||||
|
C->>G: Request with access and refresh tokens
|
||||||
|
Note over G: Check refresh token
|
||||||
|
alt refresh token valid, user is active
|
||||||
|
Note over G: Create new tokens with session data from access token
|
||||||
|
G->>C: Response with new access and refresh tokens
|
||||||
|
else refresh token expared
|
||||||
|
G-->>C: 401 Token expired
|
||||||
|
else refresh token already used
|
||||||
|
Note left of G: Leak warning
|
||||||
|
G-->>C: 419 Token already used, leak warning
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `refresh token` уже был ранее использован, то это может означать что токен ранее утек, потому
|
||||||
|
пользователю надо об этом сообщить, и, возможно заблокировать все refresh token'ы, выпущенные
|
||||||
|
благодаря потенциально утекшему
|
||||||
|
|
||||||
|
### Пользователь логинится
|
||||||
|
|
||||||
|
При переходе пользователя на страницу логина его перенаправляет на главную
|
||||||
|
|
||||||
|
### Пользователь регистрируется
|
||||||
|
|
||||||
|
Регистрация недоступна для аутентифицированного пользователя
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Настройки
|
||||||
|
|
||||||
|
## Порядок применения настроек
|
||||||
|
|
||||||
|
Настройки берутся из следующих мест (в порядке убывания приоритета):
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
Ключ для каждой настройки формируется как SCREAMING_SNAKE_CASE из полного пути к настройке, включая
|
||||||
|
scope
|
||||||
|
|
||||||
|
### .env файл
|
||||||
|
|
||||||
|
Значения берутся из `.env` файла (для `NODE_ENV` = `test` из файла `.env.test`)
|
||||||
|
|
||||||
|
### Значения по умолчанию
|
||||||
|
|
||||||
|
Значения по умолчанию указаны в коде приложения
|
||||||
|
|
||||||
|
## Настройки для тестирования
|
||||||
|
|
||||||
|
Переменные окружения для теста можно указать, подменив значение провайдера `VIRTUAL_ENV`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
})
|
||||||
|
.overrideProvider(VIRTUAL_ENV)
|
||||||
|
.useValue({ NEW_OPTION: 'someValue' })
|
||||||
|
.compile();
|
||||||
|
```
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Общие концепции
|
||||||
|
|
||||||
|
## Что такое DataStoreModule
|
||||||
|
|
||||||
|
`DataStoreModule` — это сервисный модуль в NestJS, предназначенный для хранения **внутренних данных
|
||||||
|
feature-модулей**. Он работает через концепцию **bucket** (аналог внутреннего S3 или localStorage
|
||||||
|
для конкретного модуля).
|
||||||
|
|
||||||
|
> Важно: `DataStoreModule` не имеет отношения к S3 и используется только для хранения служебных
|
||||||
|
> данных конкретного модуля.
|
||||||
|
|
||||||
|
Каждый модуль получает собственное пространство хранения, изолированное от других. Даже если два
|
||||||
|
разных модуля используют bucket с одинаковым именем, они всё равно будут независимы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Основные концепции
|
||||||
|
|
||||||
|
### Bucket
|
||||||
|
|
||||||
|
- Логическое пространство хранения внутри модуля.
|
||||||
|
- Для каждого конкурса используется отдельный bucket.
|
||||||
|
- Если требуется хранить данные, не связанные с конкурсом, используется `contestId = 0`.
|
||||||
|
- Имеет имя (например, `"test"`).
|
||||||
|
- Может использоваться для разных типов данных: настройки, результаты модерации и т.д.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
- `bucket: settings` → хранение общих настроек модуля.
|
||||||
|
- `bucket: moderationResults` → хранение результатов модерации.
|
||||||
|
|
||||||
|
### Feature-модуль
|
||||||
|
|
||||||
|
Каждый бизнес-модуль (например, `FooModule`, `BarModule`) подключает `DataStoreModule` через метод
|
||||||
|
`forFeature`. В результате внутри модуля можно работать с одним или несколькими bucket’ами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поддерживаемые сценарии
|
||||||
|
|
||||||
|
- **Работа в production** — `forRoot` подключает модуль к базе данных, а `forFeature` регистрирует
|
||||||
|
bucket’ы для конкретного feature.
|
||||||
|
- **Тестирование** — `forTesting` создаёт in-memory версию, совместимую по API с production, но
|
||||||
|
без использования БД.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Зачем использовать DataStoreModule
|
||||||
|
|
||||||
|
- Унифицированное API для хранения служебных данных модулей без необходимости создания собственных
|
||||||
|
таблиц.
|
||||||
|
- Автоматическое разделение данных между модулями и конкурсами.
|
||||||
|
- Простая интеграция в любой feature-модуль.
|
||||||
|
|
||||||
|
### Когда использовать DataStoreModule
|
||||||
|
|
||||||
|
- Временное хранение данных, не требующих сложных запросов.
|
||||||
|
- Хранение данных, не требующих частого обращения и сложных запросов.
|
||||||
|
- Хранение данных при прототипировании.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Использование
|
||||||
|
|
||||||
|
## Подключение в AppModule
|
||||||
|
|
||||||
|
В корневом модуле приложения необходимо инициализировать `DataStoreModule` с помощью метода
|
||||||
|
`forRoot`. В production используется хранилище на базе БД.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DataStoreModule } from '@src/server/data-store';
|
||||||
|
import { FooModule } from './foo/foo.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DataStoreModule.forRoot(), FooModule],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подключение bucket в feature-модуле
|
||||||
|
|
||||||
|
Каждый feature-модуль определяет, какие bucket’ы ему нужны, через метод forFeature.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// foo.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DataStoreModule } from '@src/server/data-store';
|
||||||
|
import { FooService } from './foo.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DataStoreModule.forFeature('foo', ['settings'])],
|
||||||
|
providers: [FooService],
|
||||||
|
exports: [FooService],
|
||||||
|
})
|
||||||
|
export class FooModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
В примере выше модуль `foo` получает доступ к bucket с именем `settings`.
|
||||||
|
|
||||||
|
> Важно: В feature-модуле будут доступны только bucket'ы указанные в `forFeature`. Попытка
|
||||||
|
> инжектировать не объявленный bucket приведёт к ошибке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инжектирование bucket в сервис
|
||||||
|
|
||||||
|
Для работы с bucket используется декоратор `@InjectDataStore(bucketName)`. Каждый сервис получает
|
||||||
|
свой экземпляр DataStore для конкретного bucket.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// foo.service.ts
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataStore, DataStore } from '@src/server/data-store';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FooService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataStore('settings')
|
||||||
|
private readonly bucket: DataStore<string, { value: string }>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async saveValue(key: string, value: string) {
|
||||||
|
await this.bucket.set(1, key, { value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadValue(key: string) {
|
||||||
|
return this.bucket.get(1, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Независимость bucket’ов разных модулей и конкурсов
|
||||||
|
|
||||||
|
Даже если несколько модулей используют bucket с одинаковым именем (`'test'`), они будут полностью
|
||||||
|
изолированы.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
- FooModule с bucket `test` и
|
||||||
|
- BarModule с bucket `test`
|
||||||
|
|
||||||
|
получат разные хранилища, и данные не будут пересекаться.
|
||||||
|
|
||||||
|
Тоже самое и для разных конкурсов: данные для `contestId = 1` и `contestId = 2` будут храниться
|
||||||
|
отдельно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Резюме
|
||||||
|
|
||||||
|
- В `AppModule` подключаем `DataStoreModule.forRoot(...)`.
|
||||||
|
- В каждом feature-модуле объявляем нужные bucket’ы через `forFeature(...)`.
|
||||||
|
- Доступ к bucket в сервисе осуществляется через `@InjectDataStore`.
|
||||||
|
- Bucket’ы одного имени в разных модулях изолированы друг от друга.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Тестирование
|
||||||
|
|
||||||
|
## Зачем нужен тестовый режим
|
||||||
|
|
||||||
|
Для юнит- и интеграционных тестов используется метод `forTesting`. В этом случае `DataStoreModule`
|
||||||
|
работает на **in-memory движке**:
|
||||||
|
|
||||||
|
- API полностью совпадает с production-режимом,
|
||||||
|
- данные не сохраняются между перезапусками,
|
||||||
|
- не требуется подключение к реальной базе данных.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подключение в тестах
|
||||||
|
|
||||||
|
Вместо `forRoot` в тестовом модуле подключается `forTesting`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// example.spec.ts
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { DataStoreModule } from '@src/server/data-store';
|
||||||
|
import { FooModule } from './foo/foo.module';
|
||||||
|
|
||||||
|
describe('FooModule', () => {
|
||||||
|
it('Инициализация', async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [DataStoreModule.forTesting(), FooModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
expect(() => module.createNestApplication()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Доступ к данным
|
||||||
|
|
||||||
|
Для задания данных в тестах достаточно получить бакет через сервис модуля:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('Запись данных в bucket', async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [DataStoreModule.forTesting(), FooModule, BarModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const dataStore = module.get(DataStoreService);
|
||||||
|
const barService = module.get(BarService);
|
||||||
|
|
||||||
|
const bucket = dataStore.bucket('bar', 'test'); // доступ к bucket "test" модуля "bar"
|
||||||
|
await bucket.set(1, 'key', { value: 'bar' });
|
||||||
|
|
||||||
|
expect(await barService.bucket.get(1, 'key')).toEqual({ value: 'bar' });
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'DataStore'
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Документы и изображения
|
||||||
|
|
||||||
|
## Общее устройство
|
||||||
|
|
||||||
|
Все загружаемые файлы хранятся в S3 хранилище, на сервер приложения файлы могут сохраняться только
|
||||||
|
на время обработки и только в директорию для временных файлов.
|
||||||
|
|
||||||
|
Изображение так же является документом, только с дополнительными возможностями
|
||||||
|
|
||||||
|
Индентификатор документа - это непорядковый длинный код
|
||||||
|
|
||||||
|
Отдельной системы контроля доступа нет, если пользователь знает ID файла - он может к нему
|
||||||
|
обратиться
|
||||||
|
|
||||||
|
Обновление файлов не предусмотрено, если нужно обновить - то загружается новый файл, и в нужном
|
||||||
|
месте указывается уже новый идентификатор, обновлять можно только название и описание.
|
||||||
|
|
||||||
|
## Документ
|
||||||
|
|
||||||
|
### Сохраняемая информация
|
||||||
|
|
||||||
|
В базе данных о каждом документе хранится информация.
|
||||||
|
|
||||||
|
- оригинальное имя файла (используется при скачивании)
|
||||||
|
- время загрузки файла
|
||||||
|
- пользователь, загрузивший файл
|
||||||
|
- mime тип файл
|
||||||
|
- общий тип файла, пока только документ или изображение
|
||||||
|
- так же файлу можно задать название документа и описание
|
||||||
|
- размер файла
|
||||||
|
|
||||||
|
### Доступные действия
|
||||||
|
|
||||||
|
- получение информации о файле (`GET /files/:id`)
|
||||||
|
- получение информации о нескольких файлах (`GET /files/many/:id,:id,:id`)
|
||||||
|
- просмотреть (`GET /files/:id/view`)
|
||||||
|
- скачать (`GET /files/:id/download`) отличается от **посмотреть** тем, что указываются заголовки
|
||||||
|
для скачивания
|
||||||
|
- загрузка файла (`POST /files`), возвращает структуру `IDocument` или дочернюю
|
||||||
|
|
||||||
|
## Изображение
|
||||||
|
|
||||||
|
Изображение так же являются документом, и для него доступны те же действия и возможности
|
||||||
|
|
||||||
|
### Сохраняемая информация
|
||||||
|
|
||||||
|
Дополнительно сохранятся размер изображения
|
||||||
|
|
||||||
|
### Дополнительные доступные действия
|
||||||
|
|
||||||
|
- просмотреть с определенными размерами (`GET /files/:id/view/:size`)
|
||||||
|
|
||||||
|
где `size` это Enum с заранее определенными размерами и параметрами
|
||||||
|
|
||||||
|
Для просмотра изображения используется сервер, на лету меняющий размеры изображения, для этого
|
||||||
|
сервер приложения выполняет редирект на специально подготовленный адрес
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'UI/UX'
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Вкладки
|
||||||
|
|
||||||
|
Допустим нам нужно разделить страницу `users/page.tsx` на 2 вкладки:
|
||||||
|
|
||||||
|
- Основные данные
|
||||||
|
- Организации
|
||||||
|
|
||||||
|
При открытии страницы по умолчанию должна открываться вкладка "Основные данные".
|
||||||
|
|
||||||
|
Организуем структуру следующим образом:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
users / page.tsx; // корневая страница
|
||||||
|
general / page.tsx; // страница вкладки
|
||||||
|
organizators / page.tsx; // страница вкладки
|
||||||
|
```
|
||||||
|
|
||||||
|
## Определение вкладок
|
||||||
|
|
||||||
|
Добавим файл с определением вкладок:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// users/tabs.ts
|
||||||
|
|
||||||
|
import { ITabs } from '@src/client/uikit/TabBar';
|
||||||
|
|
||||||
|
export enum UserTab {
|
||||||
|
General = 'general',
|
||||||
|
Organizators = 'organizators',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientUserTabs: ITabs<UserTab> = [
|
||||||
|
{
|
||||||
|
label: 'Основные данные',
|
||||||
|
slug: UserTab.General,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Организации',
|
||||||
|
slug: UserTab.Organizators,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
И сделать по странице для каждой вкладки, _имя страницы_ должно совпадать с _текстовым значением_
|
||||||
|
варианта enum.
|
||||||
|
|
||||||
|
## Страницы вкладок
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// users/general/page.tsx
|
||||||
|
|
||||||
|
import { clientUserTabs } from '../tabs';
|
||||||
|
|
||||||
|
export default async function UserViewPage({ params }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Card.Header />
|
||||||
|
<TabBar items={clientUserTabs} />
|
||||||
|
<Card.Body />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Компонент `TabBar` предназначен для использования непосредственно внутри `Card`. Если поместить его
|
||||||
|
в `Card.Header`, стили могут сломаться.
|
||||||
|
|
||||||
|
## Корневая страница
|
||||||
|
|
||||||
|
Корневые страницы (`users/page.tsx` в нашем примере) не поддерживаются, поскольку имя страницы
|
||||||
|
привязано к варианту enum, а корневая страница была бы пустой строкой.
|
||||||
|
|
||||||
|
Если она вам нужна, создайте страницу для редиректа:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// users/page.tsx
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
interface IProps {
|
||||||
|
params: { id: string };
|
||||||
|
}
|
||||||
|
export default function UserViewPage({ params }: IProps) {
|
||||||
|
redirect(`/admin/users/${params.id}/general`);
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# CI/CD и деплой
|
||||||
|
|
||||||
|
Система CI построена на **Gitea Actions** (`.gitea/workflows/`). Два основных сценария: ветка и тег.
|
||||||
|
|
||||||
|
## Ветки → стенды
|
||||||
|
|
||||||
|
При каждом `git push` в любую ветку (включая `master`) автоматически:
|
||||||
|
|
||||||
|
1. Собирается проект (`yarn build`)
|
||||||
|
2. Публикуются Docker-образы в registry `git.jt4d.ru/1vit/more/`:
|
||||||
|
- `web:<branch>`
|
||||||
|
- `api:<branch>`
|
||||||
|
- `ingress:<branch>`
|
||||||
|
3. Разворачивается стенд на стейджинге по адресу `http://<branch>.<STAGING_HOST>`
|
||||||
|
|
||||||
|
Каждый стенд получает изолированную базу данных. Миграции запускаются автоматически при деплое.
|
||||||
|
|
||||||
|
При удалении ветки стенд и образы удаляются автоматически.
|
||||||
|
|
||||||
|
## Теги → релизные образы
|
||||||
|
|
||||||
|
При `git push` тега (например `v1.18.0`) запускается workflow `release.yaml`:
|
||||||
|
|
||||||
|
1. Собирается проект
|
||||||
|
2. Публикуются Docker-образы с **двумя тегами** — именем тега и `latest`:
|
||||||
|
- `git.jt4d.ru/1vit/more/web:v1.18.0`
|
||||||
|
- `git.jt4d.ru/1vit/more/web:latest`
|
||||||
|
- Аналогично для `api` и `ingress`
|
||||||
|
|
||||||
|
Стенд при push тега **не создаётся**.
|
||||||
|
|
||||||
|
Чтобы выпустить релиз:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.18.0
|
||||||
|
git push origin v1.18.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Продакшн-деплой
|
||||||
|
|
||||||
|
Для деплоя на продакшн используется `docker-compose.production.yml`. Образы берутся из Gitea
|
||||||
|
registry.
|
||||||
|
|
||||||
|
Развернуть последнюю версию (`latest`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PROD_HOST=example.com \
|
||||||
|
DB_HOST=db DB_USERNAME=user DB_PASSWORD=pass DB_DATABASE=mydb \
|
||||||
|
docker compose -f docker-compose.production.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Развернуть конкретную версию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TAG=v1.18.0 PROD_HOST=example.com \
|
||||||
|
DB_HOST=db DB_USERNAME=user DB_PASSWORD=pass DB_DATABASE=mydb \
|
||||||
|
docker compose -f docker-compose.production.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
После деплоя не забыть прогнать миграции:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec 1vit_more_prod_api node_modules/.bin/typeorm migration:run -d data-source.js
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
label: 'Поставка'
|
||||||
@@ -89,6 +89,16 @@ const config: Config = {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
themes: ['@docusaurus/theme-mermaid'],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
path.resolve(__dirname, './plugins/docuservix-search/index.ts'),
|
||||||
|
{
|
||||||
|
providersModule: require.resolve('./src/search-providers'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
// Replace with your project's social card
|
// Replace with your project's social card
|
||||||
image: 'img/docusaurus-social-card.jpg',
|
image: 'img/docusaurus-social-card.jpg',
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import type { LoadContext, Plugin } from '@docusaurus/types';
|
||||||
|
|
||||||
|
interface SearchPluginOptions {
|
||||||
|
providersModule: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function docuservixSearchPlugin(
|
||||||
|
_ctx: LoadContext,
|
||||||
|
options: SearchPluginOptions,
|
||||||
|
): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'docuservix-search',
|
||||||
|
|
||||||
|
getThemePath() {
|
||||||
|
return path.resolve(__dirname, './theme');
|
||||||
|
},
|
||||||
|
|
||||||
|
getTypeScriptThemePath() {
|
||||||
|
return path.resolve(__dirname, './theme');
|
||||||
|
},
|
||||||
|
|
||||||
|
configureWebpack() {
|
||||||
|
return {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@docuservix-search/config': options.providersModule,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async contentLoaded({ actions }) {
|
||||||
|
actions.addRoute({
|
||||||
|
path: '/search',
|
||||||
|
component: '@theme/SearchPage',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
actions.addRoute({
|
||||||
|
path: '/chat',
|
||||||
|
component: '@theme/ChatPage',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useLocation } from '@docusaurus/router';
|
||||||
|
import Link from '@docusaurus/Link';
|
||||||
|
import Layout from '@theme/Layout';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { chatUrl } from '@docuservix-search/config';
|
||||||
|
|
||||||
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
|
interface Source {
|
||||||
|
file: string;
|
||||||
|
heading: string;
|
||||||
|
anchor: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
sources?: Source[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNumericPrefixes(p: string): string {
|
||||||
|
return p
|
||||||
|
.split('/')
|
||||||
|
.map((seg) => seg.replace(/^\d+-/, ''))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceToUrl(file: string, anchor: string): string {
|
||||||
|
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
||||||
|
|
||||||
|
p = stripNumericPrefixes(p);
|
||||||
|
|
||||||
|
return `/docs/${p}${anchor ? `#${anchor}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceToPath(file: string): string {
|
||||||
|
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
||||||
|
|
||||||
|
return stripNumericPrefixes(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useQuery(): string {
|
||||||
|
const location = useLocation();
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
return params.get('q') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPage(): JSX.Element {
|
||||||
|
const urlQuery = useQuery();
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initialSentRef = useRef(false);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async (content: string, history: Message[]) => {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
|
||||||
|
const userMessage: Message = { role: 'user', content };
|
||||||
|
const newHistory = [...history, userMessage];
|
||||||
|
|
||||||
|
setMessages(newHistory);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(chatUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages: newHistory }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { answer: string; sources?: Source[] } = await res.json();
|
||||||
|
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'assistant', content: data.answer, sources: data.sources },
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка при обращении к серверу');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlQuery && !initialSentRef.current) {
|
||||||
|
initialSentRef.current = true;
|
||||||
|
sendMessage(urlQuery, []);
|
||||||
|
}
|
||||||
|
}, [urlQuery, sendMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, loading]);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!loading && input.trim()) {
|
||||||
|
sendMessage(input, messages);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Чат">
|
||||||
|
<div className={styles.page}>
|
||||||
|
{urlQuery && (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
to={`/search?q=${encodeURIComponent(urlQuery)}`}
|
||||||
|
className={styles.backLink}
|
||||||
|
>
|
||||||
|
← Назад к поиску
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.messages}>
|
||||||
|
{messages.length === 0 && !loading && (
|
||||||
|
<div className={styles.empty}>Задайте вопрос...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`${styles.messageRow} ${msg.role === 'user' ? styles.messageRowUser : styles.messageRowAssistant}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.bubble} ${msg.role === 'user' ? styles.userBubble : styles.assistantBubble}`}
|
||||||
|
>
|
||||||
|
<div className={styles.bubbleContent}>{msg.content}</div>
|
||||||
|
|
||||||
|
{msg.sources && msg.sources.length > 0 && (
|
||||||
|
<div className={styles.sources}>
|
||||||
|
<div className={styles.sourcesLabel}>Источники:</div>
|
||||||
|
{msg.sources.map((src, j) => (
|
||||||
|
<Link
|
||||||
|
key={j}
|
||||||
|
to={sourceToUrl(src.file, src.anchor)}
|
||||||
|
className={styles.sourceLink}
|
||||||
|
>
|
||||||
|
{src.heading || sourceToPath(src.file)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className={`${styles.messageRow} ${styles.messageRowAssistant}`}>
|
||||||
|
<div
|
||||||
|
className={`${styles.bubble} ${styles.assistantBubble} ${styles.loadingBubble}`}
|
||||||
|
>
|
||||||
|
<span className={styles.loadingDot} />
|
||||||
|
<span className={styles.loadingDot} />
|
||||||
|
<span className={styles.loadingDot} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorRow}>
|
||||||
|
<div className={styles.errorText}>{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.inputRow}>
|
||||||
|
<textarea
|
||||||
|
className={styles.input}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Введите сообщение... (Enter — отправить, Shift+Enter — перенос)"
|
||||||
|
rows={2}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.sendBtn}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={loading || !input.trim()}
|
||||||
|
>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
.page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink {
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 2rem 0;
|
||||||
|
color: var(--ifm-color-emphasis-500);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageRow {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageRowUser {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageRowAssistant {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 75%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userBubble {
|
||||||
|
color: var(--ifm-color-primary-contrast-foreground);
|
||||||
|
background: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantBubble {
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
background: var(--ifm-background-surface-color);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbleContent {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourcesLabel {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--ifm-color-emphasis-600);
|
||||||
|
font-weight: var(--ifm-font-weight-semibold);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceLink {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBubble {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: var(--ifm-color-emphasis-400);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingDot:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingDot:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
30% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
padding: 8px 14px;
|
||||||
|
color: var(--ifm-color-danger);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--ifm-color-danger-contrast-background);
|
||||||
|
border: 1px solid var(--ifm-color-danger);
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
font-size: var(--ifm-font-size-base);
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--ifm-background-surface-color);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn {
|
||||||
|
padding: 8px 18px;
|
||||||
|
color: var(--ifm-color-primary-contrast-foreground);
|
||||||
|
font-weight: var(--ifm-font-weight-semibold);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--ifm-color-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:hover:not(:disabled) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useHistory } from '@docusaurus/router';
|
||||||
|
import searchConfig from '@docuservix-search/config';
|
||||||
|
import type { SearchResult } from '../../types';
|
||||||
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
|
const MAX_DROPDOWN_RESULTS = 10;
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export default function SearchBar(): JSX.Element {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [notices, setNotices] = useState<string[]>([]);
|
||||||
|
const [hasErrors, setHasErrors] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const runSearch = useCallback(async (q: string) => {
|
||||||
|
if (q.trim().length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setNotices([]);
|
||||||
|
setHasErrors(false);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
const timeout = searchConfig.timeout ?? 5000;
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setHasErrors(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
searchConfig.providers.map((provider) => {
|
||||||
|
const signal = controller.signal;
|
||||||
|
return provider.search(q, signal);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
const allResults: SearchResult[] = [];
|
||||||
|
const allNotices: string[] = [];
|
||||||
|
let anyError = false;
|
||||||
|
|
||||||
|
for (const outcome of settled) {
|
||||||
|
if (outcome.status === 'fulfilled') {
|
||||||
|
allResults.push(...outcome.value.results);
|
||||||
|
if (outcome.value.notice) {
|
||||||
|
allNotices.push(outcome.value.notice);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
anyError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.sort((a, b) => b.relevance - a.relevance);
|
||||||
|
const top = allResults.slice(0, MAX_DROPDOWN_RESULTS);
|
||||||
|
|
||||||
|
setResults(top);
|
||||||
|
setNotices(allNotices);
|
||||||
|
setHasErrors(anyError);
|
||||||
|
setOpen(true);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setQuery(value);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => runSearch(value), DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!open) {
|
||||||
|
if (e.key === 'Enter' && query.trim()) {
|
||||||
|
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.max(prev - 1, -1));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||||
|
window.location.href = results[selectedIndex].url;
|
||||||
|
} else if (query.trim()) {
|
||||||
|
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={styles.container}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="search"
|
||||||
|
placeholder="Поиск..."
|
||||||
|
value={query}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label="Поиск по документации"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
role="combobox"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<span
|
||||||
|
className={styles.spinner}
|
||||||
|
aria-label="Загрузка..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={styles.dropdown}
|
||||||
|
role="listbox"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{hasErrors && (
|
||||||
|
<div className={styles.errorBadge}>Некоторые источники недоступны</div>
|
||||||
|
)}
|
||||||
|
{results.length === 0 && !loading && (
|
||||||
|
<div className={styles.empty}>Ничего не найдено</div>
|
||||||
|
)}
|
||||||
|
{results.map((result, i) => (
|
||||||
|
<a
|
||||||
|
key={`${result.url}-${i}`}
|
||||||
|
href={result.url}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === selectedIndex}
|
||||||
|
className={
|
||||||
|
i === selectedIndex
|
||||||
|
? `${styles.item} ${styles.selectedItem}`
|
||||||
|
: styles.item
|
||||||
|
}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<div className={styles.itemHeading}>{result.title}</div>
|
||||||
|
<div className={styles.itemFile}>
|
||||||
|
<span
|
||||||
|
className={styles.badge}
|
||||||
|
data-type={result.type}
|
||||||
|
>
|
||||||
|
{result.type}
|
||||||
|
</span>
|
||||||
|
{result.path}
|
||||||
|
<span className={styles.itemScore}>
|
||||||
|
{Math.round(result.relevance * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.content && (
|
||||||
|
<div className={styles.itemContent}>{result.content}</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{notices.map((notice, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={styles.notice}
|
||||||
|
>
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 200px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 32px 0 12px;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
font-size: var(--ifm-font-size-base);
|
||||||
|
background: var(--ifm-background-surface-color);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
width 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
width: 280px;
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--ifm-color-emphasis-300);
|
||||||
|
border-top-color: var(--ifm-color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--ifm-background-surface-color);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
box-shadow: var(--ifm-global-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--ifm-color-emphasis-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedItem {
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
|
background: var(--ifm-color-emphasis-100);
|
||||||
|
outline: 2px solid var(--ifm-color-primary-light);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemHeading {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-weight: var(--ifm-font-weight-semibold);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
color: var(--ifm-color-emphasis-700);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--ifm-color-emphasis-200);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemFile {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--ifm-color-emphasis-600);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemScore {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ifm-color-emphasis-500);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemContent {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ifm-color-emphasis-700);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--ifm-color-emphasis-600);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 8px 14px;
|
||||||
|
color: var(--ifm-color-info-dark);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--ifm-color-info-contrast-background);
|
||||||
|
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBadge {
|
||||||
|
padding: 8px 14px;
|
||||||
|
color: var(--ifm-color-warning-dark);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--ifm-color-warning-contrast-background);
|
||||||
|
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useHistory, useLocation } from '@docusaurus/router';
|
||||||
|
import searchConfig from '@docuservix-search/config';
|
||||||
|
import Link from '@docusaurus/Link';
|
||||||
|
import Layout from '@theme/Layout';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { SearchResult } from '../../types';
|
||||||
|
|
||||||
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
|
const MAX_RESULTS = 25;
|
||||||
|
|
||||||
|
function useQuery(): string {
|
||||||
|
const location = useLocation();
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
return params.get('q') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage(): JSX.Element {
|
||||||
|
const urlQuery = useQuery();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState(urlQuery);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [notices, setNotices] = useState<string[]>([]);
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const runSearch = useCallback(async (q: string) => {
|
||||||
|
if (!q.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
setNotices([]);
|
||||||
|
setErrors([]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
const timeout = searchConfig.timeout ?? 5000;
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setErrors([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
searchConfig.providers.map((provider) => provider.search(q, controller.signal)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResults: SearchResult[] = [];
|
||||||
|
const allNotices: string[] = [];
|
||||||
|
const allErrors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < settled.length; i++) {
|
||||||
|
const outcome = settled[i];
|
||||||
|
const provider = searchConfig.providers[i];
|
||||||
|
|
||||||
|
if (outcome.status === 'fulfilled') {
|
||||||
|
allResults.push(...outcome.value.results);
|
||||||
|
|
||||||
|
if (outcome.value.notice) {
|
||||||
|
allNotices.push(outcome.value.notice);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allErrors.push(`${provider.name}: недоступен`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.sort((a, b) => b.relevance - a.relevance);
|
||||||
|
|
||||||
|
setResults(allResults.slice(0, MAX_RESULTS));
|
||||||
|
setNotices(allNotices);
|
||||||
|
setErrors(allErrors);
|
||||||
|
setActiveFilter('all');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(urlQuery);
|
||||||
|
runSearch(urlQuery);
|
||||||
|
}, [urlQuery, runSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && inputValue.trim()) {
|
||||||
|
history.push(`/search?q=${encodeURIComponent(inputValue.trim())}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceTypes = ['all', ...Array.from(new Set(results.map((r) => r.type)))];
|
||||||
|
|
||||||
|
const filteredResults =
|
||||||
|
activeFilter === 'all' ? results : results.filter((r) => r.type === activeFilter);
|
||||||
|
|
||||||
|
const typeLabel: Record<string, string> = {
|
||||||
|
all: 'Все',
|
||||||
|
docs: 'Docs',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title={urlQuery ? `Поиск: ${urlQuery}` : 'Поиск'}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<h1 className={styles.heading}>Поиск</h1>
|
||||||
|
<input
|
||||||
|
className={styles.searchInput}
|
||||||
|
type="search"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
placeholder="Введите запрос и нажмите Enter..."
|
||||||
|
aria-label="Поисковый запрос"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{urlQuery && (
|
||||||
|
<Link
|
||||||
|
to={`/chat?q=${encodeURIComponent(urlQuery)}`}
|
||||||
|
className={styles.chatLink}
|
||||||
|
>
|
||||||
|
Спросить ИИ →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="alert alert--warning"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{errors.map((err, i) => (
|
||||||
|
<div key={i}>{err}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notices.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="alert alert--info"
|
||||||
|
role="note"
|
||||||
|
>
|
||||||
|
{notices.map((notice, i) => (
|
||||||
|
<div key={i}>{notice}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <div className={styles.loadingText}>Поиск...</div>}
|
||||||
|
|
||||||
|
{!loading && urlQuery && (
|
||||||
|
<>
|
||||||
|
{sourceTypes.length > 2 && (
|
||||||
|
<div
|
||||||
|
className={styles.filters}
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{sourceTypes.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeFilter === type}
|
||||||
|
className={
|
||||||
|
activeFilter === type
|
||||||
|
? `${styles.filterBtn} ${styles.filterBtnActive}`
|
||||||
|
: styles.filterBtn
|
||||||
|
}
|
||||||
|
onClick={() => setActiveFilter(type)}
|
||||||
|
>
|
||||||
|
{typeLabel[type] ?? type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredResults.length === 0 ? (
|
||||||
|
<div className={styles.empty}>Ничего не найдено</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.resultList}>
|
||||||
|
{filteredResults.map((result, i) => (
|
||||||
|
<a
|
||||||
|
key={`${result.url}-${i}`}
|
||||||
|
href={result.url}
|
||||||
|
className={styles.resultItem}
|
||||||
|
>
|
||||||
|
<div className={styles.resultTitle}>{result.title}</div>
|
||||||
|
<div className={styles.resultMeta}>
|
||||||
|
<span
|
||||||
|
className={styles.badge}
|
||||||
|
data-type={result.type}
|
||||||
|
>
|
||||||
|
{typeLabel[result.type] ?? result.type}
|
||||||
|
</span>
|
||||||
|
<span className={styles.resultPath}>{result.path}</span>
|
||||||
|
{result.anchor && (
|
||||||
|
<span className={styles.resultAnchor}>
|
||||||
|
#{result.anchor}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.resultScore}>
|
||||||
|
{Math.round(result.relevance * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.content && (
|
||||||
|
<div className={styles.resultContent}>
|
||||||
|
{result.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0 16px;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
font-size: var(--ifm-font-size-base);
|
||||||
|
background: var(--ifm-background-surface-color);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput:focus {
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText {
|
||||||
|
padding: 1rem 0;
|
||||||
|
color: var(--ifm-color-emphasis-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBtn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--ifm-background-surface-color);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBtn:hover {
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBtnActive {
|
||||||
|
color: var(--ifm-color-primary-contrast-foreground);
|
||||||
|
background: var(--ifm-color-primary);
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 2rem 0;
|
||||||
|
color: var(--ifm-color-emphasis-600);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultItem {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultItem:hover {
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--ifm-color-emphasis-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultTitle {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: var(--ifm-font-weight-semibold);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultMeta {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultPath {
|
||||||
|
color: var(--ifm-color-emphasis-600);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultAnchor {
|
||||||
|
color: var(--ifm-color-emphasis-500);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultScore {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ifm-color-emphasis-500);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultContent {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ifm-color-emphasis-700);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatLink {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--ifm-color-primary);
|
||||||
|
border-radius: var(--ifm-global-radius);
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatLink:hover {
|
||||||
|
color: var(--ifm-color-primary-contrast-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
color: var(--ifm-color-emphasis-700);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--ifm-color-emphasis-200);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
anchor?: string;
|
||||||
|
type: string;
|
||||||
|
relevance: number; // 0–1
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchProviderResponse {
|
||||||
|
results: SearchResult[];
|
||||||
|
notice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
timeout?: number;
|
||||||
|
search: (query: string, signal: AbortSignal) => Promise<SearchProviderResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchConfig {
|
||||||
|
timeout: number;
|
||||||
|
providers: SearchProvider[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type {
|
||||||
|
SearchConfig,
|
||||||
|
SearchProvider,
|
||||||
|
SearchResult,
|
||||||
|
} from '../plugins/docuservix-search/types';
|
||||||
|
|
||||||
|
interface RawResult {
|
||||||
|
score: number;
|
||||||
|
file: string;
|
||||||
|
heading: string;
|
||||||
|
anchor?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNumericPrefixes(p: string): string {
|
||||||
|
return p
|
||||||
|
.split('/')
|
||||||
|
.map((seg) => seg.replace(/^\d+-/, ''))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultToUrl(file: string, anchor: string): string {
|
||||||
|
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
||||||
|
|
||||||
|
p = stripNumericPrefixes(p);
|
||||||
|
|
||||||
|
return `/docs/${p}#${anchor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultToDisplayPath(file: string): string {
|
||||||
|
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
||||||
|
|
||||||
|
return stripNumericPrefixes(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawToSearchResult(raw: RawResult): SearchResult {
|
||||||
|
return {
|
||||||
|
title: raw.heading,
|
||||||
|
content: raw.content,
|
||||||
|
path: resultToDisplayPath(raw.file),
|
||||||
|
anchor: raw.anchor,
|
||||||
|
type: 'docs',
|
||||||
|
relevance: raw.score,
|
||||||
|
url: resultToUrl(raw.file, raw.anchor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const docsProvider: SearchProvider = {
|
||||||
|
id: 'docs',
|
||||||
|
name: 'Документация',
|
||||||
|
timeout: 5000,
|
||||||
|
async search(query, signal) {
|
||||||
|
const res = await fetch(
|
||||||
|
`http://localhost:8080/1vit/more/v1/search?q=${encodeURIComponent(query)}&limit=25`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { results: RawResult[] } = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: (data.results ?? []).map(rawToSearchResult),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchConfig: SearchConfig = {
|
||||||
|
timeout: 5000,
|
||||||
|
providers: [docsProvider],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default searchConfig;
|
||||||
|
|
||||||
|
export const chatUrl = 'http://localhost:8080/1vit/more/v1/chat';
|
||||||
Reference in New Issue
Block a user