refactor: натравил форматтер на весь фронт

This commit is contained in:
2026-05-25 02:06:11 +03:00
parent 24df65a13c
commit 34c2547de5
44 changed files with 1948 additions and 657 deletions
+6 -2
View File
@@ -12,7 +12,11 @@ const route = useRoute()
const isAuthPage = computed(() => Boolean(route.meta.public)) 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[]>([])
let toastId = 0 let toastId = 0
@@ -21,7 +25,7 @@ function addToast(msg: string, type: Toast['type'] = 'success') {
} }
function removeToast(id: number) { function removeToast(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id) toasts.value = toasts.value.filter((t) => t.id !== id)
} }
// expose globally via provide // expose globally via provide
+31 -8
View File
@@ -1,4 +1,12 @@
import type { Achievement, CoinTransaction, Lecture, Notification, Review, User, UserRole } from '@/types' import type {
Achievement,
CoinTransaction,
Lecture,
Notification,
Review,
User,
UserRole,
} from '@/types'
import type { import type {
AchievementDto, AchievementDto,
CoinTransactionDto, CoinTransactionDto,
@@ -30,7 +38,10 @@ function getDefaultActiveRole(roles: UserRole[]): UserRole {
return 'student' return 'student'
} }
export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: UserStatsDto): User { export function mapApiUser(
user: UserAuthDto | UserDto | CurrentUserDto,
stats?: UserStatsDto,
): User {
const roles = mapApiRoles(user.roles) const roles = mapApiRoles(user.roles)
return { return {
id: user.id, id: user.id,
@@ -38,7 +49,7 @@ export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?:
email: user.email || '', email: user.email || '',
roles, roles,
activeRole: getDefaultActiveRole(roles), activeRole: getDefaultActiveRole(roles),
avatar: 'avatarUrl' in user ? user.avatarUrl ?? undefined : undefined, avatar: 'avatarUrl' in user ? (user.avatarUrl ?? undefined) : undefined,
institute: 'ЮФУ', institute: 'ЮФУ',
department: '', department: '',
year: 0, year: 0,
@@ -50,7 +61,9 @@ export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?:
nextLevelXp: stats?.nextLevelXp, nextLevelXp: stats?.nextLevelXp,
lecturesAttended: stats?.attendedLectures ?? 0, lecturesAttended: stats?.attendedLectures ?? 0,
hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0, hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0,
achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [], achievements: stats
? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1))
: [],
activeEnrollments: stats?.activeEnrollments, activeEnrollments: stats?.activeEnrollments,
enrollmentSlotLimit: stats?.enrollmentSlotLimit, enrollmentSlotLimit: stats?.enrollmentSlotLimit,
enrollmentSlotRules: stats?.enrollmentSlotRules, enrollmentSlotRules: stats?.enrollmentSlotRules,
@@ -61,11 +74,13 @@ export function mapApiLecture(lecture: LectureDto): Lecture {
const startsAt = new Date(lecture.startsAt) const startsAt = new Date(lecture.startsAt)
const endsAt = new Date(lecture.endsAt) const endsAt = new Date(lecture.endsAt)
const durationMs = endsAt.getTime() - startsAt.getTime() const durationMs = endsAt.getTime() - startsAt.getTime()
const duration = Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs / 60000) : 90 const duration =
Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs / 60000) : 90
const totalSeats = lecture.maxEnrollments || 0 const totalSeats = lecture.maxEnrollments || 0
const enrolled = lecture.enrollmentsCount || 0 const enrolled = lecture.enrollmentsCount || 0
const freeSeats = Math.max(totalSeats - enrolled, 0) const freeSeats = Math.max(totalSeats - enrolled, 0)
const locationName = lecture.locationName || (lecture.format === 'Online' ? 'Онлайн' : 'Аудитория уточняется') const locationName =
lecture.locationName || (lecture.format === 'Online' ? 'Онлайн' : 'Аудитория уточняется')
return { return {
id: String(lecture.id), id: String(lecture.id),
@@ -96,9 +111,17 @@ export function mapApiLecture(lecture: LectureDto): Lecture {
export function mapApiReview(review: ReviewDto): Review { export function mapApiReview(review: ReviewDto): Review {
const sentiment = const sentiment =
review.sentiment === 'Positive' ? 'positive' : review.sentiment === 'Negative' ? 'negative' : 'neutral' review.sentiment === 'Positive'
? 'positive'
: review.sentiment === 'Negative'
? 'negative'
: 'neutral'
const status = const status =
review.llmStatus === 'Rejected' ? 'rejected' : review.llmStatus === 'Analyzed' ? 'done' : 'pending' review.llmStatus === 'Rejected'
? 'rejected'
: review.llmStatus === 'Analyzed'
? 'done'
: 'pending'
return { return {
id: String(review.id), id: String(review.id),
+287 -152
View File
@@ -1,147 +1,184 @@
:root { :root {
--color-white: #FFFFFF; --color-white: #ffffff;
--color-black: #000000; --color-black: #000000;
--color-primary: #22C55E; --color-primary: #22c55e;
--color-primary-dark: #16A34A; --color-primary-dark: #16a34a;
--color-primary-light: #86EFAC; --color-primary-light: #86efac;
--color-primary-bright: #4ADE80; --color-primary-bright: #4ade80;
--color-primary-border: #15803D; --color-primary-border: #15803d;
--color-aqua: #06B6D4; --color-aqua: #06b6d4;
--color-aqua-dark: #0E7490; --color-aqua-dark: #0e7490;
--color-aqua-light: #67E8F9; --color-aqua-light: #67e8f9;
--color-sky: #7DD3FC; --color-sky: #7dd3fc;
--color-sky-light: #BAE6FD; --color-sky-light: #bae6fd;
--color-white-glass: rgba(255, 255, 255, 0.75); --color-white-glass: rgba(255, 255, 255, 0.75);
--color-surface: rgba(255, 255, 255, 0.85); --color-surface: rgba(255, 255, 255, 0.85);
--color-border-glass: rgba(255, 255, 255, 0.8); --color-border-glass: rgba(255, 255, 255, 0.8);
--color-text: #1E293B; --color-text: #1e293b;
--color-text-secondary: #64748B; --color-text-secondary: #64748b;
--color-bg-start: #E0F2FE; --color-bg-start: #e0f2fe;
--color-bg-mid: #DCFCE7; --color-bg-mid: #dcfce7;
--color-error: #EF4444; --color-error: #ef4444;
--color-error-dark: #DC2626; --color-error-dark: #dc2626;
--color-error-border: #B91C1C; --color-error-border: #b91c1c;
--color-success: #22C55E; --color-success: #22c55e;
--color-success-text: #166534; --color-success-text: #166534;
--color-warning: #F59E0B; --color-warning: #f59e0b;
--color-warning-text: #92400E; --color-warning-text: #92400e;
--color-warning-border: #FDE68A; --color-warning-border: #fde68a;
--color-brown-dark: #78350F; --color-brown-dark: #78350f;
--color-danger-text: #991B1B; --color-danger-text: #991b1b;
--color-danger-light: #FCA5A5; --color-danger-light: #fca5a5;
--color-danger-pale: #FECACA; --color-danger-pale: #fecaca;
--color-info-text: #1E40AF; --color-info-text: #1e40af;
--color-info-border: #93C5FD; --color-info-border: #93c5fd;
--color-orange: #FB923C; --color-orange: #fb923c;
--color-orange-deep: #EA580C; --color-orange-deep: #ea580c;
--color-orange-dark: #C2410C; --color-orange-dark: #c2410c;
--color-yellow: #FCD34D; --color-yellow: #fcd34d;
--color-purple: #A78BFA; --color-purple: #a78bfa;
--color-purple-light: #C4B5FD; --color-purple-light: #c4b5fd;
--color-purple-dark: #6D28D9; --color-purple-dark: #6d28d9;
--color-gray-400: #9CA3AF; --color-gray-400: #9ca3af;
--color-star: #FBBF24; --color-star: #fbbf24;
--color-white-a10: rgba(255,255,255,0.1); --color-white-a10: rgba(255, 255, 255, 0.1);
--color-white-a30: rgba(255,255,255,0.3); --color-white-a30: rgba(255, 255, 255, 0.3);
--color-white-a40: rgba(255,255,255,0.4); --color-white-a40: rgba(255, 255, 255, 0.4);
--color-white-a50: rgba(255,255,255,0.5); --color-white-a50: rgba(255, 255, 255, 0.5);
--color-white-a60: rgba(255,255,255,0.6); --color-white-a60: rgba(255, 255, 255, 0.6);
--color-white-a70: rgba(255,255,255,0.7); --color-white-a70: rgba(255, 255, 255, 0.7);
--color-white-a72: rgba(255,255,255,0.72); --color-white-a72: rgba(255, 255, 255, 0.72);
--color-white-a80: rgba(255,255,255,0.8); --color-white-a80: rgba(255, 255, 255, 0.8);
--color-white-a82: rgba(255,255,255,0.82); --color-white-a82: rgba(255, 255, 255, 0.82);
--color-white-a86: rgba(255,255,255,0.86); --color-white-a86: rgba(255, 255, 255, 0.86);
--color-white-a90: rgba(255,255,255,0.9); --color-white-a90: rgba(255, 255, 255, 0.9);
--color-white-a96: rgba(255,255,255,0.96); --color-white-a96: rgba(255, 255, 255, 0.96);
--color-black-a04: rgba(0,0,0,0.04); --color-black-a04: rgba(0, 0, 0, 0.04);
--color-black-a05: rgba(0,0,0,0.05); --color-black-a05: rgba(0, 0, 0, 0.05);
--color-black-a06: rgba(0,0,0,0.06); --color-black-a06: rgba(0, 0, 0, 0.06);
--color-black-a08: rgba(0,0,0,0.08); --color-black-a08: rgba(0, 0, 0, 0.08);
--color-black-a12: rgba(0,0,0,0.12); --color-black-a12: rgba(0, 0, 0, 0.12);
--color-black-a15: rgba(0,0,0,0.15); --color-black-a15: rgba(0, 0, 0, 0.15);
--color-black-a20: rgba(0,0,0,0.2); --color-black-a20: rgba(0, 0, 0, 0.2);
--color-black-a35: rgba(0,0,0,0.35); --color-black-a35: rgba(0, 0, 0, 0.35);
--color-slate-900-a08: rgba(15,23,42,0.08); --color-slate-900-a08: rgba(15, 23, 42, 0.08);
--color-slate-900-a14: rgba(15,23,42,0.14); --color-slate-900-a14: rgba(15, 23, 42, 0.14);
--color-slate-500-a10: rgba(100,116,139,0.1); --color-slate-500-a10: rgba(100, 116, 139, 0.1);
--color-slate-500-a20: rgba(100,116,139,0.2); --color-slate-500-a20: rgba(100, 116, 139, 0.2);
--color-primary-a05: rgba(34,197,94,0.05); --color-primary-a05: rgba(34, 197, 94, 0.05);
--color-primary-a08: rgba(34,197,94,0.08); --color-primary-a08: rgba(34, 197, 94, 0.08);
--color-primary-a10: rgba(34,197,94,0.1); --color-primary-a10: rgba(34, 197, 94, 0.1);
--color-primary-a12: rgba(34,197,94,0.12); --color-primary-a12: rgba(34, 197, 94, 0.12);
--color-primary-a15: rgba(34,197,94,0.15); --color-primary-a15: rgba(34, 197, 94, 0.15);
--color-primary-a18: rgba(34,197,94,0.18); --color-primary-a18: rgba(34, 197, 94, 0.18);
--color-primary-a20: rgba(34,197,94,0.2); --color-primary-a20: rgba(34, 197, 94, 0.2);
--color-primary-a25: rgba(34,197,94,0.25); --color-primary-a25: rgba(34, 197, 94, 0.25);
--color-primary-a30: rgba(34,197,94,0.3); --color-primary-a30: rgba(34, 197, 94, 0.3);
--color-primary-a40: rgba(34,197,94,0.4); --color-primary-a40: rgba(34, 197, 94, 0.4);
--color-primary-a45: rgba(34,197,94,0.45); --color-primary-a45: rgba(34, 197, 94, 0.45);
--color-primary-a50: rgba(34,197,94,0.5); --color-primary-a50: rgba(34, 197, 94, 0.5);
--color-primary-light-a12: rgba(134,239,172,0.12); --color-primary-light-a12: rgba(134, 239, 172, 0.12);
--color-error-a10: rgba(239,68,68,0.1); --color-error-a10: rgba(239, 68, 68, 0.1);
--color-error-a12: rgba(239,68,68,0.12); --color-error-a12: rgba(239, 68, 68, 0.12);
--color-error-a20: rgba(239,68,68,0.2); --color-error-a20: rgba(239, 68, 68, 0.2);
--color-error-a24: rgba(239,68,68,0.24); --color-error-a24: rgba(239, 68, 68, 0.24);
--color-error-a30: rgba(239,68,68,0.3); --color-error-a30: rgba(239, 68, 68, 0.3);
--color-error-a40: rgba(239,68,68,0.4); --color-error-a40: rgba(239, 68, 68, 0.4);
--color-aqua-a15: rgba(6,182,212,0.15); --color-aqua-a15: rgba(6, 182, 212, 0.15);
--color-aqua-a25: rgba(6,182,212,0.25); --color-aqua-a25: rgba(6, 182, 212, 0.25);
--color-aqua-a30: rgba(6,182,212,0.3); --color-aqua-a30: rgba(6, 182, 212, 0.3);
--color-aqua-a40: rgba(6,182,212,0.4); --color-aqua-a40: rgba(6, 182, 212, 0.4);
--color-orange-a15: rgba(251,146,60,0.15); --color-orange-a15: rgba(251, 146, 60, 0.15);
--color-orange-a30: rgba(251,146,60,0.3); --color-orange-a30: rgba(251, 146, 60, 0.3);
--color-purple-a12: rgba(139,92,246,0.12); --color-purple-a12: rgba(139, 92, 246, 0.12);
--color-purple-a20: rgba(139,92,246,0.2); --color-purple-a20: rgba(139, 92, 246, 0.2);
--color-star-a15: rgba(251,191,36,0.15); --color-star-a15: rgba(251, 191, 36, 0.15);
--color-star-a20: rgba(251,191,36,0.2); --color-star-a20: rgba(251, 191, 36, 0.2);
--color-star-a30: rgba(251,191,36,0.3); --color-star-a30: rgba(251, 191, 36, 0.3);
--color-warning-a15: rgba(245,158,11,0.15); --color-warning-a15: rgba(245, 158, 11, 0.15);
--color-warning-a25: rgba(245,158,11,0.25); --color-warning-a25: rgba(245, 158, 11, 0.25);
--color-warning-a40: rgba(245,158,11,0.4); --color-warning-a40: rgba(245, 158, 11, 0.4);
--color-success-bg-a90: rgba(220,252,231,0.9); --color-success-bg-a90: rgba(220, 252, 231, 0.9);
--color-success-bg-a95: rgba(220,252,231,0.95); --color-success-bg-a95: rgba(220, 252, 231, 0.95);
--color-info-bg-a90: rgba(224,242,254,0.9); --color-info-bg-a90: rgba(224, 242, 254, 0.9);
--color-info-bg-a95: rgba(224,242,254,0.95); --color-info-bg-a95: rgba(224, 242, 254, 0.95);
--color-danger-bg-a68: rgba(254,242,242,0.68); --color-danger-bg-a68: rgba(254, 242, 242, 0.68);
--color-danger-bg-a90: rgba(254,226,226,0.9); --color-danger-bg-a90: rgba(254, 226, 226, 0.9);
--color-danger-bg-a95: rgba(254,226,226,0.95); --color-danger-bg-a95: rgba(254, 226, 226, 0.95);
--color-warning-bg-a90: rgba(254,243,199,0.9); --color-warning-bg-a90: rgba(254, 243, 199, 0.9);
--gradient-bg: linear-gradient(135deg, var(--color-bg-start) 0%, var(--color-bg-mid) 50%, var(--color-bg-start) 100%); --gradient-bg: linear-gradient(
--gradient-brand: linear-gradient(135deg, var(--color-primary) 0%, var(--color-aqua) 60%, var(--color-sky) 100%); 135deg,
--gradient-progress-success: linear-gradient(90deg, var(--color-primary), var(--color-primary-light)); var(--color-bg-start) 0%,
var(--color-bg-mid) 50%,
var(--color-bg-start) 100%
);
--gradient-brand: linear-gradient(
135deg,
var(--color-primary) 0%,
var(--color-aqua) 60%,
var(--color-sky) 100%
);
--gradient-progress-success: linear-gradient(
90deg,
var(--color-primary),
var(--color-primary-light)
);
--gradient-progress-neutral: linear-gradient(90deg, var(--color-sky), var(--color-sky-light)); --gradient-progress-neutral: linear-gradient(90deg, var(--color-sky), var(--color-sky-light));
--gradient-progress-danger: linear-gradient(90deg, var(--color-danger-light), var(--color-danger-pale)); --gradient-progress-danger: linear-gradient(
--gradient-bar-success-vertical: linear-gradient(180deg, var(--color-primary), var(--color-primary-light)); 90deg,
--gradient-bar-neutral-vertical: linear-gradient(180deg, var(--color-sky), var(--color-sky-light)); var(--color-danger-light),
--gradient-nav-active: linear-gradient(135deg, var(--color-primary-a18), var(--color-primary-light-a12)); var(--color-danger-pale)
);
--gradient-bar-success-vertical: linear-gradient(
180deg,
var(--color-primary),
var(--color-primary-light)
);
--gradient-bar-neutral-vertical: linear-gradient(
180deg,
var(--color-sky),
var(--color-sky-light)
);
--gradient-nav-active: linear-gradient(
135deg,
var(--color-primary-a18),
var(--color-primary-light-a12)
);
--gradient-stats-green: linear-gradient(90deg, var(--color-primary), var(--color-primary-light)); --gradient-stats-green: linear-gradient(90deg, var(--color-primary), var(--color-primary-light));
--gradient-stats-aqua: linear-gradient(90deg, var(--color-aqua), var(--color-aqua-light)); --gradient-stats-aqua: linear-gradient(90deg, var(--color-aqua), var(--color-aqua-light));
--gradient-stats-orange: linear-gradient(90deg, var(--color-orange), var(--color-yellow)); --gradient-stats-orange: linear-gradient(90deg, var(--color-orange), var(--color-yellow));
--gradient-stats-purple: linear-gradient(90deg, var(--color-purple), var(--color-purple-light)); --gradient-stats-purple: linear-gradient(90deg, var(--color-purple), var(--color-purple-light));
--gradient-coin-chip: linear-gradient(135deg, var(--color-primary-a25) 0%, var(--color-aqua-a25) 100%); --gradient-coin-chip: linear-gradient(
--gradient-coin-chip-hover: linear-gradient(135deg, var(--color-primary-a40) 0%, var(--color-aqua-a40) 100%); 135deg,
var(--color-primary-a25) 0%,
var(--color-aqua-a25) 100%
);
--gradient-coin-chip-hover: linear-gradient(
135deg,
var(--color-primary-a40) 0%,
var(--color-aqua-a40) 100%
);
--color-coin-chip-border: var(--color-primary-a45); --color-coin-chip-border: var(--color-primary-a45);
--color-coin-chip-text: var(--color-primary-border); --color-coin-chip-text: var(--color-primary-border);
--color-coin-chip-label: var(--color-primary-border); --color-coin-chip-label: var(--color-primary-border);
@@ -155,15 +192,19 @@
--topbar-height: 60px; --topbar-height: 60px;
} }
*, *::before, *::after { *,
*::before,
*::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
html, body { html,
body {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px; font-size: 14px;
color: var(--color-text); color: var(--color-text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -175,7 +216,6 @@ body {
min-height: 100vh; min-height: 100vh;
} }
a { a {
color: var(--color-primary-dark); color: var(--color-primary-dark);
text-decoration: none; text-decoration: none;
@@ -188,16 +228,29 @@ button {
outline: none; outline: none;
} }
input, textarea, select { input,
textarea,
select {
font-family: inherit; font-family: inherit;
outline: none; outline: none;
} }
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar {
::-webkit-scrollbar-track { background: var(--color-black-a04); border-radius: 3px; } width: 6px;
::-webkit-scrollbar-thumb { background: var(--color-primary-a30); border-radius: 3px; } height: 6px;
::-webkit-scrollbar-thumb:hover { background: var(--color-primary-a50); } }
::-webkit-scrollbar-track {
background: var(--color-black-a04);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--color-primary-a30);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-primary-a50);
}
/* Glass panel utility */ /* Glass panel utility */
.glass-panel { .glass-panel {
@@ -239,7 +292,9 @@ input, textarea, select {
box-shadow: 0 8px 18px var(--color-primary-a15); box-shadow: 0 8px 18px var(--color-primary-a15);
} }
.btn-primary:focus-visible { .btn-primary:focus-visible {
box-shadow: 0 0 0 3px var(--color-primary-a20), 0 2px 8px var(--color-primary-a08); box-shadow:
0 0 0 3px var(--color-primary-a20),
0 2px 8px var(--color-primary-a08);
} }
.btn-primary:active { .btn-primary:active {
background: var(--color-primary-a30); background: var(--color-primary-a30);
@@ -312,7 +367,9 @@ input, textarea, select {
box-shadow: 0 8px 18px var(--color-error-a12); box-shadow: 0 8px 18px var(--color-error-a12);
} }
.btn-danger:focus-visible { .btn-danger:focus-visible {
box-shadow: 0 0 0 3px var(--color-error-a20), 0 2px 8px var(--color-error-a10); box-shadow:
0 0 0 3px var(--color-error-a20),
0 2px 8px var(--color-error-a10);
} }
.btn-danger:active { .btn-danger:active {
background: var(--color-danger-pale); background: var(--color-danger-pale);
@@ -380,12 +437,35 @@ input, textarea, select {
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
} }
.badge-green { color: var(--color-primary-border); border: 1.3px solid var(--color-primary-a30); } .badge-green {
.badge-blue { background: var(--color-aqua-a15); color: var(--color-aqua-dark); border: 1px solid var(--color-aqua-a30); } color: var(--color-primary-border);
.badge-orange { background: var(--color-orange-a15); color: var(--color-orange-dark); border: 1px solid var(--color-orange-a30); } border: 1.3px solid var(--color-primary-a30);
.badge-gray { background: var(--color-slate-500-a10); color: var(--color-text-secondary); border: 1px solid var(--color-slate-500-a20); } }
.badge-red { background: var(--color-error-a12); color: var(--color-error-border); border: 1px solid var(--color-error-a20); } .badge-blue {
.badge-purple { background: var(--color-purple-a12); color: var(--color-purple-dark); border: 1px solid var(--color-purple-a20); } background: var(--color-aqua-a15);
color: var(--color-aqua-dark);
border: 1px solid var(--color-aqua-a30);
}
.badge-orange {
background: var(--color-orange-a15);
color: var(--color-orange-dark);
border: 1px solid var(--color-orange-a30);
}
.badge-gray {
background: var(--color-slate-500-a10);
color: var(--color-text-secondary);
border: 1px solid var(--color-slate-500-a20);
}
.badge-red {
background: var(--color-error-a12);
color: var(--color-error-border);
border: 1px solid var(--color-error-a20);
}
.badge-purple {
background: var(--color-purple-a12);
color: var(--color-purple-dark);
border: 1px solid var(--color-purple-a20);
}
/* Tag chip */ /* Tag chip */
.tag-chip { .tag-chip {
@@ -430,45 +510,101 @@ input, textarea, select {
} }
/* Grid helpers */ /* Grid helpers */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } .grid-2 {
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } display: grid;
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } .grid-2,
.page-content { padding: 16px; } .grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
.page-content {
padding: 16px;
}
} }
/* Flex helpers */ /* Flex helpers */
.flex { display: flex; } .flex {
.flex-col { display: flex; flex-direction: column; } display: flex;
.items-center { align-items: center; } }
.justify-between { justify-content: space-between; } .flex-col {
.gap-2 { gap: 8px; } display: flex;
.gap-3 { gap: 12px; } flex-direction: column;
.gap-4 { gap: 16px; } }
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 12px;
}
.gap-4 {
gap: 16px;
}
/* Text helpers */ /* Text helpers */
.text-sm { font-size: 12px; } .text-sm {
.text-secondary { color: var(--color-text-secondary); } font-size: 12px;
.font-bold { font-weight: 700; } }
.font-semibold { font-weight: 600; } .text-secondary {
color: var(--color-text-secondary);
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
/* Stars rating */ /* Stars rating */
.stars { color: var(--color-star); font-size: 14px; letter-spacing: 1px; } .stars {
color: var(--color-star);
font-size: 14px;
letter-spacing: 1px;
}
/* Animations */ /* Animations */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease;
} }
.fade-in { animation: fadeIn 0.3s ease; }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
.spinner { .spinner {
width: 20px; height: 20px; width: 20px;
height: 20px;
border: 2px solid var(--color-white-a30); border: 2px solid var(--color-white-a30);
border-top-color: var(--color-white); border-top-color: var(--color-white);
border-radius: 50%; border-radius: 50%;
@@ -480,7 +616,6 @@ input, textarea, select {
border-top-color: var(--color-primary); border-top-color: var(--color-primary);
} }
#app { #app {
min-height: 100vh; min-height: 100vh;
} }
@@ -50,7 +50,9 @@ const filteredLocations = computed(() => {
const search = locationSearch.value.trim().toLowerCase() const search = locationSearch.value.trim().toLowerCase()
if (!search) return props.locations if (!search) return props.locations
return props.locations.filter((location) => formatLocation(location).toLowerCase().includes(search)) return props.locations.filter((location) =>
formatLocation(location).toLowerCase().includes(search),
)
}) })
const isOpen = computed({ const isOpen = computed({
@@ -9,12 +9,14 @@ const route = useRoute()
const navItems = computed(() => { const navItems = computed(() => {
const role = auth.user?.activeRole ?? 'student' const role = auth.user?.activeRole ?? 'student'
if (role === 'teacher') return [ if (role === 'teacher')
return [
{ label: 'Дашборд', icon: 'chart-bar', to: '/teacher' }, { label: 'Дашборд', icon: 'chart-bar', to: '/teacher' },
{ label: 'Лекции', icon: 'book-2', to: '/teacher/lectures' }, { label: 'Лекции', icon: 'book-2', to: '/teacher/lectures' },
{ label: 'Аналитика', icon: 'chart-line', to: '/teacher/analytics' }, { label: 'Аналитика', icon: 'chart-line', to: '/teacher/analytics' },
] ]
if (role === 'admin') return [ if (role === 'admin')
return [
{ label: 'Дашборд', icon: 'shield', to: '/admin' }, { label: 'Дашборд', icon: 'shield', to: '/admin' },
{ label: 'Юзеры', icon: 'users', to: '/admin/users' }, { label: 'Юзеры', icon: 'users', to: '/admin/users' },
{ label: 'Лекции', icon: 'books', to: '/admin/lectures' }, { label: 'Лекции', icon: 'books', to: '/admin/lectures' },
@@ -76,9 +78,20 @@ function isActive(to: string) {
padding: 4px 0; padding: 4px 0;
transition: color 0.2s; transition: color 0.2s;
} }
.bottom-nav-item.active { color: var(--color-primary-dark); } .bottom-nav-item.active {
.bottom-nav-icon { color: currentColor; } color: var(--color-primary-dark);
.bottom-nav-label { font-size: 10px; font-weight: 600; } }
.bottom-nav-icon {
color: currentColor;
}
.bottom-nav-label {
font-size: 10px;
font-weight: 600;
}
@media (max-width: 768px) { .bottom-nav { display: flex; } } @media (max-width: 768px) {
.bottom-nav {
display: flex;
}
}
</style> </style>
+16 -4
View File
@@ -7,7 +7,12 @@ import AppIcon from '@/components/ui/AppIcon.vue'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
interface NavItem { label: string; icon: string; to: string; roles: string[] } interface NavItem {
label: string
icon: string
to: string
roles: string[]
}
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: 'Главная', icon: 'home', to: '/', roles: ['student'] }, { label: 'Главная', icon: 'home', to: '/', roles: ['student'] },
@@ -25,7 +30,7 @@ const navItems: NavItem[] = [
] ]
const visible = computed(() => const visible = computed(() =>
navItems.filter(n => auth.user && n.roles.includes(auth.user.activeRole)) navItems.filter((n) => auth.user && n.roles.includes(auth.user.activeRole)),
) )
function isActive(to: string) { function isActive(to: string) {
@@ -101,7 +106,10 @@ function isActive(to: string) {
font-weight: 700; font-weight: 700;
box-shadow: 0 2px 8px var(--color-primary-a12); box-shadow: 0 2px 8px var(--color-primary-a12);
} }
.nav-icon { flex-shrink: 0; color: currentColor; } .nav-icon {
flex-shrink: 0;
color: currentColor;
}
.sidebar-footer { .sidebar-footer {
padding: 10px 18px 8px; padding: 10px 18px 8px;
display: flex; display: flex;
@@ -113,5 +121,9 @@ function isActive(to: string) {
font-weight: 600; font-weight: 600;
} }
@media (max-width: 768px) { .sidebar { display: none; } } @media (max-width: 768px) {
.sidebar {
display: none;
}
}
</style> </style>
+70 -25
View File
@@ -41,8 +41,8 @@ const roleButtons = computed(() => {
if (!auth.user) return [] if (!auth.user) return []
return auth.user.roles return auth.user.roles
.filter(role => role !== auth.user?.activeRole) .filter((role) => role !== auth.user?.activeRole)
.map(role => ({ .map((role) => ({
role, role,
label: roleLabels[role], label: roleLabels[role],
to: roleTargets[role], to: roleTargets[role],
@@ -137,8 +137,7 @@ onBeforeUnmount(() => {
<span class="brand-name">UniVerse</span> <span class="brand-name">UniVerse</span>
</div> </div>
<div class="topbar-center"> <div class="topbar-center"></div>
</div>
<div class="topbar-right"> <div class="topbar-right">
<CoinChip <CoinChip
@@ -172,7 +171,12 @@ onBeforeUnmount(() => {
<span class="slot-value">{{ enrollmentSlotText }}</span> <span class="slot-value">{{ enrollmentSlotText }}</span>
</button> </button>
<button class="notif-btn" type="button" aria-label="Уведомления" @click="$router.push('/notifications')"> <button
class="notif-btn"
type="button"
aria-label="Уведомления"
@click="$router.push('/notifications')"
>
<AppIcon icon="bell" :size="18" /> <AppIcon icon="bell" :size="18" />
<span class="notif-dot" v-if="auth.user && unreadCount > 0"> <span class="notif-dot" v-if="auth.user && unreadCount > 0">
{{ unreadCount }} {{ unreadCount }}
@@ -192,7 +196,12 @@ onBeforeUnmount(() => {
<span class="avatar-name" v-if="auth.user">{{ formatUserName(auth.user.name) }}</span> <span class="avatar-name" v-if="auth.user">{{ formatUserName(auth.user.name) }}</span>
</button> </button>
<div v-if="isProfileMenuOpen" class="profile-dropdown" role="menu" @keydown.esc.prevent="closeProfileMenu"> <div
v-if="isProfileMenuOpen"
class="profile-dropdown"
role="menu"
@keydown.esc.prevent="closeProfileMenu"
>
<button class="profile-menu-item" type="button" role="menuitem" @click="openProfile"> <button class="profile-menu-item" type="button" role="menuitem" @click="openProfile">
<AppIcon icon="user" :size="16" /> <AppIcon icon="user" :size="16" />
Профиль Профиль
@@ -208,7 +217,12 @@ onBeforeUnmount(() => {
role="menuitem" role="menuitem"
@click="switchToRole(item.role, item.to)" @click="switchToRole(item.role, item.to)"
> >
<AppIcon :icon="item.role === 'admin' ? 'shield' : item.role === 'teacher' ? 'chart-bar' : 'home'" :size="16" /> <AppIcon
:icon="
item.role === 'admin' ? 'shield' : item.role === 'teacher' ? 'chart-bar' : 'home'
"
:size="16"
/>
{{ item.label }} {{ item.label }}
</button> </button>
</div> </div>
@@ -223,7 +237,8 @@ onBeforeUnmount(() => {
<ModalDialog v-model="isCoinDialogOpen" title="Монеты UniVerse" icon="coin" size="sm"> <ModalDialog v-model="isCoinDialogOpen" title="Монеты UniVerse" icon="coin" size="sm">
<p> <p>
В будущем монеты можно будет использовать во внутреннем магазине. Сейчас магазин еще не запущен, поэтому монеты просто копятся на вашем балансе. В будущем монеты можно будет использовать во внутреннем магазине. Сейчас магазин еще не
запущен, поэтому монеты просто копятся на вашем балансе.
</p> </p>
<template #footer> <template #footer>
<button class="btn-primary" type="button" @click="closeCoinDialog">ОК</button> <button class="btn-primary" type="button" @click="closeCoinDialog">ОК</button>
@@ -232,8 +247,8 @@ onBeforeUnmount(() => {
<ModalDialog v-model="isLevelDialogOpen" title="Уровень студента" icon="star" size="sm"> <ModalDialog v-model="isLevelDialogOpen" title="Уровень студента" icon="star" size="sm">
<p> <p>
Уровень показывает ваш прогресс в UniVerse. Он растет вместе с активностью: посещением открытых лекций, Уровень показывает ваш прогресс в UniVerse. Он растет вместе с активностью: посещением
полезными отзывами и достижениями. открытых лекций, полезными отзывами и достижениями.
</p> </p>
<template #footer> <template #footer>
<button class="btn-primary" type="button" @click="closeLevelDialog">Понятно</button> <button class="btn-primary" type="button" @click="closeLevelDialog">Понятно</button>
@@ -242,11 +257,14 @@ onBeforeUnmount(() => {
<ModalDialog v-model="isSlotsDialogOpen" title="Слоты записи" icon="calendar" size="sm"> <ModalDialog v-model="isSlotsDialogOpen" title="Слоты записи" icon="calendar" size="sm">
<p> <p>
Слоты показывают, сколько открытых лекций уже занято в вашем текущем лимите. Когда лимит заполнен, Слоты показывают, сколько открытых лекций уже занято в вашем текущем лимите. Когда лимит
освободите слот отменой записи или повышайте уровень, чтобы получить больше возможностей. заполнен, освободите слот отменой записи или повышайте уровень, чтобы получить больше
возможностей.
</p> </p>
<template #footer> <template #footer>
<button class="btn-secondary" type="button" @click="openMyLecturesFromSlotsDialog">Мои записи</button> <button class="btn-secondary" type="button" @click="openMyLecturesFromSlotsDialog">
Мои записи
</button>
<button class="btn-primary" type="button" @click="closeSlotsDialog">Понятно</button> <button class="btn-primary" type="button" @click="closeSlotsDialog">Понятно</button>
</template> </template>
</ModalDialog> </ModalDialog>
@@ -279,7 +297,9 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
cursor: pointer; cursor: pointer;
} }
.brand-icon { color: var(--color-text); } .brand-icon {
color: var(--color-text);
}
.brand-name { .brand-name {
font-size: 20px; font-size: 20px;
font-weight: 800; font-weight: 800;
@@ -288,7 +308,10 @@ onBeforeUnmount(() => {
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.topbar-center { flex: 1; max-width: 400px; } .topbar-center {
flex: 1;
max-width: 400px;
}
.topbar-right { .topbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -308,7 +331,10 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
white-space: nowrap; white-space: nowrap;
transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; transition:
background-color 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
} }
.level-icon { .level-icon {
color: var(--color-primary); color: var(--color-primary);
@@ -336,7 +362,10 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
white-space: nowrap; white-space: nowrap;
transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; transition:
background-color 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
} }
.level-chip:hover, .level-chip:hover,
.slot-chip:hover { .slot-chip:hover {
@@ -371,7 +400,9 @@ onBeforeUnmount(() => {
border-radius: 50%; border-radius: 50%;
transition: background 0.2s; transition: background 0.2s;
} }
.notif-btn:hover { background: var(--color-black-a05); } .notif-btn:hover {
background: var(--color-black-a05);
}
.notif-dot { .notif-dot {
position: absolute; position: absolute;
top: -2px; top: -2px;
@@ -404,7 +435,7 @@ onBeforeUnmount(() => {
transition: all 0.2s; transition: all 0.2s;
} }
.avatar:hover, .avatar:hover,
.avatar[aria-expanded="true"] { .avatar[aria-expanded='true'] {
border-color: var(--color-primary-a50); border-color: var(--color-primary-a50);
background: var(--color-white-a80); background: var(--color-white-a80);
box-shadow: 0 0 0 3px var(--color-primary-a12); box-shadow: 0 0 0 3px var(--color-primary-a12);
@@ -414,8 +445,14 @@ onBeforeUnmount(() => {
outline: 2px solid var(--color-primary-a45); outline: 2px solid var(--color-primary-a45);
outline-offset: 2px; outline-offset: 2px;
} }
.avatar-icon { color: var(--color-text-secondary); } .avatar-icon {
.avatar-name { font-size: 13px; font-weight: 600; color: var(--color-text); } color: var(--color-text-secondary);
}
.avatar-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.profile-dropdown { .profile-dropdown {
position: fixed; position: fixed;
top: calc(var(--topbar-height) + 10px); top: calc(var(--topbar-height) + 10px);
@@ -462,7 +499,9 @@ onBeforeUnmount(() => {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
text-align: left; text-align: left;
transition: background 0.2s, color 0.2s; transition:
background 0.2s,
color 0.2s;
} }
.profile-menu-item:hover { .profile-menu-item:hover {
background: var(--color-primary-a10); background: var(--color-primary-a10);
@@ -477,8 +516,14 @@ onBeforeUnmount(() => {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.topbar-center { display: none; } .topbar-center {
.brand-name { display: none; } display: none;
.avatar-name { display: none; } }
.brand-name {
display: none;
}
.avatar-name {
display: none;
}
} }
</style> </style>
@@ -46,17 +46,42 @@ defineProps<{
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
transition: transform 0.2s; transition: transform 0.2s;
} }
.badge-card:not(.locked):hover { transform: translateY(-2px); } .badge-card:not(.locked):hover {
transform: translateY(-2px);
}
.locked { .locked {
opacity: 0.5; opacity: 0.5;
filter: grayscale(0.5); filter: grayscale(0.5);
} }
.badge-icon { flex-shrink: 0; color: var(--color-text); } .badge-icon {
.badge-title { font-size: 15px; font-weight: 700; color: var(--color-text); margin-bottom: 3px; } flex-shrink: 0;
.badge-desc { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; } color: var(--color-text);
.badge-meta { font-size: 11px; color: var(--color-text-secondary); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } }
.meta-icon { color: var(--color-text-secondary); } .badge-title {
.locked-msg { color: var(--color-gray-400); } font-size: 15px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 3px;
}
.badge-desc {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.badge-meta {
font-size: 11px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.meta-icon {
color: var(--color-text-secondary);
}
.locked-msg {
color: var(--color-gray-400);
}
.coins-tag { .coins-tag {
margin-left: 6px; margin-left: 6px;
background: var(--color-star-a15); background: var(--color-star-a15);
@@ -68,5 +93,7 @@ defineProps<{
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.coin-icon { color: var(--color-warning-text); } .coin-icon {
color: var(--color-warning-text);
}
</style> </style>
-1
View File
@@ -65,4 +65,3 @@ const sizePx = computed(() => {
vertical-align: middle; vertical-align: middle;
} }
</style> </style>
+16 -4
View File
@@ -24,7 +24,10 @@ defineEmits<{ click: [] }>()
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s; transition:
background 0.2s,
border-color 0.2s,
box-shadow 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.coin-chip:hover { .coin-chip:hover {
@@ -35,7 +38,16 @@ defineEmits<{ click: [] }>()
outline: 2px solid var(--color-primary-a45); outline: 2px solid var(--color-primary-a45);
outline-offset: 2px; outline-offset: 2px;
} }
.coin-icon { color: var(--color-coin-chip-text); } .coin-icon {
.coin-amount { font-weight: 800; font-size: 14px; color: var(--color-coin-chip-text); } color: var(--color-coin-chip-text);
.coin-label { font-size: 12px; color: var(--color-coin-chip-label); } }
.coin-amount {
font-weight: 800;
font-size: 14px;
color: var(--color-coin-chip-text);
}
.coin-label {
font-size: 12px;
color: var(--color-coin-chip-label);
}
</style> </style>
+16 -14
View File
@@ -10,20 +10,14 @@ defineProps<{
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th <th v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
v-for="col in columns" {{ col.label }}
:key="col.key" </th>
:class="`align-${col.align ?? 'left'}`"
>{{ col.label }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(row, i) in rows" :key="i"> <tr v-for="(row, i) in rows" :key="i">
<td <td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
v-for="col in columns"
:key="col.key"
:class="`align-${col.align ?? 'left'}`"
>
<slot :name="col.key" :row="row" :value="row[col.key]"> <slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }} {{ row[col.key] }}
</slot> </slot>
@@ -57,11 +51,19 @@ defineProps<{
border-bottom: 1px solid var(--color-border-glass); border-bottom: 1px solid var(--color-border-glass);
color: var(--color-text); color: var(--color-text);
} }
.data-table tbody tr:last-child td { border-bottom: none; } .data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover td { .data-table tbody tr:hover td {
background: var(--color-primary-a05); background: var(--color-primary-a05);
} }
.align-left { text-align: left; } .align-left {
.align-center { text-align: center; } text-align: left;
.align-right { text-align: right; } }
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
</style> </style>
+15 -4
View File
@@ -12,7 +12,9 @@ defineProps<{
<div class="empty-state"> <div class="empty-state">
<AppIcon class="empty-icon" :icon="icon ?? 'inbox'" :size="52" /> <AppIcon class="empty-icon" :icon="icon ?? 'inbox'" :size="52" />
<div class="empty-title">{{ title ?? 'Ничего не найдено' }}</div> <div class="empty-title">{{ title ?? 'Ничего не найдено' }}</div>
<div class="empty-sub">{{ subtitle ?? 'Попробуйте изменить фильтры или вернитесь позже.' }}</div> <div class="empty-sub">
{{ subtitle ?? 'Попробуйте изменить фильтры или вернитесь позже.' }}
</div>
<slot /> <slot />
</div> </div>
</template> </template>
@@ -28,7 +30,16 @@ defineProps<{
text-align: center; text-align: center;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.empty-icon { color: var(--color-text-secondary); } .empty-icon {
.empty-title { font-size: 18px; font-weight: 700; color: var(--color-text); } color: var(--color-text-secondary);
.empty-sub { font-size: 14px; max-width: 320px; } }
.empty-title {
font-size: 18px;
font-weight: 700;
color: var(--color-text);
}
.empty-sub {
font-size: 14px;
max-width: 320px;
}
</style> </style>
@@ -28,8 +28,8 @@ function openMyLectures() {
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<p> <p>
Все доступные слоты записи уже заняты. Чтобы записаться на новую лекцию, отмените одну из текущих записей Все доступные слоты записи уже заняты. Чтобы записаться на новую лекцию, отмените одну из
или повысьте уровень. текущих записей или повысьте уровень.
</p> </p>
<template #footer> <template #footer>
<button class="btn-secondary" type="button" @click="close">Понятно</button> <button class="btn-secondary" type="button" @click="close">Понятно</button>
+9 -4
View File
@@ -1,12 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { toRefs } from 'vue' import { toRefs } from 'vue'
const props = withDefaults(defineProps<{ const props = withDefaults(
defineProps<{
padding?: string padding?: string
hoverable?: boolean hoverable?: boolean
}>(), { }>(),
{
padding: '18px', padding: '18px',
}) },
)
const { padding, hoverable } = toRefs(props) const { padding, hoverable } = toRefs(props)
</script> </script>
@@ -28,7 +31,9 @@ const { padding, hoverable } = toRefs(props)
padding: v-bind(padding); padding: v-bind(padding);
} }
.hoverable { .hoverable {
transition: transform 0.2s, box-shadow 0.2s; transition:
transform 0.2s,
box-shadow 0.2s;
cursor: pointer; cursor: pointer;
} }
.hoverable:hover { .hoverable:hover {
+2 -2
View File
@@ -15,8 +15,8 @@ const emit = defineEmits<{ register: [id: string] }>()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const enrollmentLimitModalOpen = ref(false) const enrollmentLimitModalOpen = ref(false)
const isRegistrationLimitReached = computed(() => const isRegistrationLimitReached = computed(
!props.registered && !userStore.hasEnrollmentSlotAvailable () => !props.registered && !userStore.hasEnrollmentSlotAvailable,
) )
function formatDate(d: string) { function formatDate(d: string) {
+24 -5
View File
@@ -9,15 +9,34 @@ defineProps<{ size?: 'sm' | 'md' | 'lg' }>()
</template> </template>
<style scoped> <style scoped>
.spinner-wrap { display: flex; align-items: center; justify-content: center; padding: 24px; } .spinner-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.spinner { .spinner {
border-radius: 50%; border-radius: 50%;
border: 3px solid var(--color-primary-a20); border: 3px solid var(--color-primary-a20);
border-top-color: var(--color-primary); border-top-color: var(--color-primary);
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
.sm { width: 20px; height: 20px; } .sm {
.md { width: 36px; height: 36px; } width: 20px;
.lg { width: 56px; height: 56px; border-width: 4px; } height: 20px;
@keyframes spin { to { transform: rotate(360deg); } } }
.md {
width: 36px;
height: 36px;
}
.lg {
width: 56px;
height: 56px;
border-width: 4px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>
+50 -14
View File
@@ -2,7 +2,8 @@
import { onBeforeUnmount, watch } from 'vue' import { onBeforeUnmount, watch } from 'vue'
import AppIcon from '@/components/ui/AppIcon.vue' import AppIcon from '@/components/ui/AppIcon.vue'
const props = withDefaults(defineProps<{ const props = withDefaults(
defineProps<{
title?: string title?: string
description?: string description?: string
icon?: string icon?: string
@@ -11,12 +12,14 @@ const props = withDefaults(defineProps<{
showClose?: boolean showClose?: boolean
closeOnOverlay?: boolean closeOnOverlay?: boolean
closeOnEscape?: boolean closeOnEscape?: boolean
}>(), { }>(),
{
size: 'md', size: 'md',
showClose: true, showClose: true,
closeOnOverlay: true, closeOnOverlay: true,
closeOnEscape: true, closeOnEscape: true,
}) },
)
const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>()
@@ -38,7 +41,7 @@ watch(
if (isOpen) document.addEventListener('keydown', handleKeydown) if (isOpen) document.addEventListener('keydown', handleKeydown)
else document.removeEventListener('keydown', handleKeydown) else document.removeEventListener('keydown', handleKeydown)
}, },
{ immediate: true } { immediate: true },
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -57,10 +60,17 @@ onBeforeUnmount(() => {
aria-modal="true" aria-modal="true"
:aria-label="title" :aria-label="title"
> >
<div class="modal-header" v-if="title || description || icon || $slots.header || showClose"> <div
class="modal-header"
v-if="title || description || icon || $slots.header || showClose"
>
<slot name="header"> <slot name="header">
<div class="modal-heading"> <div class="modal-heading">
<span v-if="icon" class="modal-icon" :class="{ 'modal-icon-warning': icon === 'alert-triangle' }"> <span
v-if="icon"
class="modal-icon"
:class="{ 'modal-icon-warning': icon === 'alert-triangle' }"
>
<AppIcon :icon="icon" :size="20" /> <AppIcon :icon="icon" :size="20" />
</span> </span>
<div class="modal-heading-text"> <div class="modal-heading-text">
@@ -69,7 +79,15 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</slot> </slot>
<button v-if="showClose" class="modal-close" type="button" aria-label="Закрыть" @click="close">×</button> <button
v-if="showClose"
class="modal-close"
type="button"
aria-label="Закрыть"
@click="close"
>
×
</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<slot /> <slot />
@@ -104,9 +122,15 @@ onBeforeUnmount(() => {
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
} }
.modal-box-sm { max-width: 420px; } .modal-box-sm {
.modal-box-md { max-width: 520px; } max-width: 420px;
.modal-box-lg { max-width: 680px; } }
.modal-box-md {
max-width: 520px;
}
.modal-box-lg {
max-width: 680px;
}
.modal-header { .modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -167,7 +191,10 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
color: var(--color-text-secondary); color: var(--color-text-secondary);
line-height: 1; line-height: 1;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease; transition:
background-color 0.18s ease,
color 0.18s ease,
border-color 0.18s ease;
} }
.modal-close:hover { .modal-close:hover {
background: var(--color-primary-a10); background: var(--color-primary-a10);
@@ -190,9 +217,18 @@ onBeforeUnmount(() => {
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
} }
.modal-enter-active, .modal-leave-active { transition: all 0.25s ease; } .modal-enter-active,
.modal-enter-from, .modal-leave-to { opacity: 0; } .modal-leave-active {
.modal-enter-from .modal-box, .modal-leave-to .modal-box { transform: scale(0.93); } transition: all 0.25s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-box,
.modal-leave-to .modal-box {
transform: scale(0.93);
}
@media (max-width: 520px) { @media (max-width: 520px) {
.modal-overlay { .modal-overlay {
+18 -5
View File
@@ -16,7 +16,7 @@ defineProps<{
class="progress-fill" class="progress-fill"
:style="{ :style="{
width: `${Math.max(0, Math.min(100, (value / (max && max > 0 ? max : 100)) * 100))}%`, width: `${Math.max(0, Math.min(100, (value / (max && max > 0 ? max : 100)) * 100))}%`,
background: color ?? 'var(--gradient-progress-success)' background: color ?? 'var(--gradient-progress-success)',
}" }"
/> />
</div> </div>
@@ -25,8 +25,16 @@ defineProps<{
</template> </template>
<style scoped> <style scoped>
.progress-wrap { display: flex; flex-direction: column; gap: 4px; } .progress-wrap {
.progress-label { font-size: 12px; color: var(--color-text-secondary); font-weight: 500; } display: flex;
flex-direction: column;
gap: 4px;
}
.progress-label {
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 500;
}
.progress-bar { .progress-bar {
height: 8px; height: 8px;
background: var(--color-black-a08); background: var(--color-black-a08);
@@ -42,10 +50,15 @@ defineProps<{
.progress-fill::after { .progress-fill::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; left: 0; right: 0; top: 0;
left: 0;
right: 0;
height: 50%; height: 50%;
background: var(--color-white-a40); background: var(--color-white-a40);
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
} }
.progress-text { font-size: 11px; color: var(--color-text-secondary); } .progress-text {
font-size: 11px;
color: var(--color-text-secondary);
}
</style> </style>
+6 -2
View File
@@ -43,11 +43,15 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
font-size: 14px; font-size: 14px;
color: var(--color-text); color: var(--color-text);
outline: none; outline: none;
transition: border-color 0.2s, box-shadow 0.2s; transition:
border-color 0.2s,
box-shadow 0.2s;
} }
.search-input:focus { .search-input:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-a15); box-shadow: 0 0 0 3px var(--color-primary-a15);
} }
.search-input::placeholder { color: var(--color-text-secondary); } .search-input::placeholder {
color: var(--color-text-secondary);
}
</style> </style>
+19 -6
View File
@@ -39,20 +39,33 @@ defineProps<{
.stats-widget::after { .stats-widget::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; left: 0; right: 0; top: 0;
left: 0;
right: 0;
height: 3px; height: 3px;
border-radius: var(--radius-md) var(--radius-md) 0 0; border-radius: var(--radius-md) var(--radius-md) 0 0;
} }
.color-green::after { background: var(--gradient-stats-green); } .color-green::after {
.color-aqua::after { background: var(--gradient-stats-aqua); } background: var(--gradient-stats-green);
.color-orange::after { background: var(--gradient-stats-orange); } }
.color-purple::after { background: var(--gradient-stats-purple); } .color-aqua::after {
background: var(--gradient-stats-aqua);
}
.color-orange::after {
background: var(--gradient-stats-orange);
}
.color-purple::after {
background: var(--gradient-stats-purple);
}
.widget-icon { .widget-icon {
flex-shrink: 0; flex-shrink: 0;
color: var(--color-text); color: var(--color-text);
} }
.widget-body { flex: 1; min-width: 0; } .widget-body {
flex: 1;
min-width: 0;
}
.widget-value { .widget-value {
font-size: 24px; font-size: 24px;
font-weight: 800; font-weight: 800;
+21 -5
View File
@@ -16,7 +16,7 @@ const statusMap: Record<string, { label: string; cls: string }> = {
upcoming: { label: 'Будущая', cls: 'info' }, upcoming: { label: 'Будущая', cls: 'info' },
ongoing: { label: 'Идет', cls: 'success' }, ongoing: { label: 'Идет', cls: 'success' },
completed: { label: 'Завершена', cls: 'info' }, completed: { label: 'Завершена', cls: 'info' },
registered:{ label: 'Записан', cls: 'success' }, registered: { label: 'Записан', cls: 'success' },
attended: { label: 'Посещено', cls: 'info' }, attended: { label: 'Посещено', cls: 'info' },
needsReview: { label: 'Нужен отзыв', cls: 'warning' }, needsReview: { label: 'Нужен отзыв', cls: 'warning' },
} }
@@ -36,8 +36,24 @@ const statusMap: Record<string, { label: string; cls: string }> = {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
.success { background: var(--color-success-bg-a90); color: var(--color-success-text); border: 1px solid var(--color-primary-light); } .success {
.warning { background: var(--color-warning-bg-a90); color: var(--color-warning-text); border: 1px solid var(--color-warning-border); } background: var(--color-success-bg-a90);
.danger { background: var(--color-danger-bg-a90); color: var(--color-danger-text); border: 1px solid var(--color-danger-light); } color: var(--color-success-text);
.info { background: var(--color-info-bg-a90); color: var(--color-info-text); border: 1px solid var(--color-info-border); } border: 1px solid var(--color-primary-light);
}
.warning {
background: var(--color-warning-bg-a90);
color: var(--color-warning-text);
border: 1px solid var(--color-warning-border);
}
.danger {
background: var(--color-danger-bg-a90);
color: var(--color-danger-text);
border: 1px solid var(--color-danger-light);
}
.info {
background: var(--color-info-bg-a90);
color: var(--color-info-text);
border: 1px solid var(--color-info-border);
}
</style> </style>
@@ -11,8 +11,13 @@ const emit = defineEmits<{ close: [] }>()
const visible = ref(false) const visible = ref(false)
onMounted(() => { onMounted(() => {
requestAnimationFrame(() => { visible.value = true }) requestAnimationFrame(() => {
setTimeout(() => { visible.value = false; setTimeout(() => emit('close'), 350) }, props.duration ?? 3000) visible.value = true
})
setTimeout(() => {
visible.value = false
setTimeout(() => emit('close'), 350)
}, props.duration ?? 3000)
}) })
const iconNameMap = { success: 'circle-check', error: 'circle-x', info: 'info-circle' } as const const iconNameMap = { success: 'circle-check', error: 'circle-x', info: 'info-circle' } as const
@@ -41,12 +46,42 @@ const iconNameMap = { success: 'circle-check', error: 'circle-x', info: 'info-ci
pointer-events: all; pointer-events: all;
max-width: 340px; max-width: 340px;
} }
.success { background: var(--color-success-bg-a95); border: 1px solid var(--color-primary-light); color: var(--color-success-text); } .success {
.error { background: var(--color-danger-bg-a95); border: 1px solid var(--color-danger-light); color: var(--color-danger-text); } background: var(--color-success-bg-a95);
.info { background: var(--color-info-bg-a95); border: 1px solid var(--color-info-border); color: var(--color-info-text); } border: 1px solid var(--color-primary-light);
.toast-close { margin-left: auto; background: none; border: none; font-size: 18px; cursor: pointer; opacity: 0.6; } color: var(--color-success-text);
.toast-close:hover { opacity: 1; } }
.toast-enter-active, .toast-leave-active { transition: all 0.35s ease; } .error {
.toast-enter-from { opacity: 0; transform: translateY(20px); } background: var(--color-danger-bg-a95);
.toast-leave-to { opacity: 0; transform: translateY(20px); } border: 1px solid var(--color-danger-light);
color: var(--color-danger-text);
}
.info {
background: var(--color-info-bg-a95);
border: 1px solid var(--color-info-border);
color: var(--color-info-text);
}
.toast-close {
margin-left: auto;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
opacity: 0.6;
}
.toast-close:hover {
opacity: 1;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.35s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateY(20px);
}
.toast-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style> </style>
+1 -1
View File
@@ -100,7 +100,7 @@ export const emojiToIcon: Record<string, IconName> = {
'👋': 'hand-stop', '👋': 'hand-stop',
'🏠': 'home', '🏠': 'home',
'📭': 'inbox', '📭': 'inbox',
'️': 'info-circle', : 'info-circle',
'🔒': 'lock', '🔒': 'lock',
'🚪': 'logout', '🚪': 'logout',
'📍': 'map-pin', '📍': 'map-pin',
+93 -16
View File
@@ -4,7 +4,12 @@ import { useAuthStore } from '@/stores/auth'
const router = createRouter({ 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', path: '/auth/callback',
name: 'auth-callback', name: 'auth-callback',
@@ -13,25 +18,93 @@ const router = createRouter({
}, },
// Student // Student
{ path: '/', name: 'dashboard', component: () => import('@/views/student/DashboardView.vue'), meta: { role: 'student' } }, {
{ path: '/catalog', name: 'catalog', component: () => import('@/views/student/CatalogView.vue'), meta: { role: 'student' } }, path: '/',
{ path: '/lecture/:id', name: 'lecture-detail', component: () => import('@/views/student/LectureDetailView.vue'), meta: { role: 'student' } }, name: 'dashboard',
{ path: '/my-lectures', name: 'my-lectures', component: () => import('@/views/student/MyLecturesView.vue'), meta: { role: 'student' } }, component: () => import('@/views/student/DashboardView.vue'),
{ path: '/review/:id', name: 'review-form', component: () => import('@/views/student/ReviewFormView.vue'), meta: { role: 'student' } }, meta: { role: 'student' },
{ path: '/profile', name: 'profile', component: () => import('@/views/student/ProfileView.vue') }, },
{ path: '/notifications', name: 'notifications', component: () => import('@/views/student/NotificationsView.vue') }, {
path: '/catalog',
name: 'catalog',
component: () => import('@/views/student/CatalogView.vue'),
meta: { role: 'student' },
},
{
path: '/lecture/:id',
name: 'lecture-detail',
component: () => import('@/views/student/LectureDetailView.vue'),
meta: { role: 'student' },
},
{
path: '/my-lectures',
name: 'my-lectures',
component: () => import('@/views/student/MyLecturesView.vue'),
meta: { role: 'student' },
},
{
path: '/review/:id',
name: 'review-form',
component: () => import('@/views/student/ReviewFormView.vue'),
meta: { role: 'student' },
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/student/ProfileView.vue'),
},
{
path: '/notifications',
name: 'notifications',
component: () => import('@/views/student/NotificationsView.vue'),
},
// Teacher // Teacher
{ path: '/teacher', name: 'teacher-dashboard', component: () => import('@/views/teacher/TeacherDashboardView.vue'), meta: { role: 'teacher' } }, {
{ path: '/teacher/lectures', name: 'teacher-lectures', component: () => import('@/views/teacher/TeacherLecturesView.vue'), meta: { role: 'teacher' } }, path: '/teacher',
{ path: '/teacher/analytics', name: 'teacher-analytics', component: () => import('@/views/teacher/TeacherAnalyticsView.vue'), meta: { role: 'teacher' } }, name: 'teacher-dashboard',
component: () => import('@/views/teacher/TeacherDashboardView.vue'),
meta: { role: 'teacher' },
},
{
path: '/teacher/lectures',
name: 'teacher-lectures',
component: () => import('@/views/teacher/TeacherLecturesView.vue'),
meta: { role: 'teacher' },
},
{
path: '/teacher/analytics',
name: 'teacher-analytics',
component: () => import('@/views/teacher/TeacherAnalyticsView.vue'),
meta: { role: 'teacher' },
},
// Admin // Admin
{ path: '/admin', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue'), meta: { role: 'admin' } }, {
{ path: '/admin/users', name: 'admin-users', component: () => import('@/views/admin/AdminUsersView.vue'), meta: { role: 'admin' } }, path: '/admin',
{ path: '/admin/lectures', name: 'admin-lectures', component: () => import('@/views/admin/AdminLecturesView.vue'), meta: { role: 'admin' } }, name: 'admin-dashboard',
component: () => import('@/views/admin/AdminDashboardView.vue'),
meta: { role: 'admin' },
},
{
path: '/admin/users',
name: 'admin-users',
component: () => import('@/views/admin/AdminUsersView.vue'),
meta: { role: 'admin' },
},
{
path: '/admin/lectures',
name: 'admin-lectures',
component: () => import('@/views/admin/AdminLecturesView.vue'),
meta: { role: 'admin' },
},
{ path: '/admin/llm-queue', redirect: '/admin/reviews' }, { path: '/admin/llm-queue', redirect: '/admin/reviews' },
{ path: '/admin/reviews', name: 'admin-reviews', component: () => import('@/views/admin/AdminReviewsView.vue'), meta: { role: 'admin' } }, {
path: '/admin/reviews',
name: 'admin-reviews',
component: () => import('@/views/admin/AdminReviewsView.vue'),
meta: { role: 'admin' },
},
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
], ],
@@ -50,7 +123,11 @@ router.beforeEach(async (to) => {
if (!to.meta.public && !auth.isAuthenticated) { if (!to.meta.public && !auth.isAuthenticated) {
return '/login' return '/login'
} }
if (to.meta.role && auth.user && !auth.user.roles.includes(to.meta.role as 'student' | 'teacher' | 'admin')) { if (
to.meta.role &&
auth.user &&
!auth.user.roles.includes(to.meta.role as 'student' | 'teacher' | 'admin')
) {
return resolveDefaultRoute() return resolveDefaultRoute()
} }
}) })
+2 -1
View File
@@ -114,7 +114,8 @@ export const useAuthStore = defineStore('auth', () => {
return true return true
} catch (err) { } catch (err) {
clearSession() clearSession()
error.value = err instanceof Error ? err.message : 'Не удалось получить пользователя после входа.' error.value =
err instanceof Error ? err.message : 'Не удалось получить пользователя после входа.'
throw err throw err
} finally { } finally {
loading.value = false loading.value = false
+25 -14
View File
@@ -15,7 +15,7 @@ export const useLecturesStore = defineStore('lectures', () => {
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) || l.registered), lectures.value.filter((l) => registered.value.includes(l.id) || l.registered),
) )
async function fetchLectures(query: LectureQuery = {}) { async function fetchLectures(query: LectureQuery = {}) {
@@ -24,7 +24,7 @@ export const useLecturesStore = defineStore('lectures', () => {
try { try {
const payload = await lecturesApi.list({ PageSize: 100, ...query }) const payload = await lecturesApi.list({ PageSize: 100, ...query })
lectures.value = payload.map(mapApiLecture) lectures.value = payload.map(mapApiLecture)
registered.value = lectures.value.filter(l => l.registered).map(l => l.id) registered.value = lectures.value.filter((l) => l.registered).map((l) => l.id)
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекции.' error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекции.'
} finally { } finally {
@@ -36,14 +36,15 @@ export const useLecturesStore = defineStore('lectures', () => {
error.value = null error.value = null
try { try {
const lecture = mapApiLecture(await lecturesApi.get(id)) const lecture = mapApiLecture(await lecturesApi.get(id))
const index = lectures.value.findIndex(item => item.id === lecture.id) const index = lectures.value.findIndex((item) => item.id === lecture.id)
if (index >= 0) lectures.value[index] = lecture if (index >= 0) lectures.value[index] = lecture
else lectures.value.push(lecture) else lectures.value.push(lecture)
if (lecture.registered && !registered.value.includes(lecture.id)) registered.value.push(lecture.id) if (lecture.registered && !registered.value.includes(lecture.id))
registered.value.push(lecture.id)
return lecture return lecture
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекцию.' error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекцию.'
return lectures.value.find(item => item.id === id) return lectures.value.find((item) => item.id === id)
} }
} }
@@ -51,11 +52,12 @@ export const useLecturesStore = defineStore('lectures', () => {
try { try {
const enrollments = await usersApi.myEnrollments() const enrollments = await usersApi.myEnrollments()
const mapped = enrollments.map(mapApiLecture) const mapped = enrollments.map(mapApiLecture)
registered.value = mapped.map(lecture => lecture.id) registered.value = mapped.map((lecture) => lecture.id)
if (mapped.length) { if (mapped.length) {
mapped.forEach(lecture => { mapped.forEach((lecture) => {
const index = lectures.value.findIndex(item => item.id === lecture.id) const index = lectures.value.findIndex((item) => item.id === lecture.id)
if (index >= 0) lectures.value[index] = { ...lectures.value[index], ...lecture, registered: true } if (index >= 0)
lectures.value[index] = { ...lectures.value[index], ...lecture, registered: true }
else lectures.value.push({ ...lecture, registered: true }) else lectures.value.push({ ...lecture, registered: true })
}) })
} }
@@ -65,8 +67,14 @@ export const useLecturesStore = defineStore('lectures', () => {
} }
async function register(lectureId: string) { async function register(lectureId: string) {
const lecture = lectures.value.find(item => item.id === lectureId) const lecture = lectures.value.find((item) => item.id === lectureId)
if (!lecture || lecture.freeSeats === 0 || lecture.registrationClosed || registered.value.includes(lectureId)) return if (
!lecture ||
lecture.freeSeats === 0 ||
lecture.registrationClosed ||
registered.value.includes(lectureId)
)
return
const userStore = useUserStore() const userStore = useUserStore()
if (!userStore.hasEnrollmentSlotAvailable) { if (!userStore.hasEnrollmentSlotAvailable) {
throw new Error('Лимит записей достигнут. Отмените одну из записей или повысьте уровень.') throw new Error('Лимит записей достигнут. Отмените одну из записей или повысьте уровень.')
@@ -84,8 +92,8 @@ export const useLecturesStore = defineStore('lectures', () => {
async function unregister(lectureId: string) { async function unregister(lectureId: string) {
await lecturesApi.unenroll(lectureId) await lecturesApi.unenroll(lectureId)
const userStore = useUserStore() const userStore = useUserStore()
registered.value = registered.value.filter(id => id !== lectureId) registered.value = registered.value.filter((id) => id !== lectureId)
const lecture = lectures.value.find(item => item.id === lectureId) const lecture = lectures.value.find((item) => item.id === lectureId)
if (lecture) { if (lecture) {
lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats) lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats)
lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0) lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0)
@@ -96,7 +104,10 @@ export const useLecturesStore = defineStore('lectures', () => {
} }
function isRegistered(lectureId: string) { function isRegistered(lectureId: string) {
return registered.value.includes(lectureId) || Boolean(lectures.value.find(item => item.id === lectureId)?.registered) return (
registered.value.includes(lectureId) ||
Boolean(lectures.value.find((item) => item.id === lectureId)?.registered)
)
} }
return { return {
+14 -10
View File
@@ -14,8 +14,8 @@ export const useUserStore = defineStore('user', () => {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const activeEnrollments = computed(() => useAuthStore().user?.activeEnrollments ?? 0) const activeEnrollments = computed(() => useAuthStore().user?.activeEnrollments ?? 0)
const enrollmentSlotLimit = computed(() => useAuthStore().user?.enrollmentSlotLimit ?? 0) const enrollmentSlotLimit = computed(() => useAuthStore().user?.enrollmentSlotLimit ?? 0)
const hasEnrollmentSlotAvailable = computed(() => const hasEnrollmentSlotAvailable = computed(
enrollmentSlotLimit.value === 0 || activeEnrollments.value < enrollmentSlotLimit.value () => enrollmentSlotLimit.value === 0 || activeEnrollments.value < enrollmentSlotLimit.value,
) )
function applyStats(stats: UserStatsDto) { function applyStats(stats: UserStatsDto) {
@@ -31,7 +31,9 @@ export const useUserStore = defineStore('user', () => {
nextLevelXp: stats.nextLevelXp, nextLevelXp: stats.nextLevelXp,
lecturesAttended: stats.attendedLectures, lecturesAttended: stats.attendedLectures,
hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10, hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10,
achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)), achievements: Array.from({ length: stats.achievementsCount }, (_, index) =>
String(index + 1),
),
activeEnrollments: stats.activeEnrollments, activeEnrollments: stats.activeEnrollments,
enrollmentSlotLimit: stats.enrollmentSlotLimit, enrollmentSlotLimit: stats.enrollmentSlotLimit,
enrollmentSlotRules: stats.enrollmentSlotRules, enrollmentSlotRules: stats.enrollmentSlotRules,
@@ -78,18 +80,20 @@ export const useUserStore = defineStore('user', () => {
]) ])
applyStats(stats) applyStats(stats)
const unlocked = new Map(achievementPayload.map(item => { const unlocked = new Map(
achievementPayload.map((item) => {
const achievement = mapApiAchievement(item) const achievement = mapApiAchievement(item)
return [achievement.id, achievement] return [achievement.id, achievement]
})) }),
const catalogIds = new Set(achievementCatalog.map(item => String(item.id))) )
const lockedAndUnlocked = achievementCatalog.map(item => { const catalogIds = new Set(achievementCatalog.map((item) => String(item.id)))
const lockedAndUnlocked = achievementCatalog.map((item) => {
const achievement = mapApiAchievement(item) const achievement = mapApiAchievement(item)
return unlocked.get(achievement.id) ?? achievement return unlocked.get(achievement.id) ?? achievement
}) })
const unlockedOutsideCatalog = achievementPayload const unlockedOutsideCatalog = achievementPayload
.map(mapApiAchievement) .map(mapApiAchievement)
.filter(item => !catalogIds.has(item.id)) .filter((item) => !catalogIds.has(item.id))
achievements.value = [...lockedAndUnlocked, ...unlockedOutsideCatalog].sort( achievements.value = [...lockedAndUnlocked, ...unlockedOutsideCatalog].sort(
(a, b) => Number(a.id) - Number(b.id), (a, b) => Number(a.id) - Number(b.id),
@@ -110,10 +114,10 @@ export const useUserStore = defineStore('user', () => {
async function markAllRead() { async function markAllRead() {
await notificationsApi.markAllRead() await notificationsApi.markAllRead()
notifications.value.forEach(n => (n.read = true)) notifications.value.forEach((n) => (n.read = true))
} }
const unreadCount = () => notifications.value.filter(n => !n.read).length const unreadCount = () => notifications.value.filter((n) => !n.read).length
return { return {
achievements, achievements,
@@ -503,8 +503,8 @@ onMounted(() => {
{{ syncError || visibleSyncResult.error }} {{ syncError || visibleSyncResult.error }}
</template> </template>
<template v-else> <template v-else>
Создано: {{ visibleSyncResult.created }} / обновлено: Создано: {{ visibleSyncResult.created }} / обновлено: {{ visibleSyncResult.updated }} /
{{ visibleSyncResult.updated }} / пропущено: {{ visibleSyncResult.skipped }} пропущено: {{ visibleSyncResult.skipped }}
</template> </template>
</div> </div>
<div v-else-if="syncError" class="sync-result failed"> <div v-else-if="syncError" class="sync-result failed">
@@ -694,7 +694,9 @@ onMounted(() => {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 12px; padding: 12px;
background: var(--color-white-a82); background: var(--color-white-a82);
box-shadow: 0 8px 22px var(--color-black-a04), inset 0 1px 0 var(--color-white-a90); box-shadow:
0 8px 22px var(--color-black-a04),
inset 0 1px 0 var(--color-white-a90);
} }
.sync-advanced-fields .glass-input { .sync-advanced-fields .glass-input {
background: var(--color-white-a96); background: var(--color-white-a96);
@@ -359,7 +359,9 @@ onMounted(() => {
></textarea> ></textarea>
<div class="prompt-footer"> <div class="prompt-footer">
<div class="prompt-messages"> <div class="prompt-messages">
<span class="prompt-hint">Обязательные плейсхолдеры: {lectureContext}, {reviewText}</span> <span class="prompt-hint"
>Обязательные плейсхолдеры: {lectureContext}, {reviewText}</span
>
<span v-if="promptError" class="prompt-error">{{ promptError }}</span> <span v-if="promptError" class="prompt-error">{{ promptError }}</span>
<span v-else-if="promptSuccess" class="prompt-success">{{ promptSuccess }}</span> <span v-else-if="promptSuccess" class="prompt-success">{{ promptSuccess }}</span>
</div> </div>
+70 -17
View File
@@ -33,11 +33,11 @@ const roleBadgeClasses: Record<ApiUserRole, string> = {
} }
const rows = computed(() => const rows = computed(() =>
users.value.map(user => ({ users.value.map((user) => ({
id: user.id, id: user.id,
name: user.displayName || user.email, name: user.displayName || user.email,
email: user.email, email: user.email,
role: user.roles.map(role => roleLabels[role]).join(', '), role: user.roles.map((role) => roleLabels[role]).join(', '),
apiRoles: user.roles, apiRoles: user.roles,
institute: 'ЮФУ', institute: 'ЮФУ',
activity: user.isActive ? 'Активен' : 'Заблокирован', activity: user.isActive ? 'Активен' : 'Заблокирован',
@@ -52,7 +52,10 @@ async function fetchUsers() {
try { try {
users.value = await usersApi.list({ users.value = await usersApi.list({
Search: search.value || undefined, Search: search.value || undefined,
Role: roleFilter.value === 'Все роли' ? undefined : roleApi[roleFilter.value as keyof typeof roleApi], Role:
roleFilter.value === 'Все роли'
? undefined
: roleApi[roleFilter.value as keyof typeof roleApi],
PageSize: 100, PageSize: 100,
}) })
} catch (err) { } catch (err) {
@@ -81,14 +84,16 @@ function hasRole(row: Record<string, unknown>, role: ApiUserRole) {
} }
function getRoleChipClass(row: Record<string, unknown>, role: ApiUserRole) { function getRoleChipClass(row: Record<string, unknown>, role: ApiUserRole) {
return hasRole(row, role) ? ['badge', roleBadgeClasses[role]] : ['btn-ghost', 'role-chip-inactive'] return hasRole(row, role)
? ['badge', roleBadgeClasses[role]]
: ['btn-ghost', 'role-chip-inactive']
} }
async function toggleRole(row: Record<string, unknown>, role: ApiUserRole) { async function toggleRole(row: Record<string, unknown>, role: ApiUserRole) {
const id = Number(row.id) const id = Number(row.id)
const currentRoles = getRowRoles(row) const currentRoles = getRowRoles(row)
const nextRoles = currentRoles.includes(role) const nextRoles = currentRoles.includes(role)
? currentRoles.filter(currentRole => currentRole !== role) ? currentRoles.filter((currentRole) => currentRole !== role)
: [...currentRoles, role] : [...currentRoles, role]
if (!nextRoles.length) return if (!nextRoles.length) return
@@ -125,7 +130,11 @@ onMounted(fetchUsers)
</div> </div>
<EmptyState v-if="error" title="Не удалось загрузить пользователей" :subtitle="error" /> <EmptyState v-if="error" title="Не удалось загрузить пользователей" :subtitle="error" />
<EmptyState v-else-if="!rows.length && !loading" title="Пользователей не найдено" subtitle="Попробуйте изменить фильтры." /> <EmptyState
v-else-if="!rows.length && !loading"
title="Пользователей не найдено"
subtitle="Попробуйте изменить фильтры."
/>
<DataTable :columns="columns" :rows="rows"> <DataTable :columns="columns" :rows="rows">
<template #role="{ row }"> <template #role="{ row }">
<div class="role-chips"> <div class="role-chips">
@@ -142,11 +151,15 @@ onMounted(fetchUsers)
</div> </div>
</template> </template>
<template #activity="{ value }"> <template #activity="{ value }">
<span class="badge" :class="value === 'Активен' ? 'badge-green' : 'badge-orange'">{{ value }}</span> <span class="badge" :class="value === 'Активен' ? 'badge-green' : 'badge-orange'">{{
value
}}</span>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }">
<div class="actions"> <div class="actions">
<button class="btn-ghost" @click="toggleActive(row)">{{ row.isActive ? 'Заблокировать' : 'Активировать' }}</button> <button class="btn-ghost" @click="toggleActive(row)">
{{ row.isActive ? 'Заблокировать' : 'Активировать' }}
</button>
</div> </div>
</template> </template>
</DataTable> </DataTable>
@@ -155,13 +168,53 @@ onMounted(fetchUsers)
</template> </template>
<style scoped> <style scoped>
.admin-users { display: flex; flex-direction: column; gap: 16px; } .admin-users {
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } display: flex;
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 12px; } flex-direction: column;
.actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; } gap: 16px;
.role-chips { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; } }
.role-chip { cursor: pointer; border: 0; transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; } .header {
.role-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--color-primary-a15); } display: flex;
.role-chip:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } align-items: center;
.role-chip-inactive { opacity: 0.75; } justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
flex-wrap: wrap;
}
.role-chips {
display: flex;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.role-chip {
cursor: pointer;
border: 0;
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
opacity 0.15s ease;
}
.role-chip:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--color-primary-a15);
}
.role-chip:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.role-chip-inactive {
opacity: 0.75;
}
</style> </style>
+19 -7
View File
@@ -11,7 +11,8 @@ const message = ref('Завершаем вход через Microsoft...')
onMounted(async () => { onMounted(async () => {
const code = typeof route.query.code === 'string' ? route.query.code : '' const code = typeof route.query.code === 'string' ? route.query.code : ''
const state = typeof route.query.state === 'string' ? route.query.state : null const state = typeof route.query.state === 'string' ? route.query.state : null
const error = typeof route.query.error_description === 'string' ? route.query.error_description : '' const error =
typeof route.query.error_description === 'string' ? route.query.error_description : ''
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')) const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''))
const accessToken = hashParams.get('access_token') const accessToken = hashParams.get('access_token')
@@ -28,7 +29,10 @@ onMounted(async () => {
else await router.replace('/') else await router.replace('/')
} catch (err) { } catch (err) {
message.value = err instanceof Error ? err.message : 'Не удалось завершить авторизацию.' message.value = err instanceof Error ? err.message : 'Не удалось завершить авторизацию.'
window.setTimeout(() => router.replace({ path: '/login', query: { error: message.value } }), 1600) window.setTimeout(
() => router.replace({ path: '/login', query: { error: message.value } }),
1600,
)
} }
}) })
</script> </script>
@@ -54,14 +58,22 @@ onMounted(async () => {
} }
.callback-card { .callback-card {
width: min(420px, 100%); width: min(420px, 100%);
background: rgba(255,255,255,0.86); background: rgba(255, 255, 255, 0.86);
border: 1px solid var(--color-border-glass); border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 32px; padding: 32px;
text-align: center; text-align: center;
box-shadow: 0 24px 70px rgba(0,0,0,0.12); 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;
} }
.spinner { margin: 0 auto 16px; }
h1 { font-size: 24px; margin: 0 0 8px; }
p { color: var(--color-text-secondary); margin: 0; }
</style> </style>
+15 -17
View File
@@ -17,22 +17,19 @@ const featureCards = [
icon: 'search' as const, icon: 'search' as const,
tone: 'blue', tone: 'blue',
title: 'Поиск лекций', title: 'Поиск лекций',
description: description: 'Найдите самые интересные курсы и открытые лекции от ведущих преподавателей ЮФУ.',
'Найдите самые интересные курсы и открытые лекции от ведущих преподавателей ЮФУ.',
}, },
{ {
icon: 'coin' as const, icon: 'coin' as const,
tone: 'green', tone: 'green',
title: 'Зарабатывайте монеты', title: 'Зарабатывайте монеты',
description: description: 'Оставляйте конструктивные отзывы после занятий и получайте вознаграждение.',
'Оставляйте конструктивные отзывы после занятий и получайте вознаграждение.',
}, },
{ {
icon: 'trophy' as const, icon: 'trophy' as const,
tone: 'amber', tone: 'amber',
title: 'Достижения и награды', title: 'Достижения и награды',
description: description: 'Отслеживайте свой рост и открывайте уникальные достижения за активность.',
'Отслеживайте свой рост и открывайте уникальные достижения за активность.',
}, },
] ]
@@ -85,11 +82,13 @@ async function loginViaYufu() {
</section> </section>
<section class="login-brand" aria-label="О платформе UniVerse"> <section class="login-brand" aria-label="О платформе UniVerse">
<ul class="feature-list"> <ul class="feature-list">
<li v-for="(card, index) in featureCards" :key="card.title" class="feature-card" <li
:style="{ '--stagger': `${index * 80}ms` }"> v-for="(card, index) in featureCards"
:key="card.title"
class="feature-card"
:style="{ '--stagger': `${index * 80}ms` }"
>
<div class="feature-icon" :class="`feature-icon--${card.tone}`" aria-hidden="true"> <div class="feature-icon" :class="`feature-icon--${card.tone}`" aria-hidden="true">
<AppIcon :icon="card.icon" :size="22" /> <AppIcon :icon="card.icon" :size="22" />
</div> </div>
@@ -100,13 +99,10 @@ async function loginViaYufu() {
</li> </li>
</ul> </ul>
</section> </section>
</main> </main>
<footer class="login-footer"> <footer class="login-footer">
<div class="footer-left"> <div class="footer-left"></div>
</div>
<nav class="footer-center" aria-label="Правовая информация"> <nav class="footer-center" aria-label="Правовая информация">
<a href="#">Политика конфиденциальности</a> <a href="#">Политика конфиденциальности</a>
<a href="#">Техническая поддержка</a> <a href="#">Техническая поддержка</a>
@@ -142,11 +138,13 @@ async function loginViaYufu() {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
linear-gradient(115deg, linear-gradient(
115deg,
rgba(224, 242, 254, 0.92) 0%, rgba(224, 242, 254, 0.92) 0%,
rgba(220, 252, 231, 0.78) 38%, rgba(220, 252, 231, 0.78) 38%,
rgba(255, 255, 255, 0.55) 62%, rgba(255, 255, 255, 0.55) 62%,
rgba(224, 242, 254, 0.88) 100%), rgba(224, 242, 254, 0.88) 100%
),
linear-gradient(to top, rgba(255, 255, 255, 0.35) 0%, transparent 42%); linear-gradient(to top, rgba(255, 255, 255, 0.35) 0%, transparent 42%);
pointer-events: none; pointer-events: none;
} }
@@ -178,7 +176,7 @@ async function loginViaYufu() {
z-index: 1; z-index: 1;
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: minmax(340px, 440px) minmax(340px, 1fr) ; grid-template-columns: minmax(340px, 440px) minmax(340px, 1fr);
gap: clamp(32px, 2vw, 72px); gap: clamp(32px, 2vw, 72px);
align-content: center; align-content: center;
align-items: stretch; align-items: stretch;
+173 -47
View File
@@ -21,7 +21,9 @@ const format = ref<'all' | 'online' | 'offline'>('all')
const onlyFree = ref(false) const onlyFree = ref(false)
const filtersOpen = ref(false) const filtersOpen = ref(false)
const enrollmentLimitModalOpen = ref(false) const enrollmentLimitModalOpen = 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(() => { onMounted(() => {
if (!lecturesStore.all.length) void lecturesStore.fetchLectures() if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
@@ -48,28 +50,44 @@ const directions = [
'Экономика и маркетинг', 'Экономика и маркетинг',
] ]
const teachers = computed(() => ['Все преподаватели', ...new Set(lecturesStore.all.map(l => l.teacher))]) const teachers = computed(() => [
const buildings = computed(() => ['Все корпуса', ...new Set(lecturesStore.all.map(l => l.building))]) 'Все преподаватели',
...new Set(lecturesStore.all.map((l) => l.teacher)),
])
const buildings = computed(() => [
'Все корпуса',
...new Set(lecturesStore.all.map((l) => l.building)),
])
function toggleTag(value: string) { function toggleTag(value: string) {
const target = tagFilters.value.find(t => t.value === value) const target = tagFilters.value.find((t) => t.value === value)
if (target) target.active = !target.active if (target) target.active = !target.active
} }
const activeTags = computed(() => tagFilters.value.filter(t => t.active).map(t => t.value)) const activeTags = computed(() => tagFilters.value.filter((t) => t.active).map((t) => t.value))
const filtered = computed(() => const filtered = computed(() =>
lecturesStore.all.filter(l => { lecturesStore.all.filter((l) => {
const matchesSearch = l.title.toLowerCase().includes(search.value.toLowerCase()) const matchesSearch = l.title.toLowerCase().includes(search.value.toLowerCase())
const directionKey = direction.value.split(' ')[0] || '' const directionKey = direction.value.split(' ')[0] || ''
const matchesDirection = direction.value === 'Все направления' || l.institute.includes(directionKey) const matchesDirection =
direction.value === 'Все направления' || l.institute.includes(directionKey)
const matchesTeacher = teacher.value === 'Все преподаватели' || l.teacher === teacher.value const matchesTeacher = teacher.value === 'Все преподаватели' || l.teacher === teacher.value
const matchesBuilding = building.value === 'Все корпуса' || l.building === building.value const matchesBuilding = building.value === 'Все корпуса' || l.building === building.value
const matchesFormat = format.value === 'all' || l.format === format.value const matchesFormat = format.value === 'all' || l.format === format.value
const matchesTags = activeTags.value.length === 0 || activeTags.value.some(tag => l.tags.includes(tag)) const matchesTags =
activeTags.value.length === 0 || activeTags.value.some((tag) => l.tags.includes(tag))
const matchesFree = !onlyFree.value || l.freeSeats > 0 const matchesFree = !onlyFree.value || l.freeSeats > 0
return matchesSearch && matchesDirection && matchesTeacher && matchesBuilding && matchesFormat && matchesTags && matchesFree return (
}) matchesSearch &&
matchesDirection &&
matchesTeacher &&
matchesBuilding &&
matchesFormat &&
matchesTags &&
matchesFree
)
}),
) )
const appliedFilters = computed(() => { const appliedFilters = computed(() => {
@@ -95,7 +113,7 @@ const tableColumns = [
const calendarGroups = computed(() => { const calendarGroups = computed(() => {
const groups: Record<string, typeof filtered.value> = {} const groups: Record<string, typeof filtered.value> = {}
filtered.value.forEach(l => { filtered.value.forEach((l) => {
const date = new Date(l.date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) const date = new Date(l.date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
groups[date] = groups[date] || [] groups[date] = groups[date] || []
groups[date].push(l) groups[date].push(l)
@@ -130,7 +148,9 @@ function isRegistered(id: string) {
<div class="catalog-header"> <div class="catalog-header">
<div> <div>
<h1 class="page-title">Каталог открытых лекций</h1> <h1 class="page-title">Каталог открытых лекций</h1>
<p class="text-secondary">Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.</p> <p class="text-secondary">
Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.
</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<SearchInput v-model="search" placeholder="Поиск по теме лекции" /> <SearchInput v-model="search" placeholder="Поиск по теме лекции" />
@@ -171,8 +191,12 @@ function isRegistered(id: string) {
<label class="filter-label">Формат</label> <label class="filter-label">Формат</label>
<div class="segmented"> <div class="segmented">
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button> <button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button> <button :class="{ active: format === 'offline' }" @click="format = 'offline'">
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button> Офлайн
</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">
Онлайн
</button>
</div> </div>
</div> </div>
<div> <div>
@@ -191,9 +215,13 @@ function isRegistered(id: string) {
<div class="view-row"> <div class="view-row">
<div class="segmented"> <div class="segmented">
<button :class="{ active: viewMode === 'cards' }" @click="viewMode = 'cards'">Карточки</button> <button :class="{ active: viewMode === 'cards' }" @click="viewMode = 'cards'">
Карточки
</button>
<button :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">Список</button> <button :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">Список</button>
<button :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'">Календарь</button> <button :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'">
Календарь
</button>
</div> </div>
<div class="applied" v-if="appliedFilters.length"> <div class="applied" v-if="appliedFilters.length">
<span class="text-secondary">Фильтры:</span> <span class="text-secondary">Фильтры:</span>
@@ -210,7 +238,10 @@ function isRegistered(id: string) {
</div> </div>
<div v-else-if="filtered.length === 0"> <div v-else-if="filtered.length === 0">
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." /> <EmptyState
title="Нет результатов"
subtitle="Попробуйте изменить фильтры или сбросить поиск."
/>
</div> </div>
<div v-else-if="viewMode === 'cards'" class="cards-grid"> <div v-else-if="viewMode === 'cards'" class="cards-grid">
@@ -239,7 +270,9 @@ function isRegistered(id: string) {
</template> </template>
<template #seats="{ row }"> <template #seats="{ row }">
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'"> <span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
{{ row.registrationClosed ? 'Запись закрыта' : `${row.enrolledSeats}/${row.totalSeats}` }} {{
row.registrationClosed ? 'Запись закрыта' : `${row.enrolledSeats}/${row.totalSeats}`
}}
</span> </span>
</template> </template>
<template #action="{ row }"> <template #action="{ row }">
@@ -256,12 +289,14 @@ function isRegistered(id: string) {
</div> </div>
<div v-else class="calendar-view"> <div v-else class="calendar-view">
<GlassCard v-for="([date, items]) in calendarGroups" :key="date" class="calendar-day"> <GlassCard v-for="[date, items] in calendarGroups" :key="date" class="calendar-day">
<div class="calendar-date">{{ date }}</div> <div class="calendar-date">{{ date }}</div>
<div class="calendar-items"> <div class="calendar-items">
<div v-for="l in items" :key="l.id" class="calendar-item"> <div v-for="l in items" :key="l.id" class="calendar-item">
<div class="calendar-title">{{ l.title }}</div> <div class="calendar-title">{{ l.title }}</div>
<div class="calendar-meta">{{ l.time }} {{ l.building }} {{ l.room ? `ауд. ${l.room}` : '' }}</div> <div class="calendar-meta">
{{ l.time }} {{ l.building }} {{ l.room ? `ауд. ${l.room}` : '' }}
</div>
<button class="btn-secondary btn-sm">Подробнее</button> <button class="btn-secondary btn-sm">Подробнее</button>
</div> </div>
</div> </div>
@@ -292,8 +327,12 @@ function isRegistered(id: string) {
<label>Формат</label> <label>Формат</label>
<div class="segmented"> <div class="segmented">
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button> <button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button> <button :class="{ active: format === 'offline' }" @click="format = 'offline'">
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button> Офлайн
</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">
Онлайн
</button>
</div> </div>
<label>Теги</label> <label>Теги</label>
<FilterChips :filters="tagFilters" @toggle="toggleTag" /> <FilterChips :filters="tagFilters" @toggle="toggleTag" />
@@ -313,7 +352,11 @@ function isRegistered(id: string) {
</template> </template>
<style scoped> <style scoped>
.catalog { display: flex; flex-direction: column; gap: 20px; } .catalog {
display: flex;
flex-direction: column;
gap: 20px;
}
.catalog-header { .catalog-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -321,14 +364,24 @@ function isRegistered(id: string) {
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.header-actions { display: flex; gap: 12px; align-items: center; flex: 1; justify-content: flex-end; } .header-actions {
.filters-btn { display: none; } display: flex;
gap: 12px;
align-items: center;
flex: 1;
justify-content: flex-end;
}
.filters-btn {
display: none;
}
.filters-grid { .filters-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; gap: 16px;
} }
.filters-grid > * { min-width: 0; } .filters-grid > * {
min-width: 0;
}
.filter-label { .filter-label {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -343,7 +396,7 @@ function isRegistered(id: string) {
overflow: hidden; overflow: hidden;
} }
.segmented button { .segmented button {
background: rgba(255,255,255,0.7); background: rgba(255, 255, 255, 0.7);
border: none; border: none;
padding: 8px 14px; padding: 8px 14px;
font-size: 13px; font-size: 13px;
@@ -351,35 +404,108 @@ function isRegistered(id: string) {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.segmented button.active { .segmented button.active {
background: rgba(34,197,94,0.15); background: rgba(34, 197, 94, 0.15);
color: var(--color-primary-dark); color: var(--color-primary-dark);
font-weight: 600; font-weight: 600;
} }
.free-toggle { display: flex; flex-direction: column; gap: 6px; } .free-toggle {
.switch { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--color-text-secondary); } display: flex;
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } flex-direction: column;
.applied { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; } gap: 6px;
.applied .tag-chip { max-width: 100%; min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; line-height: 1.25; } }
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text-secondary);
}
.view-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.applied {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.applied .tag-chip {
max-width: 100%;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.25;
}
.cards-grid { .cards-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; gap: 16px;
} }
.list-title { font-weight: 600; } .list-title {
.list-view { margin-top: 6px; } font-weight: 600;
.calendar-view { display: flex; flex-direction: column; gap: 14px; } }
.calendar-day { padding: 16px; } .list-view {
.calendar-date { font-weight: 700; margin-bottom: 8px; } margin-top: 6px;
.calendar-items { display: flex; flex-direction: column; gap: 10px; } }
.calendar-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--color-border-glass); padding-bottom: 8px; } .calendar-view {
.calendar-item:last-child { border-bottom: none; padding-bottom: 0; } display: flex;
.calendar-title { font-weight: 600; } flex-direction: column;
.calendar-meta { font-size: 12px; color: var(--color-text-secondary); } gap: 14px;
.modal-filters { display: flex; flex-direction: column; gap: 12px; min-width: 0; } }
.calendar-day {
padding: 16px;
}
.calendar-date {
font-weight: 700;
margin-bottom: 8px;
}
.calendar-items {
display: flex;
flex-direction: column;
gap: 10px;
}
.calendar-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--color-border-glass);
padding-bottom: 8px;
}
.calendar-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.calendar-title {
font-weight: 600;
}
.calendar-meta {
font-size: 12px;
color: var(--color-text-secondary);
}
.modal-filters {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.filters-grid { display: none; } .filters-grid {
.filters-btn { display: inline-flex; } display: none;
.header-actions { width: 100%; justify-content: space-between; } }
.filters-btn {
display: inline-flex;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
} }
</style> </style>
+139 -36
View File
@@ -18,7 +18,9 @@ const lectures = useLecturesStore()
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const enrollmentLimitModalOpen = ref(false) const enrollmentLimitModalOpen = 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
const user = computed(() => auth.user!) const user = computed(() => auth.user!)
@@ -26,21 +28,26 @@ const userMetaLine = computed(() => {
const parts: string[] = [] const parts: string[] = []
if (user.value.institute) parts.push(user.value.institute) if (user.value.institute) parts.push(user.value.institute)
if (user.value.direction) parts.push(user.value.direction) if (user.value.direction) parts.push(user.value.direction)
if (Number.isFinite(user.value.year) && (user.value.year as number) > 0) parts.push(`${user.value.year} курс`) if (Number.isFinite(user.value.year) && (user.value.year as number) > 0)
parts.push(`${user.value.year} курс`)
return parts.join(' · ') return parts.join(' · ')
}) })
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),
) )
const achievements = computed(() => userStore.achievements.filter(a => a.unlocked).slice(0, 3)) const achievements = computed(() => userStore.achievements.filter((a) => a.unlocked).slice(0, 3))
const reminders = computed(() => userStore.notifications.slice(0, 3)) const reminders = computed(() => userStore.notifications.slice(0, 3))
const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0) const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp) const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0) const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined) const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value) const hasNextLevel = computed(
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1) () => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value,
)
const levelProgressMax = computed(() =>
hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1,
)
const levelProgress = computed(() => { const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0 if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1 if (!hasNextLevel.value) return 1
@@ -49,10 +56,14 @@ const levelProgress = computed(() => {
const levelProgressLabel = computed(() => const levelProgressLabel = computed(() =>
!hasLevelProgress.value !hasLevelProgress.value
? `Уровень ${user.value.level}` ? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Прогресс до уровня ${user.value.level + 1}` : 'Максимальный уровень' : hasNextLevel.value
? `Прогресс до уровня ${user.value.level + 1}`
: 'Максимальный уровень',
) )
const levelProgressText = computed(() => const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP` hasNextLevel.value
? `${levelProgress.value} / ${levelProgressMax.value} XP`
: `${userXp.value} XP`,
) )
onMounted(async () => { onMounted(async () => {
@@ -81,9 +92,7 @@ async function registerLecture(id: string) {
<div class="dashboard page-content"> <div class="dashboard page-content">
<div class="dashboard-welcome"> <div class="dashboard-welcome">
<div> <div>
<h1 class="page-title"> <h1 class="page-title">Добрый день, {{ formatUserName(user.name) }}!</h1>
Добрый день, {{ formatUserName(user.name) }}!
</h1>
<p v-if="userMetaLine" class="text-secondary">{{ userMetaLine }}</p> <p v-if="userMetaLine" class="text-secondary">{{ userMetaLine }}</p>
</div> </div>
<div class="quick-actions"> <div class="quick-actions">
@@ -113,12 +122,18 @@ async function registerLecture(id: string) {
</div> </div>
</div> </div>
<div class="next-actions"> <div class="next-actions">
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">Открыть</button> <button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
Открыть
</button>
<button class="btn-secondary">Добавить в календарь</button> <button class="btn-secondary">Добавить в календарь</button>
</div> </div>
</div> </div>
</GlassCard> </GlassCard>
<EmptyState v-else-if="!lectures.loading" title="Пока нет лекций" subtitle="Каталог пуст или данные ещё не синхронизированы." /> <EmptyState
v-else-if="!lectures.loading"
title="Пока нет лекций"
subtitle="Каталог пуст или данные ещё не синхронизированы."
/>
<GlassCard> <GlassCard>
<div class="xp-section"> <div class="xp-section">
@@ -140,7 +155,11 @@ async function registerLecture(id: string) {
</h2> </h2>
<button class="link-btn" @click="router.push('/catalog')">Все лекции </button> <button class="link-btn" @click="router.push('/catalog')">Все лекции </button>
</div> </div>
<EmptyState v-if="lectures.loading" title="Загружаем рекомендации" subtitle="Получаем данные с backend." /> <EmptyState
v-if="lectures.loading"
title="Загружаем рекомендации"
subtitle="Получаем данные с backend."
/>
<div v-else class="cards-grid"> <div v-else class="cards-grid">
<LectureCard <LectureCard
v-for="l in recommended" v-for="l in recommended"
@@ -195,7 +214,11 @@ async function registerLecture(id: string) {
</template> </template>
<style scoped> <style scoped>
.dashboard { display: flex; flex-direction: column; gap: 24px; } .dashboard {
display: flex;
flex-direction: column;
gap: 24px;
}
.dashboard-welcome { .dashboard-welcome {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -203,25 +226,78 @@ async function registerLecture(id: string) {
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.quick-actions { display: flex; gap: 10px; flex-wrap: wrap; } .quick-actions {
.next-lecture { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; } display: flex;
.next-title { font-size: 18px; font-weight: 700; margin: 6px 0; } gap: 10px;
.next-meta { display: flex; flex-direction: column; gap: 4px; color: var(--color-text-secondary); font-size: 13px; } flex-wrap: wrap;
.meta-line { display: inline-flex; align-items: center; gap: 6px; } }
.meta-icon { color: var(--color-text-secondary); } .next-lecture {
.next-actions { display: flex; gap: 10px; align-items: flex-start; } display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.next-title {
font-size: 18px;
font-weight: 700;
margin: 6px 0;
}
.next-meta {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--color-text-secondary);
font-size: 13px;
}
.meta-line {
display: inline-flex;
align-items: center;
gap: 6px;
}
.meta-icon {
color: var(--color-text-secondary);
}
.next-actions {
display: flex;
gap: 10px;
align-items: flex-start;
}
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; gap: 16px;
} }
.xp-section { display: flex; flex-direction: column; gap: 10px; } .xp-section {
.xp-header { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; } display: flex;
.xp-val { color: var(--color-text-secondary); } flex-direction: column;
.section-header { display: flex; align-items: center; justify-content: space-between; } gap: 10px;
.title-with-icon { display: inline-flex; align-items: center; gap: 8px; } }
.title-icon { color: var(--color-text); } .xp-header {
.inline-icon { color: var(--color-text); vertical-align: middle; } display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
}
.xp-val {
color: var(--color-text-secondary);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title-with-icon {
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-icon {
color: var(--color-text);
}
.inline-icon {
color: var(--color-text);
vertical-align: middle;
}
.cards-grid { .cards-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -236,15 +312,42 @@ async function registerLecture(id: string) {
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
} }
.two-column { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; } .two-column {
.achievements { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; } display: grid;
.reminders { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; } grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.achievements {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
}
.reminders {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
}
.reminder-item { .reminder-item {
border-bottom: 1px solid var(--color-border-glass); border-bottom: 1px solid var(--color-border-glass);
padding-bottom: 10px; padding-bottom: 10px;
} }
.reminder-item:last-child { border-bottom: none; padding-bottom: 0; } .reminder-item:last-child {
.reminder-title { font-weight: 700; margin-bottom: 4px; } border-bottom: none;
.reminder-body { font-size: 13px; color: var(--color-text-secondary); } padding-bottom: 0;
.reminder-date { font-size: 11px; color: var(--color-text-secondary); margin-top: 4px; } }
.reminder-title {
font-weight: 700;
margin-bottom: 4px;
}
.reminder-body {
font-size: 13px;
color: var(--color-text-secondary);
}
.reminder-date {
font-size: 11px;
color: var(--color-text-secondary);
margin-top: 4px;
}
</style> </style>
@@ -13,16 +13,24 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const lecturesStore = useLecturesStore() const lecturesStore = useLecturesStore()
const userStore = useUserStore() const userStore = useUserStore()
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
const enrollmentLimitModalOpen = ref(false) const enrollmentLimitModalOpen = ref(false)
const lectureId = computed(() => String(route.params.id)) const lectureId = computed(() => String(route.params.id))
const lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.value)) const lecture = computed(() => lecturesStore.all.find((l) => l.id === lectureId.value))
const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false)) const isRegistered = computed(() =>
const slotRegistrationDisabled = computed(() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value) lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false,
)
const slotRegistrationDisabled = computed(
() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value,
)
const isAttended = computed(() => lecture.value?.status === 'completed') const isAttended = computed(() => lecture.value?.status === 'completed')
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lectureId.value).slice(0, 3)) const similarLectures = computed(() =>
lecturesStore.all.filter((l) => l.id !== lectureId.value).slice(0, 3),
)
onMounted(async () => { onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures() if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
@@ -57,7 +65,10 @@ async function registerLecture() {
</div> </div>
<div v-else-if="!lecture" class="lecture-detail page-content"> <div v-else-if="!lecture" class="lecture-detail page-content">
<EmptyState title="Лекция не найдена" :subtitle="lecturesStore.error ?? 'Попробуйте открыть каталог и выбрать лекцию заново.'" /> <EmptyState
title="Лекция не найдена"
:subtitle="lecturesStore.error ?? 'Попробуйте открыть каталог и выбрать лекцию заново.'"
/>
</div> </div>
<div v-else class="lecture-detail page-content"> <div v-else class="lecture-detail page-content">
@@ -76,9 +87,13 @@ async function registerLecture() {
> >
Записаться Записаться
</button> </button>
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">Отменить запись</button> <button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
Отменить запись
</button>
<button class="btn-secondary">Добавить в календарь</button> <button class="btn-secondary">Добавить в календарь</button>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">Оставить отзыв</button> <button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
Оставить отзыв
</button>
</div> </div>
</div> </div>
@@ -86,19 +101,34 @@ async function registerLecture() {
<GlassCard> <GlassCard>
<div class="info-section"> <div class="info-section">
<h3>Преподаватель</h3> <h3>Преподаватель</h3>
<div class="info-value">{{ lecture.teacher }}<span v-if="lecture.teacherTitle"> {{ lecture.teacherTitle }}</span></div> <div class="info-value">
<div class="info-sub">{{ [lecture.department, lecture.institute].filter(Boolean).join(', ') }}</div> {{ lecture.teacher
}}<span v-if="lecture.teacherTitle"> {{ lecture.teacherTitle }}</span>
</div>
<div class="info-sub">
{{ [lecture.department, lecture.institute].filter(Boolean).join(', ') }}
</div>
</div> </div>
<div class="info-section"> <div class="info-section">
<h3>Детали занятия</h3> <h3>Детали занятия</h3>
<div class="info-value">{{ new Date(lecture.date).toLocaleDateString('ru-RU') }} {{ lecture.time }}</div> <div class="info-value">
{{ new Date(lecture.date).toLocaleDateString('ru-RU') }} {{ lecture.time }}
</div>
<div class="info-sub">Длительность: {{ lecture.duration }} мин</div> <div class="info-sub">Длительность: {{ lecture.duration }} мин</div>
<div class="info-sub">Локация: {{ lecture.building }} {{ lecture.room ? `ауд. ${lecture.room}` : '' }}</div> <div class="info-sub">
Локация: {{ lecture.building }} {{ lecture.room ? `ауд. ${lecture.room}` : '' }}
</div>
</div> </div>
<div class="info-section"> <div class="info-section">
<h3>Места</h3> <h3>Места</h3>
<div class="info-value">Записано {{ lecture.enrolledSeats }} из {{ lecture.totalSeats }}</div> <div class="info-value">
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" /> Записано {{ lecture.enrolledSeats }} из {{ lecture.totalSeats }}
</div>
<StatusBadge
:status="
lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'
"
/>
</div> </div>
<div class="info-section"> <div class="info-section">
<h3>Теги</h3> <h3>Теги</h3>
@@ -121,8 +151,16 @@ async function registerLecture() {
</template> </template>
<style scoped> <style scoped>
.lecture-detail { display: flex; flex-direction: column; gap: 24px; } .lecture-detail {
.breadcrumb { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; } display: flex;
flex-direction: column;
gap: 24px;
}
.breadcrumb {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.header { .header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -130,14 +168,39 @@ async function registerLecture() {
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.actions { display: flex; gap: 10px; flex-wrap: wrap; } .actions {
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; } display: flex;
.info-section { margin-bottom: 16px; } gap: 10px;
.info-section:last-child { margin-bottom: 0; } flex-wrap: wrap;
.info-section h3 { font-size: 14px; margin-bottom: 8px; } }
.info-value { font-weight: 700; } .info-grid {
.info-sub { font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; } display: grid;
.tags { display: flex; flex-wrap: wrap; gap: 6px; } grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.info-section {
margin-bottom: 16px;
}
.info-section:last-child {
margin-bottom: 0;
}
.info-section h3 {
font-size: 14px;
margin-bottom: 8px;
}
.info-value {
font-weight: 700;
}
.info-sub {
font-size: 13px;
color: var(--color-text-secondary);
margin-top: 4px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cards-grid { .cards-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+92 -23
View File
@@ -16,10 +16,10 @@ const cancelModal = ref(false)
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const upcoming = computed(() => const upcoming = computed(() =>
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' })) lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
) )
const history = computed(() => lecturesStore.all.filter(l => l.status === 'completed')) const history = computed(() => lecturesStore.all.filter((l) => l.status === 'completed'))
onMounted(async () => { onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures() if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
@@ -42,23 +42,37 @@ async function confirmCancel() {
<div class="header"> <div class="header">
<div> <div>
<h1 class="page-title">Мои записи</h1> <h1 class="page-title">Мои записи</h1>
<p class="text-secondary">Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.</p> <p class="text-secondary">
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
</p>
</div> </div>
<button class="btn-secondary">Экспорт в календарь</button> <button class="btn-secondary">Экспорт в календарь</button>
</div> </div>
<div class="tabs"> <div class="tabs">
<button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">Предстоящие</button> <button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">История</button> Предстоящие
</button>
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
История
</button>
</div> </div>
<div v-if="activeTab === 'upcoming'" class="list"> <div v-if="activeTab === 'upcoming'" class="list">
<EmptyState v-if="!upcoming.length" title="У вас нет предстоящих лекций" subtitle="Выберите лекцию в каталоге и запишитесь на неё." /> <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>
<div class="lecture-meta">{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}</div> <div class="lecture-meta">
<div class="lecture-meta">{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}</div> {{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
</div>
<div class="lecture-meta">
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
</div>
</div> </div>
<div class="lecture-actions"> <div class="lecture-actions">
<StatusBadge status="registered" /> <StatusBadge status="registered" />
@@ -69,12 +83,20 @@ async function confirmCancel() {
</div> </div>
<div v-else class="list"> <div v-else class="list">
<EmptyState v-if="!history.length" title="История пока пуста" subtitle="Завершённые лекции появятся здесь после посещения." /> <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>
<div class="lecture-meta">{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}</div> <div class="lecture-meta">
<div class="lecture-meta">{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}</div> {{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
</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 ?? 'completed'" /> <StatusBadge :status="item.status ?? 'completed'" />
@@ -86,7 +108,10 @@ async function confirmCancel() {
</div> </div>
<ModalDialog v-model="cancelModal" title="Отменить запись?" icon="alert-triangle" size="sm"> <ModalDialog v-model="cancelModal" title="Отменить запись?" icon="alert-triangle" size="sm">
<p>Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других студентов.</p> <p>
Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других
студентов.
</p>
<template #footer> <template #footer>
<button class="btn-secondary" type="button" @click="cancelModal = false">Нет</button> <button class="btn-secondary" type="button" @click="cancelModal = false">Нет</button>
<button class="btn-danger" type="button" @click="confirmCancel">Да, отменить</button> <button class="btn-danger" type="button" @click="confirmCancel">Да, отменить</button>
@@ -96,11 +121,27 @@ async function confirmCancel() {
</template> </template>
<style scoped> <style scoped>
.my-lectures { display: flex; flex-direction: column; gap: 18px; } .my-lectures {
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } display: flex;
.tabs { display: inline-flex; width: fit-content; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; } flex-direction: column;
gap: 18px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.tabs {
display: inline-flex;
width: fit-content;
border: 1px solid var(--color-border-glass);
border-radius: 12px;
overflow: hidden;
}
.tabs button { .tabs button {
background: rgba(255,255,255,0.7); background: rgba(255, 255, 255, 0.7);
border: none; border: none;
padding: 8px 18px; padding: 8px 18px;
font-size: 13px; font-size: 13px;
@@ -109,11 +150,39 @@ async function confirmCancel() {
cursor: pointer; cursor: pointer;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.tabs button.active { background: rgba(34,197,94,0.18); color: var(--color-primary-dark); font-weight: 600; } .tabs button.active {
.list { display: flex; flex-direction: column; gap: 12px; } background: rgba(34, 197, 94, 0.18);
.lecture-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap; } color: var(--color-primary-dark);
.lecture-title { font-weight: 700; margin-bottom: 4px; } font-weight: 600;
.lecture-meta { font-size: 13px; color: var(--color-text-secondary); } }
.lecture-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .list {
.btn-sm { padding: 6px 12px; font-size: 12px; } display: flex;
flex-direction: column;
gap: 12px;
}
.lecture-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.lecture-title {
font-weight: 700;
margin-bottom: 4px;
}
.lecture-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.lecture-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
</style> </style>
@@ -13,7 +13,7 @@ onMounted(() => {
const grouped = computed(() => { const grouped = computed(() => {
const map: Record<string, typeof userStore.notifications> = {} const map: Record<string, typeof userStore.notifications> = {}
userStore.notifications.forEach(n => { userStore.notifications.forEach((n) => {
const day = new Date(n.createdAt).toLocaleDateString('ru-RU') const day = new Date(n.createdAt).toLocaleDateString('ru-RU')
map[day] = map[day] || [] map[day] = map[day] || []
map[day].push(n) map[day].push(n)
@@ -34,7 +34,9 @@ const typeIcon: Record<string, string> = {
<div class="notifications page-content"> <div class="notifications page-content">
<div class="header"> <div class="header">
<h1 class="page-title">Уведомления</h1> <h1 class="page-title">Уведомления</h1>
<button class="btn-secondary" @click="userStore.markAllRead">Отметить все как прочитанные</button> <button class="btn-secondary" @click="userStore.markAllRead">
Отметить все как прочитанные
</button>
</div> </div>
<div v-if="userStore.notifications.length === 0" class="empty-wrap"> <div v-if="userStore.notifications.length === 0" class="empty-wrap">
@@ -46,7 +48,7 @@ const typeIcon: Record<string, string> = {
</div> </div>
<div v-else class="notification-groups"> <div v-else class="notification-groups">
<GlassCard v-for="([day, items]) in grouped" :key="day" class="group"> <GlassCard v-for="[day, items] in grouped" :key="day" class="group">
<div class="group-title">{{ day }}</div> <div class="group-title">{{ day }}</div>
<div class="items"> <div class="items">
<div v-for="n in items" :key="n.id" class="item" :class="{ unread: !n.read }"> <div v-for="n in items" :key="n.id" class="item" :class="{ unread: !n.read }">
@@ -69,16 +71,53 @@ const typeIcon: Record<string, string> = {
gap: 18px; gap: 18px;
min-height: calc(100vh - var(--topbar-height) - 28px - 80px); min-height: calc(100vh - var(--topbar-height) - 28px - 80px);
} }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .header {
.header .page-title { margin-bottom: 0; } display: flex;
.notification-groups { display: flex; flex-direction: column; gap: 14px; } align-items: center;
.group-title { font-weight: 700; margin-bottom: 10px; } justify-content: space-between;
.items { display: flex; flex-direction: column; gap: 10px; } gap: 12px;
.item { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); } flex-wrap: wrap;
.item.unread { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.08); } }
.icon { color: var(--color-text); flex-shrink: 0; } .header .page-title {
.item-title { font-weight: 600; } margin-bottom: 0;
.item-body { font-size: 13px; color: var(--color-text-secondary); } }
.notification-groups {
display: flex;
flex-direction: column;
gap: 14px;
}
.group-title {
font-weight: 700;
margin-bottom: 10px;
}
.items {
display: flex;
flex-direction: column;
gap: 10px;
}
.item {
display: flex;
gap: 12px;
padding: 10px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--color-border-glass);
}
.item.unread {
border-color: rgba(34, 197, 94, 0.4);
background: rgba(34, 197, 94, 0.08);
}
.icon {
color: var(--color-text);
flex-shrink: 0;
}
.item-title {
font-weight: 600;
}
.item-body {
font-size: 13px;
color: var(--color-text-secondary);
}
.empty-wrap { .empty-wrap {
flex: 1; flex: 1;
@@ -88,6 +127,8 @@ const typeIcon: Record<string, string> = {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.notifications { min-height: calc(100vh - var(--topbar-height) - 16px - 80px); } .notifications {
min-height: calc(100vh - var(--topbar-height) - 16px - 80px);
}
} }
</style> </style>
+99 -25
View File
@@ -27,8 +27,12 @@ const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp) const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0) const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined) const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value) const hasNextLevel = computed(
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1) () => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value,
)
const levelProgressMax = computed(() =>
hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1,
)
const levelProgress = computed(() => { const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0 if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1 if (!hasNextLevel.value) return 1
@@ -37,27 +41,32 @@ const levelProgress = computed(() => {
const levelProgressLabel = computed(() => const levelProgressLabel = computed(() =>
!hasLevelProgress.value !hasLevelProgress.value
? `Уровень ${user.value.level}` ? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Уровень ${user.value.level}` : `Уровень ${user.value.level} · максимум` : hasNextLevel.value
? `Уровень ${user.value.level}`
: `Уровень ${user.value.level} · максимум`,
) )
const levelProgressText = computed(() => const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP` hasNextLevel.value
? `${levelProgress.value} / ${levelProgressMax.value} XP`
: `${userXp.value} XP`,
) )
const activeEnrollments = computed(() => user.value.activeEnrollments ?? 0) const activeEnrollments = computed(() => user.value.activeEnrollments ?? 0)
const enrollmentSlotLimit = computed(() => user.value.enrollmentSlotLimit ?? 0) const enrollmentSlotLimit = computed(() => user.value.enrollmentSlotLimit ?? 0)
const enrollmentSlotsRemaining = computed(() => const enrollmentSlotsRemaining = computed(() =>
Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0) Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0),
) )
const enrollmentSlotRules = computed(() => user.value.enrollmentSlotRules ?? []) const enrollmentSlotRules = computed(() => user.value.enrollmentSlotRules ?? [])
const enrollmentSlotText = computed(() => const enrollmentSlotText = computed(() =>
enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...' enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...',
) )
const enrollmentSlotHint = computed(() => { const enrollmentSlotHint = computed(() => {
if (!enrollmentSlotLimit.value) return 'Загружаем лимит активных записей.' if (!enrollmentSlotLimit.value) return 'Загружаем лимит активных записей.'
if (enrollmentSlotsRemaining.value === 0) return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.' if (enrollmentSlotsRemaining.value === 0)
return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.'
return `Можно записаться еще на ${formatLectureCount(enrollmentSlotsRemaining.value)}.` return `Можно записаться еще на ${formatLectureCount(enrollmentSlotsRemaining.value)}.`
}) })
const unlockedAchievements = computed(() => userStore.achievements.filter(a => a.unlocked)) const unlockedAchievements = computed(() => userStore.achievements.filter((a) => a.unlocked))
const lockedAchievements = computed(() => userStore.achievements.filter(a => !a.unlocked)) const lockedAchievements = computed(() => userStore.achievements.filter((a) => !a.unlocked))
const interestTags = ref([ const interestTags = ref([
{ label: '#ML', active: true }, { label: '#ML', active: true },
{ label: '#ИИ', active: true }, { label: '#ИИ', active: true },
@@ -73,7 +82,8 @@ function formatSlotCount(slots: number) {
const lastDigit = slots % 10 const lastDigit = slots % 10
const lastTwoDigits = slots % 100 const lastTwoDigits = slots % 100
if (lastDigit === 1 && lastTwoDigits !== 11) return `${slots} слот` if (lastDigit === 1 && lastTwoDigits !== 11) return `${slots} слот`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${slots} слота` if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14))
return `${slots} слота`
return `${slots} слотов` return `${slots} слотов`
} }
@@ -81,7 +91,8 @@ function formatLectureCount(count: number) {
const lastDigit = count % 10 const lastDigit = count % 10
const lastTwoDigits = count % 100 const lastTwoDigits = count % 100
if (lastDigit === 1 && lastTwoDigits !== 11) return `${count} лекцию` if (lastDigit === 1 && lastTwoDigits !== 11) return `${count} лекцию`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${count} лекции` if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14))
return `${count} лекции`
return `${count} лекций` return `${count} лекций`
} }
@@ -210,16 +221,60 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.profile { display: flex; flex-direction: column; gap: 20px; } .profile {
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } display: flex;
.profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; } flex-direction: column;
.user-info { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; } gap: 20px;
.avatar { font-size: 38px; background: rgba(34,197,94,0.15); border-radius: 16px; padding: 12px; } }
.name { font-weight: 700; font-size: 18px; } .header {
.email, .meta { font-size: 13px; color: var(--color-text-secondary); } display: flex;
.level { margin: 16px 0; } align-items: center;
.level-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; } justify-content: space-between;
.tags-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } gap: 12px;
flex-wrap: wrap;
}
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.user-info {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 16px;
}
.avatar {
font-size: 38px;
background: rgba(34, 197, 94, 0.15);
border-radius: 16px;
padding: 12px;
}
.name {
font-weight: 700;
font-size: 18px;
}
.email,
.meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.level {
margin: 16px 0;
}
.level-header {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.tags-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.slot-overview { .slot-overview {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -273,10 +328,29 @@ onMounted(() => {
border-color: var(--color-primary-a30); border-color: var(--color-primary-a30);
background: var(--color-primary-a08); background: var(--color-primary-a08);
} }
.settings { display: flex; flex-direction: column; gap: 8px; } .settings {
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; } display: flex;
.achievements-section { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; } flex-direction: column;
.achievements-list { display: flex; flex-direction: column; gap: 12px; } gap: 8px;
}
.setting {
font-size: 13px;
color: var(--color-text-secondary);
display: flex;
gap: 8px;
align-items: center;
}
.achievements-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 18px;
}
.achievements-list {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 520px) { @media (max-width: 520px) {
.slot-overview { .slot-overview {
+89 -22
View File
@@ -21,7 +21,9 @@ const ratingMap = {
negative: 'Dislike', negative: 'Dislike',
} as const } as const
const lectureTitle = computed(() => lecture.value?.title || lecture.value?.courseName || 'Отзыв о лекции') const lectureTitle = computed(
() => lecture.value?.title || lecture.value?.courseName || 'Отзыв о лекции',
)
const lectureMeta = computed(() => { const lectureMeta = computed(() => {
if (!lecture.value) return '' if (!lecture.value) return ''
@@ -75,7 +77,9 @@ onMounted(() => {
<div> <div>
<h1 class="page-title">{{ lectureLoading ? 'Загрузка лекции...' : lectureTitle }}</h1> <h1 class="page-title">{{ lectureLoading ? 'Загрузка лекции...' : lectureTitle }}</h1>
<p class="text-secondary"> <p class="text-secondary">
{{ lectureMeta || 'Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.' }} {{
lectureMeta || 'Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.'
}}
</p> </p>
</div> </div>
</div> </div>
@@ -85,7 +89,8 @@ onMounted(() => {
<AppIcon class="success-icon" icon="circle-check" :size="32" /> <AppIcon class="success-icon" icon="circle-check" :size="32" />
<div class="success-title">Отзыв отправлен и будет обработан</div> <div class="success-title">Отзыв отправлен и будет обработан</div>
<div class="success-sub"> <div class="success-sub">
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM. Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается
техническая оценка LLM.
</div> </div>
</div> </div>
@@ -95,18 +100,39 @@ onMounted(() => {
<label class="field-label">Оценка впечатлений</label> <label class="field-label">Оценка впечатлений</label>
<div class="rating-options"> <div class="rating-options">
<button type="button" :class="{ active: rating === 'positive' }" @click="rating = 'positive'">👍 Положительный</button> <button
<button type="button" :class="{ active: rating === 'neutral' }" @click="rating = 'neutral'">😐 Нейтральный</button> type="button"
<button type="button" :class="{ active: rating === 'negative' }" @click="rating = 'negative'">👎 Отрицательный</button> :class="{ active: rating === 'positive' }"
@click="rating = 'positive'"
>
👍 Положительный
</button>
<button
type="button"
:class="{ active: rating === 'neutral' }"
@click="rating = 'neutral'"
>
😐 Нейтральный
</button>
<button
type="button"
:class="{ active: rating === 'negative' }"
@click="rating = 'negative'"
>
👎 Отрицательный
</button>
</div> </div>
<div class="hint"> <div class="hint">
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите раскрыть глубже. 💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите
раскрыть глубже.
</div> </div>
<div class="error" v-if="error">{{ error }}</div> <div class="error" v-if="error">{{ error }}</div>
<div class="form-actions"> <div class="form-actions">
<button class="btn-primary" type="submit" :disabled="loading">{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}</button> <button class="btn-primary" type="submit" :disabled="loading">
{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}
</button>
</div> </div>
</form> </form>
</GlassCard> </GlassCard>
@@ -114,37 +140,78 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.review { display: flex; flex-direction: column; gap: 16px; } .review {
.form { display: flex; flex-direction: column; gap: 12px; } display: flex;
.field-label { font-weight: 600; font-size: 13px; color: var(--color-text-secondary); } flex-direction: column;
gap: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-label {
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
}
textarea { textarea {
padding: 12px; padding: 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass); border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.8); background: rgba(255, 255, 255, 0.8);
font-size: 14px; font-size: 14px;
resize: vertical; resize: vertical;
} }
.rating-options { display: flex; gap: 10px; flex-wrap: wrap; } .rating-options {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.rating-options button { .rating-options button {
padding: 8px 12px; padding: 8px 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass); border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.6); background: rgba(255, 255, 255, 0.6);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.rating-options button.active { .rating-options button.active {
border-color: var(--color-primary); border-color: var(--color-primary);
background: rgba(34,197,94,0.15); background: rgba(34, 197, 94, 0.15);
color: var(--color-primary-dark); color: var(--color-primary-dark);
font-weight: 600; font-weight: 600;
} }
.hint { font-size: 12px; color: var(--color-text-secondary); background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: var(--radius-sm); } .hint {
.form-actions { display: flex; gap: 10px; } font-size: 12px;
.success-state { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; } color: var(--color-text-secondary);
.success-icon { color: var(--color-primary); } background: rgba(255, 255, 255, 0.6);
.success-title { font-size: 16px; font-weight: 700; } padding: 8px 12px;
.success-sub { font-size: 13px; color: var(--color-text-secondary); } border-radius: var(--radius-sm);
.error { color: var(--color-error); font-size: 13px; } }
.form-actions {
display: flex;
gap: 10px;
}
.success-state {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.success-icon {
color: var(--color-primary);
}
.success-title {
font-size: 16px;
font-weight: 700;
}
.success-sub {
font-size: 13px;
color: var(--color-text-secondary);
}
.error {
color: var(--color-error);
font-size: 13px;
}
</style> </style>
@@ -14,9 +14,9 @@ const lecturesStore = useLecturesStore()
const auth = useAuthStore() const auth = useAuthStore()
const reviews = ref<Review[]>([]) const reviews = ref<Review[]>([])
const positive = computed(() => reviews.value.filter(r => r.sentiment === 'positive').length) const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
const neutral = computed(() => reviews.value.filter(r => r.sentiment === 'neutral').length) const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
const negative = computed(() => reviews.value.filter(r => r.sentiment === 'negative').length) const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
const total = computed(() => reviews.value.length || 1) const total = computed(() => reviews.value.length || 1)
const pct = (value: number) => Math.round((value / total.value) * 100) const pct = (value: number) => Math.round((value / total.value) * 100)
@@ -24,8 +24,10 @@ async function fetchTeacherAnalytics() {
if (!auth.user?.id) return if (!auth.user?.id) return
await lecturesStore.fetchLectures({ TeacherId: auth.user.id }) await lecturesStore.fetchLectures({ TeacherId: auth.user.id })
const targetLectures = lecturesStore.all.slice(0, 5) const targetLectures = lecturesStore.all.slice(0, 5)
const payload = await Promise.allSettled(targetLectures.map(l => lecturesApi.reviews(l.id))) const payload = await Promise.allSettled(targetLectures.map((l) => lecturesApi.reviews(l.id)))
reviews.value = payload.flatMap(result => (result.status === 'fulfilled' ? result.value.map(mapApiReview) : [])) reviews.value = payload.flatMap((result) =>
result.status === 'fulfilled' ? result.value.map(mapApiReview) : [],
)
} }
onMounted(fetchTeacherAnalytics) onMounted(fetchTeacherAnalytics)
@@ -57,11 +59,19 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
</div> </div>
<div> <div>
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div> <div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
<ProgressBar :value="pct(neutral)" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" /> <ProgressBar
:value="pct(neutral)"
:max="100"
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
/>
</div> </div>
<div> <div>
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div> <div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
<ProgressBar :value="pct(negative)" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" /> <ProgressBar
:value="pct(negative)"
:max="100"
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
/>
</div> </div>
</div> </div>
</GlassCard> </GlassCard>
@@ -70,8 +80,9 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<GlassCard> <GlassCard>
<div class="section-title">LLM-сводка проблем и рекомендаций</div> <div class="section-title">LLM-сводка проблем и рекомендаций</div>
<p class="summary"> <p class="summary">
Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из аудитории. Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из
Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на интерактив. аудитории. Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на
интерактив.
</p> </p>
<div class="tags"> <div class="tags">
<span class="tag-chip">много практики</span> <span class="tag-chip">много практики</span>
@@ -83,29 +94,92 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<GlassCard> <GlassCard>
<div class="section-title">Отзывы</div> <div class="section-title">Отзывы</div>
<EmptyState v-if="!reviews.length" title="Отзывов пока нет" subtitle="Когда студенты оставят отзывы, они появятся здесь." /> <EmptyState
v-if="!reviews.length"
title="Отзывов пока нет"
subtitle="Когда студенты оставят отзывы, они появятся здесь."
/>
<div v-else class="reviews"> <div v-else class="reviews">
<div v-for="review in reviews" :key="review.id" class="review"> <div v-for="review in reviews" :key="review.id" class="review">«{{ review.text }}»</div>
«{{ review.text }}»
</div>
</div> </div>
</GlassCard> </GlassCard>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.teacher-analytics { display: flex; flex-direction: column; gap: 18px; } .teacher-analytics {
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; } display: flex;
.chart { display: flex; gap: 12px; align-items: flex-end; height: 160px; padding: 10px 0; } flex-direction: column;
.bar { display: flex; flex-direction: column; align-items: center; gap: 6px; } gap: 18px;
.bar-fill { width: 26px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, #22C55E, #86EFAC); } }
.bar-label { font-size: 11px; color: var(--color-text-secondary); } .grid {
.avg { margin-top: 6px; font-weight: 600; } display: grid;
.sentiment { display: flex; flex-direction: column; gap: 12px; } grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
.sentiment-label { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; } gap: 16px;
.summary { font-size: 14px; color: var(--color-text-secondary); line-height: 1.5; } }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } .chart {
.reviews { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } display: flex;
.review { background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); padding: 10px; border-radius: var(--radius-sm); font-size: 13px; } gap: 12px;
.top-list { padding-left: 18px; color: var(--color-text-secondary); font-size: 13px; } align-items: flex-end;
height: 160px;
padding: 10px 0;
}
.bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.bar-fill {
width: 26px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #22c55e, #86efac);
}
.bar-label {
font-size: 11px;
color: var(--color-text-secondary);
}
.avg {
margin-top: 6px;
font-weight: 600;
}
.sentiment {
display: flex;
flex-direction: column;
gap: 12px;
}
.sentiment-label {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.summary {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.5;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.reviews {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.review {
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--color-border-glass);
padding: 10px;
border-radius: var(--radius-sm);
font-size: 13px;
}
.top-list {
padding-left: 18px;
color: var(--color-text-secondary);
font-size: 13px;
}
</style> </style>
@@ -14,9 +14,15 @@ const router = useRouter()
const teacherLectures = computed(() => { const teacherLectures = computed(() => {
return lecturesStore.all return lecturesStore.all
}) })
const upcoming = computed(() => teacherLectures.value.filter(l => l.status !== 'completed').slice(0, 3)) const upcoming = computed(() =>
const enrolledTotal = computed(() => teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0)) teacherLectures.value.filter((l) => l.status !== 'completed').slice(0, 3),
const visibility = computed(() => (teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0)) )
const enrolledTotal = computed(() =>
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
)
const visibility = computed(() =>
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
)
function fetchTeacherLectures() { function fetchTeacherLectures() {
if (!auth.user?.id) return if (!auth.user?.id) return
@@ -33,7 +39,9 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<h1 class="page-title">Дашборд преподавателя</h1> <h1 class="page-title">Дашборд преподавателя</h1>
<div class="actions"> <div class="actions">
<button class="btn-primary" @click="router.push('/teacher/lectures')">Мои лекции</button> <button class="btn-primary" @click="router.push('/teacher/lectures')">Мои лекции</button>
<button class="btn-secondary" @click="router.push('/teacher/analytics')">Посмотреть отзывы</button> <button class="btn-secondary" @click="router.push('/teacher/analytics')">
Посмотреть отзывы
</button>
</div> </div>
</div> </div>
@@ -41,20 +49,33 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" /> <StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" /> <StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" /> <StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
<StatsWidget label="Вовлеченность вне направления" :value="`${visibility}%`" icon="🌍" color="purple" /> <StatsWidget
label="Вовлеченность вне направления"
:value="`${visibility}%`"
icon="🌍"
color="purple"
/>
</div> </div>
<GlassCard> <GlassCard>
<div class="section-title">Ближайшие лекции</div> <div class="section-title">Ближайшие лекции</div>
<EmptyState v-if="!upcoming.length" title="Лекций пока нет" subtitle="После синхронизации или назначения лекции появятся здесь." /> <EmptyState
v-if="!upcoming.length"
title="Лекций пока нет"
subtitle="После синхронизации или назначения лекции появятся здесь."
/>
<div v-else class="upcoming"> <div v-else class="upcoming">
<div class="upcoming-item" v-for="l in upcoming" :key="l.id"> <div class="upcoming-item" v-for="l in upcoming" :key="l.id">
<div> <div>
<div class="upcoming-title">{{ l.title }}</div> <div class="upcoming-title">{{ l.title }}</div>
<div class="upcoming-meta">{{ new Date(l.date).toLocaleDateString('ru-RU') }} {{ l.time }}</div> <div class="upcoming-meta">
{{ new Date(l.date).toLocaleDateString('ru-RU') }} {{ l.time }}
</div>
<div class="upcoming-meta">Записалось {{ l.enrolledSeats }} студентов</div> <div class="upcoming-meta">Записалось {{ l.enrolledSeats }} студентов</div>
</div> </div>
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">Управлять</button> <button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">
Управлять
</button>
</div> </div>
</div> </div>
</GlassCard> </GlassCard>
@@ -62,16 +83,63 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</template> </template>
<style scoped> <style scoped>
.teacher-dashboard { display: flex; flex-direction: column; gap: 18px; } .teacher-dashboard {
.header { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; } display: flex;
.actions { display: flex; gap: 10px; flex-wrap: wrap; } flex-direction: column;
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; } gap: 18px;
.visibility { display: flex; flex-direction: column; gap: 8px; } }
.visibility-meta { font-size: 13px; color: var(--color-text-secondary); } .header {
.upcoming { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; } display: flex;
.upcoming-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border-glass); } justify-content: space-between;
.upcoming-item:last-child { border-bottom: none; padding-bottom: 0; } gap: 12px;
.upcoming-title { font-weight: 700; } flex-wrap: wrap;
.upcoming-meta { font-size: 13px; color: var(--color-text-secondary); } }
.btn-sm { padding: 6px 12px; font-size: 12px; } .actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.visibility {
display: flex;
flex-direction: column;
gap: 8px;
}
.visibility-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.upcoming {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
}
.upcoming-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border-glass);
}
.upcoming-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.upcoming-title {
font-weight: 700;
}
.upcoming-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
</style> </style>
@@ -18,7 +18,7 @@ const columns = [
] ]
const rows = computed(() => { const rows = computed(() => {
return lecturesStore.all.map(l => ({ return lecturesStore.all.map((l) => ({
id: l.id, id: l.id,
title: l.title, title: l.title,
date: `${new Date(l.date).toLocaleDateString('ru-RU')} ${l.time}`, date: `${new Date(l.date).toLocaleDateString('ru-RU')} ${l.time}`,
@@ -44,7 +44,11 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</div> </div>
<GlassCard> <GlassCard>
<EmptyState v-if="!rows.length && !lecturesStore.loading" title="Лекций пока нет" subtitle="Backend не вернул лекции для текущего преподавателя." /> <EmptyState
v-if="!rows.length && !lecturesStore.loading"
title="Лекций пока нет"
subtitle="Backend не вернул лекции для текущего преподавателя."
/>
<DataTable :columns="columns" :rows="rows"> <DataTable :columns="columns" :rows="rows">
<template #status="{ value }"> <template #status="{ value }">
<StatusBadge :status="value" /> <StatusBadge :status="value" />
@@ -63,7 +67,22 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</template> </template>
<style scoped> <style scoped>
.teacher-lectures { display: flex; flex-direction: column; gap: 16px; } .teacher-lectures {
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } display: flex;
.actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; } flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
</style> </style>
File diff suppressed because one or more lines are too long