Files
UniVerse/frontend/src/views/admin/AdminLecturesView.vue
T
serega404 7f923cd612
Frontend CI / build-and-check (push) Successful in 26s
🚀 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) Successful in 13s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 35s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
Backend CI / build-and-test (pull_request) Successful in 1m6s
Frontend CI / build-and-check (pull_request) Successful in 49s
Frontend Playwright / e2e (pull_request) Successful in 10m53s
feat: обновил типы в DataTable
2026-05-28 20:00:05 +03:00

829 lines
25 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'
import CreateLectureModal from '@/components/admin/CreateLectureModal.vue'
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
type TabConfig = {
title: string
columns: DataTableColumn[]
rows: Record<string, unknown>[]
}
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 isCreateLectureModalOpen = ref(false)
const showAdvancedSyncFilters = ref(false)
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const lockedScheduleTypeIds: ApiScheduleTypeId[] = ['LECT', 'EVENT_OTHER', 'CONS']
const defaultScheduleTypeIds: ApiScheduleTypeId[] = ['LECT']
const scheduleTypeOptions: Array<{ id: ApiScheduleTypeId; label: string }> = [
{ id: 'LECT', label: 'Лекционное занятие' },
{ id: 'EVENT_OTHER', label: 'Прочее' },
{ id: 'CONS', label: 'Консультация' },
{ id: 'MID_CHECK', label: 'Аттестация' },
{ id: 'LAB', label: 'Лабораторное занятие' },
{ id: 'SEMI', label: 'Практическое занятие' },
{ id: 'SELF', label: 'Самостоятельная работа' },
{ id: 'CUR_CHECK', label: 'Текущий контроль' },
]
const syncSchedulePageSize = 900
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)
function parseStringList(value: string) {
return value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function parseNumberList(value: string) {
return parseStringList(value)
.map((item) => Number(item))
.filter((item) => Number.isInteger(item))
}
function toNullableList<T>(values: T[]) {
return values.length ? values : null
}
const syncForm = ref({
roomId: '',
attendeePersonId: '',
courseUnitRealizationId: '',
cycleRealizationId: '',
specialtyCode: '',
learningStartYear: '',
profileName: '',
curriculumId: '',
typeIds: [...defaultScheduleTypeIds],
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 activeAdvancedSyncFilters = computed(() => {
const fields = [
syncForm.value.roomId,
syncForm.value.attendeePersonId,
syncForm.value.courseUnitRealizationId,
syncForm.value.cycleRealizationId,
syncForm.value.specialtyCode,
syncForm.value.profileName,
syncForm.value.curriculumId,
]
const filledTextFields = fields.filter((value) => parseStringList(value).length > 0).length
return filledTextFields + (parseNumberList(syncForm.value.learningStartYear).length ? 1 : 0)
})
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<TabConfig>(() => {
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
}
async function handleLectureCreated(lecture: LectureDto) {
lectures.value = [lecture, ...lectures.value]
activeTab.value = 'lectures'
await loadData()
}
function handleLectureMissingCourse() {
activeTab.value = 'courses'
}
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({
size: syncSchedulePageSize,
roomId: toNullableList(parseStringList(syncForm.value.roomId)),
attendeePersonId: toNullableList(parseStringList(syncForm.value.attendeePersonId)),
courseUnitRealizationId: toNullableList(
parseStringList(syncForm.value.courseUnitRealizationId),
),
cycleRealizationId: toNullableList(parseStringList(syncForm.value.cycleRealizationId)),
specialtyCode: toNullableList(parseStringList(syncForm.value.specialtyCode)),
learningStartYear: toNullableList(parseNumberList(syncForm.value.learningStartYear)),
profileName: toNullableList(parseStringList(syncForm.value.profileName)),
curriculumId: toNullableList(parseStringList(syncForm.value.curriculumId)),
typeId: toNullableList(syncForm.value.typeIds),
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>
<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>
</div>
</div>
<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">
<div class="sync-fields sync-primary-fields">
<label>
<span>Период с</span>
<input v-model="syncForm.timeMin" class="glass-input" type="datetime-local" required />
</label>
<label>
<span>Период по</span>
<input v-model="syncForm.timeMax" class="glass-input" type="datetime-local" required />
</label>
</div>
<div class="type-section">
<div class="field-title">Типы пар</div>
<div class="type-grid">
<label
v-for="type in scheduleTypeOptions"
:key="type.id"
class="type-option"
:class="{ locked: lockedScheduleTypeIds.includes(type.id) }"
>
<input
v-model="syncForm.typeIds"
type="checkbox"
:value="type.id"
:disabled="!lockedScheduleTypeIds.includes(type.id)"
/>
<span>{{ type.label }}</span>
</label>
</div>
</div>
<div class="advanced-sync">
<button
class="advanced-toggle"
type="button"
:aria-expanded="showAdvancedSyncFilters"
@click="showAdvancedSyncFilters = !showAdvancedSyncFilters"
>
<span>
Дополнительные фильтры
<span v-if="activeAdvancedSyncFilters"> · {{ activeAdvancedSyncFilters }}</span>
</span>
<span class="advanced-toggle-icon" :class="{ open: showAdvancedSyncFilters }"></span>
</button>
<div v-show="showAdvancedSyncFilters" class="sync-fields sync-advanced-fields">
<label>
<span>ID аудиторий</span>
<textarea
v-model="syncForm.roomId"
class="glass-input"
placeholder="UUID, UUID"
rows="2"
/>
</label>
<label>
<span>ID участников</span>
<textarea
v-model="syncForm.attendeePersonId"
class="glass-input"
placeholder="UUID, UUID"
rows="2"
/>
</label>
<label>
<span>ID реализаций курсов</span>
<textarea
v-model="syncForm.courseUnitRealizationId"
class="glass-input"
placeholder="UUID, UUID"
rows="2"
/>
</label>
<label>
<span>ID реализаций циклов</span>
<textarea
v-model="syncForm.cycleRealizationId"
class="glass-input"
placeholder="UUID, UUID"
rows="2"
/>
</label>
<label>
<span>Коды специальностей</span>
<input
v-model="syncForm.specialtyCode"
class="glass-input"
placeholder="09.03.04, 01.03.02"
/>
</label>
<label>
<span>Годы начала обучения</span>
<input
v-model="syncForm.learningStartYear"
class="glass-input"
placeholder="2022, 2023"
/>
</label>
<label>
<span>Названия профилей</span>
<textarea
v-model="syncForm.profileName"
class="glass-input"
placeholder="Название профиля"
rows="2"
/>
</label>
<label>
<span>ID учебных планов</span>
<textarea
v-model="syncForm.curriculumId"
class="glass-input"
placeholder="UUID, UUID"
rows="2"
/>
</label>
</div>
</div>
<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>
<div
v-if="visibleSyncResult"
class="sync-result"
:class="{ failed: Boolean(syncError || visibleSyncResult.error) }"
>
<template v-if="syncError || visibleSyncResult.error">
{{ syncError || visibleSyncResult.error }}
</template>
<template v-else>
Создано: {{ visibleSyncResult.created }} / обновлено: {{ visibleSyncResult.updated }} /
пропущено: {{ visibleSyncResult.skipped }}
</template>
</div>
<div v-else-if="syncError" class="sync-result failed">
{{ syncError }}
</div>
<details v-if="visibleSyncDetails.length" class="sync-details">
<summary>Подробности ошибки</summary>
<ul>
<li v-for="detail in visibleSyncDetails" :key="detail">
{{ detail }}
</li>
</ul>
</details>
</form>
</GlassCard>
<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>
</div>
<CreateLectureModal
v-model="isCreateLectureModalOpen"
:courses="courses"
:locations="locations"
:loading="loading"
@created="handleLectureCreated"
@missing-course="handleLectureMissingCourse"
/>
</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;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
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: 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);
}
.sync-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.sync-primary-fields {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: end;
}
.sync-fields label {
display: flex;
flex-direction: column;
gap: 6px;
}
.sync-fields textarea {
min-height: 68px;
resize: vertical;
}
.type-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.advanced-sync {
display: flex;
flex-direction: column;
gap: 10px;
}
.advanced-toggle {
width: 100%;
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid var(--color-slate-500-a20);
border-radius: var(--radius-sm);
padding: 8px 12px;
background: var(--color-white-a90);
color: var(--color-text);
font-size: 13px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 14px var(--color-black-a04);
}
.advanced-toggle:hover {
border-color: var(--color-primary-a30);
background: var(--color-white-a96);
}
.advanced-toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.advanced-toggle[aria-expanded='true'] {
border-color: var(--color-primary-a30);
background: var(--color-primary-a08);
color: var(--color-primary-border);
}
.advanced-toggle-icon {
line-height: 1;
transition: transform 0.16s ease;
}
.advanced-toggle-icon.open {
transform: rotate(180deg);
}
.sync-advanced-fields {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
border: 1px solid var(--color-slate-500-a20);
border-radius: var(--radius-sm);
padding: 12px;
background: var(--color-white-a82);
box-shadow:
0 8px 22px var(--color-black-a04),
inset 0 1px 0 var(--color-white-a90);
}
.sync-advanced-fields .glass-input {
background: var(--color-white-a96);
border-color: var(--color-slate-500-a20);
}
.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.type-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.type-option {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
padding: 8px 14px;
border: 1px solid var(--color-slate-500-a20);
border-radius: 10px;
background: var(--color-white-a86);
color: var(--color-text);
cursor: default;
transition:
background 0.16s ease,
border-color 0.16s ease,
color 0.16s ease;
}
.type-option input {
position: absolute;
inset: 0;
opacity: 0;
cursor: default;
}
.type-option.locked,
.type-option.locked input {
cursor: pointer;
}
.type-option span {
font-size: 13px;
line-height: 1.2;
}
.type-option:not(.locked) {
border-color: var(--color-slate-500-a10);
background: var(--color-white-a50);
color: var(--color-text-secondary);
opacity: 0.62;
}
.type-option:has(input:checked) {
border-color: var(--color-primary-a30);
background: var(--color-primary-a18);
color: var(--color-primary-dark);
font-weight: 600;
}
.type-option.locked:hover {
border-color: var(--color-primary-a30);
background: var(--color-primary-a18);
}
.type-option:not(.locked):hover {
border-color: var(--color-slate-500-a10);
background: var(--color-white-a50);
}
.type-option:has(input:checked):hover,
.type-option:has(input:checked):active {
background: var(--color-primary-a25);
}
.sync-meta {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 4px;
}
.sync-result {
border: 1px solid var(--color-primary-light);
border-radius: var(--radius-sm);
padding: 9px 12px;
background: var(--color-success-bg-a90);
font-size: 13px;
color: var(--color-success-text);
}
.sync-result.failed {
border-color: var(--color-danger-light);
background: var(--color-danger-bg-a90);
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>