diff --git a/TODO.md b/TODO.md index 5251580..72b62e2 100644 --- a/TODO.md +++ b/TODO.md @@ -73,8 +73,8 @@ - [x] 按活动规则汇总已确认线下票,生成候选人/议案选项得票、有效/无效/弃权/缺投统计及当选名单;线上投票接入后复用同一结果服务。 - [ ] 建立结果审核、监票确认、发布和归档流程。 - [x] SignalR 推送已确认线下计票的公开看板数据;断线后使用公开结果接口受控轮询兜底。 -- [ ] 公开看板显示活动状态、参与人数、参与率、数据更新时间和公开规则说明。 -- [ ] 候选人实时票数、排名和图表只在活动明确开启后公开;不得公开未确认线下结果。 +- [x] 公开看板显示活动状态、参与人数、数据更新时间和公开规则说明;`/results` 以受控轮询刷新。 +- [x] 候选人实时票数只在活动明确开启后公开;接口与 SignalR 推送共用公开策略,且不会公开未确认线下结果。 - [x] CMS 公开信息基础:建立八类固定内容定义(选举公告、规则与政策、候选人公示、选民指南、结果公告、常见问题、资料下载、历史归档)及只读公开内容契约。 - [x] 公开信息前端:提供八类内容的首页最新发布区、直接栏目菜单入口、目录/详情页、发布内容空状态与服务不可用提示;采用响应式中性政务信息风格。 - [ ] CMS:栏目、文章、公告、政策、操作指南、候选人公示、结果公告、媒体库与附件。 diff --git a/docs/offline-election-counting.md b/docs/offline-election-counting.md index fbd471d..51e0123 100644 --- a/docs/offline-election-counting.md +++ b/docs/offline-election-counting.md @@ -17,8 +17,8 @@ ## 对外公开 -- `GET /api/public/elections/{electionId}/results` 仅返回活动明确允许的数据。截止及结果发布前,默认只能按活动配置公开参与量;候选人或议案票数只有在 `PublishLiveCandidateTotals=true` 或最终结果发布后才返回。 -- SignalR 端点为 `/hubs/public-results`;客户端调用 `Subscribe(electionId)` 后接收 `resultUpdated`。推送只基于已确认的线下批次。 +- `GET /api/public/elections/{electionId}/results` 与 `/results` 公开看板仅返回活动明确允许的数据。截止及结果发布前,默认只能按活动配置公开参与量;候选人或议案票数只有在 `PublishLiveCandidateTotals=true` 或最终结果发布后才返回。开启实时票数必须同时开启参与量公开。 +- SignalR 端点为 `/hubs/public-results`;客户端调用 `Subscribe(electionId)` 后接收 `resultUpdated`。Hub 订阅和推送与公开接口使用同一公开策略,不能借此读取未获准公开的票数。公开看板每 15 秒受控轮询接口,以便在实时连接不可用时仍可刷新。 - 每次确认、以及后台每分钟推进活动状态时都会更新公开数据。所有时间均为 UTC。 ## 管理 API diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Elections.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Elections.razor index 9d7d213..584a8c5 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Elections.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Elections.razor @@ -21,7 +21,7 @@ @if (selected.Status == ElectionActivityStatus.Draft) {

添加岗位或议案

添加选项

-

指定计票所

@foreach (var station in stations) { }
+

指定计票所

@if (stations.Count == 0) {

当前投票范围内没有启用的计票所。请先在计票所管理中创建或启用计票所。

} else { @foreach (var station in stations) { } }
}

已配置议题

@foreach (var contest in contests) { }
议题类型规则选项
@contest.Title@contest.Type@contest.Determination / @contest.Seats 席@string.Join("、", options.Where(x => x.ElectionContestId == contest.Id).Select(x => x.Label))
@@ -43,7 +43,7 @@ private void SelectNationwide() { provinceId = cityId = districtId = townshipId = villageId = null; draft.RegionId = RegionScopeService.NationwideRegionId; cities.Clear(); districts.Clear(); townships.Clear(); villages.Clear(); } private string SelectedScopeName => draft.RegionId == RegionScopeService.NationwideRegionId ? "全国" : villageId.HasValue ? villages.SingleOrDefault(x => x.Id == villageId.Value)?.Name ?? "村社" : townshipId.HasValue ? townships.SingleOrDefault(x => x.Id == townshipId.Value)?.Name ?? "镇街" : districtId.HasValue ? districts.SingleOrDefault(x => x.Id == districtId.Value)?.Name ?? "区县" : cityId.HasValue ? cities.SingleOrDefault(x => x.Id == cityId.Value)?.Name ?? "市" : provinceId.HasValue ? provinces.SingleOrDefault(x => x.Id == provinceId.Value)?.Name ?? "省" : "未选择"; private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null; - private async Task CreateAsync() { try { if (!draft.RegionId.HasValue) throw new ArgumentException("请选择要发布投票的本级地区。"); await Scope.RequirePublisherAtRegionAsync(actorId, draft.RegionId.Value, CancellationToken.None); var start = Utc(draft.VotingStarts); var end = Utc(draft.VotingEnds); var countingEnd = Utc(draft.CountingEnds); if (start >= end || end > countingEnd) throw new ArgumentException("时间必须满足投票开始 < 投票截止 <= 计票截止。"); var election = new ElectionActivity { Name = Text(draft.Name), RegionId = draft.RegionId.Value, VotingStartsAtUtc = start, VotingEndsAtUtc = end, CountingEndsAtUtc = countingEnd, PublishLiveParticipation = draft.PublishLiveParticipation, PublishLiveCandidateTotals = draft.PublishLiveCandidateTotals, CreatedByUserId = actorId }; Db.ElectionActivities.Add(election); await Db.SaveChangesAsync(); elections.Insert(0, election); selected = election; notice = "草稿已创建,请继续配置议题和计票所。"; } catch (Exception exception) { notice = exception.Message; } } + private async Task CreateAsync() { try { if (!draft.RegionId.HasValue) throw new ArgumentException("请选择要发布投票的本级地区。"); if (draft.PublishLiveCandidateTotals && !draft.PublishLiveParticipation) throw new ArgumentException("实时公开候选人或议案票数时,必须同时公开参与量。"); await Scope.RequirePublisherAtRegionAsync(actorId, draft.RegionId.Value, CancellationToken.None); var start = Utc(draft.VotingStarts); var end = Utc(draft.VotingEnds); var countingEnd = Utc(draft.CountingEnds); if (start >= end || end > countingEnd) throw new ArgumentException("时间必须满足投票开始 < 投票截止 <= 计票截止。"); var election = new ElectionActivity { Name = Text(draft.Name), RegionId = draft.RegionId.Value, VotingStartsAtUtc = start, VotingEndsAtUtc = end, CountingEndsAtUtc = countingEnd, PublishLiveParticipation = draft.PublishLiveParticipation, PublishLiveCandidateTotals = draft.PublishLiveCandidateTotals, CreatedByUserId = actorId }; Db.ElectionActivities.Add(election); await Db.SaveChangesAsync(); elections.Insert(0, election); await SelectAsync(election.Id); notice = "草稿已创建,请继续配置议题和计票所。"; } catch (Exception exception) { notice = exception.Message; } } private async Task SelectAsync(Guid id) { selected = elections.Single(x => x.Id == id); if (systemAdmin && managed.Count == 0) managed = await Scope.GetManagedRegionIdsAsync(actorId, CancellationToken.None); contests.Clear(); contests.AddRange(await Db.ElectionContests.Where(x => x.ElectionActivityId == id).OrderBy(x => x.SortOrder).ToListAsync()); options.Clear(); options.AddRange(await Db.ElectionOptions.Where(x => contests.Select(c => c.Id).Contains(x.ElectionContestId)).OrderBy(x => x.SortOrder).ToListAsync()); var level = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.Id == selected.RegionId).Select(x => x.Level).SingleAsync(); HashSet eligibleRegions; if (selected.RegionId == RegionScopeService.NationwideRegionId) eligibleRegions = managed; else if (level == AdministrativeRegionLevel.Village) { if (!managed.Contains(selected.RegionId)) throw new UnauthorizedAccessException("投票目标地区超出当前管理范围。"); eligibleRegions = [selected.RegionId]; } else { var filter = level switch { AdministrativeRegionLevel.Province => new AdministrativeRegionFilter(selected.RegionId, null, null, null), AdministrativeRegionLevel.City => new AdministrativeRegionFilter(null, selected.RegionId, null, null), AdministrativeRegionLevel.District => new AdministrativeRegionFilter(null, null, selected.RegionId, null), AdministrativeRegionLevel.Township => new AdministrativeRegionFilter(null, null, null, selected.RegionId), _ => throw new InvalidOperationException("投票目标地区无效。") }; eligibleRegions = await Cascade.ResolveAsync(managed, filter, CancellationToken.None); } candidates.Clear(); candidates.AddRange(await Db.Candidates.AsNoTracking().Where(x => eligibleRegions.Contains(x.RegionId) && (x.Status == CandidateStatus.Approved || x.Status == CandidateStatus.Publicized)).OrderBy(x => x.DisplayName).ToListAsync()); stations.Clear(); stations.AddRange(await Db.CountingStations.AsNoTracking().Where(x => eligibleRegions.Contains(x.RegionId) && x.IsActive).OrderBy(x => x.Code).ToListAsync()); selectedStationIds.Clear(); foreach (var stationId in await Db.ElectionCountingStations.Where(x => x.ElectionActivityId == id).Select(x => x.CountingStationId).ToListAsync()) selectedStationIds.Add(stationId); } private async Task AddContestAsync() { try { await RequireDraftPublisherAsync(); if (contestDraft.MinSelections < 0 || contestDraft.MinSelections > contestDraft.MaxSelections || contestDraft.MaxSelections > contestDraft.Seats || contestDraft.RequiredSupportPercent is <= 0 or > 100) throw new ArgumentException("选择数或门槛配置无效。"); var contest = new ElectionContest { ElectionActivityId = selected!.Id, Title = Text(contestDraft.Title), Type = contestDraft.Type, SortOrder = contests.Count + 1, Seats = contestDraft.Type == ElectionContestType.Proposition ? 1 : contestDraft.Seats, MinSelections = contestDraft.Type == ElectionContestType.Proposition ? 1 : contestDraft.MinSelections, MaxSelections = contestDraft.Type == ElectionContestType.Proposition ? 1 : contestDraft.MaxSelections, Determination = contestDraft.Determination, RequiredSupportPercent = contestDraft.RequiredSupportPercent }; Db.ElectionContests.Add(contest); await Db.SaveChangesAsync(); contests.Add(contest); contestDraft = new(); notice = "议题已添加。"; } catch (Exception exception) { notice = exception.Message; } } private async Task AddOptionAsync() { try { await RequireDraftPublisherAsync(); if (!optionDraft.ContestId.HasValue) throw new ArgumentException("请选择议题。"); var contest = contests.Single(x => x.Id == optionDraft.ContestId); if (contest.Type == ElectionContestType.CandidateElection && !optionDraft.CandidateId.HasValue) throw new ArgumentException("岗位竞选必须选择候选人。"); if (contest.Type == ElectionContestType.Proposition && optionDraft.CandidateId.HasValue) throw new ArgumentException("议案选项不能关联候选人。"); if (optionDraft.CandidateId.HasValue) { var candidate = candidates.SingleOrDefault(x => x.Id == optionDraft.CandidateId.Value) ?? throw new ArgumentException("候选人不可用。"); if (!await Scope.IsInRegionSubtreeAsync(candidate.RegionId, selected!.RegionId, CancellationToken.None)) throw new ArgumentException("候选人必须属于该活动辖区。"); } if (options.Any(x => x.ElectionContestId == contest.Id && (x.Label == optionDraft.Label.Trim() || (optionDraft.CandidateId.HasValue && x.CandidateId == optionDraft.CandidateId)))) throw new ArgumentException("该候选人或选项已存在。"); var option = new ElectionOption { ElectionContestId = contest.Id, CandidateId = optionDraft.CandidateId, Label = Text(optionDraft.Label), SortOrder = options.Count(x => x.ElectionContestId == contest.Id) + 1 }; Db.ElectionOptions.Add(option); await Db.SaveChangesAsync(); options.Add(option); optionDraft = new(); notice = "选项已添加。"; } catch (Exception exception) { notice = exception.Message; } } diff --git a/src/ElectionSystem.Api/Controllers/ElectionsController.cs b/src/ElectionSystem.Api/Controllers/ElectionsController.cs index a6a85b7..c7159c2 100644 --- a/src/ElectionSystem.Api/Controllers/ElectionsController.cs +++ b/src/ElectionSystem.Api/Controllers/ElectionsController.cs @@ -26,6 +26,7 @@ public sealed class ElectionsController(ElectionDbContext dbContext, Modules.Org public async Task Create(CreateElectionRequest request, CancellationToken cancellationToken) { ValidateDates(request.VotingStartsAtUtc, request.VotingEndsAtUtc, request.CountingEndsAtUtc); + ValidatePublicationPolicy(request.PublishLiveParticipation, request.PublishLiveCandidateTotals); await ScopeService.RequirePublisherAtRegionAsync(ActorId, request.RegionId, cancellationToken); var election = new ElectionActivity { Name = RequiredText(request.Name, "名称"), Description = request.Description?.Trim(), RegionId = request.RegionId, VotingStartsAtUtc = request.VotingStartsAtUtc, VotingEndsAtUtc = request.VotingEndsAtUtc, CountingEndsAtUtc = request.CountingEndsAtUtc, PublishLiveParticipation = request.PublishLiveParticipation, PublishLiveCandidateTotals = request.PublishLiveCandidateTotals, CreatedByUserId = ActorId }; dbContext.ElectionActivities.Add(election); await dbContext.SaveChangesAsync(cancellationToken); @@ -112,6 +113,7 @@ public sealed class ElectionsController(ElectionDbContext dbContext, Modules.Org await ScopeService.RequirePublisherAtRegionAsync(ActorId, election.RegionId, cancellationToken); return election; } private static void ValidateDates(DateTime start, DateTime end, DateTime countingEnd) { if (start.Kind != DateTimeKind.Utc || end.Kind != DateTimeKind.Utc || countingEnd.Kind != DateTimeKind.Utc || start >= end || end > countingEnd) throw new ArgumentException("时间必须为 UTC,且满足投票开始 < 投票截止 <= 计票截止。"); } + private static void ValidatePublicationPolicy(bool publishLiveParticipation, bool publishLiveCandidateTotals) { if (publishLiveCandidateTotals && !publishLiveParticipation) throw new ArgumentException("实时公开候选人或议案票数时,必须同时公开参与量。"); } private static void ValidateContest(CreateContestRequest request) { if (request.SortOrder < 0 || request.Seats < 1 || request.MinSelections < 0 || request.MaxSelections < 1 || request.RequiredSupportPercent <= 0 || request.RequiredSupportPercent > 100) throw new ArgumentException("议题规则参数无效。"); if (request.Type == ElectionContestType.Proposition && (request.Seats != 1 || request.MinSelections != 1 || request.MaxSelections != 1)) throw new ArgumentException("议案必须为单项表决。"); } private static string RequiredText(string value, string name) => !string.IsNullOrWhiteSpace(value) ? value.Trim() : throw new ArgumentException($"{name}不能为空。"); } @@ -130,10 +132,8 @@ public sealed class PublicElectionResultsController(ElectionDbContext dbContext, public async Task Get(Guid electionId, CancellationToken cancellationToken) { var election = await dbContext.ElectionActivities.AsNoTracking().SingleOrDefaultAsync(x => x.Id == electionId, cancellationToken); - if (election is null || election.Status == ElectionActivityStatus.Draft) return NotFound(); - var isFinal = election.Status == ElectionActivityStatus.PublishedResults; - if (!isFinal && !election.PublishLiveParticipation) return NotFound(); + if (election is null || !PublicElectionResultsProjection.IsAvailable(election)) return NotFound(); var snapshot = await resultService.BuildAsync(electionId, cancellationToken); - return Ok(new { election.Id, election.Name, election.Status, election.ResultsPublishedAtUtc, updatedAtUtc = DateTime.UtcNow, publicationPolicy = new { election.PublishLiveParticipation, election.PublishLiveCandidateTotals, isFinal }, participation = snapshot.Contests.Sum(x => x.ValidVotes + x.Abstentions + x.InvalidBallots + x.MissingSelections), contests = (isFinal || election.PublishLiveCandidateTotals) ? snapshot.Contests : null }); + return Ok(PublicElectionResultsProjection.Create(snapshot, DateTime.UtcNow)); } } diff --git a/src/ElectionSystem.Api/Hubs/PublicResultsHub.cs b/src/ElectionSystem.Api/Hubs/PublicResultsHub.cs index b43b782..09f7136 100644 --- a/src/ElectionSystem.Api/Hubs/PublicResultsHub.cs +++ b/src/ElectionSystem.Api/Hubs/PublicResultsHub.cs @@ -1,8 +1,18 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.PublicBoard; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Hubs; -public sealed class PublicResultsHub : Hub +public sealed class PublicResultsHub(ElectionDbContext dbContext) : Hub { - public Task Subscribe(Guid electionId) => Groups.AddToGroupAsync(Context.ConnectionId, $"election:{electionId}"); + public async Task Subscribe(Guid electionId) + { + var election = await dbContext.ElectionActivities.AsNoTracking().SingleOrDefaultAsync(x => x.Id == electionId); + if (election is null || !PublicElectionResultsProjection.IsAvailable(election)) + throw new HubException("Election results are not publicly available."); + + await Groups.AddToGroupAsync(Context.ConnectionId, $"election:{electionId}"); + } } diff --git a/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs index 7249099..6069e3b 100644 --- a/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs +++ b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs @@ -20,18 +20,24 @@ public sealed class RegionScopeService(ElectionDbContext dbContext) public async Task> GetManagedRegionIdsAsync(Guid userId, CancellationToken cancellationToken) { - var assignments = await dbContext.UserRegionalRoleAssignments + var assignments = await dbContext.UserRegionalRoleAssignments.AsNoTracking() .Where(x => x.UserId == userId && (x.Role == RegionalRole.SystemAdministrator || x.Role == RegionalRole.ProvinceAdministrator || x.Role == RegionalRole.CityAdministrator || x.Role == RegionalRole.DistrictAdministrator || x.Role == RegionalRole.TownshipAdministrator || x.Role == RegionalRole.VillageAdministrator)) .ToListAsync(cancellationToken); if (assignments.Any(x => x.Role == RegionalRole.SystemAdministrator && x.RegionId == null)) - return (await dbContext.AdministrativeRegions.Select(x => x.Id).ToListAsync(cancellationToken)).ToHashSet(); + return (await dbContext.AdministrativeRegions.AsNoTracking().Select(x => x.Id).ToListAsync(cancellationToken)).ToHashSet(); - var children = await dbContext.AdministrativeRegions.Select(x => new { x.Id, x.ParentId }).ToListAsync(cancellationToken); var allowed = assignments.Where(x => x.RegionId.HasValue).Select(x => x.RegionId!.Value).ToHashSet(); - var queue = new Queue(allowed); - while (queue.TryDequeue(out var parentId)) - foreach (var childId in children.Where(x => x.ParentId == parentId).Select(x => x.Id)) - if (allowed.Add(childId)) queue.Enqueue(childId); + var parents = allowed.ToArray(); + // The administrative catalogue has five levels. Query each level through the + // ParentId index instead of reading every region into process memory first. + for (var level = 0; level < 5 && parents.Length > 0; level++) + { + var children = await dbContext.AdministrativeRegions.AsNoTracking() + .Where(x => x.ParentId.HasValue && parents.Contains(x.ParentId.Value)) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + parents = children.Where(allowed.Add).ToArray(); + } return allowed; } diff --git a/src/ElectionSystem.Api/Modules/PublicBoard/PublicElectionResultsProjection.cs b/src/ElectionSystem.Api/Modules/PublicBoard/PublicElectionResultsProjection.cs new file mode 100644 index 0000000..2196b2f --- /dev/null +++ b/src/ElectionSystem.Api/Modules/PublicBoard/PublicElectionResultsProjection.cs @@ -0,0 +1,40 @@ +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.Results; + +namespace ElectionSystem.Api.Modules.PublicBoard; + +/// Produces the minimum public result representation permitted by an election's publication policy. +public static class PublicElectionResultsProjection +{ + public static bool IsAvailable(ElectionActivity election) + => election.Status != ElectionActivityStatus.Draft + && (election.Status == ElectionActivityStatus.PublishedResults || election.PublishLiveParticipation); + + public static PublicElectionResultsPayload Create(ElectionResultSnapshot snapshot, DateTime updatedAtUtc) + { + var election = snapshot.Election; + if (!IsAvailable(election)) throw new InvalidOperationException("Election results are not publicly available."); + + var isFinal = election.Status == ElectionActivityStatus.PublishedResults; + return new PublicElectionResultsPayload( + election.Id, + election.Name, + election.Status, + election.ResultsPublishedAtUtc, + updatedAtUtc, + new PublicElectionPublicationPolicy(election.PublishLiveParticipation, election.PublishLiveCandidateTotals, isFinal), + snapshot.Contests.Sum(x => x.ValidVotes + x.Abstentions + x.InvalidBallots + x.MissingSelections), + isFinal || election.PublishLiveCandidateTotals ? snapshot.Contests : null); + } +} + +public sealed record PublicElectionPublicationPolicy(bool PublishLiveParticipation, bool PublishLiveCandidateTotals, bool IsFinal); +public sealed record PublicElectionResultsPayload( + Guid Id, + string Name, + ElectionActivityStatus Status, + DateTime? ResultsPublishedAtUtc, + DateTime UpdatedAtUtc, + PublicElectionPublicationPolicy PublicationPolicy, + int Participation, + IReadOnlyList? Contests); diff --git a/src/ElectionSystem.Api/Modules/PublicBoard/PublicResultsNotifier.cs b/src/ElectionSystem.Api/Modules/PublicBoard/PublicResultsNotifier.cs index 5552777..3a7a57b 100644 --- a/src/ElectionSystem.Api/Modules/PublicBoard/PublicResultsNotifier.cs +++ b/src/ElectionSystem.Api/Modules/PublicBoard/PublicResultsNotifier.cs @@ -9,14 +9,9 @@ public sealed class PublicResultsNotifier(IHubContext hub, Ele public async Task PublishAsync(Guid electionId, CancellationToken cancellationToken) { var snapshot = await resultService.BuildAsync(electionId, cancellationToken); - var payload = new - { - electionId, - snapshot.Election.Status, - snapshot.Election.ResultsPublishedAtUtc, - updatedAtUtc = DateTime.UtcNow, - contests = snapshot.Contests.Select(x => new { x.ContestId, x.Title, x.ValidVotes, x.Abstentions, x.InvalidBallots, x.MissingSelections, options = x.Options.Select(o => new { o.OptionId, o.Label, o.Votes }), x.WinnerOptionIds, x.RequiresManualReview }) - }; + if (!PublicElectionResultsProjection.IsAvailable(snapshot.Election)) return; + + var payload = PublicElectionResultsProjection.Create(snapshot, DateTime.UtcNow); await hub.Clients.Group($"election:{electionId}").SendAsync("resultUpdated", payload, cancellationToken); } } diff --git a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml index 411cf32..2e1b54c 100644 --- a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml +++ b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml @@ -5,6 +5,6 @@

基础资料 / 批量导入

选民登记

可导入具名选民名单;如身份信息不能上传,则按辖区登记未具名的补充人数。汇总人数不会创建个人选民资料,也不能作为线上投票资格或防重复投票依据。

@if (Model.Message is not null) {
@Model.Message
}
-

不含身份信息的汇总登记

填写后将覆盖该辖区此前的汇总数,并记录操作审计。若同一辖区也已导入具名名单,请只填写未具名的补充人数,避免重复统计。

@if (Model.AggregateRegistrations.Count > 0) {
@foreach (var row in Model.AggregateRegistrations) { }
行政区划未具名补充人数原因更新时间(UTC)
@row.RegionCode @row.RegionName@row.RegisteredCount@row.Reason@row.UpdatedAtUtc.ToString("u")
}
+

不含身份信息的汇总登记

填写后将覆盖该辖区此前的汇总数,并记录操作审计。请输入行政区划代码;系统会在保存时复核该辖区有效性与您的管理范围。若同一辖区也已导入具名名单,请只填写未具名的补充人数,避免重复统计。

@if (Model.AggregateRegistrations.Count > 0) {
@foreach (var row in Model.AggregateRegistrations) { }
行政区划未具名补充人数原因更新时间(UTC)
@row.RegionCode @row.RegionName@row.RegisteredCount@row.Reason@row.UpdatedAtUtc.ToString("u")
}
@if (Model.Preview is not null) {

预校验结果

可导入 @Model.Preview.ValidCount 行;错误 @Model.Preview.ErrorCount 行。

@foreach (var row in Model.Preview.Rows) { }
行号选民编号姓名行政区划结果
@row.RowNumber@row.VoterNumber@row.DisplayName@row.RegionCode@(row.Error ?? "通过")
} diff --git a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs index 6585f86..73d13de 100644 --- a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs +++ b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs @@ -12,9 +12,8 @@ public sealed class VoterImportModel(VoterImportService importService, VoterRegi { public VoterImportPreview? Preview { get; private set; } public string? Message { get; private set; } - public IReadOnlyList Regions { get; private set; } = []; public IReadOnlyList AggregateRegistrations { get; private set; } = []; - [BindProperty] public Guid AggregateRegionId { get; set; } + [BindProperty] public string? AggregateRegionCode { get; set; } [BindProperty] public int AggregateRegisteredCount { get; set; } [BindProperty] public string? AggregateReason { get; set; } public async Task OnGetAsync(CancellationToken cancellationToken) => await LoadRegionsAsync(cancellationToken); @@ -36,7 +35,12 @@ public sealed class VoterImportModel(VoterImportService importService, VoterRegi await LoadRegionsAsync(cancellationToken); try { - await aggregateService.SetAsync(RegionScopeService.GetRequiredUserId(User), AggregateRegionId, AggregateRegisteredCount, AggregateReason, cancellationToken); + var normalizedCode = AggregateRegionCode?.Trim(); + var region = !string.IsNullOrWhiteSpace(normalizedCode) + ? await dbContext.AdministrativeRegions.AsNoTracking().SingleOrDefaultAsync(x => x.Code == normalizedCode && x.IsActive, cancellationToken) + : null; + if (region is null) throw new ArgumentException("请输入有效且已启用的行政区划代码。"); + await aggregateService.SetAsync(RegionScopeService.GetRequiredUserId(User), region.Id, AggregateRegisteredCount, AggregateReason, cancellationToken); Message = "已保存不含身份信息的汇总登记人数。该数字不产生个人选民资料,也不能用于线上逐人资格核验。"; } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or UnauthorizedAccessException) { Message = exception.Message; } @@ -45,13 +49,12 @@ public sealed class VoterImportModel(VoterImportService importService, VoterRegi private async Task LoadRegionsAsync(CancellationToken cancellationToken) { var allowed = await scopeService.GetManagedRegionIdsAsync(RegionScopeService.GetRequiredUserId(User), cancellationToken); - Regions = await dbContext.AdministrativeRegions.AsNoTracking().Where(x => allowed.Contains(x.Id) && x.IsActive).OrderBy(x => x.Code).Select(x => new RegionOption(x.Id, x.Code, x.Name)).ToListAsync(cancellationToken); - AggregateRegistrations = await (from aggregate in dbContext.VoterRegistrationAggregates.AsNoTracking() - join region in dbContext.AdministrativeRegions.AsNoTracking() on aggregate.RegionId equals region.Id - where allowed.Contains(aggregate.RegionId) - orderby region.Code - select new AggregateRegistrationRow(region.Code, region.Name, aggregate.RegisteredCount, aggregate.Reason, aggregate.UpdatedAtUtc)).ToListAsync(cancellationToken); + var registrations = await (from aggregate in dbContext.VoterRegistrationAggregates.AsNoTracking() + join region in dbContext.AdministrativeRegions.AsNoTracking() on aggregate.RegionId equals region.Id + orderby region.Code + select new AggregateRegistrationLookupRow(region.Id, region.Code, region.Name, aggregate.RegisteredCount, aggregate.Reason, aggregate.UpdatedAtUtc)).ToListAsync(cancellationToken); + AggregateRegistrations = registrations.Where(x => allowed.Contains(x.RegionId)).Select(x => new AggregateRegistrationRow(x.RegionCode, x.RegionName, x.RegisteredCount, x.Reason, x.UpdatedAtUtc)).ToList(); } - public sealed record RegionOption(Guid Id, string Code, string Name); + private sealed record AggregateRegistrationLookupRow(Guid RegionId, string RegionCode, string RegionName, int RegisteredCount, string Reason, DateTime UpdatedAtUtc); public sealed record AggregateRegistrationRow(string RegionCode, string RegionName, int RegisteredCount, string Reason, DateTime UpdatedAtUtc); } diff --git a/src/ElectionSystem.Api/Pages/Results/Index.cshtml b/src/ElectionSystem.Api/Pages/Results/Index.cshtml new file mode 100644 index 0000000..485d241 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Results/Index.cshtml @@ -0,0 +1,42 @@ +@page +@model ElectionSystem.Api.Pages.Results.IndexModel +@{ + ViewData["Title"] = "实时结果"; +} + + + + + + 实时结果 - 选举与投票信息系统 + + + + +
+

公开结果看板

+

选举实时结果

+

仅展示活动公开策略允许的数据。参与量每 15 秒刷新一次;候选人或议案票数仅在活动明确开启实时公开或正式发布最终结果后展示。

+ @if (Model.Elections.Count == 0) + { +

当前没有可公开展示结果的选举活动。

+ } + else + { +
+ @foreach (var election in Model.Elections) + { +
+

@election.Name

正在获取数据
+

@(election.CandidateTotalsVisible ? "活动允许展示票数;最终结果以正式公告为准。" : "当前仅公开参与量;候选人或议案票数尚未公开。")

+
参与量
—
更新时间
—
+ +
+ } +
+ } +
+ + + + diff --git a/src/ElectionSystem.Api/Pages/Results/Index.cshtml.cs b/src/ElectionSystem.Api/Pages/Results/Index.cshtml.cs new file mode 100644 index 0000000..06e214d --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Results/Index.cshtml.cs @@ -0,0 +1,24 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.PublicBoard; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Pages.Results; + +public sealed class IndexModel(ElectionDbContext dbContext) : PageModel +{ + public IReadOnlyList Elections { get; private set; } = []; + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Elections = await dbContext.ElectionActivities.AsNoTracking() + .Where(x => x.Status != ElectionActivityStatus.Draft + && (x.Status == ElectionActivityStatus.PublishedResults || x.PublishLiveParticipation)) + .OrderByDescending(x => x.ResultsPublishedAtUtc ?? x.UpdatedAtUtc) + .Select(x => new PublicElectionListItem(x.Id, x.Name, x.Status, x.ResultsPublishedAtUtc, x.PublishLiveCandidateTotals || x.Status == ElectionActivityStatus.PublishedResults)) + .ToListAsync(cancellationToken); + } +} + +public sealed record PublicElectionListItem(Guid Id, string Name, ElectionActivityStatus Status, DateTime? ResultsPublishedAtUtc, bool CandidateTotalsVisible); diff --git a/src/ElectionSystem.Api/Pages/Shared/_PublicHeader.cshtml b/src/ElectionSystem.Api/Pages/Shared/_PublicHeader.cshtml index ac4a3a7..579171d 100644 --- a/src/ElectionSystem.Api/Pages/Shared/_PublicHeader.cshtml +++ b/src/ElectionSystem.Api/Pages/Shared/_PublicHeader.cshtml @@ -6,6 +6,7 @@