feat: добавил ограничение записи на лекции
Backend CI / build-and-test (push) Failing after 32s
Frontend CI / build-and-check (push) Failing after 5m5s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 1m28s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped

This commit is contained in:
2026-05-21 19:34:08 +03:00
parent 32b8bdfd24
commit 2e7ce6c2e8
21 changed files with 569 additions and 23 deletions
+116
View File
@@ -6,6 +6,7 @@ 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()
@@ -41,6 +42,20 @@ const levelProgressLabel = computed(() =>
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([
@@ -54,6 +69,22 @@ const interestTags = ref([
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()
})
@@ -99,6 +130,28 @@ onMounted(() => {
</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">
@@ -167,8 +220,71 @@ onMounted(() => {
.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>