feat: подробное отображение ошибок синхронизации

This commit is contained in:
2026-05-12 00:18:47 +03:00
parent fb8ad6de7c
commit 9b28a09253
5 changed files with 122 additions and 6 deletions
+49 -1
View File
@@ -1,6 +1,7 @@
<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,
@@ -30,6 +31,7 @@ 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 addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
@@ -66,6 +68,10 @@ const syncMeta = computed(() => {
})
const visibleSyncResult = computed(() => syncResult.value ?? syncStatus.value?.lastResult ?? null)
const visibleSyncDetails = computed(() => {
if (syncErrorDetails.value.length) return syncErrorDetails.value
return visibleSyncResult.value?.details ?? []
})
const tabConfig: Record<TabKey, TabConfig> = {
lectures: {
@@ -182,6 +188,7 @@ async function refreshSyncStatus() {
async function runScheduleSync() {
syncingSchedule.value = true
syncError.value = ''
syncErrorDetails.value = []
syncResult.value = null
try {
syncResult.value = await syncApi.schedule({
@@ -193,6 +200,7 @@ async function runScheduleSync() {
if (syncResult.value.error) {
syncError.value = syncResult.value.error
syncErrorDetails.value = syncResult.value.details ?? []
addToast?.('Синхронизация завершилась с ошибкой.', 'error')
} else {
addToast?.('Расписание синхронизировано.', 'success')
@@ -201,6 +209,7 @@ async function runScheduleSync() {
await loadData()
} catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
syncErrorDetails.value = extractApiErrorDetails(err)
addToast?.(syncError.value, 'error')
await refreshSyncStatus()
} finally {
@@ -211,19 +220,41 @@ async function runScheduleSync() {
async function runRoomsSync() {
syncingRooms.value = true
syncError.value = ''
syncErrorDetails.value = []
syncResult.value = null
try {
syncResult.value = await syncApi.rooms()
addToast?.('Аудитории синхронизированы.', 'success')
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()
})
@@ -282,6 +313,12 @@ onMounted(() => {
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
{{ syncError || visibleSyncResult?.error }}
</div>
<details v-if="visibleSyncDetails.length" class="sync-details">
<summary>Подробности ошибки</summary>
<ul>
<li v-for="detail in visibleSyncDetails" :key="detail">{{ detail }}</li>
</ul>
</details>
<div class="form-actions">
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
@@ -326,6 +363,17 @@ onMounted(() => {
.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 rgba(239,68,68,0.24);
border-radius: var(--radius-sm);
padding: 8px 10px;
background: rgba(254,242,242,0.68);
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);