Compare commits
2
Commits
e96a56314f
...
1f459d53f4
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
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
<section class="admin-page"><div class="admin-page__heading"><div><p class="eyebrow">人员与场所 / 计票</p><h1>计票所管理</h1><p>按四级行政区划筛选;工作人员必须另行分配到具体计票所。</p></div><button class="admin-button" @onclick="ToggleCreateAsync">新增计票所</button></div>
|
||||
@if (notice is not null) { <div class="admin-alert">@notice</div> }
|
||||
<div class="admin-filter admin-filter--regions"><label>省<select value="@provinceId" @onchange="SelectProvinceAsync"><option value="">全部省份</option>@foreach (var x in provinces) { <option value="@x.Id">@x.Name</option> }</select></label><label>市<select value="@cityId" @onchange="SelectCityAsync"><option value="">全部城市</option>@foreach (var x in cities) { <option value="@x.Id">@x.Name</option> }</select></label><label>区县<select value="@districtId" @onchange="SelectDistrictAsync"><option value="">全部区县</option>@foreach (var x in districts) { <option value="@x.Id">@x.Name</option> }</select></label><label>镇街<select value="@townshipId" @onchange="SelectTownshipAsync"><option value="">全部镇街</option>@foreach (var x in townships) { <option value="@x.Id">@x.Name</option> }</select></label><label>名称或代码<input @bind="keyword" @bind:event="oninput" placeholder="输入后点击查询" /></label><button class="admin-button admin-button--quiet" @onclick="SearchAsync">查询</button></div>
|
||||
@if (creating) { <EditForm Model="draft" OnValidSubmit="CreateAsync" class="admin-form"><h2>新增计票所</h2><label>计票所代码<input @bind="draft.Code" required /></label><label>名称<input @bind="draft.Name" required /></label><label>所属地区<select @bind="draft.RegionId"><option value="">请选择地区</option>@foreach (var r in regions) { <option value="@r.Id">@r.Path</option> }</select></label><div><button class="admin-button" type="submit">保存</button><button class="admin-link-button" type="button" @onclick="() => creating = false">取消</button></div></EditForm> }
|
||||
@if (creating) { <EditForm Model="draft" OnValidSubmit="CreateAsync" class="admin-form"><h2>新增计票所</h2><label>计票所代码<input @bind="draft.Code" required /></label><label>名称<input @bind="draft.Name" required /></label><fieldset><legend>所属地区(可停在任意已授权层级)</legend><label>省<select value="@provinceId" @onchange="SelectProvinceAsync"><option value="">请选择省份</option>@foreach (var x in provinces) { <option value="@x.Id">@x.Name</option> }</select></label><label>市<select value="@cityId" @onchange="SelectCityAsync"><option value="">请选择城市</option>@foreach (var x in cities) { <option value="@x.Id">@x.Name</option> }</select></label><label>区县<select value="@districtId" @onchange="SelectDistrictAsync"><option value="">请选择区县</option>@foreach (var x in districts) { <option value="@x.Id">@x.Name</option> }</select></label><label>镇街<select value="@townshipId" @onchange="SelectTownshipAsync"><option value="">请选择镇街</option>@foreach (var x in townships) { <option value="@x.Id">@x.Name</option> }</select></label></fieldset><div><button class="admin-button" type="submit">保存</button><button class="admin-link-button" type="button" @onclick="() => creating = false">取消</button></div></EditForm> }
|
||||
<div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>代码</th><th>名称</th><th>所属地区</th><th>状态</th></tr></thead><tbody>@if (!loaded) { <tr><td colspan="4">正在加载…</td></tr> } else if (stations.Count == 0) { <tr><td colspan="4" class="admin-empty">暂无计票所。</td></tr> } @foreach (var station in stations) { <tr><td class="admin-mono">@station.Code</td><td>@station.Name</td><td>@station.Region</td><td>@(station.Active ? "启用" : "停用")</td></tr> }</tbody></table></div><div class="admin-pagination"><button class="admin-link-button" disabled="@(cursorHistory.Count == 0)" @onclick="PreviousAsync">上一页</button><span>每页 @PageSize 条</span><button class="admin-link-button" disabled="@(nextCursor is null)" @onclick="NextAsync">下一页</button></div>
|
||||
<EditForm Model="staffDraft" OnValidSubmit="AssignStaffAsync" class="admin-form"><h2>分配计票所工作人员</h2><label>搜索计票所<input @bind="stationKeyword" @bind:event="oninput" placeholder="名称或代码" /></label><button class="admin-button admin-button--quiet" type="button" @onclick="SearchStationChoicesAsync">查询计票所</button><label>计票所<select @bind="staffDraft.StationId"><option value="">请选择计票所</option>@foreach (var station in stationChoices) { <option value="@station.Id">@station.Name</option> }</select></label><label>用户 ID<input @bind="staffDraft.UserId" placeholder="用户 GUID" required /></label><button class="admin-button" type="submit">分配工作人员</button></EditForm></section>
|
||||
@code { private const int PageSize = 50; [CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!; private HashSet<Guid> scope = []; private bool loaded, creating; private string? notice, currentCursor, nextCursor; private string keyword = "", stationKeyword = ""; private Guid? provinceId, cityId, districtId, townshipId; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List<Region> regions = [], provinces = [], cities = [], districts = [], townships = []; private readonly List<StationRow> stations = []; private readonly List<StationChoice> stationChoices = []; private readonly List<string?> cursorHistory = [];
|
||||
protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); await SearchAsync(); }
|
||||
@code { private const int PageSize = 50; [CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!; private HashSet<Guid> scope = []; private bool systemAdmin, loaded, creating; private string? notice, currentCursor, nextCursor; private string keyword = "", stationKeyword = ""; private Guid? provinceId, cityId, districtId, townshipId; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List<Region> regions = [], provinces = [], cities = [], districts = [], townships = []; private readonly List<StationRow> stations = []; private readonly List<StationChoice> stationChoices = []; private readonly List<string?> cursorHistory = [];
|
||||
protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Scope.IsSystemAdministratorAsync(id, CancellationToken.None); if (!systemAdmin) scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); await SearchAsync(); }
|
||||
private async Task LoadInitialOptionsAsync(Guid userId) { var systemAdmin = await Db.UserRegionalRoleAssignments.AsNoTracking().AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null); if (systemAdmin) { await LoadOptionsAsync(provinces, null, AdministrativeRegionLevel.Province); return; } var roots = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && Db.UserRegionalRoleAssignments.Any(a => a.UserId == userId && a.RegionId == x.Id && (a.Role == RegionalRole.ProvinceAdministrator || a.Role == RegionalRole.CityAdministrator || a.Role == RegionalRole.DistrictAdministrator || a.Role == RegionalRole.TownshipAdministrator))).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync(); provinces.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Province)); cities.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.City)); districts.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.District)); townships.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Township)); }
|
||||
private async Task LoadOptionsAsync(List<Region> target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && scope.Contains(x.Id)).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); }
|
||||
private async Task ToggleCreateAsync() { creating = !creating; if (creating && regions.Count == 0) regions.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && scope.Contains(x.Id)).OrderBy(x => x.Level).ThenBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); }
|
||||
private async Task SearchStationChoicesAsync() { stationChoices.Clear(); var query = Db.CountingStations.AsNoTracking().Where(x => x.IsActive && scope.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(stationKeyword)) query = query.Where(x => x.Code.Contains(stationKeyword) || x.Name.Contains(stationKeyword)); var rows = await query.OrderBy(x => x.Code).Take(PageSize).Select(x => new { x.Id, x.Code, x.Name, x.RegionId }).ToListAsync(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stationChoices.AddRange(rows.Select(x => new StationChoice(x.Id, $"{x.Code} · {x.Name}({names.GetValueOrDefault(x.RegionId, "—")})"))); }
|
||||
private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; cities.Clear(); districts.Clear(); townships.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; districts.Clear(); townships.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; townships.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); await SearchAsync(); } private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); await SearchAsync(); } private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
||||
private async Task LoadOptionsAsync(List<Region> target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && (systemAdmin || scope.Contains(x.Id))).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); }
|
||||
private Task ToggleCreateAsync() { creating = !creating; if (creating) draft.RegionId = townshipId ?? districtId ?? cityId ?? provinceId; return Task.CompletedTask; }
|
||||
private async Task SearchStationChoicesAsync() { stationChoices.Clear(); IQueryable<CountingStation> query = Db.CountingStations.AsNoTracking().Where(x => x.IsActive); if (!systemAdmin) query = query.Where(x => scope.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(stationKeyword)) query = query.Where(x => x.Code.Contains(stationKeyword) || x.Name.Contains(stationKeyword)); var rows = await query.OrderBy(x => x.Code).Take(PageSize).Select(x => new { x.Id, x.Code, x.Name, x.RegionId }).ToListAsync(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stationChoices.AddRange(rows.Select(x => new StationChoice(x.Id, $"{x.Code} · {x.Name}({names.GetValueOrDefault(x.RegionId, "—")})"))); }
|
||||
private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; cities.Clear(); districts.Clear(); townships.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); SyncDraftRegion(); await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; districts.Clear(); townships.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); SyncDraftRegion(); await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; townships.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); SyncDraftRegion(); await SearchAsync(); } private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); SyncDraftRegion(); await SearchAsync(); } private void SyncDraftRegion() { if (creating) draft.RegionId = townshipId ?? districtId ?? cityId ?? provinceId; } private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
||||
private async Task SearchAsync() { currentCursor = null; cursorHistory.Clear(); await LoadAsync(); } private async Task NextAsync() { if (nextCursor is null) return; cursorHistory.Add(currentCursor); currentCursor = nextCursor; await LoadAsync(); } private async Task PreviousAsync() { if (cursorHistory.Count == 0) return; currentCursor = cursorHistory[^1]; cursorHistory.RemoveAt(cursorHistory.Count - 1); await LoadAsync(); }
|
||||
private async Task LoadAsync() { loaded = false; try { var selectedRegions = await Cascade.ResolveAsync(scope, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); var query = Db.CountingStations.Where(x => selectedRegions.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); if (!CursorToken.TryDecode<Cursor>(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.Code, token.Code) > 0 || (x.Code == token.Code && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.Code).ThenBy(x => x.Id).Take(PageSize + 1).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var map = await Db.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stations.Clear(); stations.AddRange(rows.Select(x => new StationRow(x.Id, x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].Code, rows[^1].Id)) : null; } catch (ArgumentException e) { notice = e.Message; stations.Clear(); nextCursor = null; } finally { loaded = true; } }
|
||||
private async Task CreateAsync() { if (!draft.RegionId.HasValue || !scope.Contains(draft.RegionId.Value)) { notice = "请选择您有权管理的地区。"; return; } if (await Db.CountingStations.AnyAsync(x => x.Code == draft.Code)) { notice = "计票所代码已存在。"; return; } Db.CountingStations.Add(new CountingStation { Code = draft.Code.Trim(), Name = draft.Name.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); notice = "计票所已创建。"; creating = false; draft = new(); stationChoices.Clear(); await SearchAsync(); }
|
||||
private async Task LoadAsync() { loaded = false; try { IQueryable<CountingStation> query = Db.CountingStations.AsNoTracking(); if (systemAdmin) { var selectedRegions = await Cascade.ResolveSystemAdministratorQueryAsync(new(provinceId, cityId, districtId, townshipId), CancellationToken.None); if (selectedRegions is not null) query = query.Where(x => selectedRegions.Contains(x.RegionId)); } else { var selectedRegions = await Cascade.ResolveAsync(scope, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); query = query.Where(x => selectedRegions.Contains(x.RegionId)); } if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); if (!CursorToken.TryDecode<Cursor>(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.Code, token.Code) > 0 || (x.Code == token.Code && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.Code).ThenBy(x => x.Id).Take(PageSize + 1).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var map = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stations.Clear(); stations.AddRange(rows.Select(x => new StationRow(x.Id, x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].Code, rows[^1].Id)) : null; } catch (ArgumentException e) { notice = e.Message; stations.Clear(); nextCursor = null; } finally { loaded = true; } }
|
||||
private async Task CreateAsync() { if (!draft.RegionId.HasValue || (!systemAdmin && !scope.Contains(draft.RegionId.Value))) { notice = "请选择您有权管理的地区。"; return; } if (await Db.CountingStations.AnyAsync(x => x.Code == draft.Code)) { notice = "计票所代码已存在。"; return; } Db.CountingStations.Add(new CountingStation { Code = draft.Code.Trim(), Name = draft.Name.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); notice = "计票所已创建。"; creating = false; draft = new(); stationChoices.Clear(); await SearchAsync(); }
|
||||
private async Task AssignStaffAsync() { if (!staffDraft.StationId.HasValue || !Guid.TryParse(staffDraft.UserId, out var userId)) { notice = "请选择计票所并输入有效用户 ID。"; return; } var station = await Db.CountingStations.FindAsync(staffDraft.StationId); if (station is null || !scope.Contains(station.RegionId)) { notice = "不能管理此计票所。"; return; } if (!await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.RegionId == station.RegionId && (x.Role == RegionalRole.CountingStationClerk || x.Role == RegionalRole.Supervisor))) { notice = "用户必须先取得本辖区的计票员或监督员角色。"; return; } if (await Db.UserCountingStationAssignments.AnyAsync(x => x.UserId == userId && x.CountingStationId == station.Id)) { notice = "该用户已分配到此计票所。"; return; } Db.UserCountingStationAssignments.Add(new UserCountingStationAssignment { UserId = userId, CountingStationId = station.Id }); await Db.SaveChangesAsync(); notice = "工作人员已分配。"; staffDraft = new(); }
|
||||
private sealed class StationDraft { public string Code { get; set; } = ""; public string Name { get; set; } = ""; public Guid? RegionId { get; set; } } private sealed class StaffDraft { public Guid? StationId { get; set; } public string UserId { get; set; } = ""; } private sealed record Region(Guid Id, Guid? ParentId, AdministrativeRegionLevel Level, string Name, string Path); private sealed record StationRow(Guid Id, string Code, string Name, string Region, bool Active); private sealed record StationChoice(Guid Id, string Name); private sealed record Cursor(string Code, Guid Id); }
|
||||
Loaded 3 of 29 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user