From 08fe656480c73059c8244995a519ff4148ed7fcb Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 29 Aug 2026 20:01:02 +0800 Subject: [PATCH] =?UTF-8?q?-=20=E9=80=89=E6=B0=91=E3=80=81=E8=AE=A1?= =?UTF-8?q?=E7=A5=A8=E6=89=80=E5=88=97=E8=A1=A8=E6=94=B9=E4=B8=BA=E6=AF=8F?= =?UTF-8?q?=E9=A1=B5=2050=20=E6=9D=A1=E7=9A=84=E6=B8=B8=E6=A0=87=E5=88=86?= =?UTF-8?q?=E9=A1=B5=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=8A=E4=B8=80=E9=A1=B5?= =?UTF-8?q?/=E4=B8=8B=E4=B8=80=E9=A1=B5=E3=80=82=20-=20=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E7=9C=81=E2=86=92=E5=B8=82=E2=86=92=E5=8C=BA=E5=8E=BF=E2=86=92?= =?UTF-8?q?=E9=95=87=E8=A1=97=E5=9B=9B=E7=BA=A7=E8=BF=9E=E9=94=81=E7=AD=9B?= =?UTF-8?q?=E9=80=89=EF=BC=9B=E9=95=87=E8=A1=97=E7=AD=9B=E9=80=89=E4=BC=9A?= =?UTF-8?q?=E5=8C=85=E5=90=AB=E5=85=B6=E4=B8=8B=E5=B1=9E=E6=9D=91=E7=BA=A7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E3=80=82=20-=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=20API=20=E5=A2=9E=E5=8A=A0=EF=BC=9A=20=20=20-=20GET=20/api/adm?= =?UTF-8?q?in/voters/cursor=20=20=20-=20GET=20/api/admin/counting-stations?= =?UTF-8?q?/cursor=20-=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=BC=9A=E5=85=88?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E7=AE=A1=E7=90=86=E5=91=98=E5=9C=B0=E5=9F=9F?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E5=92=8C=E8=A1=8C=E6=94=BF=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E5=86=8D=E5=BA=94=E7=94=A8=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E4=B8=8E=E6=B8=B8=E6=A0=87=EF=BC=9B=E9=9D=9E=E6=B3=95?= =?UTF-8?q?=E6=B8=B8=E6=A0=87=E3=80=81=E8=B7=A8=E5=B1=82=E7=BA=A7=E6=88=96?= =?UTF-8?q?=E8=B6=8A=E6=9D=83=E5=9C=B0=E5=8C=BA=E5=8F=82=E6=95=B0=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20400=E3=80=82=20-=20=E6=89=80=E6=9C=89=20Blazor=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E5=B7=B2=E5=90=AF=E7=94=A8?= =?UTF-8?q?=20Interactive=20Server=EF=BC=8C=E6=8C=89=E9=92=AE=E3=80=81?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=92=8C=E5=88=86=E9=A1=B5=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=8F=AA=E6=98=AF=E9=9D=99=E6=80=81=E5=A4=96=E5=A3=B3=E3=80=82?= =?UTF-8?q?=20-=20=E6=9B=B4=E6=96=B0=E4=BA=86=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=9A[admin-management.md](E:\election=20?= =?UTF-8?q?system\docs\admin-management.md)=E3=80=82=20=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=80=9A=E8=BF=87=EF=BC=9Adotnet=20build=200=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=EF=BC=8Cdotnet=20test=208/8=20=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E3=80=82=E5=B0=9A=E6=9C=AA=E5=9C=A8=E5=AE=9E=E9=99=85=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E5=92=8C=20MySQL=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E4=B8=AD=E5=81=9A=E8=81=94=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/admin-management.md | 6 +- .../Common/Pagination/CursorPagedResult.cs | 33 ++++++++++ .../Components/Pages/Admin/Candidates.razor | 1 + .../Pages/Admin/CountingStations.razor | 23 ++++--- .../Components/Pages/Admin/Dashboard.razor | 1 + .../Components/Pages/Admin/Regions.razor | 1 + .../Components/Pages/Admin/Roles.razor | 1 + .../Components/Pages/Admin/Voters.razor | 37 +++++++++--- .../Components/_Imports.razor | 1 + .../Controllers/CountingStationsController.cs | 31 +++++++++- .../Controllers/VotersController.cs | 32 +++++++++- .../RegionCascadeFilterService.cs | 60 +++++++++++++++++++ src/ElectionSystem.Api/Program.cs | 1 + src/ElectionSystem.Api/wwwroot/css/site.css | 1 + .../PersistenceModelTests.cs | 42 +++++++++++++ 15 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 src/ElectionSystem.Api/Common/Pagination/CursorPagedResult.cs create mode 100644 src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs diff --git a/docs/admin-management.md b/docs/admin-management.md index 96a5ff4..4ffe870 100644 --- a/docs/admin-management.md +++ b/docs/admin-management.md @@ -26,9 +26,11 @@ dotnet user-secrets remove "Bootstrap:SystemAdministrator:Password" --project sr - `/admin/dashboard`:仅显示当前授权范围内的汇总。 - `/admin/regions` 与 `/admin/region-import`:系统管理员导入 AreaCity 四级目录;村/社区使用本地确认目录。 -- `/admin/voters` 与 `/admin/voter-import`:按地域维护选民、预校验并确认 Excel 导入。 +- `/admin/voters` 与 `/admin/voter-import`:按地域维护选民、预校验并确认 Excel 导入。选民列表使用每页 50 条的游标分页,并支持省→市→区县→镇街四级级联筛选。 - `/admin/candidates`:维护候选人资料和服务端允许的状态转换。 - `/admin/roles`:系统管理员授予地域管理员、计票员或监督员角色。 -- `/admin/counting-stations`:建立辖区计票所,并向已获地域角色的人员分配具体计票所。 +- `/admin/counting-stations`:建立辖区计票所,并向已获地域角色的人员分配具体计票所;列表同样使用游标分页及四级级联筛选。 + +对应管理 API 提供 `GET /api/admin/voters/cursor` 与 `GET /api/admin/counting-stations/cursor`。`cursor` 只能用于同一组筛选条件;筛选范围、下级地域展开和管理员地域授权均在服务端先行执行。非法游标或跨层级、越权的行政区划参数会返回 `400`,不会放宽数据范围。 管理端用于第二阶段基础资料与授权操作。选举活动、匿名投票、线下双录计票和公开结果将在后续阶段接入相应菜单,且不会通过本阶段页面绕过其规则。 diff --git a/src/ElectionSystem.Api/Common/Pagination/CursorPagedResult.cs b/src/ElectionSystem.Api/Common/Pagination/CursorPagedResult.cs new file mode 100644 index 0000000..1c078a4 --- /dev/null +++ b/src/ElectionSystem.Api/Common/Pagination/CursorPagedResult.cs @@ -0,0 +1,33 @@ +using System.Text; +using System.Text.Json; + +namespace ElectionSystem.Api.Common.Pagination; + +/// Keyset-pagination response. The cursor represents the last item in the current stable ordering. +public sealed record CursorPagedResult(IReadOnlyCollection Items, string? NextCursor, bool HasMore, int PageSize); + +public static class CursorToken +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static string Encode(T value) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, SerializerOptions); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + public static bool TryDecode(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(Convert.FromBase64String(normalized), SerializerOptions); + return value is not null; + } + catch (FormatException) { return false; } + catch (JsonException) { return false; } + } +} diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor index 53a8f00..6adac70 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor @@ -1,4 +1,5 @@ @page "/admin/candidates" +@rendermode InteractiveServer @attribute [Authorize] @layout AdminLayout @inject ElectionDbContext Db diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor b/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor index bdf62f6..1790224 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor @@ -1,16 +1,25 @@ @page "/admin/counting-stations" +@rendermode InteractiveServer @attribute [Authorize] @layout AdminLayout @inject ElectionDbContext Db @inject RegionScopeService Scope +@inject RegionCascadeFilterService Cascade 计票所管理 -

人员与场所 / 计票

计票所管理

计票员与监督员后续必须再分配到具体计票所,才能参与线下计票。

-@if (notice is not null) {
@notice
} @if (creating) {

新增计票所

} -
@if (!loaded) { } else if (stations.Count == 0) { } @foreach (var station in stations) { }
代码名称所属地区状态
正在加载…
暂无计票所。
@station.Code@station.Name@station.Region@(station.Active ? "启用" : "停用")
+

人员与场所 / 计票

计票所管理

按四级行政区划筛选;工作人员必须另行分配到具体计票所。

+@if (notice is not null) {
@notice
} +
+@if (creating) {

新增计票所

} +
@if (!loaded) { } else if (stations.Count == 0) { } @foreach (var station in stations) { }
代码名称所属地区状态
正在加载…
暂无计票所。
@station.Code@station.Name@station.Region@(station.Active ? "启用" : "停用")
每页 @PageSize 条

分配计票所工作人员

-@code { [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private HashSet scope = []; private bool loaded, creating; private string? notice; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List regions = []; private readonly List stations = []; private readonly List 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 AuthenticationStateTask { get; set; } = default!; private HashSet 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 regions = []; private readonly List filterRegions = []; private readonly List stations = []; private readonly List stationChoices = []; private readonly List 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 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(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); } diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor index 880936d..a5e00a6 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor @@ -1,4 +1,5 @@ @page "/admin/dashboard" +@rendermode InteractiveServer @attribute [Authorize] @layout AdminLayout @inject ElectionDbContext Db diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor index 4374417..a21058a 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor @@ -1,4 +1,5 @@ @page "/admin/regions" +@rendermode InteractiveServer @attribute [Authorize] @layout AdminLayout @inject ElectionDbContext Db diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor index 2ebe6bd..89a9db9 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor @@ -1,4 +1,5 @@ @page "/admin/roles" +@rendermode InteractiveServer @attribute [Authorize] @layout AdminLayout @inject ElectionDbContext Db diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor index 88ec35a..ef3fe7c 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor @@ -1,27 +1,46 @@ @page "/admin/voters" +@rendermode InteractiveServer @attribute [Authorize] @layout AdminLayout @inject ElectionDbContext Db @inject RegionScopeService Scope +@inject RegionCascadeFilterService Cascade 选民管理
-

基础资料 / 选民

选民管理

只能查询和维护当前辖区及下级地区的选民。

+

基础资料 / 选民

选民管理

按省、市、区县、镇街筛选;结果仅包含当前辖区及下级地区。

@if (message is not null) {
@message
} -
+
+ + + + + +
@if (creating) {

新增选民

} - @if (!loaded) {

正在加载选民…

} else {
@if (voters.Count == 0) { } @foreach (var voter in voters) { }
选民编号姓名所属地区状态操作
当前条件下没有选民。可先从“选民导入”上传名单。
@voter.VoterNumber@voter.DisplayName@voter.RegionName@StatusText(voter.Status)@if (voter.Status == VoterStatus.Disabled) { } else { }
} + @if (!loaded) {

正在加载选民…

} else {
@if (voters.Count == 0) { } @foreach (var voter in voters) { }
选民编号姓名所属地区状态操作
当前条件下没有选民。可先从“选民导入”上传名单。
@voter.VoterNumber@voter.DisplayName@voter.RegionName@StatusText(voter.Status)@if (voter.Status == VoterStatus.Disabled) { } else { }
每页 @PageSize 条
}
@code { + private const int PageSize = 50; [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; - private readonly List voters = []; private readonly List regions = []; private VoterDraft draft = new(); private string keyword = "", status = ""; private bool loaded, creating; private string? message; private HashSet managed = []; - protected override async Task OnInitializedAsync() { await LoadRegionsAsync(); await LoadAsync(); } - private async Task LoadRegionsAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); managed = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); var all = await Db.AdministrativeRegions.Where(x => managed.Contains(x.Id) && x.IsActive).OrderBy(x => x.Level).ThenBy(x => x.Code).ToListAsync(); foreach (var x in all) regions.Add(new(x.Id, $"{x.Level}: {x.Name}")); } - private async Task LoadAsync() { var query = Db.Voters.Where(x => managed.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.VoterNumber.Contains(keyword) || x.DisplayName.Contains(keyword)); if (Enum.TryParse(status, out var state)) query = query.Where(x => x.Status == state); var names = await Db.AdministrativeRegions.ToDictionaryAsync(x => x.Id, x => x.Name); var rows = await query.OrderBy(x => x.DisplayName).Take(100).Select(x => new { x.Id, x.VoterNumber, x.DisplayName, x.RegionId, x.Status }).ToListAsync(); voters.Clear(); voters.AddRange(rows.Select(x => new VoterRow(x.Id, x.VoterNumber, x.DisplayName, names.GetValueOrDefault(x.RegionId, "—"), x.Status))); loaded = true; } + private readonly List voters = []; private readonly List regions = []; private readonly List filterRegions = []; private readonly List cursorHistory = []; + private VoterDraft draft = new(); private string keyword = "", status = ""; private bool loaded, creating; private string? message, currentCursor, nextCursor; private HashSet managed = []; private Guid? provinceId, cityId, districtId, townshipId; + protected override async Task OnInitializedAsync() { await LoadRegionsAsync(); await SearchAsync(); } + private async Task LoadRegionsAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); managed = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); var all = await Db.AdministrativeRegions.Where(x => managed.Contains(x.Id) && x.IsActive).OrderBy(x => x.Level).ThenBy(x => x.Code).ToListAsync(); filterRegions.AddRange(all.Select(x => new RegionOption(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}"))); regions.AddRange(filterRegions); } + private IEnumerable Options(AdministrativeRegionLevel level, Guid? parentId) => filterRegions.Where(x => x.Level == level && (!parentId.HasValue || x.ParentId == parentId)).OrderBy(x => x.Name); + 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(managed, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); var query = Db.Voters.Where(x => selectedRegions.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.VoterNumber.Contains(keyword) || x.DisplayName.Contains(keyword)); if (Enum.TryParse(status, out var state)) query = query.Where(x => x.Status == state); if (!CursorToken.TryDecode(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.DisplayName, token.DisplayName) > 0 || (x.DisplayName == token.DisplayName && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.DisplayName).ThenBy(x => x.Id).Take(PageSize + 1).Select(x => new { x.Id, x.VoterNumber, x.DisplayName, x.RegionId, x.Status }).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var names = await Db.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); voters.Clear(); voters.AddRange(rows.Select(x => new VoterRow(x.Id, x.VoterNumber, x.DisplayName, names.GetValueOrDefault(x.RegionId, "—"), x.Status))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].DisplayName, rows[^1].Id)) : null; } catch (ArgumentException e) { message = e.Message; voters.Clear(); nextCursor = null; } finally { loaded = true; } } private void ToggleCreate() { creating = !creating; message = null; } - private async Task CreateAsync() { if (!draft.RegionId.HasValue || !managed.Contains(draft.RegionId.Value)) { message = "请选择您有权管理的行政区划。"; return; } if (await Db.Voters.AnyAsync(x => x.VoterNumber == draft.VoterNumber)) { message = "选民编号已存在。"; return; } Db.Voters.Add(new VoterProfile { VoterNumber = draft.VoterNumber.Trim(), DisplayName = draft.DisplayName.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); draft = new(); creating = false; message = "已新增选民。"; await LoadAsync(); } + private async Task CreateAsync() { if (!draft.RegionId.HasValue || !managed.Contains(draft.RegionId.Value)) { message = "请选择您有权管理的行政区划。"; return; } if (await Db.Voters.AnyAsync(x => x.VoterNumber == draft.VoterNumber)) { message = "选民编号已存在。"; return; } Db.Voters.Add(new VoterProfile { VoterNumber = draft.VoterNumber.Trim(), DisplayName = draft.DisplayName.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); draft = new(); creating = false; message = "已新增选民。"; await SearchAsync(); } private async Task SetStatusAsync(VoterRow voter, VoterStatus target) { var entity = await Db.Voters.FindAsync(voter.Id); if (entity is null || !managed.Contains(entity.RegionId)) { message = "未找到可管理的选民。"; return; } entity.Status = target; entity.DisabledReason = target == VoterStatus.Disabled ? "管理员在管理端停用" : null; entity.UpdatedAtUtc = DateTime.UtcNow; await Db.SaveChangesAsync(); message = target == VoterStatus.Disabled ? "已停用选民。" : "已恢复选民。"; await LoadAsync(); } private static string StatusText(VoterStatus value) => value switch { VoterStatus.Active => "有效", VoterStatus.Disabled => "已停用", _ => "待审核" }; private sealed class VoterDraft { public string VoterNumber { get; set; } = ""; public string DisplayName { get; set; } = ""; public Guid? RegionId { get; set; } } - private sealed record VoterRow(Guid Id, string VoterNumber, string DisplayName, string RegionName, VoterStatus Status); private sealed record RegionOption(Guid Id, string Path); + private sealed record RegionOption(Guid Id, Guid? ParentId, AdministrativeRegionLevel Level, string Name, string Path); private sealed record VoterRow(Guid Id, string VoterNumber, string DisplayName, string RegionName, VoterStatus Status); private sealed record Cursor(string DisplayName, Guid Id); } diff --git a/src/ElectionSystem.Api/Components/_Imports.razor b/src/ElectionSystem.Api/Components/_Imports.razor index 2959a86..b0b6b4f 100644 --- a/src/ElectionSystem.Api/Components/_Imports.razor +++ b/src/ElectionSystem.Api/Components/_Imports.razor @@ -6,6 +6,7 @@ @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.EntityFrameworkCore @using ElectionSystem.Api.Infrastructure.Persistence +@using ElectionSystem.Api.Common.Pagination @using ElectionSystem.Api.Modules.Organization @using ElectionSystem.Api.Modules.Organization.Domain @using ElectionSystem.Api.Modules.Voters.Domain diff --git a/src/ElectionSystem.Api/Controllers/CountingStationsController.cs b/src/ElectionSystem.Api/Controllers/CountingStationsController.cs index e1ab44a..8fb5ef8 100644 --- a/src/ElectionSystem.Api/Controllers/CountingStationsController.cs +++ b/src/ElectionSystem.Api/Controllers/CountingStationsController.cs @@ -1,3 +1,4 @@ +using ElectionSystem.Api.Common.Pagination; using ElectionSystem.Api.Infrastructure.Persistence; using ElectionSystem.Api.Modules.Organization.Domain; using Microsoft.AspNetCore.Mvc; @@ -6,7 +7,7 @@ using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Controllers; [Route("api/admin/counting-stations")] -public sealed class CountingStationsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService) : AdministrationControllerBase(scopeService) +public sealed class CountingStationsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, Modules.Organization.RegionCascadeFilterService cascadeFilterService) : AdministrationControllerBase(scopeService) { [HttpGet] public async Task List([FromQuery] Guid? regionId, CancellationToken cancellationToken) @@ -17,6 +18,32 @@ public sealed class CountingStationsController(ElectionDbContext dbContext, Modu return Ok(await query.OrderBy(x => x.Code).Select(x => new { x.Id, x.Code, x.Name, x.RegionId, x.IsActive }).ToListAsync(cancellationToken)); } + [HttpGet("cursor")] + public async Task>> ListCursor( + [FromQuery] Guid? provinceId, [FromQuery] Guid? cityId, [FromQuery] Guid? districtId, [FromQuery] Guid? townshipId, + [FromQuery] string? keyword, [FromQuery] string? cursor, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) + { + pageSize = Math.Clamp(pageSize, 1, 100); + if (!CursorToken.TryDecode(cursor, out var token)) return BadRequest(new { code = "invalid_cursor", message = "分页游标无效,请重新查询。" }); + try + { + var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); + var query = dbContext.CountingStations.Where(x => regions.Contains(x.RegionId)); + if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); + 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) + .Select(x => new CountingStationCursorRow(x.Id, x.Code, x.Name, x.RegionId, x.IsActive)).ToListAsync(cancellationToken); + var hasMore = rows.Count > pageSize; + if (hasMore) rows.RemoveAt(rows.Count - 1); + var regionNames = await dbContext.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken); + var items = rows.Select(x => (object)new { x.Id, x.Code, x.Name, x.RegionId, RegionName = regionNames.GetValueOrDefault(x.RegionId, "—"), x.IsActive }).ToList(); + var next = hasMore && rows.Count > 0 ? CursorToken.Encode(new CountingStationCursor(rows[^1].Code, rows[^1].Id)) : null; + return Ok(new CursorPagedResult(items, next, hasMore, pageSize)); + } + catch (ArgumentException exception) { return BadRequest(new { code = "invalid_region_filter", message = exception.Message }); } + } + [HttpPost] public async Task Create(CreateCountingStationRequest request, CancellationToken cancellationToken) { @@ -41,3 +68,5 @@ public sealed class CountingStationsController(ElectionDbContext dbContext, Modu public sealed record CreateCountingStationRequest(string Code, string Name, Guid RegionId); public sealed record AssignCountingStationStaffRequest(Guid UserId); +public sealed record CountingStationCursor(string Code, Guid Id); +internal sealed record CountingStationCursorRow(Guid Id, string Code, string Name, Guid RegionId, bool IsActive); diff --git a/src/ElectionSystem.Api/Controllers/VotersController.cs b/src/ElectionSystem.Api/Controllers/VotersController.cs index 1e20d8d..867e7bd 100644 --- a/src/ElectionSystem.Api/Controllers/VotersController.cs +++ b/src/ElectionSystem.Api/Controllers/VotersController.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Controllers; [Route("api/admin/voters")] -public sealed class VotersController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, VoterImportService importService) : AdministrationControllerBase(scopeService) +public sealed class VotersController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, Modules.Organization.RegionCascadeFilterService cascadeFilterService, VoterImportService importService) : AdministrationControllerBase(scopeService) { [HttpGet] public async Task>> List([FromQuery] Guid? regionId, [FromQuery] VoterStatus? status, [FromQuery] string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) @@ -24,6 +24,34 @@ public sealed class VotersController(ElectionDbContext dbContext, Modules.Organi return Ok(new PagedResult(items, page, pageSize, total)); } + [HttpGet("cursor")] + public async Task>> ListCursor( + [FromQuery] Guid? provinceId, [FromQuery] Guid? cityId, [FromQuery] Guid? districtId, [FromQuery] Guid? townshipId, + [FromQuery] VoterStatus? status, [FromQuery] string? keyword, [FromQuery] string? cursor, [FromQuery] int pageSize = 50, + CancellationToken cancellationToken = default) + { + pageSize = Math.Clamp(pageSize, 1, 100); + if (!CursorToken.TryDecode(cursor, out var token)) return BadRequest(new { code = "invalid_cursor", message = "分页游标无效,请重新查询。" }); + try + { + var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); + var query = dbContext.Voters.Where(x => regions.Contains(x.RegionId)); + if (status.HasValue) query = query.Where(x => x.Status == status); + if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.VoterNumber.Contains(keyword) || x.DisplayName.Contains(keyword)); + if (token is not null) query = query.Where(x => string.Compare(x.DisplayName, token.DisplayName) > 0 || (x.DisplayName == token.DisplayName && x.Id.CompareTo(token.Id) > 0)); + var rows = await query.OrderBy(x => x.DisplayName).ThenBy(x => x.Id).Take(pageSize + 1) + .Select(x => new VoterCursorRow(x.Id, x.VoterNumber, x.DisplayName, x.RegionId, x.Status, x.DisabledReason, x.UpdatedAtUtc)).ToListAsync(cancellationToken); + var hasMore = rows.Count > pageSize; + if (hasMore) rows.RemoveAt(rows.Count - 1); + var regionNames = await dbContext.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken); + var items = rows.Select(x => (object)new { x.Id, x.VoterNumber, x.DisplayName, x.RegionId, RegionName = regionNames.GetValueOrDefault(x.RegionId, "—"), x.Status, x.DisabledReason, x.UpdatedAtUtc }).ToList(); + var next = hasMore && rows.Count > 0 ? CursorToken.Encode(new VoterCursor(rows[^1].DisplayName, rows[^1].Id)) : null; + return Ok(new CursorPagedResult(items, next, hasMore, pageSize)); + } + catch (ArgumentException exception) { return BadRequest(new { code = "invalid_region_filter", message = exception.Message }); } + } + [HttpPost] public async Task Create(CreateVoterRequest request, CancellationToken cancellationToken) { @@ -61,3 +89,5 @@ public sealed class VotersController(ElectionDbContext dbContext, Modules.Organi public sealed record CreateVoterRequest(string VoterNumber, string DisplayName, Guid RegionId); public sealed record SetVoterStatusRequest(VoterStatus Status, string? Reason); +public sealed record VoterCursor(string DisplayName, Guid Id); +internal sealed record VoterCursorRow(Guid Id, string VoterNumber, string DisplayName, Guid RegionId, VoterStatus Status, string? DisabledReason, DateTime UpdatedAtUtc); diff --git a/src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs b/src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs new file mode 100644 index 0000000..38969a5 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs @@ -0,0 +1,60 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Modules.Organization; + +public sealed record AdministrativeRegionFilter(Guid? ProvinceId, Guid? CityId, Guid? DistrictId, Guid? TownshipId); + +/// Validates province-to-township selections and returns the selected subtree intersected with the caller scope. +public sealed class RegionCascadeFilterService(ElectionDbContext dbContext) +{ + public async Task> ResolveAsync(HashSet managedRegionIds, AdministrativeRegionFilter filter, CancellationToken cancellationToken) + { + if (managedRegionIds.Count == 0) return []; + + var regions = await dbContext.AdministrativeRegions + .Where(x => x.IsActive) + .Select(x => new RegionNode(x.Id, x.ParentId, x.Level)) + .ToListAsync(cancellationToken); + var byId = regions.ToDictionary(x => x.Id); + Validate(filter.ProvinceId, AdministrativeRegionLevel.Province, byId, managedRegionIds); + Validate(filter.CityId, AdministrativeRegionLevel.City, byId, managedRegionIds); + Validate(filter.DistrictId, AdministrativeRegionLevel.District, byId, managedRegionIds); + Validate(filter.TownshipId, AdministrativeRegionLevel.Township, byId, managedRegionIds); + ValidateParent(filter.ProvinceId, filter.CityId, byId); + ValidateParent(filter.CityId, filter.DistrictId, byId); + ValidateParent(filter.DistrictId, filter.TownshipId, byId); + + var selectedId = filter.TownshipId ?? filter.DistrictId ?? filter.CityId ?? filter.ProvinceId; + if (!selectedId.HasValue) return managedRegionIds; + + var children = regions.Where(x => x.ParentId.HasValue) + .GroupBy(x => x.ParentId!.Value) + .ToDictionary(x => x.Key, x => x.Select(y => y.Id).ToArray()); + var subtree = new HashSet { selectedId.Value }; + var queue = new Queue(subtree); + while (queue.TryDequeue(out var parentId) && children.TryGetValue(parentId, out var childIds)) + foreach (var childId in childIds) + if (subtree.Add(childId)) queue.Enqueue(childId); + + subtree.IntersectWith(managedRegionIds); + return subtree; + } + + private static void Validate(Guid? id, AdministrativeRegionLevel level, IReadOnlyDictionary regions, IReadOnlySet managed) + { + if (!id.HasValue) return; + if (!managed.Contains(id.Value) || !regions.TryGetValue(id.Value, out var region) || region.Level != level) + throw new ArgumentException("行政区划筛选无效或超出当前管理范围。"); + } + + private static void ValidateParent(Guid? parentId, Guid? childId, IReadOnlyDictionary regions) + { + if (!parentId.HasValue || !childId.HasValue) return; + if (!regions.TryGetValue(childId.Value, out var child) || child.ParentId != parentId) + throw new ArgumentException("行政区划筛选层级不匹配。"); + } + + private sealed record RegionNode(Guid Id, Guid? ParentId, AdministrativeRegionLevel Level); +} diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index b28da62..e637b0a 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -31,6 +31,7 @@ builder.Services.AddExceptionHandler(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddMemoryCache(); diff --git a/src/ElectionSystem.Api/wwwroot/css/site.css b/src/ElectionSystem.Api/wwwroot/css/site.css index 856290b..d2c39b3 100644 --- a/src/ElectionSystem.Api/wwwroot/css/site.css +++ b/src/ElectionSystem.Api/wwwroot/css/site.css @@ -74,3 +74,4 @@ a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid .login-page { display: grid; min-height: 100vh; place-items: center; padding: 1.5rem; background: linear-gradient(135deg, #e5eff6 0%, #f4f7fa 55%, #dce9f2 100%); }.login-card { width: min(100%, 27rem); padding: 2.25rem; background: #fff; border-top: .3rem solid #1769aa; box-shadow: 0 1.4rem 3rem rgba(22, 58, 89, .14); }.login-card h1 { margin: 0; font-family: "Noto Serif SC", SimSun, serif; font-size: 2rem; }.login-card > p:not(.eyebrow) { color: #617487; line-height: 1.7; }.login-form { display: grid; gap: 1rem; margin-top: 1.5rem; }.login-form label { display: grid; gap: .4rem; color: #405769; font-size: .86rem; font-weight: 750; }.login-form input:not([type="checkbox"]) { padding: .68rem; border: 1px solid #b9c8d5; font: inherit; }.login-check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: .5rem !important; font-weight: 500 !important; }.login-error { color: #992f2f; font-size: .88rem; } .management-document { display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; background: #eef2f5; }.management-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 4.25rem; padding: 0 1.5rem; color: #fff; background: #163a59; }.management-header__brand { display: flex; align-items: baseline; gap: .55rem; color: #fff; text-decoration: none; }.management-header__brand span { padding: .16rem .38rem; color: #163a59; background: #d4e7f5; font-weight: 850; letter-spacing: .08em; }.management-header__brand strong { letter-spacing: .08em; }.management-header__nav { display: flex; align-items: center; gap: 1rem; }.management-header__nav a, .management-header__nav button { color: #d4e7f5; background: transparent; border: 0; font: inherit; font-size: .85rem; text-decoration: none; cursor: pointer; }.management-header__nav form { margin: 0; }.management-footer { padding: 1rem 1.5rem; color: #b8c7d2; background: #23313f; font-size: .78rem; }.management-document .admin-razor-page { width: min(100%, 82rem); margin: 0 auto; padding: clamp(1.5rem, 4vw, 3rem); }.management-document .admin-razor-page > h1 { margin: 0; font-family: "Noto Serif SC", SimSun, serif; font-size: clamp(1.8rem, 3.4vw, 2.6rem); }.management-document .admin-razor-page > p:not(.eyebrow) { max-width: 48rem; color: #617487; line-height: 1.7; }.logout-page { width: min(100% - 2rem, 34rem); margin: 4rem auto; padding: 1.5rem; background: #fff; border-top: .25rem solid #1769aa; } @media (max-width: 40rem) { .management-header { align-items: flex-start; flex-direction: column; justify-content: center; padding: .9rem 1rem; }.management-header__nav { width: 100%; justify-content: space-between; }.management-footer { padding: 1rem; }.management-document .admin-razor-page { padding: 1.25rem 1rem; } } +.admin-filter--regions { align-items: end; }.admin-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 1rem; margin-top: 1rem; color: #596579; font-size: .9rem; }.admin-pagination button:disabled { cursor: not-allowed; opacity: .48; } diff --git a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs index 4242879..2907d23 100644 --- a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs +++ b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs @@ -1,5 +1,6 @@ using ElectionSystem.Api.Infrastructure.Auditing; using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Common.Pagination; using ElectionSystem.Api.Modules.Identity.Domain; using ElectionSystem.Api.Modules.Organization; using ElectionSystem.Api.Modules.Organization.Domain; @@ -86,4 +87,45 @@ public sealed class PersistenceModelTests Assert.NotEqual(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "Correct-Horse-7!Battery")); Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong-password")); } + + [Fact] + public void Cursor_token_round_trips_and_rejects_malformed_values() + { + var cursor = new TestCursor("张三", Guid.NewGuid()); + + var encoded = CursorToken.Encode(cursor); + + Assert.True(CursorToken.TryDecode(encoded, out var decoded)); + Assert.Equal(cursor, decoded); + Assert.False(CursorToken.TryDecode("not-a-cursor", out _)); + } + + [Fact] + public async Task Four_level_filter_returns_selected_subtree_and_rejects_cross_branch_selection() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + await using var context = new ElectionDbContext(options); + var province = Region("11", "省", AdministrativeRegionLevel.Province); + var city = Region("1101", "市", AdministrativeRegionLevel.City, province.Id); + var district = Region("110101", "区", AdministrativeRegionLevel.District, city.Id); + var township = Region("110101001", "镇", AdministrativeRegionLevel.Township, district.Id); + var village = Region("110101001001", "村", AdministrativeRegionLevel.Village, township.Id); + var otherCity = Region("1201", "另一市", AdministrativeRegionLevel.City, province.Id); + context.AddRange(province, city, district, township, village, otherCity); + await context.SaveChangesAsync(); + var service = new RegionCascadeFilterService(context); + var scope = new HashSet { province.Id, city.Id, district.Id, township.Id, village.Id, otherCity.Id }; + + var result = await service.ResolveAsync(scope, new AdministrativeRegionFilter(province.Id, city.Id, district.Id, township.Id), CancellationToken.None); + + Assert.Equal(new[] { township.Id, village.Id }.Order(), result.Order()); + await Assert.ThrowsAsync(() => service.ResolveAsync(scope, new AdministrativeRegionFilter(province.Id, otherCity.Id, district.Id, null), CancellationToken.None)); + } + + private static AdministrativeRegion Region(string code, string name, AdministrativeRegionLevel level, Guid? parentId = null) => new() + { + Code = code, Name = name, Level = level, ParentId = parentId, Source = "test", SourceVersion = "test" + }; + + private sealed record TestCursor(string Name, Guid Id); }