Files
UniVerse/frontend/src/views/student/DashboardView.vue
T
serega404 c4ed23a3d9
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m22s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 26s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 4s
Backend CI / build-and-test (pull_request) Successful in 54s
Frontend CI / build-and-check (pull_request) Failing after 5m4s
refactor: настроил линтер против сиротского css
2026-05-25 02:15:34 +03:00

345 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, inject, onMounted, ref } 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 LectureCard from '@/components/ui/LectureCard.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import AppIcon from '@/components/ui/AppIcon.vue'
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
import { formatUserName } from '@/utils/formatUserName'
const auth = useAuthStore()
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 user = computed(() => auth.user!)
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} курс`)
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),
)
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 levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1
return Math.min(Math.max(userXp.value - currentLevelXp.value, 0), levelProgressMax.value)
})
const levelProgressLabel = computed(() =>
!hasLevelProgress.value
? `Уровень ${user.value.level}`
: hasNextLevel.value
? `Прогресс до уровня ${user.value.level + 1}`
: 'Максимальный уровень',
)
const levelProgressText = computed(() =>
hasNextLevel.value
? `${levelProgress.value} / ${levelProgressMax.value} XP`
: `${userXp.value} XP`,
)
onMounted(async () => {
await Promise.all([
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
userStore.fetchStudentData(),
])
await lectures.fetchRegisteredForCurrentUser()
})
async function registerLecture(id: string) {
try {
await lectures.register(id)
addToast?.('Вы записаны на лекцию.', 'success')
} catch (err) {
if (err instanceof Error && err.message.includes('Лимит записей достигнут')) {
enrollmentLimitModalOpen.value = true
return
}
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
}
}
</script>
<template>
<div class="dashboard page-content">
<div class="dashboard-welcome">
<div>
<h1 class="page-title">Добрый день, {{ formatUserName(user.name) }}!</h1>
<p v-if="userMetaLine" class="text-secondary">{{ userMetaLine }}</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>
</div>
</div>
<GlassCard v-if="nextLecture">
<div class="next-lecture">
<div>
<div class="section-title">Ближайшая лекция</div>
<div class="next-title">{{ nextLecture.title }}</div>
<div class="next-meta">
<span class="meta-line">
<AppIcon class="meta-icon" icon="calendar" :size="14" />
Завтра, {{ nextLecture.time }}
</span>
<span class="meta-line">
<AppIcon class="meta-icon" icon="building" :size="14" />
{{ nextLecture.building }}, ауд. {{ nextLecture.room ?? 'онлайн' }}
</span>
<span class="meta-line">
<AppIcon class="meta-icon" icon="user" :size="14" />
{{ 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>
<EmptyState
v-else-if="!lectures.loading"
title="Пока нет лекций"
subtitle="Каталог пуст или данные ещё не синхронизированы."
/>
<GlassCard>
<div class="xp-section">
<div class="xp-header">
<span class="xp-label">{{ levelProgressLabel }}</span>
<span class="xp-val">{{ levelProgressText }}</span>
</div>
<ProgressBar :value="levelProgress" :max="levelProgressMax" :text="levelProgressText" />
</div>
</GlassCard>
<section>
<div class="section-header">
<h2 class="section-title">
<span class="title-with-icon">
<AppIcon class="title-icon" icon="sparkles" :size="18" />
Ближайшие лекции
</span>
</h2>
<button class="link-btn" @click="router.push('/catalog')">Все лекции </button>
</div>
<EmptyState
v-if="lectures.loading"
title="Загружаем рекомендации"
subtitle="Получаем данные с backend."
/>
<div v-else class="cards-grid">
<LectureCard
v-for="l in recommended"
:key="l.id"
:lecture="l"
:registered="lectures.registeredIds.includes(l.id)"
@register="registerLecture"
/>
</div>
</section>
<section class="two-column">
<GlassCard>
<div class="section-title">
<span class="title-with-icon">
<AppIcon class="title-icon" icon="trophy" :size="18" />
Достижения
</span>
</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">
<span class="title-with-icon">
<AppIcon class="title-icon" icon="bell" :size="18" />
Увидомления
</span>
</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>
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
</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;
}
.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;
}
.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);
}
.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>