Добавил в админку создание фиктивных лекций
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

This commit is contained in:
2026-05-16 10:56:21 +03:00
parent 3ba6fe940e
commit e8a4622fa8
3 changed files with 395 additions and 59 deletions
+36 -12
View File
@@ -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<LectureDto>(`/lectures/${id}`),
create: (payload: CreateLectureRequest) =>
apiRequest<LectureDto>('/lectures', {
method: 'POST',
body: JSON.stringify(payload),
}),
enroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'POST' }),
unenroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'DELETE' }),
unenroll: (id: string | number) =>
apiRequest<void>(`/lectures/${id}/enroll`, { method: 'DELETE' }),
async reviews(id: string | number) {
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>(`/lectures/${id}/reviews`)
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>(
`/lectures/${id}/reviews`,
)
return extractItems(payload)
},
}
@@ -57,13 +66,15 @@ export const usersApi = {
},
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
async enrollments(id: string | number) {
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(`/users/${id}/enrollments`)
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
`/users/${id}/enrollments`,
)
return extractItems(payload)
},
async achievements(id: string | number) {
const payload = await apiRequest<PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]>(
`/users/${id}/achievements`,
)
const payload = await apiRequest<
PagedResult<UserAchievementDto> | 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<void>(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(roles) }),
apiRequest<void>(`/users/${id}/role`, {
method: 'PATCH',
body: JSON.stringify(roles),
}),
setActive: (id: string | number, isActive: boolean) =>
apiRequest<void>(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }),
apiRequest<void>(`/users/${id}/active`, {
method: 'PATCH',
body: JSON.stringify(isActive),
}),
}
export const achievementsApi = {
async list() {
const payload = await apiRequest<PagedResult<AchievementDto> | AchievementDto[]>('/achievements')
const payload = await apiRequest<PagedResult<AchievementDto> | AchievementDto[]>(
'/achievements',
)
return extractItems(payload)
},
}
export const notificationsApi = {
async list() {
const payload = await apiRequest<PagedResult<UserNotificationDto> | UserNotificationDto[]>('/notifications')
const payload = await apiRequest<PagedResult<UserNotificationDto> | UserNotificationDto[]>(
'/notifications',
)
return extractItems(payload)
},
markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }),
@@ -104,12 +125,15 @@ export const reviewsApi = {
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
return extractItems(payload)
},
reanalyze: (id: string | number) => apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
reanalyze: (id: string | number) =>
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
}
export const coursesApi = {
async list() {
const payload = await apiRequest<PagedResult<CourseDto> | CourseDto[]>('/courses', { query: { PageSize: 100 } })
const payload = await apiRequest<PagedResult<CourseDto> | CourseDto[]>('/courses', {
query: { PageSize: 100 },
})
return extractItems(payload)
},
}
+14
View File
@@ -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
+339 -41
View File
@@ -34,7 +34,22 @@ const syncError = ref('')
const syncErrorDetails = ref<string[]>([])
const syncStatus = ref<SyncStatusDto | null>(null)
const syncResult = ref<SyncResultDto | null>(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<TabKey, TabConfig> = {
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,7 +181,8 @@ const current = computed(() => {
async function loadData() {
loading.value = true
const [lecturesResult, coursesResult, locationsResult, tagsResult, syncStatusResult] = await Promise.allSettled([
const [lecturesResult, coursesResult, locationsResult, tagsResult, syncStatusResult] =
await Promise.allSettled([
lecturesApi.list({ PageSize: 100 }),
coursesApi.list(),
locationsApi.list(),
@@ -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(() => {
<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>
<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 === '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 не вернул записи для выбранного раздела." />
<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>
<span class="sync-status" :class="syncStatus?.status ?? 'idle'">{{
syncStatus?.status ?? 'idle'
}}</span>
</div>
<form class="form" @submit.prevent="runScheduleSync">
@@ -298,7 +465,11 @@ onMounted(() => {
<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" />
<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">
@@ -308,9 +479,9 @@ onMounted(() => {
</div>
<div v-if="visibleSyncResult" class="sync-result">
Создано: {{ visibleSyncResult.created }},
обновлено: {{ visibleSyncResult.updated }},
пропущено: {{ visibleSyncResult.skipped }}
Создано: {{ visibleSyncResult.created }}, обновлено: {{ visibleSyncResult.updated }},
пропущено:
{{ visibleSyncResult.skipped }}
</div>
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
{{ syncError || visibleSyncResult?.error }}
@@ -318,7 +489,9 @@ onMounted(() => {
<details v-if="visibleSyncDetails.length" class="sync-details">
<summary>Подробности ошибки</summary>
<ul>
<li v-for="detail in visibleSyncDetails" :key="detail">{{ detail }}</li>
<li v-for="detail in visibleSyncDetails" :key="detail">
{{ detail }}
</li>
</ul>
</details>
@@ -326,7 +499,12 @@ onMounted(() => {
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
{{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }}
</button>
<button class="btn-secondary" type="button" :disabled="syncingRooms" @click="runRoomsSync">
<button
class="btn-secondary"
type="button"
:disabled="syncingRooms"
@click="runRoomsSync"
>
{{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }}
</button>
</div>
@@ -337,17 +515,104 @@ onMounted(() => {
</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; }
.type-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; }
.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;
@@ -360,11 +625,26 @@ onMounted(() => {
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); }
.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);
@@ -373,9 +653,19 @@ onMounted(() => {
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-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);
@@ -385,6 +675,14 @@ onMounted(() => {
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); }
.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>