- 选民、计票所列表改为每页 50 条的游标分页,支持上一页/下一页。

- 加入省→市→区县→镇街四级连锁筛选;镇街筛选会包含其下属村级数据。
- 服务端 API 增加:
  - GET /api/admin/voters/cursor
  - GET /api/admin/counting-stations/cursor
- 服务端会先执行管理员地域授权和行政层级校验,再应用筛选与游标;非法游标、跨层级或越权地区参数返回 400。
- 所有 Blazor 管理页面已启用 Interactive Server,按钮、表单和分页不再只是静态外壳。
- 更新了管理端说明:[admin-management.md](E:\election system\docs\admin-management.md)。
验证通过:dotnet build 0 错误,dotnet test 8/8 通过。尚未在实际浏览器和 MySQL 数据库中做联调
This commit is contained in:
biss committed 2026-08-29 20:01:02 +08:00
1 parent 660d6fa2c4
commit 08fe656480
15 files changed
+251 -20

No files matched your search

@@ -0,0 +1,33 @@
using System.Text;
using System.Text.Json;
namespace ElectionSystem.Api.Common.Pagination;
/// <summary>Keyset-pagination response. The cursor represents the last item in the current stable ordering.</summary>
public sealed record CursorPagedResult<T>(IReadOnlyCollection<T> Items, string? NextCursor, bool HasMore, int PageSize);
public static class CursorToken
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public static string Encode<T>(T value)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, SerializerOptions);
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
public static bool TryDecode<T>(string? cursor, out T? value)
{
value = default;
if (string.IsNullOrWhiteSpace(cursor)) return true;
try
{
var normalized = cursor.Replace('-', '+').Replace('_', '/');
normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '=');
value = JsonSerializer.Deserialize<T>(Convert.FromBase64String(normalized), SerializerOptions);
return value is not null;
}
catch (FormatException) { return false; }
catch (JsonException) { return false; }
}
}
@@ -1,4 +1,5 @@
@page "/admin/candidates"
@rendermode InteractiveServer
@attribute [Authorize]
@layout AdminLayout
@inject ElectionDbContext Db
@@ -1,16 +1,25 @@
@page "/admin/counting-stations"
@rendermode InteractiveServer
@attribute [Authorize]
@layout AdminLayout
@inject ElectionDbContext Db
@inject RegionScopeService Scope
@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>
@if (notice is not null) { <div class="admin-alert">@notice</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.Name</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>
<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>
@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>
@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 { [CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!; private HashSet<Guid> scope = []; private bool loaded, creating; private string? notice; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List<Region> regions = []; private readonly List<StationRow> stations = []; private readonly List<StationChoice> stationChoices = [];
protected override async Task OnInitializedAsync() { regions.Clear(); stations.Clear(); stationChoices.Clear(); var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); var map = await Db.AdministrativeRegions.Where(x => scope.Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); regions.AddRange(map.Select(x => new Region(x.Key, x.Value))); var entities = await Db.CountingStations.Where(x => scope.Contains(x.RegionId)).OrderBy(x => x.Code).ToListAsync(); stations.AddRange(entities.Select(x => new StationRow(x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); stationChoices.AddRange(entities.Where(x => x.IsActive).Select(x => new StationChoice(x.Id, x.Name))); 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; stations.Clear(); regions.Clear(); await OnInitializedAsync(); }
@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;
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 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, string Name); private sealed record StationRow(string Code, string Name, string Region, bool Active); private sealed record StationChoice(Guid Id, string Name); }
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 15 files, more files were not shown because too many files have changed in this diff. Show more