Compare commits
2 Commits
next
..
f5181ef8a0
| Author | SHA1 | Date | |
|---|---|---|---|
| f5181ef8a0 | |||
| da37322232 |
+3
-3
@@ -1,4 +1,4 @@
|
||||
title: 'Title example'
|
||||
title: "Title example"
|
||||
project:
|
||||
org: 'example'
|
||||
repo: 'example'
|
||||
org: "example"
|
||||
repo: "example"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
yarn lint-staged
|
||||
@@ -1,7 +0,0 @@
|
||||
dist
|
||||
coverage
|
||||
*.d.ts
|
||||
node_modules
|
||||
.idea
|
||||
logs
|
||||
report
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"singleAttributePerLine": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.json"
|
||||
],
|
||||
"options": {
|
||||
"printWidth": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.md",
|
||||
"*.mdx"
|
||||
],
|
||||
"options": {
|
||||
"proseWrap": "always"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this
|
||||
repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Docuservix docs — шаблон документационного сайта на Docusaurus 3.10 (React 19, TypeScript 6).
|
||||
Конфигурация сайта читается из `.docuservix.yml` (title, project.org, project.repo, dirs). Локаль —
|
||||
русский (`ru`).
|
||||
|
||||
## Commands
|
||||
|
||||
Используется **yarn**.
|
||||
|
||||
- `yarn start` — dev-сервер
|
||||
- `yarn build` — production-сборка в `build/`
|
||||
- `yarn typecheck` — проверка типов (tsc)
|
||||
- `yarn prettier:check` — проверка форматирования
|
||||
- `yarn prettier:fix` — автоформатирование
|
||||
|
||||
## Architecture
|
||||
|
||||
- `docusaurus.config.ts` — главный конфиг; читает `.docuservix.yml` через `js-yaml`
|
||||
- `src/pages/` — кастомные страницы (index.tsx — главная)
|
||||
- `src/css/custom.css` — глобальные CSS-переменные (`--ifm-*`)
|
||||
- `docs/` — Markdown/MDX-документация
|
||||
- `blog/` — блог (опционально, включается через `dirs.blog` в `.docuservix.yml`)
|
||||
- Mermaid-диаграммы включены (`@docusaurus/theme-mermaid`)
|
||||
- Docusaurus future v4 compatibility flag включён
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prettier: 4 пробела, single quotes, trailing commas, `printWidth: 100`,
|
||||
`singleAttributePerLine: true`
|
||||
- JSON: `printWidth: 10` (каждое свойство на отдельной строке)
|
||||
- Markdown/MDX: `proseWrap: always`
|
||||
- Husky + lint-staged: prettier запускается автоматически на pre-commit
|
||||
- CSS Modules (`*.module.css`) с camelCase именами классов
|
||||
- **Без default export** в shared/UI компонентах; default export допустим только для Docusaurus
|
||||
route-компонентов (page components)
|
||||
|
||||
## Environment
|
||||
|
||||
- Node >= 20
|
||||
- Env vars: `DOCUSERVIX_URL` (production URL), `DOCUSERVIX_ON_BROKEN_LINKS` (override onBrokenLinks)
|
||||
- Gitea instance: `git.jt4d.ru`
|
||||
@@ -14,8 +14,7 @@ yarn
|
||||
yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are
|
||||
reflected live without having to restart the server.
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -23,5 +22,4 @@ reflected live without having to restart the server.
|
||||
yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static
|
||||
contents hosting service.
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
+97
-105
@@ -2,120 +2,112 @@ name: 'Docusaurus Deploy'
|
||||
description: 'Builds Docusaurus docs from repo and deploys to S3'
|
||||
|
||||
inputs:
|
||||
docs-path:
|
||||
description: 'Path to docs directory in calling repo'
|
||||
default: 'docs'
|
||||
on-broken-links:
|
||||
description: 'Behavior on broken links: throw, warn, or ignore'
|
||||
default: 'throw'
|
||||
prefix:
|
||||
description: 'Prefix for S3 path'
|
||||
default: ''
|
||||
docs-path:
|
||||
description: 'Path to docs directory in calling repo'
|
||||
default: 'docs'
|
||||
on-broken-links:
|
||||
description: 'Behavior on broken links: throw, warn, or ignore'
|
||||
default: 'throw'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Compute target URL
|
||||
shell: bash
|
||||
run: |
|
||||
REF="${{ github.head_ref || github.ref_name }}"
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
ORG="${{ github.repository_owner }}"
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Compute target URL
|
||||
shell: bash
|
||||
run: |
|
||||
REF="${{ github.head_ref || github.ref_name }}"
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
ORG="${{ github.repository_owner }}"
|
||||
|
||||
if [[ "$REF" == "main" || "$REF" == "master" ]]; then
|
||||
URL="http://${REPO}.${ORG}.jt4d-wiki.ru.net"
|
||||
S3_PATH="${ORG}.${REPO}"
|
||||
else
|
||||
URL="http://${REF}.${REPO}.${ORG}.jt4d-wiki.ru.net"
|
||||
S3_PATH="${ORG}.${REPO}.${REF}"
|
||||
fi
|
||||
if [[ "$REF" == "main" || "$REF" == "master" ]]; then
|
||||
URL="http://${REPO}.${ORG}.jt4d-wiki.ru.net"
|
||||
S3_PATH="${ORG}.${REPO}"
|
||||
else
|
||||
URL="http://${REF}.${REPO}.${ORG}.jt4d-wiki.ru.net"
|
||||
S3_PATH="${ORG}.${REPO}.${REF}"
|
||||
fi
|
||||
|
||||
PREFIX="${{ inputs.prefix }}"
|
||||
if [[ -n "$PREFIX" ]]; then
|
||||
S3_PATH="${PREFIX}/${S3_PATH}"
|
||||
fi
|
||||
echo "TARGET_URL=$URL" >> $GITHUB_ENV
|
||||
echo "S3_PATH=$S3_PATH" >> $GITHUB_ENV
|
||||
|
||||
echo "TARGET_URL=$URL" >> $GITHUB_ENV
|
||||
echo "S3_PATH=$S3_PATH" >> $GITHUB_ENV
|
||||
- name: Set docs status pending
|
||||
shell: bash
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"state": "pending",
|
||||
"context": "Docs",
|
||||
"description": "building",
|
||||
"target_url": "${{ env.TARGET_URL }}"
|
||||
}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||
|
||||
- name: Set docs status pending
|
||||
shell: bash
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"state": "pending",
|
||||
"context": "Docs",
|
||||
"description": "building",
|
||||
"target_url": "${{ env.TARGET_URL }}"
|
||||
}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||
- name: Copy docs into Docusaurus
|
||||
shell: bash
|
||||
run: |
|
||||
DOCUSAURUS_DIR="${{ github.action_path }}"
|
||||
rm -rf "${DOCUSAURUS_DIR}/docs"
|
||||
cp -r "${{ inputs.docs-path }}" "${DOCUSAURUS_DIR}/docs"
|
||||
cp "${{ github.workspace }}/.docuservix.yml" "${DOCUSAURUS_DIR}"
|
||||
|
||||
- name: Copy docs into Docusaurus
|
||||
shell: bash
|
||||
run: |
|
||||
DOCUSAURUS_DIR="${{ github.action_path }}"
|
||||
rm -rf "${DOCUSAURUS_DIR}/docs"
|
||||
cp -r "${{ inputs.docs-path }}" "${DOCUSAURUS_DIR}/docs"
|
||||
cp "${{ github.workspace }}/.docuservix.yml" "${DOCUSAURUS_DIR}"
|
||||
- name: Prepare docs
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
run: node scripts/prepare-docs.mjs
|
||||
|
||||
- name: Prepare docs
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
run: node scripts/prepare-docs.mjs
|
||||
- name: Install Docusaurus dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install Docusaurus dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build docs
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
env:
|
||||
DOCUSERVIX_ON_BROKEN_LINKS: ${{ inputs.on-broken-links }}
|
||||
DOCUSERVIX_URL: ${{ env.TARGET_URL }}
|
||||
run: yarn docusaurus build --out-dir ${{ github.workspace }}/generated-docs
|
||||
|
||||
- name: Build docs
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
env:
|
||||
DOCUSERVIX_ON_BROKEN_LINKS: ${{ inputs.on-broken-links }}
|
||||
DOCUSERVIX_URL: ${{ env.TARGET_URL }}
|
||||
run: yarn docusaurus build --out-dir ${{ github.workspace }}/generated-docs
|
||||
- name: Upload to S3
|
||||
shell: bash
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.DOCUSERVIX_S3_ACCESS }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ vars.DOCUSERVIX_S3_SECRET }}
|
||||
run: |
|
||||
aws s3 sync generated-docs/ \
|
||||
s3://${{ vars.DOCUSERVIX_S3_BUCKET }}/${{ env.S3_PATH }}\
|
||||
--endpoint-url ${{ vars.DOCUSERVIX_S3_URL }} \
|
||||
--acl public-read \
|
||||
--delete
|
||||
|
||||
- name: Upload to S3
|
||||
shell: bash
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.DOCUSERVIX_S3_ACCESS }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ vars.DOCUSERVIX_S3_SECRET }}
|
||||
run: |
|
||||
aws s3 sync generated-docs/ \
|
||||
s3://${{ vars.DOCUSERVIX_S3_BUCKET }}/${{ env.S3_PATH }}\
|
||||
--endpoint-url ${{ vars.DOCUSERVIX_S3_URL }} \
|
||||
--acl public-read \
|
||||
--delete
|
||||
- name: Set docs status success
|
||||
if: success()
|
||||
shell: bash
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"state": "success",
|
||||
"context": "Docs",
|
||||
"description": "deployed",
|
||||
"target_url": "${{ env.TARGET_URL }}"
|
||||
}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||
|
||||
- name: Set docs status success
|
||||
if: success()
|
||||
shell: bash
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"state": "success",
|
||||
"context": "Docs",
|
||||
"description": "deployed",
|
||||
"target_url": "${{ env.TARGET_URL }}"
|
||||
}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||
|
||||
- name: Set docs status failure
|
||||
if: failure()
|
||||
shell: bash
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"state": "failure",
|
||||
"context": "Docs",
|
||||
"description": "build failed",
|
||||
"target_url": "${{ env.TARGET_URL }}"
|
||||
}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||
- name: Set docs status failure
|
||||
if: failure()
|
||||
shell: bash
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"state": "failure",
|
||||
"context": "Docs",
|
||||
"description": "build failed",
|
||||
"target_url": "${{ env.TARGET_URL }}"
|
||||
}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||
@@ -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: 'Поставка'
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Добро пожаловать в Docuservix!
|
||||
|
||||
Вам надо настроить публикацию документации по инструкции в https://git.jt4d.ru/jt4d/docuservix
|
||||
Вам надо настроить публикацию документации по инструкции в https://git.jt4d.ru/jt4d/docuservix
|
||||
+33
-23
@@ -1,12 +1,9 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
import type { NavbarItem } from '@docusaurus/theme-common';
|
||||
import type { Config } from '@docusaurus/types';
|
||||
import yaml from 'js-yaml';
|
||||
import { themes as prismThemes } from 'prism-react-renderer';
|
||||
|
||||
import docuservix from './plugins/docuservix';
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
import type {Config} from '@docusaurus/types';
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
import type {NavbarItem} from '@docusaurus/theme-common'
|
||||
|
||||
interface DocsConfig {
|
||||
title: string;
|
||||
@@ -16,17 +13,24 @@ interface DocsConfig {
|
||||
|
||||
const docsConfig = yaml.load(fs.readFileSync('./.docuservix.yml', 'utf8')) as DocsConfig;
|
||||
|
||||
const { title } = docsConfig;
|
||||
const {
|
||||
title,
|
||||
} = docsConfig
|
||||
|
||||
const url = process.env.DOCUSERVIX_URL || 'http://example.com';
|
||||
const url = process.env.DOCUSERVIX_URL;
|
||||
|
||||
const { org, repo } = docsConfig.project;
|
||||
const {
|
||||
org,
|
||||
repo
|
||||
} = docsConfig.project
|
||||
|
||||
const { docs: _docsDir = 'docs', blog: blogDir } = docsConfig.dirs || {};
|
||||
const {
|
||||
docs: docsDir = 'docs',
|
||||
blog: blogDir
|
||||
} = docsConfig.dirs || {}
|
||||
|
||||
const giteaUrl = 'https://git.jt4d.ru';
|
||||
const onBrokenLinks =
|
||||
(process.env.DOCUSERVIX_ON_BROKEN_LINKS as Config['onBrokenLinks']) || 'throw';
|
||||
const onBrokenLinks = (process.env.DOCUSERVIX_ON_BROKEN_LINKS as Config['onBrokenLinks']) || 'throw';
|
||||
|
||||
const config: Config = {
|
||||
title,
|
||||
@@ -35,7 +39,6 @@ const config: Config = {
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
},
|
||||
plugins: [docuservix()],
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
|
||||
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
||||
@@ -86,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: {
|
||||
// Replace with your project's social card
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
@@ -104,13 +117,11 @@ const config: Config = {
|
||||
label: 'Документация',
|
||||
position: 'left',
|
||||
},
|
||||
blogDir
|
||||
? {
|
||||
to: '/blog',
|
||||
label: 'Блог',
|
||||
position: 'left',
|
||||
}
|
||||
: undefined,
|
||||
blogDir ? {
|
||||
to: '/blog',
|
||||
label: 'Блог',
|
||||
position: 'left'
|
||||
} : undefined,
|
||||
{
|
||||
href: `${giteaUrl}/${org}/${repo}`,
|
||||
label: 'Gitea',
|
||||
@@ -120,8 +131,7 @@ const config: Config = {
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
copyright:
|
||||
'Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.',
|
||||
copyright: `Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { fixupPluginRules } from '@eslint/compat';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import js from '@eslint/js';
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import etc from 'eslint-plugin-etc';
|
||||
import _import from 'eslint-plugin-import';
|
||||
import noOnlyTests from 'eslint-plugin-no-only-tests';
|
||||
import noSkipTests from 'eslint-plugin-no-skip-tests';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import globals from 'globals';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/.eslintrc.js',
|
||||
'**/node_modules',
|
||||
'**/coverage',
|
||||
'**/build',
|
||||
'**/.docusaurus',
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*',
|
||||
],
|
||||
},
|
||||
...compat.extends(
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'prettier',
|
||||
'plugin:eslint-comments/recommended',
|
||||
),
|
||||
{
|
||||
plugins: {
|
||||
import: fixupPluginRules(_import),
|
||||
react,
|
||||
'react-hooks': fixupPluginRules(reactHooks),
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
etc,
|
||||
'no-only-tests': noOnlyTests,
|
||||
'no-skip-tests': noSkipTests,
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
ecmaVersion: 6,
|
||||
sourceType: 'module',
|
||||
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
modules: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.ts', '.tsx', '.json'],
|
||||
},
|
||||
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
|
||||
curly: ['error', 'all'],
|
||||
'max-params': 'off',
|
||||
|
||||
'no-console': [
|
||||
'error',
|
||||
{
|
||||
allow: ['warn', 'error'],
|
||||
},
|
||||
],
|
||||
|
||||
'no-warning-comments': [
|
||||
'error',
|
||||
{
|
||||
terms: ['fixme'],
|
||||
location: 'anywhere',
|
||||
},
|
||||
],
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
'space-before-blocks': 'error',
|
||||
|
||||
'padding-line-between-statements': [
|
||||
'error',
|
||||
{
|
||||
blankLine: 'always',
|
||||
prev: '*',
|
||||
next: ['break', 'continue', 'return'],
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
prev: ['const', 'let'],
|
||||
next: '*',
|
||||
},
|
||||
{
|
||||
blankLine: 'any',
|
||||
prev: ['const', 'let'],
|
||||
next: ['const', 'let'],
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
prev: 'directive',
|
||||
next: '*',
|
||||
},
|
||||
{
|
||||
blankLine: 'any',
|
||||
prev: 'directive',
|
||||
next: 'directive',
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
prev: 'block-like',
|
||||
next: '*',
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
prev: '*',
|
||||
next: 'block-like',
|
||||
},
|
||||
],
|
||||
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: 'react,bem-css-modules',
|
||||
group: 'builtin',
|
||||
position: 'before',
|
||||
},
|
||||
{
|
||||
pattern: '@docuservix/**',
|
||||
group: 'internal',
|
||||
},
|
||||
],
|
||||
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
'newlines-between': 'always',
|
||||
groups: ['builtin', 'external', 'internal', 'parent', ['sibling', 'index']],
|
||||
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
'react/no-direct-mutation-state': 'error',
|
||||
'react/no-deprecated': 'error',
|
||||
'react/no-unsafe': 'error',
|
||||
'react/jsx-uses-vars': 'error',
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-curly-brace-presence': ['error', 'never'],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
quotes: [
|
||||
'error',
|
||||
'single',
|
||||
{
|
||||
avoidEscape: true,
|
||||
},
|
||||
],
|
||||
|
||||
'quote-props': ['warn', 'as-needed'],
|
||||
|
||||
'@typescript-eslint/no-explicit-any': [
|
||||
'warn',
|
||||
{
|
||||
ignoreRestArgs: true,
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/member-ordering': [
|
||||
'error',
|
||||
{
|
||||
default: [
|
||||
'public-static-field',
|
||||
'protected-static-field',
|
||||
'private-static-field',
|
||||
|
||||
'public-instance-field',
|
||||
'protected-instance-field',
|
||||
'private-instance-field',
|
||||
|
||||
'constructor',
|
||||
|
||||
'public-instance-method',
|
||||
'protected-instance-method',
|
||||
'private-instance-method',
|
||||
|
||||
'public-static-method',
|
||||
'protected-static-method',
|
||||
'private-static-method',
|
||||
|
||||
'signature',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'etc/prefer-interface': [
|
||||
'warn',
|
||||
{
|
||||
allowLocal: true,
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-ignore': 'allow-with-description',
|
||||
'ts-nocheck': 'allow-with-description',
|
||||
'ts-check': false,
|
||||
'ts-expect-error': false,
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-empty-interface': 'warn',
|
||||
'@typescript-eslint/no-empty-function': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
// todo изучить и включить
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
|
||||
// todo изучить и включить
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@nestjs/swagger',
|
||||
importNames: ['PartialType'],
|
||||
message:
|
||||
"Please import 'PartialType' from '@src/server/common/nest' instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-bootstrap',
|
||||
importNames: [
|
||||
'Card',
|
||||
'CardHeader',
|
||||
'CardBody',
|
||||
'CardFooter',
|
||||
'Row',
|
||||
'Col',
|
||||
'Modal',
|
||||
],
|
||||
message: "Please use project's components with same name",
|
||||
},
|
||||
{
|
||||
name: 'react-bootstrap/Modal',
|
||||
message: "Please use project's components with same name",
|
||||
},
|
||||
{
|
||||
name: '@nestjs/common',
|
||||
importNames: ['Logger'],
|
||||
message: "Please import 'Logger' from '@src/server/logger' instead.",
|
||||
},
|
||||
{
|
||||
name: 'nestjs-pino',
|
||||
importNames: ['Logger', 'PinoLogger'],
|
||||
message: "Please import 'Logger' from '@src/server/logger' instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
vars: 'all',
|
||||
args: 'after-used',
|
||||
ignoreRestSiblings: true,
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
'eslint-comments/require-description': [
|
||||
'error',
|
||||
{
|
||||
ignore: ['eslint-enable'],
|
||||
},
|
||||
],
|
||||
|
||||
'eslint-comments/disable-enable-pair': [
|
||||
'error',
|
||||
{
|
||||
allowWholeFile: true,
|
||||
},
|
||||
],
|
||||
|
||||
complexity: ['warn', 10],
|
||||
eqeqeq: ['error'],
|
||||
'func-style': ['warn', 'declaration'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.{js,jsx,ts,tsx}'],
|
||||
|
||||
rules: {
|
||||
'no-only-tests/no-only-tests': 'error',
|
||||
'no-skip-tests/no-skip-tests': 'warn',
|
||||
|
||||
'no-console': [
|
||||
'warn',
|
||||
{
|
||||
allow: ['warn', 'error'],
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
+50
-86
@@ -1,88 +1,52 @@
|
||||
{
|
||||
"name": "docusaurus",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "docusaurus build",
|
||||
"clear": "docusaurus clear",
|
||||
"deploy": "docusaurus deploy",
|
||||
"docusaurus": "docusaurus",
|
||||
"eslint:check": "yarn eslint",
|
||||
"eslint:fix": "yarn eslint --fix",
|
||||
"lint": "run-s eslint:fix prettier:fix",
|
||||
"lint:check": "run-s eslint:check prettier:check",
|
||||
"prepare": "husky",
|
||||
"prettier:check": "prettier --check \"**/*.{ts,tsx,js,mjs,json,yml,yaml,md,mdx}\"",
|
||||
"prettier:fix": "prettier --write \"**/*.{ts,tsx,js,mjs,json,yml,yaml,md,mdx}\"",
|
||||
"serve": "docusaurus serve",
|
||||
"start": "docusaurus start",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"typecheck": "tsc",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"write-translations": "docusaurus write-translations"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{json,ts,tsx,js,jsx,js,mjs,md,mdx,yaml,yml}": "prettier --write",
|
||||
"{src,e2e}/**/*.{ts,tsx}": "eslint --quiet --fix"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.10.1",
|
||||
"@docusaurus/faster": "3.10.1",
|
||||
"@docusaurus/preset-classic": "3.10.1",
|
||||
"@docusaurus/theme-mermaid": "3.10.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"bem-css-modules": "^1.4.3",
|
||||
"clsx": "^2.0.0",
|
||||
"js-yaml": "^4.2.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.10.1",
|
||||
"@docusaurus/tsconfig": "3.10.1",
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@eslint/compat": "^1.1.1",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
"eslint-plugin-etc": "^2.0.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-no-only-tests": "^3.1.0",
|
||||
"eslint-plugin-no-skip-tests": "^1.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.0.1",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.7",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.8.4",
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
"name": "docusaurus",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.10.1",
|
||||
"@docusaurus/faster": "3.10.1",
|
||||
"@docusaurus/preset-classic": "3.10.1",
|
||||
"@docusaurus/theme-mermaid": "3.10.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"js-yaml": "^4.2.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.10.1",
|
||||
"@docusaurus/tsconfig": "3.10.1",
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
.MD p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.MD p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.MD code {
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.MD pre {
|
||||
margin: 0.5em 0;
|
||||
padding: 0.75em;
|
||||
overflow-x: auto;
|
||||
border-radius: 6px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.MD pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.MD ul,
|
||||
.MD ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.MD table {
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.MD th,
|
||||
.MD td {
|
||||
padding: 0.4em 0.75em;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.MD th {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.MD blockquote {
|
||||
margin: 0.5em 0;
|
||||
padding: 0.25em 1em;
|
||||
border-left: 3px solid var(--ifm-color-emphasis-300);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import styles from './MD.module.css';
|
||||
|
||||
interface MDProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function MD({ children }: MDProps): ReactNode {
|
||||
return (
|
||||
<div className={styles.MD}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { MD } from './MD';
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useOptions } from '@docuservix/hooks/useOptions';
|
||||
import { IChat, IChatMessage, IChatSource } from '@docuservix/models/chat';
|
||||
|
||||
interface UseChatResult {
|
||||
dialog: IChat;
|
||||
typing: boolean;
|
||||
statusMessage?: string;
|
||||
sendMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
function useQuery(): string {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
return params.get('q') ?? '';
|
||||
}
|
||||
|
||||
export function useChat(): UseChatResult {
|
||||
const chatEndpoint = useOptions().api + '/chat';
|
||||
const urlQuery = useQuery();
|
||||
|
||||
const [messages, setMessages] = useState<IChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const initialSentRef = useRef(false);
|
||||
const messagesEndRef = useRef(messages);
|
||||
|
||||
messagesEndRef.current = messages;
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
const content = text.trim();
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: IChatMessage = { role: 'user', content };
|
||||
const newHistory = [...messagesEndRef.current, userMessage];
|
||||
|
||||
setMessages(newHistory);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(chatEndpoint, {
|
||||
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?: IChatSource[] } = 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);
|
||||
}
|
||||
},
|
||||
[chatEndpoint],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlQuery && !initialSentRef.current) {
|
||||
initialSentRef.current = true;
|
||||
sendMessage(urlQuery);
|
||||
}
|
||||
}, [urlQuery, sendMessage]);
|
||||
|
||||
return {
|
||||
dialog: { messages },
|
||||
typing: loading,
|
||||
statusMessage: error ?? undefined,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { usePluginData } from '@docusaurus/useGlobalData';
|
||||
|
||||
import { DocuservixOptions } from '@docuservix/models/docuservix';
|
||||
|
||||
export function useOptions(): DocuservixOptions {
|
||||
return usePluginData('docuservix') as DocuservixOptions;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import path from 'path';
|
||||
|
||||
import type { LoadContext, Plugin } from '@docusaurus/types';
|
||||
|
||||
import { DocuservixOptions } from '@docuservix/models/docuservix';
|
||||
|
||||
export default function docuservix(options: Partial<DocuservixOptions> = {}) {
|
||||
const { api = '/api' } = options;
|
||||
|
||||
return function pluginDocuservix(_context: LoadContext): Plugin {
|
||||
return {
|
||||
name: 'docuservix',
|
||||
|
||||
configureWebpack() {
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@docuservix': path.resolve(__dirname),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async contentLoaded({ actions }) {
|
||||
const { addRoute, setGlobalData } = actions;
|
||||
|
||||
setGlobalData({
|
||||
api,
|
||||
});
|
||||
|
||||
addRoute({
|
||||
path: '/chat',
|
||||
component: '@docuservix/pages/chat',
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface IChat {
|
||||
messages: IChatMessage[];
|
||||
}
|
||||
|
||||
export interface IChatSource {
|
||||
file: string;
|
||||
heading: string;
|
||||
anchor: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface IChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: IChatSource[];
|
||||
}
|
||||
|
||||
function stripNumericPrefixes(p: string): string {
|
||||
return p
|
||||
.split('/')
|
||||
.map((seg) => seg.replace(/^\d+-/, ''))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
export function sourceToUrl(file: string, anchor: string): string {
|
||||
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
||||
|
||||
p = stripNumericPrefixes(p);
|
||||
|
||||
return `/docs/${p}${anchor ? `#${anchor}` : ''}`;
|
||||
}
|
||||
|
||||
export function sourceToPath(file: string): string {
|
||||
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
||||
|
||||
return stripNumericPrefixes(p);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface DocuservixOptions {
|
||||
api?: string;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import Layout from '@theme/Layout';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { useChat } from '@docuservix/hooks/useChat';
|
||||
import { Chat } from '@docuservix/widgets/chat';
|
||||
|
||||
export function ChatPage(): ReactNode {
|
||||
const { dialog, typing, statusMessage, sendMessage } = useChat();
|
||||
|
||||
return (
|
||||
<Layout title="Чат">
|
||||
<main className="container margin-vert--lg">
|
||||
<Chat
|
||||
dialog={dialog}
|
||||
typing={typing}
|
||||
statusMessage={statusMessage}
|
||||
onSend={sendMessage}
|
||||
/>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ChatPage } from './ChatPage';
|
||||
|
||||
export default ChatPage;
|
||||
@@ -1,24 +0,0 @@
|
||||
.Chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Chat__statusMessage {
|
||||
font-size: .65rem;
|
||||
color: #666;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: .75rem 2.5rem;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.Chat__statusMessage i {
|
||||
margin-right: .25rem;
|
||||
color: #667eea;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import block from 'bem-css-modules';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { IChat } from '@docuservix/models/chat';
|
||||
|
||||
import styles from './Chat.module.css';
|
||||
import { Header } from './Header';
|
||||
import { Input } from './Input';
|
||||
import { Messages } from './Messages';
|
||||
|
||||
const b = block(styles, 'Chat');
|
||||
|
||||
interface ChatProps {
|
||||
dialog: IChat;
|
||||
typing?: boolean;
|
||||
statusMessage?: string;
|
||||
onSend?: (text: string) => void;
|
||||
}
|
||||
|
||||
export function Chat({ dialog, typing, statusMessage, onSend }: ChatProps): ReactNode {
|
||||
const { messages } = dialog;
|
||||
|
||||
return (
|
||||
<div className={b()}>
|
||||
<Header />
|
||||
<Messages
|
||||
messages={messages}
|
||||
typing={typing}
|
||||
/>
|
||||
{statusMessage && <div className={b('statusMessage')}>{statusMessage}</div>}
|
||||
<Input
|
||||
disabled={typing}
|
||||
onSend={onSend}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.Header__avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Header__info h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.Header__info p {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import block from 'bem-css-modules';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import styles from './Header.module.css';
|
||||
import { RobotIcon } from './icons';
|
||||
|
||||
const b = block(styles, 'Header');
|
||||
|
||||
export function Header(): ReactNode {
|
||||
return (
|
||||
<div className={b()}>
|
||||
<div className={b('avatar')}>
|
||||
<RobotIcon />
|
||||
</div>
|
||||
<div className={b('info')}>
|
||||
<h3>AI Assistant</h3>
|
||||
<p>Ready to help</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
.Input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.Input__field {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-surface-color);
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
resize: none;
|
||||
min-height: 3.25rem;
|
||||
max-height: 8rem;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.Input__field:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.Input__field:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.Input__send {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 1.125rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.Input__send:hover:not(:disabled) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.Input__send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import block from 'bem-css-modules';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
|
||||
import { PaperPlaneIcon } from './icons';
|
||||
import styles from './Input.module.css';
|
||||
|
||||
const b = block(styles, 'Input');
|
||||
|
||||
interface InputProps {
|
||||
disabled?: boolean;
|
||||
onSend?: (text: string) => void;
|
||||
}
|
||||
|
||||
export function Input({ disabled, onSend }: InputProps): ReactNode {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
|
||||
if (!text || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInput('');
|
||||
onSend?.(text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={b()}>
|
||||
<textarea
|
||||
className={b('field')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message here..."
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
className={b('send')}
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !input.trim()}
|
||||
>
|
||||
<PaperPlaneIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.Message {
|
||||
max-width: 90%;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.Message_role_assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.Message_role_user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.Message__content {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.Message_role_assistant .Message__content {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
color: var(--ifm-font-color-base);
|
||||
border-top-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.Message_role_user .Message__content {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.Message__sources {
|
||||
margin-top: 8px;
|
||||
padding: 8px 1rem 0;
|
||||
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.Message__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;
|
||||
}
|
||||
|
||||
.Message__sourceLink {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.Message__sourceLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.Message {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Link from '@docusaurus/Link';
|
||||
import block from 'bem-css-modules';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { MD } from '@docuservix/entities/markdown';
|
||||
|
||||
import { IChatSource, sourceToPath, sourceToUrl } from '@docuservix/models/chat';
|
||||
|
||||
import styles from './Message.module.css';
|
||||
|
||||
const b = block(styles, 'Message');
|
||||
|
||||
interface MessageProps {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: IChatSource[];
|
||||
}
|
||||
|
||||
export function Message({ role, content, sources }: MessageProps): ReactNode {
|
||||
return (
|
||||
<div className={b({ role })}>
|
||||
<div className={b('content')}>
|
||||
<MD>{content}</MD>
|
||||
</div>
|
||||
|
||||
{sources && sources.length > 0 && (
|
||||
<div className={b('sources')}>
|
||||
<div className={b('sourcesLabel')}>Источники:</div>
|
||||
{sources.map((src, j) => (
|
||||
<Link
|
||||
key={j}
|
||||
to={sourceToUrl(src.file, src.anchor)}
|
||||
className={b('sourceLink')}
|
||||
>
|
||||
{src.heading || sourceToPath(src.file)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
.Messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.Messages__typing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.Messages__typingIndicator {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border-radius: 1.25rem;
|
||||
border-top-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.Messages__typingIndicator span {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background: var(--ifm-color-emphasis-500);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.Messages__typingIndicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.Messages__typingIndicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.Messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.Messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.Messages::-webkit-scrollbar-thumb {
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.Messages::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.Messages {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import block from 'bem-css-modules';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { IChatMessage } from '@docuservix/models/chat';
|
||||
|
||||
import { Message } from './Message';
|
||||
import styles from './Messages.module.css';
|
||||
|
||||
const b = block(styles, 'Messages');
|
||||
|
||||
interface MessagesProps {
|
||||
messages: IChatMessage[];
|
||||
typing?: boolean;
|
||||
}
|
||||
|
||||
export function Messages({ messages, typing }: MessagesProps): ReactNode {
|
||||
return (
|
||||
<div className={b()}>
|
||||
{messages.map((msg, i) => (
|
||||
<Message
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
sources={msg.sources}
|
||||
/>
|
||||
))}
|
||||
|
||||
{typing && (
|
||||
<div className={b('typing')}>
|
||||
<div className={b('typingIndicator')}>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
export function RobotIcon(): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135" />
|
||||
<path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaperPlaneIcon(): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Chat } from './Chat';
|
||||
+14
-26
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable no-console -- logs required */
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -11,33 +9,23 @@ pinIndexToTop();
|
||||
* Гарантирует наличие sidebar_position: 0 в front matter файла index.md
|
||||
*/
|
||||
function pinIndexToTop() {
|
||||
const indexPath = path.join(docsDir, 'index.md');
|
||||
const indexPath = path.join(docsDir, 'index.md');
|
||||
if (!fs.existsSync(indexPath)) return;
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
return;
|
||||
}
|
||||
let content = fs.readFileSync(indexPath, 'utf8');
|
||||
|
||||
let content = fs.readFileSync(indexPath, 'utf8');
|
||||
if (content.startsWith('---\n')) {
|
||||
const endIdx = content.indexOf('\n---\n', 4);
|
||||
if (endIdx === -1) return;
|
||||
|
||||
if (content.startsWith('---\n')) {
|
||||
const endIdx = content.indexOf('\n---\n', 4);
|
||||
const frontMatter = content.slice(4, endIdx);
|
||||
if (/^sidebar_position\s*:/m.test(frontMatter)) return;
|
||||
|
||||
if (endIdx === -1) {
|
||||
return;
|
||||
}
|
||||
content = '---\nsidebar_position: 0\n' + frontMatter + '\n---\n' + content.slice(endIdx + 5);
|
||||
} else {
|
||||
content = '---\nsidebar_position: 0\n---\n' + content;
|
||||
}
|
||||
|
||||
const frontMatter = content.slice(4, endIdx);
|
||||
|
||||
if (/^sidebar_position\s*:/m.test(frontMatter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
content =
|
||||
'---\nsidebar_position: 0\n' + frontMatter + '\n---\n' + content.slice(endIdx + 5);
|
||||
} else {
|
||||
content = '---\nsidebar_position: 0\n---\n' + content;
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexPath, content);
|
||||
console.log('prepare-docs: pinned index.md to sidebar top');
|
||||
fs.writeFileSync(indexPath, content);
|
||||
console.log('prepare-docs: pinned index.md to sidebar top');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user