From 779b6aba77522349c321ffa36ded140a41b3a28c Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Mon, 11 May 2026 01:33:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.env.example | 2 + frontend/src/App.vue | 2 +- frontend/src/api/client.ts | 81 ++++++ frontend/src/api/index.ts | 70 ++++++ frontend/src/api/mappers.ts | 115 +++++++++ frontend/src/api/types.ts | 132 ++++++++++ frontend/src/components/layout/AppSidebar.vue | 2 +- frontend/src/router/index.ts | 11 +- frontend/src/stores/auth.ts | 205 ++++++++++----- frontend/src/stores/lectures.ts | 236 +++++++----------- frontend/src/stores/user.ts | 80 +++--- frontend/src/views/auth/AuthCallbackView.vue | 67 +++++ frontend/src/views/auth/LoginView.vue | 73 +----- .../src/views/student/AchievementsView.vue | 23 +- frontend/src/views/student/CatalogView.vue | 34 ++- frontend/src/views/student/DashboardView.vue | 21 +- .../src/views/student/LectureDetailView.vue | 45 ++-- frontend/src/views/student/MyLecturesView.vue | 26 +- frontend/src/views/student/ProfileView.vue | 19 +- frontend/src/views/student/ReviewFormView.vue | 28 ++- frontend/vite.config.ts | 35 ++- 21 files changed, 942 insertions(+), 365 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/mappers.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/views/auth/AuthCallbackView.vue diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c649528 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=/api +VITE_AUTH_RETURN_URL=/auth/callback diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 349edd7..85ce08e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,7 +10,7 @@ import ToastNotification from '@/components/ui/ToastNotification.vue' const auth = useAuthStore() const route = useRoute() -const isAuthPage = computed(() => route.path === '/login') +const isAuthPage = computed(() => Boolean(route.meta.public)) interface Toast { id: number; message: string; type: 'success' | 'error' | 'info' } const toasts = ref([]) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..1e06d95 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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) { + 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) { + 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( + path: string, + options: RequestInit & { query?: Record } = {}, +): Promise { + 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(payload: T[] | { items?: T[] } | undefined): T[] { + if (Array.isArray(payload)) return payload + return payload?.items ?? [] +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..16e7bf9 --- /dev/null +++ b/frontend/src/api/index.ts @@ -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('/auth/login/microsoft', { + method: 'POST', + body: JSON.stringify({ authorizationCode, redirectUri }), + }), + refresh: () => apiRequest('/auth/refresh', { method: 'POST' }), + logout: () => apiRequest('/auth/logout', { method: 'POST' }), + me: () => apiRequest('/auth/me'), +} + +export const lecturesApi = { + async list(query: LectureQuery = {}) { + const payload = await apiRequest | LectureDto[]>('/lectures', { + query: query as Record, + }) + return extractItems(payload) + }, + get: (id: string | number) => apiRequest(`/lectures/${id}`), + enroll: (id: string | number) => apiRequest(`/lectures/${id}/enroll`, { method: 'POST' }), + unenroll: (id: string | number) => apiRequest(`/lectures/${id}/enroll`, { method: 'DELETE' }), + async reviews(id: string | number) { + const payload = await apiRequest | ReviewDto[]>(`/lectures/${id}/reviews`) + return extractItems(payload) + }, +} + +export const usersApi = { + get: (id: string | number) => apiRequest(`/users/${id}`), + stats: (id: string | number) => apiRequest(`/users/${id}/stats`), + async enrollments(id: string | number) { + const payload = await apiRequest | LectureDto[] | undefined>(`/users/${id}/enrollments`) + return extractItems(payload) + }, + async achievements(id: string | number) { + const payload = await apiRequest | UserAchievementDto[] | AchievementDto[]>( + `/users/${id}/achievements`, + ) + if (Array.isArray(payload)) return payload + return payload.items ?? [] + }, + async transactions(id: string | number) { + const payload = await apiRequest | CoinTransactionDto[]>( + `/users/${id}/transactions`, + ) + return extractItems(payload) + }, +} + +export const reviewsApi = { + create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) => + apiRequest('/reviews', { + method: 'POST', + body: JSON.stringify({ lectureId: Number(lectureId), rating, text }), + }), +} diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts new file mode 100644 index 0000000..119ef3c --- /dev/null +++ b/frontend/src/api/mappers.ts @@ -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', + } +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..2f9ede9 --- /dev/null +++ b/frontend/src/api/types.ts @@ -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 { + 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 +} diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 3f850af..d2ae79c 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -54,7 +54,7 @@ function isActive(to: string) { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3201561..54cb61b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,6 +5,12 @@ const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { public: true } }, + { + path: '/auth/callback', + name: 'auth-callback', + component: () => import('@/views/auth/AuthCallbackView.vue'), + meta: { public: true }, + }, // Student { path: '/', name: 'dashboard', component: () => import('@/views/student/DashboardView.vue'), meta: { role: 'student' } }, @@ -31,8 +37,11 @@ const router = createRouter({ ], }) -router.beforeEach((to) => { +router.beforeEach(async (to) => { const auth = useAuthStore() + if (!auth.initialized && !to.meta.public) { + await auth.initialize() + } if (!to.meta.public && !auth.isAuthenticated) { return '/login' } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index df2125d..a280c6c 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,90 +1,159 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' -import type { User, UserRole } from '@/types' +import { computed, ref } from 'vue' +import { authApi } from '@/api' +import { mapApiUser } from '@/api/mappers' +import { buildApiUrl, setApiAccessToken } from '@/api/client' +import type { AuthResponse } from '@/api/types' +import type { User } from '@/types' -const defaultUsers: Record = { - student: { - id: 'stu-1', - name: 'Алексей Морозов', - email: 'a.morozov@sfedu.ru', - role: 'student', - institute: 'ИКТИБ', - department: 'Программная инженерия', - year: 3, - direction: 'Программная инженерия', - coins: 340, - level: 3, - xp: 120, - lecturesAttended: 12, - hoursLearned: 18.5, - achievements: ['1', '2', '3'], - }, - teacher: { - id: 't-1', - name: 'Михаил Сергеевич Волков', - email: 'm.volkov@sfedu.ru', - role: 'teacher', - institute: 'ИКТИБ', - department: 'каф. Информатики', - year: 0, - direction: 'Информатика и вычислительная техника', - coins: 90, - level: 4, - xp: 240, - lecturesAttended: 24, - hoursLearned: 56, - achievements: ['1', '2', '3', '4'], - }, - admin: { - id: 'adm-1', - name: 'Виктор Алексеев', - email: 'admin@sfedu.ru', - role: 'admin', - institute: 'ЮФУ', - department: 'Администрация', - year: 0, - direction: 'Цифровое развитие', - coins: 0, - level: 5, - xp: 500, - lecturesAttended: 0, - hoursLearned: 0, - achievements: [], - }, +const TOKEN_STORAGE_KEY = 'universe.accessToken' + +function applyAuthResponse(response: AuthResponse) { + localStorage.setItem(TOKEN_STORAGE_KEY, response.accessToken) + setApiAccessToken(response.accessToken) + return mapApiUser(response.user) +} + +function getAuthReturnUrl() { + return import.meta.env.VITE_AUTH_RETURN_URL || '/auth/callback' +} + +function getAbsoluteAuthReturnUrl() { + return new URL(getAuthReturnUrl(), window.location.origin).toString() } export const useAuthStore = defineStore('auth', () => { const user = ref(null) - const isAuthenticated = ref(false) const loading = ref(false) + const initialized = ref(false) const error = ref(null) + const accessToken = ref(localStorage.getItem(TOKEN_STORAGE_KEY)) - async function login(role: UserRole = 'student', shouldFail = false) { + if (accessToken.value) setApiAccessToken(accessToken.value) + + const isAuthenticated = computed(() => Boolean(user.value && accessToken.value)) + + async function hydrateFromResponse(response: AuthResponse) { + accessToken.value = response.accessToken + user.value = applyAuthResponse(response) + error.value = null + } + + async function initialize() { + if (initialized.value) return isAuthenticated.value loading.value = true error.value = null - await new Promise(r => setTimeout(r, 800)) - if (shouldFail) { - loading.value = false - error.value = 'Не удалось подтвердить доступ через ЮФУ. Попробуйте еще раз.' + + try { + const refreshed = await authApi.refresh() + await hydrateFromResponse(refreshed) + const me = await authApi.me() + user.value = mapApiUser(me) + return true + } catch (refreshError) { + if (accessToken.value) { + try { + const me = await authApi.me() + user.value = mapApiUser(me) + return true + } catch { + // Fall through to local cleanup below. + } + } + clearSession() + error.value = refreshError instanceof Error ? refreshError.message : null return false + } finally { + initialized.value = true + loading.value = false } - user.value = { ...defaultUsers[role] } - isAuthenticated.value = true - loading.value = false + } + + function startMicrosoftLogin() { + window.location.assign(buildApiUrl('/auth/login/microsoft', { returnUrl: getAuthReturnUrl() })) return true } - function logout() { + async function completeMicrosoftLogin(code: string, state: string | null) { + loading.value = true + error.value = null + try { + const redirectUri = getAbsoluteAuthReturnUrl() + const response = await authApi.loginMicrosoft(code, redirectUri) + await hydrateFromResponse(response) + initialized.value = true + return true + } catch (err) { + clearSession() + error.value = err instanceof Error ? err.message : 'Ошибка авторизации через Microsoft.' + throw err + } finally { + loading.value = false + } + } + + async function completeTokenLogin(token: string) { + loading.value = true + error.value = null + try { + accessToken.value = token + localStorage.setItem(TOKEN_STORAGE_KEY, token) + setApiAccessToken(token) + const me = await authApi.me() + user.value = mapApiUser(me) + initialized.value = true + return true + } catch (err) { + clearSession() + error.value = err instanceof Error ? err.message : 'Не удалось получить пользователя после входа.' + throw err + } finally { + loading.value = false + } + } + + async function logout() { + loading.value = true + try { + await authApi.logout() + } catch { + // Local cleanup is still correct if the server session is already gone. + } finally { + clearSession() + initialized.value = true + loading.value = false + } + } + + function clearSession() { user.value = null - isAuthenticated.value = false + accessToken.value = null + localStorage.removeItem(TOKEN_STORAGE_KEY) + setApiAccessToken(null) } - function switchRole(role?: UserRole) { - if (!user.value) return - const roles: UserRole[] = ['student', 'teacher', 'admin'] - const nextRole = (role ?? roles[(roles.indexOf(user.value.role) + 1) % roles.length]) as UserRole - user.value = { ...defaultUsers[nextRole] } + function setUser(nextUser: User) { + user.value = nextUser } - return { user, isAuthenticated, loading, error, login, logout, switchRole } + function switchRole() { + error.value = 'Смена роли доступна только через backend.' + } + + return { + user, + accessToken, + isAuthenticated, + loading, + initialized, + error, + initialize, + startMicrosoftLogin, + completeMicrosoftLogin, + completeTokenLogin, + logout, + clearSession, + setUser, + switchRole, + } }) diff --git a/frontend/src/stores/lectures.ts b/frontend/src/stores/lectures.ts index 97fa342..cb55457 100644 --- a/frontend/src/stores/lectures.ts +++ b/frontend/src/stores/lectures.ts @@ -1,163 +1,115 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import type { Lecture } from '@/types' - -export const LECTURES: Lecture[] = [ - { - id: '1', - title: 'Введение в нейронные сети и глубокое обучение', - description: 'Лекция охватывает базовые концепции нейронных сетей: перцептрон, многослойные сети, метод обратного распространения ошибки, а также современные архитектуры — CNN, RNN, Transformer. Рассматриваются практические примеры на Python с использованием PyTorch.', - teacher: 'Волков М.С.', - teacherTitle: 'Профессор', - department: 'каф. Информатики', - institute: 'ИКТИБ', - date: '2025-05-07', - time: '14:00', - duration: 90, - building: 'ИКТИБ', - room: '305', - format: 'offline', - totalSeats: 30, - freeSeats: 12, - tags: ['#ML', '#ИИ', '#Python', '#нейросети'], - rating: 4.8, - reviewCount: 24, - status: 'upcoming', - }, - { - id: '2', - title: 'Квантовые вычисления: от теории к практике', - description: 'Обзор квантовых алгоритмов и их применения. Рассматриваются кубиты, суперпозиция, запутанность, алгоритмы Шора и Гровера, а также введение в программирование на Qiskit.', - teacher: 'Петров А.И.', - teacherTitle: 'Доцент', - department: 'каф. Теоретической физики', - institute: 'ИФиМКН', - date: '2025-05-08', - time: '16:00', - duration: 120, - building: 'ИФиМКН', - room: '201', - format: 'offline', - totalSeats: 25, - freeSeats: 5, - tags: ['#квантовые-вычисления', '#физика', '#алгоритмы'], - rating: 4.6, - reviewCount: 18, - status: 'upcoming', - }, - { - id: '3', - title: 'Современные методы биоинформатики', - description: 'Введение в биоинформатику: анализ последовательностей ДНК/РНК, геномная сборка, аннотация генов, инструменты BLAST, Biopython. Актуальные задачи вычислительной биологии.', - teacher: 'Смирнова Е.В.', - teacherTitle: 'Доктор биологических наук', - department: 'каф. Биологии', - institute: 'АГиС', - date: '2025-05-09', - time: '10:00', - duration: 90, - building: 'АГиС', - room: '118', - format: 'offline', - totalSeats: 20, - freeSeats: 2, - tags: ['#биоинформатика', '#генетика', '#Python'], - rating: 4.7, - reviewCount: 31, - status: 'upcoming', - }, - { - id: '4', - title: 'Философия цифровой эпохи', - description: 'Как цифровые технологии меняют мышление, идентичность и общество. Тема охватывает этику ИИ, постгуманизм, цифровой дуализм и проблему сознания в эпоху автоматизации.', - teacher: 'Дмитриев К.О.', - teacherTitle: 'Кандидат философских наук', - department: 'каф. Философии', - institute: 'ИФиСН', - date: '2025-05-10', - time: '18:00', - duration: 90, - building: 'Онлайн', - format: 'online', - totalSeats: 40, - freeSeats: 16, - tags: ['#философия', '#этика', '#ИИ'], - rating: 4.5, - reviewCount: 42, - status: 'upcoming', - }, - { - id: '5', - title: 'Право в информационном обществе', - description: 'Правовые аспекты работы с данными: GDPR, ФЗ-152, авторское право в сети, кибербезопасность с точки зрения права, ответственность разработчиков и операторов персональных данных.', - teacher: 'Захарова Н.А.', - teacherTitle: 'Доцент', - department: 'каф. Гражданского права', - institute: 'ЮФ', - date: '2025-05-12', - time: '15:30', - duration: 90, - building: 'ЮФ', - room: '412', - format: 'offline', - totalSeats: 30, - freeSeats: 0, - registrationClosed: true, - tags: ['#право', '#данные', '#GDPR'], - rating: 4.4, - reviewCount: 15, - status: 'upcoming', - }, - { - id: '6', - title: 'Нейромаркетинг и поведение потребителей', - description: 'Как нейронауки применяются в маркетинге: eye-tracking, EEG-анализ реакций, влияние UX на покупки, нейропсихология принятия решений и кейсы ведущих брендов.', - teacher: 'Орлов П.Р.', - teacherTitle: 'Кандидат экономических наук', - department: 'каф. Маркетинга', - institute: 'ИУЭиП', - date: '2025-05-14', - time: '11:00', - duration: 120, - building: 'Онлайн', - format: 'online', - totalSeats: 35, - freeSeats: 27, - tags: ['#маркетинг', '#нейронауки', '#поведение'], - rating: 4.3, - reviewCount: 9, - status: 'upcoming', - }, -] +import { lecturesApi, usersApi } from '@/api' +import { mapApiLecture, mapApiReview } from '@/api/mappers' +import type { Lecture, Review } from '@/types' export const useLecturesStore = defineStore('lectures', () => { - const lectures = ref(LECTURES) - const registered = ref(['1', '3']) + const lectures = ref([]) + const registered = ref([]) + const reviewsByLecture = ref>({}) + const loading = ref(false) + const error = ref(null) const all = computed(() => lectures.value) const registeredIds = computed(() => registered.value) const registeredLectures = computed(() => - lectures.value.filter(l => registered.value.includes(l.id)) + lectures.value.filter(l => registered.value.includes(l.id) || l.registered), ) - function register(lectureId: string) { - if (!registered.value.includes(lectureId)) { - const l = lectures.value.find(x => x.id === lectureId) - if (!l || l.freeSeats === 0 || l.registrationClosed) return - registered.value.push(lectureId) - l.freeSeats-- + async function fetchLectures() { + loading.value = true + error.value = null + try { + const payload = await lecturesApi.list({ PageSize: 100 }) + lectures.value = payload.map(mapApiLecture) + registered.value = lectures.value.filter(l => l.registered).map(l => l.id) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекции.' + } finally { + loading.value = false } } - function unregister(lectureId: string) { + async function fetchLecture(id: string) { + error.value = null + try { + const lecture = mapApiLecture(await lecturesApi.get(id)) + const index = lectures.value.findIndex(item => item.id === lecture.id) + if (index >= 0) lectures.value[index] = lecture + else lectures.value.push(lecture) + if (lecture.registered && !registered.value.includes(lecture.id)) registered.value.push(lecture.id) + return lecture + } catch (err) { + error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекцию.' + return lectures.value.find(item => item.id === id) + } + } + + async function fetchRegisteredForUser(userId: string) { + try { + const enrollments = await usersApi.enrollments(userId) + const mapped = enrollments.map(mapApiLecture) + if (mapped.length) { + mapped.forEach(lecture => { + const index = lectures.value.findIndex(item => item.id === lecture.id) + if (index >= 0) lectures.value[index] = { ...lectures.value[index], ...lecture, registered: true } + else lectures.value.push({ ...lecture, registered: true }) + }) + registered.value = mapped.map(lecture => lecture.id) + } + } catch { + // Some backend builds return an empty 200 for this endpoint; catalog detail still carries isEnrolled. + } + } + + async function fetchReviews(lectureId: string) { + try { + reviewsByLecture.value[lectureId] = (await lecturesApi.reviews(lectureId)).map(mapApiReview) + } catch { + reviewsByLecture.value[lectureId] = [] + } + } + + async function register(lectureId: string) { + const lecture = lectures.value.find(item => item.id === lectureId) + if (!lecture || lecture.freeSeats === 0 || lecture.registrationClosed || registered.value.includes(lectureId)) return + + await lecturesApi.enroll(lectureId) + registered.value.push(lectureId) + lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0) + lecture.registered = true + } + + async function unregister(lectureId: string) { + await lecturesApi.unenroll(lectureId) registered.value = registered.value.filter(id => id !== lectureId) - const l = lectures.value.find(x => x.id === lectureId) - if (l) l.freeSeats++ + const lecture = lectures.value.find(item => item.id === lectureId) + if (lecture) { + lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats) + lecture.registered = false + } } function isRegistered(lectureId: string) { return registered.value.includes(lectureId) } - return { lectures, registered, all, registeredIds, registeredLectures, register, unregister, isRegistered } + return { + lectures, + registered, + reviewsByLecture, + loading, + error, + all, + registeredIds, + registeredLectures, + fetchLectures, + fetchLecture, + fetchRegisteredForUser, + fetchReviews, + register, + unregister, + isRegistered, + } }) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index efb8e2d..06d9108 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -1,37 +1,50 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import type { Achievement, Notification, CoinTransaction } from '@/types' +import { usersApi } from '@/api' +import { mapApiAchievement, mapApiCoinTransaction } from '@/api/mappers' +import type { Achievement, CoinTransaction, Notification } from '@/types' +import { useAuthStore } from './auth' export const useUserStore = defineStore('user', () => { - const achievements = ref([ - { id: '1', title: 'Первый отзыв', description: 'Оставьте первый отзыв о лекции', icon: '⭐', unlocked: true, unlockedAt: '2025-04-10', coins: 20 }, - { id: '2', title: 'Межфакультетский исследователь', description: 'Посетите лекции 3 разных институтов', icon: '🔭', unlocked: true, unlockedAt: '2025-04-18', coins: 50 }, - { id: '3', title: '10 часов лекций', description: 'Наберите 10 часов посещённых лекций', icon: '⏱', unlocked: true, unlockedAt: '2025-04-25', coins: 30 }, - { id: '4', title: 'Полезный критик', description: 'Получите 5 монет за качественный отзыв', icon: '💡', unlocked: false }, - { id: '5', title: 'Знаток науки', description: 'Посетите лекции по 5 разным тематикам', icon: '🎓', unlocked: false }, - { id: '6', title: 'Ранний пташка', description: 'Запишитесь на лекцию за 7 дней до начала', icon: '🌅', unlocked: false }, - { id: '7', title: 'Социальная бабочка', description: 'Приведите друга на межфакультетскую лекцию', icon: '🦋', unlocked: false }, - ]) + const achievements = ref([]) + const notifications = ref([]) + const coinHistory = ref([]) + const loading = ref(false) + const error = ref(null) - const notifications = ref([ - { id: '1', type: 'reminder', title: 'Напоминание о лекции', body: 'Завтра в 14:00 — «Введение в нейронные сети». Ауд. 305, ИКТИБ', read: false, createdAt: '2025-05-06T09:00:00' }, - { id: '2', type: 'coins', title: 'Начислено 20 монет', body: 'Ваш отзыв о лекции «Квантовые вычисления» признан полезным', read: false, createdAt: '2025-05-05T18:30:00' }, - { id: '3', type: 'achievement', title: 'Новое достижение!', body: 'Вы получили значок «Межфакультетский исследователь» 🔭', read: false, createdAt: '2025-05-04T12:00:00' }, - { id: '4', type: 'recommendation', title: 'Рекомендация для вас', body: 'Новая лекция «Нейромаркетинг» — может быть интересна вам', read: true, createdAt: '2025-05-03T10:00:00' }, - { id: '5', type: 'schedule-change', title: 'Изменение расписания', body: 'Лекция «Философия цифровой эпохи» перенесена с 18:00 на 19:00', read: true, createdAt: '2025-05-02T16:00:00' }, - { id: '6', type: 'coins', title: 'Начислено 30 монет', body: 'Поздравляем с достижением «10 часов лекций»!', read: true, createdAt: '2025-04-25T11:00:00' }, - ]) + async function fetchStudentData(userId?: string) { + const auth = useAuthStore() + const id = userId ?? auth.user?.id + if (!id) return - const coinHistory = ref([ - { id: '1', date: '2025-05-05', description: 'Полезный отзыв о лекции', amount: 20, type: 'earned' }, - { id: '2', date: '2025-04-25', description: 'Достижение «10 часов лекций»', amount: 30, type: 'earned' }, - { id: '3', date: '2025-04-18', description: 'Достижение «Исследователь»', amount: 50, type: 'earned' }, - { id: '4', date: '2025-04-10', description: 'Первый отзыв', amount: 20, type: 'earned' }, - { id: '5', date: '2025-04-05', description: 'Покупка стикерпака ЮФУ', amount: -80, type: 'spent' }, - { id: '6', date: '2025-03-20', description: 'Посещение серии лекций', amount: 60, type: 'earned' }, - { id: '7', date: '2025-03-10', description: 'Покупка термокружки ЮФУ', amount: -120, type: 'spent' }, - { id: '8', date: '2025-02-28', description: 'Первое посещение лекции вне факультета', amount: 40, type: 'earned' }, - ]) + loading.value = true + error.value = null + try { + const [stats, achievementPayload, transactions] = await Promise.all([ + usersApi.stats(id), + usersApi.achievements(id), + usersApi.transactions(id), + ]) + + if (auth.user) { + auth.setUser({ + ...auth.user, + coins: stats.coins, + level: stats.level, + xp: stats.xp, + lecturesAttended: stats.attendedLectures, + hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10, + achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)), + }) + } + achievements.value = achievementPayload.map(mapApiAchievement) + coinHistory.value = transactions.map(mapApiCoinTransaction) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Не удалось загрузить данные профиля.' + } finally { + loading.value = false + } + } function markAllRead() { notifications.value.forEach(n => (n.read = true)) @@ -39,5 +52,14 @@ export const useUserStore = defineStore('user', () => { const unreadCount = () => notifications.value.filter(n => !n.read).length - return { achievements, notifications, coinHistory, markAllRead, unreadCount } + return { + achievements, + notifications, + coinHistory, + loading, + error, + fetchStudentData, + markAllRead, + unreadCount, + } }) diff --git a/frontend/src/views/auth/AuthCallbackView.vue b/frontend/src/views/auth/AuthCallbackView.vue new file mode 100644 index 0000000..72579cc --- /dev/null +++ b/frontend/src/views/auth/AuthCallbackView.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 4983ad5..b0d8f2d 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -1,34 +1,21 @@ @@ -46,21 +33,6 @@ async function login() { Получайте рекомендации, оставляйте отзывы и зарабатывайте монеты за полезную обратную связь. -
-

Роль для демонстрации:

-
- -
-
- @@ -125,39 +93,8 @@ async function login() { padding: 14px 16px; border: 1px solid var(--color-border-glass); } -.demo-label { - font-size: 12px; - color: var(--color-text-secondary); - font-weight: 600; - text-transform: uppercase; - margin-bottom: 8px; -} -.role-options { display: flex; flex-wrap: wrap; gap: 8px; } -.role-option { - background: rgba(255,255,255,0.6); - border: 1px solid var(--color-border-glass); - border-radius: var(--radius-sm); - padding: 8px 12px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - color: var(--color-text); -} -.role-option.active { - border-color: var(--color-primary); - background: rgba(34,197,94,0.12); - color: var(--color-primary-dark); -} .login-actions { display: flex; flex-direction: column; gap: 10px; } .btn-full { width: 100%; justify-content: center; } -.toggle { - font-size: 12px; - color: var(--color-text-secondary); - display: flex; - align-items: center; - gap: 8px; -} .error { font-size: 13px; color: var(--color-error); diff --git a/frontend/src/views/student/AchievementsView.vue b/frontend/src/views/student/AchievementsView.vue index b74536c..d495032 100644 --- a/frontend/src/views/student/AchievementsView.vue +++ b/frontend/src/views/student/AchievementsView.vue @@ -1,10 +1,13 @@ diff --git a/frontend/src/views/student/DashboardView.vue b/frontend/src/views/student/DashboardView.vue index 33233b9..d890b4a 100644 --- a/frontend/src/views/student/DashboardView.vue +++ b/frontend/src/views/student/DashboardView.vue @@ -1,5 +1,5 @@