12 Commits
26 changed files with 10234 additions and 78 deletions

No files matched your search

@@ -0,0 +1,177 @@
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/grades/sheets/{sheetId:guid}/discussion")]
public sealed class CourseDiscussionsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet("comments")]
public async Task<ActionResult> GetComments(Guid sheetId, DateTime? beforeCreatedAt,
Guid? beforeId, int pageSize = 30, CancellationToken cancellationToken = default)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) return NotFound();
if (beforeCreatedAt.HasValue != beforeId.HasValue) return BadRequest("评论游标不完整。");
pageSize = Math.Clamp(pageSize, 1, 50);
var userId = currentUserDataScope.Current.UserId;
var source = db.GradeAnalyticsComments.AsNoTracking()
.Where(x => x.CourseId == courseId && x.ParentCommentId == null);
if (beforeCreatedAt.HasValue)
{
var cursorAt = beforeCreatedAt.Value;
var cursorId = beforeId!.Value;
source = source.Where(x => x.CreatedAt < cursorAt ||
x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) < 0);
}
var rows = await source.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id)
.Take(pageSize + 1)
.Join(db.Users.AsNoTracking(), comment => comment.AuthorUserId, user => user.Id,
(comment, user) => new
{
comment.Id, comment.Content, comment.IsAnonymous, comment.CreatedAt,
AuthorName = comment.IsAnonymous ? "匿名用户" : user.DisplayName,
CanDelete = comment.AuthorUserId == userId || CanModerate(),
ReplyCount = db.GradeAnalyticsComments.Count(reply => reply.ParentCommentId == comment.Id),
LikeCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Like),
InsightfulCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Insightful),
AgreeCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Agree),
HoorayCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Hooray),
HeartCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Heart),
RocketCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Rocket),
EyesCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Eyes),
LaughCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Laugh),
HasLiked = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Like),
HasInsightful = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Insightful),
HasAgreed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Agree),
HasHoorayed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Hooray),
HasHearted = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Heart),
HasRocketed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Rocket),
HasEyed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Eyes),
HasLaughed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Laugh)
}).ToListAsync(cancellationToken);
var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).ToArray();
var last = items.LastOrDefault();
return Ok(new { Items = items, HasMore = hasMore,
NextCursor = hasMore && last is not null ? new { last.CreatedAt, last.Id } : null });
}
[HttpPost("comments")]
public async Task<ActionResult> CreateComment(Guid sheetId, CreateCourseDiscussionRequest request,
CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) return NotFound();
var content = request.Content?.Trim();
if (string.IsNullOrWhiteSpace(content) || content.Length > 1000)
return ValidationProblem("评论内容应为 1 到 1000 个字符。");
var userId = currentUserDataScope.Current.UserId;
if (userId == Guid.Empty) return Forbid();
db.GradeAnalyticsComments.Add(new GradeAnalyticsComment
{ CourseId = courseId.Value, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content });
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("comments/{commentId:guid}/replies")]
public async Task<ActionResult> GetReplies(Guid sheetId, Guid commentId, CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) return NotFound();
var userId = currentUserDataScope.Current.UserId;
var items = await db.GradeAnalyticsComments.AsNoTracking()
.Where(x => x.CourseId == courseId && x.ParentCommentId == commentId)
.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(100)
.Join(db.Users.AsNoTracking(), comment => comment.AuthorUserId, user => user.Id,
(comment, user) => new { comment.Id, comment.Content, comment.IsAnonymous, comment.CreatedAt,
AuthorName = comment.IsAnonymous ? "匿名用户" : user.DisplayName,
CanDelete = comment.AuthorUserId == userId || CanModerate() })
.ToListAsync(cancellationToken);
return Ok(new { Items = items });
}
[HttpPost("comments/{commentId:guid}/replies")]
public async Task<ActionResult> CreateReply(Guid sheetId, Guid commentId,
CreateCourseDiscussionRequest request, CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue || !await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId && x.ParentCommentId == null, cancellationToken)) return NotFound();
var content = request.Content?.Trim();
if (string.IsNullOrWhiteSpace(content) || content.Length > 1000) return ValidationProblem("回复内容应为 1 到 1000 个字符。");
var userId = currentUserDataScope.Current.UserId;
if (userId == Guid.Empty) return Forbid();
db.GradeAnalyticsComments.Add(new GradeAnalyticsComment { CourseId = courseId.Value, ParentCommentId = commentId, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content });
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("comments/{commentId:guid}/reactions/{reaction}")]
public async Task<ActionResult> ToggleReaction(Guid sheetId, Guid commentId, string reaction,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<GradeAnalyticsCommentReactionType>(reaction, true, out var type)) return BadRequest("不支持的 reaction。");
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue || !await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId, cancellationToken)) return NotFound();
var userId = currentUserDataScope.Current.UserId;
if (userId == Guid.Empty) return Forbid();
var existing = await db.GradeAnalyticsCommentReactions.SingleOrDefaultAsync(x => x.CommentId == commentId && x.UserId == userId && x.Type == type, cancellationToken);
if (existing is null) db.GradeAnalyticsCommentReactions.Add(new GradeAnalyticsCommentReaction { CommentId = commentId, UserId = userId, Type = type });
else db.GradeAnalyticsCommentReactions.Remove(existing);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("comments/{commentId:guid}")]
public async Task<ActionResult> DeleteComment(Guid sheetId, Guid commentId, CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) return NotFound();
var comment = await db.GradeAnalyticsComments.SingleOrDefaultAsync(x => x.Id == commentId && x.CourseId == courseId, cancellationToken);
if (comment is null) return NotFound();
if (comment.AuthorUserId != currentUserDataScope.Current.UserId && !CanModerate()) return Forbid();
db.GradeAnalyticsComments.Remove(comment);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private bool CanModerate() => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private async Task<Guid?> GetAccessibleCourseIdAsync(Guid sheetId, CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var sheet = await db.GradeSheets.AsNoTracking().Where(x => x.Id == sheetId)
.Select(x => new { x.Status, x.TeachingTask!.CourseId, CourseCollegeId = x.TeachingTask.Course!.CollegeId })
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return null;
if (scope.IsInRole(SystemRoles.Student))
{
if (sheet.Status != GradeSheetStatus.Published) return null;
return await db.GradeRecords.AsNoTracking().AnyAsync(x => x.GradeSheetId == sheetId && x.Student!.UserId == scope.UserId, cancellationToken)
? sheet.CourseId : null;
}
if (scope.Scope == DataScope.All) return sheet.CourseId;
if (scope.Scope == DataScope.College && scope.CanAccessCollege(sheet.CourseCollegeId)) return sheet.CourseId;
if (scope.IsInRole(SystemRoles.Teacher))
return await db.TeachingTasks.AsNoTracking().AnyAsync(x =>
x.CourseId == sheet.CourseId && x.Teachers.Any(t => t.Teacher!.UserId == scope.UserId),
cancellationToken) ? sheet.CourseId : null;
if (scope.IsInRole(SystemRoles.Counselor))
return await db.GradeRecords.AsNoTracking().AnyAsync(x => x.GradeSheetId == sheetId && x.Student!.AdministrativeClass!.CounselorUserId == scope.UserId, cancellationToken) ? sheet.CourseId : null;
return null;
}
}
public sealed record CreateCourseDiscussionRequest(string Content, bool IsAnonymous);
@@ -631,8 +631,10 @@ public sealed class EvaluationsController(
{
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));
return Ok(await PlanSummary(db.TeachingImprovementPlans.AsNoTracking()
.Where(x => x.TeacherId == teacherId.Value)
.OrderByDescending(x => x.AcademicTerm!.StartDate))
.ToListAsync(cancellationToken));
}
[HttpPost("closure/plans")]
@@ -676,7 +678,8 @@ public sealed class EvaluationsController(
{
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 planRows = await PlanSummary(plans.OrderByDescending(x => x.AcademicTerm!.StartDate))
.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);
@@ -190,6 +190,84 @@ public sealed class ExperimentGradesController(
});
}
[HttpGet("teaching-tasks/{teachingTaskId:guid}/export.xlsx")]
[Authorize(Roles = Managers)]
public async Task<IActionResult> ExportTeachingTaskScores(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var sheets = await ScopedSheets()
.Where(sheet =>
sheet.ExperimentProject!.TeachingTaskId == teachingTaskId)
.Include(sheet => sheet.Records)
.ThenInclude(record => record.Student)
.ThenInclude(student => student!.AdministrativeClass)
.Include(sheet => sheet.ExperimentProject)
.ThenInclude(project => project!.TeachingTask)
.ThenInclude(task => task!.AcademicTerm)
.Include(sheet => sheet.ExperimentProject)
.ThenInclude(project => project!.TeachingTask)
.ThenInclude(task => task!.Course)
.ThenInclude(course => course!.College)
.AsSplitQuery()
.AsNoTracking()
.OrderBy(sheet => sheet.ExperimentProject!.Code)
.ToListAsync(cancellationToken);
if (sheets.Count == 0)
return NotFound();
var task = sheets[0].ExperimentProject!.TeachingTask!;
var course = task.Course!;
var students = sheets
.SelectMany(sheet => sheet.Records)
.Select(record => record.Student!)
.GroupBy(student => student.Id)
.Select(group => group.First())
.OrderBy(student => student.StudentNumber)
.ToList();
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
db,
teachingTaskId,
students.Select(student => student.Id).ToArray(),
cancellationToken);
var headers = new List<string>
{
"学期", "开课学院", "课程代码", "课程名称", "教学班",
"学号", "姓名", "行政班"
};
headers.AddRange(sheets.Select(sheet =>
$"{sheet.ExperimentProject!.Code}·{sheet.ExperimentProject.Name}" +
$"({SheetStatusLabel(sheet.Status)})"));
headers.Add("实验合成总分(仅已发布项目)");
var rows = students.Select(student =>
{
var values = new List<object?>
{
task.AcademicTerm!.Name,
course.College!.Name,
course.Code,
course.Name,
task.TaskNumber,
student.StudentNumber,
student.Name,
student.AdministrativeClass!.Name
};
values.AddRange(sheets.Select(sheet => (object?)sheet.Records
.FirstOrDefault(record => record.StudentId == student.Id)
?.TotalScore));
values.Add(aggregate.Scores.GetValueOrDefault(student.Id));
return (IReadOnlyList<object?>)values;
}).ToList();
var bytes = ExcelWorkbookHelper.Create("课程实验成绩", headers, rows);
return File(bytes, ExcelWorkbookHelper.ContentType,
DownloadFileName.Export(
"课程实验成绩",
".xlsx",
course.Code,
task.TaskNumber));
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
@@ -568,21 +646,39 @@ public sealed class ExperimentGradesController(
sheet.ContributionWeight = request.ContributionWeight;
sheet.PassScore = request.PassScore;
db.ExperimentGradeItems.RemoveRange(sheet.Items);
var existingItems = sheet.Items.ToList();
var existingItemScores = sheet.Records
.SelectMany(record => record.ItemScores)
.ToList();
// MySQL must see each replacement item before its score rows. Do not
// rely on a single mixed insert/delete batch to infer that order.
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
db.ExperimentGradeItemScores.RemoveRange(existingItemScores);
db.ExperimentGradeItems.RemoveRange(existingItems);
foreach (var record in sheet.Records)
{
record.ItemScores.Clear();
record.TotalScore = null;
record.IsPassed = null;
}
sheet.Items = replacementItems;
foreach (var record in sheet.Records)
foreach (var item in replacementItems)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
foreach (var item in replacementItems)
item.ExperimentGradeSheetId = sheet.Id;
db.ExperimentGradeItems.AddRange(replacementItems);
await db.SaveChangesAsync(cancellationToken);
db.ExperimentGradeItemScores.AddRange(
sheet.Records.SelectMany(record => replacementItems.Select(item =>
new ExperimentGradeItemScore
{
ExperimentGradeRecordId = record.Id,
ExperimentGradeItemId = item.Id
})));
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
}
@@ -725,8 +821,20 @@ public sealed class ExperimentGradesController(
format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318");
}
});
var project = sheet.ExperimentProject!;
var task = project.TeachingTask!;
var course = await db.Courses.AsNoTracking()
.Where(item => item.Id == task.CourseId)
.Select(item => new { item.Code, item.Name })
.FirstOrDefaultAsync(cancellationToken);
return File(bytes, ExcelWorkbookHelper.ContentType,
DownloadFileName.Template("实验成绩", ".xlsx", sheet.ExperimentProject!.Code));
DownloadFileName.Template("实验成绩", ".xlsx",
course?.Code,
course?.Name,
task.TaskNumber,
task.Name,
project.Code,
project.Name));
}
[HttpPost("sheets/{id:guid}/import")]
@@ -1282,6 +1390,17 @@ public sealed class ExperimentGradesController(
_ => "待登记"
};
private static string SheetStatusLabel(ExperimentGradeSheetStatus status) =>
status switch
{
ExperimentGradeSheetStatus.Draft => "录入中",
ExperimentGradeSheetStatus.Submitted => "待审核",
ExperimentGradeSheetStatus.Approved => "审核通过",
ExperimentGradeSheetStatus.Published => "已发布",
ExperimentGradeSheetStatus.Returned => "已退回",
_ => status.ToString()
};
private static bool TryParseParticipationStatus(
ExcelRow row,
out ExperimentParticipationStatus status,
Loaded 3 of 26 files, more files were not shown because too many files have changed in this diff. Show more