From e8a4622fa828b84c928f3399cdcfd7832ec4070e Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Sat, 16 May 2026 10:56:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D1=83=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20=D0=BB=D0=B5=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/index.ts | 48 ++- frontend/src/api/types.ts | 14 + .../src/views/admin/AdminLecturesView.vue | 392 +++++++++++++++--- 3 files changed, 395 insertions(+), 59 deletions(-) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d69dcf6..afcee0f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -4,6 +4,7 @@ import type { AuthResponse, CoinTransactionDto, CourseDto, + CreateLectureRequest, LectureDto, LectureQuery, LocationDto, @@ -39,10 +40,18 @@ export const lecturesApi = { return extractItems(payload) }, get: (id: string | number) => apiRequest(`/lectures/${id}`), + create: (payload: CreateLectureRequest) => + apiRequest('/lectures', { + method: 'POST', + body: JSON.stringify(payload), + }), enroll: (id: string | number) => apiRequest(`/lectures/${id}/enroll`, { method: 'POST' }), - unenroll: (id: string | number) => apiRequest(`/lectures/${id}/enroll`, { method: 'DELETE' }), + unenroll: (id: string | number) => + apiRequest(`/lectures/${id}/enroll`, { method: 'DELETE' }), async reviews(id: string | number) { - const payload = await apiRequest | ReviewDto[]>(`/lectures/${id}/reviews`) + const payload = await apiRequest | ReviewDto[]>( + `/lectures/${id}/reviews`, + ) return extractItems(payload) }, } @@ -57,13 +66,15 @@ export const usersApi = { }, stats: (id: string | number) => apiRequest(`/users/${id}/stats`), async enrollments(id: string | number) { - const payload = await apiRequest | LectureDto[] | undefined>(`/users/${id}/enrollments`) + const payload = await apiRequest | LectureDto[] | undefined>( + `/users/${id}/enrollments`, + ) return extractItems(payload) }, async achievements(id: string | number) { - const payload = await apiRequest | UserAchievementDto[] | AchievementDto[]>( - `/users/${id}/achievements`, - ) + const payload = await apiRequest< + PagedResult | UserAchievementDto[] | AchievementDto[] + >(`/users/${id}/achievements`) if (Array.isArray(payload)) return payload return payload.items ?? [] }, @@ -74,21 +85,31 @@ export const usersApi = { return extractItems(payload) }, setRole: (id: string | number, roles: Array<'Student' | 'Teacher' | 'Admin'>) => - apiRequest(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(roles) }), + apiRequest(`/users/${id}/role`, { + method: 'PATCH', + body: JSON.stringify(roles), + }), setActive: (id: string | number, isActive: boolean) => - apiRequest(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }), + apiRequest(`/users/${id}/active`, { + method: 'PATCH', + body: JSON.stringify(isActive), + }), } export const achievementsApi = { async list() { - const payload = await apiRequest | AchievementDto[]>('/achievements') + const payload = await apiRequest | AchievementDto[]>( + '/achievements', + ) return extractItems(payload) }, } export const notificationsApi = { async list() { - const payload = await apiRequest | UserNotificationDto[]>('/notifications') + const payload = await apiRequest | UserNotificationDto[]>( + '/notifications', + ) return extractItems(payload) }, markAllRead: () => apiRequest('/notifications/read-all', { method: 'PATCH' }), @@ -104,12 +125,15 @@ export const reviewsApi = { const payload = await apiRequest | ReviewDto[]>('/reviews/pending') return extractItems(payload) }, - reanalyze: (id: string | number) => apiRequest(`/reviews/${id}/reanalyze`, { method: 'POST' }), + reanalyze: (id: string | number) => + apiRequest(`/reviews/${id}/reanalyze`, { method: 'POST' }), } export const coursesApi = { async list() { - const payload = await apiRequest | CourseDto[]>('/courses', { query: { PageSize: 100 } }) + const payload = await apiRequest | CourseDto[]>('/courses', { + query: { PageSize: 100 }, + }) return extractItems(payload) }, } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1735e21..5ecbf16 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -83,6 +83,20 @@ export interface LectureDto { isEnrolled?: boolean } +export interface CreateLectureRequest { + courseId: number + teacherId?: number | null + locationId?: number | null + title: string + description?: string | null + format: ApiLectureFormat + startsAt: string + endsAt: string + isOpen: boolean + maxEnrollments: number + onlineUrl?: string | null +} + export interface ReviewDto { id: number lectureId: number diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue index df7845a..3d389a7 100644 --- a/frontend/src/views/admin/AdminLecturesView.vue +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -34,7 +34,22 @@ const syncError = ref('') const syncErrorDetails = ref([]) const syncStatus = ref(null) const syncResult = ref(null) -const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined +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: 'Консультация' }, @@ -80,6 +95,7 @@ const tabConfig: Record = { title: 'Лекции', columns: [ { key: 'title', label: 'Название' }, + { key: 'startsAt', label: 'Начало' }, { key: 'teacher', label: 'Преподаватель' }, { key: 'format', label: 'Формат' }, { key: 'status', label: 'Синхронизация', align: 'center' }, @@ -120,9 +136,10 @@ const current = computed(() => { if (activeTab.value === 'lectures') { return { ...config, - rows: lectures.value.map(l => ({ + 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 ? 'Открыта' : 'Закрыта', @@ -132,18 +149,18 @@ const current = computed(() => { if (activeTab.value === 'courses') { return { ...config, - rows: courses.value.map(c => ({ + rows: courses.value.map((c) => ({ id: c.id, title: c.name || 'Без названия', institute: c.isSynced ? 'Синхронизирован' : 'Ручной', - tags: c.tags?.map(tag => `#${tag.name}`).join(' ') || '—', + tags: c.tags?.map((tag) => `#${tag.name}`).join(' ') || '—', })), } } if (activeTab.value === 'rooms') { return { ...config, - rows: locations.value.map(l => ({ + rows: locations.value.map((l) => ({ id: l.id, building: l.building || l.name || '—', room: l.room || '—', @@ -153,7 +170,7 @@ const current = computed(() => { } return { ...config, - rows: tags.value.map(tag => ({ + rows: tags.value.map((tag) => ({ id: tag.id, tag: `#${tag.name}`, category: tag.type, @@ -164,13 +181,14 @@ const current = computed(() => { 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(), - ]) + 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 @@ -179,6 +197,62 @@ async function loadData() { 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() @@ -248,7 +322,12 @@ function extractApiErrorDetails(err: unknown) { const details = err.details if (!details || typeof details !== 'object') return [] - const problem = details as { title?: unknown; detail?: unknown; traceId?: unknown; status?: unknown } + 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}` : '', @@ -266,30 +345,118 @@ onMounted(() => {

Управление лекциями и справочниками

- +
- - - + + +
{{ current.title }}
- +
+ +
+
+
Фиктивная лекция
+
Для ручной проверки отзывов и уведомлений
+
+
+ +
+ + + + +
+ +
+ + + + +
+ + +
+ +
+ Лекция создаётся открытой для записи. Аудитория подставится автоматически, если она уже + есть в справочнике. +
+ +
+ +
+
+
+
Синхронизация расписания
{{ syncMeta }}
- {{ syncStatus?.status ?? 'idle' }} + {{ + syncStatus?.status ?? 'idle' + }}
@@ -298,7 +465,11 @@ onMounted(() => { - +
- Создано: {{ visibleSyncResult.created }}, - обновлено: {{ visibleSyncResult.updated }}, - пропущено: {{ visibleSyncResult.skipped }} + Создано: {{ visibleSyncResult.created }}, обновлено: {{ visibleSyncResult.updated }}, + пропущено: + {{ visibleSyncResult.skipped }}
{{ syncError || visibleSyncResult?.error }} @@ -318,7 +489,9 @@ onMounted(() => {
Подробности ошибки
    -
  • {{ detail }}
  • +
  • + {{ detail }} +
@@ -326,7 +499,12 @@ onMounted(() => { -
@@ -337,17 +515,104 @@ onMounted(() => {