Files
UniVerse/frontend/src/views/admin/AdminLecturesView.vue
T
serega404 e8a4622fa8
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 31s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
Добавил в админку создание фиктивных лекций
2026-05-16 10:56:21 +03:00

689 lines
21 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 { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
import { ApiError } from '@/api/client'
import type {
ApiScheduleTypeId,
CourseDto,
LectureDto,
LocationDto,
SyncResultDto,
SyncStatusDto,
TagDto,
} from '@/api/types'
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
type TabConfig = {
title: string
columns: Array<{ key: string; label: string; align?: string }>
rows: Record<string, any>[]
}
const activeTab = ref<TabKey>('lectures')
const lectures = ref<LectureDto[]>([])
const courses = ref<CourseDto[]>([])
const locations = ref<LocationDto[]>([])
const tags = ref<TagDto[]>([])
const loading = ref(false)
const syncingSchedule = ref(false)
const syncingRooms = ref(false)
const syncError = ref('')
const syncErrorDetails = ref<string[]>([])
const syncStatus = ref<SyncStatusDto | null>(null)
const syncResult = ref<SyncResultDto | null>(null)
const creatingDummyLecture = 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
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const scheduleTypeOptions: Array<{ id: ApiScheduleTypeId; label: string }> = [
{ id: 'MID_CHECK', label: 'Аттестация' },
{ id: 'CONS', label: 'Консультация' },
{ id: 'LAB', label: 'Лабораторное занятие' },
{ id: 'LECT', label: 'Лекционное занятие' },
{ id: 'SEMI', label: 'Практическое занятие' },
{ id: 'EVENT_OTHER', label: 'Прочее' },
{ id: 'SELF', label: 'Самостоятельная работа' },
{ id: 'CUR_CHECK', label: 'Текущий контроль' },
]
function toInputDateTime(date: Date) {
const offsetMs = date.getTimezoneOffset() * 60000
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
}
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const inTwoWeeks = new Date(todayStart)
inTwoWeeks.setDate(inTwoWeeks.getDate() + 14)
inTwoWeeks.setHours(23, 59, 0, 0)
const syncForm = ref({
specialtyCode: '',
typeIds: [] as ApiScheduleTypeId[],
timeMin: toInputDateTime(todayStart),
timeMax: toInputDateTime(inTwoWeeks),
})
const syncMeta = computed(() => {
if (!syncStatus.value?.lastSyncAt) return 'Синхронизация ещё не выполнялась'
return `Последняя: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
})
const visibleSyncResult = computed(() => syncResult.value ?? syncStatus.value?.lastResult ?? null)
const visibleSyncDetails = computed(() => {
if (syncErrorDetails.value.length) return syncErrorDetails.value
return visibleSyncResult.value?.details ?? []
})
const tabConfig: Record<TabKey, TabConfig> = {
lectures: {
title: 'Лекции',
columns: [
{ key: 'title', label: 'Название' },
{ key: 'startsAt', label: 'Начало' },
{ key: 'teacher', label: 'Преподаватель' },
{ key: 'format', label: 'Формат' },
{ key: 'status', label: 'Синхронизация', align: 'center' },
],
rows: [],
},
courses: {
title: 'Курсы',
columns: [
{ key: 'title', label: 'Курс' },
{ key: 'institute', label: 'Институт' },
{ key: 'tags', label: 'Теги' },
],
rows: [],
},
rooms: {
title: 'Аудитории',
columns: [
{ key: 'building', label: 'Корпус' },
{ key: 'room', label: 'Аудитория' },
{ key: 'capacity', label: 'Вместимость', align: 'center' },
],
rows: [],
},
tags: {
title: 'Теги',
columns: [
{ key: 'tag', label: 'Тег' },
{ key: 'category', label: 'Категория' },
{ key: 'linked', label: 'Привязки', align: 'center' },
],
rows: [],
},
}
const current = computed(() => {
const config = tabConfig[activeTab.value]
if (activeTab.value === 'lectures') {
return {
...config,
rows: lectures.value.map((l) => ({
id: l.id,
title: l.title || l.courseName || 'Без названия',
startsAt: new Date(l.startsAt).toLocaleString('ru-RU'),
teacher: l.teacherName || 'Не назначен',
format: l.format === 'Online' ? 'Онлайн' : 'Офлайн',
status: l.isOpen ? 'Открыта' : 'Закрыта',
})),
}
}
if (activeTab.value === 'courses') {
return {
...config,
rows: courses.value.map((c) => ({
id: c.id,
title: c.name || 'Без названия',
institute: c.isSynced ? 'Синхронизирован' : 'Ручной',
tags: c.tags?.map((tag) => `#${tag.name}`).join(' ') || '—',
})),
}
}
if (activeTab.value === 'rooms') {
return {
...config,
rows: locations.value.map((l) => ({
id: l.id,
building: l.building || l.name || '—',
room: l.room || '—',
capacity: '—',
})),
}
}
return {
...config,
rows: tags.value.map((tag) => ({
id: tag.id,
tag: `#${tag.name}`,
category: tag.type,
linked: '—',
})),
}
})
async function loadData() {
loading.value = true
const [lecturesResult, coursesResult, locationsResult, tagsResult, syncStatusResult] =
await Promise.allSettled([
lecturesApi.list({ PageSize: 100 }),
coursesApi.list(),
locationsApi.list(),
tagsApi.list(),
syncApi.status(),
])
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
if (coursesResult.status === 'fulfilled') courses.value = coursesResult.value
if (locationsResult.status === 'fulfilled') locations.value = locationsResult.value
if (tagsResult.status === 'fulfilled') tags.value = tagsResult.value
if (syncStatusResult.status === 'fulfilled') syncStatus.value = syncStatusResult.value
loading.value = false
}
function getDummyLectureCourseId() {
const selectedCourseId = dummyLectureForm.value.courseId
if (selectedCourseId && courses.value.some((course) => course.id === selectedCourseId))
return selectedCourseId
return courses.value[0]?.id ?? null
}
async function createDummyLecture() {
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'
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() {
try {
syncStatus.value = await syncApi.status()
} catch {
// The table refresh is more important than the status badge here.
}
}
async function runScheduleSync() {
syncingSchedule.value = true
syncError.value = ''
syncErrorDetails.value = []
syncResult.value = null
try {
syncResult.value = await syncApi.schedule({
specialtyCode: syncForm.value.specialtyCode.trim() || null,
typeId: syncForm.value.typeIds.length ? syncForm.value.typeIds : null,
timeMin: syncForm.value.timeMin ? new Date(syncForm.value.timeMin).toISOString() : null,
timeMax: syncForm.value.timeMax ? new Date(syncForm.value.timeMax).toISOString() : null,
})
if (syncResult.value.error) {
syncError.value = syncResult.value.error
syncErrorDetails.value = syncResult.value.details ?? []
addToast?.('Синхронизация завершилась с ошибкой.', 'error')
} else {
addToast?.('Расписание синхронизировано.', 'success')
}
await loadData()
} catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
syncErrorDetails.value = extractApiErrorDetails(err)
addToast?.(syncError.value, 'error')
await refreshSyncStatus()
} finally {
syncingSchedule.value = false
}
}
async function runRoomsSync() {
syncingRooms.value = true
syncError.value = ''
syncErrorDetails.value = []
syncResult.value = null
try {
syncResult.value = await syncApi.rooms()
if (syncResult.value.error) {
syncError.value = syncResult.value.error
syncErrorDetails.value = syncResult.value.details ?? []
addToast?.('Синхронизация аудиторий завершилась с ошибкой.', 'error')
} else {
addToast?.('Аудитории синхронизированы.', 'success')
}
await loadData()
} catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.'
syncErrorDetails.value = extractApiErrorDetails(err)
addToast?.(syncError.value, 'error')
} finally {
syncingRooms.value = false
}
}
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)
}
onMounted(() => {
void loadData()
})
</script>
<template>
<div class="admin-lectures page-content">
<div class="header">
<h1 class="page-title">Управление лекциями и справочниками</h1>
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData">
Обновить
</button>
</div>
<div class="tabs">
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">
Лекции
</button>
<button :class="{ active: activeTab === 'courses' }" @click="activeTab = 'courses'">
Курсы
</button>
<button :class="{ active: activeTab === 'rooms' }" @click="activeTab = 'rooms'">
Аудитории
</button>
<button :class="{ active: activeTab === 'tags' }" @click="activeTab = 'tags'">Теги</button>
</div>
<div class="grid">
<GlassCard>
<div class="section-title">{{ current.title }}</div>
<EmptyState
v-if="!current.rows.length && !loading"
title="Данных пока нет"
subtitle="Backend не вернул записи для выбранного раздела."
/>
<DataTable :columns="current.columns" :rows="current.rows" />
</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>
<div class="section-heading">
<div>
<div class="section-title">Синхронизация расписания</div>
<div class="sync-meta">{{ syncMeta }}</div>
</div>
<span class="sync-status" :class="syncStatus?.status ?? 'idle'">{{
syncStatus?.status ?? 'idle'
}}</span>
</div>
<form class="form" @submit.prevent="runScheduleSync">
<label>Период с</label>
<input v-model="syncForm.timeMin" class="glass-input" type="datetime-local" />
<label>Период по</label>
<input v-model="syncForm.timeMax" class="glass-input" type="datetime-local" />
<label>Код специальности</label>
<input
v-model="syncForm.specialtyCode"
class="glass-input"
placeholder="Например, 09.03.04"
/>
<label>Типы пар</label>
<div class="type-grid">
<label v-for="type in scheduleTypeOptions" :key="type.id" class="type-option">
<input v-model="syncForm.typeIds" type="checkbox" :value="type.id" />
<span>{{ type.label }}</span>
</label>
</div>
<div v-if="visibleSyncResult" class="sync-result">
Создано: {{ visibleSyncResult.created }}, обновлено: {{ visibleSyncResult.updated }},
пропущено:
{{ visibleSyncResult.skipped }}
</div>
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
{{ syncError || visibleSyncResult?.error }}
</div>
<details v-if="visibleSyncDetails.length" class="sync-details">
<summary>Подробности ошибки</summary>
<ul>
<li v-for="detail in visibleSyncDetails" :key="detail">
{{ detail }}
</li>
</ul>
</details>
<div class="form-actions">
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
{{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }}
</button>
<button
class="btn-secondary"
type="button"
:disabled="syncingRooms"
@click="runRoomsSync"
>
{{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }}
</button>
</div>
</form>
</GlassCard>
</div>
</div>
</template>
<style scoped>
.admin-lectures {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.tabs {
display: inline-flex;
width: fit-content;
border: 1px solid var(--color-border-glass);
border-radius: 12px;
overflow: hidden;
}
.tabs button {
background: var(--color-white-a70);
border: none;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
color: var(--color-text-secondary);
}
.tabs button.active {
background: var(--color-primary-a18);
color: var(--color-primary-dark);
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.form {
display: flex;
flex-direction: column;
gap: 10px;
}
.form label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.form-actions {
display: flex;
gap: 10px;
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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.type-option {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-sm);
background: var(--color-white-a72);
color: var(--color-text);
cursor: pointer;
}
.type-option input {
flex: 0 0 auto;
}
.type-option span {
font-size: 13px;
line-height: 1.2;
}
.sync-meta {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 4px;
}
.sync-result {
font-size: 13px;
color: var(--color-text-secondary);
}
.sync-error {
font-size: 13px;
color: var(--color-error);
}
.sync-details {
border: 1px solid var(--color-error-a24);
border-radius: var(--radius-sm);
padding: 8px 10px;
background: var(--color-danger-bg-a68);
color: var(--color-text-secondary);
font-size: 12px;
}
.sync-details summary {
cursor: pointer;
color: var(--color-error);
font-weight: 600;
}
.sync-details ul {
margin: 8px 0 0;
padding-left: 18px;
overflow-wrap: anywhere;
}
.sync-details li + li {
margin-top: 4px;
}
.sync-status {
flex: 0 0 auto;
border: 1px solid var(--color-border-glass);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
color: var(--color-text-secondary);
background: var(--color-white-a72);
}
.sync-status.completed {
color: var(--color-success-text);
background: var(--color-success-bg-a90);
border-color: var(--color-primary-light);
}
.sync-status.failed {
color: var(--color-danger-text);
background: var(--color-danger-bg-a90);
border-color: var(--color-danger-light);
}
</style>