feat: подготовил дизайн (изменения из другого репозитория)
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s

This commit is contained in:
2026-05-08 01:06:22 +03:00
parent 655ab1b5c5
commit 047611fd24
54 changed files with 4497 additions and 28 deletions
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
const userStore = useUserStore()
const unlocked = computed(() => userStore.achievements.filter(a => a.unlocked))
const locked = computed(() => userStore.achievements.filter(a => !a.unlocked))
const rewards = [
{ id: 'r1', title: 'Стикерпак UniVerse', price: 80, available: true },
{ id: 'r2', title: 'Термокружка ЮФУ', price: 150, available: true },
{ id: 'r3', title: 'Доп. консультация преподавателя', price: 220, available: false },
{ id: 'r4', title: 'Цифровой бейдж «Research Explorer»', price: 60, available: true },
]
</script>
<template>
<div class="achievements page-content">
<h1 class="page-title">Достижения и магазин наград</h1>
<section>
<h2 class="section-title">Полученные достижения</h2>
<div class="list">
<AchievementBadge
v-for="a in unlocked"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
:unlockedAt="a.unlockedAt"
:coins="a.coins"
/>
</div>
</section>
<section>
<h2 class="section-title">Заблокированные</h2>
<div class="list">
<AchievementBadge
v-for="a in locked"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
/>
</div>
</section>
<section>
<h2 class="section-title">Магазин наград</h2>
<div class="rewards">
<GlassCard v-for="r in rewards" :key="r.id" class="reward-card">
<div class="reward-title">{{ r.title }}</div>
<div class="reward-price">{{ r.price }} монет</div>
<button class="btn-primary" :disabled="!r.available">
{{ r.available ? 'Купить' : 'Недоступно' }}
</button>
</GlassCard>
</div>
</section>
</div>
</template>
<style scoped>
.achievements { display: flex; flex-direction: column; gap: 20px; }
.list { display: flex; flex-direction: column; gap: 12px; }
.rewards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
.reward-card { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.reward-title { font-weight: 700; }
.reward-price { color: var(--color-text-secondary); font-size: 13px; }
</style>
+345
View File
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useLecturesStore } from '@/stores/lectures'
import SearchInput from '@/components/ui/SearchInput.vue'
import LectureCard from '@/components/ui/LectureCard.vue'
import GlassCard from '@/components/ui/GlassCard.vue'
import FilterChips from '@/components/ui/FilterChips.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import DataTable from '@/components/ui/DataTable.vue'
import ModalDialog from '@/components/ui/ModalDialog.vue'
const lecturesStore = useLecturesStore()
const search = ref('')
const viewMode = ref<'cards' | 'list' | 'calendar'>('cards')
const dateFilter = ref('Любая дата')
const direction = ref('Все направления')
const teacher = ref('Все преподаватели')
const building = ref('Все корпуса')
const format = ref<'all' | 'online' | 'offline'>('all')
const onlyFree = ref(false)
const filtersOpen = ref(false)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const tagFilters = ref([
{ label: '#ML', value: '#ML', active: false },
{ label: '#ИИ', value: '#ИИ', active: false },
{ label: '#Python', value: '#Python', active: false },
{ label: '#квантовые-вычисления', value: '#квантовые-вычисления', active: false },
{ label: '#биоинформатика', value: '#биоинформатика', active: false },
{ label: '#философия', value: '#философия', active: false },
{ label: '#право', value: '#право', active: false },
{ label: '#маркетинг', value: '#маркетинг', active: false },
])
const directions = [
'Все направления',
'Информатика и вычислительная техника',
'Физика',
'Биология',
'Философия',
'Право',
'Экономика и маркетинг',
]
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)
if (target) target.active = !target.active
}
const activeTags = computed(() => tagFilters.value.filter(t => t.active).map(t => t.value))
const filtered = computed(() =>
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 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 matchesFree = !onlyFree.value || l.freeSeats > 0
return matchesSearch && matchesDirection && matchesTeacher && matchesBuilding && matchesFormat && matchesTags && matchesFree
})
)
const appliedFilters = computed(() => {
const filters: string[] = []
if (dateFilter.value !== 'Любая дата') filters.push(dateFilter.value)
if (direction.value !== 'Все направления') filters.push(direction.value)
if (teacher.value !== 'Все преподаватели') filters.push(teacher.value)
if (building.value !== 'Все корпуса') filters.push(building.value)
if (format.value !== 'all') filters.push(format.value === 'online' ? 'Онлайн' : 'Офлайн')
if (onlyFree.value) filters.push('Есть места')
filters.push(...activeTags.value)
return filters
})
const tableColumns = [
{ key: 'title', label: 'Лекция' },
{ key: 'teacher', label: 'Преподаватель' },
{ key: 'date', label: 'Дата' },
{ key: 'place', label: 'Локация' },
{ key: 'seats', label: 'Места', align: 'center' },
{ key: 'action', label: 'Действия', align: 'right' },
]
const calendarGroups = computed(() => {
const groups: Record<string, typeof filtered.value> = {}
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)
})
return Object.entries(groups)
})
function registerLecture(id: string) {
lecturesStore.register(id)
addToast?.('Вы записаны на лекцию. Напоминание придет за сутки.', 'success')
}
</script>
<template>
<div class="catalog page-content">
<div class="catalog-header">
<div>
<h1 class="page-title">Каталог открытых лекций</h1>
<p class="text-secondary">Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.</p>
</div>
<div class="header-actions">
<SearchInput v-model="search" placeholder="Поиск по теме лекции" />
<button class="btn-secondary filters-btn" @click="filtersOpen = true">Фильтры</button>
</div>
</div>
<GlassCard>
<div class="filters-grid">
<div>
<label class="filter-label">Дата</label>
<select v-model="dateFilter" class="glass-input">
<option>Любая дата</option>
<option>Сегодня</option>
<option>Завтра</option>
<option>На этой неделе</option>
</select>
</div>
<div>
<label class="filter-label">Направление</label>
<select v-model="direction" class="glass-input">
<option v-for="d in directions" :key="d">{{ d }}</option>
</select>
</div>
<div>
<label class="filter-label">Преподаватель</label>
<select v-model="teacher" class="glass-input">
<option v-for="t in teachers" :key="t">{{ t }}</option>
</select>
</div>
<div>
<label class="filter-label">Корпус</label>
<select v-model="building" class="glass-input">
<option v-for="b in buildings" :key="b">{{ b }}</option>
</select>
</div>
<div>
<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>
</div>
</div>
<div>
<label class="filter-label">Теги</label>
<FilterChips :filters="tagFilters" @toggle="toggleTag" />
</div>
<div class="free-toggle">
<label class="filter-label">Наличие мест</label>
<label class="switch">
<input type="checkbox" v-model="onlyFree" />
<span>Только свободные</span>
</label>
</div>
</div>
</GlassCard>
<div class="view-row">
<div class="segmented">
<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>
</div>
<div class="applied" v-if="appliedFilters.length">
<span class="text-secondary">Фильтры:</span>
<span v-for="f in appliedFilters" :key="f" class="tag-chip active">{{ f }}</span>
</div>
</div>
<div v-if="filtered.length === 0">
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." />
</div>
<div v-else-if="viewMode === 'cards'" class="cards-grid">
<LectureCard
v-for="l in filtered"
:key="l.id"
:lecture="l"
:registered="lecturesStore.registeredIds.includes(l.id)"
@register="registerLecture"
/>
</div>
<div v-else-if="viewMode === 'list'" class="list-view">
<GlassCard>
<DataTable :columns="tableColumns" :rows="filtered">
<template #title="{ row }">
<div class="list-title">{{ row.title }}</div>
<div class="text-secondary text-sm">{{ row.tags.join(' ') }}</div>
</template>
<template #date="{ row }">
{{ new Date(row.date).toLocaleDateString('ru-RU') }} · {{ row.time }}
</template>
<template #place="{ row }">
{{ row.building }} {{ row.room ? `· ауд. ${row.room}` : '' }}
</template>
<template #seats="{ row }">
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
{{ row.registrationClosed ? 'Запись закрыта' : `${row.freeSeats}/${row.totalSeats}` }}
</span>
</template>
<template #action="{ row }">
<button class="btn-primary btn-sm" :disabled="row.freeSeats === 0 || row.registrationClosed">Записаться</button>
</template>
</DataTable>
</GlassCard>
</div>
<div v-else class="calendar-view">
<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>
<button class="btn-secondary btn-sm">Подробнее</button>
</div>
</div>
</GlassCard>
</div>
<ModalDialog v-model="filtersOpen" title="Фильтры">
<div class="modal-filters">
<label>Дата</label>
<select v-model="dateFilter" class="glass-input">
<option>Любая дата</option>
<option>Сегодня</option>
<option>Завтра</option>
<option>На этой неделе</option>
</select>
<label>Направление</label>
<select v-model="direction" class="glass-input">
<option v-for="d in directions" :key="d">{{ d }}</option>
</select>
<label>Преподаватель</label>
<select v-model="teacher" class="glass-input">
<option v-for="t in teachers" :key="t">{{ t }}</option>
</select>
<label>Корпус</label>
<select v-model="building" class="glass-input">
<option v-for="b in buildings" :key="b">{{ b }}</option>
</select>
<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>
</div>
<label>Теги</label>
<FilterChips :filters="tagFilters" @toggle="toggleTag" />
<label class="switch">
<input type="checkbox" v-model="onlyFree" />
<span>Только свободные места</span>
</label>
</div>
<template #footer>
<button class="btn-secondary" @click="filtersOpen = false">Закрыть</button>
<button class="btn-primary" @click="filtersOpen = false">Применить</button>
</template>
</ModalDialog>
</div>
</template>
<style scoped>
.catalog { display: flex; flex-direction: column; gap: 20px; }
.catalog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.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;
}
.filter-label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); margin-bottom: 6px; display: block; }
.segmented {
display: inline-flex;
border: 1px solid var(--color-border-glass);
border-radius: 10px;
overflow: hidden;
}
.segmented button {
background: rgba(255,255,255,0.7);
border: none;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
color: var(--color-text-secondary);
}
.segmented button.active {
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; }
.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; }
@media (max-width: 768px) {
.filters-grid { display: none; }
.filters-btn { display: inline-flex; }
.header-actions { width: 100%; justify-content: space-between; }
}
</style>
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue'
import LectureCard from '@/components/ui/LectureCard.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
const auth = useAuthStore()
const lectures = useLecturesStore()
const userStore = useUserStore()
const router = useRouter()
const user = computed(() => auth.user!)
const nextLecture = computed(() => lectures.registeredLectures[0] ?? lectures.all[0]!)
const recommended = computed(() =>
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 reminders = computed(() => userStore.notifications.slice(0, 3))
const xpToNext = 200
const xpProgress = computed(() => user.value.xp ?? 120)
</script>
<template>
<div class="dashboard page-content">
<div class="dashboard-welcome">
<div>
<h1 class="page-title">Добрый день, {{ user.name.split(' ')[0] }}! 👋</h1>
<p class="text-secondary">{{ user.institute }} · {{ user.direction }} · {{ user.year }} курс</p>
</div>
<div class="quick-actions">
<button class="btn-primary" @click="router.push('/catalog')">Найти лекцию</button>
<button class="btn-secondary" @click="router.push('/my-lectures')">Мои записи</button>
<button class="btn-secondary" @click="router.push(`/review/${nextLecture?.id ?? '1'}`)">Оставить отзыв</button>
</div>
</div>
<GlassCard>
<div class="next-lecture">
<div>
<div class="section-title">Ближайшая лекция</div>
<div class="next-title">{{ nextLecture.title }}</div>
<div class="next-meta">
<span>📅 Завтра, {{ nextLecture.time }}</span>
<span>🏛 {{ nextLecture.building }}, ауд. {{ nextLecture.room ?? 'онлайн' }}</span>
<span>👤 {{ nextLecture.teacher }}</span>
</div>
</div>
<div class="next-actions">
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">Открыть</button>
<button class="btn-secondary">Добавить в календарь</button>
</div>
</div>
</GlassCard>
<div class="stats-row">
<StatsWidget label="Посещено лекций" :value="user.lecturesAttended ?? 12" icon="📚" color="green" />
<StatsWidget label="Часов обучения" :value="user.hoursLearned ?? 18.5" icon="⏱" color="aqua" />
<StatsWidget label="Монет" :value="user.coins" icon="💰" color="orange" />
<StatsWidget label="Уровень" :value="user.level" icon="⭐" color="purple" sub="текущий уровень" />
</div>
<GlassCard>
<div class="xp-section">
<div class="xp-header">
<span class="xp-label">Прогресс до уровня {{ user.level + 1 }}</span>
<span class="xp-val">{{ xpProgress }} / {{ xpToNext }} XP</span>
</div>
<ProgressBar :value="xpProgress" :max="xpToNext" />
</div>
</GlassCard>
<section>
<div class="section-header">
<h2 class="section-title"> Рекомендуемые лекции</h2>
<button class="link-btn" @click="router.push('/catalog')">Все лекции </button>
</div>
<div class="cards-grid">
<LectureCard
v-for="l in recommended"
:key="l.id"
:lecture="l"
:registered="lectures.registeredIds.includes(l.id)"
@register="lectures.register"
/>
</div>
</section>
<section class="two-column">
<GlassCard>
<div class="section-title">🏆 Достижения</div>
<div class="achievements">
<AchievementBadge
v-for="a in achievements"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
:unlockedAt="a.unlockedAt"
:coins="a.coins"
/>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">🔔 Напоминания</div>
<div class="reminders">
<div class="reminder-item" v-for="n in reminders" :key="n.id">
<div class="reminder-title">{{ n.title }}</div>
<div class="reminder-body">{{ n.body }}</div>
<div class="reminder-date">{{ new Date(n.createdAt).toLocaleDateString('ru-RU') }}</div>
</div>
</div>
</GlassCard>
</section>
</div>
</template>
<style scoped>
.dashboard { display: flex; flex-direction: column; gap: 24px; }
.dashboard-welcome {
display: flex;
align-items: flex-start;
justify-content: space-between;
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; }
.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; }
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-top: 12px;
}
.link-btn {
background: none;
border: none;
color: var(--color-primary-dark);
font-weight: 600;
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; }
.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; }
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLecturesStore } from '@/stores/lectures'
import GlassCard from '@/components/ui/GlassCard.vue'
import LectureCard from '@/components/ui/LectureCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const route = useRoute()
const router = useRouter()
const lecturesStore = useLecturesStore()
const lecture = computed(() => lecturesStore.all.find(l => l.id === route.params.id) ?? lecturesStore.all[0]!)
const isRegistered = computed(() => lecturesStore.isRegistered(lecture.value.id))
const attendedLectures = ['1']
const isAttended = computed(() => attendedLectures.includes(lecture.value.id))
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lecture.value.id).slice(0, 3))
</script>
<template>
<div class="lecture-detail page-content">
<div class="header">
<div>
<div class="breadcrumb">Каталог / {{ lecture.title }}</div>
<h1 class="page-title">{{ lecture.title }}</h1>
<p class="text-secondary">{{ lecture.description }}</p>
</div>
<div class="actions">
<button
v-if="!isRegistered"
class="btn-primary"
:disabled="lecture.freeSeats === 0 || lecture.registrationClosed"
@click="lecturesStore.register(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>
</div>
</div>
<div class="info-grid">
<GlassCard>
<div class="info-section">
<h3>Преподаватель</h3>
<div class="info-value">{{ lecture.teacher }} · {{ lecture.teacherTitle }}</div>
<div class="info-sub">{{ lecture.department }}, {{ lecture.institute }}</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-sub">Длительность: {{ lecture.duration }} мин</div>
<div class="info-sub">Локация: {{ lecture.building }} {{ lecture.room ? `· ауд. ${lecture.room}` : '' }}</div>
</div>
<div class="info-section">
<h3>Места</h3>
<div class="info-value">Свободно {{ lecture.freeSeats }} из {{ lecture.totalSeats }}</div>
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" />
</div>
<div class="info-section">
<h3>Теги</h3>
<div class="tags">
<span class="tag-chip" v-for="tag in lecture.tags" :key="tag">{{ tag }}</span>
</div>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">LLM-сводка отзывов</div>
<p class="summary">
Студенты отмечают «понятные примеры» и «много практики». Предлагается добавить больше времени на вопросы и
прикладные кейсы. Средняя оценка 4.8/5.
</p>
<div class="reviews">
<div class="review">
<div class="review-head">Анонимный отзыв · 5 </div>
<div class="review-body">Очень структурно, понравились живые примеры и объяснение базовых концепций.</div>
</div>
<div class="review">
<div class="review-head">Анонимный отзыв · 4 </div>
<div class="review-body">Полезно, но хотелось больше времени на практику и разбор домашних заданий.</div>
</div>
</div>
</GlassCard>
</div>
<section>
<h2 class="section-title">Похожие лекции</h2>
<div class="cards-grid">
<LectureCard v-for="l in similarLectures" :key="l.id" :lecture="l" />
</div>
</section>
</div>
</template>
<style scoped>
.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;
justify-content: space-between;
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; }
.summary { font-size: 14px; color: var(--color-text-secondary); line-height: 1.5; }
.reviews { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
.review { padding: 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); }
.review-head { font-weight: 600; margin-bottom: 4px; }
.review-body { font-size: 13px; color: var(--color-text-secondary); }
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useLecturesStore } from '@/stores/lectures'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModalDialog from '@/components/ui/ModalDialog.vue'
const lecturesStore = useLecturesStore()
const router = useRouter()
const activeTab = ref<'upcoming' | 'history'>('upcoming')
const cancelModal = ref(false)
const selectedId = ref<string | null>(null)
const upcoming = computed(() =>
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' }))
)
const history = ref([
{ id: '1', title: 'Введение в нейронные сети и глубокое обучение', date: '2025-04-20', time: '14:00', building: 'ИКТИБ', room: '305', status: 'attended' },
{ id: '4', title: 'Философия цифровой эпохи', date: '2025-04-12', time: '18:00', building: 'Онлайн', room: '', status: 'needsReview' },
{ id: '5', title: 'Право в информационном обществе', date: '2025-04-05', time: '15:30', building: 'ЮФ', room: '412', status: 'cancelled' },
])
function openCancel(id: string) {
selectedId.value = id
cancelModal.value = true
}
function confirmCancel() {
if (selectedId.value) lecturesStore.unregister(selectedId.value)
cancelModal.value = false
}
</script>
<template>
<div class="my-lectures page-content">
<div class="header">
<div>
<h1 class="page-title">Мои записи</h1>
<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>
</div>
<div v-if="activeTab === 'upcoming'" class="list">
<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>
<div class="lecture-actions">
<StatusBadge status="registered" />
<button class="btn-secondary btn-sm">Добавить в календарь</button>
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
</div>
</GlassCard>
</div>
<div v-else class="list">
<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>
<div class="lecture-actions">
<StatusBadge :status="item.status" />
<button v-if="item.status === 'needsReview'" class="btn-primary btn-sm" @click="router.push(`/review/${item.id}`)">
Оставить отзыв
</button>
</div>
</GlassCard>
</div>
<ModalDialog v-model="cancelModal" title="Отменить запись?">
<p>Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других студентов.</p>
<template #footer>
<button class="btn-secondary" @click="cancelModal = false">Нет</button>
<button class="btn-danger" @click="confirmCancel">Да, отменить</button>
</template>
</ModalDialog>
</div>
</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; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; }
.tabs button {
background: rgba(255,255,255,0.7);
border: none;
padding: 8px 18px;
font-size: 13px;
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; }
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
const userStore = useUserStore()
const grouped = computed(() => {
const map: Record<string, typeof userStore.notifications> = {}
userStore.notifications.forEach(n => {
const day = new Date(n.createdAt).toLocaleDateString('ru-RU')
map[day] = map[day] || []
map[day].push(n)
})
return Object.entries(map)
})
const typeIcon: Record<string, string> = {
reminder: '⏰',
'schedule-change': '🗓️',
achievement: '🏆',
coins: '💰',
recommendation: '✨',
}
</script>
<template>
<div class="notifications page-content">
<div class="header">
<h1 class="page-title">Уведомления</h1>
<button class="btn-secondary" @click="userStore.markAllRead">Отметить все как прочитанные</button>
</div>
<div class="notification-groups">
<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 }">
<div class="icon">{{ typeIcon[n.type] }}</div>
<div>
<div class="item-title">{{ n.title }}</div>
<div class="item-body">{{ n.body }}</div>
</div>
</div>
</div>
</GlassCard>
</div>
</div>
</template>
<style scoped>
.notifications { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.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 { font-size: 20px; }
.item-title { font-weight: 600; }
.item-body { font-size: 13px; color: var(--color-text-secondary); }
</style>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
import CoinChip from '@/components/ui/CoinChip.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
import DataTable from '@/components/ui/DataTable.vue'
const auth = useAuthStore()
const userStore = useUserStore()
const user = computed(() => auth.user!)
const interestTags = ref([
{ label: '#ML', active: true },
{ label: '#ИИ', active: true },
{ label: '#дизайн', active: false },
{ label: '#право', active: false },
{ label: '#биоинформатика', active: false },
{ label: '#маркетинг', active: true },
])
const notificationSettings = ref({ email: true, web: true, telegram: false })
const historyColumns = [
{ key: 'date', label: 'Дата' },
{ key: 'description', label: 'Описание' },
{ key: 'amount', label: 'Монеты', align: 'right' },
]
</script>
<template>
<div class="profile page-content">
<div class="header">
<h1 class="page-title">Профиль студента</h1>
<CoinChip :amount="user.coins" />
</div>
<div class="profile-grid">
<GlassCard>
<div class="user-info">
<div class="avatar">👤</div>
<div>
<div class="name">{{ user.name }}</div>
<div class="email">{{ user.email }}</div>
<div class="meta">{{ user.institute }} · {{ user.direction }}</div>
<div class="meta">{{ user.year }} курс</div>
</div>
</div>
<div class="level">
<div class="level-header">
<span>Уровень {{ user.level }}</span>
<span>{{ user.xp }} / 200 XP</span>
</div>
<ProgressBar :value="user.xp ?? 0" :max="200" />
</div>
<div class="tags">
<div class="section-title">Интересы</div>
<div class="tags-grid">
<button
v-for="tag in interestTags"
:key="tag.label"
class="tag-chip"
:class="{ active: tag.active }"
@click="tag.active = !tag.active"
>
{{ tag.label }}
</button>
</div>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">Настройки уведомлений</div>
<div class="settings">
<label class="setting">
<input type="checkbox" v-model="notificationSettings.email" />
Email уведомления
</label>
<label class="setting">
<input type="checkbox" v-model="notificationSettings.web" />
Web push
</label>
<label class="setting">
<input type="checkbox" v-model="notificationSettings.telegram" />
Telegram бот @universe_sfedu
</label>
</div>
<div class="section-title">Достижения</div>
<div class="achievements">
<AchievementBadge
v-for="a in userStore.achievements.slice(0, 3)"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
:unlockedAt="a.unlockedAt"
:coins="a.coins"
/>
</div>
</GlassCard>
</div>
<GlassCard>
<div class="section-title">История начисления монет</div>
<DataTable :columns="historyColumns" :rows="userStore.coinHistory">
<template #amount="{ value }">
<span :class="value > 0 ? 'positive' : 'negative'">{{ value > 0 ? `+${value}` : value }}</span>
</template>
</DataTable>
</GlassCard>
</div>
</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; }
.settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; }
.achievements { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.positive { color: #166534; font-weight: 600; }
.negative { color: #991B1B; font-weight: 600; }
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import GlassCard from '@/components/ui/GlassCard.vue'
const route = useRoute()
const rating = ref<'positive' | 'neutral' | 'negative'>('positive')
const text = ref('Лекция была хорошо структурирована, особенно понравились практические примеры и разбор кейсов.')
const submitted = ref(false)
const editing = ref(false)
function submit() {
submitted.value = true
editing.value = false
}
</script>
<template>
<div class="review page-content">
<div class="header">
<div>
<h1 class="page-title">Отзыв о лекции #{{ route.params.id }}</h1>
<p class="text-secondary">Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.</p>
</div>
</div>
<GlassCard>
<div v-if="submitted && !editing" class="success-state">
<div class="success-icon"></div>
<div class="success-title">Отзыв отправлен и будет обработан</div>
<div class="success-sub">
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
</div>
<button class="btn-secondary" @click="editing = true">Редактировать отзыв</button>
</div>
<form v-else @submit.prevent="submit" class="form">
<label class="field-label">Ваш отзыв о лекции</label>
<textarea v-model="text" rows="6" placeholder="Опишите, что было полезно, а что можно улучшить"></textarea>
<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>
</div>
<div class="hint">
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите раскрыть глубже.
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">Отправить отзыв</button>
<button class="btn-secondary" type="button" :disabled="submitted">Сохранить черновик</button>
</div>
</form>
</GlassCard>
</div>
</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); }
textarea {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.8);
font-size: 14px;
resize: vertical;
}
.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);
cursor: pointer;
font-size: 13px;
}
.rating-options button.active {
border-color: var(--color-primary);
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 { font-size: 28px; }
.success-title { font-size: 16px; font-weight: 700; }
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
</style>