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
🚀 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:
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
VITE_AUTH_RETURN_URL=/auth/callback
|
||||||
@@ -10,7 +10,7 @@ import ToastNotification from '@/components/ui/ToastNotification.vue'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
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' }
|
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info' }
|
||||||
const toasts = ref<Toast[]>([])
|
const toasts = ref<Toast[]>([])
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ function isActive(to: string) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="logout-btn" @click="auth.logout(); router.push('/login')">
|
<button class="logout-btn" @click="auth.logout().then(() => router.push('/login'))">
|
||||||
🚪 Выйти
|
🚪 Выйти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ const router = createRouter({
|
|||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { public: true } },
|
{ 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
|
// Student
|
||||||
{ path: '/', name: 'dashboard', component: () => import('@/views/student/DashboardView.vue'), meta: { role: '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()
|
const auth = useAuthStore()
|
||||||
|
if (!auth.initialized && !to.meta.public) {
|
||||||
|
await auth.initialize()
|
||||||
|
}
|
||||||
if (!to.meta.public && !auth.isAuthenticated) {
|
if (!to.meta.public && !auth.isAuthenticated) {
|
||||||
return '/login'
|
return '/login'
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-68
@@ -1,90 +1,159 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { User, UserRole } from '@/types'
|
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<UserRole, User> = {
|
const TOKEN_STORAGE_KEY = 'universe.accessToken'
|
||||||
student: {
|
|
||||||
id: 'stu-1',
|
function applyAuthResponse(response: AuthResponse) {
|
||||||
name: 'Алексей Морозов',
|
localStorage.setItem(TOKEN_STORAGE_KEY, response.accessToken)
|
||||||
email: 'a.morozov@sfedu.ru',
|
setApiAccessToken(response.accessToken)
|
||||||
role: 'student',
|
return mapApiUser(response.user)
|
||||||
institute: 'ИКТИБ',
|
}
|
||||||
department: 'Программная инженерия',
|
|
||||||
year: 3,
|
function getAuthReturnUrl() {
|
||||||
direction: 'Программная инженерия',
|
return import.meta.env.VITE_AUTH_RETURN_URL || '/auth/callback'
|
||||||
coins: 340,
|
}
|
||||||
level: 3,
|
|
||||||
xp: 120,
|
function getAbsoluteAuthReturnUrl() {
|
||||||
lecturesAttended: 12,
|
return new URL(getAuthReturnUrl(), window.location.origin).toString()
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const isAuthenticated = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const initialized = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const accessToken = ref<string | null>(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
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
await new Promise(r => setTimeout(r, 800))
|
|
||||||
if (shouldFail) {
|
try {
|
||||||
loading.value = false
|
const refreshed = await authApi.refresh()
|
||||||
error.value = 'Не удалось подтвердить доступ через ЮФУ. Попробуйте еще раз.'
|
await hydrateFromResponse(refreshed)
|
||||||
return false
|
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.
|
||||||
}
|
}
|
||||||
user.value = { ...defaultUsers[role] }
|
}
|
||||||
isAuthenticated.value = true
|
clearSession()
|
||||||
|
error.value = refreshError instanceof Error ? refreshError.message : null
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
initialized.value = true
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMicrosoftLogin() {
|
||||||
|
window.location.assign(buildApiUrl('/auth/login/microsoft', { returnUrl: getAuthReturnUrl() }))
|
||||||
return true
|
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
|
user.value = null
|
||||||
isAuthenticated.value = false
|
accessToken.value = null
|
||||||
|
localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
|
setApiAccessToken(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchRole(role?: UserRole) {
|
function setUser(nextUser: User) {
|
||||||
if (!user.value) return
|
user.value = nextUser
|
||||||
const roles: UserRole[] = ['student', 'teacher', 'admin']
|
|
||||||
const nextRole = (role ?? roles[(roles.indexOf(user.value.role) + 1) % roles.length]) as UserRole
|
|
||||||
user.value = { ...defaultUsers[nextRole] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+94
-142
@@ -1,163 +1,115 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Lecture } from '@/types'
|
import { lecturesApi, usersApi } from '@/api'
|
||||||
|
import { mapApiLecture, mapApiReview } from '@/api/mappers'
|
||||||
export const LECTURES: Lecture[] = [
|
import type { Lecture, Review } from '@/types'
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const useLecturesStore = defineStore('lectures', () => {
|
export const useLecturesStore = defineStore('lectures', () => {
|
||||||
const lectures = ref<Lecture[]>(LECTURES)
|
const lectures = ref<Lecture[]>([])
|
||||||
const registered = ref<string[]>(['1', '3'])
|
const registered = ref<string[]>([])
|
||||||
|
const reviewsByLecture = ref<Record<string, Review[]>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
const all = computed(() => lectures.value)
|
const all = computed(() => lectures.value)
|
||||||
const registeredIds = computed(() => registered.value)
|
const registeredIds = computed(() => registered.value)
|
||||||
const registeredLectures = computed(() =>
|
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) {
|
async function fetchLectures() {
|
||||||
if (!registered.value.includes(lectureId)) {
|
loading.value = true
|
||||||
const l = lectures.value.find(x => x.id === lectureId)
|
error.value = null
|
||||||
if (!l || l.freeSeats === 0 || l.registrationClosed) return
|
try {
|
||||||
registered.value.push(lectureId)
|
const payload = await lecturesApi.list({ PageSize: 100 })
|
||||||
l.freeSeats--
|
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)
|
registered.value = registered.value.filter(id => id !== lectureId)
|
||||||
const l = lectures.value.find(x => x.id === lectureId)
|
const lecture = lectures.value.find(item => item.id === lectureId)
|
||||||
if (l) l.freeSeats++
|
if (lecture) {
|
||||||
|
lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats)
|
||||||
|
lecture.registered = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRegistered(lectureId: string) {
|
function isRegistered(lectureId: string) {
|
||||||
return registered.value.includes(lectureId)
|
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,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+51
-29
@@ -1,37 +1,50 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
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', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
const achievements = ref<Achievement[]>([
|
const achievements = ref<Achievement[]>([])
|
||||||
{ id: '1', title: 'Первый отзыв', description: 'Оставьте первый отзыв о лекции', icon: '⭐', unlocked: true, unlockedAt: '2025-04-10', coins: 20 },
|
const notifications = ref<Notification[]>([])
|
||||||
{ id: '2', title: 'Межфакультетский исследователь', description: 'Посетите лекции 3 разных институтов', icon: '🔭', unlocked: true, unlockedAt: '2025-04-18', coins: 50 },
|
const coinHistory = ref<CoinTransaction[]>([])
|
||||||
{ id: '3', title: '10 часов лекций', description: 'Наберите 10 часов посещённых лекций', icon: '⏱', unlocked: true, unlockedAt: '2025-04-25', coins: 30 },
|
const loading = ref(false)
|
||||||
{ id: '4', title: 'Полезный критик', description: 'Получите 5 монет за качественный отзыв', icon: '💡', unlocked: false },
|
const error = ref<string | null>(null)
|
||||||
{ id: '5', title: 'Знаток науки', description: 'Посетите лекции по 5 разным тематикам', icon: '🎓', unlocked: false },
|
|
||||||
{ id: '6', title: 'Ранний пташка', description: 'Запишитесь на лекцию за 7 дней до начала', icon: '🌅', unlocked: false },
|
async function fetchStudentData(userId?: string) {
|
||||||
{ id: '7', title: 'Социальная бабочка', description: 'Приведите друга на межфакультетскую лекцию', icon: '🦋', unlocked: false },
|
const auth = useAuthStore()
|
||||||
|
const id = userId ?? auth.user?.id
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const [stats, achievementPayload, transactions] = await Promise.all([
|
||||||
|
usersApi.stats(id),
|
||||||
|
usersApi.achievements(id),
|
||||||
|
usersApi.transactions(id),
|
||||||
])
|
])
|
||||||
|
|
||||||
const notifications = ref<Notification[]>([
|
if (auth.user) {
|
||||||
{ id: '1', type: 'reminder', title: 'Напоминание о лекции', body: 'Завтра в 14:00 — «Введение в нейронные сети». Ауд. 305, ИКТИБ', read: false, createdAt: '2025-05-06T09:00:00' },
|
auth.setUser({
|
||||||
{ id: '2', type: 'coins', title: 'Начислено 20 монет', body: 'Ваш отзыв о лекции «Квантовые вычисления» признан полезным', read: false, createdAt: '2025-05-05T18:30:00' },
|
...auth.user,
|
||||||
{ id: '3', type: 'achievement', title: 'Новое достижение!', body: 'Вы получили значок «Межфакультетский исследователь» 🔭', read: false, createdAt: '2025-05-04T12:00:00' },
|
coins: stats.coins,
|
||||||
{ id: '4', type: 'recommendation', title: 'Рекомендация для вас', body: 'Новая лекция «Нейромаркетинг» — может быть интересна вам', read: true, createdAt: '2025-05-03T10:00:00' },
|
level: stats.level,
|
||||||
{ id: '5', type: 'schedule-change', title: 'Изменение расписания', body: 'Лекция «Философия цифровой эпохи» перенесена с 18:00 на 19:00', read: true, createdAt: '2025-05-02T16:00:00' },
|
xp: stats.xp,
|
||||||
{ id: '6', type: 'coins', title: 'Начислено 30 монет', body: 'Поздравляем с достижением «10 часов лекций»!', read: true, createdAt: '2025-04-25T11:00:00' },
|
lecturesAttended: stats.attendedLectures,
|
||||||
])
|
hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10,
|
||||||
|
achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)),
|
||||||
const coinHistory = ref<CoinTransaction[]>([
|
})
|
||||||
{ id: '1', date: '2025-05-05', description: 'Полезный отзыв о лекции', amount: 20, type: 'earned' },
|
}
|
||||||
{ id: '2', date: '2025-04-25', description: 'Достижение «10 часов лекций»', amount: 30, type: 'earned' },
|
achievements.value = achievementPayload.map(mapApiAchievement)
|
||||||
{ id: '3', date: '2025-04-18', description: 'Достижение «Исследователь»', amount: 50, type: 'earned' },
|
coinHistory.value = transactions.map(mapApiCoinTransaction)
|
||||||
{ id: '4', date: '2025-04-10', description: 'Первый отзыв', amount: 20, type: 'earned' },
|
} catch (err) {
|
||||||
{ id: '5', date: '2025-04-05', description: 'Покупка стикерпака ЮФУ', amount: -80, type: 'spent' },
|
error.value = err instanceof Error ? err.message : 'Не удалось загрузить данные профиля.'
|
||||||
{ id: '6', date: '2025-03-20', description: 'Посещение серии лекций', amount: 60, type: 'earned' },
|
} finally {
|
||||||
{ id: '7', date: '2025-03-10', description: 'Покупка термокружки ЮФУ', amount: -120, type: 'spent' },
|
loading.value = false
|
||||||
{ id: '8', date: '2025-02-28', description: 'Первое посещение лекции вне факультета', amount: 40, type: 'earned' },
|
}
|
||||||
])
|
}
|
||||||
|
|
||||||
function markAllRead() {
|
function markAllRead() {
|
||||||
notifications.value.forEach(n => (n.read = true))
|
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
|
const unreadCount = () => notifications.value.filter(n => !n.read).length
|
||||||
|
|
||||||
return { achievements, notifications, coinHistory, markAllRead, unreadCount }
|
return {
|
||||||
|
achievements,
|
||||||
|
notifications,
|
||||||
|
coinHistory,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchStudentData,
|
||||||
|
markAllRead,
|
||||||
|
unreadCount,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const message = ref('Завершаем вход через Microsoft...')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const code = typeof route.query.code === 'string' ? route.query.code : ''
|
||||||
|
const state = typeof route.query.state === 'string' ? route.query.state : null
|
||||||
|
const error = typeof route.query.error_description === 'string' ? route.query.error_description : ''
|
||||||
|
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
||||||
|
const accessToken = hashParams.get('access_token')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (error) throw new Error(error)
|
||||||
|
if (accessToken) await auth.completeTokenLogin(accessToken)
|
||||||
|
else if (code) await auth.completeMicrosoftLogin(code, state)
|
||||||
|
else throw new Error('Microsoft не вернул токен авторизации.')
|
||||||
|
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
|
const role = auth.user?.role
|
||||||
|
if (role === 'teacher') await router.replace('/teacher')
|
||||||
|
else if (role === 'admin') await router.replace('/admin')
|
||||||
|
else await router.replace('/')
|
||||||
|
} catch (err) {
|
||||||
|
message.value = err instanceof Error ? err.message : 'Не удалось завершить авторизацию.'
|
||||||
|
window.setTimeout(() => router.replace({ path: '/login', query: { error: message.value } }), 1600)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="callback-bg">
|
||||||
|
<div class="callback-card">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h1>Вход в UniVerse</h1>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.callback-bg {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--gradient-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.callback-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: rgba(255,255,255,0.86);
|
||||||
|
border: 1px solid var(--color-border-glass);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 24px 70px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.spinner { margin: 0 auto 16px; }
|
||||||
|
h1 { font-size: 24px; margin: 0 0 8px; }
|
||||||
|
p { color: var(--color-text-secondary); margin: 0; }
|
||||||
|
</style>
|
||||||
@@ -1,34 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { UserRole } from '@/types'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const route = useRoute()
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectedRole = ref<UserRole>('student')
|
|
||||||
const simulateError = ref(false)
|
|
||||||
|
|
||||||
const roleOptions: Array<{ label: string; role: UserRole }> = [
|
if (typeof route.query.error === 'string') error.value = route.query.error
|
||||||
{ label: '🎓 Студент', role: 'student' },
|
|
||||||
{ label: '👩🏫 Преподаватель', role: 'teacher' },
|
|
||||||
{ label: '🛡️ Администратор', role: 'admin' },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const ok = await auth.login(selectedRole.value, simulateError.value)
|
const ok = auth.startMicrosoftLogin()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (ok) {
|
if (!ok) error.value = auth.error ?? 'Не удалось начать вход через Microsoft.'
|
||||||
if (selectedRole.value === 'teacher') router.push('/teacher')
|
|
||||||
else if (selectedRole.value === 'admin') router.push('/admin')
|
|
||||||
else router.push('/')
|
|
||||||
} else {
|
|
||||||
error.value = auth.error ?? 'Ошибка авторизации. Проверьте доступ и попробуйте снова.'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,21 +33,6 @@ async function login() {
|
|||||||
Получайте рекомендации, оставляйте отзывы и зарабатывайте монеты за полезную обратную связь.
|
Получайте рекомендации, оставляйте отзывы и зарабатывайте монеты за полезную обратную связь.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="role-select">
|
|
||||||
<p class="demo-label">Роль для демонстрации:</p>
|
|
||||||
<div class="role-options">
|
|
||||||
<button
|
|
||||||
v-for="opt in roleOptions"
|
|
||||||
:key="opt.role"
|
|
||||||
class="role-option"
|
|
||||||
:class="{ active: selectedRole === opt.role }"
|
|
||||||
@click="selectedRole = opt.role"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="login-actions">
|
<div class="login-actions">
|
||||||
<button class="btn-primary btn-full" type="button" :disabled="loading" @click="login">
|
<button class="btn-primary btn-full" type="button" :disabled="loading" @click="login">
|
||||||
<span v-if="loading" class="spinner-inline">
|
<span v-if="loading" class="spinner-inline">
|
||||||
@@ -68,10 +40,6 @@ async function login() {
|
|||||||
</span>
|
</span>
|
||||||
{{ loading ? 'Вход...' : 'Войти через ЮФУ (Microsoft Entra ID)' }}
|
{{ loading ? 'Вход...' : 'Войти через ЮФУ (Microsoft Entra ID)' }}
|
||||||
</button>
|
</button>
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" v-model="simulateError" />
|
|
||||||
Показать ошибку авторизации
|
|
||||||
</label>
|
|
||||||
<div class="error" v-if="error">⚠️ {{ error }}</div>
|
<div class="error" v-if="error">⚠️ {{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,39 +93,8 @@ async function login() {
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--color-border-glass);
|
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; }
|
.login-actions { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.btn-full { width: 100%; justify-content: center; }
|
.btn-full { width: 100%; justify-content: center; }
|
||||||
.toggle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.error {
|
.error {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
const unlocked = computed(() => userStore.achievements.filter(a => a.unlocked))
|
const unlocked = computed(() => userStore.achievements.filter(a => a.unlocked))
|
||||||
const locked = computed(() => userStore.achievements.filter(a => !a.unlocked))
|
const locked = computed(() => userStore.achievements.filter(a => !a.unlocked))
|
||||||
|
|
||||||
@@ -14,6 +17,10 @@ const rewards = [
|
|||||||
{ id: 'r3', title: 'Доп. консультация преподавателя', price: 220, available: false },
|
{ id: 'r3', title: 'Доп. консультация преподавателя', price: 220, available: false },
|
||||||
{ id: 'r4', title: 'Цифровой бейдж «Research Explorer»', price: 60, available: true },
|
{ id: 'r4', title: 'Цифровой бейдж «Research Explorer»', price: 60, available: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (auth.user) void userStore.fetchStudentData(auth.user.id)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -22,7 +29,12 @@ const rewards = [
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="section-title">Полученные достижения</h2>
|
<h2 class="section-title">Полученные достижения</h2>
|
||||||
<div class="list">
|
<EmptyState
|
||||||
|
v-if="!unlocked.length"
|
||||||
|
title="Полученных достижений пока нет"
|
||||||
|
subtitle="Они появятся после участия в лекциях и отзывах."
|
||||||
|
/>
|
||||||
|
<div v-else class="list">
|
||||||
<AchievementBadge
|
<AchievementBadge
|
||||||
v-for="a in unlocked"
|
v-for="a in unlocked"
|
||||||
:key="a.id"
|
:key="a.id"
|
||||||
@@ -38,7 +50,12 @@ const rewards = [
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="section-title">Заблокированные</h2>
|
<h2 class="section-title">Заблокированные</h2>
|
||||||
<div class="list">
|
<EmptyState
|
||||||
|
v-if="!locked.length"
|
||||||
|
title="Нет заблокированных достижений"
|
||||||
|
subtitle="Backend пока не вернул список будущих достижений."
|
||||||
|
/>
|
||||||
|
<div v-else class="list">
|
||||||
<AchievementBadge
|
<AchievementBadge
|
||||||
v-for="a in locked"
|
v-for="a in locked"
|
||||||
:key="a.id"
|
:key="a.id"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import SearchInput from '@/components/ui/SearchInput.vue'
|
import SearchInput from '@/components/ui/SearchInput.vue'
|
||||||
import LectureCard from '@/components/ui/LectureCard.vue'
|
import LectureCard from '@/components/ui/LectureCard.vue'
|
||||||
@@ -21,6 +21,10 @@ const onlyFree = ref(false)
|
|||||||
const filtersOpen = ref(false)
|
const filtersOpen = ref(false)
|
||||||
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
|
||||||
|
})
|
||||||
|
|
||||||
const tagFilters = ref([
|
const tagFilters = ref([
|
||||||
{ label: '#ML', value: '#ML', active: false },
|
{ label: '#ML', value: '#ML', active: false },
|
||||||
{ label: '#ИИ', value: '#ИИ', active: false },
|
{ label: '#ИИ', value: '#ИИ', active: false },
|
||||||
@@ -97,9 +101,13 @@ const calendarGroups = computed(() => {
|
|||||||
return Object.entries(groups)
|
return Object.entries(groups)
|
||||||
})
|
})
|
||||||
|
|
||||||
function registerLecture(id: string) {
|
async function registerLecture(id: string) {
|
||||||
lecturesStore.register(id)
|
try {
|
||||||
|
await lecturesStore.register(id)
|
||||||
addToast?.('Вы записаны на лекцию. Напоминание придет за сутки.', 'success')
|
addToast?.('Вы записаны на лекцию. Напоминание придет за сутки.', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -179,7 +187,15 @@ function registerLecture(id: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="filtered.length === 0">
|
<GlassCard v-if="lecturesStore.loading">
|
||||||
|
<div class="text-secondary">Загружаем лекции...</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<div v-else-if="lecturesStore.error">
|
||||||
|
<EmptyState title="Не удалось загрузить каталог" :subtitle="lecturesStore.error" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filtered.length === 0">
|
||||||
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." />
|
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,7 +228,13 @@ function registerLecture(id: string) {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<button class="btn-primary btn-sm" :disabled="row.freeSeats === 0 || row.registrationClosed">Записаться</button>
|
<button
|
||||||
|
class="btn-primary btn-sm"
|
||||||
|
:disabled="row.freeSeats === 0 || row.registrationClosed || lecturesStore.registeredIds.includes(row.id)"
|
||||||
|
@click="registerLecture(row.id)"
|
||||||
|
>
|
||||||
|
{{ lecturesStore.registeredIds.includes(row.id) ? 'Записан' : 'Записаться' }}
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
@@ -9,6 +9,7 @@ import StatsWidget from '@/components/ui/StatsWidget.vue'
|
|||||||
import LectureCard from '@/components/ui/LectureCard.vue'
|
import LectureCard from '@/components/ui/LectureCard.vue'
|
||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const lectures = useLecturesStore()
|
const lectures = useLecturesStore()
|
||||||
@@ -16,7 +17,7 @@ const userStore = useUserStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const user = computed(() => auth.user!)
|
const user = computed(() => auth.user!)
|
||||||
const nextLecture = computed(() => lectures.registeredLectures[0] ?? lectures.all[0]!)
|
const nextLecture = computed(() => lectures.registeredLectures[0] ?? lectures.all[0])
|
||||||
const recommended = computed(() =>
|
const recommended = computed(() =>
|
||||||
lectures.all.filter(l => !lectures.registeredIds.includes(l.id)).slice(0, 3)
|
lectures.all.filter(l => !lectures.registeredIds.includes(l.id)).slice(0, 3)
|
||||||
)
|
)
|
||||||
@@ -24,6 +25,14 @@ const achievements = computed(() => userStore.achievements.filter(a => a.unlocke
|
|||||||
const reminders = computed(() => userStore.notifications.slice(0, 3))
|
const reminders = computed(() => userStore.notifications.slice(0, 3))
|
||||||
const xpToNext = 200
|
const xpToNext = 200
|
||||||
const xpProgress = computed(() => user.value.xp ?? 120)
|
const xpProgress = computed(() => user.value.xp ?? 120)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
|
||||||
|
userStore.fetchStudentData(user.value.id),
|
||||||
|
])
|
||||||
|
await lectures.fetchRegisteredForUser(user.value.id)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,11 +45,11 @@ const xpProgress = computed(() => user.value.xp ?? 120)
|
|||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<button class="btn-primary" @click="router.push('/catalog')">Найти лекцию</button>
|
<button class="btn-primary" @click="router.push('/catalog')">Найти лекцию</button>
|
||||||
<button class="btn-secondary" @click="router.push('/my-lectures')">Мои записи</button>
|
<button class="btn-secondary" @click="router.push('/my-lectures')">Мои записи</button>
|
||||||
<button class="btn-secondary" @click="router.push(`/review/${nextLecture?.id ?? '1'}`)">Оставить отзыв</button>
|
<button class="btn-secondary" :disabled="!nextLecture" @click="nextLecture && router.push(`/review/${nextLecture.id}`)">Оставить отзыв</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard v-if="nextLecture">
|
||||||
<div class="next-lecture">
|
<div class="next-lecture">
|
||||||
<div>
|
<div>
|
||||||
<div class="section-title">Ближайшая лекция</div>
|
<div class="section-title">Ближайшая лекция</div>
|
||||||
@@ -57,6 +66,7 @@ const xpProgress = computed(() => user.value.xp ?? 120)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
<EmptyState v-else-if="!lectures.loading" title="Пока нет лекций" subtitle="Каталог пуст или данные ещё не синхронизированы." />
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatsWidget label="Посещено лекций" :value="user.lecturesAttended ?? 12" icon="📚" color="green" />
|
<StatsWidget label="Посещено лекций" :value="user.lecturesAttended ?? 12" icon="📚" color="green" />
|
||||||
@@ -80,7 +90,8 @@ const xpProgress = computed(() => user.value.xp ?? 120)
|
|||||||
<h2 class="section-title">✨ Рекомендуемые лекции</h2>
|
<h2 class="section-title">✨ Рекомендуемые лекции</h2>
|
||||||
<button class="link-btn" @click="router.push('/catalog')">Все лекции →</button>
|
<button class="link-btn" @click="router.push('/catalog')">Все лекции →</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cards-grid">
|
<EmptyState v-if="lectures.loading" title="Загружаем рекомендации" subtitle="Получаем данные с backend." />
|
||||||
|
<div v-else class="cards-grid">
|
||||||
<LectureCard
|
<LectureCard
|
||||||
v-for="l in recommended"
|
v-for="l in recommended"
|
||||||
:key="l.id"
|
:key="l.id"
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import LectureCard from '@/components/ui/LectureCard.vue'
|
import LectureCard from '@/components/ui/LectureCard.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
|
|
||||||
const lecture = computed(() => lecturesStore.all.find(l => l.id === route.params.id) ?? lecturesStore.all[0]!)
|
const lectureId = computed(() => String(route.params.id))
|
||||||
const isRegistered = computed(() => lecturesStore.isRegistered(lecture.value.id))
|
const lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.value))
|
||||||
const attendedLectures = ['1']
|
const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false))
|
||||||
const isAttended = computed(() => attendedLectures.includes(lecture.value.id))
|
const isAttended = computed(() => lecture.value?.status === 'completed')
|
||||||
|
const reviews = computed(() => lecturesStore.reviewsByLecture[lectureId.value] ?? [])
|
||||||
|
|
||||||
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lecture.value.id).slice(0, 3))
|
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lectureId.value).slice(0, 3))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
|
||||||
|
await lecturesStore.fetchLecture(lectureId.value)
|
||||||
|
await lecturesStore.fetchReviews(lectureId.value)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="lecture-detail page-content">
|
<div v-if="lecturesStore.loading && !lecture" class="lecture-detail page-content">
|
||||||
|
<GlassCard>
|
||||||
|
<div class="text-secondary">Загружаем лекцию...</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!lecture" class="lecture-detail page-content">
|
||||||
|
<EmptyState title="Лекция не найдена" :subtitle="lecturesStore.error ?? 'Попробуйте открыть каталог и выбрать лекцию заново.'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="lecture-detail page-content">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div>
|
||||||
<div class="breadcrumb">Каталог / {{ lecture.title }}</div>
|
<div class="breadcrumb">Каталог / {{ lecture.title }}</div>
|
||||||
@@ -73,16 +91,13 @@ const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== le
|
|||||||
Студенты отмечают «понятные примеры» и «много практики». Предлагается добавить больше времени на вопросы и
|
Студенты отмечают «понятные примеры» и «много практики». Предлагается добавить больше времени на вопросы и
|
||||||
прикладные кейсы. Средняя оценка — 4.8/5.
|
прикладные кейсы. Средняя оценка — 4.8/5.
|
||||||
</p>
|
</p>
|
||||||
<div class="reviews">
|
<div class="reviews" v-if="reviews.length">
|
||||||
<div class="review">
|
<div v-for="review in reviews" :key="review.id" class="review">
|
||||||
<div class="review-head">Анонимный отзыв · 5 ⭐</div>
|
<div class="review-head">{{ review.userName }} · {{ review.sentiment }}</div>
|
||||||
<div class="review-body">Очень структурно, понравились живые примеры и объяснение базовых концепций.</div>
|
<div class="review-body">{{ review.text }}</div>
|
||||||
</div>
|
|
||||||
<div class="review">
|
|
||||||
<div class="review-head">Анонимный отзыв · 4 ⭐</div>
|
|
||||||
<div class="review-body">Полезно, но хотелось больше времени на практику и разбор домашних заданий.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else class="text-secondary text-sm">Отзывов пока нет.</p>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const activeTab = ref<'upcoming' | 'history'>('upcoming')
|
const activeTab = ref<'upcoming' | 'history'>('upcoming')
|
||||||
const cancelModal = ref(false)
|
const cancelModal = ref(false)
|
||||||
@@ -16,19 +19,20 @@ const upcoming = computed(() =>
|
|||||||
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' }))
|
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' }))
|
||||||
)
|
)
|
||||||
|
|
||||||
const history = ref([
|
const history = computed(() => lecturesStore.all.filter(l => l.status === 'completed'))
|
||||||
{ id: '1', title: 'Введение в нейронные сети и глубокое обучение', date: '2025-04-20', time: '14:00', building: 'ИКТИБ', room: '305', status: 'attended' },
|
|
||||||
{ id: '4', title: 'Философия цифровой эпохи', date: '2025-04-12', time: '18:00', building: 'Онлайн', room: '', status: 'needsReview' },
|
onMounted(async () => {
|
||||||
{ id: '5', title: 'Право в информационном обществе', date: '2025-04-05', time: '15:30', building: 'ЮФ', room: '412', status: 'cancelled' },
|
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
|
||||||
])
|
if (auth.user) await lecturesStore.fetchRegisteredForUser(auth.user.id)
|
||||||
|
})
|
||||||
|
|
||||||
function openCancel(id: string) {
|
function openCancel(id: string) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
cancelModal.value = true
|
cancelModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmCancel() {
|
async function confirmCancel() {
|
||||||
if (selectedId.value) lecturesStore.unregister(selectedId.value)
|
if (selectedId.value) await lecturesStore.unregister(selectedId.value)
|
||||||
cancelModal.value = false
|
cancelModal.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -49,6 +53,7 @@ function confirmCancel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'upcoming'" class="list">
|
<div v-if="activeTab === 'upcoming'" class="list">
|
||||||
|
<EmptyState v-if="!upcoming.length" title="Нет предстоящих записей" subtitle="Выберите лекцию в каталоге и запишитесь на неё." />
|
||||||
<GlassCard v-for="item in upcoming" :key="item.id" class="lecture-row">
|
<GlassCard v-for="item in upcoming" :key="item.id" class="lecture-row">
|
||||||
<div>
|
<div>
|
||||||
<div class="lecture-title">{{ item.title }}</div>
|
<div class="lecture-title">{{ item.title }}</div>
|
||||||
@@ -64,6 +69,7 @@ function confirmCancel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="list">
|
<div v-else class="list">
|
||||||
|
<EmptyState v-if="!history.length" title="История пока пуста" subtitle="Завершённые лекции появятся здесь после посещения." />
|
||||||
<GlassCard v-for="item in history" :key="item.id" class="lecture-row">
|
<GlassCard v-for="item in history" :key="item.id" class="lecture-row">
|
||||||
<div>
|
<div>
|
||||||
<div class="lecture-title">{{ item.title }}</div>
|
<div class="lecture-title">{{ item.title }}</div>
|
||||||
@@ -71,8 +77,8 @@ function confirmCancel() {
|
|||||||
<div class="lecture-meta">🏛 {{ item.building }} {{ item.room ? `· ауд. ${item.room}` : '' }}</div>
|
<div class="lecture-meta">🏛 {{ item.building }} {{ item.room ? `· ауд. ${item.room}` : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lecture-actions">
|
<div class="lecture-actions">
|
||||||
<StatusBadge :status="item.status" />
|
<StatusBadge :status="item.status ?? 'completed'" />
|
||||||
<button v-if="item.status === 'needsReview'" class="btn-primary btn-sm" @click="router.push(`/review/${item.id}`)">
|
<button class="btn-primary btn-sm" @click="router.push(`/review/${item.id}`)">
|
||||||
Оставить отзыв
|
Оставить отзыв
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
@@ -7,6 +7,7 @@ import CoinChip from '@/components/ui/CoinChip.vue'
|
|||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||||
import DataTable from '@/components/ui/DataTable.vue'
|
import DataTable from '@/components/ui/DataTable.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -27,6 +28,10 @@ const historyColumns = [
|
|||||||
{ key: 'description', label: 'Описание' },
|
{ key: 'description', label: 'Описание' },
|
||||||
{ key: 'amount', label: 'Монеты', align: 'right' },
|
{ key: 'amount', label: 'Монеты', align: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void userStore.fetchStudentData(user.value.id)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -87,7 +92,12 @@ const historyColumns = [
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">Достижения</div>
|
<div class="section-title">Достижения</div>
|
||||||
<div class="achievements">
|
<EmptyState
|
||||||
|
v-if="!userStore.achievements.length"
|
||||||
|
title="Достижений пока нет"
|
||||||
|
subtitle="Они появятся после посещений, отзывов и начислений."
|
||||||
|
/>
|
||||||
|
<div v-else class="achievements">
|
||||||
<AchievementBadge
|
<AchievementBadge
|
||||||
v-for="a in userStore.achievements.slice(0, 3)"
|
v-for="a in userStore.achievements.slice(0, 3)"
|
||||||
:key="a.id"
|
:key="a.id"
|
||||||
@@ -104,6 +114,11 @@ const historyColumns = [
|
|||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">История начисления монет</div>
|
<div class="section-title">История начисления монет</div>
|
||||||
|
<EmptyState
|
||||||
|
v-if="!userStore.coinHistory.length"
|
||||||
|
title="История монет пуста"
|
||||||
|
subtitle="Начисления появятся после активностей на платформе."
|
||||||
|
/>
|
||||||
<DataTable :columns="historyColumns" :rows="userStore.coinHistory">
|
<DataTable :columns="historyColumns" :rows="userStore.coinHistory">
|
||||||
<template #amount="{ value }">
|
<template #amount="{ value }">
|
||||||
<span :class="value > 0 ? 'positive' : 'negative'">{{ value > 0 ? `+${value}` : value }}</span>
|
<span :class="value > 0 ? 'positive' : 'negative'">{{ value > 0 ? `+${value}` : value }}</span>
|
||||||
|
|||||||
@@ -2,16 +2,34 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
|
import { reviewsApi } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const rating = ref<'positive' | 'neutral' | 'negative'>('positive')
|
const rating = ref<'positive' | 'neutral' | 'negative'>('positive')
|
||||||
const text = ref('Лекция была хорошо структурирована, особенно понравились практические примеры и разбор кейсов.')
|
const text = ref('Лекция была хорошо структурирована, особенно понравились практические примеры и разбор кейсов.')
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
function submit() {
|
const ratingMap = {
|
||||||
|
positive: 'Like',
|
||||||
|
neutral: 'Neutral',
|
||||||
|
negative: 'Dislike',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await reviewsApi.create(String(route.params.id), ratingMap[rating.value], text.value)
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
editing.value = false
|
editing.value = false
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Не удалось отправить отзыв.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,9 +66,10 @@ function submit() {
|
|||||||
<div class="hint">
|
<div class="hint">
|
||||||
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите раскрыть глубже.
|
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите раскрыть глубже.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="error" v-if="error">{{ error }}</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn-primary" type="submit">Отправить отзыв</button>
|
<button class="btn-primary" type="submit" :disabled="loading">{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}</button>
|
||||||
<button class="btn-secondary" type="button" :disabled="submitted">Сохранить черновик</button>
|
<button class="btn-secondary" type="button" :disabled="submitted">Сохранить черновик</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -91,4 +110,5 @@ textarea {
|
|||||||
.success-icon { font-size: 28px; }
|
.success-icon { font-size: 28px; }
|
||||||
.success-title { font-size: 16px; font-weight: 700; }
|
.success-title { font-size: 16px; font-weight: 700; }
|
||||||
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
|
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
|
||||||
|
.error { color: var(--color-error); font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+22
-7
@@ -1,18 +1,33 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [
|
// loadEnv(..., '') — чтобы получить и НЕ-VITE переменные при необходимости,
|
||||||
vue(),
|
// но мы используем именно VITE_* для простоты прокидывания из AppHost.
|
||||||
vueDevTools(),
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
],
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:5019'
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [vue(), vueDevTools()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user