Dev #11
@@ -7,7 +7,13 @@ public record SyncScheduleRequest(
|
|||||||
IReadOnlyList<string>? TypeId
|
IReadOnlyList<string>? TypeId
|
||||||
);
|
);
|
||||||
|
|
||||||
public record SyncResultDto(int Created, int Updated, int Skipped, string? Error);
|
public record SyncResultDto(
|
||||||
|
int Created,
|
||||||
|
int Updated,
|
||||||
|
int Skipped,
|
||||||
|
string? Error,
|
||||||
|
IReadOnlyList<string>? Details = null
|
||||||
|
);
|
||||||
|
|
||||||
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
|
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ public class ModeusApiClient : IModeusApiClient
|
|||||||
{
|
{
|
||||||
var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId };
|
var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId };
|
||||||
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
|
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
|
||||||
response.EnsureSuccessStatusCode();
|
await EnsureSuccessAsync(response, "Modeus events search",
|
||||||
|
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}, timeMin={request.TimeMin:O}, timeMax={request.TimeMax:O}, typeId=[{string.Join(", ", request.TypeId ?? [])}]");
|
||||||
return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new());
|
return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ public class ModeusApiClient : IModeusApiClient
|
|||||||
};
|
};
|
||||||
|
|
||||||
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", body);
|
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", body);
|
||||||
response.EnsureSuccessStatusCode();
|
await EnsureSuccessAsync(response, "Modeus rooms search",
|
||||||
|
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false");
|
||||||
|
|
||||||
var payload = await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new ModeusRoomsResponse();
|
var payload = await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new ModeusRoomsResponse();
|
||||||
allRooms.AddRange(payload.RoomItems);
|
allRooms.AddRange(payload.RoomItems);
|
||||||
@@ -59,6 +61,20 @@ public class ModeusApiClient : IModeusApiClient
|
|||||||
return new ModeusRoomsResponse { Rooms = allRooms };
|
return new ModeusRoomsResponse { Rooms = allRooms };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, string requestSummary)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode) return;
|
||||||
|
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
if (responseBody.Length > 2000)
|
||||||
|
responseBody = string.Concat(responseBody.AsSpan(0, 2000), "...<truncated>");
|
||||||
|
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {responseBody}",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname)
|
public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname)
|
||||||
{
|
{
|
||||||
var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>(
|
var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
|
|
||||||
public async Task<SyncResultDto> SyncScheduleAsync(SyncScheduleRequest request)
|
public async Task<SyncResultDto> SyncScheduleAsync(SyncScheduleRequest request)
|
||||||
{
|
{
|
||||||
|
const string stage = "schedule";
|
||||||
int created = 0, updated = 0, skipped = 0;
|
int created = 0, updated = 0, skipped = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -45,7 +46,18 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Schedule sync failed");
|
_logger.LogError(ex, "Schedule sync failed");
|
||||||
var result = new SyncResultDto(created, updated, skipped, ex.Message);
|
var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
|
||||||
|
ex,
|
||||||
|
stage,
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
[
|
||||||
|
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}",
|
||||||
|
$"timeMin={request.TimeMin:O}",
|
||||||
|
$"timeMax={request.TimeMax:O}",
|
||||||
|
$"typeId=[{string.Join(", ", request.TypeId ?? [])}]"
|
||||||
|
]));
|
||||||
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
|
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -53,6 +65,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
|
|
||||||
public async Task<SyncResultDto> SyncRoomsAsync()
|
public async Task<SyncResultDto> SyncRoomsAsync()
|
||||||
{
|
{
|
||||||
|
const string stage = "rooms";
|
||||||
int created = 0, updated = 0, skipped = 0;
|
int created = 0, updated = 0, skipped = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -96,7 +109,13 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Rooms sync failed");
|
_logger.LogError(ex, "Rooms sync failed");
|
||||||
var result = new SyncResultDto(created, updated, skipped, ex.Message);
|
var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
|
||||||
|
ex,
|
||||||
|
stage,
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
["request=name:<empty>, sort:+building.name,+name, deleted:false, page size:100"]));
|
||||||
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
|
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -109,4 +128,30 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus);
|
public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus);
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> BuildErrorDetails(
|
||||||
|
Exception exception,
|
||||||
|
string stage,
|
||||||
|
int created,
|
||||||
|
int updated,
|
||||||
|
int skipped,
|
||||||
|
IReadOnlyList<string> context)
|
||||||
|
{
|
||||||
|
var details = new List<string>
|
||||||
|
{
|
||||||
|
$"stage={stage}",
|
||||||
|
$"exceptionType={exception.GetType().FullName}",
|
||||||
|
$"message={exception.Message}",
|
||||||
|
$"partialResult=created:{created}, updated:{updated}, skipped:{skipped}"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (exception is HttpRequestException httpException && httpException.StatusCode.HasValue)
|
||||||
|
details.Add($"httpStatus={(int)httpException.StatusCode.Value} {httpException.StatusCode.Value}");
|
||||||
|
|
||||||
|
if (exception.InnerException != null)
|
||||||
|
details.Add($"innerException={exception.InnerException.GetType().FullName}: {exception.InnerException.Message}");
|
||||||
|
|
||||||
|
details.AddRange(context);
|
||||||
|
return details;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export interface SyncResultDto {
|
|||||||
updated: number
|
updated: number
|
||||||
skipped: number
|
skipped: number
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
details?: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAchievementDto {
|
export interface UserAchievementDto {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
|
import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
import type {
|
import type {
|
||||||
ApiScheduleTypeId,
|
ApiScheduleTypeId,
|
||||||
CourseDto,
|
CourseDto,
|
||||||
@@ -30,6 +31,7 @@ const loading = ref(false)
|
|||||||
const syncingSchedule = ref(false)
|
const syncingSchedule = ref(false)
|
||||||
const syncingRooms = ref(false)
|
const syncingRooms = ref(false)
|
||||||
const syncError = ref('')
|
const syncError = ref('')
|
||||||
|
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 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 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> = {
|
const tabConfig: Record<TabKey, TabConfig> = {
|
||||||
lectures: {
|
lectures: {
|
||||||
@@ -182,6 +188,7 @@ async function refreshSyncStatus() {
|
|||||||
async function runScheduleSync() {
|
async function runScheduleSync() {
|
||||||
syncingSchedule.value = true
|
syncingSchedule.value = true
|
||||||
syncError.value = ''
|
syncError.value = ''
|
||||||
|
syncErrorDetails.value = []
|
||||||
syncResult.value = null
|
syncResult.value = null
|
||||||
try {
|
try {
|
||||||
syncResult.value = await syncApi.schedule({
|
syncResult.value = await syncApi.schedule({
|
||||||
@@ -193,6 +200,7 @@ async function runScheduleSync() {
|
|||||||
|
|
||||||
if (syncResult.value.error) {
|
if (syncResult.value.error) {
|
||||||
syncError.value = syncResult.value.error
|
syncError.value = syncResult.value.error
|
||||||
|
syncErrorDetails.value = syncResult.value.details ?? []
|
||||||
addToast?.('Синхронизация завершилась с ошибкой.', 'error')
|
addToast?.('Синхронизация завершилась с ошибкой.', 'error')
|
||||||
} else {
|
} else {
|
||||||
addToast?.('Расписание синхронизировано.', 'success')
|
addToast?.('Расписание синхронизировано.', 'success')
|
||||||
@@ -201,6 +209,7 @@ async function runScheduleSync() {
|
|||||||
await loadData()
|
await loadData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
|
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
|
||||||
|
syncErrorDetails.value = extractApiErrorDetails(err)
|
||||||
addToast?.(syncError.value, 'error')
|
addToast?.(syncError.value, 'error')
|
||||||
await refreshSyncStatus()
|
await refreshSyncStatus()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -211,19 +220,41 @@ async function runScheduleSync() {
|
|||||||
async function runRoomsSync() {
|
async function runRoomsSync() {
|
||||||
syncingRooms.value = true
|
syncingRooms.value = true
|
||||||
syncError.value = ''
|
syncError.value = ''
|
||||||
|
syncErrorDetails.value = []
|
||||||
syncResult.value = null
|
syncResult.value = null
|
||||||
try {
|
try {
|
||||||
syncResult.value = await syncApi.rooms()
|
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()
|
await loadData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.'
|
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.'
|
||||||
|
syncErrorDetails.value = extractApiErrorDetails(err)
|
||||||
addToast?.(syncError.value, 'error')
|
addToast?.(syncError.value, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
syncingRooms.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
void loadData()
|
void loadData()
|
||||||
})
|
})
|
||||||
@@ -282,6 +313,12 @@ onMounted(() => {
|
|||||||
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
|
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
|
||||||
{{ syncError || visibleSyncResult?.error }}
|
{{ syncError || visibleSyncResult?.error }}
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
|
<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-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; }
|
||||||
.sync-result { font-size: 13px; color: var(--color-text-secondary); }
|
.sync-result { font-size: 13px; color: var(--color-text-secondary); }
|
||||||
.sync-error { font-size: 13px; color: var(--color-error); }
|
.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 {
|
.sync-status {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid var(--color-border-glass);
|
border: 1px solid var(--color-border-glass);
|
||||||
|
|||||||
Reference in New Issue
Block a user