fix: синхронизации аудиторий

This commit is contained in:
2026-05-11 23:59:13 +03:00
parent 6824d7ce7d
commit 34334e9a8d
6 changed files with 255 additions and 41 deletions
+8
View File
@@ -9,6 +9,8 @@ import type {
LocationDto,
PagedResult,
ReviewDto,
SyncResultDto,
SyncScheduleRequest,
SyncStatusDto,
TagDto,
UserAchievementDto,
@@ -112,4 +114,10 @@ export const tagsApi = {
export const syncApi = {
status: () => apiRequest<SyncStatusDto>('/sync/status'),
schedule: (request: SyncScheduleRequest) =>
apiRequest<SyncResultDto>('/sync/schedule', {
method: 'POST',
body: JSON.stringify(request),
}),
rooms: () => apiRequest<SyncResultDto>('/sync/rooms', { method: 'POST' }),
}
+15 -6
View File
@@ -141,12 +141,21 @@ export interface TagDto {
export interface SyncStatusDto {
lastSyncAt?: string | null
status?: string | null
lastResult?: {
created: number
updated: number
skipped: number
error?: string | null
} | null
lastResult?: SyncResultDto | null
}
export interface SyncScheduleRequest {
specialtyCode?: string | null
timeMin?: string | null
timeMax?: string | null
typeId?: string | null
}
export interface SyncResultDto {
created: number
updated: number
skipped: number
error?: string | null
}
export interface UserAchievementDto {
+144 -22
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { coursesApi, lecturesApi, locationsApi, tagsApi } from '@/api'
import type { CourseDto, LectureDto, LocationDto, TagDto } from '@/api/types'
import { computed, inject, onMounted, ref } from 'vue'
import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
import type { 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'
@@ -19,6 +19,35 @@ 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 syncStatus = ref<SyncStatusDto | null>(null)
const syncResult = ref<SyncResultDto | null>(null)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
function toInputDateTime(date: Date) {
const offsetMs = date.getTimezoneOffset() * 60000
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
}
const now = new Date()
const inTwoWeeks = new Date(now)
inTwoWeeks.setDate(inTwoWeeks.getDate() + 14)
const syncForm = ref({
specialtyCode: '',
typeId: '',
timeMin: toInputDateTime(now),
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 tabConfig: Record<TabKey, TabConfig> = {
lectures: {
@@ -107,19 +136,78 @@ const current = computed(() => {
}
})
onMounted(async () => {
async function loadData() {
loading.value = true
const [lecturesResult, coursesResult, locationsResult, tagsResult] = await Promise.allSettled([
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 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 = ''
syncResult.value = null
try {
syncResult.value = await syncApi.schedule({
specialtyCode: syncForm.value.specialtyCode.trim() || null,
typeId: syncForm.value.typeId.trim() || 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
addToast?.('Синхронизация завершилась с ошибкой.', 'error')
} else {
addToast?.('Расписание синхронизировано.', 'success')
}
await loadData()
} catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
addToast?.(syncError.value, 'error')
await refreshSyncStatus()
} finally {
syncingSchedule.value = false
}
}
async function runRoomsSync() {
syncingRooms.value = true
syncError.value = ''
syncResult.value = null
try {
syncResult.value = await syncApi.rooms()
addToast?.('Аудитории синхронизированы.', 'success')
await loadData()
} catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.'
addToast?.(syncError.value, 'error')
} finally {
syncingRooms.value = false
}
}
onMounted(() => {
void loadData()
})
</script>
@@ -127,7 +215,7 @@ onMounted(async () => {
<div class="admin-lectures page-content">
<div class="header">
<h1 class="page-title">Управление лекциями и справочниками</h1>
<button class="btn-primary">Создать запись</button>
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData">Обновить</button>
</div>
<div class="tabs">
@@ -145,21 +233,40 @@ onMounted(async () => {
</GlassCard>
<GlassCard>
<div class="section-title">Создать / редактировать</div>
<form class="form">
<label>Название</label>
<input class="glass-input" placeholder="Введите название" />
<label>Описание</label>
<textarea rows="4" placeholder="Описание записи"></textarea>
<label>Статус синхронизации</label>
<select class="glass-input">
<option>Синхронизировано</option>
<option>Ожидает</option>
<option>Ошибка</option>
</select>
<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>
<input v-model="syncForm.typeId" class="glass-input" placeholder="Оставьте пустым для всех типов" />
<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>
<div class="form-actions">
<button class="btn-primary" type="button">Сохранить</button>
<button class="btn-secondary" type="button">Отменить</button>
<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>
@@ -174,7 +281,22 @@ onMounted(async () => {
.tabs button { background: rgba(255,255,255,0.7); border: none; padding: 8px 18px; font-size: 13px; cursor: pointer; color: var(--color-text-secondary); }
.tabs button.active { background: rgba(34,197,94,0.18); 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; }
textarea { padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-glass); background: rgba(255,255,255,0.8); }
.form-actions { display: flex; gap: 10px; }
.form label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); }
.form-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.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-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: rgba(255,255,255,0.72);
}
.sync-status.completed { color: #166534; background: rgba(220,252,231,0.9); border-color: #86EFAC; }
.sync-status.failed { color: #991B1B; background: rgba(254,226,226,0.9); border-color: #FCA5A5; }
</style>