已完成“考试安排与考场管理”模块:

考试计划草稿、场次配置与正式发布。
教学班、考试时间、考场、监考教师关联。
自动校验考场容量。
阻止考场、监考教师和学生考试时间冲突。
管理员查看考生名单。
教师查看个人监考安排。
学生查看已发布考试日程。
SQLite 增量升级和 MySQL 正式迁移。
This commit is contained in:
2026-07-24 15:43:55 +08:00 Unverified
parent af52e27a3a
commit 7f3e37de20
18 changed files with 3057 additions and 2 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单。 当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
+20
View File
@@ -309,6 +309,24 @@ try {
if (@($studentTranscript.records).Count -lt 1) { if (@($studentTranscript.records).Count -lt 1) {
throw 'Published grade is not visible in the student transcript.' throw 'Published grade is not visible in the student transcript.'
} }
$examPlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/exams/plans' -Headers $headers
if (@($examPlans).Count -lt 1) { throw 'Development exam plan was not seeded.' }
$examDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/exams/plans/$($examPlans[0].id)" `
-Headers $headers
if (@($examDetail.sessions).Count -lt 1) { throw 'Exam plan has no sessions.' }
$examRoster = Invoke-RestMethod `
-Uri "http://localhost:5255/api/exams/sessions/$($examDetail.sessions[0].id)/roster" `
-Headers $headers
$studentExams = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/exams/my-schedule' `
-Headers $studentHeaders
$teacherExams = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/exams/my-schedule' `
-Headers $teacherHeaders
if (@($studentExams).Count -lt 1 -or @($teacherExams).Count -lt 1) {
throw 'Personal exam schedule is not visible to student or invigilator.'
}
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5 $frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5 $spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
@@ -341,6 +359,8 @@ try {
GradeSheets = @($gradeTasks).Count GradeSheets = @($gradeTasks).Count
GradeRecords = @($gradeDetail.sheet.records).Count GradeRecords = @($gradeDetail.sheet.records).Count
TranscriptRecords = @($studentTranscript.records).Count TranscriptRecords = @($studentTranscript.records).Count
ExamSessions = @($examDetail.sessions).Count
ExamRoster = @($examRoster.students).Count
AccessUpdate = $true AccessUpdate = $true
ScopeChecks = $scopeChecks -join ', ' ScopeChecks = $scopeChecks -join ', '
StaticIndex = $frontend.Content.Contains('明序教务管理系统') StaticIndex = $frontend.Content.Contains('明序教务管理系统')
@@ -49,6 +49,8 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
x => x.Status == GradeSheetStatus.Published, x => x.Status == GradeSheetStatus.Published,
cancellationToken), cancellationToken),
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
Users = await db.Users.CountAsync(cancellationToken) Users = await db.Users.CountAsync(cancellationToken)
} }
}; };
@@ -0,0 +1,307 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/exams")]
public sealed class ExamsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("plans")]
public async Task<ActionResult> GetPlans(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = db.ExamPlans.AsNoTracking().AsQueryable();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (!IsManager())
source = source.Where(x => x.Status == ExamPlanStatus.Published);
return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt
}).ToListAsync(cancellationToken));
}
[HttpPost("plans")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreatePlan(
ExamPlanRequest request,
CancellationToken cancellationToken)
{
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
var plan = new ExamPlan
{
AcademicTermId = request.AcademicTermId,
Name = request.Name.Trim(),
Notes = Normalize(request.Notes)
};
db.ExamPlans.Add(plan);
return await SaveAsync(plan.Id, true, cancellationToken);
}
[HttpGet("plans/{id:guid}")]
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
{
var manager = IsManager();
var plan = await db.ExamPlans.AsNoTracking()
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
.Select(x => new
{
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.Status, x.Notes, x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.StartsAt).Select(item => new
{
item.Id, item.TeachingTaskId, item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId, ClassroomName = item.Classroom!.Name,
BuildingName = item.Classroom.Building!.Name,
ClassroomCapacity = item.Classroom.Capacity,
item.StartsAt, item.EndsAt, item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
})
}).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan);
}
[HttpPost("plans/{planId:guid}/sessions")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSession(
Guid planId,
ExamSessionRequest request,
CancellationToken cancellationToken)
{
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft)
return ConflictProblem("已发布的考试计划不能调整场次。");
var validation = await ValidateSessionAsync(plan, null, request, cancellationToken);
if (validation is not null) return validation;
var session = new ExamSession
{
ExamPlanId = planId,
TeachingTaskId = request.TeachingTaskId,
ClassroomId = request.ClassroomId,
StartsAt = request.StartsAt.ToUniversalTime(),
EndsAt = request.EndsAt.ToUniversalTime(),
Notes = Normalize(request.Notes),
Invigilators = request.InvigilatorIds.Distinct().Select(id =>
new ExamSessionInvigilator { TeacherId = id }).ToList()
};
db.ExamSessions.Add(session);
return await SaveAsync(session.Id, true, cancellationToken);
}
[HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteSession(
Guid planId,
Guid id,
CancellationToken cancellationToken)
{
var session = await db.ExamSessions.Include(x => x.ExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
if (session is null) return NotFound();
if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
return ConflictProblem("已发布的考试计划不能调整场次。");
db.ExamSessions.Remove(session);
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("plans/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var plan = await db.ExamPlans.Include(x => x.Sessions)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft)
return ConflictProblem("只有草稿考试计划可以发布。");
if (plan.Sessions.Count == 0)
return ConflictProblem("至少安排一个考试场次后才能发布。");
plan.Status = ExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken);
}
[HttpGet("sessions/{id:guid}/roster")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
{
var session = await db.ExamSessions.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber,
CourseName = x.TeachingTask.Course!.Name,
ClassroomName = x.Classroom!.Name, x.StartsAt,
Students = db.CourseEnrollments
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
.OrderBy(e => e.Student!.StudentNumber)
.Select(e => new
{
e.StudentId, e.Student!.StudentNumber, e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name
}).ToList()
}).FirstOrDefaultAsync(cancellationToken);
return session is null ? NotFound() : Ok(session);
}
[HttpGet("my-schedule")]
public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.Student))
{
var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。");
return Ok(await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId))
.OrderBy(x => x.StartsAt).Select(x => new
{
x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt,
x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
ClassroomName = x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name,
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name)
})
.ToListAsync(cancellationToken));
}
if (scope.IsInRole(SystemRoles.Teacher))
{
return Ok(await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
.OrderBy(x => x.StartsAt).Select(x => new
{
x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt,
x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
ClassroomName = x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name,
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
})
.ToListAsync(cancellationToken));
}
return Ok(Array.Empty<object>());
}
private async Task<ActionResult?> ValidateSessionAsync(
ExamPlan plan,
Guid? currentId,
ExamSessionRequest request,
CancellationToken cancellationToken)
{
var starts = request.StartsAt.ToUniversalTime();
var ends = request.EndsAt.ToUniversalTime();
if (starts >= ends) return ValidationProblem("考试开始时间必须早于结束时间。");
var task = await db.TeachingTasks.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null || task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("教学班与考试计划必须属于同一学期。");
var room = await db.Classrooms.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken);
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
var studentCount = await db.CourseEnrollments.CountAsync(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken);
if (studentCount > room.Capacity)
return ConflictProblem($"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。");
var teacherIds = request.InvigilatorIds.Distinct().ToArray();
if (teacherIds.Length == 0) return ValidationProblem("至少安排一名监考教师。");
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。");
var overlaps = db.ExamSessions.Where(x => x.ExamPlanId == plan.Id &&
x.Id != currentId && x.StartsAt < ends && starts < x.EndsAt);
if (await overlaps.AnyAsync(x => x.ClassroomId == room.Id, cancellationToken))
return ConflictProblem("该时段考场已被占用。");
if (await overlaps.AnyAsync(x =>
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), cancellationToken))
return ConflictProblem("监考教师在该时段已有考试任务。");
var studentIds = db.CourseEnrollments.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
.Select(x => x.StudentId);
if (await overlaps.AnyAsync(other =>
db.CourseEnrollments.Any(e =>
studentIds.Contains(e.StudentId) &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == other.TeachingTaskId),
cancellationToken))
return ConflictProblem("存在学生考试时间冲突。");
return null;
}
private bool IsManager() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private async Task<ActionResult> SaveAsync(Guid id, bool created, CancellationToken token)
{
try
{
await db.SaveChangesAsync(token);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("考试场次重复,或关联数据已发生变化。");
}
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成考务操作", Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record ExamPlanRequest(
Guid AcademicTermId,
[Required, MaxLength(120)] string Name,
[MaxLength(500)] string? Notes);
public sealed record ExamSessionRequest(
Guid TeachingTaskId,
Guid ClassroomId,
DateTime StartsAt,
DateTime EndsAt,
IReadOnlyCollection<Guid> InvigilatorIds,
[MaxLength(500)] string? Notes);
@@ -0,0 +1,43 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExamPlan : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public required string Name { get; set; }
public ExamPlanStatus Status { get; set; } = ExamPlanStatus.Draft;
public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ExamSession> Sessions { get; set; } = [];
}
public sealed class ExamSession : EntityBase
{
public Guid ExamPlanId { get; set; }
public ExamPlan? ExamPlan { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; }
public string? Notes { get; set; }
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
}
public sealed class ExamSessionInvigilator
{
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
}
public enum ExamPlanStatus
{
Draft = 1,
Published = 2,
Archived = 3
}
@@ -0,0 +1,11 @@
namespace Jiaowu.Api.Infrastructure.Exams;
public static class ExamConflictRules
{
public static bool TimeOverlaps(
DateTime firstStart,
DateTime firstEnd,
DateTime secondStart,
DateTime secondEnd) =>
firstStart < secondEnd && secondStart < firstEnd;
}
@@ -35,6 +35,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>(); public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>(); public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>();
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>(); public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@@ -353,6 +357,36 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExamPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm).WithMany()
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSession>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => x.ClassroomId);
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSessionInvigilator>(entity =>
{
entity.HasKey(x => new { x.ExamSessionId, x.TeacherId });
entity.HasOne(x => x.ExamSession).WithMany(x => x.Invigilators)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity => builder.Entity<AuditLog>(entity =>
{ {
entity.Property(x => x.Method).HasMaxLength(10); entity.Property(x => x.Method).HasMaxLength(10);
@@ -423,6 +423,7 @@ public sealed class DatabaseInitializer(
await SeedDevelopmentUsersAsync(computerCollege.Id); await SeedDevelopmentUsersAsync(computerCollege.Id);
await SeedDevelopmentCourseSelectionAsync(); await SeedDevelopmentCourseSelectionAsync();
await SeedDevelopmentGradesAsync(); await SeedDevelopmentGradesAsync();
await SeedDevelopmentExamsAsync();
} }
private async Task SeedDevelopmentCourseSelectionAsync() private async Task SeedDevelopmentCourseSelectionAsync()
@@ -506,6 +507,38 @@ public sealed class DatabaseInitializer(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private async Task SeedDevelopmentExamsAsync()
{
if (await db.ExamPlans.AnyAsync()) return;
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
var room = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001");
db.ExamPlans.Add(new ExamPlan
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期期末考试",
Status = ExamPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Notes = "本地开发演示考试计划。",
Sessions =
[
new ExamSession
{
TeachingTaskId = task.Id,
ClassroomId = room.Id,
StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc),
Invigilators =
[
new ExamSessionInvigilator { TeacherId = teacher.Id }
]
}
]
});
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentUsersAsync(Guid collegeId) private async Task SeedDevelopmentUsersAsync(Guid collegeId)
{ {
var definitions = new[] var definitions = new[]
@@ -13,6 +13,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string ClassCounselorMigration = "20260724_05_class_counselor"; private const string ClassCounselorMigration = "20260724_05_class_counselor";
private const string CourseSelectionMigration = "20260724_06_course_selection"; private const string CourseSelectionMigration = "20260724_06_course_selection";
private const string GradesMigration = "20260724_07_grades"; private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -68,6 +69,7 @@ public sealed class DevelopmentSqliteMigrator(
GradesMigration, GradesMigration,
GradesStatements, GradesStatements,
cancellationToken); cancellationToken);
await ApplyMigrationAsync(ExamsMigration, ExamsStatements, cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -569,4 +571,47 @@ public sealed class DevelopmentSqliteMigrator(
ON "GradeRecords" ("StudentId", "TotalScore"); ON "GradeRecords" ("StudentId", "TotalScore");
""" """
]; ];
private static readonly string[] ExamsStatements =
[
"""
CREATE TABLE IF NOT EXISTS "ExamPlans" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamPlans" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamPlans_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamPlans_AcademicTermId_Status" ON "ExamPlans" ("AcademicTermId", "Status");""",
"""
CREATE TABLE IF NOT EXISTS "ExamSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamSessions" PRIMARY KEY,
"ExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL,
"Notes" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamSessions_ExamPlans_ExamPlanId" FOREIGN KEY ("ExamPlanId")
REFERENCES "ExamPlans" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId")
REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_ExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId")
REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessions_ExamPlanId_StartsAt" ON "ExamSessions" ("ExamPlanId", "StartsAt");""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessions_TeachingTaskId" ON "ExamSessions" ("TeachingTaskId");""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessions_ClassroomId" ON "ExamSessions" ("ClassroomId");""",
"""
CREATE TABLE IF NOT EXISTS "ExamSessionInvigilators" (
"ExamSessionId" TEXT NOT NULL, "TeacherId" TEXT NOT NULL,
CONSTRAINT "PK_ExamSessionInvigilators" PRIMARY KEY ("ExamSessionId", "TeacherId"),
CONSTRAINT "FK_ExamSessionInvigilators_ExamSessions_ExamSessionId"
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExamSessionInvigilators_Teachers_TeacherId"
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");"""
];
} }
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamPlans",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamPlans", x => x.Id);
table.ForeignKey(
name: "FK_ExamPlans_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExamPlanId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamSessions", x => x.Id);
table.ForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamSessions_ExamPlans_ExamPlanId",
column: x => x.ExamPlanId,
principalTable: "ExamPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamSessions_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamSessionInvigilators",
columns: table => new
{
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamSessionInvigilators", x => new { x.ExamSessionId, x.TeacherId });
table.ForeignKey(
name: "FK_ExamSessionInvigilators_ExamSessions_ExamSessionId",
column: x => x.ExamSessionId,
principalTable: "ExamSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamSessionInvigilators_Teachers_TeacherId",
column: x => x.TeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExamPlans_AcademicTermId_Status",
table: "ExamPlans",
columns: new[] { "AcademicTermId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_ExamSessionInvigilators_TeacherId",
table: "ExamSessionInvigilators",
column: "TeacherId");
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ExamPlanId_StartsAt",
table: "ExamSessions",
columns: new[] { "ExamPlanId", "StartsAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_TeachingTaskId",
table: "ExamSessions",
column: "TeachingTaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamSessionInvigilators");
migrationBuilder.DropTable(
name: "ExamSessions");
migrationBuilder.DropTable(
name: "ExamPlans");
}
}
}
@@ -634,6 +634,100 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CurriculumPlans"); b.ToTable("CurriculumPlans");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Status");
b.ToTable("ExamPlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ExamPlanId")
.HasColumnType("char(36)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("StartsAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("ClassroomId");
b.HasIndex("TeachingTaskId");
b.HasIndex("ExamPlanId", "StartsAt");
b.ToTable("ExamSessions");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b =>
{
b.Property<Guid>("ExamSessionId")
.HasColumnType("char(36)");
b.Property<Guid>("TeacherId")
.HasColumnType("char(36)");
b.HasKey("ExamSessionId", "TeacherId");
b.HasIndex("TeacherId");
b.ToTable("ExamSessionInvigilators");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1538,6 +1632,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Major"); b.Navigation("Major");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan")
.WithMany("Sessions")
.HasForeignKey("ExamPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("ExamPlan");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession")
.WithMany("Invigilators")
.HasForeignKey("ExamSessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
.WithMany()
.HasForeignKey("TeacherId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ExamSession");
b.Navigation("Teacher");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
@@ -1782,6 +1933,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Modules"); b.Navigation("Modules");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.Navigation("Sessions");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b =>
{
b.Navigation("Invigilators");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b =>
{ {
b.Navigation("Records"); b.Navigation("Records");
@@ -0,0 +1,22 @@
using Jiaowu.Api.Infrastructure.Exams;
namespace Jiaowu.Api.Tests;
public sealed class ExamConflictRulesTests
{
[Fact]
public void Overlapping_exam_times_conflict() =>
Assert.True(ExamConflictRules.TimeOverlaps(
new DateTime(2027, 1, 8, 9, 0, 0),
new DateTime(2027, 1, 8, 11, 0, 0),
new DateTime(2027, 1, 8, 10, 30, 0),
new DateTime(2027, 1, 8, 12, 0, 0)));
[Fact]
public void Adjacent_exam_times_do_not_conflict() =>
Assert.False(ExamConflictRules.TimeOverlaps(
new DateTime(2027, 1, 8, 9, 0, 0),
new DateTime(2027, 1, 8, 11, 0, 0),
new DateTime(2027, 1, 8, 11, 0, 0),
new DateTime(2027, 1, 8, 13, 0, 0)));
}
+9
View File
@@ -11,6 +11,7 @@ import {
Calendar, Calendar,
CircleCheck, CircleCheck,
DocumentChecked, DocumentChecked,
AlarmClock,
User, User,
UserFilled, UserFilled,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
@@ -43,6 +44,7 @@ const pageTitle = computed(() => {
schedules: '排课与课表', schedules: '排课与课表',
'course-selections': '选课与教学班', 'course-selections': '选课与教学班',
grades: '成绩与学业档案', grades: '成绩与学业档案',
exams: '考试与考场',
users: '用户与权限', users: '用户与权限',
} }
return titles[String(route.name)] ?? '教务管理' return titles[String(route.name)] ?? '教务管理'
@@ -138,6 +140,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><DocumentChecked /></el-icon> <el-icon><DocumentChecked /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '学业成绩' : '成绩管理' }}</template> <template #title>{{ auth.user?.roles.includes('Student') ? '学业成绩' : '成绩管理' }}</template>
</el-menu-item> </el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'].includes(role))"
index="/exams"
>
<el-icon><AlarmClock /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '我的考试' : auth.user?.roles.includes('Teacher') ? '我的监考' : '考试管理' }}</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users"> <el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<template #title>用户与权限</template> <template #title>用户与权限</template>
+6
View File
@@ -74,6 +74,12 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'], roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
}, },
}, },
{
path: 'exams',
name: 'exams',
component: () => import('../views/ExamsView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
},
{ {
path: 'users', path: 'users',
name: 'users', name: 'users',
+44
View File
@@ -510,6 +510,42 @@ button { cursor: pointer; }
.transcript-result b { margin: 5px 0 3px; color: var(--indigo); font: 700 25px/1 Consolas, monospace; } .transcript-result b { margin: 5px 0 3px; color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
.transcript-result small { color: var(--muted); font-size: 9px; } .transcript-result small { color: var(--muted); font-size: 9px; }
.exam-plan-strip { padding: 10px; display: flex; gap: 9px; overflow-x: auto; border: 1px solid var(--line); background: white; }
.exam-plan-strip button { flex: 0 0 275px; padding: 14px; display: grid; grid-template-columns: 1fr auto; gap: 6px; text-align: left; border: 1px solid var(--line); background: #fafbfc; }
.exam-plan-strip button.active { color: white; border-color: var(--indigo); background: var(--indigo); }
.exam-plan-strip b { grid-column: 1/-1; font-family: "STZhongsong","Songti SC",serif; }
.exam-plan-strip span, .exam-plan-strip small, .exam-plan-strip i { color: #8c94a3; font-size: 9px; font-style: normal; }
.exam-plan-strip button.active span, .exam-plan-strip button.active small { color: #c5cee5; }
.exam-board { border: 1px solid var(--line); background: white; }
.exam-board > header { padding: 22px 24px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); }
.exam-board header span { color: var(--teal); font: 700 9px Consolas,monospace; letter-spacing: .13em; }
.exam-board header h3 { margin: 7px 0 5px; font: 600 21px "STZhongsong","Songti SC",serif; }
.exam-board header p { margin: 0; color: var(--muted); font-size: 10px; }
.exam-timeline { padding: 10px 22px 24px 42px; }
.exam-timeline article { min-height: 108px; display: grid; grid-template-columns: 120px minmax(220px,1fr) 210px 120px; align-items: center; gap: 20px; position: relative; border-bottom: 1px solid var(--line); }
.exam-timeline article::before { content:""; position:absolute; left:-24px; width:9px; height:9px; border:3px solid white; border-radius:50%; background:var(--teal); box-shadow:0 0 0 1px var(--teal); }
.exam-timeline article::after { content:""; position:absolute; left:-20px; top:0; bottom:0; width:1px; background:#cdd4df; z-index:-1; }
.exam-timeline time b, .exam-timeline time span { display:block; }
.exam-timeline time b { color:var(--indigo); font:700 15px Consolas,monospace; }
.exam-timeline time span { margin-top:5px; color:var(--muted); font-size:9px; }
.exam-timeline article > div > span { color:var(--teal); font-size:9px; }
.exam-timeline h4 { margin:6px 0 4px; font-size:14px; }
.exam-timeline p { margin:0; color:var(--muted); font-size:9px; }
.exam-staff { padding-left:18px; border-left:1px solid var(--line); }
.exam-staff b,.exam-staff small { display:block; margin-top:5px; font-size:10px; }
.exam-staff small { color:var(--muted); }
.exam-row-actions { text-align:right; }
.exam-ticket-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.exam-ticket-grid article { min-height:180px; display:grid; grid-template-columns:105px 1fr; border:1px solid var(--line); background:white; overflow:hidden; }
.exam-ticket-date { padding:20px 14px; display:grid; place-content:center; text-align:center; color:white; background:linear-gradient(145deg,#182d64,#21706f); }
.exam-ticket-date b { font:700 17px Consolas,monospace; }
.exam-ticket-date span { margin-top:8px; color:#bfe0da; font-size:9px; }
.exam-ticket-grid article > div:nth-child(2) { padding:24px 20px; }
.exam-ticket-grid article > div:nth-child(2) span { color:var(--teal); font-size:9px; }
.exam-ticket-grid h3 { margin:9px 0 7px; font:600 19px "STZhongsong","Songti SC",serif; }
.exam-ticket-grid p { margin:0; color:var(--muted); font-size:10px; }
.exam-ticket-grid footer { grid-column:1/-1; padding:12px 17px; display:flex; align-items:center; gap:7px; border-top:1px dashed var(--line); color:#586275; font-size:10px; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; } .login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; } .login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; } .login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
@@ -615,6 +651,8 @@ button { cursor: pointer; }
.transcript-list > article { grid-template-columns: 1fr 120px; } .transcript-list > article { grid-template-columns: 1fr 120px; }
.grade-scale { grid-column: 1 / -1; grid-row: 2; } .grade-scale { grid-column: 1 / -1; grid-row: 2; }
.transcript-result { grid-column: 2; grid-row: 1; } .transcript-result { grid-column: 2; grid-row: 1; }
.exam-timeline article { grid-template-columns:100px 1fr 180px; }
.exam-row-actions { grid-column:2/-1; padding-bottom:10px; text-align:left; }
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; } .role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; } .form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; } .el-dialog { width: calc(100vw - 24px) !important; }
@@ -654,6 +692,12 @@ button { cursor: pointer; }
.transcript-list > article { grid-template-columns: 1fr; gap: 17px; } .transcript-list > article { grid-template-columns: 1fr; gap: 17px; }
.transcript-result { grid-column: auto; grid-row: auto; padding: 12px 0 0; border-left: none; border-top: 1px solid var(--line); text-align: left; } .transcript-result { grid-column: auto; grid-row: auto; padding: 12px 0 0; border-left: none; border-top: 1px solid var(--line); text-align: left; }
.grade-scale { grid-column: auto; grid-row: auto; } .grade-scale { grid-column: auto; grid-row: auto; }
.exam-board > header { align-items:flex-start; flex-direction:column; }
.exam-timeline { padding-left:28px; }
.exam-timeline article { padding:16px 0; grid-template-columns:1fr; gap:9px; }
.exam-staff { padding:10px 0 0; border-left:none; border-top:1px solid var(--line); }
.exam-row-actions { grid-column:auto; padding:0; }
.exam-ticket-grid { grid-template-columns:1fr; }
.el-drawer { width: 100% !important; } .el-drawer { width: 100% !important; }
} }
+7 -1
View File
@@ -117,13 +117,18 @@ onMounted(async () => {
<b>{{ data.counts.gradeSheets ?? 0 }} 张登记册 · {{ data.counts.publishedGradeSheets ?? 0 }} 张已发布</b> <b>{{ data.counts.gradeSheets ?? 0 }} 张登记册 · {{ data.counts.publishedGradeSheets ?? 0 }} 张已发布</b>
<i class="done">已建立</i> <i class="done">已建立</i>
</div> </div>
<div>
<span>考试考场</span>
<b>{{ data.counts.examPlans ?? 0 }} 个计划 · {{ data.counts.examSessions ?? 0 }} 个场次</b>
<i class="done">已建立</i>
</div>
</div> </div>
</article> </article>
<article class="work-card phase-card"> <article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span> <span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3> <h3>下一段业务链</h3>
<p>成绩录入审核发布与学生成绩单已就绪下一步进入考试安排与考场管理</p> <p>考试计划考场监考与个人日程已就绪下一步进入学籍异动与毕业审核</p>
<div class="phase-line"> <div class="phase-line">
<span class="active">基础底座</span> <span class="active">基础底座</span>
<span class="active">人员档案</span> <span class="active">人员档案</span>
@@ -131,6 +136,7 @@ onMounted(async () => {
<span class="active">排课课表</span> <span class="active">排课课表</span>
<span class="active">学生选课</span> <span class="active">学生选课</span>
<span class="active">成绩管理</span> <span class="active">成绩管理</span>
<span class="active">考试考场</span>
</div> </div>
</article> </article>
</section> </section>
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isManager = computed(() =>
auth.user?.roles.some((r) => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const personal = ref<any[]>([])
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const rooms = ref<any[]>([])
const teachers = ref<any[]>([])
const loading = ref(false)
const planDialog = ref(false)
const sessionDialog = ref(false)
const rosterDrawer = ref(false)
const roster = ref<any | null>(null)
const planForm = reactive<Record<string, any>>({})
const sessionForm = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿', Published: '已发布', Archived: '已归档',
}
function dateText(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', weekday: 'short',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
function toIso(value: string) { return new Date(value.replace(' ', 'T')).toISOString() }
async function load() {
loading.value = true
try {
if (!isManager.value) {
personal.value = (await http.get('/exams/my-schedule')).data
return
}
plans.value = (await http.get('/exams/plans')).data
const plan = plans.value.find((x) => x.id === selected.value?.id) ?? plans.value[0]
if (plan) await selectPlan(plan.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { loading.value = false }
}
async function selectPlan(id: string) {
selected.value = (await http.get(`/exams/plans/${id}`)).data
}
function openPlan() {
Object.assign(planForm, {
academicTermId: terms.value.find((x) => x.isCurrent)?.id,
name: '', notes: '',
})
planDialog.value = true
}
async function savePlan() {
try {
await http.post('/exams/plans', planForm)
planDialog.value = false
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function openSession() {
Object.assign(sessionForm, {
teachingTaskId: undefined, classroomId: undefined,
startsAt: '', endsAt: '', invigilatorIds: [], notes: '',
})
sessionDialog.value = true
}
async function saveSession() {
try {
await http.post(`/exams/plans/${selected.value.id}/sessions`, {
...sessionForm,
startsAt: toIso(sessionForm.startsAt),
endsAt: toIso(sessionForm.endsAt),
})
sessionDialog.value = false
await selectPlan(selected.value.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function removeSession(row: any) {
try {
await ElMessageBox.confirm(`移除“${row.courseName}”考试场次?`, '移除场次', { type: 'warning' })
await http.delete(`/exams/plans/${selected.value.id}/sessions/${row.id}`)
await selectPlan(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', {
type: 'warning', confirmButtonText: '确认发布',
})
await http.post(`/exams/plans/${selected.value.id}/publish`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function showRoster(row: any) {
try {
roster.value = (await http.get(`/exams/sessions/${row.id}/roster`)).data
rosterDrawer.value = true
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
onMounted(async () => {
try {
if (isManager.value) {
const [termRes, taskRes, roomRes, teacherRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 100 } }),
http.get('/base-data/classrooms'),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 100, teacherStatus: 'Active' } }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
rooms.value = roomRes.data
teachers.value = teacherRes.data.items
}
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
})
</script>
<template>
<div class="page-stack exam-page">
<section class="page-intro">
<div>
<span class="section-kicker">EXAMINATION OFFICE</span>
<h2>{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}</h2>
<p>{{ isManager ? '集中安排考试时间、考场与监考教师,并在发布前消除冲突。' : '查看学校已经正式发布的考试日程。' }}</p>
</div>
<el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建考试计划</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新日程</el-button>
</section>
<template v-if="isManager">
<section class="exam-plan-strip">
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)">
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
</button>
</section>
<section v-if="selected" class="exam-board" v-loading="loading">
<header>
<div><span>EXAM TIMELINE</span><h3>{{ selected.name }}</h3><p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p></div>
<div>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession">安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
</div>
</header>
<div class="exam-timeline">
<article v-for="session in selected.sessions" :key="session.id">
<time><b>{{ dateText(session.startsAt).split(' ')[0] }}</b><span>{{ dateText(session.startsAt).split(' ').slice(1).join(' ') }}</span></time>
<div><span>{{ session.courseCode }} · {{ session.taskNumber }}</span><h4>{{ session.courseName }}</h4><p>{{ session.buildingName }} · {{ session.classroomName }} · {{ session.studentCount }} </p></div>
<div class="exam-staff"><span>监考</span><b>{{ session.invigilatorNames.join('、') }}</b><small>{{ dateText(session.startsAt) }}{{ dateText(session.endsAt).split(' ').slice(-1)[0] }}</small></div>
<div class="exam-row-actions">
<el-button link type="primary" @click="showRoster(session)">考生名单</el-button>
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
</div>
</article>
<el-empty v-if="!selected.sessions.length" description="尚未安排考试场次" />
</div>
</section>
</template>
<section v-else class="exam-ticket-grid" v-loading="loading">
<article v-for="item in personal" :key="item.id">
<div class="exam-ticket-date"><b>{{ dateText(item.startsAt).split(' ')[0] }}</b><span>{{ dateText(item.startsAt).split(' ').slice(1).join(' ') }}</span></div>
<div><span>{{ item.courseCode }} · {{ item.taskNumber }}</span><h3>{{ item.courseName }}</h3><p>{{ item.buildingName }} · {{ item.classroomName }}</p></div>
<footer><el-icon><UserFilled /></el-icon>{{ isTeacher ? `${item.studentCount} 名考生` : `监考:${item.invigilatorNames.join('、')}` }}</footer>
</article>
<el-empty v-if="!personal.length" description="暂无已发布考试安排" />
</section>
<el-dialog v-model="planDialog" title="新建考试计划" width="600px">
<el-form label-position="top">
<el-form-item label="学期"><el-select v-model="planForm.academicTermId"><el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" /></el-select></el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog=false">取消</el-button><el-button type="primary" @click="savePlan">保存草稿</el-button></template>
</el-dialog>
<el-dialog v-model="sessionDialog" title="安排考试场次" width="680px">
<el-form label-position="top">
<el-form-item label="教学班"><el-select v-model="sessionForm.teachingTaskId" filterable><el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" /></el-select></el-form-item>
<el-form-item label="考场"><el-select v-model="sessionForm.classroomId"><el-option v-for="x in rooms" :key="x.id" :label="`${x.name} · ${x.capacity} 座`" :value="x.id" /></el-select></el-form-item>
<div class="form-grid"><el-form-item label="开始时间"><el-date-picker v-model="sessionForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item><el-form-item label="结束时间"><el-date-picker v-model="sessionForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item></div>
<el-form-item label="监考教师"><el-select v-model="sessionForm.invigilatorIds" multiple filterable><el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="sessionDialog=false">取消</el-button><el-button type="primary" @click="saveSession">保存场次</el-button></template>
</el-dialog>
<el-drawer v-model="rosterDrawer" title="考生名单" size="560px">
<el-table v-if="roster" :data="roster.students"><el-table-column prop="studentNumber" label="学号" width="130" /><el-table-column prop="name" label="姓名" width="90" /><el-table-column prop="className" label="行政班" /></el-table>
</el-drawer>
</div>
</template>