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
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:
@@ -8,6 +8,7 @@ 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'
|
||||
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
||||
|
||||
const lecturesStore = useLecturesStore()
|
||||
const search = ref('')
|
||||
@@ -19,6 +20,7 @@ const building = ref('Все корпуса')
|
||||
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
|
||||
|
||||
onMounted(() => {
|
||||
@@ -101,11 +103,19 @@ const calendarGroups = computed(() => {
|
||||
return Object.entries(groups)
|
||||
})
|
||||
|
||||
function isEnrollmentLimitError(err: unknown) {
|
||||
return err instanceof Error && err.message.includes('Лимит записей достигнут')
|
||||
}
|
||||
|
||||
async function registerLecture(id: string) {
|
||||
try {
|
||||
await lecturesStore.register(id)
|
||||
addToast?.('Вы записаны на лекцию.', 'success')
|
||||
} catch (err) {
|
||||
if (isEnrollmentLimitError(err)) {
|
||||
enrollmentLimitModalOpen.value = true
|
||||
return
|
||||
}
|
||||
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
|
||||
}
|
||||
}
|
||||
@@ -297,6 +307,8 @@ function isRegistered(id: string) {
|
||||
<button class="btn-primary" @click="filtersOpen = false">Применить</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
|
||||
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
@@ -11,12 +11,15 @@ 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!)
|
||||
|
||||
@@ -60,6 +63,19 @@ onMounted(async () => {
|
||||
])
|
||||
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>
|
||||
@@ -128,7 +144,7 @@ onMounted(async () => {
|
||||
<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>
|
||||
@@ -140,7 +156,7 @@ onMounted(async () => {
|
||||
:key="l.id"
|
||||
:lecture="l"
|
||||
:registered="lectures.registeredIds.includes(l.id)"
|
||||
@register="lectures.register"
|
||||
@register="registerLecture"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -182,6 +198,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
|
||||
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
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 StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
||||
|
||||
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 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 isAttended = computed(() => lecture.value?.status === 'completed')
|
||||
const reviews = computed(() => lecturesStore.reviewsByLecture[lectureId.value] ?? [])
|
||||
|
||||
@@ -24,6 +30,25 @@ onMounted(async () => {
|
||||
await lecturesStore.fetchLecture(lectureId.value)
|
||||
await lecturesStore.fetchReviews(lectureId.value)
|
||||
})
|
||||
|
||||
async function registerLecture() {
|
||||
if (!lecture.value) return
|
||||
if (slotRegistrationDisabled.value) {
|
||||
enrollmentLimitModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await lecturesStore.register(lecture.value.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>
|
||||
@@ -49,7 +74,7 @@ onMounted(async () => {
|
||||
v-if="!isRegistered"
|
||||
class="btn-primary"
|
||||
:disabled="lecture.freeSeats === 0 || lecture.registrationClosed"
|
||||
@click="lecturesStore.register(lecture.id)"
|
||||
@click="registerLecture"
|
||||
>
|
||||
Записаться
|
||||
</button>
|
||||
@@ -107,6 +132,8 @@ onMounted(async () => {
|
||||
<LectureCard v-for="l in similarLectures" :key="l.id" :lecture="l" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user