Compare commits

..

15 Commits
lev ... main

39 changed files with 1231 additions and 834 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
api/.idea api/.idea
.idea .idea
web/node_modules
# ---> Node # ---> Node
# Logs # Logs
logs logs

258
README.md
View File

@ -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 + Lets 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, Lets 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
View File

@ -0,0 +1,4 @@
.venv
k8s
.idea
.env

18
api/app/Dockerfile Normal file
View 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"]

View File

@ -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)

View File

@ -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],

View 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

View File

@ -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

View File

@ -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):

View File

@ -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",)

View 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())
)

View File

@ -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=['*'],

View File

@ -0,0 +1,5 @@
apiVersion: v2
name: lectio-api-app
description: Lectio API project
version: 0.1.0
appVersion: "1.0"

View 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"

View 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 }}

View 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 }}

View 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 }}

View 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

View File

@ -8,3 +8,5 @@ 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
View File

@ -0,0 +1,7 @@
node_modules
npm-debug.log
build
.dockerignore
.git
k8s
.env

27
web/Dockerfile Normal file
View 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"]

View File

@ -0,0 +1,5 @@
apiVersion: v2
name: lectio-web-app
description: Lectio WEB project
version: 0.1.0
appVersion: "1.0"

View 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 }}

View 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 }}

View 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 }}

View 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

View File

@ -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;

View File

@ -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,

View File

@ -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>

View File

@ -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",

View File

@ -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}}>
@ -247,7 +234,10 @@ 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"}}>
@ -261,7 +251,8 @@ 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>
} }
@ -291,29 +282,19 @@ const ViewTaskModal = () => {
/> />
)} )}
</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 <Input.TextArea
rows={3} rows={3}
placeholder="Напишите комментарий к решению..." placeholder="Напишите комментарий к решению..."
allowClear allowClear
value={comment}
onChange={(e) => setComment(e.target.value)}
style={{ marginTop: 20, marginBottom: 20}}
/> />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <Button onClick={() => onCommentSubmit(solution.id)} type="primary"
<Button type="primary" htmlType="submit"> htmlType="submit">
Отправить комментарий Отправить комментарий
</Button> </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}}>
@ -464,7 +445,10 @@ 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"}}>
@ -478,7 +462,8 @@ 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>
} }
@ -509,28 +494,20 @@ const ViewTaskModal = () => {
)} )}
</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 <Input.TextArea
rows={3} rows={3}
placeholder="Напишите комментарий к решению..." placeholder="Напишите комментарий к решению..."
allowClear allowClear
value={comment}
onChange={(e) => setComment(e.target.value)}
style={{ marginTop: 20, marginBottom: 20}}
/> />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <Button onClick={() => onCommentSubmit(solution.id)} type="primary"
<Button type="primary" htmlType="submit"> htmlType="submit">
Отправить комментарий Отправить комментарий
</Button> </Button>
</Form.Item>
</Form>
</div> </div>
</Panel> </Panel>
))} ))}

View File

@ -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,
} }
}; };

View File

@ -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>
) )
}; };

View File

@ -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,6 +166,7 @@ const CoursesPage = () => {
</Row> </Row>
)} )}
{(isTeacher || isAdmin) && (
<Tooltip title="Создать курс"> <Tooltip title="Создать курс">
<FloatButton <FloatButton
icon={<PlusOutlined/>} icon={<PlusOutlined/>}
@ -173,6 +174,7 @@ const CoursesPage = () => {
type="primary" type="primary"
/> />
</Tooltip> </Tooltip>
)}
<CreateCourseModalForm/> <CreateCourseModalForm/>
<UpdateCourseModalForm/> <UpdateCourseModalForm/>

View File

@ -1,721 +1,215 @@
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]);
// Фильтрация и сортировка данных if (isLoading) {
const filteredAndSortedStudents = useMemo(() => { return (
let filtered = students.filter(student => { <div style={{padding: 80, textAlign: "center"}}>
// Фильтр по группам <Spin size="large" tip="Загружается журнал..."/>
if (filters.groups.length > 0 && !filters.groups.includes(student.group)) { </div>
return false; );
} }
// Поиск по тексту (ФИО, email, группа) if (gradebookError || !gradebook) {
if (filters.searchText) { return <Empty description="Не удалось загрузить журнал"/>;
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;
}
} }
// Фильтр по прогрессу const isStudent = userData?.role?.title === "student";
if (student.progress < filters.progressRange[0] || student.progress > filters.progressRange[1]) { const currentStudentId = userData?.id;
return false;
// Если студент фильтруем только его данные
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>
);
} }
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 = [ const baseColumns = [
{ {
title: ( title: "№",
<Space> width: 60,
Студент
<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", fixed: "left",
width: 250, render: (_, __, index) => index + 1,
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: ( 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> <Space>
Группа <Avatar icon={<UserOutlined/>} style={{backgroundColor: "#1890ff"}}/>
<Button <div>
type="text" <Text strong>{fullName}</Text>
size="small" <br/>
icon={sortConfig.key === 'group' ? <Text type="secondary">Ваша успеваемость</Text>
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) : </div>
<SortAscendingOutlined />
}
onClick={() => handleSort('group')}
/>
</Space> </Space>
), );
dataIndex: "group", }
key: "group", return <Text strong>{fullName}</Text>;
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 lessonColumns = gradebook.lessons.map((lesson) => ({
const assignmentColumns = assignments.map(assignment => ({
title: ( title: (
<Space> <Tooltip title={lesson.title}>
{assignment.name} <div style={{fontSize: 12}}>Л{lesson.number}</div>
<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";
return (
<Tooltip title={`${grade} из ${assignment.maxScore} (${percentage.toFixed(1)}%)`}>
<Tag color={color}>{grade}</Tag>
</Tooltip> </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 summaryColumns = [
{
title: ( title: (
<Space> <Tooltip title={task.title}>
Средний балл <div style={{fontSize: 12}}>З{task.number}</div>
<Button </Tooltip>
type="text"
size="small"
icon={sortConfig.key === 'average' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('average')}
/>
</Space>
), ),
key: "average", width: 90,
width: 140,
align: "center", align: "center",
render: (_, record) => { render: (record) => {
const grades = Object.values(record.grades).filter(g => g !== null); const grade = record.task_grades[task.id];
if (grades.length === 0) return <Tag>Нет оценок</Tag>; if (grade === null || grade === undefined) {
return <MinusOutlined style={{color: "#bfbfbf"}}/>;
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>
);
} }
const color = grade >= 90 ? "green" : grade >= 70 ? "orange" : "red";
return <Tag color={color}>{grade}</Tag>;
}, },
{ }));
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>
)
}
];
return [...baseColumns, ...assignmentColumns, ...summaryColumns]; const columns = [...baseColumns, ...lessonColumns, ...taskColumns];
}, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]);
// Статистика с учетом фильтров const dataSource = visibleStudents.map((student) => ({
const statistics = useMemo(() => { key: student.student_id,
if (!selectedCourse) { ...student,
return { }));
totalStudents: 0,
averageGrade: 0,
completedAssignments: 0,
totalAssignments: 0,
pendingReview: 0
};
}
const totalStudents = filteredAndSortedStudents.length;
const totalAssignments = assignments.length * filteredAndSortedStudents.length;
let completedCount = 0;
let totalGradeSum = 0;
let gradedCount = 0;
filteredAndSortedStudents.forEach(student => {
Object.values(student.grades).forEach(grade => {
if (grade !== null) {
completedCount++;
totalGradeSum += grade;
gradedCount++;
}
});
});
const averageGrade = gradedCount > 0 ? (totalGradeSum / gradedCount).toFixed(1) : 0;
const pendingReview = filteredAndSortedStudents.reduce((count, student) => {
const nullGrades = Object.values(student.grades).filter(grade => grade === null).length;
return count + nullGrades;
}, 0);
return {
totalStudents,
averageGrade,
completedAssignments: completedCount,
totalAssignments,
pendingReview
};
}, [selectedCourse, filteredAndSortedStudents, assignments]);
const handleCourseChange = (courseId) => {
setIsLoading(true);
setSelectedCourse(courseId);
// Сброс фильтров при смене курса
setFilters({
groups: [],
searchText: "",
progressRange: [0, 100]
});
setSortConfig({
key: null,
direction: 'ascend'
});
// Имитация загрузки данных
setTimeout(() => {
setIsLoading(false);
}, 500);
};
// Модальное окно поиска
const SearchModal = () => (
<Modal
title="Поиск студентов"
open={isSearchModalVisible}
onCancel={() => setIsSearchModalVisible(false)}
footer={[
<Button key="reset" onClick={() => handleSearch('')}>
Сбросить поиск
</Button>,
<Button key="cancel" onClick={() => setIsSearchModalVisible(false)}>
Отмена
</Button>,
]}
width={400}
>
<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>
{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 ( return (
<div style={{ padding: 24, backgroundColor: '#ffffff', minHeight: '100vh' }}> <div style={{padding: 24, backgroundColor: "#f5f5f5", minHeight: "100vh"}}>
{/* Шапка с навигацией */} <Card style={{marginBottom: 24}}>
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}> <Space direction="vertical" size="middle" style={{width: "100%"}}>
<Col> <Button icon={<ArrowLeftOutlined/>} onClick={() => navigate(-1)} type="text">
<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> </Button>
<Title level={2} style={{margin: 0}}>
{isStudent ? "Моя успеваемость" : "Журнал успеваемости"} {courseData?.title}
</Title>
{!isStudent && (
<Space size="large">
<Statistic title="Студентов" value={gradebook.students.length}/>
<Statistic title="Лекций" value={gradebook.lessons.length}/>
<Statistic title="Заданий" value={gradebook.tasks.length}/>
</Space>
)} )}
</Space> </Space>
</Col>
</Row>
</Card> </Card>
)}
{/* Таблица студентов */} <Card>
{selectedCourse && (
<Card
style={{
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
styles={{ body: { padding: 0 } }}
>
<Table <Table
columns={columns} columns={columns}
dataSource={filteredAndSortedStudents.map(student => ({ ...student, key: student.id }))} dataSource={dataSource}
loading={isLoading} scroll={{x: isStudent ? 1000 : 1400}}
scroll={{ x: 1200 }} pagination={isStudent ? false : {pageSize: 15, showSizeChanger: true}}
pagination={{ bordered
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" size="middle"
/> />
</Card> </Card>
)}
{/* Сообщение при невыбранном курсе */} {!isStudent && (
{!selectedCourse && ( <Card style={{marginTop: 24}}>
<Card <Text>Легенда: </Text>
style={{ <Tag color="green">90100</Tag>
textAlign: 'center', <Tag color="orange">7089</Tag>
borderRadius: 8, <Tag color="red">069</Tag>
backgroundColor: '#fafafa' <Text strong style={{marginLeft: 16}}>
}} <CheckCircleFilled style={{color: "#52c41a"}}/> прочитано
styles={{ body: { padding: '60px' } }} </Text>
> <Text strong>
<Title level={3} style={{ color: '#8c8c8c' }}> <ClockCircleOutlined style={{color: "#d9d9d9"}}/> не прочитано
Выберите курс для просмотра журнала </Text>
</Title> <Text strong>
<p style={{ color: '#8c8c8c', fontSize: 16 }}> <MinusOutlined/> не сдано / не оценено
Пожалуйста, выберите курс из выпадающего списка выше чтобы увидеть успеваемость студентов </Text>
</p>
</Card> </Card>
)} )}
{/* Модальное окно поиска */}
<SearchModal />
</div> </div>
); );
}; };

View File

@ -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

View File

@ -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: "Подтвердите пароль"},

View File

@ -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,
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'); navigate('/login');
} catch {
notification.error({
title: "Ошибка",
message: "Пользователь не создан",
placement: "topRight",
})
}
}; };
return { return {

View File

@ -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',
},
}) })