评教系统

This commit is contained in:
2026-07-25 18:41:53 +08:00 Unverified
parent 15a34a306c
commit 376bc0d0a1
12 changed files with 6667 additions and 487 deletions
@@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AttendanceRoles)]
[Authorize]
[Route("api/attendance")]
public sealed class AttendanceController(
AppDbContext db,
@@ -27,6 +27,7 @@ public sealed class AttendanceController(
SystemRoles.Teacher;
[HttpGet("my-tasks")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetMyTasks(
Guid? academicTermId,
CancellationToken cancellationToken)
@@ -57,6 +58,7 @@ public sealed class AttendanceController(
}
[HttpGet("sheets")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetSheets(
Guid teachingTaskId,
CancellationToken cancellationToken)
@@ -88,6 +90,7 @@ public sealed class AttendanceController(
}
[HttpPost("sheets")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> CreateSheet(
AttendanceSheetRequest request,
CancellationToken cancellationToken)
@@ -123,6 +126,7 @@ public sealed class AttendanceController(
}
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets.AsNoTracking()
@@ -184,6 +188,7 @@ public sealed class AttendanceController(
}
[HttpPut("sheets/{id:guid}/records")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> UpdateRecords(
Guid id,
AttendanceRecordsRequest request,
@@ -193,6 +198,7 @@ public sealed class AttendanceController(
.Include(x => x.Records)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
@@ -213,6 +219,7 @@ public sealed class AttendanceController(
}
[HttpPost("sheets/{id:guid}/import")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> Import(
Guid id,
IFormFile file,
@@ -223,6 +230,7 @@ public sealed class AttendanceController(
.ThenInclude(x => x.Student)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
@@ -262,6 +270,7 @@ public sealed class AttendanceController(
}
[HttpGet("sheets/{id:guid}/export.xlsx")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> Export(Guid id, CancellationToken cancellationToken)
{
var sheetData = await db.AttendanceSheets.AsNoTracking()
@@ -308,11 +317,13 @@ public sealed class AttendanceController(
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
@@ -496,6 +507,7 @@ public sealed class AttendanceController(
.Include(r => r.AttendanceSheet)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(r =>
r.AttendanceSheetId == attendanceSheetId &&
r.StudentId == studentId,
@@ -538,11 +550,13 @@ public sealed class AttendanceController(
}
[HttpDelete("sheets/{id:guid}")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
@@ -0,0 +1,571 @@
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/evaluations")]
public sealed class EvaluationsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string AdminRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
private const string StaffRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher + "," +
SystemRoles.Leader;
// ── Admin: Manage setups ────────────────────────────────────────
[HttpGet("setups")]
[Authorize(Roles = AdminRoles)]
public async Task<ActionResult> GetSetups(CancellationToken cancellationToken)
{
var setups = await db.EvaluationSetups.AsNoTracking()
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
.Include(x => x.AcademicTerm)
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.Select(x => new
{
x.Id, x.Name, x.Status,
x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.StartsAt, x.EndsAt,
Dimensions = x.Dimensions.Select(d => new
{
d.Id, d.Name, d.MaxScore, d.SortOrder
}),
RecordCount = x.Records.Count,
x.CreatedAt
})
.ToListAsync(cancellationToken);
return Ok(setups);
}
[HttpPost("setups")]
[Authorize(Roles = AdminRoles)]
public async Task<ActionResult> CreateSetup(
EvaluationSetupRequest request,
CancellationToken cancellationToken)
{
if (request.Dimensions.Count == 0)
return ValidationProblem("至少需要一个评分维度。");
if (request.Dimensions.Sum(d => d.MaxScore) != 100)
return ValidationProblem("所有维度的满分之和必须等于 100。");
var termExists = await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId, cancellationToken);
if (!termExists) return ValidationProblem("所选学期不存在。");
var existing = await db.EvaluationSetups
.AnyAsync(x => x.AcademicTermId == request.AcademicTermId, cancellationToken);
if (existing) return ConflictProblem("该学期已有评教方案。");
var setup = new EvaluationSetup
{
AcademicTermId = request.AcademicTermId,
Name = request.Name.Trim(),
Status = EvaluationSetupStatus.Draft,
StartsAt = request.StartsAt,
EndsAt = request.EndsAt,
Dimensions = request.Dimensions.Select((d, i) => new EvaluationDimension
{
Name = d.Name.Trim(),
MaxScore = d.MaxScore,
SortOrder = i
}).ToList()
};
db.EvaluationSetups.Add(setup);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { setup.Id });
}
[HttpPut("setups/{id:guid}")]
[Authorize(Roles = AdminRoles)]
public async Task<ActionResult> UpdateSetup(
Guid id,
EvaluationSetupRequest request,
CancellationToken cancellationToken)
{
if (request.Dimensions.Count == 0)
return ValidationProblem("至少需要一个评分维度。");
if (request.Dimensions.Sum(d => d.MaxScore) != 100)
return ValidationProblem("所有维度的满分之和必须等于 100。");
var setup = await db.EvaluationSetups
.Include(x => x.Dimensions)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (setup is null) return NotFound();
if (setup.Status == EvaluationSetupStatus.Closed)
return ConflictProblem("已关闭的评教方案不能修改。");
setup.Name = request.Name.Trim();
setup.StartsAt = request.StartsAt;
setup.EndsAt = request.EndsAt;
db.EvaluationDimensions.RemoveRange(setup.Dimensions);
setup.Dimensions = request.Dimensions.Select((d, i) => new EvaluationDimension
{
EvaluationSetupId = setup.Id,
Name = d.Name.Trim(),
MaxScore = d.MaxScore,
SortOrder = i
}).ToList();
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("setups/{id:guid}/open")]
[Authorize(Roles = AdminRoles)]
public async Task<ActionResult> OpenSetup(Guid id, CancellationToken cancellationToken)
{
var setup = await db.EvaluationSetups.FindAsync([id], cancellationToken);
if (setup is null) return NotFound();
if (setup.Status == EvaluationSetupStatus.Closed)
return ConflictProblem("已关闭的方案不能重新开启。");
setup.Status = EvaluationSetupStatus.Open;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("setups/{id:guid}/close")]
[Authorize(Roles = AdminRoles)]
public async Task<ActionResult> CloseSetup(Guid id, CancellationToken cancellationToken)
{
var setup = await db.EvaluationSetups.FindAsync([id], cancellationToken);
if (setup is null) return NotFound();
setup.Status = EvaluationSetupStatus.Closed;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("setups/{id:guid}")]
[Authorize(Roles = AdminRoles)]
public async Task<ActionResult> DeleteSetup(Guid id, CancellationToken cancellationToken)
{
var setup = await db.EvaluationSetups
.Include(x => x.Records)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (setup is null) return NotFound();
if (setup.Records.Count > 0)
return ConflictProblem("已有评教记录的方案不能删除。");
db.EvaluationSetups.Remove(setup);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
// ── Student: Evaluate courses ────────────────────────────────────
[HttpGet("my-tasks")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyTasks(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var studentId = await db.Students
.Where(s => s.UserId == userId)
.Select(s => (Guid?)s.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
// Find open evaluation setups
var openSetups = await db.EvaluationSetups.AsNoTracking()
.Where(x => x.Status == EvaluationSetupStatus.Open)
.Select(x => new { x.Id, x.AcademicTermId })
.ToListAsync(cancellationToken);
if (openSetups.Count == 0)
return Ok(new { Setups = Array.Empty<object>(), Tasks = Array.Empty<object>() });
// Find enrolled teaching tasks for the student in those terms
var termIds = openSetups.Select(x => x.AcademicTermId).ToHashSet();
var enrollments = await db.CourseEnrollments.AsNoTracking()
.Where(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.StudentId == studentId.Value &&
termIds.Contains(e.CourseSelectionOffering!.TeachingTask!.AcademicTermId))
.Select(e => new
{
e.CourseSelectionOffering!.TeachingTaskId,
e.CourseSelectionOffering.TeachingTask!.AcademicTermId,
e.CourseSelectionOffering.TeachingTask.TaskNumber,
TaskName = e.CourseSelectionOffering.TeachingTask.Name,
CourseCode = e.CourseSelectionOffering.TeachingTask.Course!.Code,
CourseName = e.CourseSelectionOffering.TeachingTask.Course.Name,
TeacherNames = e.CourseSelectionOffering.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name)
})
.ToListAsync(cancellationToken);
// Check which tasks have already been evaluated
var evaluatedIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.StudentId == studentId.Value &&
openSetups.Select(s => s.Id).Contains(r.EvaluationSetupId))
.Select(r => r.TeachingTaskId)
.ToHashSetAsync(cancellationToken);
var tasks = enrollments.Select(e => new
{
e.TeachingTaskId,
e.AcademicTermId,
e.TaskNumber,
e.TaskName,
e.CourseCode,
e.CourseName,
e.TeacherNames,
SetupId = openSetups.First(s => s.AcademicTermId == e.AcademicTermId).Id,
IsEvaluated = evaluatedIds.Contains(e.TeachingTaskId)
}).ToList();
return Ok(new { Tasks = tasks });
}
[HttpGet("tasks/{teachingTaskId:guid}/form")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetEvaluationForm(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var studentId = await db.Students
.Where(s => s.UserId == userId)
.Select(s => (Guid?)s.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var task = await db.TeachingTasks.AsNoTracking()
.Where(x => x.Id == teachingTaskId)
.Select(x => new
{
x.Id, x.TaskNumber, x.Name,
x.AcademicTermId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
TeacherNames = x.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name)
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null) return NotFound();
var setup = await db.EvaluationSetups.AsNoTracking()
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
.Where(x => x.AcademicTermId == task.AcademicTermId &&
x.Status == EvaluationSetupStatus.Open)
.FirstOrDefaultAsync(cancellationToken);
if (setup is null) return ConflictProblem("当前学期没有开放的评教活动。");
// Check if already evaluated
var exists = await db.EvaluationRecords.AnyAsync(
x => x.EvaluationSetupId == setup.Id &&
x.StudentId == studentId.Value &&
x.TeachingTaskId == teachingTaskId,
cancellationToken);
if (exists) return ConflictProblem("您已经对该课程进行过评教。");
return Ok(new
{
SetupId = setup.Id,
setup.Name,
Dimensions = setup.Dimensions.Select(d => new
{
d.Id, d.Name, d.MaxScore, d.SortOrder
}),
Task = task
});
}
[HttpPost("tasks/{teachingTaskId:guid}/submit")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> SubmitEvaluation(
Guid teachingTaskId,
SubmitEvaluationRequest request,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var studentId = await db.Students
.Where(s => s.UserId == userId)
.Select(s => (Guid?)s.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var setup = await db.EvaluationSetups
.Include(x => x.Dimensions)
.FirstOrDefaultAsync(x => x.Id == request.SetupId, cancellationToken);
if (setup is null) return NotFound("评教方案不存在。");
if (setup.Status != EvaluationSetupStatus.Open)
return ConflictProblem("该评教活动已结束。");
// Validate against dimensions
var dimMap = setup.Dimensions.ToDictionary(d => d.Id);
if (request.Scores.Count != dimMap.Count)
return ValidationProblem("评分维度数量不匹配。");
var totalScore = 0;
foreach (var score in request.Scores)
{
if (!dimMap.TryGetValue(score.DimensionId, out var dim))
return ValidationProblem($"无效的评分维度。");
if (score.Score < 0 || score.Score > dim.MaxScore)
return ValidationProblem($"「{dim.Name}」评分超出范围(0-{dim.MaxScore})。");
totalScore += score.Score;
}
// Check duplicate
var exists = await db.EvaluationRecords.AnyAsync(
x => x.EvaluationSetupId == setup.Id &&
x.StudentId == studentId.Value &&
x.TeachingTaskId == teachingTaskId,
cancellationToken);
if (exists) return ConflictProblem("您已经对该课程进行过评教。");
var record = new EvaluationRecord
{
EvaluationSetupId = setup.Id,
StudentId = studentId.Value,
TeachingTaskId = teachingTaskId,
SubmittedAt = DateTime.UtcNow,
Scores = request.Scores.Select(s => new EvaluationScore
{
EvaluationDimensionId = s.DimensionId,
Score = s.Score
}).ToList()
};
db.EvaluationRecords.Add(record);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { record.Id, TotalScore = totalScore });
}
// ── Teacher / Staff: View results ─────────────────────────────────
[HttpGet("my-results")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMyResults(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var teacherId = await db.Teachers
.Where(t => t.UserId == userId)
.Select(t => (Guid?)t.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!teacherId.HasValue)
return ConflictProblem("当前账号未关联教师档案。");
var taskIds = await db.TeachingTaskTeachers
.Where(x => x.TeacherId == teacherId.Value)
.Select(x => x.TeachingTaskId)
.ToListAsync(cancellationToken);
if (taskIds.Count == 0)
return Ok(new { Tasks = Array.Empty<object>() });
var setups = await db.EvaluationSetups.AsNoTracking()
.Where(x => x.Status == EvaluationSetupStatus.Closed)
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ToListAsync(cancellationToken);
var result = new List<object>();
foreach (var setup in setups)
{
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.EvaluationSetupId == setup.Id &&
taskIds.Contains(r.TeachingTaskId))
.Select(r => r.TeachingTaskId)
.Distinct()
.ToListAsync(cancellationToken);
foreach (var taskId in setupTaskIds)
{
var task = await db.TeachingTasks.AsNoTracking()
.Where(x => x.Id == taskId)
.Select(x => new
{
x.Id, x.TaskNumber, x.Name,
x.AcademicTermId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null) continue;
var records = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.EvaluationSetupId == setup.Id &&
r.TeachingTaskId == taskId)
.Include(r => r.Scores)
.ToListAsync(cancellationToken);
if (records.Count == 0) continue;
var dimensionResults = setup.Dimensions.Select(dim =>
{
var scores = records
.SelectMany(r => r.Scores)
.Where(s => s.EvaluationDimensionId == dim.Id)
.Select(s => (double)s.Score)
.ToList();
return new
{
DimensionId = dim.Id,
DimensionName = dim.Name,
MaxScore = dim.MaxScore,
AverageScore = scores.Count > 0
? Math.Round(scores.Average(), 1) : 0,
TotalScore = scores.Sum(),
Count = scores.Count
};
}).ToList();
var totalAvg = records
.Select(r => (double)r.Scores.Sum(s => s.Score))
.ToList();
result.Add(new
{
SetupId = setup.Id,
setup.Name,
Task = task,
StudentCount = records.Count,
AverageTotal = totalAvg.Count > 0
? Math.Round(totalAvg.Average(), 1) : 0,
Dimensions = dimensionResults
});
}
}
return Ok(new { Tasks = result });
}
[HttpGet("setups/{id:guid}/results")]
[Authorize(Roles = StaffRoles)]
public async Task<ActionResult> GetSetupResults(
Guid id,
CancellationToken cancellationToken)
{
var setup = await db.EvaluationSetups.AsNoTracking()
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
.Include(x => x.AcademicTerm)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (setup is null) return NotFound();
var records = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.EvaluationSetupId == id)
.Include(r => r.Scores)
.Include(r => r.TeachingTask)
.ThenInclude(t => t!.Course)
.ThenInclude(c => c!.College)
.ToListAsync(cancellationToken);
// By teaching task
var byTask = records
.GroupBy(r => new
{
r.TeachingTaskId,
r.TeachingTask!.TaskNumber,
TaskName = r.TeachingTask.Name,
CourseName = r.TeachingTask.Course!.Name,
CollegeName = r.TeachingTask.Course.College!.Name,
TeacherNames = string.Join("、",
r.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name))
})
.Select(g =>
{
var dimAvgs = setup.Dimensions.Select(dim =>
{
var scores = g
.SelectMany(r => r.Scores)
.Where(s => s.EvaluationDimensionId == dim.Id)
.Select(s => (double)s.Score)
.ToList();
return new
{
DimensionName = dim.Name,
AverageScore = scores.Count > 0
? Math.Round(scores.Average(), 1) : 0,
Count = scores.Count
};
}).ToList();
var totals = g.Select(r => (double)r.Scores.Sum(s => s.Score)).ToList();
return new
{
g.Key.TeachingTaskId,
g.Key.TaskNumber,
g.Key.TaskName,
g.Key.CourseName,
g.Key.CollegeName,
g.Key.TeacherNames,
StudentCount = g.Count(),
AverageTotal = totals.Count > 0
? Math.Round(totals.Average(), 1) : 0,
Dimensions = dimAvgs
};
})
.OrderByDescending(x => x.AverageTotal)
.ToList();
return Ok(new
{
Setup = new
{
setup.Id, setup.Name, setup.Status,
TermName = setup.AcademicTerm!.Name,
setup.StartsAt, setup.EndsAt,
Dimensions = setup.Dimensions.Select(d => new
{
d.Id, d.Name, d.MaxScore, d.SortOrder
})
},
ByTask = byTask,
TotalRecords = records.Count,
DistinctTasks = byTask.Count,
DistinctStudents = records.Select(r => r.StudentId).Distinct().Count()
});
}
// ── Helpers ──────────────────────────────────────────────────────
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成评教操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record EvaluationSetupRequest(
Guid AcademicTermId,
[MaxLength(120)] string Name,
DateTime? StartsAt,
DateTime? EndsAt,
IReadOnlyCollection<EvaluationDimensionRequest> Dimensions);
public sealed record EvaluationDimensionRequest(
[MaxLength(60)] string Name,
int MaxScore);
public sealed record SubmitEvaluationRequest(
Guid SetupId,
IReadOnlyCollection<EvaluationScoreRequest> Scores);
public sealed record EvaluationScoreRequest(
Guid DimensionId,
int Score);
@@ -0,0 +1,84 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
/// <summary>
/// 评教方案 — 每个学期可以有一个评教方案,包含多个评分维度。
/// </summary>
public sealed class EvaluationSetup : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm AcademicTerm { get; set; } = null!;
/// <summary>方案名称,如"2026年秋季学期学生评教"</summary>
public string Name { get; set; } = string.Empty;
/// <summary>状态:Draft / Open / Closed</summary>
public EvaluationSetupStatus Status { get; set; } = EvaluationSetupStatus.Draft;
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
public ICollection<EvaluationDimension> Dimensions { get; set; } = new List<EvaluationDimension>();
public ICollection<EvaluationRecord> Records { get; set; } = new List<EvaluationRecord>();
}
public enum EvaluationSetupStatus
{
Draft = 0,
Open = 1,
Closed = 2
}
/// <summary>
/// 评分维度 — 例如"教学内容 30分"、"教学方法 30分"等。
/// </summary>
public sealed class EvaluationDimension : EntityBase
{
public Guid EvaluationSetupId { get; set; }
public EvaluationSetup EvaluationSetup { get; set; } = null!;
/// <summary>维度名称</summary>
public string Name { get; set; } = string.Empty;
/// <summary>该维度满分</summary>
public int MaxScore { get; set; }
/// <summary>排序</summary>
public int SortOrder { get; set; }
public ICollection<EvaluationScore> Scores { get; set; } = new List<EvaluationScore>();
}
/// <summary>
/// 学生评教记录 — 一个学生对一个教学班的评价。
/// </summary>
public sealed class EvaluationRecord : EntityBase
{
public Guid EvaluationSetupId { get; set; }
public EvaluationSetup EvaluationSetup { get; set; } = null!;
public Guid StudentId { get; set; }
public Student Student { get; set; } = null!;
public Guid TeachingTaskId { get; set; }
public TeachingTask TeachingTask { get; set; } = null!;
public DateTime? SubmittedAt { get; set; }
public ICollection<EvaluationScore> Scores { get; set; } = new List<EvaluationScore>();
}
/// <summary>
/// 评分明细 — 每个维度一条记录。
/// </summary>
public sealed class EvaluationScore
{
public Guid EvaluationRecordId { get; set; }
public EvaluationRecord EvaluationRecord { get; set; } = null!;
public Guid EvaluationDimensionId { get; set; }
public EvaluationDimension EvaluationDimension { get; set; } = null!;
public int Score { get; set; }
}
@@ -62,6 +62,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
public DbSet<WarningRule> WarningRules => Set<WarningRule>();
public DbSet<WarningRecord> WarningRecords => Set<WarningRecord>();
public DbSet<EvaluationSetup> EvaluationSetups => Set<EvaluationSetup>();
public DbSet<EvaluationDimension> EvaluationDimensions => Set<EvaluationDimension>();
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
@@ -731,6 +735,43 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
// ── Evaluation ──
builder.Entity<EvaluationSetup>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm).WithMany()
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<EvaluationDimension>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.HasIndex(x => new { x.EvaluationSetupId, x.SortOrder });
entity.HasOne(x => x.EvaluationSetup).WithMany(x => x.Dimensions)
.HasForeignKey(x => x.EvaluationSetupId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<EvaluationRecord>(entity =>
{
entity.HasIndex(x => new { x.EvaluationSetupId, x.StudentId, x.TeachingTaskId }).IsUnique();
entity.HasOne(x => x.EvaluationSetup).WithMany(x => x.Records)
.HasForeignKey(x => x.EvaluationSetupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<EvaluationScore>(entity =>
{
entity.HasKey(x => new { x.EvaluationRecordId, x.EvaluationDimensionId });
entity.HasOne(x => x.EvaluationRecord).WithMany(x => x.Scores)
.HasForeignKey(x => x.EvaluationRecordId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.EvaluationDimension).WithMany(x => x.Scores)
.HasForeignKey(x => x.EvaluationDimensionId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSubstitution>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
@@ -290,6 +290,14 @@ public sealed class DevelopmentSqliteMigrator(
AcademicWarningsMigration,
warningRulesExist ? [] : AcademicWarningStatements,
cancellationToken);
var evaluationSetupsExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'EvaluationSetups'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
TeachingEvaluationMigration,
evaluationSetupsExist ? [] : TeachingEvaluationStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1630,4 +1638,17 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "WarningRules" ADD COLUMN "CheckMinute" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;"""
];
private const string TeachingEvaluationMigration = "TeachingEvaluation";
private static readonly string[] TeachingEvaluationStatements =
[
"""CREATE TABLE "EvaluationSetups" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "StartsAt" TEXT NULL, "EndsAt" TEXT NULL, CONSTRAINT "PK_EvaluationSetups" PRIMARY KEY ("Id"), CONSTRAINT "FK_EvaluationSetups_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
"""CREATE INDEX "IX_EvaluationSetups_AcademicTermId_Status" ON "EvaluationSetups" ("AcademicTermId", "Status");""",
"""CREATE TABLE "EvaluationDimensions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "EvaluationSetupId" TEXT NOT NULL, "Name" TEXT NOT NULL, "MaxScore" INTEGER NOT NULL, "SortOrder" INTEGER NOT NULL, CONSTRAINT "PK_EvaluationDimensions" PRIMARY KEY ("Id"), CONSTRAINT "FK_EvaluationDimensions_EvaluationSetups_EvaluationSetupId" FOREIGN KEY ("EvaluationSetupId") REFERENCES "EvaluationSetups" ("Id") ON DELETE CASCADE);""",
"""CREATE INDEX "IX_EvaluationDimensions_EvaluationSetupId_SortOrder" ON "EvaluationDimensions" ("EvaluationSetupId", "SortOrder");""",
"""CREATE TABLE "EvaluationRecords" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "EvaluationSetupId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "SubmittedAt" TEXT NULL, CONSTRAINT "PK_EvaluationRecords" PRIMARY KEY ("Id"), CONSTRAINT "FK_EvaluationRecords_EvaluationSetups_EvaluationSetupId" FOREIGN KEY ("EvaluationSetupId") REFERENCES "EvaluationSetups" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_EvaluationRecords_Students_StudentId" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_EvaluationRecords_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX "IX_EvaluationRecords_SetupId_StudentId_TaskId" ON "EvaluationRecords" ("EvaluationSetupId", "StudentId", "TeachingTaskId");""",
"""CREATE TABLE "EvaluationScores" ("EvaluationRecordId" TEXT NOT NULL, "EvaluationDimensionId" TEXT NOT NULL, "Score" INTEGER NOT NULL, CONSTRAINT "PK_EvaluationScores" PRIMARY KEY ("EvaluationRecordId", "EvaluationDimensionId"), CONSTRAINT "FK_EvaluationScores_EvaluationRecords_EvaluationRecordId" FOREIGN KEY ("EvaluationRecordId") REFERENCES "EvaluationRecords" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_EvaluationScores_EvaluationDimensions_EvaluationDimensionId" FOREIGN KEY ("EvaluationDimensionId") REFERENCES "EvaluationDimensions" ("Id") ON DELETE RESTRICT);""",
];
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class TeachingEvaluation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff