feat: первое подключение фронтенда
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 54s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 27s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s

This commit is contained in:
2026-05-11 01:33:38 +03:00
parent 71e7d84e0f
commit 779b6aba77
21 changed files with 942 additions and 365 deletions
+81
View File
@@ -0,0 +1,81 @@
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '')
const API_PREFIX = '/v1'
let accessToken: string | null = null
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public details?: unknown,
) {
super(message)
this.name = 'ApiError'
}
}
export function setApiAccessToken(token: string | null) {
accessToken = token
}
export function getApiAccessToken() {
return accessToken
}
function makeUrl(path: string, query?: Record<string, unknown>) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
const url = new URL(`${API_BASE_URL}${API_PREFIX}${normalizedPath}`, window.location.origin)
Object.entries(query ?? {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
url.searchParams.set(key, String(value))
})
return url.toString()
}
export function buildApiUrl(path: string, query?: Record<string, unknown>) {
return makeUrl(path, query)
}
async function parseResponse(response: Response) {
if (response.status === 204) return undefined
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) return response.json()
const text = await response.text()
return text || undefined
}
export async function apiRequest<T>(
path: string,
options: RequestInit & { query?: Record<string, unknown> } = {},
): Promise<T> {
const headers = new Headers(options.headers)
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
const response = await fetch(makeUrl(path, options.query), {
...options,
headers,
credentials: 'include',
})
const body = await parseResponse(response)
if (!response.ok) {
const message =
typeof body === 'object' && body && 'message' in body
? String((body as { message: unknown }).message)
: `API request failed with status ${response.status}`
throw new ApiError(message, response.status, body)
}
return body as T
}
export function extractItems<T>(payload: T[] | { items?: T[] } | undefined): T[] {
if (Array.isArray(payload)) return payload
return payload?.items ?? []
}
+70
View File
@@ -0,0 +1,70 @@
import { apiRequest, extractItems } from './client'
import type {
AchievementDto,
AuthResponse,
CoinTransactionDto,
LectureDto,
LectureQuery,
PagedResult,
ReviewDto,
UserAchievementDto,
UserDto,
UserStatsDto,
} from './types'
export const authApi = {
loginMicrosoft: (authorizationCode: string, redirectUri?: string) =>
apiRequest<AuthResponse>('/auth/login/microsoft', {
method: 'POST',
body: JSON.stringify({ authorizationCode, redirectUri }),
}),
refresh: () => apiRequest<AuthResponse>('/auth/refresh', { method: 'POST' }),
logout: () => apiRequest<void>('/auth/logout', { method: 'POST' }),
me: () => apiRequest<UserDto>('/auth/me'),
}
export const lecturesApi = {
async list(query: LectureQuery = {}) {
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[]>('/lectures', {
query: query as Record<string, unknown>,
})
return extractItems(payload)
},
get: (id: string | number) => apiRequest<LectureDto>(`/lectures/${id}`),
enroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'POST' }),
unenroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'DELETE' }),
async reviews(id: string | number) {
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>(`/lectures/${id}/reviews`)
return extractItems(payload)
},
}
export const usersApi = {
get: (id: string | number) => apiRequest<UserDto>(`/users/${id}`),
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
async enrollments(id: string | number) {
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(`/users/${id}/enrollments`)
return extractItems(payload)
},
async achievements(id: string | number) {
const payload = await apiRequest<PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]>(
`/users/${id}/achievements`,
)
if (Array.isArray(payload)) return payload
return payload.items ?? []
},
async transactions(id: string | number) {
const payload = await apiRequest<PagedResult<CoinTransactionDto> | CoinTransactionDto[]>(
`/users/${id}/transactions`,
)
return extractItems(payload)
},
}
export const reviewsApi = {
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
apiRequest<ReviewDto>('/reviews', {
method: 'POST',
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
}),
}
+115
View File
@@ -0,0 +1,115 @@
import type { Achievement, CoinTransaction, Lecture, Review, User, UserRole } from '@/types'
import type {
AchievementDto,
CoinTransactionDto,
LectureDto,
ReviewDto,
UserAuthDto,
UserDto,
UserStatsDto,
UserAchievementDto,
} from './types'
export function mapApiRole(role: string | undefined): UserRole {
if (role === 'Teacher') return 'teacher'
if (role === 'Admin') return 'admin'
return 'student'
}
export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): User {
return {
id: String(user.id),
name: user.displayName || user.email || 'Пользователь UniVerse',
email: user.email || '',
role: mapApiRole(user.role),
avatar: 'avatarUrl' in user ? user.avatarUrl ?? undefined : undefined,
institute: 'ЮФУ',
department: '',
year: 0,
direction: '',
coins: stats?.coins ?? ('coins' in user ? user.coins : 0),
level: stats?.level ?? ('level' in user ? user.level : 1),
xp: stats?.xp ?? ('xp' in user ? user.xp : 0),
lecturesAttended: stats?.attendedLectures ?? 0,
hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0,
achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [],
}
}
export function mapApiLecture(lecture: LectureDto): Lecture {
const startsAt = new Date(lecture.startsAt)
const endsAt = new Date(lecture.endsAt)
const durationMs = endsAt.getTime() - startsAt.getTime()
const duration = Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs / 60000) : 90
const totalSeats = lecture.maxEnrollments || 0
const enrolled = lecture.enrollmentsCount || 0
const freeSeats = Math.max(totalSeats - enrolled, 0)
const locationName = lecture.locationName || (lecture.format === 'Online' ? 'Онлайн' : 'Аудитория уточняется')
return {
id: String(lecture.id),
title: lecture.title || lecture.courseName || 'Лекция без названия',
description: lecture.description || 'Описание появится позже.',
teacher: lecture.teacherName || 'Преподаватель уточняется',
teacherTitle: '',
department: '',
institute: lecture.courseName || 'ЮФУ',
date: startsAt.toISOString().slice(0, 10),
time: startsAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
duration,
building: lecture.format === 'Online' ? 'Онлайн' : locationName,
room: lecture.format === 'Online' ? undefined : locationName,
format: lecture.format === 'Online' ? 'online' : 'offline',
totalSeats,
freeSeats,
registrationClosed: !lecture.isOpen,
tags: lecture.courseName ? [`#${lecture.courseName}`] : [],
rating: 0,
reviewCount: 0,
status: startsAt.getTime() > Date.now() ? 'upcoming' : 'completed',
registered: lecture.isEnrolled,
}
}
export function mapApiReview(review: ReviewDto): Review {
const sentiment = review.sentiment === 'Negative' ? 'negative' : review.sentiment === 'Neutral' ? 'neutral' : 'positive'
const status =
review.llmStatus === 'Rejected' ? 'rejected' : review.llmStatus === 'Analyzed' ? 'done' : 'pending'
return {
id: String(review.id),
lectureId: String(review.lectureId),
userId: String(review.userId),
userName: review.userName || 'Анонимный отзыв',
text: review.text || '',
sentiment,
createdAt: review.createdAt,
status,
quality: review.qualityScore ?? undefined,
}
}
export function mapApiAchievement(input: AchievementDto | UserAchievementDto): Achievement {
const dto = 'achievement' in input ? input.achievement : input
const awardedAt = 'achievement' in input ? input.awardedAt : undefined
return {
id: String(dto.id),
title: dto.name || 'Достижение',
description: dto.description || dto.condition || '',
icon: dto.iconUrl || '⭐',
unlocked: Boolean(awardedAt),
unlockedAt: awardedAt,
coins: dto.coinReward,
}
}
export function mapApiCoinTransaction(transaction: CoinTransactionDto): CoinTransaction {
return {
id: String(transaction.id),
date: transaction.createdAt.slice(0, 10),
description: transaction.description || transaction.type,
amount: transaction.amount,
type: transaction.amount >= 0 ? 'earned' : 'spent',
}
}
+132
View File
@@ -0,0 +1,132 @@
export type ApiUserRole = 'Student' | 'Teacher' | 'Admin'
export type ApiLectureFormat = 'Online' | 'Offline'
export type ApiReviewRating = 'Like' | 'Neutral' | 'Dislike'
export type ApiReviewLlmStatus = 'Pending' | 'Analyzed' | 'Rejected'
export type ApiReviewSentiment = 'Positive' | 'Neutral' | 'Negative'
export type ApiCoinTransactionType =
| 'ReviewReward'
| 'AchievementReward'
| 'AttendanceReward'
| 'AdminAdjustment'
export interface PagedResult<T> {
items: T[]
totalCount: number
page: number
pageSize: number
totalPages: number
}
export interface AuthResponse {
accessToken: string
expiresAt: string
user: UserAuthDto
}
export interface LoginMicrosoftRequest {
authorizationCode: string
redirectUri?: string
}
export interface UserAuthDto {
id: number
email: string
displayName?: string | null
role: ApiUserRole
}
export interface UserDto extends UserAuthDto {
avatarUrl?: string | null
isActive: boolean
xp: number
coins: number
level: number
createdAt: string
}
export interface UserStatsDto {
totalLectures: number
attendedLectures: number
totalReviews: number
xp: number
coins: number
level: number
achievementsCount: number
}
export interface LectureDto {
id: number
courseId: number
courseName?: string | null
teacherId?: number | null
teacherName?: string | null
locationId?: number | null
locationName?: string | null
title?: string | null
description?: string | null
format: ApiLectureFormat
startsAt: string
endsAt: string
isOpen: boolean
maxEnrollments: number
enrollmentsCount: number
onlineUrl?: string | null
createdAt: string
isEnrolled?: boolean
}
export interface ReviewDto {
id: number
lectureId: number
lectureTitle?: string | null
userId: number
userName?: string | null
rating: ApiReviewRating
text?: string | null
llmStatus: ApiReviewLlmStatus
sentiment: ApiReviewSentiment
qualityScore?: number | null
isInformative?: boolean | null
llmTags?: string[] | null
createdAt: string
}
export interface AchievementDto {
id: number
name?: string | null
description?: string | null
iconUrl?: string | null
xpReward: number
coinReward: number
condition?: string | null
createdAt: string
}
export interface UserAchievementDto {
id: number
achievement: AchievementDto
awardedAt: string
}
export interface CoinTransactionDto {
id: number
amount: number
type: ApiCoinTransactionType
reviewId?: number | null
achievementId?: number | null
description?: string | null
createdAt: string
}
export interface LectureQuery {
DateFrom?: string
DateTo?: string
CourseId?: number
TeacherId?: number
Format?: ApiLectureFormat
IsOpen?: boolean
TagId?: number
Search?: string
Page?: number
PageSize?: number
}