投票范围修改

This commit is contained in:
biss committed 2026-09-08 08:23:02 +08:00
1 parent 1e0652211b
commit 6d765ac8f3
9 files changed
+128 -37

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
## 规则与权限
- 活动必须指定一个目标行政区。只有直接被分配至该行政区的省、市、区、镇或村管理员可创建、编辑及发布;系统管理员可以全局操作。上级管理员仍可以只读查看其下级范围的活动。
- 活动必须指定一个投票范围。创建页可停在省、市、区县、镇街或村社任一级;系统管理员还可选择“全国”。全国范围是系统保留的持久化范围,覆盖所有有效行政区,但不会改写 AreaCity 原始层级。只有直接被分配至目标行政区的省、市、区、镇或村管理员可创建、编辑及发布;全国范围仅系统管理员可创建、编辑及发布。上级管理员仍可以只读查看其下级范围的活动。
- 活动在草稿阶段依次配置议题、选项和计票所。发布后定义冻结,不能增删候选人、计票所或改变计票规则。
- 一个活动可以包含多个 `CandidateElection`(岗位/多席位竞选)及 `Proposition`(议案)议题。每个议题独立记录每个选项的有效票、弃权、无效票和系统计算的缺投数。
- 每个议题可配置席位数、最少/最多选择数、最高票/简单多数/绝对多数/特定比例门槛、计票分母(仅有效票或全部投出选票)以及平票后的人工复核或无结果处理。
@@ -7,19 +7,21 @@
@inject RegionCascadeFilterService Cascade
<PageTitle>计票所管理</PageTitle>
<section class="admin-page"><div class="admin-page__heading"><div><p class="eyebrow">人员与场所 / 计票</p><h1>计票所管理</h1><p>按四级行政区划筛选;工作人员必须另行分配到具体计票所。</p></div><button class="admin-button" @onclick="() => creating = !creating">新增计票所</button></div>
<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 Options(AdministrativeRegionLevel.Province, null)) { <option value="@x.Id">@x.Name</option> }</select></label><label>市<select value="@cityId" @onchange="SelectCityAsync"><option value="">全部城市</option>@foreach (var x in Options(AdministrativeRegionLevel.City, provinceId)) { <option value="@x.Id">@x.Name</option> }</select></label><label>区县<select value="@districtId" @onchange="SelectDistrictAsync"><option value="">全部区县</option>@foreach (var x in Options(AdministrativeRegionLevel.District, cityId)) { <option value="@x.Id">@x.Name</option> }</select></label><label>镇街<select value="@townshipId" @onchange="SelectTownshipAsync"><option value="">全部镇街</option>@foreach (var x in Options(AdministrativeRegionLevel.Township, districtId)) { <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>
<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> }
<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>计票所<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 = ""; private Guid? provinceId, cityId, districtId, townshipId; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List<Region> regions = []; private readonly List<Region> filterRegions = []; 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); var all = await Db.AdministrativeRegions.Where(x => scope.Contains(x.Id) && x.IsActive).OrderBy(x => x.Level).ThenBy(x => x.Code).ToListAsync(); regions.AddRange(all.Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}"))); filterRegions.AddRange(regions); await LoadStationChoicesAsync(); await SearchAsync(); }
private IEnumerable<Region> Options(AdministrativeRegionLevel level, Guid? parentId) => filterRegions.Where(x => x.Level == level && (!parentId.HasValue || x.ParentId == parentId)).OrderBy(x => x.Name);
private async Task LoadStationChoicesAsync() { var map = await Db.AdministrativeRegions.Where(x => scope.Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stationChoices.Clear(); stationChoices.AddRange((await Db.CountingStations.Where(x => scope.Contains(x.RegionId) && x.IsActive).OrderBy(x => x.Code).ToListAsync()).Select(x => new StationChoice(x.Id, $"{x.Code} · {x.Name}({map.GetValueOrDefault(x.RegionId, "—")})"))); }
private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; 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;
<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(); }
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 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(); await LoadStationChoicesAsync(); await SearchAsync(); }
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 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); }
@@ -6,6 +6,7 @@
@using ElectionSystem.Api.Modules.Candidates.Domain
@inject ElectionDbContext Db
@inject RegionScopeService Scope
@inject RegionCascadeFilterService Cascade
<PageTitle>投票发布</PageTitle>
<section class="admin-page">
@@ -13,7 +14,7 @@
@if (notice is not null) { <div class="admin-alert" role="alert">@notice</div> }
@if (!loaded) { <p class="admin-loading">正在加载授权范围…</p> }
else {
<EditForm Model="draft" OnValidSubmit="CreateAsync" class="admin-form"><h2>新建本级投票</h2><label>名称<input @bind="draft.Name" required /></label><label>目标地区<select @bind="draft.RegionId"><option value="">请选择本级地区</option>@foreach (var region in publishRegions) { <option value="@region.Id">@region.Name</option> }</select></label><label>投票开始(UTC)<input type="datetime-local" @bind="draft.VotingStarts" /></label><label>投票截止(UTC)<input type="datetime-local" @bind="draft.VotingEnds" /></label><label>计票截止(UTC)<input type="datetime-local" @bind="draft.CountingEnds" /></label><label><input type="checkbox" @bind="draft.PublishLiveParticipation" /> 实时公开参与量</label><label><input type="checkbox" @bind="draft.PublishLiveCandidateTotals" /> 实时公开候选人/议案票数</label><button class="admin-button" type="submit">创建草稿</button></EditForm>
<EditForm Model="draft" OnValidSubmit="CreateAsync" class="admin-form"><h2>新建投票</h2><p>可停在任一层级创建:全国、全省、全市、全县、镇街或村社。当前范围:<strong>@SelectedScopeName</strong></p><label>名称<input @bind="draft.Name" required /></label>@if (systemAdmin) { <div><button class="admin-link-button" type="button" @onclick="SelectNationwide">设为全国范围</button></div> }<label>省<select value="@provinceId" @onchange="SelectProvinceAsync"><option value="">请选择省</option>@foreach (var region in provinces) { <option value="@region.Id">@region.Name</option> }</select></label><label>市<select value="@cityId" @onchange="SelectCityAsync"><option value="">请选择市</option>@foreach (var region in cities) { <option value="@region.Id">@region.Name</option> }</select></label><label>区县<select value="@districtId" @onchange="SelectDistrictAsync"><option value="">请选择区县</option>@foreach (var region in districts) { <option value="@region.Id">@region.Name</option> }</select></label><label>镇街<select value="@townshipId" @onchange="SelectTownshipAsync"><option value="">请选择镇街</option>@foreach (var region in townships) { <option value="@region.Id">@region.Name</option> }</select></label><label>村社<select value="@villageId" @onchange="SelectVillageAsync"><option value="">请选择村社</option>@foreach (var region in villages) { <option value="@region.Id">@region.Name</option> }</select></label><label>投票开始(UTC)<input type="datetime-local" @bind="draft.VotingStarts" /></label><label>投票截止(UTC)<input type="datetime-local" @bind="draft.VotingEnds" /></label><label>计票截止(UTC)<input type="datetime-local" @bind="draft.CountingEnds" /></label><label><input type="checkbox" @bind="draft.PublishLiveParticipation" /> 实时公开参与量</label><label><input type="checkbox" @bind="draft.PublishLiveCandidateTotals" /> 实时公开候选人/议案票数</label><button class="admin-button" type="submit">创建草稿</button></EditForm>
<div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>活动</th><th>地区</th><th>状态</th><th>截止时间</th><th></th></tr></thead><tbody>@foreach (var election in elections) { <tr><td>@election.Name</td><td>@regionNames.GetValueOrDefault(election.RegionId, "—")</td><td>@election.Status</td><td>@election.VotingEndsAtUtc.ToString("yyyy-MM-dd HH:mm") UTC</td><td><button class="admin-link-button" @onclick="() => SelectAsync(election.Id)">配置</button></td></tr> }</tbody></table></div>
@if (selected is not null) {
<section class="admin-next"><h2>@selected.Name · 配置</h2><p>状态:@selected.Status。@if (selected.Status != ElectionActivityStatus.Draft) { <span>已发布活动仅可查看。</span> }</p>
@@ -30,11 +31,20 @@
@code {
[CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
private Guid actorId; private bool loaded; private string? notice; private readonly List<ElectionActivity> elections = []; private readonly List<ElectionContest> contests = []; private readonly List<ElectionOption> options = []; private readonly List<CandidateProfile> candidates = []; private readonly List<CountingStation> stations = []; private readonly List<AdministrativeRegion> publishRegions = []; private readonly Dictionary<Guid, string> regionNames = []; private readonly HashSet<Guid> selectedStationIds = []; private ElectionActivity? selected; private ElectionDraft draft = new(); private ContestDraft contestDraft = new(); private OptionDraft optionDraft = new();
protected override async Task OnInitializedAsync() { actorId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var managed = await Scope.GetManagedRegionIdsAsync(actorId, CancellationToken.None); var allRegions = await Db.AdministrativeRegions.Where(x => managed.Contains(x.Id) && x.IsActive).OrderBy(x => x.Level).ThenBy(x => x.Code).ToListAsync(); foreach (var region in allRegions) { regionNames[region.Id] = region.Name; if (await IsDirectPublisherAsync(region.Id)) publishRegions.Add(region); } elections.AddRange(await Db.ElectionActivities.Where(x => managed.Contains(x.RegionId)).OrderByDescending(x => x.CreatedAtUtc).ToListAsync()); stations.AddRange(await Db.CountingStations.Where(x => managed.Contains(x.RegionId) && x.IsActive).OrderBy(x => x.Code).ToListAsync()); candidates.AddRange(await Db.Candidates.Where(x => managed.Contains(x.RegionId) && (x.Status == CandidateStatus.Approved || x.Status == CandidateStatus.Publicized)).OrderBy(x => x.DisplayName).ToListAsync()); loaded = true; }
private async Task<bool> IsDirectPublisherAsync(Guid regionId) { try { await Scope.RequirePublisherAtRegionAsync(actorId, regionId, CancellationToken.None); return true; } catch (UnauthorizedAccessException) { return false; } }
private Guid actorId; private bool loaded, systemAdmin; private string? notice; private HashSet<Guid> managed = []; private Guid? provinceId, cityId, districtId, townshipId, villageId; private readonly List<ElectionActivity> elections = []; private readonly List<ElectionContest> contests = []; private readonly List<ElectionOption> options = []; private readonly List<CandidateProfile> candidates = []; private readonly List<CountingStation> stations = []; private readonly List<RegionOption> provinces = [], cities = [], districts = [], townships = [], villages = []; private readonly Dictionary<Guid, string> regionNames = []; private readonly HashSet<Guid> selectedStationIds = []; private ElectionActivity? selected; private ElectionDraft draft = new(); private ContestDraft contestDraft = new(); private OptionDraft optionDraft = new();
protected override async Task OnInitializedAsync() { actorId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Db.UserRegionalRoleAssignments.AsNoTracking().AnyAsync(x => x.UserId == actorId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null); if (!systemAdmin) managed = await Scope.GetManagedRegionIdsAsync(actorId, CancellationToken.None); await LoadPublishRegionsAsync(); var query = Db.ElectionActivities.AsQueryable(); if (!systemAdmin) query = query.Where(x => managed.Contains(x.RegionId)); elections.AddRange(await query.OrderByDescending(x => x.CreatedAtUtc).ToListAsync()); var ids = elections.Select(x => x.RegionId).Distinct().ToArray(); foreach (var region in await Db.AdministrativeRegions.AsNoTracking().Where(x => ids.Contains(x.Id)).Select(x => new { x.Id, x.Name }).ToListAsync()) regionNames[region.Id] = region.Name; loaded = true; }
private async Task LoadPublishRegionsAsync() { if (systemAdmin) { await LoadRegionOptionsAsync(provinces, null, AdministrativeRegionLevel.Province); return; } var direct = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && Db.UserRegionalRoleAssignments.Any(a => a.UserId == actorId && a.RegionId == x.Id && (a.Role == RegionalRole.ProvinceAdministrator || a.Role == RegionalRole.CityAdministrator || a.Role == RegionalRole.DistrictAdministrator || a.Role == RegionalRole.TownshipAdministrator || a.Role == RegionalRole.VillageAdministrator))).OrderBy(x => x.Code).Select(x => new RegionOption(x.Id, x.Level, x.Name)).ToListAsync(); provinces.AddRange(direct.Where(x => x.Level == AdministrativeRegionLevel.Province)); cities.AddRange(direct.Where(x => x.Level == AdministrativeRegionLevel.City)); districts.AddRange(direct.Where(x => x.Level == AdministrativeRegionLevel.District)); townships.AddRange(direct.Where(x => x.Level == AdministrativeRegionLevel.Township)); villages.AddRange(direct.Where(x => x.Level == AdministrativeRegionLevel.Village)); }
private async Task LoadRegionOptionsAsync(List<RegionOption> target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId).OrderBy(x => x.Code).Select(x => new RegionOption(x.Id, x.Level, x.Name)).ToListAsync()); }
private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = villageId = null; draft.RegionId = provinceId; cities.Clear(); districts.Clear(); townships.Clear(); villages.Clear(); if (provinceId.HasValue) await LoadRegionOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); }
private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = villageId = null; draft.RegionId = cityId ?? provinceId; districts.Clear(); townships.Clear(); villages.Clear(); if (cityId.HasValue) await LoadRegionOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); }
private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = villageId = null; draft.RegionId = districtId ?? cityId ?? provinceId; townships.Clear(); villages.Clear(); if (districtId.HasValue) await LoadRegionOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); }
private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); villageId = null; draft.RegionId = townshipId ?? districtId ?? cityId ?? provinceId; villages.Clear(); if (townshipId.HasValue) await LoadRegionOptionsAsync(villages, townshipId, AdministrativeRegionLevel.Village); }
private Task SelectVillageAsync(ChangeEventArgs e) { villageId = Parse(e); draft.RegionId = villageId ?? townshipId ?? districtId ?? cityId ?? provinceId; return Task.CompletedTask; }
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 SelectAsync(Guid id) { selected = elections.Single(x => x.Id == id); 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()); 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 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; } }
private void ToggleStation(Guid stationId, ChangeEventArgs e) { if (e.Value is bool value && value) selectedStationIds.Add(stationId); else selectedStationIds.Remove(stationId); }
@@ -45,4 +55,5 @@
private sealed class ElectionDraft { public string Name { get; set; } = ""; public Guid? RegionId { get; set; } public DateTime VotingStarts { get; set; } = DateTime.UtcNow.AddDays(1); public DateTime VotingEnds { get; set; } = DateTime.UtcNow.AddDays(2); public DateTime CountingEnds { get; set; } = DateTime.UtcNow.AddDays(3); public bool PublishLiveParticipation { get; set; } = true; public bool PublishLiveCandidateTotals { get; set; } }
private sealed class ContestDraft { public string Title { get; set; } = ""; public ElectionContestType Type { get; set; } = ElectionContestType.CandidateElection; public int Seats { get; set; } = 1; public int MinSelections { get; set; } = 1; public int MaxSelections { get; set; } = 1; public WinnerDetermination Determination { get; set; } = WinnerDetermination.HighestVotes; public decimal RequiredSupportPercent { get; set; } = 50m; }
private sealed class OptionDraft { public Guid? ContestId { get; set; } public Guid? CandidateId { get; set; } public string Label { get; set; } = ""; }
private sealed record RegionOption(Guid Id, AdministrativeRegionLevel Level, string Name);
}
Loaded 3 of 9 files, more files were not shown because too many files have changed in this diff. Show more