refactor: натравил форматтер на весь фронт
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user