计票实时公开
This commit is contained in:
1 parent
e96a56314f
commit
2d38c9cb60
16 files changed
+232
-38
No files matched your search
@@ -73,8 +73,8 @@
|
||||
- [x] 按活动规则汇总已确认线下票,生成候选人/议案选项得票、有效/无效/弃权/缺投统计及当选名单;线上投票接入后复用同一结果服务。
|
||||
- [ ] 建立结果审核、监票确认、发布和归档流程。
|
||||
- [x] SignalR 推送已确认线下计票的公开看板数据;断线后使用公开结果接口受控轮询兜底。
|
||||
- [ ] 公开看板显示活动状态、参与人数、参与率、数据更新时间和公开规则说明。
|
||||
- [ ] 候选人实时票数、排名和图表只在活动明确开启后公开;不得公开未确认线下结果。
|
||||
- [x] 公开看板显示活动状态、参与人数、数据更新时间和公开规则说明;`/results` 以受控轮询刷新。
|
||||
- [x] 候选人实时票数只在活动明确开启后公开;接口与 SignalR 推送共用公开策略,且不会公开未确认线下结果。
|
||||
- [x] CMS 公开信息基础:建立八类固定内容定义(选举公告、规则与政策、候选人公示、选民指南、结果公告、常见问题、资料下载、历史归档)及只读公开内容契约。
|
||||
- [x] 公开信息前端:提供八类内容的首页最新发布区、直接栏目菜单入口、目录/详情页、发布内容空状态与服务不可用提示;采用响应式中性政务信息风格。
|
||||
- [ ] CMS:栏目、文章、公告、政策、操作指南、候选人公示、结果公告、媒体库与附件。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
@if (selected.Status == ElectionActivityStatus.Draft) {
|
||||
<EditForm Model="contestDraft" OnValidSubmit="AddContestAsync" class="admin-form"><h3>添加岗位或议案</h3><label>名称<input @bind="contestDraft.Title" required /></label><label>类型<select @bind="contestDraft.Type"><option value="@ElectionContestType.CandidateElection">岗位竞选</option><option value="@ElectionContestType.Proposition">议案表决</option></select></label><label>席位数<input type="number" @bind="contestDraft.Seats" min="1" /></label><label>最少选择数<input type="number" @bind="contestDraft.MinSelections" min="0" /></label><label>最多选择数<input type="number" @bind="contestDraft.MaxSelections" min="1" /></label><label>判定<select @bind="contestDraft.Determination"><option value="@WinnerDetermination.HighestVotes">最高票</option><option value="@WinnerDetermination.SimpleMajority">简单多数</option><option value="@WinnerDetermination.AbsoluteMajority">绝对多数</option><option value="@WinnerDetermination.QualifiedMajority">比例门槛</option></select></label><label>门槛百分比<input type="number" @bind="contestDraft.RequiredSupportPercent" min="1" max="100" /></label><button class="admin-button" type="submit">添加议题</button></EditForm>
|
||||
<EditForm Model="optionDraft" OnValidSubmit="AddOptionAsync" class="admin-form"><h3>添加选项</h3><label>议题<select @bind="optionDraft.ContestId"><option value="">请选择议题</option>@foreach (var contest in contests) { <option value="@contest.Id">@contest.Title</option> }</select></label><label>候选人(岗位投票可选)<select @bind="optionDraft.CandidateId"><option value="">议案选项不选候选人</option>@foreach (var candidate in candidates) { <option value="@candidate.Id">@candidate.DisplayName</option> }</select></label><label>显示名称<input @bind="optionDraft.Label" required /></label><button class="admin-button" type="submit">添加选项</button></EditForm>
|
||||
<div class="admin-form"><h3>指定计票所</h3>@foreach (var station in stations) { <label><input type="checkbox" checked="@selectedStationIds.Contains(station.Id)" @onchange="e => ToggleStation(station.Id, e)" /> @station.Code · @station.Name</label> }<button class="admin-button" @onclick="SaveStationsAsync">保存计票所</button></div>
|
||||
<div class="admin-form"><h3>指定计票所</h3>@if (stations.Count == 0) { <p class="admin-empty">当前投票范围内没有启用的计票所。请先在<a href="/admin/counting-stations">计票所管理</a>中创建或启用计票所。</p> } else { @foreach (var station in stations) { <label><input type="checkbox" checked="@selectedStationIds.Contains(station.Id)" @onchange="e => ToggleStation(station.Id, e)" /> @station.Code · @station.Name</label> } }<button class="admin-button" @onclick="SaveStationsAsync" disabled="@(stations.Count == 0)">保存计票所</button></div>
|
||||
<button class="admin-button" @onclick="PublishAsync">发布并冻结活动定义</button>
|
||||
}
|
||||
<h3>已配置议题</h3><div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>议题</th><th>类型</th><th>规则</th><th>选项</th></tr></thead><tbody>@foreach (var contest in contests) { <tr><td>@contest.Title</td><td>@contest.Type</td><td>@contest.Determination / @contest.Seats 席</td><td>@string.Join("、", options.Where(x => x.ElectionContestId == contest.Id).Select(x => x.Label))</td></tr> }</tbody></table></div>
|
||||
@@ -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<Guid> 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; } }
|
||||
|
||||
Loaded 3 of 16 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user