教学评价闭环:
新增同行/督导评价、教师改进计划、教师完成申报、管理员复评与退回机制。 闭环数据按教学班和学期归档;管理端可追踪计划数、已复评数及未闭环事项。 保留原学生评教流程;同行不能评价本人教学班,所有查询与写入均受学院数据范围限制。
This commit is contained in:
1 parent
390d0b123f
commit
73f870221b
9 files changed
+8314
-103
No files matched your search
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
### P1:提升教学质量与服务体验
|
### 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 ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
@@ -627,3 +764,25 @@ public sealed record TeacherEvaluationDimensionResult(
|
|||||||
double AverageScore,
|
double AverageScore,
|
||||||
decimal TotalScore,
|
decimal TotalScore,
|
||||||
int Count);
|
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; }
|
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
Reference in new issue
Block a user