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

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