Добавил в админку создание фиктивных лекций
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
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:
+36
-12
@@ -4,6 +4,7 @@ import type {
|
|||||||
AuthResponse,
|
AuthResponse,
|
||||||
CoinTransactionDto,
|
CoinTransactionDto,
|
||||||
CourseDto,
|
CourseDto,
|
||||||
|
CreateLectureRequest,
|
||||||
LectureDto,
|
LectureDto,
|
||||||
LectureQuery,
|
LectureQuery,
|
||||||
LocationDto,
|
LocationDto,
|
||||||
@@ -39,10 +40,18 @@ export const lecturesApi = {
|
|||||||
return extractItems(payload)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
get: (id: string | number) => apiRequest<LectureDto>(`/lectures/${id}`),
|
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' }),
|
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) {
|
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)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -57,13 +66,15 @@ export const usersApi = {
|
|||||||
},
|
},
|
||||||
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
|
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
|
||||||
async enrollments(id: string | number) {
|
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)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
async achievements(id: string | number) {
|
async achievements(id: string | number) {
|
||||||
const payload = await apiRequest<PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]>(
|
const payload = await apiRequest<
|
||||||
`/users/${id}/achievements`,
|
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
|
||||||
)
|
>(`/users/${id}/achievements`)
|
||||||
if (Array.isArray(payload)) return payload
|
if (Array.isArray(payload)) return payload
|
||||||
return payload.items ?? []
|
return payload.items ?? []
|
||||||
},
|
},
|
||||||
@@ -74,21 +85,31 @@ export const usersApi = {
|
|||||||
return extractItems(payload)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
setRole: (id: string | number, roles: Array<'Student' | 'Teacher' | 'Admin'>) =>
|
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) =>
|
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 = {
|
export const achievementsApi = {
|
||||||
async list() {
|
async list() {
|
||||||
const payload = await apiRequest<PagedResult<AchievementDto> | AchievementDto[]>('/achievements')
|
const payload = await apiRequest<PagedResult<AchievementDto> | AchievementDto[]>(
|
||||||
|
'/achievements',
|
||||||
|
)
|
||||||
return extractItems(payload)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notificationsApi = {
|
export const notificationsApi = {
|
||||||
async list() {
|
async list() {
|
||||||
const payload = await apiRequest<PagedResult<UserNotificationDto> | UserNotificationDto[]>('/notifications')
|
const payload = await apiRequest<PagedResult<UserNotificationDto> | UserNotificationDto[]>(
|
||||||
|
'/notifications',
|
||||||
|
)
|
||||||
return extractItems(payload)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }),
|
markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }),
|
||||||
@@ -104,12 +125,15 @@ export const reviewsApi = {
|
|||||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
|
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
|
||||||
return extractItems(payload)
|
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 = {
|
export const coursesApi = {
|
||||||
async list() {
|
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)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ export interface LectureDto {
|
|||||||
isEnrolled?: boolean
|
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 {
|
export interface ReviewDto {
|
||||||
id: number
|
id: number
|
||||||
lectureId: number
|
lectureId: number
|
||||||
|
|||||||
@@ -34,7 +34,22 @@ 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 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 }> = [
|
const scheduleTypeOptions: Array<{ id: ApiScheduleTypeId; label: string }> = [
|
||||||
{ id: 'MID_CHECK', label: 'Аттестация' },
|
{ id: 'MID_CHECK', label: 'Аттестация' },
|
||||||
{ id: 'CONS', label: 'Консультация' },
|
{ id: 'CONS', label: 'Консультация' },
|
||||||
@@ -80,6 +95,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
|
|||||||
title: 'Лекции',
|
title: 'Лекции',
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'title', label: 'Название' },
|
{ key: 'title', label: 'Название' },
|
||||||
|
{ key: 'startsAt', label: 'Начало' },
|
||||||
{ key: 'teacher', label: 'Преподаватель' },
|
{ key: 'teacher', label: 'Преподаватель' },
|
||||||
{ key: 'format', label: 'Формат' },
|
{ key: 'format', label: 'Формат' },
|
||||||
{ key: 'status', label: 'Синхронизация', align: 'center' },
|
{ key: 'status', label: 'Синхронизация', align: 'center' },
|
||||||
@@ -120,9 +136,10 @@ const current = computed(() => {
|
|||||||
if (activeTab.value === 'lectures') {
|
if (activeTab.value === 'lectures') {
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
rows: lectures.value.map(l => ({
|
rows: lectures.value.map((l) => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
title: l.title || l.courseName || 'Без названия',
|
title: l.title || l.courseName || 'Без названия',
|
||||||
|
startsAt: new Date(l.startsAt).toLocaleString('ru-RU'),
|
||||||
teacher: l.teacherName || 'Не назначен',
|
teacher: l.teacherName || 'Не назначен',
|
||||||
format: l.format === 'Online' ? 'Онлайн' : 'Офлайн',
|
format: l.format === 'Online' ? 'Онлайн' : 'Офлайн',
|
||||||
status: l.isOpen ? 'Открыта' : 'Закрыта',
|
status: l.isOpen ? 'Открыта' : 'Закрыта',
|
||||||
@@ -132,18 +149,18 @@ const current = computed(() => {
|
|||||||
if (activeTab.value === 'courses') {
|
if (activeTab.value === 'courses') {
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
rows: courses.value.map(c => ({
|
rows: courses.value.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
title: c.name || 'Без названия',
|
title: c.name || 'Без названия',
|
||||||
institute: c.isSynced ? 'Синхронизирован' : 'Ручной',
|
institute: c.isSynced ? 'Синхронизирован' : 'Ручной',
|
||||||
tags: c.tags?.map(tag => `#${tag.name}`).join(' ') || '—',
|
tags: c.tags?.map((tag) => `#${tag.name}`).join(' ') || '—',
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'rooms') {
|
if (activeTab.value === 'rooms') {
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
rows: locations.value.map(l => ({
|
rows: locations.value.map((l) => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
building: l.building || l.name || '—',
|
building: l.building || l.name || '—',
|
||||||
room: l.room || '—',
|
room: l.room || '—',
|
||||||
@@ -153,7 +170,7 @@ const current = computed(() => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
rows: tags.value.map(tag => ({
|
rows: tags.value.map((tag) => ({
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
tag: `#${tag.name}`,
|
tag: `#${tag.name}`,
|
||||||
category: tag.type,
|
category: tag.type,
|
||||||
@@ -164,7 +181,8 @@ const current = computed(() => {
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
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 }),
|
lecturesApi.list({ PageSize: 100 }),
|
||||||
coursesApi.list(),
|
coursesApi.list(),
|
||||||
locationsApi.list(),
|
locationsApi.list(),
|
||||||
@@ -179,6 +197,62 @@ async function loadData() {
|
|||||||
loading.value = false
|
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() {
|
async function refreshSyncStatus() {
|
||||||
try {
|
try {
|
||||||
syncStatus.value = await syncApi.status()
|
syncStatus.value = await syncApi.status()
|
||||||
@@ -248,7 +322,12 @@ function extractApiErrorDetails(err: unknown) {
|
|||||||
const details = err.details
|
const details = err.details
|
||||||
if (!details || typeof details !== 'object') return []
|
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 [
|
return [
|
||||||
typeof problem.title === 'string' ? `title=${problem.title}` : '',
|
typeof problem.title === 'string' ? `title=${problem.title}` : '',
|
||||||
typeof problem.status === 'number' ? `status=${problem.status}` : '',
|
typeof problem.status === 'number' ? `status=${problem.status}` : '',
|
||||||
@@ -266,30 +345,118 @@ 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>
|
||||||
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData">Обновить</button>
|
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData">
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">Лекции</button>
|
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">
|
||||||
<button :class="{ active: activeTab === 'courses' }" @click="activeTab = 'courses'">Курсы</button>
|
Лекции
|
||||||
<button :class="{ active: activeTab === 'rooms' }" @click="activeTab = 'rooms'">Аудитории</button>
|
</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>
|
<button :class="{ active: activeTab === 'tags' }" @click="activeTab = 'tags'">Теги</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">{{ current.title }}</div>
|
<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" />
|
<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>
|
||||||
<div class="section-title">Синхронизация расписания</div>
|
<div class="section-title">Синхронизация расписания</div>
|
||||||
<div class="sync-meta">{{ syncMeta }}</div>
|
<div class="sync-meta">{{ syncMeta }}</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<form class="form" @submit.prevent="runScheduleSync">
|
<form class="form" @submit.prevent="runScheduleSync">
|
||||||
@@ -298,7 +465,11 @@ onMounted(() => {
|
|||||||
<label>Период по</label>
|
<label>Период по</label>
|
||||||
<input v-model="syncForm.timeMax" class="glass-input" type="datetime-local" />
|
<input v-model="syncForm.timeMax" class="glass-input" type="datetime-local" />
|
||||||
<label>Код специальности</label>
|
<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>
|
<label>Типы пар</label>
|
||||||
<div class="type-grid">
|
<div class="type-grid">
|
||||||
<label v-for="type in scheduleTypeOptions" :key="type.id" class="type-option">
|
<label v-for="type in scheduleTypeOptions" :key="type.id" class="type-option">
|
||||||
@@ -308,9 +479,9 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="visibleSyncResult" class="sync-result">
|
<div v-if="visibleSyncResult" class="sync-result">
|
||||||
Создано: {{ visibleSyncResult.created }},
|
Создано: {{ visibleSyncResult.created }}, обновлено: {{ visibleSyncResult.updated }},
|
||||||
обновлено: {{ visibleSyncResult.updated }},
|
пропущено:
|
||||||
пропущено: {{ visibleSyncResult.skipped }}
|
{{ visibleSyncResult.skipped }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
|
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
|
||||||
{{ syncError || visibleSyncResult?.error }}
|
{{ syncError || visibleSyncResult?.error }}
|
||||||
@@ -318,7 +489,9 @@ onMounted(() => {
|
|||||||
<details v-if="visibleSyncDetails.length" class="sync-details">
|
<details v-if="visibleSyncDetails.length" class="sync-details">
|
||||||
<summary>Подробности ошибки</summary>
|
<summary>Подробности ошибки</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="detail in visibleSyncDetails" :key="detail">{{ detail }}</li>
|
<li v-for="detail in visibleSyncDetails" :key="detail">
|
||||||
|
{{ detail }}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -326,7 +499,12 @@ onMounted(() => {
|
|||||||
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
|
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
|
||||||
{{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }}
|
{{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-secondary" type="button" :disabled="syncingRooms" @click="runRoomsSync">
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="syncingRooms"
|
||||||
|
@click="runRoomsSync"
|
||||||
|
>
|
||||||
{{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }}
|
{{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,17 +515,104 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-lectures { display: flex; flex-direction: column; gap: 16px; }
|
.admin-lectures {
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
display: flex;
|
||||||
.tabs { display: inline-flex; width: fit-content; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; }
|
flex-direction: column;
|
||||||
.tabs button { background: var(--color-white-a70); border: none; padding: 8px 18px; font-size: 13px; cursor: pointer; color: var(--color-text-secondary); }
|
gap: 16px;
|
||||||
.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; }
|
.header {
|
||||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
display: flex;
|
||||||
.form { display: flex; flex-direction: column; gap: 10px; }
|
align-items: center;
|
||||||
.form label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); }
|
justify-content: space-between;
|
||||||
.form-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
gap: 12px;
|
||||||
.type-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; }
|
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 {
|
.type-option {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -360,11 +625,26 @@ onMounted(() => {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.type-option input { flex: 0 0 auto; }
|
.type-option input {
|
||||||
.type-option span { font-size: 13px; line-height: 1.2; }
|
flex: 0 0 auto;
|
||||||
.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; }
|
}
|
||||||
.sync-result { font-size: 13px; color: var(--color-text-secondary); }
|
.type-option span {
|
||||||
.sync-error { font-size: 13px; color: var(--color-error); }
|
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 {
|
.sync-details {
|
||||||
border: 1px solid var(--color-error-a24);
|
border: 1px solid var(--color-error-a24);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -373,9 +653,19 @@ onMounted(() => {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.sync-details summary { cursor: pointer; color: var(--color-error); font-weight: 600; }
|
.sync-details summary {
|
||||||
.sync-details ul { margin: 8px 0 0; padding-left: 18px; overflow-wrap: anywhere; }
|
cursor: pointer;
|
||||||
.sync-details li + li { margin-top: 4px; }
|
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 {
|
.sync-status {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid var(--color-border-glass);
|
border: 1px solid var(--color-border-glass);
|
||||||
@@ -385,6 +675,14 @@ onMounted(() => {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
background: var(--color-white-a72);
|
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.completed {
|
||||||
.sync-status.failed { color: var(--color-danger-text); background: var(--color-danger-bg-a90); border-color: var(--color-danger-light); }
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user