修复场地课表遗漏借用的问题

This commit is contained in:
biss committed 2026-08-13 21:21:15 +08:00
1 parent 5872c06042
commit 54e1986ad0
3 files changed
+102 -4

No files matched your search

@@ -30,9 +30,33 @@ public sealed class VenueDisplaysController(
term.Id, null, false, null, null, cancellationToken); term.Id, null, false, null, null, cancellationToken);
var today = DateOnly.FromDateTime(DateTime.Now); var today = DateOnly.FromDateTime(DateTime.Now);
var (week, day) = ClassroomReservationAvailabilityService.ResolveTeachingWeek(term, today); var (week, day) = ClassroomReservationAvailabilityService.ResolveTeachingWeek(term, today);
var entries = sheet?.Entries.Where(x => x.DayOfWeek == day && x.StartWeek <= week && x.EndWeek >= week && var entries = sheet?.Entries
.Where(x => x.DayOfWeek == day && x.StartWeek <= week && x.EndWeek >= week &&
(x.WeekPattern == WeekPattern.All || x.WeekPattern == WeekPattern.Odd && week % 2 == 1 || x.WeekPattern == WeekPattern.Even && week % 2 == 0)) (x.WeekPattern == WeekPattern.All || x.WeekPattern == WeekPattern.Odd && week % 2 == 1 || x.WeekPattern == WeekPattern.Even && week % 2 == 0))
.Select(x => new { x.CourseName, x.TeacherNames, x.ClassNames, x.StartPeriod, x.PeriodCount }) ?? []; .Select(x => new VenueDisplayClassroomEntry(
x.CourseName,
x.TeacherNames,
x.ClassNames,
x.StartPeriod,
x.PeriodCount,
false))
.ToList() ?? [];
var reservations = await db.ClassroomReservations.AsNoTracking()
.Where(x => x.AcademicTermId == term.Id &&
x.ClassroomId == classroomId &&
x.Status == ClassroomReservationStatus.Approved &&
x.ReservationDate == today)
.OrderBy(x => x.StartPeriod)
.Select(x => new VenueDisplayClassroomEntry(
"教室借用",
new[] { x.ApplicantName },
new[] { x.Purpose },
x.StartPeriod,
x.PeriodCount,
true))
.ToListAsync(cancellationToken);
entries.AddRange(reservations);
entries.Sort((left, right) => left.StartPeriod.CompareTo(right.StartPeriod));
return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries }); return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries });
} }
@@ -59,3 +83,11 @@ public sealed class VenueDisplaysController(
private Task<AcademicTerm?> CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking() private Task<AcademicTerm?> CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled && x.IsCurrent).FirstOrDefaultAsync(cancellationToken); .Where(x => x.IsEnabled && x.IsCurrent).FirstOrDefaultAsync(cancellationToken);
} }
public sealed record VenueDisplayClassroomEntry(
string CourseName,
IEnumerable<string> TeacherNames,
IEnumerable<string> ClassNames,
int StartPeriod,
int PeriodCount,
bool IsReservation);
@@ -0,0 +1,66 @@
using System.Text.Json;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class VenueDisplaysControllerTests
{
[Fact]
public async Task Classroom_IncludesApprovedReservationForToday()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var today = DateOnly.FromDateTime(DateTime.Now);
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building { Code = "A", Name = "明德楼", CampusId = campus.Id };
var classroom = new Classroom { Code = "A101", Name = "A101", BuildingId = building.Id, Capacity = 60 };
var college = new College { Code = "CS", Name = "计算机学院" };
var applicant = new ApplicationUser
{
Id = Guid.NewGuid(), UserName = "applicant", NormalizedUserName = "APPLICANT",
DisplayName = "张老师", CollegeId = college.Id
};
var term = new AcademicTerm
{
Code = "CURRENT", Name = "当前学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn,
StartDate = today.AddDays(-7), EndDate = today.AddDays(120), IsCurrent = true
};
db.AddRange(campus, building, classroom, college, applicant, term);
db.ClassroomReservations.Add(new ClassroomReservation
{
ApplicantUserId = applicant.Id, ApplicantName = applicant.DisplayName,
ApplicantCollegeId = college.Id, AcademicTermId = term.Id, ClassroomId = classroom.Id,
ReservationDate = today, StartPeriod = 3, PeriodCount = 2, AttendeeCount = 30,
Purpose = "学院学术讲座", ContactPhone = "13800000000",
Status = ClassroomReservationStatus.Approved
});
await db.SaveChangesAsync();
var controller = new VenueDisplaysController(
db,
new TimetableDataService(db),
new ClassroomReservationAvailabilityService(db));
var result = await controller.Classroom(classroom.Id, CancellationToken.None);
var response = Assert.IsType<OkObjectResult>(result).Value;
using var document = JsonDocument.Parse(JsonSerializer.Serialize(response));
var entry = Assert.Single(document.RootElement.GetProperty("Entries").EnumerateArray());
Assert.Equal("教室借用", entry.GetProperty("CourseName").GetString());
Assert.Equal("学院学术讲座", entry.GetProperty("ClassNames")[0].GetString());
Assert.Equal("张老师", entry.GetProperty("TeacherNames")[0].GetString());
Assert.True(entry.GetProperty("IsReservation").GetBoolean());
}
}
+2 -2
View File
@@ -36,7 +36,7 @@ onBeforeUnmount(() => { window.clearInterval(timer); window.clearInterval(pageTi
<main class="venue-display" :class="`venue-${type}`"> <main class="venue-display" :class="`venue-${type}`">
<header><div><span>明序大学 · 智慧教学空间</span><h1>{{ title?.name ?? '正在加载' }}</h1><p>{{ title?.campusName }} · {{ title?.buildingName ?? title?.code }} · {{ data?.term ?? '当前教学日' }}</p></div><time>{{ timeText }}<small>{{ data?.today }}</small></time></header> <header><div><span>明序大学 · 智慧教学空间</span><h1>{{ title?.name ?? '正在加载' }}</h1><p>{{ title?.campusName }} · {{ title?.buildingName ?? title?.code }} · {{ data?.term ?? '当前教学日' }}</p></div><time>{{ timeText }}<small>{{ data?.today }}</small></time></header>
<section v-if="type === 'classroom'" class="course-board"> <section v-if="type === 'classroom'" class="course-board">
<article v-for="entry in visibleItems" :key="`${entry.startPeriod}-${entry.courseName}`"><b>第 {{ entry.startPeriod }}-{{ entry.startPeriod + entry.periodCount - 1 }} 节</b><h2>{{ entry.courseName }}</h2><p>{{ entry.classNames?.join('、') }} · {{ entry.teacherNames?.join('、') }}</p></article> <article v-for="entry in visibleItems" :key="`${entry.startPeriod}-${entry.courseName}`" :class="{ reservation: entry.isReservation }"><b>第 {{ entry.startPeriod }}-{{ entry.startPeriod + entry.periodCount - 1 }} 节 <em v-if="entry.isReservation">已借用</em></b><h2>{{ entry.courseName }}</h2><p>{{ entry.classNames?.join('、') }} · {{ entry.teacherNames?.join('、') }}</p></article>
<div v-if="data && !data.entries?.length" class="empty">今日暂无排课<br><small>此教室可供安排使用</small></div> <div v-if="data && !data.entries?.length" class="empty">今日暂无排课<br><small>此教室可供安排使用</small></div>
</section> </section>
<section v-else class="room-board"><div class="periods"><span v-for="slot in data?.slots" :key="slot.periodNumber">{{ slot.periodNumber }}<small>{{ slot.startsAt?.slice(0,5) }}</small></span></div><article v-for="room in visibleItems" :key="room.code"><b>{{ room.name }}</b><span v-for="slot in data?.slots" :key="slot.periodNumber" :class="{ free: room.freePeriods?.includes(slot.periodNumber) }">{{ room.freePeriods?.includes(slot.periodNumber) ? '空闲' : '占用' }}</span></article></section> <section v-else class="room-board"><div class="periods"><span v-for="slot in data?.slots" :key="slot.periodNumber">{{ slot.periodNumber }}<small>{{ slot.startsAt?.slice(0,5) }}</small></span></div><article v-for="room in visibleItems" :key="room.code"><b>{{ room.name }}</b><span v-for="slot in data?.slots" :key="slot.periodNumber" :class="{ free: room.freePeriods?.includes(slot.periodNumber) }">{{ room.freePeriods?.includes(slot.periodNumber) ? '空闲' : '占用' }}</span></article></section>
@@ -45,6 +45,6 @@ onBeforeUnmount(() => { window.clearInterval(timer); window.clearInterval(pageTi
</template> </template>
<style scoped> <style scoped>
.venue-display{min-height:100vh;padding:clamp(22px,4vw,62px);background:#0b1e2b;color:#eef7f5;font-family:"Microsoft YaHei",sans-serif}.venue-display header{display:flex;justify-content:space-between;gap:28px;padding-bottom:28px;border-bottom:1px solid #315464}.venue-display header span{color:#63d6bc;letter-spacing:.16em;font-size:12px}.venue-display h1{font-size:clamp(32px,5vw,68px);margin:10px 0}.venue-display p{margin:0;color:#abc5cf}.venue-display time{font:clamp(26px,4vw,52px) ui-monospace,monospace;text-align:right;color:#63d6bc}.venue-display time small{display:block;font:13px "Microsoft YaHei";color:#abc5cf;margin-top:8px}.course-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:18px;margin-top:32px}.course-board article{padding:26px;background:#123243;border-left:5px solid #63d6bc}.course-board b{color:#63d6bc}.course-board h2{font-size:clamp(24px,3vw,40px);margin:24px 0 12px}.empty{grid-column:1/-1;padding:80px 20px;text-align:center;font-size:30px;background:#102a38;color:#63d6bc}.room-board{margin-top:30px;overflow:auto}.periods,.room-board article{display:grid;grid-template-columns:minmax(150px,2fr) repeat(12,minmax(65px,1fr));gap:4px;min-width:950px}.periods{margin-left:0}.periods span{grid-column:span 1;text-align:center;color:#abc5cf}.periods span:first-child{grid-column:1}.periods small{display:block}.room-board article{margin-top:5px}.room-board article b,.room-board article span{padding:16px 8px;background:#213c49;text-align:center}.room-board article span.free{background:#1d6b5d;color:white;font-weight:bold}@media(max-width:620px){.venue-display header{display:block}.venue-display time{text-align:left;margin-top:18px}.venue-display{padding:22px}.course-board article{padding:20px}} .venue-display{min-height:100vh;padding:clamp(22px,4vw,62px);background:#0b1e2b;color:#eef7f5;font-family:"Microsoft YaHei",sans-serif}.venue-display header{display:flex;justify-content:space-between;gap:28px;padding-bottom:28px;border-bottom:1px solid #315464}.venue-display header span{color:#63d6bc;letter-spacing:.16em;font-size:12px}.venue-display h1{font-size:clamp(32px,5vw,68px);margin:10px 0}.venue-display p{margin:0;color:#abc5cf}.venue-display time{font:clamp(26px,4vw,52px) ui-monospace,monospace;text-align:right;color:#63d6bc}.venue-display time small{display:block;font:13px "Microsoft YaHei";color:#abc5cf;margin-top:8px}.course-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:18px;margin-top:32px}.course-board article{padding:26px;background:#123243;border-left:5px solid #63d6bc}.course-board article.reservation{border-left-color:#f5a524;background:#3b3020}.course-board b{color:#63d6bc}.course-board article.reservation b{color:#ffd07b}.course-board em{margin-left:8px;padding:3px 8px;border-radius:999px;background:#7a4b00;color:#fff;font-size:13px;font-style:normal}.course-board h2{font-size:clamp(24px,3vw,40px);margin:24px 0 12px}.empty{grid-column:1/-1;padding:80px 20px;text-align:center;font-size:30px;background:#102a38;color:#63d6bc}.room-board{margin-top:30px;overflow:auto}.periods,.room-board article{display:grid;grid-template-columns:minmax(150px,2fr) repeat(12,minmax(65px,1fr));gap:4px;min-width:950px}.periods{margin-left:0}.periods span{grid-column:span 1;text-align:center;color:#abc5cf}.periods span:first-child{grid-column:1}.periods small{display:block}.room-board article{margin-top:5px}.room-board article b,.room-board article span{padding:16px 8px;background:#213c49;text-align:center}.room-board article span.free{background:#1d6b5d;color:white;font-weight:bold}@media(max-width:620px){.venue-display header{display:block}.venue-display time{text-align:left;margin-top:18px}.venue-display{padding:22px}.course-board article{padding:20px}}
.venue-display footer{display:flex;align-items:center;justify-content:center;gap:7px;margin-top:20px;color:#abc5cf}.venue-display footer span{width:7px;height:7px;border-radius:50%;background:#42616d}.venue-display footer span.active{width:22px;border-radius:5px;background:#63d6bc}.venue-display footer b{margin-left:8px;font:12px ui-monospace,monospace} .venue-display footer{display:flex;align-items:center;justify-content:center;gap:7px;margin-top:20px;color:#abc5cf}.venue-display footer span{width:7px;height:7px;border-radius:50%;background:#42616d}.venue-display footer span.active{width:22px;border-radius:5px;background:#63d6bc}.venue-display footer b{margin-left:8px;font:12px ui-monospace,monospace}
</style> </style>