Dev #11
@@ -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
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function apiRequest<T>(
|
||||
? String((body as { message: unknown }).message)
|
||||
: typeof body === 'object' && body && 'detail' in body
|
||||
? String((body as { detail: unknown }).detail)
|
||||
: `API request failed with status ${response.status}`
|
||||
: `API request failed with status ${response.status}`
|
||||
throw new ApiError(message, response.status, body)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,17 +9,19 @@ const route = useRoute()
|
||||
|
||||
const navItems = computed(() => {
|
||||
const role = auth.user?.activeRole ?? 'student'
|
||||
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 [
|
||||
{ label: 'Дашборд', icon: 'shield', to: '/admin' },
|
||||
{ label: 'Юзеры', icon: 'users', to: '/admin/users' },
|
||||
{ label: 'Лекции', icon: 'books', to: '/admin/lectures' },
|
||||
{ label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews' },
|
||||
]
|
||||
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 [
|
||||
{ label: 'Дашборд', icon: 'shield', to: '/admin' },
|
||||
{ label: 'Юзеры', icon: 'users', to: '/admin/users' },
|
||||
{ label: 'Лекции', icon: 'books', to: '/admin/lectures' },
|
||||
{ label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews' },
|
||||
]
|
||||
return [
|
||||
{ label: 'Главная', icon: 'home', to: '/' },
|
||||
{ label: 'Лекции', icon: 'books', to: '/catalog' },
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
@@ -40,10 +45,10 @@ function isActive(to: string) {
|
||||
<RouterLink
|
||||
v-for="item in visible"
|
||||
:key="item.to + item.label"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.to) }"
|
||||
>
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.to) }"
|
||||
>
|
||||
<AppIcon class="nav-icon" :icon="item.icon" :size="17" />
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -65,4 +65,3 @@ const sizePx = computed(() => {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
padding?: string
|
||||
hoverable?: boolean
|
||||
}>(), {
|
||||
padding: '18px',
|
||||
})
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
import { onBeforeUnmount, watch } from 'vue'
|
||||
import AppIcon from '@/components/ui/AppIcon.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
modelValue?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showClose?: boolean
|
||||
closeOnOverlay?: boolean
|
||||
closeOnEscape?: boolean
|
||||
}>(), {
|
||||
size: 'md',
|
||||
showClose: true,
|
||||
closeOnOverlay: true,
|
||||
closeOnEscape: true,
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
modelValue?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,20 +4,20 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const statusMap: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: 'Ожидание', cls: 'warning' },
|
||||
active: { label: 'Активно', cls: 'success' },
|
||||
done: { label: 'Завершено', cls: 'info' },
|
||||
pending: { label: 'Ожидание', cls: 'warning' },
|
||||
active: { label: 'Активно', cls: 'success' },
|
||||
done: { label: 'Завершено', cls: 'info' },
|
||||
cancelled: { label: 'Отменено', cls: 'danger' },
|
||||
approved: { label: 'Одобрено', cls: 'success' },
|
||||
rejected: { label: 'Отклонено', cls: 'danger' },
|
||||
open: { label: 'Открыта', cls: 'success' },
|
||||
closed: { label: 'Закрыта', cls: 'danger' },
|
||||
full: { label: 'Нет мест', cls: 'warning' },
|
||||
upcoming: { label: 'Будущая', cls: 'info' },
|
||||
ongoing: { label: 'Идет', cls: 'success' },
|
||||
approved: { label: 'Одобрено', cls: 'success' },
|
||||
rejected: { label: 'Отклонено', cls: 'danger' },
|
||||
open: { label: 'Открыта', cls: 'success' },
|
||||
closed: { label: 'Закрыта', cls: 'danger' },
|
||||
full: { label: 'Нет мест', cls: 'warning' },
|
||||
upcoming: { label: 'Будущая', cls: 'info' },
|
||||
ongoing: { label: 'Идет', cls: 'success' },
|
||||
completed: { label: 'Завершена', cls: 'info' },
|
||||
registered:{ label: 'Записан', cls: 'success' },
|
||||
attended: { label: 'Посещено', cls: 'info' },
|
||||
registered: { label: 'Записан', cls: 'success' },
|
||||
attended: { label: 'Посещено', cls: 'info' },
|
||||
needsReview: { label: 'Нужен отзыв', cls: 'warning' },
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const emojiToIcon: Record<string, IconName> = {
|
||||
'👋': 'hand-stop',
|
||||
'🏠': 'home',
|
||||
'📭': 'inbox',
|
||||
'ℹ️': 'info-circle',
|
||||
ℹ️: 'info-circle',
|
||||
'🔒': 'lock',
|
||||
'🚪': 'logout',
|
||||
'📍': 'map-pin',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+16
-12
@@ -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 achievement = mapApiAchievement(item)
|
||||
return [achievement.id, achievement]
|
||||
}))
|
||||
const catalogIds = new Set(achievementCatalog.map(item => String(item.id)))
|
||||
const lockedAndUnlocked = achievementCatalog.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 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,24 +121,68 @@ 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;
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user