Compare commits

..

No commits in common. "main" and "lev" have entirely different histories.
main ... lev

39 changed files with 829 additions and 1226 deletions

1
.gitignore vendored
View File

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

258
README.md
View File

@ -1,258 +1,2 @@
# Lectio
# psb_hack
**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
Пожалуйста, досмотрите видео целиком
## Регистрация
В системе можно зарегистрироваться перейдя на страницу регистрации из страницы авторизации, но по умолчанию вам будет выдана роль Студент без зачисления на курс

View File

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

View File

@ -1,18 +0,0 @@
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,13 +7,11 @@ from app.database.session import get_db
from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated
from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate
from app.domain.entities.gradebook import GradeBookRead
from app.domain.models import User
from app.infrastructure.course_teachers_service import CourseTeachersService
from app.infrastructure.courses_service import CoursesService
from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin
from app.infrastructure.enrollments_service import EnrollmentsService
from app.infrastructure.gradebook_service import GradeBookService
courses_router = APIRouter()
@ -154,13 +152,3 @@ async def replace_course_students(
):
service = EnrollmentsService(db)
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,20 +89,6 @@ async def create_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(
'/role/{role_name}/',
response_model=List[UserRead],

View File

@ -1,44 +0,0 @@
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,7 +42,6 @@ class SolutionCommentCreate(SolutionCommentBase):
class SolutionCommentRead(SolutionCommentBase):
comment_autor_id: int
solution_id: int
created_at: datetime
comment_autor: UserRead

View File

@ -27,7 +27,7 @@ class UserCreate(BaseModel):
birthdate: date
password: str = Field(min_length=8)
repeat_password: str = Field(min_length=8)
role_id: Optional[int] = Field(default=None)
role_id: int = Field()
class UserUpdate(BaseModel):

View File

@ -21,4 +21,4 @@ class Lesson(RootTable):
creator: Mapped['User'] = relationship('User', back_populates='created_lessons', lazy='joined')
files: Mapped[List['LessonFile']] = relationship('LessonFile', back_populates='lesson')
user_check_lessons: Mapped[List['UserCheckLessons']] = relationship('UserCheckLessons', back_populates='lesson', cascade="all, delete-orphan",)
user_check_lessons: Mapped[List['UserCheckLessons']] = relationship('UserCheckLessons', back_populates='lesson')

View File

@ -1,97 +0,0 @@
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(
CORSMiddleware,
allow_origins=['https://api.lectio.numerum.team', 'https://lectio.numerum.team', 'http://localhost:5173', 'http://localhost:3000', 'http://localhost:8000'],
allow_origins=['*'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],

View File

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

View File

@ -1,52 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,28 +0,0 @@
{{- 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

@ -1,11 +0,0 @@
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

@ -1,38 +0,0 @@
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,5 +8,3 @@ pyjwt==2.9.0
fastapi==0.115.0
pydantic[email]==2.11.4
aiofiles==25.1.0
uvicorn==0.34.0
python-multipart==0.0.12

View File

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

View File

@ -1,27 +0,0 @@
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

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

View File

@ -1,22 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,23 +0,0 @@
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,13 +74,6 @@ export const coursesApi = createApi({
}),
invalidatesTags: ['student'],
}),
getGradebookByCourse: builder.query({
query: (courseId) => ({
url: `/courses/gradebook/${courseId}/`,
method: "GET",
}),
providesTags: ['gradebook'],
}),
}),
});
@ -94,5 +87,4 @@ export const {
useReplaceCourseTeachersMutation,
useGetCourseStudentsQuery,
useReplaceCourseStudentsMutation,
useGetGradebookByCourseQuery,
} = coursesApi;

View File

@ -39,14 +39,6 @@ export const usersApi = createApi({
}),
invalidatesTags: ["user"],
}),
registerUser: builder.mutation({
query: (data) => ({
url: "/users/register/",
method: "POST",
body: data,
}),
invalidatesTags: ["user"],
}),
getUsersByRoleName: builder.query({
query: (roleName) => ({
url: `/users/role/${roleName}/`,
@ -84,7 +76,6 @@ export const {
useUpdateUserMutation,
useUpdateUserPasswordMutation,
useCreateUserMutation,
useRegisterUserMutation,
useGetUsersByRoleNameQuery,
useGetReadedLessonsByCourseQuery,
useSetLessonAsReadedMutation,

View File

@ -8,7 +8,6 @@ import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx";
import AdminPage from "../Components/Pages/AdminPage/AdminPage.jsx";
import CourseDetailPage from "../Components/Pages/CourseDetailPage/CourseDetailPage.jsx";
import CoursesPage from "../Components/Pages/CoursesPage/CoursesPage.jsx";
import GradebookPage from "../Components/Pages/GradebookPage/GradebookPage.jsx";
const AppRouter = () => (
@ -21,7 +20,6 @@ const AppRouter = () => (
<Route path={"/courses"} element={<CoursesPage/>}/>
<Route path={"/profile"} element={<ProfilePage/>}/>
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path="/courses/:courseId/gradebook" element={<GradebookPage />} />
<Route path={"*"} element={<Navigate to={"/courses"}/>}/>
</Route>
</Route>

View File

@ -56,6 +56,11 @@ const AdminPage = () => {
filters: rolesData.map(status => ({text: status.title, value: status.title})),
onFilter: (value, record) => record.status.title === value,
},
{
title: "Статус",
dataIndex: "status",
key: "status",
},
{
title: "Действия",
key: "actions",

View File

@ -37,7 +37,7 @@ const ViewTaskModal = () => {
currentTaskFiles,
isCurrentTaskFilesLoading,
isCurrentTaskFilesError,
downloadTasksFile,
downloadFile,
downloadingFiles,
currentUser,
mySolutions,
@ -52,10 +52,7 @@ const ViewTaskModal = () => {
onAssessmentFinish,
assessmentForm,
onCommentSubmit,
commentForm,
setComment,
comment,
downloadSolutionFile,
commentForm
} = useViewTaskModal();
return (
@ -127,7 +124,7 @@ const ViewTaskModal = () => {
<span>{file.filename || "Не указан"}</span>
<div>
<Button
onClick={() => downloadTasksFile(file.id, file.filename)}
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id] || false}
disabled={downloadingFiles[file.id] || false}
type={"dashed"}
@ -163,6 +160,22 @@ const ViewTaskModal = () => {
) : (
<Tag color="blue">На проверке</Tag>
)}
<Popconfirm
title={`Удалить ответ на задание?`}
description="Это действие нельзя отменить"
onConfirm={(e) => {
handleDeleSolution(solution.id)
}}
okText="Удалить"
cancelText="Отмена"
>
<Button
type="text"
danger
icon={<DeleteOutlined/>}
/>
</Popconfirm>
</Flex>
}
extra={
@ -203,7 +216,7 @@ const ViewTaskModal = () => {
type="dashed"
icon={<FileOutlined/>}
style={{textAlign: "left"}}
onClick={() => downloadSolutionFile(file.id, file.filename)}
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id]}
>
<span style={{marginLeft: 8}}>
@ -217,7 +230,7 @@ const ViewTaskModal = () => {
) : (
<Text type="secondary">Файлы не прикреплены</Text>
)}
<div style={{marginTop: 32}}>
<div style={{ marginTop: 32 }}>
<Title level={4}>Комментарии к решению</Title>
<div
@ -234,13 +247,10 @@ const ViewTaskModal = () => {
<List
dataSource={solution.solution_comments}
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
avatar={
<Avatar style={{backgroundColor: "#1890ff"}}>
<Avatar style={{ backgroundColor: "#1890ff" }}>
{comment.comment_autor.first_name[0]}
{comment.comment_autor.last_name[0]}
</Avatar>
@ -251,13 +261,12 @@ const ViewTaskModal = () => {
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
</Text>
{comment.comment_autor.role?.title === "teacher" && (
<Tag color="gold"
size="small">Преподаватель</Tag>
<Tag color="gold" size="small">Преподаватель</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{fontSize: 12}}>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
</Text>
}
@ -269,7 +278,7 @@ const ViewTaskModal = () => {
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
dangerouslySetInnerHTML={{__html: comment.comment_text}}
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
/>
</List.Item>
)}
@ -278,23 +287,33 @@ const ViewTaskModal = () => {
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Пока нет комментариев"
style={{margin: "20px 0"}}
style={{ margin: "20px 0" }}
/>
)}
</div>
<Input.TextArea
rows={3}
placeholder="Напишите комментарий к решению..."
allowClear
value={comment}
onChange={(e) => setComment(e.target.value)}
style={{ marginTop: 20, marginBottom: 20}}
/>
<Button onClick={() => onCommentSubmit(solution.id)} type="primary"
htmlType="submit">
Отправить комментарий
</Button>
<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 }}>
<Button type="primary" htmlType="submit">
Отправить комментарий
</Button>
</Form.Item>
</Form>
</div>
</Panel>
))}
@ -389,7 +408,7 @@ const ViewTaskModal = () => {
type="dashed"
icon={<FileOutlined/>}
style={{textAlign: "left"}}
onClick={() => downloadSolutionFile(file.id, file.filename)}
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id]}
>
<span style={{marginLeft: 8}}>
@ -428,7 +447,7 @@ const ViewTaskModal = () => {
</Button>
</Form.Item>
</Form>
<div style={{marginTop: 32}}>
<div style={{ marginTop: 32 }}>
<Title level={4}>Комментарии к решению</Title>
<div
@ -445,13 +464,10 @@ const ViewTaskModal = () => {
<List
dataSource={solution.solution_comments}
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
avatar={
<Avatar style={{backgroundColor: "#1890ff"}}>
<Avatar style={{ backgroundColor: "#1890ff" }}>
{comment.comment_autor.first_name[0]}
{comment.comment_autor.last_name[0]}
</Avatar>
@ -462,13 +478,12 @@ const ViewTaskModal = () => {
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
</Text>
{comment.comment_autor.role?.title === "teacher" && (
<Tag color="gold"
size="small">Преподаватель</Tag>
<Tag color="gold" size="small">Преподаватель</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{fontSize: 12}}>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
</Text>
}
@ -480,7 +495,7 @@ const ViewTaskModal = () => {
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
dangerouslySetInnerHTML={{__html: comment.comment_text}}
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
/>
</List.Item>
)}
@ -489,25 +504,33 @@ const ViewTaskModal = () => {
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Пока нет комментариев"
style={{margin: "20px 0"}}
style={{ margin: "20px 0" }}
/>
)}
</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>
<Input.TextArea
rows={3}
placeholder="Напишите комментарий к решению..."
allowClear
value={comment}
onChange={(e) => setComment(e.target.value)}
style={{ marginTop: 20, marginBottom: 20}}
/>
<Button onClick={() => onCommentSubmit(solution.id)} type="primary"
htmlType="submit">
Отправить комментарий
</Button>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit">
Отправить комментарий
</Button>
</Form.Item>
</Form>
</div>
</Panel>
))}

View File

@ -23,7 +23,6 @@ const useViewTaskModal = () => {
} = useSelector((state) => state.tasks);
const [assessmentForm] = Form.useForm();
const [comment, setComment] = useState("");
const [
createSolution,
@ -44,35 +43,34 @@ const useViewTaskModal = () => {
isError: isErrorCreatingComment
}] = useCreateCommentMutation();
const onCommentSubmit = async (solutionId) => {
if (!comment?.trim()) return;
const onCommentSubmit = async (solutionId, commentText) => {
if (!commentText?.trim()) return;
try {
await createComment({
solutionId: solutionId,
comment: {
comment_text: comment.trim()
comment_text: commentText.trim()
}
}).unwrap();
setComment("");
commentForm.resetFields();
notification.success({
title: "Комментарий отправлен",
message: "Комментарий отправлен",
description: "Ваш комментарий успешно добавлен",
});
} catch (error) {
notification.error({
title: "Ошибка",
message: "Ошибка",
description: error?.data?.detail || "Не удалось отправить комментарий",
});
}
};
const handleAddFile = (file) => {
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
if (file.size > maxSize) {
notification.error({
title: "Ошибка вставки",
message: "Ошибка вставки",
description: "Файл слишком большой.",
placement: "topRight",
});
@ -199,84 +197,7 @@ const useViewTaskModal = () => {
const [commentForm] = Form.useForm();
const [downloadingFiles, setDownloadingFiles] = useState({});
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) => {
const downloadFile = async (fileId, fileName) => {
try {
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
@ -451,7 +372,7 @@ const useViewTaskModal = () => {
currentTaskFiles,
isCurrentTaskFilesLoading,
isCurrentTaskFilesError,
downloadTasksFile,
downloadFile,
downloadingFiles,
currentUser,
mySolutions,
@ -466,9 +387,6 @@ const useViewTaskModal = () => {
onAssessmentFinish,
assessmentForm,
onCommentSubmit,
setComment,
comment,
downloadSolutionFile,
}
};

View File

@ -5,7 +5,7 @@ import {
Card,
Col,
Empty,
FloatButton, Grid,
FloatButton,
Popconfirm,
Result,
Row,
@ -19,8 +19,8 @@ import {
BookOutlined, CheckCircleFilled, ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
FormOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
PlusOutlined, TableOutlined
FormOutlined,
PlusOutlined
} from "@ant-design/icons";
import {useNavigate, useParams} from "react-router-dom";
import {ROLES} from "../../../Core/constants.js";
@ -32,17 +32,12 @@ import UpdateLessonModalForm from "./Components/UpdateLessonModalForm/UpdateLess
import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx";
import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx";
import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx";
import {useState} from "react";
const {Title, Text} = Typography;
const {useBreakpoint} = Grid;
const CourseDetailPage = () => {
const navigate = useNavigate();
const screens = useBreakpoint();
const [hovered, setHovered] = useState(false);
const {courseId} = useParams();
const {
tasksData,
@ -247,39 +242,6 @@ const CourseDetailPage = () => {
<ViewTaskModal
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/>
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
<FloatButton.Group
@ -300,9 +262,7 @@ const CourseDetailPage = () => {
onClick={handleCreateTask}
/>
</FloatButton.Group>
)
}
)}
</div>
)
};

View File

@ -156,7 +156,7 @@ const CoursesPage = () => {
<Divider/>
<Text type="secondary">Прогресс:</Text>
{courseProgress[course.id] !== undefined && (
<Progress showInfo={true} status={"active"} percent={courseProgress[course.id]} size={[300, 20]}/>
<Progress percent={courseProgress[course.id]} size={[300, 20]}/>
)}
</div>
)}
@ -166,15 +166,13 @@ const CoursesPage = () => {
</Row>
)}
{(isTeacher || isAdmin) && (
<Tooltip title="Создать курс">
<FloatButton
icon={<PlusOutlined/>}
onClick={openCreateModal}
type="primary"
/>
</Tooltip>
)}
<Tooltip title="Создать курс">
<FloatButton
icon={<PlusOutlined/>}
onClick={openCreateModal}
type="primary"
/>
</Tooltip>
<CreateCourseModalForm/>
<UpdateCourseModalForm/>

View File

@ -1,217 +1,723 @@
import {useParams, useNavigate} from "react-router-dom";
import {
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";
import { useState, useMemo } from "react";
import { Avatar, Progress, Tag, Tooltip, Space, Button, Table, Card, Row, Col, Statistic, Select, Typography, Input, Modal, Badge } from "antd";
import { UserOutlined, SearchOutlined, SortAscendingOutlined, SortDescendingOutlined, FilterOutlined } from "@ant-design/icons";
const {Title, Text} = Typography;
const { Title } = Typography;
const { Search } = Input;
const GradebookPage = () => {
const navigate = useNavigate();
const {courseId} = useParams();
const GradebookPage = ({ onLogout }) => {
const [selectedCourse, setSelectedCourse] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [filters, setFilters] = useState({
groups: [],
searchText: "",
progressRange: [0, 100]
});
const [sortConfig, setSortConfig] = useState({
key: null,
direction: 'ascend'
});
const [isSearchModalVisible, setIsSearchModalVisible] = useState(false);
// Данные текущего пользователя
const {data: userData, isLoading: userLoading} = useGetAuthenticatedUserDataQuery();
// Заглушки для данных
const courses = [
{ id: 1, title: "Основы программирования" },
{ id: 2, title: "Веб-разработка" },
{ id: 3, title: "Базы данных" },
{ id: 4, title: "Алгоритмы и структуры данных" },
];
const {
data: courseData,
isLoading: courseLoading,
} = useGetCourseByIdQuery(courseId);
const assignments = [
{ id: 1, name: "ДЗ №1", maxScore: 100 },
{ id: 2, name: "ДЗ №2", maxScore: 100 },
{ id: 3, name: "ДЗ №3", maxScore: 100 },
{ id: 4, name: "Проект", maxScore: 200 },
];
const {
data: gradebook,
isLoading: gradebookLoading,
isError: gradebookError,
} = useGetGradebookByCourseQuery(courseId, {pollingInterval: 15000});
const students = [
{
id: 1,
firstName: "Иван",
lastName: "Иванов",
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) {
return (
<div style={{padding: 80, textAlign: "center"}}>
<Spin size="large" tip="Загружается журнал..."/>
</div>
);
// Фильтрация и сортировка данных
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;
});
}
if (gradebookError || !gradebook) {
return <Empty description="Не удалось загрузить журнал"/>;
}
return filtered;
}, [students, filters, sortConfig]);
const isStudent = userData?.role?.title === "student";
const currentStudentId = userData?.id;
// Обработчики фильтров
const handleGroupFilter = (selectedGroups) => {
setFilters(prev => ({ ...prev, groups: selectedGroups }));
};
// Если студент фильтруем только его данные
const visibleStudents = isStudent
? gradebook.students.filter((s) => s.student_id === currentStudentId)
: gradebook.students;
const handleSearch = (value) => {
setFilters(prev => ({ ...prev, searchText: value }));
setIsSearchModalVisible(false);
};
// Если студент, но его нет в курсе покажем сообщение
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 handleSort = (key) => {
setSortConfig(prev => ({
key,
direction: prev.key === key && prev.direction === 'ascend' ? 'descend' : 'ascend'
}));
};
// === Колонки ===
// Колонки таблицы с фильтрацией и сортировкой
const columns = useMemo(() => {
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>;
},
},
{
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 lessonColumns = gradebook.lessons.map((lesson) => ({
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) => ({
title: (
<Tooltip title={task.title}>
<div style={{fontSize: 12}}>З{task.number}</div>
</Tooltip>
),
width: 90,
align: "center",
render: (record) => {
const grade = record.task_grades[task.id];
if (grade === null || grade === undefined) {
return <MinusOutlined style={{color: "#bfbfbf"}}/>;
// Колонки для заданий
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 />
}
const color = grade >= 90 ? "green" : grade >= 70 ? "orange" : "red";
return <Tag color={color}>{grade}</Tag>;
},
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>
);
}
}));
const columns = [...baseColumns, ...lessonColumns, ...taskColumns];
// Колонки итогов
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 dataSource = visibleStudents.map((student) => ({
key: student.student_id,
...student,
}));
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);
return (
<div style={{padding: 24, backgroundColor: "#f5f5f5", minHeight: "100vh"}}>
<Card style={{marginBottom: 24}}>
<Space direction="vertical" size="middle" style={{width: "100%"}}>
<Button icon={<ArrowLeftOutlined/>} onClick={() => navigate(-1)} type="text">
Назад
</Button>
const percentage = (totalGrade / totalMax) * 100;
<Title level={2} style={{margin: 0}}>
{isStudent ? "Моя успеваемость" : "Журнал успеваемости"} {courseData?.title}
</Title>
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>
)
}
];
{!isStudent && (
<Space size="large">
<Statistic title="Студентов" value={gradebook.students.length}/>
<Statistic title="Лекций" value={gradebook.lessons.length}/>
<Statistic title="Заданий" value={gradebook.tasks.length}/>
</Space>
)}
</Space>
</Card>
return [...baseColumns, ...assignmentColumns, ...summaryColumns];
}, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]);
<Card>
<Table
columns={columns}
dataSource={dataSource}
scroll={{x: isStudent ? 1000 : 1400}}
pagination={isStudent ? false : {pageSize: 15, showSizeChanger: true}}
bordered
size="middle"
/>
</Card>
// Статистика с учетом фильтров
const statistics = useMemo(() => {
if (!selectedCourse) {
return {
totalStudents: 0,
averageGrade: 0,
completedAssignments: 0,
totalAssignments: 0,
pendingReview: 0
};
}
{!isStudent && (
<Card style={{marginTop: 24}}>
<Text>Легенда: </Text>
<Tag color="green">90100</Tag>
<Tag color="orange">7089</Tag>
<Tag color="red">069</Tag>
<Text strong style={{marginLeft: 16}}>
<CheckCircleFilled style={{color: "#52c41a"}}/> прочитано
</Text>
<Text strong>
<ClockCircleOutlined style={{color: "#d9d9d9"}}/> не прочитано
</Text>
<Text strong>
<MinusOutlined/> не сдано / не оценено
</Text>
</Card>
)}
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 (
<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;

View File

@ -1,4 +1,4 @@
import {Button, Col, Flex, Form, Input, Space, Image, Typography} from "antd";
import { Button, Col, Flex, Form, Input, Typography } from "antd";
import { Link } from "react-router-dom";
import useLoginPage from "./useLoginPage.js";
@ -24,31 +24,6 @@ const LoginPage = () => {
gap={24}
>
<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>
<Form name="login" onFinish={onFinish}>
<Form.Item

View File

@ -1,7 +1,6 @@
import {Button, Col, DatePicker, Flex, Form, Input, Select, Tooltip, Typography} from "antd";
import {UserOutlined, MailOutlined, LockOutlined, InfoCircleOutlined, CalendarOutlined} from "@ant-design/icons";
import {Button, Col, Flex, Form, Input, Select, Typography} from "antd";
import {UserOutlined, MailOutlined, LockOutlined} from "@ant-design/icons";
import useRegisterPage from "./useRegisterPage.js";
import dayjs from "dayjs";
const {Title, Text} = Typography;
@ -24,9 +23,23 @@ const RegisterPage = () => {
onFinish={onFinish}
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
label="Имя"
name="first_name"
name="firstName"
rules={[{required: true, message: "Введите имя"}]}
>
<Input
@ -38,7 +51,7 @@ const RegisterPage = () => {
<Form.Item
label="Фамилия"
name="last_name"
name="lastName"
rules={[{required: true, message: "Введите фамилию"}]}
>
<Input
@ -62,19 +75,6 @@ const RegisterPage = () => {
size="large"
/>
</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
label="Логин"
@ -87,13 +87,6 @@ const RegisterPage = () => {
size="large"
/>
</Form.Item>
<Tooltip
title="Пароль должен содержать не менее 8 символов, включая хотя бы одну букву и одну цифру и один специальный символ"
>
<Typography.Title level={3} style={{width: 30}}>
<InfoCircleOutlined/>
</Typography.Title>
</Tooltip>
<Form.Item
label="Пароль"
@ -112,7 +105,7 @@ const RegisterPage = () => {
<Form.Item
label="Подтверждение пароля"
name="repeat_password"
name="confirmPassword"
dependencies={['password']}
rules={[
{required: true, message: "Подтвердите пароль"},

View File

@ -1,18 +1,9 @@
import {message, notification} from 'antd';
import {useNavigate} from 'react-router-dom';
import {useRegisterUserMutation} from "../../../Api/usersApi.js";
import { message } from 'antd';
import { useNavigate } from 'react-router-dom';
const useRegisterPage = () => {
const navigate = useNavigate();
const [
createUser,
{
isLoading,
isError,
}
] = useRegisterUserMutation();
const pageContainerStyle = {
width: 450,
padding: 40,
@ -21,30 +12,10 @@ const useRegisterPage = () => {
backgroundColor: "white",
};
const onFinish = async (values) => {
const payload = {
...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');
} catch {
notification.error({
title: "Ошибка",
message: "Пользователь не создан",
placement: "topRight",
})
}
const onFinish = (values) => {
console.log('Регистрация:', values);
message.success('Регистрация выполнена успешно!');
navigate('/login');
};
return {

View File

@ -4,7 +4,4 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../dist',
},
})