From f12afe8a3670f6e379ce064c483c57af4bbe2762 Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Wed, 28 Oct 2026 14:48:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=A3=81=E5=88=A4?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=86=E9=A1=B5=E4=B8=8E=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E5=9C=BA=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=9F=A5=E8=AF=A2=E5=B9=B6=E7=A8=B3=E5=AE=9A=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E8=A3=81=E5=88=A4=E4=BB=BB=E5=8A=A1=EF=BC=8C=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=88=86=E9=A1=B5=E5=8F=8A=E5=90=84=E9=A1=B5=E5=9C=BA?= =?UTF-8?q?=E6=AC=A1=E8=8C=83=E5=9B=B4=EF=BC=9B=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=8C=89=E5=87=BA=E5=9C=BA=20ID=20=E5=AE=9A=E4=BD=8D=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=92=8C=E6=9F=A5=E8=AF=A2=E5=BD=93=E5=89=8D=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E4=B9=8B=E5=90=8E=E4=B8=8B=E4=B8=80=E6=9D=A1=E6=9C=AA?= =?UTF-8?q?=E8=AF=84=E5=88=86=E4=BB=BB=E5=8A=A1=E7=9A=84=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=9C=BA=E6=AC=A1=E6=91=98=E8=A6=81?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Client/CheckinScheduleService.cs | 10 +- .../Judge/Dtos/JudgeScoringDtos.cs | 27 ++ .../Judge/JudgeScoringController.cs | 231 +++++++++++++++++- 3 files changed, 252 insertions(+), 16 deletions(-) diff --git a/YD_Event.Application/Client/CheckinScheduleService.cs b/YD_Event.Application/Client/CheckinScheduleService.cs index 82134e4..97a59ae 100644 --- a/YD_Event.Application/Client/CheckinScheduleService.cs +++ b/YD_Event.Application/Client/CheckinScheduleService.cs @@ -173,17 +173,13 @@ public class CheckinScheduleService : ITransient var sessionOrderMap = sessions.ToDictionary( x => x.Id, x => GetImportedSessionOrder(x.SessionName, GetSessionOrder(x.SessionNo))); - var orderedSessions = sessions - .OrderBy(x => sessionOrderMap[x.Id]) - .ThenBy(x => venueMap.GetValueOrDefault(x.VenueId)?.Sort ?? int.MaxValue) - .ThenBy(x => x.SessionNo) - .ToList(); var sessionMap = sessions.ToDictionary(x => x.Id); + // 与成绩管理列表保持一致:导入记录的 ID 按秩序册预览行顺序生成。 + // SessionName 中的“第 N 场”仅用于展示场次,不能用它重新排列选手。 var units = entries .Where(x => sessionMap.ContainsKey(x.SessionId)) - .OrderBy(x => sessionOrderMap[x.SessionId]) - .ThenBy(x => x.PlayOrder) + .OrderBy(x => x.Id) .Select((entry, index) => { var session = sessionMap[entry.SessionId]; diff --git a/YD_Event.Application/Judge/Dtos/JudgeScoringDtos.cs b/YD_Event.Application/Judge/Dtos/JudgeScoringDtos.cs index 82e2531..3469247 100644 --- a/YD_Event.Application/Judge/Dtos/JudgeScoringDtos.cs +++ b/YD_Event.Application/Judge/Dtos/JudgeScoringDtos.cs @@ -1,5 +1,32 @@ namespace YD_Event.Application.Scoring.Dtos; +/// 裁判任务分页结果;场次范围使用秩序册中的实际场次号。 +public class JudgeTaskPageDto +{ + public long EventId { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public int TotalPages { get; set; } + public List PageRanges { get; set; } = new(); + public List Items { get; set; } = new(); +} + +public class JudgeTaskPageRangeDto +{ + public int Page { get; set; } + public int FirstSessionOrder { get; set; } + public int LastSessionOrder { get; set; } +} + +/// 仅含当前裁判可访问的出场信息,不含平台最终成绩。 +public class JudgeTaskDto +{ + public int Page { get; set; } + public JudgeSessionDto Session { get; set; } = new(); + public JudgeEntryDto Entry { get; set; } = new(); +} + /// /// 裁判端查询的当前场次赛事状态与操作权限。 /// diff --git a/YD_Event.Application/Judge/JudgeScoringController.cs b/YD_Event.Application/Judge/JudgeScoringController.cs index 1c9f3da..83a5d7e 100644 --- a/YD_Event.Application/Judge/JudgeScoringController.cs +++ b/YD_Event.Application/Judge/JudgeScoringController.cs @@ -187,12 +187,17 @@ public class JudgeScoringController : IDynamicApiController .Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id && x.IsCurrent && !x.DeleteMark && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted).ToListAsync(); + var entriesBySession = entries.GroupBy(x => x.SessionId) + .ToDictionary(x => x.Key, x => x.ToList()); + var scoredBySession = mySheets.GroupBy(x => x.SessionId) + .ToDictionary(x => x.Key, x => x.Count()); + var assignmentsBySession = assignments.GroupBy(x => x.SessionId) + .ToDictionary(x => x.Key, x => x.First()); var rows = new List(); foreach (var session in sessions) { - var assignment = assignments.FirstOrDefault(x => x.SessionId == session.Id); - if (assignment == null) continue; + if (!assignmentsBySession.TryGetValue(session.Id, out var assignment)) continue; var roleType = JudgeRoleSchemaService.NormalizeRoleType( string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType); var project = eventProjects.TryGetValue(session.EventProjectId, out var ep) @@ -200,6 +205,7 @@ public class JudgeScoringController : IDynamicApiController var rule = rules.GetValueOrDefault(session.ScoreRuleId); var slot = JudgeRoleSchemaService.ParseSlots(rule?.JudgeRoleSchemaJson) .FirstOrDefault(x => string.Equals(x.Code, roleType, StringComparison.OrdinalIgnoreCase)); + entriesBySession.TryGetValue(session.Id, out var sessionEntries); rows.Add(new JudgeSessionDto { SessionId = session.Id, @@ -209,9 +215,8 @@ public class JudgeScoringController : IDynamicApiController RuleName = rule?.RuleName ?? string.Empty, SessionName = session.SessionName ?? session.SessionNo, VenueName = venues.GetValueOrDefault(session.VenueId)?.VenueName ?? "-", - GroupName = entries.Where(x => x.SessionId == session.Id).Select(x => x.GroupName) - .Distinct().Count() == 1 - ? entries.First(x => x.SessionId == session.Id).GroupName + GroupName = sessionEntries?.Select(x => x.GroupName).Distinct().Count() == 1 + ? sessionEntries[0].GroupName : null, StartTime = session.StartTime, SessionStatus = session.SessionStatus, @@ -219,8 +224,8 @@ public class JudgeScoringController : IDynamicApiController JudgeRole = assignment.JudgeRole, JudgeRoleType = roleType, JudgeRoleName = slot?.Name ?? JudgeRoleSchemaService.RoleName(roleType), - EntryCount = entries.Count(x => x.SessionId == session.Id), - ScoredCount = mySheets.Count(x => x.SessionId == session.Id), + EntryCount = sessionEntries?.Count ?? 0, + ScoredCount = scoredBySession.GetValueOrDefault(session.Id), ArrangementOrder = GetArrangementSequence(session.SessionNo) is var order && order < int.MaxValue ? order @@ -230,6 +235,212 @@ public class JudgeScoringController : IDynamicApiController return rows; } + /// 分页读取当前裁判最近待执裁赛事的任务。 + [HttpGet("/api/judge-scoring/tasks")] + [DisplayName("分页获取我的评分任务")] + public async Task GetTaskPage([FromQuery] int page = 1, [FromQuery] int pageSize = 10) + { + ValidateTaskPageSize(pageSize); + if (page < 1) throw Oops.Bah("页码必须大于0"); + var (eventId, tasks) = await LoadJudgeTasksAsync(); + var totalPages = (tasks.Count + pageSize - 1) / pageSize; + var actualPage = totalPages == 0 ? 1 : Math.Min(page, totalPages); + for (var index = 0; index < tasks.Count; index++) + tasks[index].Page = index / pageSize + 1; + + return new JudgeTaskPageDto + { + EventId = eventId, + Page = actualPage, + PageSize = pageSize, + TotalCount = tasks.Count, + TotalPages = totalPages, + PageRanges = tasks.Chunk(pageSize).Select((chunk, index) => new JudgeTaskPageRangeDto + { + Page = index + 1, + FirstSessionOrder = chunk[0].Entry.SessionOrder, + LastSessionOrder = chunk[^1].Entry.SessionOrder + }).ToList(), + Items = tasks.Skip((actualPage - 1) * pageSize).Take(pageSize).ToList() + }; + } + + /// 按出场ID定位本人任务,用于修正通知跳转。 + [HttpGet("/api/judge-scoring/tasks/{sessionEntryId:long}")] + [DisplayName("按出场ID获取我的评分任务")] + public async Task GetTaskByEntry(long sessionEntryId, [FromQuery] int pageSize = 10) + { + ValidateTaskPageSize(pageSize); + var entry = await _entryRepository.GetFirstAsync(x => x.Id == sessionEntryId && !x.DeleteMark); + if (entry == null) throw Oops.Bah("评分任务不存在或不属于当前裁判"); + var session = await _sessionRepository.GetFirstAsync(x => x.Id == entry.SessionId && !x.DeleteMark); + if (session == null) throw Oops.Bah("评分任务不存在或不属于当前裁判"); + var (_, tasks) = await LoadJudgeTasksAsync(session.EventId); + var index = tasks.FindIndex(x => x.Entry.SessionEntryId == sessionEntryId); + if (index < 0) throw Oops.Bah("评分任务不存在或不属于当前裁判"); + tasks[index].Page = index / pageSize + 1; + return tasks[index]; + } + + /// 只沿编排顺序向后查找下一条正常待评分任务。 + [HttpGet("/api/judge-scoring/tasks/next")] + [DisplayName("获取下一条待评分任务")] + public async Task GetNextTask([FromQuery] long afterEntryId, [FromQuery] int pageSize = 10) + { + ValidateTaskPageSize(pageSize); + var (_, tasks) = await LoadJudgeTasksAsync(); + var index = tasks.FindIndex(x => x.Entry.SessionEntryId == afterEntryId); + if (index < 0) + { + // The completed event may no longer be the selected event. Verify + // ownership before advancing to the first task of the new event. + var referee = await EnsureRefereeAsync(); + var previous = await _entryRepository.GetFirstAsync(x => x.Id == afterEntryId && !x.DeleteMark); + if (previous == null) throw Oops.Bah("当前评分任务不存在或不属于当前裁判"); + await GetMyAssignmentAsync(previous.SessionId, referee.Id); + } + for (var next = index + 1; next < tasks.Count; next++) + { + if (tasks[next].Entry.ScoreStatus is 2 or 3) continue; + tasks[next].Page = next / pageSize + 1; + return tasks[next]; + } + return null; + } + + private static void ValidateTaskPageSize(int pageSize) + { + if (pageSize is not (10 or 20 or 50)) throw Oops.Bah("每页数量只支持10、20或50"); + } + + /// 一次批量读取任务所需数据,避免对每个场次请求一次出场名单。 + private async Task<(long EventId, List Tasks)> LoadJudgeTasksAsync(long? eventId = null) + { + var referee = await EnsureRefereeAsync(); + var summaries = await GetSessions(eventId); + if (summaries.Count == 0) return (0, new List()); + + var targetEventId = summaries[0].EventId; + var sessionIds = summaries.Select(x => x.SessionId).ToList(); + var summaryBySession = summaries.ToDictionary(x => x.SessionId); + var sessionOrder = summaries.Select((value, index) => (value.SessionId, index)) + .ToDictionary(x => x.SessionId, x => x.index); + var sessions = (await _sessionRepository.AsQueryable() + .Where(x => sessionIds.Contains(x.Id)).ToListAsync()) + .ToDictionary(x => x.Id); + var assignments = (await _assignmentRepository.AsQueryable() + .Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id && + x.AssignmentStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) + .ToListAsync()).GroupBy(x => x.SessionId) + .ToDictionary(x => x.Key, x => x.First()); + var allEntries = await _entryRepository.AsQueryable() + .Where(x => sessionIds.Contains(x.SessionId) && !x.DeleteMark).ToListAsync(); + var entryById = allEntries.ToDictionary(x => x.Id); + var entries = allEntries.Where(x => !x.IsVoided && x.Status == AvailabilityStatus.Enable).ToList(); + if (entries.Count == 0) return (targetEventId, new List()); + var sheets = await _sheetRepository.AsQueryable() + .Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id && !x.DeleteMark) + .ToListAsync(); + var submittedByEntry = sheets.Where(x => x.IsCurrent && + x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted) + .GroupBy(x => x.SessionEntryId).ToDictionary(x => x.Key, x => x.First()); + var draftByEntry = sheets.Where(x => x.SheetStatus == SheetStatusDraft) + .GroupBy(x => x.SessionEntryId).ToDictionary(x => x.Key, x => x.First()); + var assignmentIds = assignments.Values.Select(x => x.Id).ToList(); + var corrections = await _correctionRepository.AsQueryable() + .Where(x => sessionIds.Contains(x.SessionId) && assignmentIds.Contains(x.JudgeAssignmentId) && + (x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing)) + .ToListAsync(); + var correctionSheetIds = corrections.Select(x => x.OriginalScoreSheetId).ToHashSet(); + + var eventProjects = await _eventProjectRepository.AsQueryable() + .Where(x => x.EventId == targetEventId && !x.DeleteMark).ToListAsync(); + var projectIds = eventProjects.Select(x => x.ProjectId).Distinct().ToList(); + var projects = (await _projectRepository.AsQueryable() + .Where(x => projectIds.Contains(x.Id) && !x.DeleteMark).ToListAsync()) + .ToDictionary(x => x.Id); + var eventProjectById = eventProjects.ToDictionary(x => x.Id); + var orderedProjectIds = projects.Values.OrderBy(x => x.SortIndex).ThenBy(x => x.Id) + .Select(x => x.Id).ToList(); + var projectOrderById = orderedProjectIds.Select((id, index) => (id, index)) + .ToDictionary(x => x.id, x => x.index + 1); + var venueIds = sessions.Values.Select(x => x.VenueId).Distinct().ToList(); + var venues = (await _venueRepository.AsQueryable() + .Where(x => venueIds.Contains(x.Id) && !x.DeleteMark).ToListAsync()) + .ToDictionary(x => x.Id); + var ruleIds = sessions.Values.Select(x => x.ScoreRuleId).Distinct().ToList(); + var rules = (await _ruleRepository.AsQueryable() + .Where(x => ruleIds.Contains(x.Id) && !x.DeleteMark).ToListAsync()) + .ToDictionary(x => x.Id); + var factorByRuleId = rules.ToDictionary(x => x.Key, + x => ScoreRuleConfigService.Parse(x.Value.RuleConfigJson).IsFactorTotal); + + int DisplayPlayOrder(EventSessionEntry entry) + { + var result = entry.PlayOrder; + var sourceId = entry.RematchFromEntryId; + var visited = new HashSet { entry.Id }; + while (sourceId.HasValue && visited.Add(sourceId.Value) && + entryById.TryGetValue(sourceId.Value, out var source)) + { + result = source.PlayOrder; + sourceId = source.RematchFromEntryId; + } + return result; + } + + var tasks = new List(entries.Count); + foreach (var entry in entries) + { + if (!sessions.TryGetValue(entry.SessionId, out var session) || + !assignments.TryGetValue(entry.SessionId, out var assignment)) continue; + var summary = summaryBySession[entry.SessionId]; + var arrangement = GetArrangementSequence(session.SessionNo); + var eventProject = eventProjectById.GetValueOrDefault(session.EventProjectId); + var project = eventProject == null ? null : projects.GetValueOrDefault(eventProject.ProjectId); + var projectOrder = eventProject == null + ? orderedProjectIds.Count + 1 + : projectOrderById.GetValueOrDefault(eventProject.ProjectId, orderedProjectIds.Count + 1); + var venue = venues.GetValueOrDefault(session.VenueId); + var venueOrder = venue?.Sort > 0 ? venue.Sort : 1; + var scope = project == null ? ScoreScope.Personal : ScoreSheetCalculationService.ResolveScope(project.ProjectType); + var submitted = submittedByEntry.GetValueOrDefault(entry.Id); + var draft = draftByEntry.GetValueOrDefault(entry.Id); + var inCorrection = submitted != null && correctionSheetIds.Contains(submitted.Id); + var status = inCorrection ? 3 : submitted != null ? 2 : draft != null ? 1 : 0; + var isFactorTotal = factorByRuleId.GetValueOrDefault(session.ScoreRuleId); + tasks.Add(new JudgeTaskDto + { + Session = summary, + Entry = new JudgeEntryDto + { + SessionEntryId = entry.Id, + EntryNo = entry.EntryNo, + PlayOrder = entry.PlayOrder, + SessionOrder = entry.SourceType == 1 + ? GetImportedSessionOrder(session.SessionName, + BuildEntrySessionOrder(arrangement, DisplayPlayOrder(entry))) + : BuildEntrySessionOrder(arrangement, DisplayPlayOrder(entry)), + ScheduleOrder = entry.SourceType == 1 ? entry.Id : + BuildArrangementScheduleOrder(projectOrder, arrangement, venueOrder, entry.PlayOrder), + ParticipantName = DisplayParticipantName(entry, scope), + GroupName = entry.GroupName, + EntryStatus = entry.EntryStatus, + IsRematch = entry.IsRematch, + ScoreStatus = status, + ScoreStatusName = ScoreStatusName(status), + MyScore = submitted == null ? null : ScoreDisplayService.JudgeText(submitted, isFactorTotal), + IsDisqualified = submitted?.IsDisqualified ?? false, + ScoreSheetId = submitted?.Id ?? draft?.Id ?? 0 + } + }); + } + return (targetEventId, tasks.OrderBy(x => x.Entry.ScheduleOrder) + .ThenBy(x => sessionOrder[x.Session.SessionId]) + .ThenBy(x => x.Entry.PlayOrder) + .ThenBy(x => x.Entry.SessionEntryId).ToList()); + } + /// /// 查询当前场次的赛事阶段与操作权限,供裁判端进入场次或准备评分时刷新界面状态。 /// @@ -1302,10 +1513,12 @@ public class JudgeScoringController : IDynamicApiController if (uploaded < 0m || uploaded > 1m) throw Oops.Bah("扣分系数必须在0至1之间"); - // 明细行保留了递进扣分的未舍入小计,用它校验 0.125 等三位小数系数。 - var expected = calculation.Lines + // 明细行保留递进扣分的未舍入小计;累计扣分可超过 1, + // 但上传的扣分系数与最终 M=Max(0, 1-扣分) 使用同一 0..1 口径。 + var rawDeduction = calculation.Lines .Where(x => x.ScoreDimension == ScoreDimensionCode.Deduction) .Sum(x => x.Subtotal); + var expected = Math.Clamp(rawDeduction, 0m, 1m); if (Math.Abs(uploaded.Value - expected) > 0.0005m) throw Oops.Bah($"扣分系数与扣分明细不一致,应为{expected:0.###}"); }