教学评价闭环:

新增同行/督导评价、教师改进计划、教师完成申报、管理员复评与退回机制。
闭环数据按教学班和学期归档;管理端可追踪计划数、已复评数及未闭环事项。
保留原学生评教流程;同行不能评价本人教学班,所有查询与写入均受学院数据范围限制。
This commit is contained in:
biss committed 2026-08-18 10:28:36 +08:00
1 parent 390d0b123f
commit 73f870221b
9 files changed
+8314 -103

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@
### P1:提升教学质量与服务体验
- [ ] **教学评价闭环**:在现有学生评教之外加入同行/督导评价、改进计划、复评和按学期追踪,避免评价只形成分数。
- [x] **教学评价闭环**:在现有学生评教之外加入同行/督导评价、改进计划、复评和按学期追踪,避免评价只形成分数。
- [ ] **学业风险处置工作流**:预警后生成辅导员跟进任务、谈话记录、帮扶措施与复核日期;仅向授权人员可见敏感信息。
- [ ] **学期校历与关键节点中心**:统一配置选课、成绩、评教、考试、毕业审核等截止时间,自动显示角色待办并在临期提醒。
- [ ] **教室设备与故障闭环**:场地二维码报修、设备可用状态、维修工单和排课时的不可用自动避让。
@@ -574,6 +574,143 @@ public sealed class EvaluationsController(
});
}
// ── Teaching quality closed loop: observation → plan → review ───
[HttpGet("closure/tasks")]
[Authorize(Roles = StaffRoles)]
public async Task<ActionResult> GetClosureTasks([FromQuery] Guid? termId, CancellationToken cancellationToken)
{
var query = ScopedTeachingTasks().AsNoTracking();
if (termId.HasValue) query = query.Where(x => x.AcademicTermId == termId.Value);
else query = query.Where(x => x.AcademicTerm!.IsCurrent);
var currentUserId = currentUserDataScope.Current.UserId;
var tasks = await query.OrderBy(x => x.Course!.Code).ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.TaskNumber,
CourseName = x.Course!.Name, CourseCode = x.Course.Code,
TeacherNames = x.Teachers.OrderByDescending(t => t.IsPrimary).Select(t => t.Teacher!.Name),
IsMyTeaching = x.Teachers.Any(t => t.Teacher!.UserId == currentUserId)
}).ToListAsync(cancellationToken);
return Ok(tasks);
}
[HttpPost("closure/observations")]
[Authorize(Roles = StaffRoles)]
public async Task<ActionResult> SubmitObservation(
TeachingObservationRequest request, CancellationToken cancellationToken)
{
if (request.Score is < 0 or > 100) return ValidationProblem("评价分数必须在 0 到 100 之间。");
if (string.IsNullOrWhiteSpace(request.Recommendation)) return ValidationProblem("请填写改进建议。");
var roleScope = currentUserDataScope.Current;
var canSupervise = roleScope.Roles.Any(role => role is SystemRoles.SuperAdmin or SystemRoles.AcademicAdmin or SystemRoles.CollegeAdmin or SystemRoles.Leader);
if (request.Source == EvaluationSource.Supervision && !canSupervise) return Forbid();
if (request.Source == EvaluationSource.Peer && !roleScope.IsInRole(SystemRoles.Teacher)) return Forbid();
var task = await ScopedTeachingTasks().AsNoTracking().FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound("教学班不存在或不在您的数据范围内。");
var isMyTeaching = await db.TeachingTaskTeachers.AnyAsync(x => x.TeachingTaskId == task.Id && x.Teacher!.UserId == roleScope.UserId, cancellationToken);
if (isMyTeaching) return ConflictProblem("不能对本人承担的教学班提交同行或督导评价。");
var duplicate = await db.TeachingObservationEvaluations.AnyAsync(x => x.TeachingTaskId == task.Id && x.EvaluatorUserId == roleScope.UserId && x.Source == request.Source, cancellationToken);
if (duplicate) return ConflictProblem("您已对此教学班提交过同类型评价。");
db.TeachingObservationEvaluations.Add(new TeachingObservationEvaluation
{
AcademicTermId = task.AcademicTermId, TeachingTaskId = task.Id, EvaluatorUserId = roleScope.UserId,
Source = request.Source, Score = request.Score, Strengths = request.Strengths.Trim(),
Issues = request.Issues.Trim(), Recommendation = request.Recommendation.Trim(), SubmittedAt = DateTime.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("closure/my-plans")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMyImprovementPlans(CancellationToken cancellationToken)
{
var teacherId = await CurrentTeacherId(cancellationToken);
if (!teacherId.HasValue) return ConflictProblem("当前账号未关联教师档案。");
return Ok(await PlanSummary(db.TeachingImprovementPlans.AsNoTracking().Where(x => x.TeacherId == teacherId.Value))
.OrderByDescending(x => x.TermStartDate).ToListAsync(cancellationToken));
}
[HttpPost("closure/plans")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> CreateImprovementPlan(TeachingImprovementPlanRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ProblemSummary) || string.IsNullOrWhiteSpace(request.Actions))
return ValidationProblem("请完整填写问题概述和改进措施。");
var teacherId = await CurrentTeacherId(cancellationToken);
if (!teacherId.HasValue) return ConflictProblem("当前账号未关联教师档案。");
var task = await db.TeachingTasks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null || !currentUserDataScope.Current.CanAccessCollege(task.Course!.CollegeId)) return NotFound();
var assigned = await db.TeachingTaskTeachers.AnyAsync(x => x.TeachingTaskId == task.Id && x.TeacherId == teacherId.Value, cancellationToken);
if (!assigned) return Forbid();
if (await db.TeachingImprovementPlans.AnyAsync(x => x.TeachingTaskId == task.Id && x.TeacherId == teacherId.Value, cancellationToken))
return ConflictProblem("该教学班已有改进计划,请在原计划中推进。");
var plan = new TeachingImprovementPlan { AcademicTermId = task.AcademicTermId, TeachingTaskId = task.Id, TeacherId = teacherId.Value,
ProblemSummary = request.ProblemSummary.Trim(), Actions = request.Actions.Trim(), DueDate = request.DueDate,
Status = ImprovementPlanStatus.Submitted, SubmittedAt = DateTime.UtcNow };
db.TeachingImprovementPlans.Add(plan);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { plan.Id });
}
[HttpPost("closure/plans/{id:guid}/complete")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> CompleteImprovementPlan(Guid id, CancellationToken cancellationToken)
{
var teacherId = await CurrentTeacherId(cancellationToken);
var plan = await db.TeachingImprovementPlans.FirstOrDefaultAsync(x => x.Id == id && x.TeacherId == teacherId, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status is ImprovementPlanStatus.Reviewed or ImprovementPlanStatus.Completed) return ConflictProblem("该计划已完成复评或已标记完成。");
plan.Status = ImprovementPlanStatus.Completed;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("closure/tracking")]
[Authorize(Roles = AdminRoles + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Leader)]
public async Task<ActionResult> GetClosureTracking([FromQuery] Guid? termId, CancellationToken cancellationToken)
{
var plans = db.TeachingImprovementPlans.AsNoTracking().Where(x => currentUserDataScope.Current.RestrictedCollegeId == null || x.TeachingTask!.Course!.CollegeId == currentUserDataScope.Current.RestrictedCollegeId);
if (termId.HasValue) plans = plans.Where(x => x.AcademicTermId == termId.Value);
var planRows = await PlanSummary(plans).OrderByDescending(x => x.TermStartDate).ToListAsync(cancellationToken);
var observations = db.TeachingObservationEvaluations.AsNoTracking().Where(x => currentUserDataScope.Current.RestrictedCollegeId == null || x.TeachingTask!.Course!.CollegeId == currentUserDataScope.Current.RestrictedCollegeId);
if (termId.HasValue) observations = observations.Where(x => x.AcademicTermId == termId.Value);
var observationStats = await observations.GroupBy(x => x.Source).Select(g => new { Source = g.Key.ToString(), Count = g.Count(), AverageScore = Math.Round(g.Average(x => (double)x.Score), 1) }).ToListAsync(cancellationToken);
return Ok(new { Plans = planRows, ObservationStats = observationStats, TotalPlans = planRows.Count, ReviewedPlans = planRows.Count(x => x.Status == ImprovementPlanStatus.Reviewed.ToString()) });
}
[HttpPost("closure/plans/{id:guid}/review")]
[Authorize(Roles = AdminRoles + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Leader)]
public async Task<ActionResult> ReviewImprovementPlan(Guid id, TeachingImprovementReviewRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Comment)) return ValidationProblem("请填写复评意见。");
var plan = await db.TeachingImprovementPlans.Include(x => x.TeachingTask).ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null || !currentUserDataScope.Current.CanAccessCollege(plan.TeachingTask!.Course!.CollegeId)) return NotFound();
if (plan.Status != ImprovementPlanStatus.Completed) return ConflictProblem("教师标记完成后才能复评。");
plan.Status = request.IsApproved ? ImprovementPlanStatus.Reviewed : ImprovementPlanStatus.Rework;
db.TeachingImprovementReviews.Add(new TeachingImprovementReview { TeachingImprovementPlanId = plan.Id, ReviewerUserId = currentUserDataScope.Current.UserId, IsApproved = request.IsApproved, Comment = request.Comment.Trim(), ReviewedAt = DateTime.UtcNow });
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private IQueryable<TeachingTask> ScopedTeachingTasks() =>
currentUserDataScope.Current.RestrictedCollegeId is { } collegeId
? db.TeachingTasks.Where(x => x.Course!.CollegeId == collegeId)
: db.TeachingTasks;
private async Task<Guid?> CurrentTeacherId(CancellationToken cancellationToken) => await db.Teachers
.Where(x => x.UserId == currentUserDataScope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
private static IQueryable<ClosurePlanSummary> PlanSummary(IQueryable<TeachingImprovementPlan> query) => query.Select(x => new ClosurePlanSummary(
x.Id, x.AcademicTermId, x.AcademicTerm!.Name, x.AcademicTerm.StartDate, x.TeachingTaskId, x.TeachingTask!.TaskNumber,
x.TeachingTask.Course!.Name, x.Teacher.Name, x.ProblemSummary, x.Actions, x.DueDate, x.Status.ToString(),
x.Reviews.OrderByDescending(r => r.ReviewedAt).Select(r => r.Comment).FirstOrDefault(), x.Reviews.Any()));
// ── Helpers ──────────────────────────────────────────────────────
private ActionResult ConflictProblem(string detail) =>
@@ -627,3 +764,25 @@ public sealed record TeacherEvaluationDimensionResult(
double AverageScore,
decimal TotalScore,
int Count);
public sealed record TeachingObservationRequest(
Guid TeachingTaskId,
EvaluationSource Source,
int Score,
[MaxLength(1000)] string Strengths,
[MaxLength(1000)] string Issues,
[MaxLength(1000)] string Recommendation);
public sealed record TeachingImprovementPlanRequest(
Guid TeachingTaskId,
[MaxLength(1000)] string ProblemSummary,
[MaxLength(2000)] string Actions,
DateOnly? DueDate);
public sealed record TeachingImprovementReviewRequest(bool IsApproved, [MaxLength(1000)] string Comment);
public sealed record ClosurePlanSummary(
Guid Id, Guid AcademicTermId, string TermName, DateOnly TermStartDate,
Guid TeachingTaskId, string TaskNumber, string CourseName, string TeacherName,
string ProblemSummary, string Actions, DateOnly? DueDate, string Status,
string? LatestReviewComment, bool HasReview);
@@ -82,3 +82,50 @@ public sealed class EvaluationScore
public int Score { get; set; }
}
/// <summary>同行或督导对教学班的独立评价,和学生匿名评教分开留痕。</summary>
public sealed class TeachingObservationEvaluation : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm AcademicTerm { get; set; } = null!;
public Guid TeachingTaskId { get; set; }
public TeachingTask TeachingTask { get; set; } = null!;
public Guid EvaluatorUserId { get; set; }
public EvaluationSource Source { get; set; }
public int Score { get; set; }
public string Strengths { get; set; } = string.Empty;
public string Issues { get; set; } = string.Empty;
public string Recommendation { get; set; } = string.Empty;
public DateTime SubmittedAt { get; set; }
}
public enum EvaluationSource { Peer = 1, Supervision = 2 }
/// <summary>教师针对评价结论形成的改进计划;复评记录挂在计划下。</summary>
public sealed class TeachingImprovementPlan : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm AcademicTerm { get; set; } = null!;
public Guid TeachingTaskId { get; set; }
public TeachingTask TeachingTask { get; set; } = null!;
public Guid TeacherId { get; set; }
public Teacher Teacher { get; set; } = null!;
public string ProblemSummary { get; set; } = string.Empty;
public string Actions { get; set; } = string.Empty;
public DateOnly? DueDate { get; set; }
public ImprovementPlanStatus Status { get; set; } = ImprovementPlanStatus.Draft;
public DateTime? SubmittedAt { get; set; }
public ICollection<TeachingImprovementReview> Reviews { get; set; } = new List<TeachingImprovementReview>();
}
public enum ImprovementPlanStatus { Draft = 0, Submitted = 1, InProgress = 2, Completed = 3, Rework = 4, Reviewed = 5 }
public sealed class TeachingImprovementReview : EntityBase
{
public Guid TeachingImprovementPlanId { get; set; }
public TeachingImprovementPlan TeachingImprovementPlan { get; set; } = null!;
public Guid ReviewerUserId { get; set; }
public bool IsApproved { get; set; }
public string Comment { get; set; } = string.Empty;
public DateTime ReviewedAt { get; set; }
}
Loaded 3 of 9 files, more files were not shown because too many files have changed in this diff. Show more