feat: улучшил создание лекций

This commit is contained in:
2026-05-24 21:32:22 +03:00
parent 99d25adbb1
commit e56b577772
3 changed files with 360 additions and 177 deletions
@@ -0,0 +1,328 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { lecturesApi } from '@/api'
import { ApiError } from '@/api/client'
import type { CourseDto, LectureDto, LocationDto } from '@/api/types'
import { useAuthStore } from '@/stores/auth'
import ModalDialog from '@/components/ui/ModalDialog.vue'
const props = defineProps<{
modelValue: boolean
courses: CourseDto[]
locations: LocationDto[]
loading?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: [lecture: LectureDto]
missingCourse: []
}>()
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const auth = useAuthStore()
const creating = ref(false)
const timeOptions = [
{ label: 'Через 3ч 5м', minutes: 185 },
{ label: 'Через 1ч 5м', minutes: 65 },
{ label: 'Через 5м', minutes: 5 },
]
const form = ref({
title: 'Лекция для проверки уведомлений',
offsetMinutes: 185,
durationMinutes: 60,
maxEnrollments: 100,
courseId: null as number | null,
teacherId: auth.user?.id ?? null,
locationId: null as number | null,
})
const locationSearch = ref('')
const currentTeacherLabel = computed(() => {
if (!auth.user) return 'Текущий пользователь не определён'
return `${auth.user.name} · ID ${auth.user.id}`
})
const filteredLocations = computed(() => {
const search = locationSearch.value.trim().toLowerCase()
if (!search) return props.locations
return props.locations.filter((location) => formatLocation(location).toLowerCase().includes(search))
})
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
watch(
() => props.modelValue,
(isOpenNow) => {
if (isOpenNow && !form.value.teacherId) form.value.teacherId = auth.user?.id ?? null
},
)
function getCourseId() {
const selectedCourseId = form.value.courseId
if (selectedCourseId && props.courses.some((course) => course.id === selectedCourseId))
return selectedCourseId
return props.courses[0]?.id ?? null
}
function formatLocation(location: LocationDto) {
return (
[location.building, location.room ? `ауд. ${location.room}` : '', location.name]
.filter(Boolean)
.join(', ') || `Аудитория #${location.id}`
)
}
function getLocation() {
const selectedLocationId = form.value.locationId
if (selectedLocationId) {
const selectedLocation = props.locations.find((location) => location.id === selectedLocationId)
if (selectedLocation) return selectedLocation
}
if (locationSearch.value.trim()) return filteredLocations.value[0] ?? null
return props.locations[0] ?? null
}
function extractApiErrorDetails(err: unknown) {
if (!(err instanceof ApiError)) return []
const details = err.details
if (!details || typeof details !== 'object') return []
const problem = details as {
title?: unknown
detail?: unknown
traceId?: unknown
status?: unknown
}
return [
typeof problem.title === 'string' ? `title=${problem.title}` : '',
typeof problem.status === 'number' ? `status=${problem.status}` : '',
typeof problem.detail === 'string' ? `detail=${problem.detail}` : '',
typeof problem.traceId === 'string' ? `traceId=${problem.traceId}` : '',
].filter(Boolean)
}
async function createLecture() {
const courseId = getCourseId()
const title = form.value.title.trim()
const offsetMinutes = Math.max(1, Number(form.value.offsetMinutes) || 1)
const durationMinutes = Math.max(5, Number(form.value.durationMinutes) || 60)
const maxEnrollments = Math.max(1, Number(form.value.maxEnrollments) || 100)
const teacherId = form.value.teacherId ? Number(form.value.teacherId) : null
if (!courseId) {
addToast?.('Нужен хотя бы один курс, чтобы создать лекцию.', 'error')
emit('missingCourse')
isOpen.value = false
return
}
if (!title) {
addToast?.('Укажите название лекции.', 'error')
return
}
creating.value = true
try {
const location = getLocation()
const startsAt = new Date(Date.now() + offsetMinutes * 60_000)
const endsAt = new Date(startsAt.getTime() + durationMinutes * 60_000)
const createdLecture = await lecturesApi.create({
courseId,
teacherId,
locationId: location?.id ?? null,
title,
description:
'Лекция создана из админки для проверки системы отзывов и напоминаний о начале лекции.',
format: location ? 'Offline' : 'Online',
startsAt: startsAt.toISOString(),
endsAt: endsAt.toISOString(),
isOpen: true,
maxEnrollments,
onlineUrl: null,
})
emit('created', createdLecture)
isOpen.value = false
addToast?.(`Лекция создана: ${startsAt.toLocaleString('ru-RU')}.`, 'success')
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось создать лекцию.'
const details = extractApiErrorDetails(err)
addToast?.(details.length ? `${message}: ${details.join('; ')}` : message, 'error')
} finally {
creating.value = false
}
}
</script>
<template>
<ModalDialog
v-model="isOpen"
title="Создать лекцию"
description="Для ручной проверки отзывов и уведомлений"
icon="calendar-event"
size="md"
>
<form class="create-lecture-form" @submit.prevent="createLecture">
<label>Название</label>
<input v-model="form.title" class="glass-input" placeholder="Например, тест уведомлений" />
<label>Старт</label>
<div class="start-controls">
<div class="time-options">
<button
v-for="option in timeOptions"
:key="option.minutes"
class="time-option"
:class="{ active: form.offsetMinutes === option.minutes }"
type="button"
@click="form.offsetMinutes = option.minutes"
>
{{ option.label }}
</button>
</div>
<label class="minutes-field">
<span>Минут до старта</span>
<input v-model.number="form.offsetMinutes" class="glass-input" min="1" type="number" />
</label>
</div>
<label>Курс</label>
<select v-model="form.courseId" class="glass-input">
<option :value="null">Первый доступный курс</option>
<option v-for="course in courses" :key="course.id" :value="course.id">
{{ course.name || `Курс #${course.id}` }}
</option>
</select>
<label>
Преподаватель
<span class="field-hint">{{ currentTeacherLabel }}</span>
</label>
<input v-model.number="form.teacherId" class="glass-input" min="1" type="number" />
<label>Аудитория</label>
<input
v-model="locationSearch"
class="glass-input"
placeholder="Поиск по корпусу, аудитории или названию"
/>
<select v-model="form.locationId" class="glass-input">
<option :value="null">
{{
filteredLocations[0]
? `Первая найденная: ${formatLocation(filteredLocations[0])}`
: 'Без аудитории, онлайн'
}}
</option>
<option v-for="location in filteredLocations" :key="location.id" :value="location.id">
{{ formatLocation(location) }}
</option>
</select>
<div class="create-lecture-fields">
<label>
<span>Длительность, мин</span>
<input v-model.number="form.durationMinutes" class="glass-input" min="5" type="number" />
</label>
<label>
<span>Мест</span>
<input v-model.number="form.maxEnrollments" class="glass-input" min="1" type="number" />
</label>
</div>
<div class="create-lecture-note">
Лекция создаётся открытой для записи. Аудитория подставится автоматически, если она уже есть
в справочнике.
</div>
<div class="create-lecture-actions">
<button class="btn-secondary" type="button" :disabled="creating" @click="isOpen = false">
Отмена
</button>
<button class="btn-primary" type="submit" :disabled="creating || loading">
{{ creating ? 'Создаём...' : 'Создать' }}
</button>
</div>
</form>
</ModalDialog>
</template>
<style scoped>
.create-lecture-form {
display: flex;
flex-direction: column;
gap: 10px;
color: var(--color-text);
}
.create-lecture-form label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.field-hint {
display: block;
margin-top: 3px;
font-weight: 500;
color: var(--color-text-muted);
}
.start-controls {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.time-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.minutes-field {
flex: 1 1 160px;
display: flex;
flex-direction: column;
gap: 6px;
}
.time-option {
border: 1px solid var(--color-border-glass);
border-radius: 999px;
padding: 8px 12px;
background: var(--color-white-a72);
color: var(--color-text-secondary);
cursor: pointer;
}
.time-option.active {
background: var(--color-primary-a18);
border-color: var(--color-primary-light);
color: var(--color-primary-dark);
font-weight: 600;
}
.create-lecture-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.create-lecture-fields label {
display: flex;
flex-direction: column;
gap: 6px;
}
.create-lecture-note {
font-size: 12px;
color: var(--color-text-secondary);
line-height: 1.4;
}
.create-lecture-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
margin-top: 4px;
}
</style>
+27 -172
View File
@@ -14,6 +14,7 @@ import type {
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue' import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue' import EmptyState from '@/components/ui/EmptyState.vue'
import CreateLectureModal from '@/components/admin/CreateLectureModal.vue'
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags' type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
type TabConfig = { type TabConfig = {
@@ -34,19 +35,7 @@ const syncError = ref('')
const syncErrorDetails = ref<string[]>([]) const syncErrorDetails = ref<string[]>([])
const syncStatus = ref<SyncStatusDto | null>(null) const syncStatus = ref<SyncStatusDto | null>(null)
const syncResult = ref<SyncResultDto | null>(null) const syncResult = ref<SyncResultDto | null>(null)
const creatingDummyLecture = ref(false) const isCreateLectureModalOpen = ref(false)
const dummyTimeOptions = [
{ label: 'Через 3ч 5м', minutes: 185 },
{ label: 'Через 1ч 5м', minutes: 65 },
{ label: 'Через 5м', minutes: 5 },
]
const dummyLectureForm = ref({
title: 'Фиктивная лекция для проверки уведомлений',
offsetMinutes: 185,
durationMinutes: 60,
maxEnrollments: 100,
courseId: null as number | null,
})
const addToast = inject('addToast') as const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void) | ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined | undefined
@@ -197,60 +186,14 @@ async function loadData() {
loading.value = false loading.value = false
} }
function getDummyLectureCourseId() { async function handleLectureCreated(lecture: LectureDto) {
const selectedCourseId = dummyLectureForm.value.courseId lectures.value = [lecture, ...lectures.value]
if (selectedCourseId && courses.value.some((course) => course.id === selectedCourseId)) activeTab.value = 'lectures'
return selectedCourseId await loadData()
return courses.value[0]?.id ?? null
} }
async function createDummyLecture() { function handleLectureMissingCourse() {
const courseId = getDummyLectureCourseId()
const title = dummyLectureForm.value.title.trim()
const durationMinutes = Math.max(5, Number(dummyLectureForm.value.durationMinutes) || 60)
const maxEnrollments = Math.max(1, Number(dummyLectureForm.value.maxEnrollments) || 100)
if (!courseId) {
addToast?.('Нужен хотя бы один курс, чтобы создать фиктивную лекцию.', 'error')
activeTab.value = 'courses' activeTab.value = 'courses'
return
}
if (!title) {
addToast?.('Укажите название фиктивной лекции.', 'error')
return
}
creatingDummyLecture.value = true
try {
const location = locations.value[0]
const startsAt = new Date(Date.now() + dummyLectureForm.value.offsetMinutes * 60_000)
const endsAt = new Date(startsAt.getTime() + durationMinutes * 60_000)
const createdLecture = await lecturesApi.create({
courseId,
teacherId: null,
locationId: location?.id ?? null,
title,
description:
'Фиктивная лекция создана из админки для проверки системы отзывов и напоминаний о начале лекции.',
format: location ? 'Offline' : 'Online',
startsAt: startsAt.toISOString(),
endsAt: endsAt.toISOString(),
isOpen: true,
maxEnrollments,
onlineUrl: null,
})
lectures.value = [createdLecture, ...lectures.value]
activeTab.value = 'lectures'
addToast?.(`Фиктивная лекция создана: ${startsAt.toLocaleString('ru-RU')}.`, 'success')
await loadData()
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось создать фиктивную лекцию.'
const details = extractApiErrorDetails(err)
addToast?.(details.length ? `${message}: ${details.join('; ')}` : message, 'error')
} finally {
creatingDummyLecture.value = false
}
} }
async function refreshSyncStatus() { async function refreshSyncStatus() {
@@ -345,10 +288,15 @@ onMounted(() => {
<div class="admin-lectures page-content"> <div class="admin-lectures page-content">
<div class="header"> <div class="header">
<h1 class="page-title">Управление лекциями и справочниками</h1> <h1 class="page-title">Управление лекциями и справочниками</h1>
<div class="header-actions">
<button class="btn-primary" type="button" @click="isCreateLectureModalOpen = true">
Создать лекцию
</button>
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData"> <button class="btn-secondary" type="button" :disabled="loading" @click="loadData">
Обновить Обновить
</button> </button>
</div> </div>
</div>
<div class="tabs"> <div class="tabs">
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'"> <button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">
@@ -374,80 +322,6 @@ onMounted(() => {
<DataTable :columns="current.columns" :rows="current.rows" /> <DataTable :columns="current.columns" :rows="current.rows" />
</GlassCard> </GlassCard>
<GlassCard>
<div class="section-heading">
<div>
<div class="section-title">Фиктивная лекция</div>
<div class="sync-meta">Для ручной проверки отзывов и уведомлений</div>
</div>
</div>
<form class="form" @submit.prevent="createDummyLecture">
<label>Название</label>
<input
v-model="dummyLectureForm.title"
class="glass-input"
placeholder="Например, тест уведомлений"
/>
<label>Старт</label>
<div class="time-options">
<button
v-for="option in dummyTimeOptions"
:key="option.minutes"
class="time-option"
:class="{
active: dummyLectureForm.offsetMinutes === option.minutes,
}"
type="button"
@click="dummyLectureForm.offsetMinutes = option.minutes"
>
{{ option.label }}
</button>
</div>
<label>Курс</label>
<select v-model="dummyLectureForm.courseId" class="glass-input">
<option :value="null">Первый доступный курс</option>
<option v-for="course in courses" :key="course.id" :value="course.id">
{{ course.name || `Курс #${course.id}` }}
</option>
</select>
<div class="dummy-fields">
<label>
<span>Длительность, мин</span>
<input
v-model.number="dummyLectureForm.durationMinutes"
class="glass-input"
min="5"
type="number"
/>
</label>
<label>
<span>Мест</span>
<input
v-model.number="dummyLectureForm.maxEnrollments"
class="glass-input"
min="1"
type="number"
/>
</label>
</div>
<div class="dummy-note">
Лекция создаётся открытой для записи. Аудитория подставится автоматически, если она уже
есть в справочнике.
</div>
<div class="form-actions">
<button class="btn-primary" type="submit" :disabled="creatingDummyLecture || loading">
{{ creatingDummyLecture ? 'Создаём...' : 'Создать фиктивную лекцию' }}
</button>
</div>
</form>
</GlassCard>
<GlassCard> <GlassCard>
<div class="section-heading"> <div class="section-heading">
<div> <div>
@@ -511,6 +385,15 @@ onMounted(() => {
</form> </form>
</GlassCard> </GlassCard>
</div> </div>
<CreateLectureModal
v-model="isCreateLectureModalOpen"
:courses="courses"
:locations="locations"
:loading="loading"
@created="handleLectureCreated"
@missing-course="handleLectureMissingCourse"
/>
</div> </div>
</template> </template>
@@ -527,6 +410,12 @@ onMounted(() => {
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.tabs { .tabs {
display: inline-flex; display: inline-flex;
width: fit-content; width: fit-content;
@@ -574,40 +463,6 @@ onMounted(() => {
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.time-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.time-option {
border: 1px solid var(--color-border-glass);
border-radius: 999px;
padding: 8px 12px;
background: var(--color-white-a72);
color: var(--color-text-secondary);
cursor: pointer;
}
.time-option.active {
background: var(--color-primary-a18);
border-color: var(--color-primary-light);
color: var(--color-primary-dark);
font-weight: 600;
}
.dummy-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.dummy-fields label {
display: flex;
flex-direction: column;
gap: 6px;
}
.dummy-note {
font-size: 12px;
color: var(--color-text-secondary);
line-height: 1.4;
}
.type-grid { .type-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -49,7 +49,7 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<div class="section-title">Заметность за пределами направления</div> <div class="section-title">Заметность за пределами направления</div>
<div class="visibility"> <div class="visibility">
<div class="visibility-meta"> <div class="visibility-meta">
{{ visibility }}% студентов из других институтов Цель 50% {{ visibility }}% студентов из других институтов
</div> </div>
<ProgressBar :value="visibility" :max="100" /> <ProgressBar :value="visibility" :max="100" />
</div> </div>