成绩评论

This commit is contained in:
biss committed 2026-08-22 17:17:43 +08:00
1 parent 9ba83c3dc1
commit 124c4ba033
13 files changed
+9123 -2

No files matched your search

@@ -36,6 +36,175 @@ public sealed class GradeAnalyticsController(
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader;
[HttpGet("courses/{courseId:guid}/comments")]
public async Task<ActionResult> GetComments(
Guid courseId,
DateTime? beforeCreatedAt,
Guid? beforeId,
int pageSize = 30,
CancellationToken cancellationToken = default)
{
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
if (beforeCreatedAt.HasValue != beforeId.HasValue)
return BadRequest("评论游标不完整。");
pageSize = Math.Clamp(pageSize, 1, 50);
var currentUserId = currentUserDataScope.Current.UserId;
var canModerate = CanModerateComments();
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 == currentUserId || canModerate,
ReplyCount = db.GradeAnalyticsComments.Count(reply => reply.ParentCommentId == comment.Id),
LikeCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Like),
InsightfulCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Insightful),
AgreeCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Agree),
HoorayCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Hooray),
HeartCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Heart),
RocketCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Rocket),
EyesCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Eyes),
LaughCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Laugh),
HasLiked = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Like),
HasInsightful = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Insightful),
HasAgreed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Agree),
HasHoorayed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Hooray),
HasHearted = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Heart),
HasRocketed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Rocket),
HasEyed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Eyes),
HasLaughed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.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("courses/{courseId:guid}/comments")]
public async Task<ActionResult> CreateComment(
Guid courseId,
CreateGradeAnalyticsCommentRequest request,
CancellationToken cancellationToken)
{
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
var content = request.Content?.Trim();
if (string.IsNullOrWhiteSpace(content))
return ValidationProblem("评论内容不能为空。");
if (content.Length > 1000)
return ValidationProblem("评论不能超过 1000 个字符。");
var userId = currentUserDataScope.Current.UserId;
if (userId == Guid.Empty) return Forbid();
var comment = new GradeAnalyticsComment
{
CourseId = courseId,
AuthorUserId = userId,
IsAnonymous = request.IsAnonymous,
Content = content
};
db.GradeAnalyticsComments.Add(comment);
await db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(GetComments), new { courseId });
}
[HttpGet("courses/{courseId:guid}/comments/{commentId:guid}/replies")]
public async Task<ActionResult> GetReplies(Guid courseId, Guid commentId, CancellationToken cancellationToken)
{
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
var currentUserId = currentUserDataScope.Current.UserId;
var canModerate = CanModerateComments();
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 == currentUserId || canModerate })
.ToListAsync(cancellationToken);
return Ok(new { Items = items });
}
[HttpPost("courses/{courseId:guid}/comments/{commentId:guid}/replies")]
public async Task<ActionResult> CreateReply(Guid courseId, Guid commentId,
CreateGradeAnalyticsCommentRequest request, CancellationToken cancellationToken)
{
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
if (!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, ParentCommentId = commentId, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content });
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("courses/{courseId:guid}/comments/{commentId:guid}/reactions/{reaction}")]
public async Task<ActionResult> ToggleReaction(Guid courseId, Guid commentId, string reaction, CancellationToken cancellationToken)
{
if (!Enum.TryParse<GradeAnalyticsCommentReactionType>(reaction, true, out var type)) return BadRequest("不支持的 reaction。");
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
if (!await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId, cancellationToken)) return NotFound();
var userId = currentUserDataScope.Current.UserId;
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/{id:guid}")]
public async Task<ActionResult> DeleteComment(Guid id, CancellationToken cancellationToken)
{
var comment = await db.GradeAnalyticsComments
.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
if (comment is null) return NotFound();
if (comment.AuthorUserId != currentUserDataScope.Current.UserId && !CanModerateComments())
return Forbid();
db.GradeAnalyticsComments.Remove(comment);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private bool CanModerateComments() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<bool> CanAccessCommentCourseAsync(
Guid courseId,
CancellationToken cancellationToken) =>
db.TeachingTaskGradeStatistics.AsNoTracking().AnyAsync(x =>
x.CourseId == courseId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
cancellationToken);
[HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
@@ -934,3 +1103,7 @@ public sealed record SaveGradeStatisticsRefreshScheduleRequest(
bool IsEnabled,
int IntervalSeconds,
int BatchSize);
public sealed record CreateGradeAnalyticsCommentRequest(
string? Content,
bool IsAnonymous);