diff --git a/src/Jiaowu.Api/Controllers/AcademicCalendarController.cs b/src/Jiaowu.Api/Controllers/AcademicCalendarController.cs index 4eaf039..06e84b4 100644 --- a/src/Jiaowu.Api/Controllers/AcademicCalendarController.cs +++ b/src/Jiaowu.Api/Controllers/AcademicCalendarController.cs @@ -119,21 +119,23 @@ public sealed class AcademicCalendarController(AppDbContext db, HolidayCnClient catch (InvalidOperationException exception) { return ValidationProblem(exception.Message); } var existing = await db.AcademicCalendarEvents.Where(item => item.AcademicTermId == request.AcademicTermId && item.Source == AcademicCalendarEventSource.HolidayCn && item.SourceYear == request.Year).ToListAsync(cancellationToken); + var ranges = HolidayCnRanges.Group(source.Days); db.AcademicCalendarEvents.RemoveRange(existing); - db.AcademicCalendarEvents.AddRange(source.Days.Select(day => new AcademicCalendarEvent + db.AcademicCalendarEvents.AddRange(ranges.Select(range => new AcademicCalendarEvent { AcademicTermId = request.AcademicTermId, - Title = day.Name, + Title = range.Name, Kind = AcademicCalendarEventKind.PublicHoliday, - StartsOn = day.Date, - ReminderDays = day.IsOffDay ? 3 : 0, - Description = day.IsOffDay ? "法定节假日或调休休息日" : "调休上班日", + StartsOn = range.StartsOn, + EndsOn = range.StartsOn == range.EndsOn ? null : range.EndsOn, + ReminderDays = range.IsOffDay ? 3 : 0, + Description = range.IsOffDay ? "法定节假日或调休休息日" : "调休上班日", Source = AcademicCalendarEventSource.HolidayCn, SourceYear = request.Year, - IsOffDay = day.IsOffDay + IsOffDay = range.IsOffDay })); await db.SaveChangesAsync(cancellationToken); - return Ok(new { Year = request.Year, SyncedCount = source.Days.Count, ReplacedCount = existing.Count, source.Papers }); + return Ok(new { Year = request.Year, SyncedCount = ranges.Count, SourceDayCount = source.Days.Count, ReplacedCount = existing.Count, source.Papers }); } private async Task ValidateAsync(AcademicCalendarEventRequest request, CancellationToken cancellationToken) diff --git a/src/Jiaowu.Api/Infrastructure/AcademicCalendar/HolidayCnClient.cs b/src/Jiaowu.Api/Infrastructure/AcademicCalendar/HolidayCnClient.cs index 42682b7..cac5b7f 100644 --- a/src/Jiaowu.Api/Infrastructure/AcademicCalendar/HolidayCnClient.cs +++ b/src/Jiaowu.Api/Infrastructure/AcademicCalendar/HolidayCnClient.cs @@ -21,3 +21,42 @@ public sealed class HolidayCnClient(HttpClient httpClient) public sealed record HolidayCnYear(int Year, IReadOnlyList Papers, IReadOnlyList Days); public sealed record HolidayCnDay(string Name, DateOnly Date, bool IsOffDay); + +/// +/// 将数据源中按日列出的同一连续节假日归并为一个校历日程。 +/// 调休上班日与休息日即使同名也必须分别保留,避免错误地把工作日包含在假期范围内。 +/// +public static class HolidayCnRanges +{ + public static IReadOnlyList Group(IEnumerable days) => days + .OrderBy(day => day.Date) + .GroupBy(day => new { day.Name, day.IsOffDay }) + .SelectMany(group => GroupConsecutive(group.Key.Name, group.Key.IsOffDay, group)) + .OrderBy(range => range.StartsOn) + .ToList(); + + private static IEnumerable GroupConsecutive( + string name, + bool isOffDay, + IEnumerable days) + { + HolidayCnDay? first = null; + HolidayCnDay? previous = null; + foreach (var day in days.OrderBy(item => item.Date)) + { + if (first is not null && previous is not null && day.Date > previous.Date.AddDays(1)) + { + yield return new HolidayCnRange(name, first.Date, previous.Date, isOffDay); + first = null; + } + + first ??= day; + previous = day; + } + + if (first is not null && previous is not null) + yield return new HolidayCnRange(name, first.Date, previous.Date, isOffDay); + } +} + +public sealed record HolidayCnRange(string Name, DateOnly StartsOn, DateOnly EndsOn, bool IsOffDay); diff --git a/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs b/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs index a4abe92..9758220 100644 --- a/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs +++ b/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs @@ -1,6 +1,7 @@ using CaptchaKit.AspNetCore; using CaptchaKit; using Microsoft.Extensions.DependencyInjection; +using SkiaSharp; namespace Jiaowu.Api.Tests; @@ -34,6 +35,28 @@ public sealed class CaptchaKitIntegrationTests Assert.True(challenge.Challenge.ImageBytes!.AsSpan().StartsWith(new byte[] { 137, 80, 78, 71 })); } + [Fact] + public void Selection_visual_keeps_the_target_character_visible_above_noise() + { + var challenge = new SkiaTextCaptchaGenerator().CreateVisualChallenge( + new CaptchaGenerationRequest("test", DateTimeOffset.UtcNow.AddMinutes(1), 5), + "A", "choice-A", CaptchaChallengeType.Selection, "请选择对应字符"); + + using var bitmap = SKBitmap.Decode(challenge.Challenge.ImageBytes!); + Assert.NotNull(bitmap); + + var targetPixelCount = 0; + for (var y = 0; y < bitmap!.Height; y++) + for (var x = bitmap.Width / 4; x < bitmap.Width * 3 / 4; x++) + { + var pixel = bitmap.GetPixel(x, y); + if (pixel.Red <= 30 && pixel.Green is >= 40 and <= 60 && pixel.Blue is >= 70 and <= 90) + targetPixelCount++; + } + + Assert.True(targetPixelCount > 20, "The target character should be visibly rendered in the image center."); + } + [Fact] public void Random_generator_produces_each_enabled_challenge_type() { diff --git a/tests/Jiaowu.Api.Tests/HolidayCnRangesTests.cs b/tests/Jiaowu.Api.Tests/HolidayCnRangesTests.cs new file mode 100644 index 0000000..1f3ae18 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/HolidayCnRangesTests.cs @@ -0,0 +1,48 @@ +using Jiaowu.Api.Infrastructure.AcademicCalendar; + +namespace Jiaowu.Api.Tests; + +public sealed class HolidayCnRangesTests +{ + [Fact] + public void Group_merges_only_consecutive_days_with_the_same_name_and_status() + { + var ranges = HolidayCnRanges.Group( + [ + new HolidayCnDay("国庆节", new DateOnly(2026, 10, 1), true), + new HolidayCnDay("国庆节", new DateOnly(2026, 10, 2), true), + new HolidayCnDay("国庆节", new DateOnly(2026, 10, 3), true), + new HolidayCnDay("国庆节", new DateOnly(2026, 10, 4), false), + new HolidayCnDay("国庆节", new DateOnly(2026, 10, 5), true), + new HolidayCnDay("国庆节", new DateOnly(2026, 10, 7), true) + ]); + + Assert.Collection( + ranges, + range => + { + Assert.Equal("国庆节", range.Name); + Assert.Equal(new DateOnly(2026, 10, 1), range.StartsOn); + Assert.Equal(new DateOnly(2026, 10, 3), range.EndsOn); + Assert.True(range.IsOffDay); + }, + range => + { + Assert.Equal(new DateOnly(2026, 10, 4), range.StartsOn); + Assert.Equal(range.StartsOn, range.EndsOn); + Assert.False(range.IsOffDay); + }, + range => + { + Assert.Equal(new DateOnly(2026, 10, 5), range.StartsOn); + Assert.Equal(range.StartsOn, range.EndsOn); + Assert.True(range.IsOffDay); + }, + range => + { + Assert.Equal(new DateOnly(2026, 10, 7), range.StartsOn); + Assert.Equal(range.StartsOn, range.EndsOn); + Assert.True(range.IsOffDay); + }); + } +} diff --git a/versions.props b/versions.props index 9151f35..f015ca2 100644 --- a/versions.props +++ b/versions.props @@ -1,7 +1,7 @@ - 2.6.0-beta2 - 2.6.0-beta2 - 2.6.0-beta2 + 2.6.0-beta3 + 2.6.0-beta3 + 2.6.0-beta3 diff --git a/web/src/views/AcademicCalendarView.vue b/web/src/views/AcademicCalendarView.vue index da31996..72909f0 100644 --- a/web/src/views/AcademicCalendarView.vue +++ b/web/src/views/AcademicCalendarView.vue @@ -121,6 +121,13 @@ function label(kind: number | string) { function spans(item: CalendarEvent, day: string) { return item.startsOn <= day && (item.endsOn ?? item.startsOn) >= day; } +interface WeekEventSegment { + item: CalendarEvent; + id: string; + startColumn: number; + length: number; + lane: number; +} const today = key(new Date()), monthTitle = computed( () => `${cursor.value.getFullYear()} 年 ${cursor.value.getMonth() + 1} 月`, @@ -145,13 +152,40 @@ const days = computed(() => { }; }); }); +const weeks = computed(() => + Array.from({ length: 6 }, (_, weekIndex) => { + const weekDays = days.value.slice(weekIndex * 7, weekIndex * 7 + 7); + const firstDay = weekDays[0].day; + const lastDay = weekDays[6].day; + const laneEnds: string[] = []; + const eventSegments: WeekEventSegment[] = events.value + .filter((item) => spans(item, firstDay) || spans(item, lastDay) || (item.startsOn < firstDay && (item.endsOn ?? item.startsOn) > lastDay)) + .map((item) => { + const startsOn = item.startsOn > firstDay ? item.startsOn : firstDay; + const endsOn = (item.endsOn ?? item.startsOn) < lastDay ? (item.endsOn ?? item.startsOn) : lastDay; + return { + item, + startsOn, + endsOn, + startColumn: weekDays.findIndex((day) => day.day === startsOn) + 1, + length: Math.round((parse(endsOn).getTime() - parse(startsOn).getTime()) / 86400000) + 1, + }; + }) + .sort((left, right) => left.startsOn.localeCompare(right.startsOn) || right.length - left.length || left.item.title.localeCompare(right.item.title)) + .map((segment) => { + const lane = laneEnds.findIndex((endsOn) => endsOn < segment.startsOn); + const assignedLane = lane === -1 ? laneEnds.length : lane; + laneEnds[assignedLane] = segment.endsOn; + return { ...segment, id: `${segment.item.id}-${weekIndex}`, lane: assignedLane }; + }); + return { days: weekDays, eventSegments }; + }), +); const monthEvents = computed(() => events.value.filter((item) => { - const date = parse(item.startsOn); - return ( - date.getFullYear() === cursor.value.getFullYear() && - date.getMonth() === cursor.value.getMonth() - ); + const monthStart = key(new Date(cursor.value.getFullYear(), cursor.value.getMonth(), 1)); + const monthEnd = key(new Date(cursor.value.getFullYear(), cursor.value.getMonth() + 1, 0)); + return item.startsOn <= monthEnd && (item.endsOn ?? item.startsOn) >= monthStart; }), ); function shift(amount: number) { @@ -238,7 +272,7 @@ async function syncHolidays() { academicTermId: selectedTermId.value, year: holidayYear.value, }); - ElMessage.success(`已同步 ${data.syncedCount} 条节假日与调休数据。`); + ElMessage.success(`已同步 ${data.syncedCount} 个节假日/调休日程,覆盖 ${data.sourceDayCount} 天。`); holidayDialogVisible.value = false; await load(); } finally { @@ -352,8 +386,10 @@ onMounted(async () => { >
+
+
{{ day.date.getDate() }}
- +{{ day.events.length - 3 }} 项
+
+
+ +
+