365 lines
11 KiB
Vue
365 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useUserStore } from '@/stores/user'
|
|
import GlassCard from '@/components/ui/GlassCard.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'
|
|
|
|
const auth = useAuthStore()
|
|
const userStore = useUserStore()
|
|
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)
|
|
return parts.join(' ')
|
|
})
|
|
|
|
const userYearLine = computed(() => {
|
|
const year = user.value.year ?? 0
|
|
return Number.isFinite(year) && year > 0 ? `${year} курс` : ''
|
|
})
|
|
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}`
|
|
: `Уровень ${user.value.level} · максимум`,
|
|
)
|
|
const levelProgressText = computed(() =>
|
|
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),
|
|
)
|
|
const enrollmentSlotRules = computed(() => user.value.enrollmentSlotRules ?? [])
|
|
const enrollmentSlotText = computed(() =>
|
|
enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...',
|
|
)
|
|
const enrollmentSlotHint = computed(() => {
|
|
if (!enrollmentSlotLimit.value) 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 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 })
|
|
|
|
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} слота`
|
|
return `${slots} слотов`
|
|
}
|
|
|
|
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} лекции`
|
|
return `${count} лекций`
|
|
}
|
|
|
|
onMounted(() => {
|
|
void userStore.fetchStudentData()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="profile page-content">
|
|
<div class="header">
|
|
<h1 class="page-title">Профиль пользователя</h1>
|
|
</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 v-if="userMetaLine" class="meta">{{ userMetaLine }}</div>
|
|
<div v-if="userYearLine" class="meta">{{ userYearLine }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="level">
|
|
<div class="level-header">
|
|
<span>{{ levelProgressLabel }}</span>
|
|
<span>{{ levelProgressText }}</span>
|
|
</div>
|
|
<ProgressBar :value="levelProgress" :max="levelProgressMax" :text="levelProgressText" />
|
|
</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="slot-overview">
|
|
<div class="slot-meter">
|
|
<AppIcon icon="calendar" :size="22" />
|
|
<span>{{ enrollmentSlotText }}</span>
|
|
</div>
|
|
<p class="slot-hint">{{ enrollmentSlotHint }}</p>
|
|
</div>
|
|
<div class="slot-rules" v-if="enrollmentSlotRules.length">
|
|
<div
|
|
v-for="rule in enrollmentSlotRules"
|
|
:key="rule.level"
|
|
class="slot-rule"
|
|
:class="{ active: user.level >= rule.level }"
|
|
>
|
|
<span>Ур. {{ rule.level }}</span>
|
|
<strong>{{ formatSlotCount(rule.slots) }}</strong>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
<GlassCard>
|
|
<div class="section-title">Настройки уведомлений</div>
|
|
<div class="settings">
|
|
<label class="setting">
|
|
<input type="checkbox" v-model="notificationSettings.email" />
|
|
Email уведомления
|
|
</label>
|
|
</div>
|
|
</GlassCard>
|
|
</div>
|
|
|
|
<GlassCard>
|
|
<div class="section-title">Достижения</div>
|
|
|
|
<section class="achievements-section">
|
|
<h2 class="section-title">Полученные достижения</h2>
|
|
<EmptyState
|
|
v-if="!unlockedAchievements.length"
|
|
title="Полученных достижений пока нет"
|
|
subtitle="Они появятся после участия в лекциях и отзывах."
|
|
/>
|
|
<div v-else class="achievements-list">
|
|
<AchievementBadge
|
|
v-for="a in unlockedAchievements"
|
|
:key="a.id"
|
|
:icon="a.icon"
|
|
:title="a.title"
|
|
:description="a.description"
|
|
:unlocked="a.unlocked"
|
|
:unlockedAt="a.unlockedAt"
|
|
:coins="a.coins"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="achievements-section">
|
|
<h2 class="section-title">Заблокированные</h2>
|
|
<EmptyState
|
|
v-if="!lockedAchievements.length"
|
|
title="Нет заблокированных достижений"
|
|
subtitle="Backend пока не вернул список будущих достижений."
|
|
/>
|
|
<div v-else class="achievements-list">
|
|
<AchievementBadge
|
|
v-for="a in lockedAchievements"
|
|
:key="a.id"
|
|
:icon="a.icon"
|
|
:title="a.title"
|
|
:description="a.description"
|
|
:unlocked="a.unlocked"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</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;
|
|
}
|
|
.slot-overview {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 14px;
|
|
margin: 14px 0;
|
|
}
|
|
.slot-meter {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 106px;
|
|
color: var(--color-primary-dark);
|
|
font-size: 26px;
|
|
font-weight: 800;
|
|
}
|
|
.slot-meter :deep(.app-icon) {
|
|
color: var(--color-primary);
|
|
}
|
|
.slot-hint {
|
|
margin: 0;
|
|
color: var(--color-text-secondary);
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
text-align: right;
|
|
}
|
|
.slot-rules {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
.slot-rule {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
padding: 10px;
|
|
border: 1px solid var(--color-slate-500-a20);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-white-a50);
|
|
}
|
|
.slot-rule span {
|
|
color: var(--color-text-secondary);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
.slot-rule strong {
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
}
|
|
.slot-rule.active {
|
|
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;
|
|
}
|
|
|
|
@media (max-width: 520px) {
|
|
.slot-overview {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
.slot-hint {
|
|
text-align: left;
|
|
}
|
|
}
|
|
</style>
|