评教系统
This commit is contained in:
1 parent
15a34a306c
commit
376bc0d0a1
12 files changed
+6667
-487
No files matched your search
@@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace Jiaowu.Api.Controllers;
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = AttendanceRoles)]
|
[Authorize]
|
||||||
[Route("api/attendance")]
|
[Route("api/attendance")]
|
||||||
public sealed class AttendanceController(
|
public sealed class AttendanceController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
@@ -27,6 +27,7 @@ public sealed class AttendanceController(
|
|||||||
SystemRoles.Teacher;
|
SystemRoles.Teacher;
|
||||||
|
|
||||||
[HttpGet("my-tasks")]
|
[HttpGet("my-tasks")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> GetMyTasks(
|
public async Task<ActionResult> GetMyTasks(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -57,6 +58,7 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sheets")]
|
[HttpGet("sheets")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
Guid teachingTaskId,
|
Guid teachingTaskId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -88,6 +90,7 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets")]
|
[HttpPost("sheets")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> CreateSheet(
|
public async Task<ActionResult> CreateSheet(
|
||||||
AttendanceSheetRequest request,
|
AttendanceSheetRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -123,6 +126,7 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sheets/{id:guid}")]
|
[HttpGet("sheets/{id:guid}")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheet = await db.AttendanceSheets.AsNoTracking()
|
var sheet = await db.AttendanceSheets.AsNoTracking()
|
||||||
@@ -184,6 +188,7 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("sheets/{id:guid}/records")]
|
[HttpPut("sheets/{id:guid}/records")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> UpdateRecords(
|
public async Task<ActionResult> UpdateRecords(
|
||||||
Guid id,
|
Guid id,
|
||||||
AttendanceRecordsRequest request,
|
AttendanceRecordsRequest request,
|
||||||
@@ -193,6 +198,7 @@ public sealed class AttendanceController(
|
|||||||
.Include(x => x.Records)
|
.Include(x => x.Records)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!CanManageSheet(sheet)) return Forbid();
|
if (!CanManageSheet(sheet)) return Forbid();
|
||||||
@@ -213,6 +219,7 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/import")]
|
[HttpPost("sheets/{id:guid}/import")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> Import(
|
public async Task<ActionResult> Import(
|
||||||
Guid id,
|
Guid id,
|
||||||
IFormFile file,
|
IFormFile file,
|
||||||
@@ -223,6 +230,7 @@ public sealed class AttendanceController(
|
|||||||
.ThenInclude(x => x.Student)
|
.ThenInclude(x => x.Student)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!CanManageSheet(sheet)) return Forbid();
|
if (!CanManageSheet(sheet)) return Forbid();
|
||||||
@@ -262,6 +270,7 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sheets/{id:guid}/export.xlsx")]
|
[HttpGet("sheets/{id:guid}/export.xlsx")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> Export(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Export(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheetData = await db.AttendanceSheets.AsNoTracking()
|
var sheetData = await db.AttendanceSheets.AsNoTracking()
|
||||||
@@ -308,11 +317,13 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/submit")]
|
[HttpPost("sheets/{id:guid}/submit")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheet = await db.AttendanceSheets
|
var sheet = await db.AttendanceSheets
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!CanManageSheet(sheet)) return Forbid();
|
if (!CanManageSheet(sheet)) return Forbid();
|
||||||
@@ -496,6 +507,7 @@ public sealed class AttendanceController(
|
|||||||
.Include(r => r.AttendanceSheet)
|
.Include(r => r.AttendanceSheet)
|
||||||
.ThenInclude(s => s!.TeachingTask)
|
.ThenInclude(s => s!.TeachingTask)
|
||||||
.ThenInclude(t => t!.Teachers)
|
.ThenInclude(t => t!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(r =>
|
.FirstOrDefaultAsync(r =>
|
||||||
r.AttendanceSheetId == attendanceSheetId &&
|
r.AttendanceSheetId == attendanceSheetId &&
|
||||||
r.StudentId == studentId,
|
r.StudentId == studentId,
|
||||||
@@ -538,11 +550,13 @@ public sealed class AttendanceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("sheets/{id:guid}")]
|
[HttpDelete("sheets/{id:guid}")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheet = await db.AttendanceSheets
|
var sheet = await db.AttendanceSheets
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!CanManageSheet(sheet)) return Forbid();
|
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; }
|
||||||
|
}
|
||||||
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user