Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ce692052f | |||
| 00d3764d2e | |||
| 9ab08acf7f | |||
| 07d44371ad | |||
| 26fcfd2968 | |||
| c2e7d57f59 | |||
| 5c05d649b0 | |||
| 486fcf9909 | |||
| 8c772bf8ae | |||
| 3445f15f44 | |||
| f53dedef2b | |||
| 8f44b12021 | |||
| f4e3cbf3be | |||
| 7bd89686a9 | |||
| 1929318d55 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
api/.idea
|
api/.idea
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
web/node_modules
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
258
README.md
258
README.md
@ -1,2 +1,258 @@
|
|||||||
# psb_hack
|
# Lectio
|
||||||
|
|
||||||
|
**Lectio** — Система позволяет создавать курсы с лекциями и заданиями, записываться на них, отмечать прочитанные материалы, загружать решения с файлами любой сложности, а преподавателям — выставлять ручные оценки. Реализована ролевая модель (студент / преподаватель / администратор), автоматический подсчёт прогресса по курсу, детальный журнал успеваемости с максимальными оценками и статусом чтения лекций, а также личный кабинет студента, где он видит только свою успеваемость.
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
* Backend: FastAPI (Python), SQLAlchemy 2.0, PostgreSQL, JWT-аутентификация
|
||||||
|
* Frontend: React + Vite + JavaScript + Ant Design + RTK Query
|
||||||
|
* Инфраструктура: Docker, Kubernetes (microk8s), Helm-чарт, cert-manager + Let’s Encrypt
|
||||||
|
|
||||||
|
## Развернутый дистрибутив
|
||||||
|
WEB: https://lectio.numerum.team
|
||||||
|
API: https://api.lectio.numerum.team (с автоматической Swagger-документацией)
|
||||||
|
Хранение файлов: PersistentVolume с hostPath
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
### API
|
||||||
|
Бэкенд построен на FastAPI с луковой архитектурой.
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── controllers/ — FastAPI-роутеры (auth, courses, lessons, tasks, solutions и т.д.)
|
||||||
|
├── infrastructure/ — сервисы бизнес-логики (gradebook_service, solutions_service и др.)
|
||||||
|
├── application/ — репозитории (работа с БД через SQLAlchemy 2.0 async)
|
||||||
|
├── domain/
|
||||||
|
│ ├── models/ — SQLAlchemy-модели (ORM)
|
||||||
|
│ └── entities/ — Pydantic-схемы для запросов/ответов
|
||||||
|
├── database/ — сессии, Alembic-миграции
|
||||||
|
├── core/ — константы, настройки
|
||||||
|
└── main.py, settings.py
|
||||||
|
```
|
||||||
|
API спроектирован так, что добавление новых сущностей (например, сертификаты, тесты, группы) требует минимум изменений — достаточно добавить модель, репозиторий, сервис и роутер.
|
||||||
|
|
||||||
|
### WEB
|
||||||
|
Фронтенд реализован на React + Vite + JavaScript с использованием UI-библиотеки Ant Design и state-менеджмента через RTK Query.
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Api/ — RTK Query API-слайсы (authApi, coursesApi, gradebookApi, solutionsApi и др.)
|
||||||
|
├── App/ — маршрутизация, PrivateRoute, AdminRoute, ErrorBoundary
|
||||||
|
├── Components/
|
||||||
|
│ ├── Layouts/ — MainLayout (шапка, сайдбар, адаптивность)
|
||||||
|
│ ├── Pages/ — все страницы: CoursesPage, CourseDetailPage, GradebookPage, Login, Profile и др.
|
||||||
|
│ └── Widgets/ — переиспользуемые компоненты (модалки, лоадеры)
|
||||||
|
├── Redux/ — store + слайсы состояния (auth, courses, modals)
|
||||||
|
├── Core/ — константы, конфиги (VITE_BASE_URL, роли)
|
||||||
|
├── Hooks/ — кастомные хуки (useAuthUtils и др.)
|
||||||
|
└── Styles/ — глобальные стили
|
||||||
|
```
|
||||||
|
|
||||||
|
## Система ролей
|
||||||
|
|
||||||
|
В системе реализована строгая ролевая модель с тремя ролями. Права проверяются как на бэкенде (зависимости FastAPI), так и на фронтенде (PrivateRoute / AdminRoute).
|
||||||
|
|
||||||
|
### Администратор
|
||||||
|
|
||||||
|
- Всё, что может преподаватель
|
||||||
|
- Управление пользователями (создание, редактирование, удаление, смена роли)
|
||||||
|
- Управление ролями и статусами
|
||||||
|
- Просмотр и редактирование всех курсов и их содержимого
|
||||||
|
- Доступ ко всем журналам успеваемости
|
||||||
|
|
||||||
|
|
||||||
|
### Преподаватель
|
||||||
|
|
||||||
|
- Создавать, редактировать и удалять свои курсы
|
||||||
|
- Добавлять/редактировать лекции и задания
|
||||||
|
- Назначать себе и другим преподавателям курсы
|
||||||
|
- Просматривать полный журнал успеваемости по своим курсам
|
||||||
|
- Выставлять оценки и комментарии к решениям студентов
|
||||||
|
- Скачивать все файлы решений
|
||||||
|
|
||||||
|
|
||||||
|
### Студент
|
||||||
|
|
||||||
|
- Просматривать список всех курсов и записываться на них
|
||||||
|
- Просматривать лекции и отмечать их как «прочитанные»
|
||||||
|
- Загружать решения заданий
|
||||||
|
- Видеть свой прогресс по каждому курсу
|
||||||
|
- Видеть свою успеваемость в журнале (только свою)
|
||||||
|
- Редактировать свой профиль (ФИО, фото и т.д.)
|
||||||
|
|
||||||
|
## Развертывание проекта
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
Бэкенд можно запустить тремя способами — от самого простого до полноценного production-деплоймента.
|
||||||
|
|
||||||
|
#### 1. Локальный запуск (без Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонируем репозиторий
|
||||||
|
git clone https://github.com/AndreiDuvakin/lectio.git
|
||||||
|
cd lectio/api
|
||||||
|
|
||||||
|
# Создаём виртуальное окружение и устанавливаем зависимости
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r req.txt
|
||||||
|
|
||||||
|
# Создаём файл .env в корне api/
|
||||||
|
cp .env.example .env
|
||||||
|
# Редактируем .env — обязательно заполняем:
|
||||||
|
DB_DRIVER=postgresql+asyncpg
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_NAME=lectio
|
||||||
|
DB_SCHEMA=public
|
||||||
|
SECRET_KEY=your_very_strong_secret_key_here
|
||||||
|
|
||||||
|
# Применяем миграции
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Запускаем сервер
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
API: http://localhost:8000
|
||||||
|
|
||||||
|
#### 2. Запуск через Docker
|
||||||
|
|
||||||
|
Обратите внимание, что для хранения файлов необходимо создавать хранилище.
|
||||||
|
**Вариант A — просто тянуть готовый образ**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name lectio-api \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-e DB_DRIVER=postgresql+asyncpg \
|
||||||
|
-e DB_HOST=host.docker.internal \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_USER=postgres \
|
||||||
|
-e DB_PASSWORD=your_password \
|
||||||
|
-e DB_NAME=lectio \
|
||||||
|
-e DB_SCHEMA=public \
|
||||||
|
-e SECRET_KEY=supersecretkey123 \
|
||||||
|
-v ./uploads:/app/uploads \
|
||||||
|
andreiduvakin/lectio-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант B — собрать локально**
|
||||||
|
Запускается из папки api:
|
||||||
|
```bash
|
||||||
|
docker build -t lectio-api . -а app/Dockerfile
|
||||||
|
docker run -d --name lectio-api -p 8000:8000 lectio-api
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Production-развёртывание в Kubernetes
|
||||||
|
Используется Helm-чарт **k8s/helm/lectio-api**.
|
||||||
|
```bash
|
||||||
|
# Устанавливаем чарт (пример для нашего microk8s)
|
||||||
|
helm upgrade --install lectio-web k8s/helm/lectio-web --namespace lectio-web --create-namespace
|
||||||
|
```
|
||||||
|
Что нужно настроить в **values.yaml**:
|
||||||
|
```
|
||||||
|
env:
|
||||||
|
DB_DRIVER: postgresql+asyncpg
|
||||||
|
DB_HOST: db.numerum.team
|
||||||
|
DB_PORT: 30000
|
||||||
|
DB_USER: lectio
|
||||||
|
DB_NAME: lectio
|
||||||
|
DB_SCHEMA: public
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secret** с чувствительными данными:
|
||||||
|
```
|
||||||
|
# k8s/helm/lectio-api/templates/secrets.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: lectio-api-secret
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
SECRET_KEY: base64_encoded_very_long_key==
|
||||||
|
DB_PASSWORD: base64_encoded_password==
|
||||||
|
```
|
||||||
|
|
||||||
|
**ОБРАТИТЕ ВНИМАНИЕ**
|
||||||
|
В настоящем решении не предоставлен действительный файл **k8s/helm/lectio-api/templates/secrets.yaml** - для предотвращения несанкционированного доступа к нашей базе данных. Для ВАШЕЙ конфигурации нужно создавать свой файл.
|
||||||
|
|
||||||
|
PersistentVolume автоматически создаётся через pvc.yaml и монтирует папку /mnt/k8s_storage/lectio-api/uploads на хосте — все загруженные студентами файлы сохраняются между перезапусками.
|
||||||
|
После деплоя:
|
||||||
|
|
||||||
|
API: https://api.lectio.numerum.team/
|
||||||
|
Swagger/ReDoc: https://api.lectio.numerum.team/docs
|
||||||
|
|
||||||
|
Именно так система работает в продакшене прямо сейчас — с HTTPS, Let’s Encrypt, персистентным хранилищем и автоматическим масштабированием.
|
||||||
|
|
||||||
|
### WEB
|
||||||
|
|
||||||
|
Фронтенд можно запустить тремя способами — от локальной разработки до production-развёртывания в Kubernetes.
|
||||||
|
|
||||||
|
### 1. Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/AndreiDuvakin/lectio.git
|
||||||
|
cd lectio/web
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Создаём .env файл (в корне web/)
|
||||||
|
echo "VITE_BASE_URL=http://localhost:8000/api/v1
|
||||||
|
VITE_ROOT_ROLE_NAME=root" > .env
|
||||||
|
|
||||||
|
# Запускаем dev-сервер
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Приложение будет доступно по адресу: http://localhost:5173
|
||||||
|
|
||||||
|
### 2. Запуск через Docker
|
||||||
|
Запускается из папки web:
|
||||||
|
**Вариант A — готовый образ**
|
||||||
|
Готовый образ уже собран с продакшен-настройками!
|
||||||
|
**VITE_BASE_URL** и **VITE_ROOT_ROLE_NAME** жёстко зашиты в образ на этапе сборки:
|
||||||
|
```Dockerfile
|
||||||
|
ENV VITE_BASE_URL=https://api.lectio.numerum.team/api/v1
|
||||||
|
ENV VITE_ROOT_ROLE_NAME=root
|
||||||
|
```
|
||||||
|
Поэтому запуск предельно прост:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name lectio-web \
|
||||||
|
-p 3000:3000 \
|
||||||
|
andreiduvakin/lectio-web:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант B — сборка локально**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name lectio-web \
|
||||||
|
-p 3000:3000 \
|
||||||
|
--build-arg VITE_BASE_URL=http://localhost:8000/api/v1 \
|
||||||
|
andreiduvakin/lectio-web:latest
|
||||||
|
```
|
||||||
|
Приложение доступно по адресу: http://localhost:3000
|
||||||
|
|
||||||
|
### 3. Production-развёртывание в Kubernetes
|
||||||
|
|
||||||
|
Используется Helm-чарт k8s/helm/lectio-web.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade --install lectio-web k8s/helm/lectio-web --namespace lectio-web --create-namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
Поскольку переменные окружения уже зашиты в образ на этапе **npm run build**, в **values.yaml** ничего передавать не нужно.
|
||||||
|
|
||||||
|
После деплоя приложение доступно по адресу:
|
||||||
|
https://lectio.numerum.team
|
||||||
|
Именно так работает продакшен прямо сейчас.
|
||||||
|
|
||||||
|
|
||||||
|
## Демонстрация
|
||||||
|
|
||||||
|
Видео УСТАНОВКИ и ДЕМОНСТРАЦИИ проекта достпуно по ссылке: https://disk.yandex.ru/i/sDi_GnOUBnpVaA
|
||||||
|
|
||||||
|
Пожалуйста, досмотрите видео целиком
|
||||||
|
|
||||||
|
## Регистрация
|
||||||
|
|
||||||
|
В системе можно зарегистрироваться перейдя на страницу регистрации из страницы авторизации, но по умолчанию вам будет выдана роль Студент без зачисления на курс
|
||||||
4
api/.dockerignore
Normal file
4
api/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.venv
|
||||||
|
k8s
|
||||||
|
.idea
|
||||||
|
.env
|
||||||
18
api/app/Dockerfile
Normal file
18
api/app/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq-dev \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY req.txt .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r req.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]
|
||||||
@ -7,11 +7,13 @@ from app.database.session import get_db
|
|||||||
from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate
|
from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate
|
||||||
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated
|
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated
|
||||||
from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate
|
from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate
|
||||||
|
from app.domain.entities.gradebook import GradeBookRead
|
||||||
from app.domain.models import User
|
from app.domain.models import User
|
||||||
from app.infrastructure.course_teachers_service import CourseTeachersService
|
from app.infrastructure.course_teachers_service import CourseTeachersService
|
||||||
from app.infrastructure.courses_service import CoursesService
|
from app.infrastructure.courses_service import CoursesService
|
||||||
from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin
|
from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin
|
||||||
from app.infrastructure.enrollments_service import EnrollmentsService
|
from app.infrastructure.enrollments_service import EnrollmentsService
|
||||||
|
from app.infrastructure.gradebook_service import GradeBookService
|
||||||
|
|
||||||
courses_router = APIRouter()
|
courses_router = APIRouter()
|
||||||
|
|
||||||
@ -152,3 +154,13 @@ async def replace_course_students(
|
|||||||
):
|
):
|
||||||
service = EnrollmentsService(db)
|
service = EnrollmentsService(db)
|
||||||
return await service.replace_course_students_list(students, course_id)
|
return await service.replace_course_students_list(students, course_id)
|
||||||
|
|
||||||
|
|
||||||
|
@courses_router.get("/gradebook/{course_id}/", response_model=GradeBookRead)
|
||||||
|
async def get_gradebook(
|
||||||
|
course_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_auth_user)
|
||||||
|
):
|
||||||
|
service = GradeBookService(db)
|
||||||
|
return await service.get_gradebook_by_course_id(course_id)
|
||||||
|
|||||||
@ -89,6 +89,20 @@ async def create_user(
|
|||||||
return await register_service.create_user(user)
|
return await register_service.create_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.post(
|
||||||
|
'/register/',
|
||||||
|
response_model=Optional[UserRead],
|
||||||
|
summary='Register a new user',
|
||||||
|
description='Register a new user',
|
||||||
|
)
|
||||||
|
async def register_user(
|
||||||
|
user: UserCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
register_service = RegisterService(db)
|
||||||
|
return await register_service.user_register(user)
|
||||||
|
|
||||||
|
|
||||||
@users_router.get(
|
@users_router.get(
|
||||||
'/role/{role_name}/',
|
'/role/{role_name}/',
|
||||||
response_model=List[UserRead],
|
response_model=List[UserRead],
|
||||||
|
|||||||
44
api/app/domain/entities/gradebook.py
Normal file
44
api/app/domain/entities/gradebook.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from typing import List, Optional, Dict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LessonInfo(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
number: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TaskInfo(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
number: int
|
||||||
|
max_points: int = 100
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StudentProgress(BaseModel):
|
||||||
|
student_id: int
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
patronymic: str = ""
|
||||||
|
read_lesson_ids: List[int] = []
|
||||||
|
task_grades: Dict[int, Optional[int]]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class GradeBookRead(BaseModel):
|
||||||
|
course_id: int
|
||||||
|
course_title: str
|
||||||
|
lessons: List[LessonInfo]
|
||||||
|
tasks: List[TaskInfo]
|
||||||
|
students: List[StudentProgress]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@ -42,6 +42,7 @@ class SolutionCommentCreate(SolutionCommentBase):
|
|||||||
class SolutionCommentRead(SolutionCommentBase):
|
class SolutionCommentRead(SolutionCommentBase):
|
||||||
comment_autor_id: int
|
comment_autor_id: int
|
||||||
solution_id: int
|
solution_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
comment_autor: UserRead
|
comment_autor: UserRead
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class UserCreate(BaseModel):
|
|||||||
birthdate: date
|
birthdate: date
|
||||||
password: str = Field(min_length=8)
|
password: str = Field(min_length=8)
|
||||||
repeat_password: str = Field(min_length=8)
|
repeat_password: str = Field(min_length=8)
|
||||||
role_id: int = Field()
|
role_id: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
|
|||||||
@ -21,4 +21,4 @@ class Lesson(RootTable):
|
|||||||
creator: Mapped['User'] = relationship('User', back_populates='created_lessons', lazy='joined')
|
creator: Mapped['User'] = relationship('User', back_populates='created_lessons', lazy='joined')
|
||||||
|
|
||||||
files: Mapped[List['LessonFile']] = relationship('LessonFile', back_populates='lesson')
|
files: Mapped[List['LessonFile']] = relationship('LessonFile', back_populates='lesson')
|
||||||
user_check_lessons: Mapped[List['UserCheckLessons']] = relationship('UserCheckLessons', back_populates='lesson')
|
user_check_lessons: Mapped[List['UserCheckLessons']] = relationship('UserCheckLessons', back_populates='lesson', cascade="all, delete-orphan",)
|
||||||
|
|||||||
97
api/app/infrastructure/gradebook_service.py
Normal file
97
api/app/infrastructure/gradebook_service.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.entities.gradebook import GradeBookRead, StudentProgress, LessonInfo, TaskInfo
|
||||||
|
from app.domain.models import Course, Lesson, Task, User, Enrollment, UserCheckLessons, Solution
|
||||||
|
|
||||||
|
|
||||||
|
class GradeBookService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_gradebook_by_course_id(self, course_id: int) -> GradeBookRead:
|
||||||
|
# 1. Курс
|
||||||
|
course = await self.db.scalar(select(Course).filter_by(id=course_id))
|
||||||
|
if not course:
|
||||||
|
raise ValueError("Курс не найден")
|
||||||
|
|
||||||
|
# 2. Лекции
|
||||||
|
lessons = (await self.db.execute(
|
||||||
|
select(Lesson).filter_by(course_id=course_id).order_by(Lesson.number)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
# 3. Задания
|
||||||
|
tasks = (await self.db.execute(
|
||||||
|
select(Task).filter_by(course_id=course_id).order_by(Task.number)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
# 4. Студенты
|
||||||
|
students = (await self.db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(Enrollment, User.id == Enrollment.student_id)
|
||||||
|
.filter(Enrollment.course_id == course_id)
|
||||||
|
.order_by(User.last_name, User.first_name)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
student_progress_map: dict[int, StudentProgress] = {}
|
||||||
|
|
||||||
|
if students:
|
||||||
|
student_ids = [s.id for s in students]
|
||||||
|
|
||||||
|
# --- Прочитанные лекции ---
|
||||||
|
read_result = await self.db.execute(
|
||||||
|
select(UserCheckLessons.user_id, UserCheckLessons.lesson_id)
|
||||||
|
.filter(UserCheckLessons.user_id.in_(student_ids))
|
||||||
|
)
|
||||||
|
read_map: dict[int, set[int]] = {}
|
||||||
|
for user_id, lesson_id in read_result:
|
||||||
|
read_map.setdefault(user_id, set()).add(lesson_id)
|
||||||
|
|
||||||
|
# --- МАКСИМАЛЬНЫЕ ОЦЕНКИ (ТОЛЬКО ИЗ ОЦЕНЕННЫХ РЕШЕНИЙ!) ---
|
||||||
|
grades_result = await self.db.execute(
|
||||||
|
select(
|
||||||
|
Solution.student_id,
|
||||||
|
Solution.task_id,
|
||||||
|
func.max(Solution.assessment)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Solution.student_id.in_(student_ids),
|
||||||
|
Solution.assessment.is_not(None) # ← Вот это главное!
|
||||||
|
)
|
||||||
|
.group_by(Solution.student_id, Solution.task_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
grade_map: dict[tuple[int, int], int] = {
|
||||||
|
(student_id, task_id): best_grade
|
||||||
|
for student_id, task_id, best_grade in grades_result
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Собираем прогресс ---
|
||||||
|
for student in students:
|
||||||
|
student_progress_map[student.id] = StudentProgress(
|
||||||
|
student_id=student.id,
|
||||||
|
first_name=student.first_name,
|
||||||
|
last_name=student.last_name,
|
||||||
|
patronymic=student.patronymic or "",
|
||||||
|
read_lesson_ids=sorted(read_map.get(student.id, set())),
|
||||||
|
task_grades={
|
||||||
|
task.id: grade_map.get((student.id, task.id))
|
||||||
|
for task in tasks
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return GradeBookRead(
|
||||||
|
course_id=course_id,
|
||||||
|
course_title=course.title,
|
||||||
|
lessons=[
|
||||||
|
LessonInfo(id=l.id, title=l.title, number=l.number)
|
||||||
|
for l in lessons
|
||||||
|
],
|
||||||
|
tasks=[
|
||||||
|
TaskInfo(id=t.id, title=t.title, number=t.number)
|
||||||
|
for t in tasks
|
||||||
|
],
|
||||||
|
students=list(student_progress_map.values())
|
||||||
|
)
|
||||||
@ -20,7 +20,7 @@ def start_app():
|
|||||||
|
|
||||||
api_app.add_middleware(
|
api_app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=['*'],
|
allow_origins=['https://api.lectio.numerum.team', 'https://lectio.numerum.team', 'http://localhost:5173', 'http://localhost:3000', 'http://localhost:8000'],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=['*'],
|
allow_methods=['*'],
|
||||||
allow_headers=['*'],
|
allow_headers=['*'],
|
||||||
|
|||||||
5
api/k8s/helm/lectio-api/Chart.yaml
Normal file
5
api/k8s/helm/lectio-api/Chart.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: lectio-api-app
|
||||||
|
description: Lectio API project
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "1.0"
|
||||||
52
api/k8s/helm/lectio-api/templates/deployment.yaml
Normal file
52
api/k8s/helm/lectio-api/templates/deployment.yaml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ .Chart.Name }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: {{ .Values.service.port }}
|
||||||
|
env:
|
||||||
|
- name: SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: lectio-api-secret
|
||||||
|
key: SECRET_KEY
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: lectio-api-secret
|
||||||
|
key: DB_PASSWORD
|
||||||
|
- name: DB_DRIVER
|
||||||
|
value: "{{ .Values.env.DB_DRIVER }}"
|
||||||
|
- name: DB_HOST
|
||||||
|
value: "{{ .Values.env.DB_HOST }}"
|
||||||
|
- name: DB_PORT
|
||||||
|
value: "{{ .Values.env.DB_PORT }}"
|
||||||
|
- name: DB_USER
|
||||||
|
value: "{{ .Values.env.DB_USER }}"
|
||||||
|
- name: DB_NAME
|
||||||
|
value: "{{ .Values.env.DB_NAME }}"
|
||||||
|
- name: DB_SCHEMA
|
||||||
|
value: "{{ .Values.env.DB_SCHEMA }}"
|
||||||
|
volumeMounts:
|
||||||
|
- name: uploads-volume
|
||||||
|
mountPath: {{ .Values.persistence.uploads.containerPath }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
volumes:
|
||||||
|
- name: uploads-volume
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: "{{ .Release.Name }}-uploads-pvc"
|
||||||
23
api/k8s/helm/lectio-api/templates/ingress.yaml
Normal file
23
api/k8s/helm/lectio-api/templates/ingress.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: lectio-api-ingress
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: lets-encrypt
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ .Values.ingress.domain }}
|
||||||
|
secretName: {{ .Values.ingress.secretTLSName }}
|
||||||
|
ingressClassName: public
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.ingress.domain }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: {{ .Values.ingress.path }}
|
||||||
|
pathType: {{ .Values.ingress.pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ .Chart.Name }}-service
|
||||||
|
port:
|
||||||
|
number: {{ .Values.service.port }}
|
||||||
28
api/k8s/helm/lectio-api/templates/pvc.yaml
Normal file
28
api/k8s/helm/lectio-api/templates/pvc.yaml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{{- if .Values.persistence.uploads.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolume
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-uploads-pv
|
||||||
|
spec:
|
||||||
|
capacity:
|
||||||
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: microk8s-hostpath
|
||||||
|
hostPath:
|
||||||
|
path: {{ .Values.persistence.path }}/uploads
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
persistentVolumeReclaimPolicy: Retain
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-uploads-pvc
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
|
volumeName: {{ .Release.Name }}-uploads-pv
|
||||||
|
{{- end }}
|
||||||
11
api/k8s/helm/lectio-api/templates/service.yaml
Normal file
11
api/k8s/helm/lectio-api/templates/service.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ .Chart.Name }}-service
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: {{ .Values.service.port }}
|
||||||
|
selector:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
38
api/k8s/helm/lectio-api/values.yaml
Normal file
38
api/k8s/helm/lectio-api/values.yaml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: andreiduvakin/lectio-api
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: Always
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8000
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
cpu: 200m
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
path: /mnt/k8s_storage/lectio-api
|
||||||
|
uploads:
|
||||||
|
enabled: true
|
||||||
|
size: 7Gi
|
||||||
|
containerPath: /app/uploads
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
secretTLSName: lectio-api-tls-secret
|
||||||
|
|
||||||
|
domain: api.lectio.numerum.team
|
||||||
|
|
||||||
|
path: /
|
||||||
|
pathType: Prefix
|
||||||
|
|
||||||
|
env:
|
||||||
|
DB_DRIVER: postgresql+asyncpg
|
||||||
|
DB_HOST: db.numerum.team
|
||||||
|
DB_PORT: 30000
|
||||||
|
DB_USER: lectio
|
||||||
|
DB_NAME: lectio
|
||||||
|
DB_SCHEMA: public
|
||||||
@ -7,4 +7,6 @@ werkzeug==3.1.3
|
|||||||
pyjwt==2.9.0
|
pyjwt==2.9.0
|
||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
pydantic[email]==2.11.4
|
pydantic[email]==2.11.4
|
||||||
aiofiles==25.1.0
|
aiofiles==25.1.0
|
||||||
|
uvicorn==0.34.0
|
||||||
|
python-multipart==0.0.12
|
||||||
7
web/.dockerignore
Normal file
7
web/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
build
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
k8s
|
||||||
|
.env
|
||||||
27
web/Dockerfile
Normal file
27
web/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VITE_BASE_URL
|
||||||
|
ENV VITE_BASE_URL=https://api.lectio.numerum.team/api/v1
|
||||||
|
ARG VITE_ROOT_ROLE_NAME
|
||||||
|
ENV VITE_ROOT_ROLE_NAME=root
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
COPY --from=builder /dist /app
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["serve", "-s", ".", "-l", "3000"]
|
||||||
5
web/k8s/helm/lectio-web/Chart.yaml
Normal file
5
web/k8s/helm/lectio-web/Chart.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: lectio-web-app
|
||||||
|
description: Lectio WEB project
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "1.0"
|
||||||
22
web/k8s/helm/lectio-web/templates/deployment.yaml
Normal file
22
web/k8s/helm/lectio-web/templates/deployment.yaml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ .Chart.Name }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: {{ .Values.service.port }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
23
web/k8s/helm/lectio-web/templates/ingress.yaml
Normal file
23
web/k8s/helm/lectio-web/templates/ingress.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: visus-api-ingress
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: lets-encrypt
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ .Values.ingress.domain }}
|
||||||
|
secretName: {{ .Values.ingress.secretTLSName }}
|
||||||
|
ingressClassName: public
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.ingress.domain }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: {{ .Values.ingress.path }}
|
||||||
|
pathType: {{ .Values.ingress.pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ .Chart.Name }}-service
|
||||||
|
port:
|
||||||
|
number: {{ .Values.service.port }}
|
||||||
11
web/k8s/helm/lectio-web/templates/service.yaml
Normal file
11
web/k8s/helm/lectio-web/templates/service.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ .Chart.Name }}-service
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: {{ .Values.service.port }}
|
||||||
|
selector:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
23
web/k8s/helm/lectio-web/values.yaml
Normal file
23
web/k8s/helm/lectio-web/values.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: andreiduvakin/lectio-web
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: Always
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
cpu: 200m
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
secretTLSName: lectio-web-tls-secret
|
||||||
|
|
||||||
|
domain: lectio.numerum.team
|
||||||
|
|
||||||
|
path: /
|
||||||
|
pathType: Prefix
|
||||||
@ -74,6 +74,13 @@ export const coursesApi = createApi({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: ['student'],
|
invalidatesTags: ['student'],
|
||||||
}),
|
}),
|
||||||
|
getGradebookByCourse: builder.query({
|
||||||
|
query: (courseId) => ({
|
||||||
|
url: `/courses/gradebook/${courseId}/`,
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
providesTags: ['gradebook'],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -87,4 +94,5 @@ export const {
|
|||||||
useReplaceCourseTeachersMutation,
|
useReplaceCourseTeachersMutation,
|
||||||
useGetCourseStudentsQuery,
|
useGetCourseStudentsQuery,
|
||||||
useReplaceCourseStudentsMutation,
|
useReplaceCourseStudentsMutation,
|
||||||
|
useGetGradebookByCourseQuery,
|
||||||
} = coursesApi;
|
} = coursesApi;
|
||||||
@ -39,6 +39,14 @@ export const usersApi = createApi({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: ["user"],
|
invalidatesTags: ["user"],
|
||||||
}),
|
}),
|
||||||
|
registerUser: builder.mutation({
|
||||||
|
query: (data) => ({
|
||||||
|
url: "/users/register/",
|
||||||
|
method: "POST",
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
invalidatesTags: ["user"],
|
||||||
|
}),
|
||||||
getUsersByRoleName: builder.query({
|
getUsersByRoleName: builder.query({
|
||||||
query: (roleName) => ({
|
query: (roleName) => ({
|
||||||
url: `/users/role/${roleName}/`,
|
url: `/users/role/${roleName}/`,
|
||||||
@ -76,6 +84,7 @@ export const {
|
|||||||
useUpdateUserMutation,
|
useUpdateUserMutation,
|
||||||
useUpdateUserPasswordMutation,
|
useUpdateUserPasswordMutation,
|
||||||
useCreateUserMutation,
|
useCreateUserMutation,
|
||||||
|
useRegisterUserMutation,
|
||||||
useGetUsersByRoleNameQuery,
|
useGetUsersByRoleNameQuery,
|
||||||
useGetReadedLessonsByCourseQuery,
|
useGetReadedLessonsByCourseQuery,
|
||||||
useSetLessonAsReadedMutation,
|
useSetLessonAsReadedMutation,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx";
|
|||||||
import AdminPage from "../Components/Pages/AdminPage/AdminPage.jsx";
|
import AdminPage from "../Components/Pages/AdminPage/AdminPage.jsx";
|
||||||
import CourseDetailPage from "../Components/Pages/CourseDetailPage/CourseDetailPage.jsx";
|
import CourseDetailPage from "../Components/Pages/CourseDetailPage/CourseDetailPage.jsx";
|
||||||
import CoursesPage from "../Components/Pages/CoursesPage/CoursesPage.jsx";
|
import CoursesPage from "../Components/Pages/CoursesPage/CoursesPage.jsx";
|
||||||
|
import GradebookPage from "../Components/Pages/GradebookPage/GradebookPage.jsx";
|
||||||
|
|
||||||
|
|
||||||
const AppRouter = () => (
|
const AppRouter = () => (
|
||||||
@ -20,6 +21,7 @@ const AppRouter = () => (
|
|||||||
<Route path={"/courses"} element={<CoursesPage/>}/>
|
<Route path={"/courses"} element={<CoursesPage/>}/>
|
||||||
<Route path={"/profile"} element={<ProfilePage/>}/>
|
<Route path={"/profile"} element={<ProfilePage/>}/>
|
||||||
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
||||||
|
<Route path="/courses/:courseId/gradebook" element={<GradebookPage />} />
|
||||||
<Route path={"*"} element={<Navigate to={"/courses"}/>}/>
|
<Route path={"*"} element={<Navigate to={"/courses"}/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -56,11 +56,6 @@ const AdminPage = () => {
|
|||||||
filters: rolesData.map(status => ({text: status.title, value: status.title})),
|
filters: rolesData.map(status => ({text: status.title, value: status.title})),
|
||||||
onFilter: (value, record) => record.status.title === value,
|
onFilter: (value, record) => record.status.title === value,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Статус",
|
|
||||||
dataIndex: "status",
|
|
||||||
key: "status",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Действия",
|
title: "Действия",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const ViewTaskModal = () => {
|
|||||||
currentTaskFiles,
|
currentTaskFiles,
|
||||||
isCurrentTaskFilesLoading,
|
isCurrentTaskFilesLoading,
|
||||||
isCurrentTaskFilesError,
|
isCurrentTaskFilesError,
|
||||||
downloadFile,
|
downloadTasksFile,
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
currentUser,
|
currentUser,
|
||||||
mySolutions,
|
mySolutions,
|
||||||
@ -52,7 +52,10 @@ const ViewTaskModal = () => {
|
|||||||
onAssessmentFinish,
|
onAssessmentFinish,
|
||||||
assessmentForm,
|
assessmentForm,
|
||||||
onCommentSubmit,
|
onCommentSubmit,
|
||||||
commentForm
|
commentForm,
|
||||||
|
setComment,
|
||||||
|
comment,
|
||||||
|
downloadSolutionFile,
|
||||||
} = useViewTaskModal();
|
} = useViewTaskModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -124,7 +127,7 @@ const ViewTaskModal = () => {
|
|||||||
<span>{file.filename || "Не указан"}</span>
|
<span>{file.filename || "Не указан"}</span>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => downloadFile(file.id, file.filename)}
|
onClick={() => downloadTasksFile(file.id, file.filename)}
|
||||||
loading={downloadingFiles[file.id] || false}
|
loading={downloadingFiles[file.id] || false}
|
||||||
disabled={downloadingFiles[file.id] || false}
|
disabled={downloadingFiles[file.id] || false}
|
||||||
type={"dashed"}
|
type={"dashed"}
|
||||||
@ -160,22 +163,6 @@ const ViewTaskModal = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Tag color="blue">На проверке</Tag>
|
<Tag color="blue">На проверке</Tag>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Popconfirm
|
|
||||||
title={`Удалить ответ на задание?`}
|
|
||||||
description="Это действие нельзя отменить"
|
|
||||||
onConfirm={(e) => {
|
|
||||||
handleDeleSolution(solution.id)
|
|
||||||
}}
|
|
||||||
okText="Удалить"
|
|
||||||
cancelText="Отмена"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined/>}
|
|
||||||
/>
|
|
||||||
</Popconfirm>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
@ -216,7 +203,7 @@ const ViewTaskModal = () => {
|
|||||||
type="dashed"
|
type="dashed"
|
||||||
icon={<FileOutlined/>}
|
icon={<FileOutlined/>}
|
||||||
style={{textAlign: "left"}}
|
style={{textAlign: "left"}}
|
||||||
onClick={() => downloadFile(file.id, file.filename)}
|
onClick={() => downloadSolutionFile(file.id, file.filename)}
|
||||||
loading={downloadingFiles[file.id]}
|
loading={downloadingFiles[file.id]}
|
||||||
>
|
>
|
||||||
<span style={{marginLeft: 8}}>
|
<span style={{marginLeft: 8}}>
|
||||||
@ -230,7 +217,7 @@ const ViewTaskModal = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Text type="secondary">Файлы не прикреплены</Text>
|
<Text type="secondary">Файлы не прикреплены</Text>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginTop: 32 }}>
|
<div style={{marginTop: 32}}>
|
||||||
<Title level={4}>Комментарии к решению</Title>
|
<Title level={4}>Комментарии к решению</Title>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -247,10 +234,13 @@ const ViewTaskModal = () => {
|
|||||||
<List
|
<List
|
||||||
dataSource={solution.solution_comments}
|
dataSource={solution.solution_comments}
|
||||||
renderItem={(comment) => (
|
renderItem={(comment) => (
|
||||||
<List.Item style={{ padding: "12px 16px", borderBottom: "1px solid #f0f0f0" }}>
|
<List.Item style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: "1px solid #f0f0f0"
|
||||||
|
}}>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
<Avatar style={{ backgroundColor: "#1890ff" }}>
|
<Avatar style={{backgroundColor: "#1890ff"}}>
|
||||||
{comment.comment_autor.first_name[0]}
|
{comment.comment_autor.first_name[0]}
|
||||||
{comment.comment_autor.last_name[0]}
|
{comment.comment_autor.last_name[0]}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -261,12 +251,13 @@ const ViewTaskModal = () => {
|
|||||||
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
|
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
|
||||||
</Text>
|
</Text>
|
||||||
{comment.comment_autor.role?.title === "teacher" && (
|
{comment.comment_autor.role?.title === "teacher" && (
|
||||||
<Tag color="gold" size="small">Преподаватель</Tag>
|
<Tag color="gold"
|
||||||
|
size="small">Преподаватель</Tag>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{fontSize: 12}}>
|
||||||
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
|
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
@ -278,7 +269,7 @@ const ViewTaskModal = () => {
|
|||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
|
dangerouslySetInnerHTML={{__html: comment.comment_text}}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
@ -287,33 +278,23 @@ const ViewTaskModal = () => {
|
|||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description="Пока нет комментариев"
|
description="Пока нет комментариев"
|
||||||
style={{ margin: "20px 0" }}
|
style={{margin: "20px 0"}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Напишите комментарий к решению..."
|
||||||
|
allowClear
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
style={{ marginTop: 20, marginBottom: 20}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Button onClick={() => onCommentSubmit(solution.id)} type="primary"
|
||||||
onFinish={(values) => onCommentSubmit(solution.id, values.comment)}
|
htmlType="submit">
|
||||||
style={{ marginTop: 16 }}
|
Отправить комментарий
|
||||||
form={commentForm}
|
</Button>
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
name="comment"
|
|
||||||
rules={[{ required: true, message: "Напишите комментарий" }]}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={3}
|
|
||||||
placeholder="Напишите комментарий к решению..."
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
|
||||||
<Button type="primary" htmlType="submit">
|
|
||||||
Отправить комментарий
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
))}
|
))}
|
||||||
@ -408,7 +389,7 @@ const ViewTaskModal = () => {
|
|||||||
type="dashed"
|
type="dashed"
|
||||||
icon={<FileOutlined/>}
|
icon={<FileOutlined/>}
|
||||||
style={{textAlign: "left"}}
|
style={{textAlign: "left"}}
|
||||||
onClick={() => downloadFile(file.id, file.filename)}
|
onClick={() => downloadSolutionFile(file.id, file.filename)}
|
||||||
loading={downloadingFiles[file.id]}
|
loading={downloadingFiles[file.id]}
|
||||||
>
|
>
|
||||||
<span style={{marginLeft: 8}}>
|
<span style={{marginLeft: 8}}>
|
||||||
@ -447,7 +428,7 @@ const ViewTaskModal = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<div style={{ marginTop: 32 }}>
|
<div style={{marginTop: 32}}>
|
||||||
<Title level={4}>Комментарии к решению</Title>
|
<Title level={4}>Комментарии к решению</Title>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -464,10 +445,13 @@ const ViewTaskModal = () => {
|
|||||||
<List
|
<List
|
||||||
dataSource={solution.solution_comments}
|
dataSource={solution.solution_comments}
|
||||||
renderItem={(comment) => (
|
renderItem={(comment) => (
|
||||||
<List.Item style={{ padding: "12px 16px", borderBottom: "1px solid #f0f0f0" }}>
|
<List.Item style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: "1px solid #f0f0f0"
|
||||||
|
}}>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
<Avatar style={{ backgroundColor: "#1890ff" }}>
|
<Avatar style={{backgroundColor: "#1890ff"}}>
|
||||||
{comment.comment_autor.first_name[0]}
|
{comment.comment_autor.first_name[0]}
|
||||||
{comment.comment_autor.last_name[0]}
|
{comment.comment_autor.last_name[0]}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -478,12 +462,13 @@ const ViewTaskModal = () => {
|
|||||||
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
|
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
|
||||||
</Text>
|
</Text>
|
||||||
{comment.comment_autor.role?.title === "teacher" && (
|
{comment.comment_autor.role?.title === "teacher" && (
|
||||||
<Tag color="gold" size="small">Преподаватель</Tag>
|
<Tag color="gold"
|
||||||
|
size="small">Преподаватель</Tag>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{fontSize: 12}}>
|
||||||
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
|
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
@ -495,7 +480,7 @@ const ViewTaskModal = () => {
|
|||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
|
dangerouslySetInnerHTML={{__html: comment.comment_text}}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
@ -504,33 +489,25 @@ const ViewTaskModal = () => {
|
|||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description="Пока нет комментариев"
|
description="Пока нет комментариев"
|
||||||
style={{ margin: "20px 0" }}
|
style={{margin: "20px 0"}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form
|
|
||||||
onFinish={(values) => onCommentSubmit(solution.id, values.comment)}
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
form={commentForm}
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
name="comment"
|
|
||||||
rules={[{ required: true, message: "Напишите комментарий" }]}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={3}
|
|
||||||
placeholder="Напишите комментарий к решению..."
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
<Input.TextArea
|
||||||
<Button type="primary" htmlType="submit">
|
rows={3}
|
||||||
Отправить комментарий
|
placeholder="Напишите комментарий к решению..."
|
||||||
</Button>
|
allowClear
|
||||||
</Form.Item>
|
value={comment}
|
||||||
</Form>
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
style={{ marginTop: 20, marginBottom: 20}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={() => onCommentSubmit(solution.id)} type="primary"
|
||||||
|
htmlType="submit">
|
||||||
|
Отправить комментарий
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const useViewTaskModal = () => {
|
|||||||
} = useSelector((state) => state.tasks);
|
} = useSelector((state) => state.tasks);
|
||||||
|
|
||||||
const [assessmentForm] = Form.useForm();
|
const [assessmentForm] = Form.useForm();
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
|
||||||
const [
|
const [
|
||||||
createSolution,
|
createSolution,
|
||||||
@ -43,34 +44,35 @@ const useViewTaskModal = () => {
|
|||||||
isError: isErrorCreatingComment
|
isError: isErrorCreatingComment
|
||||||
}] = useCreateCommentMutation();
|
}] = useCreateCommentMutation();
|
||||||
|
|
||||||
const onCommentSubmit = async (solutionId, commentText) => {
|
const onCommentSubmit = async (solutionId) => {
|
||||||
if (!commentText?.trim()) return;
|
if (!comment?.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createComment({
|
await createComment({
|
||||||
solutionId: solutionId,
|
solutionId: solutionId,
|
||||||
comment: {
|
comment: {
|
||||||
comment_text: commentText.trim()
|
comment_text: comment.trim()
|
||||||
}
|
}
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
commentForm.resetFields();
|
setComment("");
|
||||||
notification.success({
|
notification.success({
|
||||||
message: "Комментарий отправлен",
|
title: "Комментарий отправлен",
|
||||||
description: "Ваш комментарий успешно добавлен",
|
description: "Ваш комментарий успешно добавлен",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notification.error({
|
notification.error({
|
||||||
message: "Ошибка",
|
title: "Ошибка",
|
||||||
description: error?.data?.detail || "Не удалось отправить комментарий",
|
description: error?.data?.detail || "Не удалось отправить комментарий",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleAddFile = (file) => {
|
const handleAddFile = (file) => {
|
||||||
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
|
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
notification.error({
|
notification.error({
|
||||||
message: "Ошибка вставки",
|
title: "Ошибка вставки",
|
||||||
description: "Файл слишком большой.",
|
description: "Файл слишком большой.",
|
||||||
placement: "topRight",
|
placement: "topRight",
|
||||||
});
|
});
|
||||||
@ -197,7 +199,84 @@ const useViewTaskModal = () => {
|
|||||||
const [commentForm] = Form.useForm();
|
const [commentForm] = Form.useForm();
|
||||||
const [downloadingFiles, setDownloadingFiles] = useState({});
|
const [downloadingFiles, setDownloadingFiles] = useState({});
|
||||||
|
|
||||||
const downloadFile = async (fileId, fileName) => {
|
const downloadTasksFile = async (fileId, fileName) => {
|
||||||
|
try {
|
||||||
|
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: "Токен не найден",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const response = await fetch(`${CONFIG.BASE_URL}/tasks/file/${fileId}/`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: errorText || "Не удалось скачать файл",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || contentType.includes('text/html')) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: errorText || "Не удалось скачать файл",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let safeFileName = fileName || "file";
|
||||||
|
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
|
||||||
|
if (contentType.includes('application/pdf')) {
|
||||||
|
safeFileName += '.pdf';
|
||||||
|
} else if (contentType.includes('image/jpeg')) {
|
||||||
|
safeFileName += '.jpg';
|
||||||
|
} else if (contentType.includes('image/png')) {
|
||||||
|
safeFileName += '.png';
|
||||||
|
} else {
|
||||||
|
safeFileName += '.bin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.setAttribute("download", safeFileName);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: error.message || "Не удалось скачать файл",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSolutionFile = async (fileId, fileName) => {
|
||||||
try {
|
try {
|
||||||
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
|
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
|
||||||
|
|
||||||
@ -372,7 +451,7 @@ const useViewTaskModal = () => {
|
|||||||
currentTaskFiles,
|
currentTaskFiles,
|
||||||
isCurrentTaskFilesLoading,
|
isCurrentTaskFilesLoading,
|
||||||
isCurrentTaskFilesError,
|
isCurrentTaskFilesError,
|
||||||
downloadFile,
|
downloadTasksFile,
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
currentUser,
|
currentUser,
|
||||||
mySolutions,
|
mySolutions,
|
||||||
@ -387,6 +466,9 @@ const useViewTaskModal = () => {
|
|||||||
onAssessmentFinish,
|
onAssessmentFinish,
|
||||||
assessmentForm,
|
assessmentForm,
|
||||||
onCommentSubmit,
|
onCommentSubmit,
|
||||||
|
setComment,
|
||||||
|
comment,
|
||||||
|
downloadSolutionFile,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
Empty,
|
Empty,
|
||||||
FloatButton,
|
FloatButton, Grid,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Result,
|
Result,
|
||||||
Row,
|
Row,
|
||||||
@ -19,8 +19,8 @@ import {
|
|||||||
BookOutlined, CheckCircleFilled, ClockCircleOutlined,
|
BookOutlined, CheckCircleFilled, ClockCircleOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
FormOutlined,
|
FormOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||||
PlusOutlined
|
PlusOutlined, TableOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import {useNavigate, useParams} from "react-router-dom";
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
import {ROLES} from "../../../Core/constants.js";
|
import {ROLES} from "../../../Core/constants.js";
|
||||||
@ -32,12 +32,17 @@ import UpdateLessonModalForm from "./Components/UpdateLessonModalForm/UpdateLess
|
|||||||
import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx";
|
import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx";
|
||||||
import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx";
|
import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx";
|
||||||
import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx";
|
import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
|
||||||
const {Title, Text} = Typography;
|
const {Title, Text} = Typography;
|
||||||
|
const {useBreakpoint} = Grid;
|
||||||
|
|
||||||
const CourseDetailPage = () => {
|
const CourseDetailPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
const {courseId} = useParams();
|
const {courseId} = useParams();
|
||||||
const {
|
const {
|
||||||
tasksData,
|
tasksData,
|
||||||
@ -242,6 +247,39 @@ const CourseDetailPage = () => {
|
|||||||
<ViewTaskModal
|
<ViewTaskModal
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: 0,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
transition: "right 0.3s ease",
|
||||||
|
zIndex: 1000,
|
||||||
|
display: screens.xs ? "none" : "block",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/courses/${courseId}/gradebook/`)
|
||||||
|
}}
|
||||||
|
icon={<TableOutlined/>}
|
||||||
|
style={{
|
||||||
|
width: hovered ? 250 : 50,
|
||||||
|
padding: hovered ? "0 20px" : "0",
|
||||||
|
overflow: "hidden",
|
||||||
|
textAlign: "left",
|
||||||
|
transition: "width 0.3s ease, padding 0.3s ease",
|
||||||
|
borderRadius: "4px 0 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hovered && (
|
||||||
|
<>Журнал успеваемости</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<UpdateTaskModalForm/>
|
<UpdateTaskModalForm/>
|
||||||
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
|
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
|
||||||
<FloatButton.Group
|
<FloatButton.Group
|
||||||
@ -262,7 +300,9 @@ const CourseDetailPage = () => {
|
|||||||
onClick={handleCreateTask}
|
onClick={handleCreateTask}
|
||||||
/>
|
/>
|
||||||
</FloatButton.Group>
|
</FloatButton.Group>
|
||||||
)}
|
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@ -156,7 +156,7 @@ const CoursesPage = () => {
|
|||||||
<Divider/>
|
<Divider/>
|
||||||
<Text type="secondary">Прогресс:</Text>
|
<Text type="secondary">Прогресс:</Text>
|
||||||
{courseProgress[course.id] !== undefined && (
|
{courseProgress[course.id] !== undefined && (
|
||||||
<Progress percent={courseProgress[course.id]} size={[300, 20]}/>
|
<Progress showInfo={true} status={"active"} percent={courseProgress[course.id]} size={[300, 20]}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -166,13 +166,15 @@ const CoursesPage = () => {
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip title="Создать курс">
|
{(isTeacher || isAdmin) && (
|
||||||
<FloatButton
|
<Tooltip title="Создать курс">
|
||||||
icon={<PlusOutlined/>}
|
<FloatButton
|
||||||
onClick={openCreateModal}
|
icon={<PlusOutlined/>}
|
||||||
type="primary"
|
onClick={openCreateModal}
|
||||||
/>
|
type="primary"
|
||||||
</Tooltip>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<CreateCourseModalForm/>
|
<CreateCourseModalForm/>
|
||||||
<UpdateCourseModalForm/>
|
<UpdateCourseModalForm/>
|
||||||
|
|||||||
@ -1,723 +1,217 @@
|
|||||||
import { useState, useMemo } from "react";
|
import {useParams, useNavigate} from "react-router-dom";
|
||||||
import { Avatar, Progress, Tag, Tooltip, Space, Button, Table, Card, Row, Col, Statistic, Select, Typography, Input, Modal, Badge } from "antd";
|
import {
|
||||||
import { UserOutlined, SearchOutlined, SortAscendingOutlined, SortDescendingOutlined, FilterOutlined } from "@ant-design/icons";
|
Table,
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
Spin,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Empty,
|
||||||
|
Result,
|
||||||
|
Avatar, Statistic,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
MinusOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
useGetCourseByIdQuery,
|
||||||
|
useGetGradebookByCourseQuery,
|
||||||
|
} from "../../../Api/coursesApi.js";
|
||||||
|
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
|
||||||
|
|
||||||
const { Title } = Typography;
|
const {Title, Text} = Typography;
|
||||||
const { Search } = Input;
|
|
||||||
|
|
||||||
const GradebookPage = ({ onLogout }) => {
|
const GradebookPage = () => {
|
||||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const {courseId} = useParams();
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
groups: [],
|
|
||||||
searchText: "",
|
|
||||||
progressRange: [0, 100]
|
|
||||||
});
|
|
||||||
const [sortConfig, setSortConfig] = useState({
|
|
||||||
key: null,
|
|
||||||
direction: 'ascend'
|
|
||||||
});
|
|
||||||
const [isSearchModalVisible, setIsSearchModalVisible] = useState(false);
|
|
||||||
|
|
||||||
// Заглушки для данных
|
// Данные текущего пользователя
|
||||||
const courses = [
|
const {data: userData, isLoading: userLoading} = useGetAuthenticatedUserDataQuery();
|
||||||
{ id: 1, title: "Основы программирования" },
|
|
||||||
{ id: 2, title: "Веб-разработка" },
|
|
||||||
{ id: 3, title: "Базы данных" },
|
|
||||||
{ id: 4, title: "Алгоритмы и структуры данных" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const assignments = [
|
const {
|
||||||
{ id: 1, name: "ДЗ №1", maxScore: 100 },
|
data: courseData,
|
||||||
{ id: 2, name: "ДЗ №2", maxScore: 100 },
|
isLoading: courseLoading,
|
||||||
{ id: 3, name: "ДЗ №3", maxScore: 100 },
|
} = useGetCourseByIdQuery(courseId);
|
||||||
{ id: 4, name: "Проект", maxScore: 200 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const students = [
|
const {
|
||||||
{
|
data: gradebook,
|
||||||
id: 1,
|
isLoading: gradebookLoading,
|
||||||
firstName: "Иван",
|
isError: gradebookError,
|
||||||
lastName: "Иванов",
|
} = useGetGradebookByCourseQuery(courseId, {pollingInterval: 15000});
|
||||||
email: "ivanov@mail.com",
|
|
||||||
group: "ПИ-201",
|
|
||||||
grades: { 1: 85, 2: 92, 3: 78, 4: 180 },
|
|
||||||
progress: 85
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
firstName: "Мария",
|
|
||||||
lastName: "Петрова",
|
|
||||||
email: "petrova@mail.com",
|
|
||||||
group: "ПИ-201",
|
|
||||||
grades: { 1: 95, 2: 88, 3: 91, 4: 190 },
|
|
||||||
progress: 92
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
firstName: "Алексей",
|
|
||||||
lastName: "Сидоров",
|
|
||||||
email: "sidorov@mail.com",
|
|
||||||
group: "ПИ-202",
|
|
||||||
grades: { 1: 72, 2: null, 3: 85, 4: null },
|
|
||||||
progress: 52
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
firstName: "Екатерина",
|
|
||||||
lastName: "Смирнова",
|
|
||||||
email: "smirnova@mail.com",
|
|
||||||
group: "ПИ-202",
|
|
||||||
grades: { 1: 88, 2: 90, 3: 87, 4: 175 },
|
|
||||||
progress: 88
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
firstName: "Дмитрий",
|
|
||||||
lastName: "Козлов",
|
|
||||||
email: "kozlov@mail.com",
|
|
||||||
group: "ПИ-203",
|
|
||||||
grades: { 1: 65, 2: 70, 3: null, 4: null },
|
|
||||||
progress: 45
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
firstName: "Анна",
|
|
||||||
lastName: "Волкова",
|
|
||||||
email: "volkova@mail.com",
|
|
||||||
group: "ПИ-201",
|
|
||||||
grades: { 1: 100, 2: 98, 3: 95, 4: 195 },
|
|
||||||
progress: 97
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
firstName: "Сергей",
|
|
||||||
lastName: "Орлов",
|
|
||||||
email: "orlov@mail.com",
|
|
||||||
group: "ПИ-203",
|
|
||||||
grades: { 1: 80, 2: 85, 3: 82, 4: 170 },
|
|
||||||
progress: 79
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
firstName: "Ольга",
|
|
||||||
lastName: "Лебедева",
|
|
||||||
email: "lebedeva@mail.com",
|
|
||||||
group: "ПИ-202",
|
|
||||||
grades: { 1: 90, 2: 92, 3: 88, 4: 185 },
|
|
||||||
progress: 89
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Получение уникальных групп для фильтра
|
const isLoading = courseLoading || gradebookLoading || userLoading;
|
||||||
const uniqueGroups = useMemo(() => {
|
|
||||||
return [...new Set(students.map(student => student.group))];
|
|
||||||
}, [students]);
|
|
||||||
|
|
||||||
// Фильтрация и сортировка данных
|
|
||||||
const filteredAndSortedStudents = useMemo(() => {
|
|
||||||
let filtered = students.filter(student => {
|
|
||||||
// Фильтр по группам
|
|
||||||
if (filters.groups.length > 0 && !filters.groups.includes(student.group)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Поиск по тексту (ФИО, email, группа)
|
|
||||||
if (filters.searchText) {
|
|
||||||
const searchLower = filters.searchText.toLowerCase();
|
|
||||||
const fullName = `${student.firstName} ${student.lastName}`.toLowerCase();
|
|
||||||
if (!fullName.includes(searchLower) &&
|
|
||||||
!student.email.toLowerCase().includes(searchLower) &&
|
|
||||||
!student.group.toLowerCase().includes(searchLower)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтр по прогрессу
|
|
||||||
if (student.progress < filters.progressRange[0] || student.progress > filters.progressRange[1]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Сортировка
|
|
||||||
if (sortConfig.key) {
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
let aValue, bValue;
|
|
||||||
|
|
||||||
switch (sortConfig.key) {
|
|
||||||
case 'student':
|
|
||||||
aValue = `${a.firstName} ${a.lastName}`;
|
|
||||||
bValue = `${b.firstName} ${b.lastName}`;
|
|
||||||
break;
|
|
||||||
case 'group':
|
|
||||||
aValue = a.group;
|
|
||||||
bValue = b.group;
|
|
||||||
break;
|
|
||||||
case 'progress':
|
|
||||||
aValue = a.progress;
|
|
||||||
bValue = b.progress;
|
|
||||||
break;
|
|
||||||
case 'average':
|
|
||||||
const aGrades = Object.values(a.grades).filter(g => g !== null);
|
|
||||||
const bGrades = Object.values(b.grades).filter(g => g !== null);
|
|
||||||
aValue = aGrades.length > 0 ? aGrades.reduce((sum, grade) => sum + grade, 0) / aGrades.length : 0;
|
|
||||||
bValue = bGrades.length > 0 ? bGrades.reduce((sum, grade) => sum + grade, 0) / bGrades.length : 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Сортировка по заданиям
|
|
||||||
if (sortConfig.key.startsWith('assignment_')) {
|
|
||||||
const assignmentId = parseInt(sortConfig.key.split('_')[1]);
|
|
||||||
aValue = a.grades[assignmentId] || 0;
|
|
||||||
bValue = b.grades[assignmentId] || 0;
|
|
||||||
} else {
|
|
||||||
aValue = a[sortConfig.key];
|
|
||||||
bValue = b[sortConfig.key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aValue < bValue) {
|
|
||||||
return sortConfig.direction === 'ascend' ? -1 : 1;
|
|
||||||
}
|
|
||||||
if (aValue > bValue) {
|
|
||||||
return sortConfig.direction === 'ascend' ? 1 : -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [students, filters, sortConfig]);
|
|
||||||
|
|
||||||
// Обработчики фильтров
|
|
||||||
const handleGroupFilter = (selectedGroups) => {
|
|
||||||
setFilters(prev => ({ ...prev, groups: selectedGroups }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = (value) => {
|
|
||||||
setFilters(prev => ({ ...prev, searchText: value }));
|
|
||||||
setIsSearchModalVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSort = (key) => {
|
|
||||||
setSortConfig(prev => ({
|
|
||||||
key,
|
|
||||||
direction: prev.key === key && prev.direction === 'ascend' ? 'descend' : 'ascend'
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Колонки таблицы с фильтрацией и сортировкой
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
const baseColumns = [
|
|
||||||
{
|
|
||||||
title: (
|
|
||||||
<Space>
|
|
||||||
Студент
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={sortConfig.key === 'student' ?
|
|
||||||
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
|
|
||||||
<SortAscendingOutlined />
|
|
||||||
}
|
|
||||||
onClick={() => handleSort('student')}
|
|
||||||
/>
|
|
||||||
<Tooltip title="Поиск студентов">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<SearchOutlined />}
|
|
||||||
onClick={() => setIsSearchModalVisible(true)}
|
|
||||||
style={{
|
|
||||||
color: filters.searchText ? '#1890ff' : undefined
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
key: "student",
|
|
||||||
fixed: "left",
|
|
||||||
width: 250,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Avatar
|
|
||||||
style={{ backgroundColor: "#1890ff" }}
|
|
||||||
icon={<UserOutlined />}
|
|
||||||
>
|
|
||||||
{record.firstName[0]}{record.lastName[0]}
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>
|
|
||||||
{record.firstName} {record.lastName}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "#8c8c8c" }}>
|
|
||||||
{record.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: (
|
|
||||||
<Space>
|
|
||||||
Группа
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={sortConfig.key === 'group' ?
|
|
||||||
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
|
|
||||||
<SortAscendingOutlined />
|
|
||||||
}
|
|
||||||
onClick={() => handleSort('group')}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
dataIndex: "group",
|
|
||||||
key: "group",
|
|
||||||
width: 120,
|
|
||||||
render: (group) => <Tag color="blue">{group}</Tag>,
|
|
||||||
filters: uniqueGroups.map(group => ({ text: group, value: group })),
|
|
||||||
filteredValue: filters.groups,
|
|
||||||
onFilter: (value, record) => record.group === value,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Колонки для заданий
|
|
||||||
const assignmentColumns = assignments.map(assignment => ({
|
|
||||||
title: (
|
|
||||||
<Space>
|
|
||||||
{assignment.name}
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={sortConfig.key === `assignment_${assignment.id}` ?
|
|
||||||
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
|
|
||||||
<SortAscendingOutlined />
|
|
||||||
}
|
|
||||||
onClick={() => handleSort(`assignment_${assignment.id}`)}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
key: `assignment_${assignment.id}`,
|
|
||||||
width: 130,
|
|
||||||
align: "center",
|
|
||||||
render: (_, record) => {
|
|
||||||
const grade = record.grades[assignment.id];
|
|
||||||
if (grade === null || grade === undefined) {
|
|
||||||
return <Tag color="default">Не сдано</Tag>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentage = (grade / assignment.maxScore) * 100;
|
|
||||||
let color = "red";
|
|
||||||
if (percentage >= 90) color = "green";
|
|
||||||
else if (percentage >= 75) color = "blue";
|
|
||||||
else if (percentage >= 60) color = "orange";
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={`${grade} из ${assignment.maxScore} (${percentage.toFixed(1)}%)`}>
|
<div style={{padding: 80, textAlign: "center"}}>
|
||||||
<Tag color={color}>{grade}</Tag>
|
<Spin size="large" tip="Загружается журнал..."/>
|
||||||
</Tooltip>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Колонки итогов
|
|
||||||
const summaryColumns = [
|
|
||||||
{
|
|
||||||
title: (
|
|
||||||
<Space>
|
|
||||||
Средний балл
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={sortConfig.key === 'average' ?
|
|
||||||
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
|
|
||||||
<SortAscendingOutlined />
|
|
||||||
}
|
|
||||||
onClick={() => handleSort('average')}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
key: "average",
|
|
||||||
width: 140,
|
|
||||||
align: "center",
|
|
||||||
render: (_, record) => {
|
|
||||||
const grades = Object.values(record.grades).filter(g => g !== null);
|
|
||||||
if (grades.length === 0) return <Tag>Нет оценок</Tag>;
|
|
||||||
|
|
||||||
const totalMax = assignments.reduce((sum, a) => sum + a.maxScore, 0);
|
|
||||||
const totalGrade = Object.entries(record.grades)
|
|
||||||
.reduce((sum, [assignmentId, grade]) => {
|
|
||||||
if (grade === null) return sum;
|
|
||||||
return sum + grade;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const percentage = (totalGrade / totalMax) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title={`${totalGrade} из ${totalMax} баллов`}>
|
|
||||||
<Tag color={percentage >= 75 ? "green" : percentage >= 60 ? "orange" : "red"}>
|
|
||||||
{percentage.toFixed(1)}%
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: (
|
|
||||||
<Space>
|
|
||||||
Прогресс
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={sortConfig.key === 'progress' ?
|
|
||||||
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
|
|
||||||
<SortAscendingOutlined />
|
|
||||||
}
|
|
||||||
onClick={() => handleSort('progress')}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
key: "progress",
|
|
||||||
width: 160,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Tooltip title={`${record.progress}% выполнено`}>
|
|
||||||
<Progress
|
|
||||||
percent={record.progress}
|
|
||||||
size="small"
|
|
||||||
status={record.progress < 50 ? "exception" : record.progress < 75 ? "normal" : "success"}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gradebookError || !gradebook) {
|
||||||
|
return <Empty description="Не удалось загрузить журнал"/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStudent = userData?.role?.title === "student";
|
||||||
|
const currentStudentId = userData?.id;
|
||||||
|
|
||||||
|
// Если студент — фильтруем только его данные
|
||||||
|
const visibleStudents = isStudent
|
||||||
|
? gradebook.students.filter((s) => s.student_id === currentStudentId)
|
||||||
|
: gradebook.students;
|
||||||
|
|
||||||
|
// Если студент, но его нет в курсе — покажем сообщение
|
||||||
|
if (isStudent && visibleStudents.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card style={{margin: 24}}>
|
||||||
|
<Result
|
||||||
|
status="info"
|
||||||
|
title="Вы не записаны на этот курс"
|
||||||
|
subTitle="Чтобы видеть свою успеваемость, нужно быть зачисленным на курс."
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => navigate(-1)}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Колонки ===
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
title: "№",
|
||||||
|
width: 60,
|
||||||
|
fixed: "left",
|
||||||
|
render: (_, __, index) => index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Студент",
|
||||||
|
fixed: "left",
|
||||||
|
width: isStudent ? 300 : 240,
|
||||||
|
render: (record) => {
|
||||||
|
const fullName = `${record.last_name} ${record.first_name} ${
|
||||||
|
record.patronymic ? record.patronymic[0] + "." : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (isStudent) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Avatar icon={<UserOutlined/>} style={{backgroundColor: "#1890ff"}}/>
|
||||||
|
<div>
|
||||||
|
<Text strong>{fullName}</Text>
|
||||||
|
<br/>
|
||||||
|
<Text type="secondary">Ваша успеваемость</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Text strong>{fullName}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return [...baseColumns, ...assignmentColumns, ...summaryColumns];
|
const lessonColumns = gradebook.lessons.map((lesson) => ({
|
||||||
}, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]);
|
title: (
|
||||||
|
<Tooltip title={lesson.title}>
|
||||||
|
<div style={{fontSize: 12}}>Л{lesson.number}</div>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
width: 70,
|
||||||
|
align: "center",
|
||||||
|
render: (record) =>
|
||||||
|
record.read_lesson_ids.includes(lesson.id) ? (
|
||||||
|
<CheckCircleFilled style={{color: "#52c41a", fontSize: 18}}/>
|
||||||
|
) : (
|
||||||
|
<ClockCircleOutlined style={{color: "#d9d9d9", fontSize: 18}}/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
// Статистика с учетом фильтров
|
const taskColumns = gradebook.tasks.map((task) => ({
|
||||||
const statistics = useMemo(() => {
|
title: (
|
||||||
if (!selectedCourse) {
|
<Tooltip title={task.title}>
|
||||||
return {
|
<div style={{fontSize: 12}}>З{task.number}</div>
|
||||||
totalStudents: 0,
|
</Tooltip>
|
||||||
averageGrade: 0,
|
),
|
||||||
completedAssignments: 0,
|
width: 90,
|
||||||
totalAssignments: 0,
|
align: "center",
|
||||||
pendingReview: 0
|
render: (record) => {
|
||||||
};
|
const grade = record.task_grades[task.id];
|
||||||
}
|
if (grade === null || grade === undefined) {
|
||||||
|
return <MinusOutlined style={{color: "#bfbfbf"}}/>;
|
||||||
|
}
|
||||||
|
const color = grade >= 90 ? "green" : grade >= 70 ? "orange" : "red";
|
||||||
|
return <Tag color={color}>{grade}</Tag>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const totalStudents = filteredAndSortedStudents.length;
|
const columns = [...baseColumns, ...lessonColumns, ...taskColumns];
|
||||||
const totalAssignments = assignments.length * filteredAndSortedStudents.length;
|
|
||||||
|
|
||||||
let completedCount = 0;
|
|
||||||
let totalGradeSum = 0;
|
|
||||||
let gradedCount = 0;
|
|
||||||
|
|
||||||
filteredAndSortedStudents.forEach(student => {
|
const dataSource = visibleStudents.map((student) => ({
|
||||||
Object.values(student.grades).forEach(grade => {
|
key: student.student_id,
|
||||||
if (grade !== null) {
|
...student,
|
||||||
completedCount++;
|
}));
|
||||||
totalGradeSum += grade;
|
|
||||||
gradedCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const averageGrade = gradedCount > 0 ? (totalGradeSum / gradedCount).toFixed(1) : 0;
|
return (
|
||||||
const pendingReview = filteredAndSortedStudents.reduce((count, student) => {
|
<div style={{padding: 24, backgroundColor: "#f5f5f5", minHeight: "100vh"}}>
|
||||||
const nullGrades = Object.values(student.grades).filter(grade => grade === null).length;
|
<Card style={{marginBottom: 24}}>
|
||||||
return count + nullGrades;
|
<Space direction="vertical" size="middle" style={{width: "100%"}}>
|
||||||
}, 0);
|
<Button icon={<ArrowLeftOutlined/>} onClick={() => navigate(-1)} type="text">
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
|
||||||
return {
|
<Title level={2} style={{margin: 0}}>
|
||||||
totalStudents,
|
{isStudent ? "Моя успеваемость" : "Журнал успеваемости"} — {courseData?.title}
|
||||||
averageGrade,
|
</Title>
|
||||||
completedAssignments: completedCount,
|
|
||||||
totalAssignments,
|
|
||||||
pendingReview
|
|
||||||
};
|
|
||||||
}, [selectedCourse, filteredAndSortedStudents, assignments]);
|
|
||||||
|
|
||||||
const handleCourseChange = (courseId) => {
|
{!isStudent && (
|
||||||
setIsLoading(true);
|
<Space size="large">
|
||||||
setSelectedCourse(courseId);
|
<Statistic title="Студентов" value={gradebook.students.length}/>
|
||||||
|
<Statistic title="Лекций" value={gradebook.lessons.length}/>
|
||||||
// Сброс фильтров при смене курса
|
<Statistic title="Заданий" value={gradebook.tasks.length}/>
|
||||||
setFilters({
|
</Space>
|
||||||
groups: [],
|
)}
|
||||||
searchText: "",
|
</Space>
|
||||||
progressRange: [0, 100]
|
</Card>
|
||||||
});
|
|
||||||
setSortConfig({
|
|
||||||
key: null,
|
|
||||||
direction: 'ascend'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Имитация загрузки данных
|
<Card>
|
||||||
setTimeout(() => {
|
<Table
|
||||||
setIsLoading(false);
|
columns={columns}
|
||||||
}, 500);
|
dataSource={dataSource}
|
||||||
};
|
scroll={{x: isStudent ? 1000 : 1400}}
|
||||||
|
pagination={isStudent ? false : {pageSize: 15, showSizeChanger: true}}
|
||||||
|
bordered
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
// Модальное окно поиска
|
{!isStudent && (
|
||||||
const SearchModal = () => (
|
<Card style={{marginTop: 24}}>
|
||||||
<Modal
|
<Text>Легенда: </Text>
|
||||||
title="Поиск студентов"
|
<Tag color="green">90–100</Tag>
|
||||||
open={isSearchModalVisible}
|
<Tag color="orange">70–89</Tag>
|
||||||
onCancel={() => setIsSearchModalVisible(false)}
|
<Tag color="red">0–69</Tag>
|
||||||
footer={[
|
<Text strong style={{marginLeft: 16}}>
|
||||||
<Button key="reset" onClick={() => handleSearch('')}>
|
<CheckCircleFilled style={{color: "#52c41a"}}/> — прочитано
|
||||||
Сбросить поиск
|
</Text>
|
||||||
</Button>,
|
<Text strong>
|
||||||
<Button key="cancel" onClick={() => setIsSearchModalVisible(false)}>
|
<ClockCircleOutlined style={{color: "#d9d9d9"}}/> — не прочитано
|
||||||
Отмена
|
</Text>
|
||||||
</Button>,
|
<Text strong>
|
||||||
]}
|
<MinusOutlined/> — не сдано / не оценено
|
||||||
width={400}
|
</Text>
|
||||||
>
|
</Card>
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
)}
|
||||||
<div>
|
|
||||||
<p style={{ marginBottom: 8, color: '#666' }}>
|
|
||||||
Поиск по ФИО, email или группе:
|
|
||||||
</p>
|
|
||||||
<Search
|
|
||||||
placeholder="Введите текст для поиска..."
|
|
||||||
allowClear
|
|
||||||
enterButton="Найти"
|
|
||||||
size="large"
|
|
||||||
defaultValue={filters.searchText}
|
|
||||||
onSearch={handleSearch}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{filters.searchText && (
|
);
|
||||||
<div style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
backgroundColor: '#f0f7ff',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid #1890ff'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: '#1890ff', fontSize: 12 }}>
|
|
||||||
Активный поиск: "{filters.searchText}"
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 24, backgroundColor: '#ffffff', minHeight: '100vh' }}>
|
|
||||||
{/* Шапка с навигацией */}
|
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
|
|
||||||
<Col>
|
|
||||||
<Title level={2} style={{ margin: 0, color: '#262626' }}>Электронный журнал</Title>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* Выбор курса */}
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
marginBottom: 24,
|
|
||||||
borderRadius: 8,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: '16px 24px' } }}
|
|
||||||
>
|
|
||||||
<Space size="middle" align="center">
|
|
||||||
<span style={{ fontWeight: 500, fontSize: 16 }}>Выберите курс:</span>
|
|
||||||
<Select
|
|
||||||
placeholder="Выберите курс"
|
|
||||||
style={{ width: 300 }}
|
|
||||||
onChange={handleCourseChange}
|
|
||||||
options={courses.map(course => ({
|
|
||||||
value: course.id,
|
|
||||||
label: course.title
|
|
||||||
}))}
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Статистика */}
|
|
||||||
{selectedCourse && (
|
|
||||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
|
||||||
<Col span={4}>
|
|
||||||
<Card
|
|
||||||
style={{ borderRadius: 8, textAlign: 'center' }}
|
|
||||||
styles={{ body: { padding: '20px 16px' } }}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title="Всего студентов"
|
|
||||||
value={statistics.totalStudents}
|
|
||||||
styles={{ content: { color: '#1890ff', fontSize: '28px' } }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={5}>
|
|
||||||
<Card
|
|
||||||
style={{ borderRadius: 8, textAlign: 'center' }}
|
|
||||||
styles={{ body: { padding: '20px 16px' } }}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title="Средний балл"
|
|
||||||
value={statistics.averageGrade}
|
|
||||||
styles={{ content: { color: '#52c41a', fontSize: '28px' } }}
|
|
||||||
suffix="баллов"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={5}>
|
|
||||||
<Card
|
|
||||||
style={{ borderRadius: 8, textAlign: 'center' }}
|
|
||||||
styles={{ body: { padding: '20px 16px' } }}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title="Выполнено заданий"
|
|
||||||
value={statistics.completedAssignments}
|
|
||||||
styles={{ content: { color: '#fa8c16', fontSize: '28px' } }}
|
|
||||||
suffix={`/ ${statistics.totalAssignments}`}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={5}>
|
|
||||||
<Card
|
|
||||||
style={{ borderRadius: 8, textAlign: 'center' }}
|
|
||||||
styles={{ body: { padding: '20px 16px' } }}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title="На проверке"
|
|
||||||
value={statistics.pendingReview}
|
|
||||||
styles={{ content: { color: '#fa541c', fontSize: '28px' } }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={5}>
|
|
||||||
<Card
|
|
||||||
style={{ borderRadius: 8, textAlign: 'center' }}
|
|
||||||
styles={{ body: { padding: '20px 16px' } }}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title="Процент выполнения"
|
|
||||||
value={((statistics.completedAssignments / statistics.totalAssignments) * 100).toFixed(1)}
|
|
||||||
styles={{ content: { color: '#722ed1', fontSize: '28px' } }}
|
|
||||||
suffix="%"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Информация о выбранном курсе */}
|
|
||||||
{selectedCourse && (
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
marginBottom: 24,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: '#f0f7ff',
|
|
||||||
border: '1px solid #1890ff'
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: '16px 24px' } }}
|
|
||||||
>
|
|
||||||
<Row justify="space-between" align="middle">
|
|
||||||
<Col>
|
|
||||||
<Space>
|
|
||||||
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
|
|
||||||
{courses.find(c => c.id === selectedCourse)?.title}
|
|
||||||
</Title>
|
|
||||||
<Tag color="blue" style={{ fontSize: 14, padding: '4px 8px' }}>
|
|
||||||
Активный курс
|
|
||||||
</Tag>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<Space>
|
|
||||||
{filters.groups.length > 0 && (
|
|
||||||
<Badge count={filters.groups.length} showZero={false}>
|
|
||||||
<Tag color="orange">Фильтр по группам</Tag>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{filters.searchText && (
|
|
||||||
<Tag color="purple">
|
|
||||||
<Space>
|
|
||||||
<SearchOutlined />
|
|
||||||
Поиск: "{filters.searchText}"
|
|
||||||
</Space>
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{(filters.groups.length > 0 || filters.searchText) && (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setFilters({
|
|
||||||
groups: [],
|
|
||||||
searchText: "",
|
|
||||||
progressRange: [0, 100]
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить фильтры
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Таблица студентов */}
|
|
||||||
{selectedCourse && (
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: 0 } }}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={filteredAndSortedStudents.map(student => ({ ...student, key: student.id }))}
|
|
||||||
loading={isLoading}
|
|
||||||
scroll={{ x: 1200 }}
|
|
||||||
pagination={{
|
|
||||||
pageSize: 10,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showQuickJumper: true,
|
|
||||||
showTotal: (total, range) =>
|
|
||||||
`Показано ${range[0]}-${range[1]} из ${total} студентов`,
|
|
||||||
pageSizeOptions: ['10', '20', '50'],
|
|
||||||
style: { marginTop: 16, marginRight: 16 }
|
|
||||||
}}
|
|
||||||
size="middle"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Сообщение при невыбранном курсе */}
|
|
||||||
{!selectedCourse && (
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: '#fafafa'
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: '60px' } }}
|
|
||||||
>
|
|
||||||
<Title level={3} style={{ color: '#8c8c8c' }}>
|
|
||||||
Выберите курс для просмотра журнала
|
|
||||||
</Title>
|
|
||||||
<p style={{ color: '#8c8c8c', fontSize: 16 }}>
|
|
||||||
Пожалуйста, выберите курс из выпадающего списка выше чтобы увидеть успеваемость студентов
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Модальное окно поиска */}
|
|
||||||
<SearchModal />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GradebookPage;
|
export default GradebookPage;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Col, Flex, Form, Input, Typography } from "antd";
|
import {Button, Col, Flex, Form, Input, Space, Image, Typography} from "antd";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import useLoginPage from "./useLoginPage.js";
|
import useLoginPage from "./useLoginPage.js";
|
||||||
|
|
||||||
@ -24,6 +24,31 @@ const LoginPage = () => {
|
|||||||
gap={24}
|
gap={24}
|
||||||
>
|
>
|
||||||
<Col style={pageContainerStyle}>
|
<Col style={pageContainerStyle}>
|
||||||
|
<Space direction="horizontal" style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}} size="large">
|
||||||
|
<Image
|
||||||
|
src="/rounded_logo.png"
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
marginBottom: 10,
|
||||||
|
borderRadius: 20,
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
}}
|
||||||
|
preview={false}
|
||||||
|
alt="Lectio API Logo"
|
||||||
|
/>
|
||||||
|
<Title level={1} style={{
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#1890ff",
|
||||||
|
marginBottom: 40,
|
||||||
|
}}>
|
||||||
|
Lectio
|
||||||
|
</Title>
|
||||||
|
</Space>
|
||||||
<Title style={{ textAlign: 'center', marginBottom: 24 }}>Аутентификация</Title>
|
<Title style={{ textAlign: 'center', marginBottom: 24 }}>Аутентификация</Title>
|
||||||
<Form name="login" onFinish={onFinish}>
|
<Form name="login" onFinish={onFinish}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {Button, Col, Flex, Form, Input, Select, Typography} from "antd";
|
import {Button, Col, DatePicker, Flex, Form, Input, Select, Tooltip, Typography} from "antd";
|
||||||
import {UserOutlined, MailOutlined, LockOutlined} from "@ant-design/icons";
|
import {UserOutlined, MailOutlined, LockOutlined, InfoCircleOutlined, CalendarOutlined} from "@ant-design/icons";
|
||||||
import useRegisterPage from "./useRegisterPage.js";
|
import useRegisterPage from "./useRegisterPage.js";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const {Title, Text} = Typography;
|
const {Title, Text} = Typography;
|
||||||
|
|
||||||
@ -23,23 +24,9 @@ const RegisterPage = () => {
|
|||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
>
|
>
|
||||||
<Form.Item
|
|
||||||
label="Роль"
|
|
||||||
name="role"
|
|
||||||
rules={[{required: true, message: "Выберите роль"}]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="Выберите роль"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<Select.Option value="student">Студент</Select.Option>
|
|
||||||
<Select.Option value="teacher">Преподаватель</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Имя"
|
label="Имя"
|
||||||
name="firstName"
|
name="first_name"
|
||||||
rules={[{required: true, message: "Введите имя"}]}
|
rules={[{required: true, message: "Введите имя"}]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@ -51,7 +38,7 @@ const RegisterPage = () => {
|
|||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Фамилия"
|
label="Фамилия"
|
||||||
name="lastName"
|
name="last_name"
|
||||||
rules={[{required: true, message: "Введите фамилию"}]}
|
rules={[{required: true, message: "Введите фамилию"}]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@ -75,6 +62,19 @@ const RegisterPage = () => {
|
|||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="birthdate"
|
||||||
|
label="Дата рождения"
|
||||||
|
rules={[{required: true, message: "Введите дату рождения"}]}
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
suffixIcon={<CalendarOutlined/>}
|
||||||
|
format="DD.MM.YYYY"
|
||||||
|
style={{width: "100%"}}
|
||||||
|
size="large"
|
||||||
|
maxDate={dayjs()}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Логин"
|
label="Логин"
|
||||||
@ -87,6 +87,13 @@ const RegisterPage = () => {
|
|||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Tooltip
|
||||||
|
title="Пароль должен содержать не менее 8 символов, включая хотя бы одну букву и одну цифру и один специальный символ"
|
||||||
|
>
|
||||||
|
<Typography.Title level={3} style={{width: 30}}>
|
||||||
|
<InfoCircleOutlined/>
|
||||||
|
</Typography.Title>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Пароль"
|
label="Пароль"
|
||||||
@ -105,7 +112,7 @@ const RegisterPage = () => {
|
|||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Подтверждение пароля"
|
label="Подтверждение пароля"
|
||||||
name="confirmPassword"
|
name="repeat_password"
|
||||||
dependencies={['password']}
|
dependencies={['password']}
|
||||||
rules={[
|
rules={[
|
||||||
{required: true, message: "Подтвердите пароль"},
|
{required: true, message: "Подтвердите пароль"},
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
import { message } from 'antd';
|
import {message, notification} from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import {useNavigate} from 'react-router-dom';
|
||||||
|
import {useRegisterUserMutation} from "../../../Api/usersApi.js";
|
||||||
|
|
||||||
const useRegisterPage = () => {
|
const useRegisterPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [
|
||||||
|
createUser,
|
||||||
|
{
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
}
|
||||||
|
] = useRegisterUserMutation();
|
||||||
|
|
||||||
const pageContainerStyle = {
|
const pageContainerStyle = {
|
||||||
width: 450,
|
width: 450,
|
||||||
padding: 40,
|
padding: 40,
|
||||||
@ -12,10 +21,30 @@ const useRegisterPage = () => {
|
|||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFinish = (values) => {
|
const onFinish = async (values) => {
|
||||||
console.log('Регистрация:', values);
|
const payload = {
|
||||||
message.success('Регистрация выполнена успешно!');
|
...values,
|
||||||
navigate('/login');
|
birthdate: values.birthdate
|
||||||
|
? values.birthdate.format("YYYY-MM-DD")
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
console.log(payload)
|
||||||
|
try {
|
||||||
|
await createUser(payload);
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
title: "Успешно",
|
||||||
|
message: "Пользователь успешно создан",
|
||||||
|
placement: "topRight",
|
||||||
|
})
|
||||||
|
navigate('/login');
|
||||||
|
} catch {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
message: "Пользователь не создан",
|
||||||
|
placement: "topRight",
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user