Compare commits
2 Commits
c35ae8a8ee
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f7302317 | |||
| f6436d0c83 |
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
title: "Title example"
|
title: 'Title example'
|
||||||
project:
|
project:
|
||||||
org: "example"
|
org: 'example'
|
||||||
repo: "example"
|
repo: 'example'
|
||||||
|
|||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
yarn lint-staged
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.d.ts
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
logs
|
||||||
|
report
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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,7 +14,8 @@ yarn
|
|||||||
yarn start
|
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
|
## Build
|
||||||
|
|
||||||
@@ -22,4 +23,5 @@ This command starts a local development server and opens up a browser window. Mo
|
|||||||
yarn build
|
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.
|
||||||
|
|||||||
+104
-104
@@ -2,120 +2,120 @@ name: 'Docusaurus Deploy'
|
|||||||
description: 'Builds Docusaurus docs from repo and deploys to S3'
|
description: 'Builds Docusaurus docs from repo and deploys to S3'
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
docs-path:
|
docs-path:
|
||||||
description: 'Path to docs directory in calling repo'
|
description: 'Path to docs directory in calling repo'
|
||||||
default: 'docs'
|
default: 'docs'
|
||||||
on-broken-links:
|
on-broken-links:
|
||||||
description: 'Behavior on broken links: throw, warn, or ignore'
|
description: 'Behavior on broken links: throw, warn, or ignore'
|
||||||
default: 'throw'
|
default: 'throw'
|
||||||
prefix:
|
prefix:
|
||||||
description: 'Prefix for S3 path'
|
description: 'Prefix for S3 path'
|
||||||
default: ''
|
default: ''
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Compute target URL
|
- name: Compute target URL
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
REF="${{ github.head_ref || github.ref_name }}"
|
REF="${{ github.head_ref || github.ref_name }}"
|
||||||
REPO="${{ github.event.repository.name }}"
|
REPO="${{ github.event.repository.name }}"
|
||||||
ORG="${{ github.repository_owner }}"
|
ORG="${{ github.repository_owner }}"
|
||||||
|
|
||||||
if [[ "$REF" == "main" || "$REF" == "master" ]]; then
|
if [[ "$REF" == "main" || "$REF" == "master" ]]; then
|
||||||
URL="http://${REPO}.${ORG}.jt4d-wiki.ru.net"
|
URL="http://${REPO}.${ORG}.jt4d-wiki.ru.net"
|
||||||
S3_PATH="${ORG}.${REPO}"
|
S3_PATH="${ORG}.${REPO}"
|
||||||
else
|
else
|
||||||
URL="http://${REF}.${REPO}.${ORG}.jt4d-wiki.ru.net"
|
URL="http://${REF}.${REPO}.${ORG}.jt4d-wiki.ru.net"
|
||||||
S3_PATH="${ORG}.${REPO}.${REF}"
|
S3_PATH="${ORG}.${REPO}.${REF}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PREFIX="${{ inputs.prefix }}"
|
PREFIX="${{ inputs.prefix }}"
|
||||||
if [[ -n "$PREFIX" ]]; then
|
if [[ -n "$PREFIX" ]]; then
|
||||||
S3_PATH="${PREFIX}/${S3_PATH}"
|
S3_PATH="${PREFIX}/${S3_PATH}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "TARGET_URL=$URL" >> $GITHUB_ENV
|
echo "TARGET_URL=$URL" >> $GITHUB_ENV
|
||||||
echo "S3_PATH=$S3_PATH" >> $GITHUB_ENV
|
echo "S3_PATH=$S3_PATH" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set docs status pending
|
- name: Set docs status pending
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${{ github.token }}" \
|
-H "Authorization: token ${{ github.token }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"state": "pending",
|
"state": "pending",
|
||||||
"context": "Docs",
|
"context": "Docs",
|
||||||
"description": "building",
|
"description": "building",
|
||||||
"target_url": "${{ env.TARGET_URL }}"
|
"target_url": "${{ env.TARGET_URL }}"
|
||||||
}' \
|
}' \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||||
|
|
||||||
- name: Copy docs into Docusaurus
|
- name: Copy docs into Docusaurus
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
DOCUSAURUS_DIR="${{ github.action_path }}"
|
DOCUSAURUS_DIR="${{ github.action_path }}"
|
||||||
rm -rf "${DOCUSAURUS_DIR}/docs"
|
rm -rf "${DOCUSAURUS_DIR}/docs"
|
||||||
cp -r "${{ inputs.docs-path }}" "${DOCUSAURUS_DIR}/docs"
|
cp -r "${{ inputs.docs-path }}" "${DOCUSAURUS_DIR}/docs"
|
||||||
cp "${{ github.workspace }}/.docuservix.yml" "${DOCUSAURUS_DIR}"
|
cp "${{ github.workspace }}/.docuservix.yml" "${DOCUSAURUS_DIR}"
|
||||||
|
|
||||||
- name: Prepare docs
|
- name: Prepare docs
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ${{ github.action_path }}
|
working-directory: ${{ github.action_path }}
|
||||||
run: node scripts/prepare-docs.mjs
|
run: node scripts/prepare-docs.mjs
|
||||||
|
|
||||||
- name: Install Docusaurus dependencies
|
- name: Install Docusaurus dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ${{ github.action_path }}
|
working-directory: ${{ github.action_path }}
|
||||||
run: yarn install --frozen-lockfile
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build docs
|
- name: Build docs
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ${{ github.action_path }}
|
working-directory: ${{ github.action_path }}
|
||||||
env:
|
env:
|
||||||
DOCUSERVIX_ON_BROKEN_LINKS: ${{ inputs.on-broken-links }}
|
DOCUSERVIX_ON_BROKEN_LINKS: ${{ inputs.on-broken-links }}
|
||||||
DOCUSERVIX_URL: ${{ env.TARGET_URL }}
|
DOCUSERVIX_URL: ${{ env.TARGET_URL }}
|
||||||
run: yarn docusaurus build --out-dir ${{ github.workspace }}/generated-docs
|
run: yarn docusaurus build --out-dir ${{ github.workspace }}/generated-docs
|
||||||
|
|
||||||
- name: Upload to S3
|
- name: Upload to S3
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ vars.DOCUSERVIX_S3_ACCESS }}
|
AWS_ACCESS_KEY_ID: ${{ vars.DOCUSERVIX_S3_ACCESS }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ vars.DOCUSERVIX_S3_SECRET }}
|
AWS_SECRET_ACCESS_KEY: ${{ vars.DOCUSERVIX_S3_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
aws s3 sync generated-docs/ \
|
aws s3 sync generated-docs/ \
|
||||||
s3://${{ vars.DOCUSERVIX_S3_BUCKET }}/${{ env.S3_PATH }}\
|
s3://${{ vars.DOCUSERVIX_S3_BUCKET }}/${{ env.S3_PATH }}\
|
||||||
--endpoint-url ${{ vars.DOCUSERVIX_S3_URL }} \
|
--endpoint-url ${{ vars.DOCUSERVIX_S3_URL }} \
|
||||||
--acl public-read \
|
--acl public-read \
|
||||||
--delete
|
--delete
|
||||||
|
|
||||||
- name: Set docs status success
|
- name: Set docs status success
|
||||||
if: success()
|
if: success()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${{ github.token }}" \
|
-H "Authorization: token ${{ github.token }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"state": "success",
|
"state": "success",
|
||||||
"context": "Docs",
|
"context": "Docs",
|
||||||
"description": "deployed",
|
"description": "deployed",
|
||||||
"target_url": "${{ env.TARGET_URL }}"
|
"target_url": "${{ env.TARGET_URL }}"
|
||||||
}' \
|
}' \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||||
|
|
||||||
- name: Set docs status failure
|
- name: Set docs status failure
|
||||||
if: failure()
|
if: failure()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${{ github.token }}" \
|
-H "Authorization: token ${{ github.token }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"state": "failure",
|
"state": "failure",
|
||||||
"context": "Docs",
|
"context": "Docs",
|
||||||
"description": "build failed",
|
"description": "build failed",
|
||||||
"target_url": "${{ env.TARGET_URL }}"
|
"target_url": "${{ env.TARGET_URL }}"
|
||||||
}' \
|
}' \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# Общее описание
|
|
||||||
|
|
||||||
Платформа для грантов предназначена для упрощения и автоматизации процесса распределения грантовых
|
|
||||||
средств. Она предоставляет удобный инструмент как для организаций, желающих вложиться в общественно
|
|
||||||
полезные проекты, так и для участников, нуждающихся в финансировании. Основные задачи платформы:
|
|
||||||
|
|
||||||
- **Прозрачность:** Платформа обеспечивает открытый процесс подачи заявок, их оценки и выбора
|
|
||||||
победителей.
|
|
||||||
|
|
||||||
- **Доступность:** Участники могут легко находить актуальные конкурсы и подавать заявки, а
|
|
||||||
компании — запускать собственные грантовые программы.
|
|
||||||
|
|
||||||
- **Эффективность:** Процесс отбора и реализации проектов автоматизирован, что экономит время
|
|
||||||
участников и организаторов.
|
|
||||||
|
|
||||||
## Основные пользователи платформы:
|
|
||||||
|
|
||||||
- **Компании:** Организуют конкурсы, финансируют проекты.
|
|
||||||
|
|
||||||
- **Участники:** Представляют свои проекты, чтобы получить финансирование. При подаче заявки
|
|
||||||
участники выбирают категорию, что влияет на структуру заявки и условия участия.
|
|
||||||
|
|
||||||
- **Модераторы:** Проверяют заявки, следят за корректностью информации.
|
|
||||||
|
|
||||||
- **Эксперты:** Оценивают проекты, прошедшие модерацию, с учетом их категории.
|
|
||||||
|
|
||||||
- **Комиссия:** Принимает окончательные решения о финансировании.
|
|
||||||
|
|
||||||
- **Администраторы:** Управляют платформой и обеспечивают её стабильную работу, включая создание и
|
|
||||||
настройку категорий.
|
|
||||||
|
|
||||||
## Основные этапы работы платформы:
|
|
||||||
|
|
||||||
1. Регистрация пользователей (компании, организации, волонтеры).
|
|
||||||
|
|
||||||
2. Публикация конкурсов
|
|
||||||
|
|
||||||
3. Сбор заявок. На этапе подачи заявки пользователь выбирает категорию, что определяет дальнейшую
|
|
||||||
структуру заявки и условия участия.
|
|
||||||
|
|
||||||
4. Модерация заявок и внесение исправлений участниками.
|
|
||||||
|
|
||||||
5. Оценка проектов экспертами с учетом специфики категории.
|
|
||||||
|
|
||||||
6. Выбор победителей комиссией.
|
|
||||||
|
|
||||||
7. Реализация проектов победителями, включая публикацию отчетов.
|
|
||||||
|
|
||||||
Платформа также включает инструменты для контроля исполнения проектов, анализа их результатов и
|
|
||||||
формирования отчетности. Это позволяет компаниям видеть, как эффективно используются их средства, а
|
|
||||||
участникам — демонстрировать успешность своих инициатив.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Основные функции платформы
|
|
||||||
|
|
||||||
## Для участников
|
|
||||||
|
|
||||||
- **Поиск конкурсов:** Участники могут просматривать доступные конкурсы.
|
|
||||||
|
|
||||||
- **Подача заявок:** Удобный интерфейс для создания и отправки заявок на участие в конкурсе.
|
|
||||||
|
|
||||||
- **Управление проектами:** Ведение проектов, отслеживание их статуса и предоставление отчетности.
|
|
||||||
|
|
||||||
- **Финансовая отчетность:** Предоставление данных о расходах по проекту через встроенные формы.
|
|
||||||
|
|
||||||
## Для организаторов
|
|
||||||
|
|
||||||
- **Создание конкурсов:** Возможность настроить параметры конкурса (цели, требования, сроки и
|
|
||||||
др.).
|
|
||||||
|
|
||||||
- **Управление заявками:** Просмотр всех поступивших заявок, их модерация и одобрение.
|
|
||||||
|
|
||||||
- **Работа с экспертами:** Назначение экспертов для оценки заявок, управление их доступами.
|
|
||||||
|
|
||||||
- **Мониторинг реализации проектов:** Контроль выполнения грантовых обязательств победителей.
|
|
||||||
|
|
||||||
## Для экспертов
|
|
||||||
|
|
||||||
- **Оценка заявок:** Просмотр и оценивание заявок участников по заданным критериям. Возможность
|
|
||||||
оставлять комментарии и замечания.
|
|
||||||
|
|
||||||
## Для модераторов
|
|
||||||
|
|
||||||
- **Проверка заявок:** Проверка заявок участников на соответствие требованиям конкурса.
|
|
||||||
|
|
||||||
- **Коммуникация с участниками:** Возможность запрашивать доработки заявок и уведомлять участников
|
|
||||||
об изменениях статуса.
|
|
||||||
|
|
||||||
## Для администраторов платформы
|
|
||||||
|
|
||||||
- **Управление пользователями:** Добавление, редактирование и удаление пользователей.
|
|
||||||
|
|
||||||
- **Мониторинг активности:** Анализ активности на платформе, выявление проблемных мест.
|
|
||||||
|
|
||||||
- **Настройка глобальных параметров:** Конфигурация технических аспектов работы системы.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Роли пользователей
|
|
||||||
|
|
||||||
## Глобальные роли
|
|
||||||
|
|
||||||
Эти роли может выдать только `root` пользователь:
|
|
||||||
|
|
||||||
- `root` Максимальный уровень доступа. С этим уровнем доступа можно обходить некоторые системы
|
|
||||||
сайта. Предполагается, что этот пользователь знает, что делает.
|
|
||||||
- `support` Имеет доступ к большому количеству данных и функций, может влиять на них, но с
|
|
||||||
ограничениями в критически важных областях.
|
|
||||||
|
|
||||||
## Сотрудники конкурса
|
|
||||||
|
|
||||||
Эти роли могут быть выданы как на отдельные конкурсы, так и глобально на все конкурсы:
|
|
||||||
|
|
||||||
- `admin` Администратор конкурса. Может настраивать конкурс и сотрудников, имеет права `moderator`
|
|
||||||
- `moderator` Просмотр и модерирование проектов, просмотр статистики конкурса, возможность
|
|
||||||
блокировки пользователей.
|
|
||||||
- `expert` Оценка проектов без доступа к настройкам или модерации.
|
|
||||||
|
|
||||||
## Сотрудники организатора
|
|
||||||
|
|
||||||
Эти роли выдаются только на конкретные организации:
|
|
||||||
|
|
||||||
- `orgAdminRole` Руководитель организации. Может настраивать организацию и её сотрудников.
|
|
||||||
- `orgMemberRole` Ответственный за заполнение проектов и отчетов.
|
|
||||||
- `orgReportedRole` Публикация новостей от имени организации.
|
|
||||||
- `orgReaderRole` Подписчик, имеет только права на просмотр информации.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Разделы сайта
|
|
||||||
|
|
||||||
## Кабинет участника
|
|
||||||
|
|
||||||
Доступен всем авторизованным пользователям.
|
|
||||||
|
|
||||||
Предназначен для сотрудников организаций, участвующих в конкурсах.
|
|
||||||
|
|
||||||
### Точка входа `/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]` Просмотр и редактирование проекта
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Процесс работы'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Настройка и подключение лендингов'
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Создание конкурса
|
|
||||||
|
|
||||||
Доступно создание конкурса с нуля или путём копирования ранее проведённого конкурса.
|
|
||||||
|
|
||||||
## Создание черновика конкурса
|
|
||||||
|
|
||||||
Создание черновика конкурса требует указания следующих сведений:
|
|
||||||
|
|
||||||
- **Название конкурса**
|
|
||||||
- **Администратор конкурса**
|
|
||||||
|
|
||||||
После создания черновика назначенный администратор получит доступ к панели управления конкурса и к
|
|
||||||
мастеру настройки.
|
|
||||||
|
|
||||||
## Мастер настройки конкурса
|
|
||||||
|
|
||||||
Мастер настройки обеспечивает последовательность действий и визуальный контроль прогресса:
|
|
||||||
|
|
||||||
- **Статус модулей**. Индикация того, какие модули уже настроены, а какие остаются в работе.
|
|
||||||
- **Краткая информация**. Краткое описание каждого модуля и текущий статус настройки.
|
|
||||||
- **Переход к настройке**. Быстрый переход к настройке конкретного модуля.
|
|
||||||
|
|
||||||
> **Важно:** Запуск конкурса становится доступным только после того, как все модули будут отмечены
|
|
||||||
> как "настроенные". После старта мастер настройки завершает работу и закрывается.
|
|
||||||
|
|
||||||
## Варианты создания конкурса
|
|
||||||
|
|
||||||
### Настройка с нуля
|
|
||||||
|
|
||||||
Для некоторых модулей мастер может предоставлять инструменты для быстрого первого заполнения,
|
|
||||||
позволяя задать основные параметры и оставить детали на более поздний этап. Если такие инструменты
|
|
||||||
недоступны, настройка осуществляется стандартными средствами модуля.
|
|
||||||
|
|
||||||
### Копирование существующего конкурса
|
|
||||||
|
|
||||||
Для некоторых модулей мастер может предложить выбор элементов для переноса из ранее проведённого
|
|
||||||
конкурса. Это ускоряет первоначальную конфигурацию. Если инструмент копирования недоступен,
|
|
||||||
применяются обычные средства модуля для настройки существующего конкурса.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Категории проектов в конкурсах
|
|
||||||
|
|
||||||
Категории позволяют учитывать специфику проектов и их участников. Пользователи сами выбирают
|
|
||||||
категорию, в которой хотят участвовать и заполняют соответствующую форму заявки.
|
|
||||||
|
|
||||||
#### Примеры категорий и их отличий:
|
|
||||||
|
|
||||||
- **Волонтерские проекты:** Ограничения по срокам проведения, максимальная сумма грантов,
|
|
||||||
упрощенные формы заявки.
|
|
||||||
|
|
||||||
- **Школьные проекты:** Участниками могут быть только группы школьников, дополнительные требования
|
|
||||||
для школ.
|
|
||||||
|
|
||||||
- **Проекты организаций:** Более сложные формы заявки, большие максимальные суммы грантов.
|
|
||||||
|
|
||||||
### Функционал категорий
|
|
||||||
|
|
||||||
- **Создание и управление категориями:**
|
|
||||||
|
|
||||||
- Только администраторы конкурса могут управлять категориями.
|
|
||||||
- Администраторы могут добавлять, изменять и удалять категории.
|
|
||||||
|
|
||||||
- **Параметры категории:**
|
|
||||||
|
|
||||||
- Условия участия (например, ограничения по типу участников, возрасту, региону).
|
|
||||||
- Формы заявки (разделы, поля, инструкции).
|
|
||||||
- Параметры конкурсов (сроки подачи, суммы грантов).
|
|
||||||
- И так далее.
|
|
||||||
|
|
||||||
- **Гибкость изменения:** Возможность адаптации категорий для конкретных конкурсов.
|
|
||||||
|
|
||||||
### Технические особенности
|
|
||||||
|
|
||||||
- У категорий нет версионирования.
|
|
||||||
- Изменять категории можно в любой момент до завершения конкурса.
|
|
||||||
- Категория относится только к одному конкурсу.
|
|
||||||
- Участники должны видеть только public категории.
|
|
||||||
- Участник не может сменить категории после ее выбора.
|
|
||||||
- Проект может относиться только к одной категории.
|
|
||||||
|
|
||||||
### Отчетность по категориям
|
|
||||||
|
|
||||||
В разделе аналитики предоставляется возможность фильтровать заявки и результаты по категориям,
|
|
||||||
анализировать успешность проектов в каждой категории и их соответствие целям конкурсов.
|
|
||||||
|
|
||||||
### Доступность категорий
|
|
||||||
|
|
||||||
Для управления доступностью категории для создания проектов с этой категорией используется поле
|
|
||||||
`access`.
|
|
||||||
|
|
||||||
Любую категорию можно включить или выключить для создания нового проекта с этой категорией.
|
|
||||||
|
|
||||||
- `enabled: true` Участники могут создавать проекты в этой категории.
|
|
||||||
- `enabled: false` Участники не могут создавать проекты в этой категории.
|
|
||||||
|
|
||||||
Флаг `manualControl` управляет способом изменения поля `enabled`:
|
|
||||||
|
|
||||||
- `true` - включается и выключается только вручную.
|
|
||||||
- `false` - включается и выключается автоматически, на основании других настроек категории.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Функционал категорий
|
|
||||||
|
|
||||||
Категории предоставляют гибкость настройки условий для разных типов проектов. Каждая категория может
|
|
||||||
изменять следующие параметры:
|
|
||||||
|
|
||||||
- Максимальная сумма гранта
|
|
||||||
- Максимальный фонд гранта
|
|
||||||
- Условия участия (текст и документ)
|
|
||||||
- Требования к участникам (текст и документ)
|
|
||||||
- Сроки подачи заявок для данной категории
|
|
||||||
- Направления проектов
|
|
||||||
- Форма заявки
|
|
||||||
- Список документов, которые могут потребоваться участникам от организатора конкурса
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Категории проектов'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Конкурсы'
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Общая схема взаимодействия модулей
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
Contest ||--o{ Project : "Проект участвует в конкурсе"
|
|
||||||
|
|
||||||
Organizator ||--o{ Contest : "Организатор может создать конкурс"
|
|
||||||
Organizator ||--o{ Project : "Организатор может создать проект"
|
|
||||||
|
|
||||||
Project ||--o{ Event: "Мероприятие проходит в рамках проекта"
|
|
||||||
Project ||--o{ News: "У проекта есть новости"
|
|
||||||
Project ||--o{ Application: "Данные проекта изменяются через заявки"
|
|
||||||
```
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Организации и волонтеры'
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Организаторы проектов
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
- В случае волонтеров названием организатора будет являться ФИО руководителя (предварительно)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Общая последовательность работы над проектом
|
|
||||||
|
|
||||||
- Любое изменение проекта проходит через создание заявки на изменение проекта
|
|
||||||
- Изменение в проект вносится только после одобрения заявки модератором
|
|
||||||
- При создании нового проекта, проект создается со статусом `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
|
|
||||||
```
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Спецификация
|
|
||||||
|
|
||||||
## Исходные требования
|
|
||||||
|
|
||||||
- Все изменения в проект (не черновик) вносятся только после прохождения модерации
|
|
||||||
- Вся история изменения проекта должна храниться столько же, сколько и сам проект
|
|
||||||
|
|
||||||
## Проект
|
|
||||||
|
|
||||||
```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` не видна никому, кроме участников организации
|
|
||||||
и модераторов
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Ревизии заявки
|
|
||||||
|
|
||||||
Сама ревизия хранит только отличия от предыдущей ревизии, кто и когда внес изменения. При
|
|
||||||
необходимости можно просмотреть историю изменений заявки и откатиться к предыдущим версиям.
|
|
||||||
|
|
||||||
## Исходные требования
|
|
||||||
|
|
||||||
- Все изменения изменения заявки должны быть сохранены
|
|
||||||
- Исключить потерю данных при редактировании заявки несколькими пользователями
|
|
||||||
- Возможность отката к предыдущим версиям заявки
|
|
||||||
- Возможность просмотра истории изменений заявки
|
|
||||||
- Возможность формирования графика интенсивности работы над заявкой
|
|
||||||
|
|
||||||
## Реализация
|
|
||||||
|
|
||||||
Над заявкой могут производится различные действия, которые влияют на ее состояние и данные. При этом
|
|
||||||
разные действия могут вносить разные изменения в данные заявки. Потому в ревизии есть отдельные поля
|
|
||||||
`action` и `payloadType`, которые позволяют определить тип действия и тип изменений в данных заявки.
|
|
||||||
|
|
||||||
Тип данных в поле `payload` зависит от значения поля `payloadType` и должно обрабатываться
|
|
||||||
соответствующим образом.
|
|
||||||
|
|
||||||
Такая структура позволяет легко добавлять новые типы действий и изменений в заявке.
|
|
||||||
|
|
||||||
## Заявка
|
|
||||||
|
|
||||||
Для получения актуальной версии заявки необходимо применить все ревизии в порядке их создания.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Проекты и заявки'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Заявки (Requests)'
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# Участники проектов
|
|
||||||
|
|
||||||
```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 | - | + | - |
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Конкурсы, проекты и заявки
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
- Заявка превращается в конкурс (предположительно) после апрува модератором
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Заявки
|
|
||||||
|
|
||||||
## Состояния заявки
|
|
||||||
|
|
||||||
```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` только если это
|
|
||||||
заявка на обновление проекта
|
|
||||||
- Отклоненная заявка не может быть подана повторно
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Введение
|
|
||||||
|
|
||||||
Данный модуль обеспечивает экспертную оценку заявок. Он автоматизирует распределение заявок между
|
|
||||||
экспертами, сбор и анализ оценок, а также формирование итогового рейтинга проектов.
|
|
||||||
|
|
||||||
## Общий процесс оценки проектов
|
|
||||||
|
|
||||||
1. Организатор настраивает форму оценки и критерии.
|
|
||||||
2. Система назначает экспертов на проекты.
|
|
||||||
3. Эксперты заполняют форму оценки.
|
|
||||||
4. Итоговые оценки агрегируются для формирования рейтинга.
|
|
||||||
|
|
||||||
## Роли участников
|
|
||||||
|
|
||||||
- **Организатор конкурса** – настраивает критерии, назначает экспертов, контролирует процесс.
|
|
||||||
- **Эксперт** – оценивает проекты по заданным критериям.
|
|
||||||
- **Платформа** – автоматически распределяет проекты, фиксирует оценки, собирает данные.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Создание формы оценки
|
|
||||||
|
|
||||||
## Назначение и общие принципы
|
|
||||||
|
|
||||||
Так как эксперты оценивают проекты по заранее настроенным критериям, требуется конструктор формы,
|
|
||||||
которую будут заполнять эксперты. Организатор конкурса настраивает критерии оценки до начала
|
|
||||||
конкурса. После начала оценки структура формы не может быть изменена.
|
|
||||||
|
|
||||||
## Типы критериев
|
|
||||||
|
|
||||||
- **Выбор из фиксированного списка ответов** (каждый вариант имеет скрытый для эксперта вес).
|
|
||||||
- **Текстовое поле** с настройками ограничений по длине.
|
|
||||||
|
|
||||||
Так же критерию можно добавить описание и подсказку.
|
|
||||||
|
|
||||||
## Группировка критериев
|
|
||||||
|
|
||||||
- Критерии разделены на группы.
|
|
||||||
- Некоторые критерии могут быть необязательными.
|
|
||||||
- Если для группы критериев требуется комментарий, то его нужно добавить в схему.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Назначение экспертов
|
|
||||||
|
|
||||||
## Принципы назначения
|
|
||||||
|
|
||||||
Платформа для грантов должна распределять проекты среди экспертов для независимой оценки. Процесс
|
|
||||||
назначения проектов должен учитывать:
|
|
||||||
|
|
||||||
- Автоматическое и ручное распределение.
|
|
||||||
- Обеспечение равномерной нагрузки на экспертов.
|
|
||||||
- Возможность перераспределения проектов в случае отказа эксперта.
|
|
||||||
|
|
||||||
## Ручное назначение
|
|
||||||
|
|
||||||
Организатор может вручную назначать экспертов и корректировать автоматическое распределение.
|
|
||||||
|
|
||||||
## Автоматическое назначение
|
|
||||||
|
|
||||||
Система может:
|
|
||||||
|
|
||||||
- Находить и назначать на проект наименее загруженного эксперта.
|
|
||||||
- Балансировать указанный проект.
|
|
||||||
- Балансировать все проекты категории.
|
|
||||||
|
|
||||||
Балансировать проект - доназначать экспертов до нужного количества, если сейчас их меньше, чем надо.
|
|
||||||
|
|
||||||
Обработка ошибок:
|
|
||||||
|
|
||||||
- Если найти и назначить наименее загруженного **эксперта** не удаётся, пользователю предлагается
|
|
||||||
**назначить его вручную**.
|
|
||||||
- Если для балансировки **проекта** не хватает экспертов, операция **отменяется**.
|
|
||||||
- Если для балансировки какого-то из проектов **категории** не хватает экспертов, этот **проект
|
|
||||||
помечается** `unassessable` и балансировка категории продолжается.
|
|
||||||
|
|
||||||
## Обработка отказов
|
|
||||||
|
|
||||||
Если эксперт отказывается от оценки, система автоматически переназначает проект другому эксперту.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Оценка проектов экспертами
|
|
||||||
|
|
||||||
## Процесс оценки
|
|
||||||
|
|
||||||
- Эксперт оценивает проект независимо, не видя оценок других экспертов.
|
|
||||||
- Черновики сохраняются автоматически.
|
|
||||||
- Эксперт может редактировать оценку, пока она не отправлена.
|
|
||||||
- После отправки оценку изменить нельзя.
|
|
||||||
|
|
||||||
## Форма
|
|
||||||
|
|
||||||
- Обязательное заполнение всех критериев.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Анализ ревью
|
|
||||||
|
|
||||||
После того как эксперты отправляют свои оценки, система автоматически анализирует их, вычисляет
|
|
||||||
средний балл проекта и определяет, завершена ли экспертиза.
|
|
||||||
|
|
||||||
Это происходит при каждом изменении ревью: при завершении, отклонении или отказе от ревью.
|
|
||||||
|
|
||||||
## Средний балл
|
|
||||||
|
|
||||||
Средний балл проекта рассчитывается как среднее арифметическое баллов всех завершённых ревью,
|
|
||||||
округлённое до целого. Незавершённые, отклонённые и удалённые ревью в расчёте не участвуют. Если ни
|
|
||||||
одно ревью ещё не завершено, средний балл не отображается.
|
|
||||||
|
|
||||||
## Завершение экспертизы
|
|
||||||
|
|
||||||
Экспертиза проекта считается завершённой, когда выполнены два условия одновременно:
|
|
||||||
|
|
||||||
- Все назначенные эксперты завершили свои ревью (не считая отклонённых и удалённых).
|
|
||||||
- Количество завершённых ревью не меньше минимально необходимого количества, заданного в
|
|
||||||
настройках номинации.
|
|
||||||
|
|
||||||
## Спорные оценки
|
|
||||||
|
|
||||||
Когда эксперты расходятся во мнениях слишком сильно, система помечает проект как спорный. Это
|
|
||||||
позволяет организатору обратить внимание на такие проекты и при необходимости назначить
|
|
||||||
дополнительных экспертов.
|
|
||||||
|
|
||||||
### Как определяется спорность
|
|
||||||
|
|
||||||
Проверка спорности происходит, когда набрано ровно минимально необходимое количество ревью. Система
|
|
||||||
сравнивает разброс оценок — разницу между максимальным и минимальным баллом среди всех завершённых
|
|
||||||
ревью — с допустимым порогом, заданным в настройках номинации в процентах от шкалы оценки.
|
|
||||||
|
|
||||||
- Если разброс превышает порог — проект помечается как спорный и экспертиза не завершается, чтобы
|
|
||||||
организатор мог назначить дополнительного эксперта.
|
|
||||||
- Если разброс в пределах порога — проект не спорный, экспертиза завершается.
|
|
||||||
|
|
||||||
### Дополнительные ревью
|
|
||||||
|
|
||||||
Когда организатор назначает дополнительного эксперта на спорный проект и тот завершает ревью,
|
|
||||||
количество ревью превышает минимально необходимое. В этом случае:
|
|
||||||
|
|
||||||
- Проект остаётся помеченным как спорный (метка не снимается автоматически).
|
|
||||||
- Экспертиза завершается, когда все назначенные эксперты завершили свои ревью.
|
|
||||||
- Модератор должен вручную снять метку спорности, если считает, что дополнительное ревью разрешило
|
|
||||||
спор.
|
|
||||||
|
|
||||||
### При недостатке ревью
|
|
||||||
|
|
||||||
Если количество завершённых ревью меньше минимально необходимого (например, ревью было отклонено),
|
|
||||||
метка спорности снимается и экспертиза остаётся незавершённой.
|
|
||||||
|
|
||||||
## Пересчёт проектов всей номинации
|
|
||||||
|
|
||||||
При изменении настроек оценки в номинации запускается пересчёт всех проектов в номинации.
|
|
||||||
|
|
||||||
При пересчёте:
|
|
||||||
|
|
||||||
- Затрагиваются только проекты с незавершённой или завершённой экспертизой.
|
|
||||||
- Метка спорности сбрасывается у всех проектов и вычисляется заново.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Оценка проектов'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Отчетность'
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Отчетность
|
|
||||||
|
|
||||||
Пользователи, получившие гранты должны предоставить отчеты по расходу полученных средств
|
|
||||||
|
|
||||||
Отчетности всего две
|
|
||||||
|
|
||||||
## Финансовый отчет
|
|
||||||
|
|
||||||
Сколько было потрачено средств, на что, с комментариями и прикреплением документов
|
|
||||||
|
|
||||||
## Аналитический отчет
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Продуктовые модули'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Гайдлайны'
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# Модальное окно
|
|
||||||
|
|
||||||
Модальное окно — диалоговый элемент интерфейса, который появляется поверх страницы и блокирует
|
|
||||||
доступ к её основному содержимому.
|
|
||||||
|
|
||||||
## Когда использовать
|
|
||||||
|
|
||||||
Используйте модальные окна для:
|
|
||||||
|
|
||||||
- подтверждения действий,
|
|
||||||
- отображения ошибок,
|
|
||||||
- вывода небольших форм (до 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` внутри, но представляют собой доменные элементы интерфейса.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Быстрый старт
|
|
||||||
|
|
||||||
## Репозиторий
|
|
||||||
|
|
||||||
Настраиваем [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).
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Ветки и пулл-реквесты
|
|
||||||
|
|
||||||
## 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 будет отображаться под задачей если
|
|
||||||
его добавить в зависимости.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Файловая структура проекта
|
|
||||||
|
|
||||||
## Фронтенд
|
|
||||||
|
|
||||||
### Гостевой фронтенд
|
|
||||||
|
|
||||||
Располагается в `/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,
|
|
||||||
]
|
|
||||||
```
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Схема зависимостей модулей
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# Тестирование с использованием реального бэкенда
|
|
||||||
|
|
||||||
Поднимается полноценнный 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
|
|
||||||
```
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Разработчику'
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Введение
|
|
||||||
|
|
||||||
## Зачем нужно логирование
|
|
||||||
|
|
||||||
### Цель
|
|
||||||
|
|
||||||
Логирование — это ключевой инструмент диагностики и анализа поведения системы. Оно фиксирует
|
|
||||||
**действия, ошибки и события**, позволяя:
|
|
||||||
|
|
||||||
- анализировать поведение приложения;
|
|
||||||
- находить причины ошибок и сбоев;
|
|
||||||
- отслеживать бизнес-процессы (например, запуск конкурса или назначение эксперта);
|
|
||||||
- проводить аудит действий пользователей;
|
|
||||||
- контролировать производительность и стабильность работы.
|
|
||||||
|
|
||||||
Хорошее логирование даёт **контекст и доказательства** — кто, когда и что сделал, с каким
|
|
||||||
результатом.
|
|
||||||
|
|
||||||
### Основные задачи логирования
|
|
||||||
|
|
||||||
Логирование решает как **технические**, так и **организационные** задачи.
|
|
||||||
|
|
||||||
**Технические:**
|
|
||||||
|
|
||||||
- **Диагностика:** восстановление хода событий при сбое.
|
|
||||||
- **Аналитика:** понимание того, как система используется.
|
|
||||||
- **Поддержка:** ускорение поиска причин ошибок в эксплуатации.
|
|
||||||
|
|
||||||
**Организационные:**
|
|
||||||
|
|
||||||
- **Аудит:** фиксация действий пользователей и администраторов.
|
|
||||||
- **Безопасность:** выявление попыток несанкционированного доступа.
|
|
||||||
|
|
||||||
## Уровни логирования
|
|
||||||
|
|
||||||
В системе используется библиотека **Pino**, интегрированная через `nestjs-pino`. Она поддерживает
|
|
||||||
стандартные уровни логирования, отражающие важность события.
|
|
||||||
|
|
||||||
| Уровень | Метод | Когда использовать | Пример |
|
|
||||||
| --------- | --------- | --------------------------------------------------- | -------------------------------- |
|
|
||||||
| **TRACE** | `trace()` | Максимальная детализация, пошаговая трассировка | Проверка цепочки вызовов |
|
|
||||||
| **DEBUG** | `debug()` | Отладочная информация о логике работы | Вывод промежуточных данных |
|
|
||||||
| **INFO** | `log()` | Обычные события нормальной работы | Создание проекта |
|
|
||||||
| **WARN** | `warn()` | Нежелательные, но некритичные ситуации | Отказ в доступе |
|
|
||||||
| **ERROR** | `error()` | Ошибки, требующие внимания и реакции | Исключение при сохранении |
|
|
||||||
| **FATAL** | `fatal()` | Критические сбои, приводящие к остановке приложения | Потеря соединения с базой данных |
|
|
||||||
|
|
||||||
> 💡 Все уровни логов фиксируются одинаково по структуре данных — различается только их
|
|
||||||
> **важность**.
|
|
||||||
|
|
||||||
## Различие между dev и prod
|
|
||||||
|
|
||||||
Логирование настроено так, чтобы быть **удобным в разработке** и **эффективным в продакшене**.
|
|
||||||
|
|
||||||
| Среда | Формат | Уровень по умолчанию | Особенности |
|
|
||||||
| --------------- | ----------------- | -------------------- | --------------------------------------------------------------------- |
|
|
||||||
| **Development** | человеко-читаемый | `debug` | Цвета, отступы, подробные данные — удобно для чтения в консоли. |
|
|
||||||
| **Production** | JSON | `info` | Машиночитаемый формат для централизованной агрегации и анализа логов. |
|
|
||||||
|
|
||||||
> Состав данных в логе всегда одинаков — меняется только способ отображения. Уровень логирования
|
|
||||||
> можно переопределить через переменные окружения.
|
|
||||||
|
|
||||||
Таким образом:
|
|
||||||
|
|
||||||
- в **разработке** акцент на удобство восприятия и диагностику;
|
|
||||||
- в **продакшене** — на структурированные данные и интеграцию с системами мониторинга (например,
|
|
||||||
Loki или ELK).
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Подключение и использование
|
|
||||||
|
|
||||||
## Получение логгера
|
|
||||||
|
|
||||||
Логгер внедряется в любой сервис, контроллер или компонент через **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()`.
|
|
||||||
- [ ] Уровень логирования соответствует окружению.
|
|
||||||
- [ ] Логи не содержат чувствительных данных.
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# Автоматическое логирование
|
|
||||||
|
|
||||||
Автоматическое логирование фиксирует системные события **без участия разработчика**. Оно
|
|
||||||
обеспечивает единый формат сообщений и полное покрытие ключевых операций платформы —
|
|
||||||
**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-запросы или проверки доступа.
|
|
||||||
- При анализе проблем **начинайте с автоматических логов** — они формируются всегда и имеют единый
|
|
||||||
формат.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Ручное логирование и бизнес-события
|
|
||||||
|
|
||||||
Автоматическое логирование фиксирует системные события (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).
|
|
||||||
- [ ] Отсутствуют конфиденциальные данные.
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
# Тестирование логирования
|
|
||||||
|
|
||||||
## Подключение
|
|
||||||
|
|
||||||
Для тестов следует подключать специальный модуль — **`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()` для сценариев, где событие **не должно** быть зафиксировано.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Логирование'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Технические модули'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Auth модуль и gateway'
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Принцип и сценарии
|
|
||||||
|
|
||||||
## Глоссарий
|
|
||||||
|
|
||||||
- **гость** не аутентифицированный посетитель
|
|
||||||
- **пользователь** аутентифицированный посетитель
|
|
||||||
|
|
||||||
## Назначение и требования к токенам
|
|
||||||
|
|
||||||
### 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'ы, выпущенные
|
|
||||||
благодаря потенциально утекшему
|
|
||||||
|
|
||||||
### Пользователь логинится
|
|
||||||
|
|
||||||
При переходе пользователя на страницу логина его перенаправляет на главную
|
|
||||||
|
|
||||||
### Пользователь регистрируется
|
|
||||||
|
|
||||||
Регистрация недоступна для аутентифицированного пользователя
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Настройки
|
|
||||||
|
|
||||||
## Порядок применения настроек
|
|
||||||
|
|
||||||
Настройки берутся из следующих мест (в порядке убывания приоритета):
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
Ключ для каждой настройки формируется как 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();
|
|
||||||
```
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Общие концепции
|
|
||||||
|
|
||||||
## Что такое 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
|
|
||||||
|
|
||||||
- Временное хранение данных, не требующих сложных запросов.
|
|
||||||
- Хранение данных, не требующих частого обращения и сложных запросов.
|
|
||||||
- Хранение данных при прототипировании.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Использование
|
|
||||||
|
|
||||||
## Подключение в 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’ы одного имени в разных модулях изолированы друг от друга.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Тестирование
|
|
||||||
|
|
||||||
## Зачем нужен тестовый режим
|
|
||||||
|
|
||||||
Для юнит- и интеграционных тестов используется метод `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' });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'DataStore'
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Документы и изображения
|
|
||||||
|
|
||||||
## Общее устройство
|
|
||||||
|
|
||||||
Все загружаемые файлы хранятся в 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 с заранее определенными размерами и параметрами
|
|
||||||
|
|
||||||
Для просмотра изображения используется сервер, на лету меняющий размеры изображения, для этого
|
|
||||||
сервер приложения выполняет редирект на специально подготовленный адрес
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'UI/UX'
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Вкладки
|
|
||||||
|
|
||||||
Допустим нам нужно разделить страницу `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`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
label: 'Поставка'
|
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Добро пожаловать в Docuservix!
|
# Добро пожаловать в Docuservix!
|
||||||
|
|
||||||
Вам надо настроить публикацию документации по инструкции в https://git.jt4d.ru/jt4d/docuservix
|
Вам надо настроить публикацию документации по инструкции в https://git.jt4d.ru/jt4d/docuservix
|
||||||
|
|||||||
+23
-33
@@ -1,9 +1,12 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import yaml from 'js-yaml';
|
|
||||||
import {themes as prismThemes} from 'prism-react-renderer';
|
|
||||||
import type {Config} from '@docusaurus/types';
|
|
||||||
import type * as Preset from '@docusaurus/preset-classic';
|
import type * as Preset from '@docusaurus/preset-classic';
|
||||||
import type {NavbarItem} from '@docusaurus/theme-common'
|
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';
|
||||||
|
|
||||||
interface DocsConfig {
|
interface DocsConfig {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,24 +16,17 @@ interface DocsConfig {
|
|||||||
|
|
||||||
const docsConfig = yaml.load(fs.readFileSync('./.docuservix.yml', 'utf8')) as DocsConfig;
|
const docsConfig = yaml.load(fs.readFileSync('./.docuservix.yml', 'utf8')) as DocsConfig;
|
||||||
|
|
||||||
const {
|
const { title } = docsConfig;
|
||||||
title,
|
|
||||||
} = docsConfig
|
|
||||||
|
|
||||||
const url = process.env.DOCUSERVIX_URL;
|
const url = process.env.DOCUSERVIX_URL || 'http://example.com';
|
||||||
|
|
||||||
const {
|
const { org, repo } = docsConfig.project;
|
||||||
org,
|
|
||||||
repo
|
|
||||||
} = docsConfig.project
|
|
||||||
|
|
||||||
const {
|
const { docs: _docsDir = 'docs', blog: blogDir } = docsConfig.dirs || {};
|
||||||
docs: docsDir = 'docs',
|
|
||||||
blog: blogDir
|
|
||||||
} = docsConfig.dirs || {}
|
|
||||||
|
|
||||||
const giteaUrl = 'https://git.jt4d.ru';
|
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 = {
|
const config: Config = {
|
||||||
title,
|
title,
|
||||||
@@ -39,6 +35,7 @@ const config: Config = {
|
|||||||
markdown: {
|
markdown: {
|
||||||
mermaid: true,
|
mermaid: true,
|
||||||
},
|
},
|
||||||
|
plugins: [docuservix()],
|
||||||
themes: ['@docusaurus/theme-mermaid'],
|
themes: ['@docusaurus/theme-mermaid'],
|
||||||
|
|
||||||
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
||||||
@@ -89,16 +86,6 @@ const config: Config = {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
themes: ['@docusaurus/theme-mermaid'],
|
|
||||||
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
path.resolve(__dirname, './plugins/docuservix-search/index.ts'),
|
|
||||||
{
|
|
||||||
providersModule: require.resolve('./src/search-providers'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
// Replace with your project's social card
|
// Replace with your project's social card
|
||||||
image: 'img/docusaurus-social-card.jpg',
|
image: 'img/docusaurus-social-card.jpg',
|
||||||
@@ -117,11 +104,13 @@ const config: Config = {
|
|||||||
label: 'Документация',
|
label: 'Документация',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
},
|
},
|
||||||
blogDir ? {
|
blogDir
|
||||||
to: '/blog',
|
? {
|
||||||
label: 'Блог',
|
to: '/blog',
|
||||||
position: 'left'
|
label: 'Блог',
|
||||||
} : undefined,
|
position: 'left',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
{
|
{
|
||||||
href: `${giteaUrl}/${org}/${repo}`,
|
href: `${giteaUrl}/${org}/${repo}`,
|
||||||
label: 'Gitea',
|
label: 'Gitea',
|
||||||
@@ -131,7 +120,8 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
style: 'dark',
|
style: 'dark',
|
||||||
copyright: `Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.`,
|
copyright:
|
||||||
|
'Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.',
|
||||||
},
|
},
|
||||||
prism: {
|
prism: {
|
||||||
theme: prismThemes.github,
|
theme: prismThemes.github,
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
+86
-50
@@ -1,52 +1,88 @@
|
|||||||
{
|
{
|
||||||
"name": "docusaurus",
|
"name": "docusaurus",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docusaurus": "docusaurus",
|
"build": "docusaurus build",
|
||||||
"start": "docusaurus start",
|
"clear": "docusaurus clear",
|
||||||
"build": "docusaurus build",
|
"deploy": "docusaurus deploy",
|
||||||
"swizzle": "docusaurus swizzle",
|
"docusaurus": "docusaurus",
|
||||||
"deploy": "docusaurus deploy",
|
"eslint:check": "yarn eslint",
|
||||||
"clear": "docusaurus clear",
|
"eslint:fix": "yarn eslint --fix",
|
||||||
"serve": "docusaurus serve",
|
"lint": "run-s eslint:fix prettier:fix",
|
||||||
"write-translations": "docusaurus write-translations",
|
"lint:check": "run-s eslint:check prettier:check",
|
||||||
"write-heading-ids": "docusaurus write-heading-ids",
|
"prepare": "husky",
|
||||||
"typecheck": "tsc"
|
"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}\"",
|
||||||
"dependencies": {
|
"serve": "docusaurus serve",
|
||||||
"@docusaurus/core": "3.10.1",
|
"start": "docusaurus start",
|
||||||
"@docusaurus/faster": "3.10.1",
|
"swizzle": "docusaurus swizzle",
|
||||||
"@docusaurus/preset-classic": "3.10.1",
|
"typecheck": "tsc",
|
||||||
"@docusaurus/theme-mermaid": "3.10.1",
|
"write-heading-ids": "docusaurus write-heading-ids",
|
||||||
"@mdx-js/react": "^3.0.0",
|
"write-translations": "docusaurus write-translations"
|
||||||
"clsx": "^2.0.0",
|
},
|
||||||
"js-yaml": "^4.2.0",
|
"lint-staged": {
|
||||||
"prism-react-renderer": "^2.3.0",
|
"*.{json,ts,tsx,js,jsx,js,mjs,md,mdx,yaml,yml}": "prettier --write",
|
||||||
"react": "^19.0.0",
|
"{src,e2e}/**/*.{ts,tsx}": "eslint --quiet --fix"
|
||||||
"react-dom": "^19.0.0"
|
},
|
||||||
},
|
"browserslist": {
|
||||||
"devDependencies": {
|
"production": [
|
||||||
"@docusaurus/module-type-aliases": "3.10.1",
|
">0.5%",
|
||||||
"@docusaurus/tsconfig": "3.10.1",
|
"not dead",
|
||||||
"@docusaurus/types": "3.10.1",
|
"not op_mini all"
|
||||||
"@types/js-yaml": "^4.0.9",
|
],
|
||||||
"@types/react": "^19.0.0",
|
"development": [
|
||||||
"typescript": "~6.0.2"
|
"last 3 chrome version",
|
||||||
},
|
"last 3 firefox version",
|
||||||
"browserslist": {
|
"last 5 safari version"
|
||||||
"production": [
|
]
|
||||||
">0.5%",
|
},
|
||||||
"not dead",
|
"dependencies": {
|
||||||
"not op_mini all"
|
"@docusaurus/core": "3.10.1",
|
||||||
],
|
"@docusaurus/faster": "3.10.1",
|
||||||
"development": [
|
"@docusaurus/preset-classic": "3.10.1",
|
||||||
"last 3 chrome version",
|
"@docusaurus/theme-mermaid": "3.10.1",
|
||||||
"last 3 firefox version",
|
"@mdx-js/react": "^3.0.0",
|
||||||
"last 5 safari version"
|
"bem-css-modules": "^1.4.3",
|
||||||
]
|
"clsx": "^2.0.0",
|
||||||
},
|
"js-yaml": "^4.2.0",
|
||||||
"engines": {
|
"prism-react-renderer": "^2.3.0",
|
||||||
"node": ">=20.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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export interface SearchResult {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
path: string;
|
|
||||||
anchor?: string;
|
|
||||||
type: string;
|
|
||||||
relevance: number; // 0–1
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchProviderResponse {
|
|
||||||
results: SearchResult[];
|
|
||||||
notice?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchProvider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
timeout?: number;
|
|
||||||
search: (query: string, signal: AbortSignal) => Promise<SearchProviderResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchConfig {
|
|
||||||
timeout: number;
|
|
||||||
providers: SearchProvider[];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MD } from './MD';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import type { LoadContext, Plugin } from '@docusaurus/types';
|
||||||
|
|
||||||
|
export default function docuservix() {
|
||||||
|
return function pluginDocuservix(_context: LoadContext): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'docuservix',
|
||||||
|
|
||||||
|
configureWebpack() {
|
||||||
|
return {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@docuservix': path.resolve(__dirname),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async contentLoaded({ actions }) {
|
||||||
|
const { addRoute } = actions;
|
||||||
|
|
||||||
|
addRoute({
|
||||||
|
path: '/chat',
|
||||||
|
component: '@docuservix/pages/chat',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface IChat {
|
||||||
|
messages: IChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Layout from '@theme/Layout';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { IChat } from '@docuservix/models/chat';
|
||||||
|
import { Chat } from '@docuservix/widgets/chat';
|
||||||
|
|
||||||
|
const dialog: IChat = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Can you show me some CSS animations? It can be simple tools like chatbots...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: "Hello! I'm your **AI assistant**. How can I help you today?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatPage(): ReactNode {
|
||||||
|
return (
|
||||||
|
<Layout title="Чат">
|
||||||
|
<main className="container margin-vert--lg">
|
||||||
|
<Chat
|
||||||
|
dialog={dialog}
|
||||||
|
statusMessage="Unable to connect to the server"
|
||||||
|
typing
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ChatPage } from './ChatPage';
|
||||||
|
|
||||||
|
export default ChatPage;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.Message {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import block from 'bem-css-modules';
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { MD } from '@docuservix/entities/markdown';
|
||||||
|
|
||||||
|
import styles from './Message.module.css';
|
||||||
|
|
||||||
|
const b = block(styles, 'Message');
|
||||||
|
|
||||||
|
interface MessageProps {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Message({ role, content }: MessageProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={b({ role })}>
|
||||||
|
<div className={b('content')}>
|
||||||
|
<MD>{content}</MD>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{typing && (
|
||||||
|
<div className={b('typing')}>
|
||||||
|
<div className={b('typingIndicator')}>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Chat } from './Chat';
|
||||||
+26
-14
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable no-console -- logs required */
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -9,23 +11,33 @@ pinIndexToTop();
|
|||||||
* Гарантирует наличие sidebar_position: 0 в front matter файла index.md
|
* Гарантирует наличие sidebar_position: 0 в front matter файла index.md
|
||||||
*/
|
*/
|
||||||
function pinIndexToTop() {
|
function pinIndexToTop() {
|
||||||
const indexPath = path.join(docsDir, 'index.md');
|
const indexPath = path.join(docsDir, 'index.md');
|
||||||
if (!fs.existsSync(indexPath)) return;
|
|
||||||
|
|
||||||
let content = fs.readFileSync(indexPath, 'utf8');
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (content.startsWith('---\n')) {
|
let content = fs.readFileSync(indexPath, 'utf8');
|
||||||
const endIdx = content.indexOf('\n---\n', 4);
|
|
||||||
if (endIdx === -1) return;
|
|
||||||
|
|
||||||
const frontMatter = content.slice(4, endIdx);
|
if (content.startsWith('---\n')) {
|
||||||
if (/^sidebar_position\s*:/m.test(frontMatter)) return;
|
const endIdx = content.indexOf('\n---\n', 4);
|
||||||
|
|
||||||
content = '---\nsidebar_position: 0\n' + frontMatter + '\n---\n' + content.slice(endIdx + 5);
|
if (endIdx === -1) {
|
||||||
} else {
|
return;
|
||||||
content = '---\nsidebar_position: 0\n---\n' + content;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(indexPath, content);
|
const frontMatter = content.slice(4, endIdx);
|
||||||
console.log('prepare-docs: pinned index.md to sidebar top');
|
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-29
@@ -1,40 +1,47 @@
|
|||||||
import type {ReactNode} from 'react';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import Link from '@docusaurus/Link';
|
import Link from '@docusaurus/Link';
|
||||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||||
import Layout from '@theme/Layout';
|
|
||||||
import Heading from '@theme/Heading';
|
import Heading from '@theme/Heading';
|
||||||
|
import Layout from '@theme/Layout';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import styles from './index.module.css';
|
import styles from './index.module.css';
|
||||||
|
|
||||||
function HomepageHeader() {
|
function HomepageHeader() {
|
||||||
const {siteConfig} = useDocusaurusContext();
|
const { siteConfig } = useDocusaurusContext();
|
||||||
return (
|
|
||||||
<header className={clsx('hero hero--primary', styles.heroBanner)}>
|
return (
|
||||||
<div className="container">
|
<header className={clsx('hero hero--primary', styles.heroBanner)}>
|
||||||
<Heading as="h1" className="hero__title">
|
<div className="container">
|
||||||
{siteConfig.title}
|
<Heading
|
||||||
</Heading>
|
as="h1"
|
||||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
className="hero__title"
|
||||||
<div className={styles.buttons}>
|
>
|
||||||
<Link
|
{siteConfig.title}
|
||||||
className="button button--secondary button--lg"
|
</Heading>
|
||||||
to="/docs">
|
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||||
Документация
|
<div className={styles.buttons}>
|
||||||
</Link>
|
<Link
|
||||||
</div>
|
className="button button--secondary button--lg"
|
||||||
</div>
|
to="/docs"
|
||||||
</header>
|
>
|
||||||
);
|
Документация
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home(): ReactNode {
|
export default function Home(): ReactNode {
|
||||||
const {siteConfig} = useDocusaurusContext();
|
const { siteConfig } = useDocusaurusContext();
|
||||||
return (
|
|
||||||
<Layout
|
return (
|
||||||
title={`Hello from ${siteConfig.title}`}
|
<Layout
|
||||||
description="Description will go into a meta tag in <head />">
|
title={`Hello from ${siteConfig.title}`}
|
||||||
<HomepageHeader />
|
description="Description will go into a meta tag in <head />"
|
||||||
</Layout>
|
>
|
||||||
);
|
<HomepageHeader />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import type {
|
|
||||||
SearchConfig,
|
|
||||||
SearchProvider,
|
|
||||||
SearchResult,
|
|
||||||
} from '../plugins/docuservix-search/types';
|
|
||||||
|
|
||||||
interface RawResult {
|
|
||||||
score: number;
|
|
||||||
file: string;
|
|
||||||
heading: string;
|
|
||||||
anchor?: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripNumericPrefixes(p: string): string {
|
|
||||||
return p
|
|
||||||
.split('/')
|
|
||||||
.map((seg) => seg.replace(/^\d+-/, ''))
|
|
||||||
.join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultToUrl(file: string, anchor: string): string {
|
|
||||||
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
|
||||||
|
|
||||||
p = stripNumericPrefixes(p);
|
|
||||||
|
|
||||||
return `/docs/${p}#${anchor}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultToDisplayPath(file: string): string {
|
|
||||||
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
|
|
||||||
|
|
||||||
return stripNumericPrefixes(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawToSearchResult(raw: RawResult): SearchResult {
|
|
||||||
return {
|
|
||||||
title: raw.heading,
|
|
||||||
content: raw.content,
|
|
||||||
path: resultToDisplayPath(raw.file),
|
|
||||||
anchor: raw.anchor,
|
|
||||||
type: 'docs',
|
|
||||||
relevance: raw.score,
|
|
||||||
url: resultToUrl(raw.file, raw.anchor),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const docsProvider: SearchProvider = {
|
|
||||||
id: 'docs',
|
|
||||||
name: 'Документация',
|
|
||||||
timeout: 5000,
|
|
||||||
async search(query, signal) {
|
|
||||||
const res = await fetch(
|
|
||||||
`http://localhost:8080/1vit/more/v1/search?q=${encodeURIComponent(query)}&limit=25`,
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: { results: RawResult[] } = await res.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
results: (data.results ?? []).map(rawToSearchResult),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchConfig: SearchConfig = {
|
|
||||||
timeout: 5000,
|
|
||||||
providers: [docsProvider],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default searchConfig;
|
|
||||||
|
|
||||||
export const chatUrl = 'http://localhost:8080/1vit/more/v1/chat';
|
|
||||||
+15
-7
@@ -2,11 +2,19 @@
|
|||||||
// It is here to improve your IDE experience (type-checking, autocompletion...),
|
// It is here to improve your IDE experience (type-checking, autocompletion...),
|
||||||
// and can also run the package.json "typecheck" script manually.
|
// and can also run the package.json "typecheck" script manually.
|
||||||
{
|
{
|
||||||
"extends": "@docusaurus/tsconfig",
|
"extends": "@docusaurus/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"ignoreDeprecations": "6.0",
|
"paths": {
|
||||||
"strict": true
|
"@docuservix/*": [
|
||||||
},
|
"plugins/docuservix/*"
|
||||||
"exclude": [".docusaurus", "build"]
|
]
|
||||||
|
},
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
".docusaurus",
|
||||||
|
"build"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user