优化查询

This commit is contained in:
biss committed 2026-09-11 21:10:43 +08:00
1 parent 2d38c9cb60
commit 1f459d53f4
14 files changed
+1275 -32

No files matched your search

@@ -10,18 +10,18 @@
<section class="admin-page"><div class="admin-page__heading"><div><p class="eyebrow">人员与场所 / 计票</p><h1>计票所管理</h1><p>按四级行政区划筛选;工作人员必须另行分配到具体计票所。</p></div><button class="admin-button" @onclick="ToggleCreateAsync">新增计票所</button></div>
@if (notice is not null) { <div class="admin-alert">@notice</div> }
<div class="admin-filter admin-filter--regions"><label>省<select value="@provinceId" @onchange="SelectProvinceAsync"><option value="">全部省份</option>@foreach (var x in provinces) { <option value="@x.Id">@x.Name</option> }</select></label><label>市<select value="@cityId" @onchange="SelectCityAsync"><option value="">全部城市</option>@foreach (var x in cities) { <option value="@x.Id">@x.Name</option> }</select></label><label>区县<select value="@districtId" @onchange="SelectDistrictAsync"><option value="">全部区县</option>@foreach (var x in districts) { <option value="@x.Id">@x.Name</option> }</select></label><label>镇街<select value="@townshipId" @onchange="SelectTownshipAsync"><option value="">全部镇街</option>@foreach (var x in townships) { <option value="@x.Id">@x.Name</option> }</select></label><label>名称或代码<input @bind="keyword" @bind:event="oninput" placeholder="输入后点击查询" /></label><button class="admin-button admin-button--quiet" @onclick="SearchAsync">查询</button></div>
@if (creating) { <EditForm Model="draft" OnValidSubmit="CreateAsync" class="admin-form"><h2>新增计票所</h2><label>计票所代码<input @bind="draft.Code" required /></label><label>名称<input @bind="draft.Name" required /></label><label>所属地区<select @bind="draft.RegionId"><option value="">请选择地区</option>@foreach (var r in regions) { <option value="@r.Id">@r.Path</option> }</select></label><div><button class="admin-button" type="submit">保存</button><button class="admin-link-button" type="button" @onclick="() => creating = false">取消</button></div></EditForm> }
@if (creating) { <EditForm Model="draft" OnValidSubmit="CreateAsync" class="admin-form"><h2>新增计票所</h2><label>计票所代码<input @bind="draft.Code" required /></label><label>名称<input @bind="draft.Name" required /></label><fieldset><legend>所属地区(可停在任意已授权层级)</legend><label>省<select value="@provinceId" @onchange="SelectProvinceAsync"><option value="">请选择省份</option>@foreach (var x in provinces) { <option value="@x.Id">@x.Name</option> }</select></label><label>市<select value="@cityId" @onchange="SelectCityAsync"><option value="">请选择城市</option>@foreach (var x in cities) { <option value="@x.Id">@x.Name</option> }</select></label><label>区县<select value="@districtId" @onchange="SelectDistrictAsync"><option value="">请选择区县</option>@foreach (var x in districts) { <option value="@x.Id">@x.Name</option> }</select></label><label>镇街<select value="@townshipId" @onchange="SelectTownshipAsync"><option value="">请选择镇街</option>@foreach (var x in townships) { <option value="@x.Id">@x.Name</option> }</select></label></fieldset><div><button class="admin-button" type="submit">保存</button><button class="admin-link-button" type="button" @onclick="() => creating = false">取消</button></div></EditForm> }
<div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>代码</th><th>名称</th><th>所属地区</th><th>状态</th></tr></thead><tbody>@if (!loaded) { <tr><td colspan="4">正在加载…</td></tr> } else if (stations.Count == 0) { <tr><td colspan="4" class="admin-empty">暂无计票所。</td></tr> } @foreach (var station in stations) { <tr><td class="admin-mono">@station.Code</td><td>@station.Name</td><td>@station.Region</td><td>@(station.Active ? "启用" : "停用")</td></tr> }</tbody></table></div><div class="admin-pagination"><button class="admin-link-button" disabled="@(cursorHistory.Count == 0)" @onclick="PreviousAsync">上一页</button><span>每页 @PageSize 条</span><button class="admin-link-button" disabled="@(nextCursor is null)" @onclick="NextAsync">下一页</button></div>
<EditForm Model="staffDraft" OnValidSubmit="AssignStaffAsync" class="admin-form"><h2>分配计票所工作人员</h2><label>搜索计票所<input @bind="stationKeyword" @bind:event="oninput" placeholder="名称或代码" /></label><button class="admin-button admin-button--quiet" type="button" @onclick="SearchStationChoicesAsync">查询计票所</button><label>计票所<select @bind="staffDraft.StationId"><option value="">请选择计票所</option>@foreach (var station in stationChoices) { <option value="@station.Id">@station.Name</option> }</select></label><label>用户 ID<input @bind="staffDraft.UserId" placeholder="用户 GUID" required /></label><button class="admin-button" type="submit">分配工作人员</button></EditForm></section>
@code { private const int PageSize = 50; [CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!; private HashSet<Guid> scope = []; private bool loaded, creating; private string? notice, currentCursor, nextCursor; private string keyword = "", stationKeyword = ""; private Guid? provinceId, cityId, districtId, townshipId; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List<Region> regions = [], provinces = [], cities = [], districts = [], townships = []; private readonly List<StationRow> stations = []; private readonly List<StationChoice> stationChoices = []; private readonly List<string?> cursorHistory = [];
protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); await SearchAsync(); }
@code { private const int PageSize = 50; [CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!; private HashSet<Guid> scope = []; private bool systemAdmin, loaded, creating; private string? notice, currentCursor, nextCursor; private string keyword = "", stationKeyword = ""; private Guid? provinceId, cityId, districtId, townshipId; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List<Region> regions = [], provinces = [], cities = [], districts = [], townships = []; private readonly List<StationRow> stations = []; private readonly List<StationChoice> stationChoices = []; private readonly List<string?> cursorHistory = [];
protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Scope.IsSystemAdministratorAsync(id, CancellationToken.None); if (!systemAdmin) scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); await SearchAsync(); }
private async Task LoadInitialOptionsAsync(Guid userId) { var systemAdmin = await Db.UserRegionalRoleAssignments.AsNoTracking().AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null); if (systemAdmin) { await LoadOptionsAsync(provinces, null, AdministrativeRegionLevel.Province); return; } var roots = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && Db.UserRegionalRoleAssignments.Any(a => a.UserId == userId && a.RegionId == x.Id && (a.Role == RegionalRole.ProvinceAdministrator || a.Role == RegionalRole.CityAdministrator || a.Role == RegionalRole.DistrictAdministrator || a.Role == RegionalRole.TownshipAdministrator))).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync(); provinces.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Province)); cities.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.City)); districts.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.District)); townships.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Township)); }
private async Task LoadOptionsAsync(List<Region> target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && scope.Contains(x.Id)).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); }
private async Task ToggleCreateAsync() { creating = !creating; if (creating && regions.Count == 0) regions.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && scope.Contains(x.Id)).OrderBy(x => x.Level).ThenBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); }
private async Task SearchStationChoicesAsync() { stationChoices.Clear(); var query = Db.CountingStations.AsNoTracking().Where(x => x.IsActive && scope.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(stationKeyword)) query = query.Where(x => x.Code.Contains(stationKeyword) || x.Name.Contains(stationKeyword)); var rows = await query.OrderBy(x => x.Code).Take(PageSize).Select(x => new { x.Id, x.Code, x.Name, x.RegionId }).ToListAsync(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stationChoices.AddRange(rows.Select(x => new StationChoice(x.Id, $"{x.Code} · {x.Name}({names.GetValueOrDefault(x.RegionId, "—")})"))); }
private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; cities.Clear(); districts.Clear(); townships.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; districts.Clear(); townships.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; townships.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); await SearchAsync(); } private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); await SearchAsync(); } private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
private async Task LoadOptionsAsync(List<Region> target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && (systemAdmin || scope.Contains(x.Id))).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); }
private Task ToggleCreateAsync() { creating = !creating; if (creating) draft.RegionId = townshipId ?? districtId ?? cityId ?? provinceId; return Task.CompletedTask; }
private async Task SearchStationChoicesAsync() { stationChoices.Clear(); IQueryable<CountingStation> query = Db.CountingStations.AsNoTracking().Where(x => x.IsActive); if (!systemAdmin) query = query.Where(x => scope.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(stationKeyword)) query = query.Where(x => x.Code.Contains(stationKeyword) || x.Name.Contains(stationKeyword)); var rows = await query.OrderBy(x => x.Code).Take(PageSize).Select(x => new { x.Id, x.Code, x.Name, x.RegionId }).ToListAsync(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stationChoices.AddRange(rows.Select(x => new StationChoice(x.Id, $"{x.Code} · {x.Name}({names.GetValueOrDefault(x.RegionId, "—")})"))); }
private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; cities.Clear(); districts.Clear(); townships.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); SyncDraftRegion(); await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; districts.Clear(); townships.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); SyncDraftRegion(); await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; townships.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); SyncDraftRegion(); await SearchAsync(); } private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); SyncDraftRegion(); await SearchAsync(); } private void SyncDraftRegion() { if (creating) draft.RegionId = townshipId ?? districtId ?? cityId ?? provinceId; } private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
private async Task SearchAsync() { currentCursor = null; cursorHistory.Clear(); await LoadAsync(); } private async Task NextAsync() { if (nextCursor is null) return; cursorHistory.Add(currentCursor); currentCursor = nextCursor; await LoadAsync(); } private async Task PreviousAsync() { if (cursorHistory.Count == 0) return; currentCursor = cursorHistory[^1]; cursorHistory.RemoveAt(cursorHistory.Count - 1); await LoadAsync(); }
private async Task LoadAsync() { loaded = false; try { var selectedRegions = await Cascade.ResolveAsync(scope, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); var query = Db.CountingStations.Where(x => selectedRegions.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); if (!CursorToken.TryDecode<Cursor>(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.Code, token.Code) > 0 || (x.Code == token.Code && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.Code).ThenBy(x => x.Id).Take(PageSize + 1).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var map = await Db.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stations.Clear(); stations.AddRange(rows.Select(x => new StationRow(x.Id, x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].Code, rows[^1].Id)) : null; } catch (ArgumentException e) { notice = e.Message; stations.Clear(); nextCursor = null; } finally { loaded = true; } }
private async Task CreateAsync() { if (!draft.RegionId.HasValue || !scope.Contains(draft.RegionId.Value)) { notice = "请选择您有权管理的地区。"; return; } if (await Db.CountingStations.AnyAsync(x => x.Code == draft.Code)) { notice = "计票所代码已存在。"; return; } Db.CountingStations.Add(new CountingStation { Code = draft.Code.Trim(), Name = draft.Name.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); notice = "计票所已创建。"; creating = false; draft = new(); stationChoices.Clear(); await SearchAsync(); }
private async Task LoadAsync() { loaded = false; try { IQueryable<CountingStation> query = Db.CountingStations.AsNoTracking(); if (systemAdmin) { var selectedRegions = await Cascade.ResolveSystemAdministratorQueryAsync(new(provinceId, cityId, districtId, townshipId), CancellationToken.None); if (selectedRegions is not null) query = query.Where(x => selectedRegions.Contains(x.RegionId)); } else { var selectedRegions = await Cascade.ResolveAsync(scope, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); query = query.Where(x => selectedRegions.Contains(x.RegionId)); } if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); if (!CursorToken.TryDecode<Cursor>(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.Code, token.Code) > 0 || (x.Code == token.Code && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.Code).ThenBy(x => x.Id).Take(PageSize + 1).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var map = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stations.Clear(); stations.AddRange(rows.Select(x => new StationRow(x.Id, x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].Code, rows[^1].Id)) : null; } catch (ArgumentException e) { notice = e.Message; stations.Clear(); nextCursor = null; } finally { loaded = true; } }
private async Task CreateAsync() { if (!draft.RegionId.HasValue || (!systemAdmin && !scope.Contains(draft.RegionId.Value))) { notice = "请选择您有权管理的地区。"; return; } if (await Db.CountingStations.AnyAsync(x => x.Code == draft.Code)) { notice = "计票所代码已存在。"; return; } Db.CountingStations.Add(new CountingStation { Code = draft.Code.Trim(), Name = draft.Name.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); notice = "计票所已创建。"; creating = false; draft = new(); stationChoices.Clear(); await SearchAsync(); }
private async Task AssignStaffAsync() { if (!staffDraft.StationId.HasValue || !Guid.TryParse(staffDraft.UserId, out var userId)) { notice = "请选择计票所并输入有效用户 ID。"; return; } var station = await Db.CountingStations.FindAsync(staffDraft.StationId); if (station is null || !scope.Contains(station.RegionId)) { notice = "不能管理此计票所。"; return; } if (!await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.RegionId == station.RegionId && (x.Role == RegionalRole.CountingStationClerk || x.Role == RegionalRole.Supervisor))) { notice = "用户必须先取得本辖区的计票员或监督员角色。"; return; } if (await Db.UserCountingStationAssignments.AnyAsync(x => x.UserId == userId && x.CountingStationId == station.Id)) { notice = "该用户已分配到此计票所。"; return; } Db.UserCountingStationAssignments.Add(new UserCountingStationAssignment { UserId = userId, CountingStationId = station.Id }); await Db.SaveChangesAsync(); notice = "工作人员已分配。"; staffDraft = new(); }
private sealed class StationDraft { public string Code { get; set; } = ""; public string Name { get; set; } = ""; public Guid? RegionId { get; set; } } private sealed class StaffDraft { public Guid? StationId { get; set; } public string UserId { get; set; } = ""; } private sealed record Region(Guid Id, Guid? ParentId, AdministrativeRegionLevel Level, string Name, string Path); private sealed record StationRow(Guid Id, string Code, string Name, string Region, bool Active); private sealed record StationChoice(Guid Id, string Name); private sealed record Cursor(string Code, Guid Id); }
@@ -28,7 +28,7 @@
private int registeredVoterCount => activeVoterCount + aggregateVoterCount;
protected override async Task OnInitializedAsync()
{
try { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var scope = await Scope.GetManagedRegionIdsAsync(userId, CancellationToken.None); regionCount = scope.Count; activeVoterCount = await Db.Voters.CountAsync(x => scope.Contains(x.RegionId) && x.Status == VoterStatus.Active); aggregateVoterCount = await Db.VoterRegistrationAggregates.Where(x => scope.Contains(x.RegionId)).SumAsync(x => (int?)x.RegisteredCount) ?? 0; candidateCount = await Db.Candidates.CountAsync(x => scope.Contains(x.RegionId)); stationCount = await Db.CountingStations.CountAsync(x => scope.Contains(x.RegionId) && x.IsActive); loaded = true; }
try { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var systemAdmin = await Scope.IsSystemAdministratorAsync(userId, CancellationToken.None); if (systemAdmin) { regionCount = await Db.AdministrativeRegions.CountAsync(); activeVoterCount = await Db.Voters.CountAsync(x => x.Status == VoterStatus.Active); aggregateVoterCount = await Db.VoterRegistrationAggregates.SumAsync(x => (int?)x.RegisteredCount) ?? 0; candidateCount = await Db.Candidates.CountAsync(); stationCount = await Db.CountingStations.CountAsync(x => x.IsActive); } else { var scope = await Scope.GetManagedRegionIdsAsync(userId, CancellationToken.None); regionCount = scope.Count; activeVoterCount = await Db.Voters.CountAsync(x => scope.Contains(x.RegionId) && x.Status == VoterStatus.Active); aggregateVoterCount = await Db.VoterRegistrationAggregates.Where(x => scope.Contains(x.RegionId)).SumAsync(x => (int?)x.RegisteredCount) ?? 0; candidateCount = await Db.Candidates.CountAsync(x => scope.Contains(x.RegionId)); stationCount = await Db.CountingStations.CountAsync(x => scope.Contains(x.RegionId) && x.IsActive); } loaded = true; }
catch (Exception) { error = "无法读取管理范围。请确认此账号已完成地域角色分配。"; }
}
[CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
@@ -6,10 +6,29 @@
@inject RegionScopeService Scope
<PageTitle>地域角色</PageTitle>
<section class="admin-page"><div class="admin-page__heading"><div><p class="eyebrow">人员与场所 / 授权</p><h1>地域角色分配</h1><p>系统管理员维护授权;地域管理员只能管理自身辖区及全部下级辖区。</p></div></div>
@if (error is not null) { <div class="admin-alert">@error</div> } else if (!systemAdmin) { <div class="admin-empty">此页面仅向系统管理员开放。</div> } else { <EditForm Model="draft" OnValidSubmit="SaveAsync" class="admin-form"><h2>授予角色</h2><label>用户 ID<input @bind="draft.UserId" placeholder="用户 GUID" required /></label><label>角色<select @bind="draft.Role">@foreach (var role in Enum.GetValues<RegionalRole>()) { <option value="@role">@RoleText(role)</option> }</select></label><label>行政区划(系统管理员留空)<select @bind="draft.RegionId"><option value="">不绑定地区</option>@foreach (var region in regions) { <option value="@region.Id">@region.Name</option> }</select></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></tr></thead><tbody>@foreach (var row in items) { <tr><td class="admin-mono">@row.UserId</td><td>@RoleText(row.Role)</td><td>@row.Region</td></tr> }</tbody></table></div> }</section>
@code { [CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!; private bool systemAdmin; private string? error; private RoleDraft draft = new(); private readonly List<Region> regions = []; private readonly List<RoleRow> items = [];
protected override async Task OnInitializedAsync() { regions.Clear(); items.Clear(); var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null); if (!systemAdmin) return; var map = await Db.AdministrativeRegions.ToDictionaryAsync(x => x.Id, x => x.Name); regions.AddRange(map.Select(x => new Region(x.Key, x.Value))); items.AddRange((await Db.UserRegionalRoleAssignments.OrderBy(x => x.UserId).ToListAsync()).Select(x => new RoleRow(x.UserId, x.Role, x.RegionId.HasValue ? map.GetValueOrDefault(x.RegionId.Value, "—") : "全部地区"))); }
private async Task SaveAsync() { if (!Guid.TryParse(draft.UserId, out var userId)) { error = "用户 ID 必须为 GUID。"; return; } if (draft.Role == RegionalRole.SystemAdministrator) draft.RegionId = null; else if (!draft.RegionId.HasValue) { error = "非系统管理员必须选择行政区划。"; return; } if (!await Db.Users.AnyAsync(x => x.Id == userId && x.IsActive)) { error = "用户不存在或已停用。"; return; } if (draft.RegionId.HasValue) { var level = await Db.AdministrativeRegions.Where(x => x.Id == draft.RegionId).Select(x => x.Level).SingleAsync(); if (!Matches(draft.Role, level)) { error = "管理员角色必须与行政区划层级一致。"; return; } } if (await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.RegionId == draft.RegionId && x.Role == draft.Role)) { error = "该角色已存在。"; return; } Db.UserRegionalRoleAssignments.Add(new UserRegionalRoleAssignment { UserId = userId, RegionId = draft.RegionId, Role = draft.Role }); await Db.SaveChangesAsync(); error = "角色已授予。"; await OnInitializedAsync(); }
<section class="admin-page"><div class="admin-page__heading"><div><p class="eyebrow">人员与场所 / 授权</p><h1>地域角色分配</h1><p>角色记录按页加载;行政区划按省、市、区县、镇街、村社区逐级查询。</p></div></div>
@if (error is not null) { <div class="admin-alert">@error</div> } else if (!systemAdmin) { <div class="admin-empty">此页面仅向系统管理员开放。</div> } else {
<EditForm Model="draft" OnValidSubmit="SaveAsync" class="admin-form"><h2>授予角色</h2><label>用户名<input @bind="draft.UserName" placeholder="本地登录用户名" required maxlength="128" /></label><label>角色<select @bind="draft.Role">@foreach (var role in Enum.GetValues<RegionalRole>()) { <option value="@role">@RoleText(role)</option> }</select></label>
@if (draft.Role != RegionalRole.SystemAdministrator) { <fieldset><legend>行政区划</legend><label>省<select value="@provinceId" @onchange="SelectProvinceAsync"><option value="">请选择省份</option>@foreach (var x in provinces) { <option value="@x.Id">@x.Name</option> }</select></label><label>市<select value="@cityId" @onchange="SelectCityAsync"><option value="">请选择城市</option>@foreach (var x in cities) { <option value="@x.Id">@x.Name</option> }</select></label><label>区县<select value="@districtId" @onchange="SelectDistrictAsync"><option value="">请选择区县</option>@foreach (var x in districts) { <option value="@x.Id">@x.Name</option> }</select></label><label>镇街<select value="@townshipId" @onchange="SelectTownshipAsync"><option value="">请选择镇街</option>@foreach (var x in townships) { <option value="@x.Id">@x.Name</option> }</select></label><label>村社区<select value="@villageId" @onchange="SelectVillage"><option value="">请选择村社区</option>@foreach (var x in villages) { <option value="@x.Id">@x.Name</option> }</select></label></fieldset> }
<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></tr></thead><tbody>@if (!loaded) { <tr><td colspan="4">正在加载…</td></tr> } else if (items.Count == 0) { <tr><td colspan="4" class="admin-empty">暂无角色分配。</td></tr> } @foreach (var row in items) { <tr><td>@row.UserName <small>@row.DisplayName</small></td><td>@RoleText(row.Role)</td><td>@row.Region</td><td>@row.CreatedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</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> }
</section>
@code {
private const int PageSize = 50; [CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
private bool systemAdmin, loaded; private string? error, currentCursor, nextCursor; private RoleDraft draft = new(); private Guid? provinceId, cityId, districtId, townshipId, villageId;
private readonly List<Region> provinces = [], cities = [], districts = [], townships = [], villages = []; private readonly List<RoleRow> items = []; private readonly List<string?> cursorHistory = [];
protected override async Task OnInitializedAsync() { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Scope.IsSystemAdministratorAsync(userId, CancellationToken.None); if (!systemAdmin) return; await LoadOptionsAsync(provinces, null, AdministrativeRegionLevel.Province); await LoadAsync(); }
private async Task LoadOptionsAsync(List<Region> target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.ParentId == parentId && x.Level == level).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.Name)).ToListAsync()); }
private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = villageId = null; cities.Clear(); districts.Clear(); townships.Clear(); villages.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); }
private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = villageId = null; districts.Clear(); townships.Clear(); villages.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); }
private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = villageId = null; townships.Clear(); villages.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); }
private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); villageId = null; villages.Clear(); if (townshipId.HasValue) await LoadOptionsAsync(villages, townshipId, AdministrativeRegionLevel.Village); }
private void SelectVillage(ChangeEventArgs e) => villageId = Parse(e); private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
private async Task LoadAsync() { loaded = false; try { if (!CursorToken.TryDecode<Cursor>(currentCursor, out var cursor)) throw new InvalidOperationException("分页游标无效,请重新查询。"); IQueryable<UserRegionalRoleAssignment> query = Db.UserRegionalRoleAssignments.AsNoTracking(); if (cursor is not null) query = query.Where(x => x.CreatedAtUtc < cursor.CreatedAtUtc || (x.CreatedAtUtc == cursor.CreatedAtUtc && x.Id.CompareTo(cursor.Id) < 0)); var rows = await query.OrderByDescending(x => x.CreatedAtUtc).ThenByDescending(x => x.Id).Take(PageSize + 1).Select(x => new AssignmentRow(x.Id, x.UserId, x.RegionId, x.Role, x.CreatedAtUtc)).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var regionIds = rows.Where(x => x.RegionId.HasValue).Select(x => x.RegionId!.Value).Distinct().ToArray(); var userIds = rows.Select(x => x.UserId).Distinct().ToArray(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => regionIds.Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); var users = await Db.Users.AsNoTracking().Where(x => userIds.Contains(x.Id)).Select(x => new UserNameRow(x.Id, x.UserName!, x.DisplayName)).ToDictionaryAsync(x => x.Id); items.Clear(); items.AddRange(rows.Select(x => { var user = users.GetValueOrDefault(x.UserId); return new RoleRow(user?.UserName ?? "已删除用户", user?.DisplayName ?? "—", x.Role, x.RegionId.HasValue ? names.GetValueOrDefault(x.RegionId.Value, "—") : "全部地区", x.CreatedAtUtc); })); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].CreatedAtUtc, rows[^1].Id)) : null; } catch (Exception exception) { error = exception.Message; items.Clear(); nextCursor = null; } finally { loaded = true; } }
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 SaveAsync() { error = null; draft.RegionId = RegionForRole(draft.Role); var userName = draft.UserName.Trim(); if (string.IsNullOrWhiteSpace(userName)) { error = "请输入用户名。"; return; } var userId = await Db.Users.AsNoTracking().Where(x => x.UserName == userName && x.IsActive).Select(x => (Guid?)x.Id).SingleOrDefaultAsync(); if (!userId.HasValue) { error = "用户名不存在或已停用。"; return; } if (draft.Role != RegionalRole.SystemAdministrator && !draft.RegionId.HasValue) { error = "请按角色选择对应层级的行政区划。"; return; } if (draft.RegionId.HasValue) { var level = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.Id == draft.RegionId.Value && x.IsActive).Select(x => (AdministrativeRegionLevel?)x.Level).SingleOrDefaultAsync(); if (!level.HasValue || !Matches(draft.Role, level.Value)) { error = "管理员角色必须与行政区划层级一致;计票员和监督员可绑定任意已选层级。"; return; } } if (await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId.Value && x.RegionId == draft.RegionId && x.Role == draft.Role)) { error = "该角色已存在。"; return; } Db.UserRegionalRoleAssignments.Add(new UserRegionalRoleAssignment { UserId = userId.Value, RegionId = draft.RegionId, Role = draft.Role }); await Db.SaveChangesAsync(); error = "角色已授予。"; draft = new(); currentCursor = null; cursorHistory.Clear(); await LoadAsync(); }
private Guid? RegionForRole(RegionalRole role) => role switch { RegionalRole.SystemAdministrator => null, RegionalRole.ProvinceAdministrator => provinceId, RegionalRole.CityAdministrator => cityId, RegionalRole.DistrictAdministrator => districtId, RegionalRole.TownshipAdministrator => townshipId, RegionalRole.VillageAdministrator => villageId, _ => villageId ?? townshipId ?? districtId ?? cityId ?? provinceId };
private static bool Matches(RegionalRole role, AdministrativeRegionLevel level) => role switch { RegionalRole.ProvinceAdministrator => level == AdministrativeRegionLevel.Province, RegionalRole.CityAdministrator => level == AdministrativeRegionLevel.City, RegionalRole.DistrictAdministrator => level == AdministrativeRegionLevel.District, RegionalRole.TownshipAdministrator => level == AdministrativeRegionLevel.Township, RegionalRole.VillageAdministrator => level == AdministrativeRegionLevel.Village, _ => true };
private static string RoleText(RegionalRole role) => role switch { RegionalRole.SystemAdministrator => "系统管理员", RegionalRole.ProvinceAdministrator => "省管理员", RegionalRole.CityAdministrator => "市管理员", RegionalRole.DistrictAdministrator => "区县管理员", RegionalRole.TownshipAdministrator => "镇街管理员", RegionalRole.VillageAdministrator => "村社区管理员", RegionalRole.CountingStationClerk => "计票员", _ => "监督员" }; private sealed class RoleDraft { public string UserId { get; set; } = ""; public Guid? RegionId { get; set; } public RegionalRole Role { get; set; } = RegionalRole.CityAdministrator; } private sealed record Region(Guid Id, string Name); private sealed record RoleRow(Guid UserId, RegionalRole Role, string Region); }
private static string RoleText(RegionalRole role) => role switch { RegionalRole.SystemAdministrator => "系统管理员", RegionalRole.ProvinceAdministrator => "省管理员", RegionalRole.CityAdministrator => "市管理员", RegionalRole.DistrictAdministrator => "区县管理员", RegionalRole.TownshipAdministrator => "镇街管理员", RegionalRole.VillageAdministrator => "村社区管理员", RegionalRole.CountingStationClerk => "计票员", _ => "监督员" };
private sealed class RoleDraft { public string UserName { get; set; } = ""; public Guid? RegionId { get; set; } public RegionalRole Role { get; set; } = RegionalRole.CityAdministrator; } private sealed record Region(Guid Id, string Name); private sealed record AssignmentRow(Guid Id, Guid UserId, Guid? RegionId, RegionalRole Role, DateTime CreatedAtUtc); private sealed record UserNameRow(Guid Id, string UserName, string DisplayName); private sealed record RoleRow(string UserName, string DisplayName, RegionalRole Role, string Region, DateTime CreatedAtUtc); private sealed record Cursor(DateTime CreatedAtUtc, Guid Id);
}
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff. Show more