在 Blazor 中构建数据库应用程序 - 第五部分 - View Components - UI 中的 CRUD 列表操作





5.00/5 (7投票s)
如何在 Blazor 数据库应用程序中构建 CRUD 列表表示/UI 层
引言
本文是关于构建 Blazor 数据库应用程序系列文章的第五篇。到目前为止的文章是
- 项目结构和框架。
- 服务 - 构建 CRUD 数据层。
- 视图组件 - UI 中的 CRUD 编辑和查看操作。
- UI 组件 - 构建 HTML/CSS 控件。
- 视图组件 - UI 中的 CRUD 列表操作。
本文详细介绍了构建可重用的列表 UI 组件并在 Server 和 WASM 项目中部署它们。
存储库和数据库
文章的存储库已移至 Blazor.Database 存储库。旧的存储库现已废弃,并将很快被移除。
存储库中的 /SQL 目录下有一个用于构建数据库的 SQL 脚本。
您可以在同一个网站上看到该项目的 Server 和 WASM 版本正在运行。.
列表功能
列表组件比其他 CRUD 组件更具挑战性。生产级列表控件期望的功能包括:
* 分页 - 处理大型数据集
* 列格式化 - 控制列宽和数据溢出
* 排序 - 按列
* 筛选 - 此处未涵盖。
基础表单
ListFormBase
是所有列表的基础抽象表单。它继承自 ComponentBase
,并包含所有样板代码。TRecord
是它操作的数据类。表单使用
代码如下所示
public abstract class ListFormBase<TRecord> : ComponentBase, IDisposable where TRecord : class, IDbRecord<TRecord>, new()
{
/// Callbacks for Edit/View/New/Exit Actions
[Parameter] public EventCallback<int> EditRecord { get; set; }
[Parameter] public EventCallback<int> ViewRecord { get; set; }
[Parameter] public EventCallback<int> NewRecord { get; set; }
[Parameter] public EventCallback ExitAction { get; set; }
/// Controller Data Service
[Inject] protected IFactoryControllerService<TRecord> Service { get; set; }
[Inject] protected NavigationManager NavManager { get; set; }
/// Booleans for Service and Recordlist state
protected bool IsLoaded => this.Service?.HasRecords ?? false;
protected bool HasService => this.Service != null;
protected override async Task OnInitializedAsync()
{
if (this.HasService)
{
await this.Service.GetRecordsAsync();
this.Service.ListHasChanged += OnListChanged;
}
}
/// Call StatehasChanged if list changed
protected void OnListChanged(object sender, EventArgs e)
=> this.InvokeAsync(this.StateHasChanged);
/// Event handlers to call EventCallbacks
protected virtual void Edit(int id)
=> this.EditRecord.InvokeAsync(id);
protected virtual void View(int id)
=> this.ViewRecord.InvokeAsync(id);
protected virtual void New()
=> this.NewRecord.InvokeAsync();
protected virtual void Exit()
{
if (ExitAction.HasDelegate)
ExitAction.InvokeAsync();
else
this.NavManager.NavigateTo("/");
}
/// IDisosable Interface implementation
public void Dispose()
=> this.Service.ListHasChanged -= OnListChanged;
}
分页和排序
分页和排序由 ControllerService
中的 Paginator
类实现。有一些 UI 组件与 Paginator
交互:PaginatorControl
和 SortControl
。
您可以在列表表单中使用 PaginatorControl
- 在此位于表单底部按钮行的左侧
<UIContainer>
<UIFormRow>
<UIColumn Cols="8">
<PaginatorControl Paginator="this.Service.Paginator"></PaginatorControl>
</UIColumn>
<UIButtonColumn Cols="4">
<UIButton Show="true" AdditionalClasses="btn-success" ClickEvent="() => this.New()">New Record</UIButton>
<UIButton AdditionalClasses="btn-secondary" ClickEvent="this.Exit">Exit</UIButton>
</UIButtonColumn>
</UIFormRow>
</UIContainer>
以及在列表表单的标题行中使用的 SortControl
。
<head>
<SortControl Paginator="this.Service.Paginator">
<UIDataTableHeaderColumn SortField="ID">ID</UIDataTableHeaderColumn>
<UIDataTableHeaderColumn SortField="Date">Date</UIDataTableHeaderColumn>
...
</SortControl>
</head>
分页器
Controller Service 包含列表表单使用的 Paginator
实例。代码不言自明,提供了分页操作的功能。它通过 PaginatorData
类传递给 Data Service 以检索正确的排序页面。
public class Paginator
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 25;
public int BlockSize { get; set; } = 10;
public int RecordCount { get; set; } = 0;
public event EventHandler PageChanged;
public string SortColumn
{
get => (!string.IsNullOrWhiteSpace(_sortColumn)) ? _sortColumn : DefaultSortColumn;
set => _sortColumn = value;
}
private string _sortColumn = string.Empty;
public string DefaultSortColumn { get; set; } = "ID";
public bool SortDescending { get; set; }
public int LastPage => (int)((RecordCount / PageSize) + 0.5);
public int LastBlock => (int)((LastPage / BlockSize) + 1.5);
public int CurrentBlock => (int)((Page / BlockSize) + 1.5);
public int StartBlockPage => ((CurrentBlock - 1) * BlockSize) + 1;
public int EndBlockPage => StartBlockPage + BlockSize;
public bool HasBlocks => ((RecordCount / (PageSize * BlockSize)) + 0.5) > 1;
public bool HasPagination => (RecordCount / PageSize) > 1;
public Paginator(int pageSize, int blockSize)
{
this.BlockSize = blockSize;
this.PageSize = pageSize;
}
public void ToPage(int page, bool forceUpdate = false)
{
if ((forceUpdate | !this.Page.Equals(page)) && page > 0)
{
this.Page = page;
this.PageChanged?.Invoke(this, EventArgs.Empty);
}
}
public void NextPage()
=> this.ToPage(this.Page + 1);
public void PreviousPage()
=> this.ToPage(this.Page - 1);
public void ToStart()
=> this.ToPage(1);
public void ToEnd()
=> this.ToPage((int)((RecordCount / PageSize) + 0.5));
public void NextBlock()
{
if (CurrentBlock != LastBlock)
{
var calcpage = (CurrentBlock * PageSize * BlockSize) + 1;
this.Page = calcpage > LastPage ? LastPage : LastPage;
this.PageChanged?.Invoke(this, EventArgs.Empty);
}
}
public void PreviousBlock()
{
if (CurrentBlock != 1)
{
this.Page = ((CurrentBlock - 1) * PageSize * BlockSize) - 1;
this.PageChanged?.Invoke(this, EventArgs.Empty);
}
}
public void NotifySortingChanged()
=> this.ToPage(1, true);
public PaginatorData GetData => new PaginatorData()
{
Page = this.Page,
PageSize = this.PageSize,
BlockSize = this.BlockSize,
RecordCount = this.RecordCount,
SortColumn = this.SortColumn,
SortDescending = this.SortDescending
};
}
PaginatorData
这是用于将数据传递到数据服务中的类。必须通过 json 传递给 api,因此“保持简单”。
public class PaginatorData
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 25;
public int BlockSize { get; set; } = 10;
public int RecordCount { get; set; } = 0;
public string SortColumn { get; set; } = string.Empty;
public bool SortDescending { get; set; } = false;
}
PaginatorControl
代码同样不言自明,构建了一个 Bootstrap ButtonGroup。我没有使用图标,您可以根据需要使用。
@namespace Blazor.SPA.Components
@if (this.hasPaginator)
{
<nav aria-label="...">
<ul class="pagination">
<li class="page-item">
<a class="page-link" @onclick="() => this.Paginator.ToStart()">|<</a>
</li>
@if (this.Paginator.HasBlocks)
{
<li class="page-item">
<a class="page-link" @onclick="() => this.Paginator.PreviousBlock()"><<</a>
</li>
}
@for (var i = this.Paginator.StartBlockPage; i < this.Paginator.EndBlockPage; i++)
{
var pageNo = i;
@if (pageNo > this.Paginator.LastPage) break;
@if (pageNo == this.Paginator.Page)
{
<li class="page-item active">
<span class="page-link">
@pageNo
<span class="sr-only">(current)</span>
</span>
</li>
}
else
{
<li class="page-item">
<a class="page-link" @onclick="() => this.Paginator.ToPage(pageNo)">@pageNo</a>
</li>
}
}
@if (this.Paginator.HasBlocks)
{
<li class="page-item">
<a class="page-link" @onclick="() => this.Paginator.NextBlock()">>></a>
</li>
}
<li class="page-item">
<a class="page-link" @onclick="() => this.Paginator.ToEnd()">>|</a>
</li>
</ul>
</nav>
}
@code {
[Parameter] public Paginator Paginator { get; set; }
private bool hasPaginator => this.Paginator != null && this.Paginator.HasPagination;
}
SortControl
SortControl
用于列表标题。它级联自身,并通过一组公共帮助方法提供标题列通过 Paginator 的接口。
@namespace Blazor.SPA.Components
<CascadingValue Value="this">
@ChildContent
</CascadingValue>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string NotSortedClass { get; set; } = "sort-column oi oi-resize-height";
[Parameter] public string AscendingClass { get; set; } = "sort-column oi oi-sort-ascending";
[Parameter] public string DescendingClass { get; set; } = "sort-column oi oi-sort-descending";
[Parameter] public EventCallback<SortingEventArgs> Sort { get; set; }
[Parameter] public Paginator Paginator { get; set; }
public string SortColumm { get; private set; } = string.Empty;
public bool Descending { get; private set; } = false;
public string GetIcon(string columnName)
=> !this.SortColumm.Equals(columnName)
? this.NotSortedClass
: this.Descending
? this.AscendingClass
: this.DescendingClass;
public void NotifySortingChanged(string sortColumn, bool descending = false)
{
this.SortColumm = sortColumn;
this.Descending = descending;
this.Notify();
}
public void NotifySortingDirectionChanged()
{
this.Descending = !this.Descending;
this.Notify();
}
private void Notify()
{
if (Paginator != null)
{
Paginator.SortDescending = this.Descending;
Paginator.SortColumn = this.SortColumm;
Paginator.NotifySortingChanged();
}
var args = SortingEventArgs.Get(this.SortColumm, this.Descending);
if (Sort.HasDelegate) this.Sort.InvokeAsync(args);
}
}
UIDataTableHeaderColumn
这是构建列表中每个标题列的 UI 控件。它构建了标题的 razor 和 Css 类,并在任何鼠标单击事件时通知捕获的 SortControl。
@namespace Blazor.SPA.Components
@if (_isSortField)
{
<th class="@this.CssClass" @attributes="UserAttributes" @onclick="SortClick">
<span class="@_iconclass"></span>
@this.ChildContent
</th>
}
else
{
<th class="@this.CssClass" @attributes="UserAttributes">
@this.ChildContent
</th>
}
@code {
[CascadingParameter] public SortControl SortControl { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string SortField { get; set; } = string.Empty;
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object> UserAttributes { get; set; } = new Dictionary<string, object>();
private bool _hasSortControl => this.SortControl != null;
private bool _isSortField => !string.IsNullOrWhiteSpace(this.SortField);
private string _iconclass => _hasSortControl && _isSortField ? this.SortControl.GetIcon(SortField) : string.Empty;
private string CssClass => CSSBuilder.Class("grid-col")
.AddClass("cursor-hand", _isSortField)
.AddClassFromAttributes(this.UserAttributes)
.Build();
private void SortClick(MouseEventArgs e)
{
if (this.SortControl.SortColumm.Equals(this.SortField))
this.SortControl.NotifySortingDirectionChanged();
else
this.SortControl.NotifySortingChanged(this.SortField);
}
}
天气预报列表表单
解决方案中有三个列表表单。它们展示了不同的 UI 方法。
- 经典的网页方法,使用不同的 RouteViews (Pages) 来查看和编辑记录。
- 模态对话框方法 - 在列表 RouteView 中打开和关闭模态对话框。
- 内联对话框方法 - 在 RouteView 中打开和关闭一个部分来显示/编辑记录。
标准的 WeatherForecastListForm
看起来像这样。它继承自 ListFormBase
,并将 WeatherForecast
作为 TRecord
。它将 WeatherForecastControllerService
分配给基础 IFactoryControllerService
属性 Service
。请注意,它有一个组件 Css 文件,定义了组件中使用的自定义 Css。
// Blazor.Database/Components/Forms/WeatherForecast/WeatherForecastListForm.razor.cs
public partial class WeatherForecastListForm : ListFormBase<WeatherForecast>
{
[Inject] private WeatherForecastControllerService ControllerService { get; set; }
[Parameter] public bool IsModal {get; set;}
private BaseModalDialog Modal { get; set; }
protected override async Task OnInitializedAsync()
{
this.Service = this.ControllerService;
await base.OnInitializedAsync();
}
protected override async void Edit(int id)
{
if (this.IsModal)
{
var options = new ModalOptions();
options.Set("Id", id);
await this.Modal.ShowAsync<WeatherForecastEditorForm>(options);
}
else
base.Edit(id);
}
protected override async void View(int id)
{
if (this.IsModal)
{
var options = new ModalOptions();
options.Set("Id", id);
await this.Modal.ShowAsync<WeatherForecastViewerForm>(options);
}
else
base.View(id);
}
protected override async void New()
{
if (this.IsModal)
{
var options = new ModalOptions();
options.Set("Id", -1);
await this.Modal.ShowAsync<WeatherForecastEditorForm>(options);
}
else
base.New();
}
}
Razor 标记。请注意
- 标题中的
SortControl
和构建带有可排序列的标题的UIDataTableHeaderColumn
组件。 - 底部按钮行中的
PaginatorControl
,链接到Service.Paginator
。分页是事件驱动的。PaginatorControl
的分页请求由控制器服务中的Paginator
直接处理。更新会在服务中触发ListChanged
事件,该事件会在列表表单中触发 UI 更新。 - 如果表单使用模态对话框,则会添加
BaseModalDialog
。
@namespace Blazor.Database.Components
@inherits ListFormBase<WeatherForecast>
<h1>Weather Forecasts</h1>
<UILoader Loaded="this.IsLoaded">
<UIDataTable TRecord="WeatherForecast" Records="this.ControllerService.Records" class="table">
<Head>
<SortControl Paginator="this.Service.Paginator">
<UIDataTableHeaderColumn SortField="ID">ID</UIDataTableHeaderColumn>
<UIDataTableHeaderColumn SortField="Date">Date</UIDataTableHeaderColumn>
<UIDataTableHeaderColumn SortField="TemperatureC">Temp. (C)</UIDataTableHeaderColumn>
<UIDataTableHeaderColumn>Temp. (F)</UIDataTableHeaderColumn>
<UIDataTableHeaderColumn SortField="Summary">Summary</UIDataTableHeaderColumn>
<UIDataTableHeaderColumn class="max-column">Description</UIDataTableHeaderColumn>
<UIDataTableHeaderColumn class="text-right">Actions</UIDataTableHeaderColumn>
</SortControl>
</Head>
<RowTemplate>
<UIDataTableRow>
<UIDataTableColumn>@context.ID</UIDataTableColumn>
<UIDataTableColumn> @context.Date.ToShortDateString()</UIDataTableColumn>
<UIDataTableColumn>@context.TemperatureC</UIDataTableColumn>
<UIDataTableColumn>@context.TemperatureF</UIDataTableColumn>
<UIDataTableColumn>@context.Summary</UIDataTableColumn>
<UIDataTableMaxColumn>@context.Description</UIDataTableMaxColumn>
<UIDataTableColumn class="text-right text-nowrap">
<UIButton AdditionalClasses="btn-sm btn-secondary" ClickEvent="() => this.View(context.ID)">View</UIButton>
<UIButton AdditionalClasses="btn-sm btn-primary" ClickEvent="() => this.Edit(context.ID)">Edit</UIButton>
</UIDataTableColumn>
</UIDataTableRow>
</RowTemplate>
</UIDataTable>
<UIContainer>
<UIFormRow>
<UIColumn Cols="8">
<PaginatorControl Paginator="this.ControllerService.Paginator"></PaginatorControl>
</UIColumn>
<UIButtonColumn Cols="4">
<UIButton Show="true" AdditionalClasses="btn-success" ClickEvent="() => this.New()">New Record</UIButton>
<UIButton AdditionalClasses="btn-secondary" ClickEvent="this.Exit">Exit</UIButton>
</UIButtonColumn>
</UIFormRow>
</UIContainer>
</UILoader>
@if (this.IsModal)
{
<BaseModalDialog @ref="this.Modal"></BaseModalDialog>
}
视图
该应用程序为列表表单声明了一组中间视图。这些是 WASM 和 Server SPA 之间的通用视图。
WeatherForecastComponent
这是多 RouteView 实现。事件处理程序已连接到 WeatherForecastListForm
,通过 NavigationManager
路由到不同的 RouteViews。
@namespace Blazor.Database.Components
<WeatherForecastListForm EditRecord="this.GoToEditor" ViewRecord="this.GoToViewer" NewRecord="this.GoToNew"></WeatherForecastListForm>
@code {
[Inject] NavigationManager NavManager { get; set; }
protected override Task OnInitializedAsync()
{
return base.OnInitializedAsync();
}
public void GoToEditor(int id)
=> this.NavManager.NavigateTo($"/weather/edit/{id}");
public void GoToNew()
=> this.NavManager.NavigateTo($"/weather/edit/-1");
public void GoToViewer(int id)
=> this.NavManager.NavigateTo($"/weather/view/{id}");
}
模态实现很简单。它通过启用 IsModal
来处理编辑器/查看器状态。您实际上不需要它,因为您可以在 RouteView 中直接声明 WeatherForecastListForm
。
@namespace Blazor.Database.Components
<WeatherForecastListForm IsModal="true"></WeatherForecastListForm>
内联对话框是最复杂的。它使用 Id 来显示/隐藏通过 UIBase
的编辑器/查看器。
@namespace Blazor.Database.Components
<UIBase Show="this.ShowEditor">
<WeatherForecastEditorForm ID="this.editorId" ExitAction="this.CloseDialog"></WeatherForecastEditorForm>
</UIBase>
<UIBase Show="this.ShowViewer">
<WeatherForecastViewerForm ID="this.editorId" ExitAction="this.CloseDialog"></WeatherForecastViewerForm>
</UIBase>
<WeatherForecastListForm EditRecord="this.GoToEditor" ViewRecord="this.GoToViewer" NewRecord="this.GoToNew" ExitAction="Exit"></WeatherForecastListForm>
@code {
[Inject] NavigationManager NavManager { get; set; }
private int editorId = 0;
private int viewerId = 0;
private bool ShowViewer => this.viewerId != 0;
private bool ShowEditor => this.editorId != 0;
public void GoToEditor(int id)
=> SetIds(id, 0);
public void GoToNew()
=> SetIds(-1, 0);
public void GoToViewer(int id)
=> SetIds(0, id);
public void CloseDialog()
=> SetIds(0, 0);
public void Exit()
=> this.NavManager.NavigateTo("/");
private void SetIds(int editorId, int viewerId)
{
this.editorId = editorId;
this.viewerId = viewerId;
}
}
RouteViews (又名 Pages)
这些只是声明路由和顶层表单组件。
- Blazor.Database.WASM/RouteViews/Weather/xxx.razor
- Blazor.Database.Server/RouteViews/Weather/xxx.razor
@page "/fetchdata"
<WeatherForecastComponent></WeatherForecastComponent>
@page "/fetchdataInline"
<WeatherForecastInlineComponent></WeatherForecastInlineComponent>
@page "/fetchdataModal"
<WeatherForecastListModal></WeatherForecastListModal>
总结
本篇到此结束。需要注意的一些关键点:
- Blazor Server 和 Blazor WASM 代码库之间没有区别。
- 90% 以上的功能作为样板通用代码在库组件中实现。大部分应用程序代码是用于单个记录表单的 Razor 标记。
- 异步功能贯穿始终。
如果您在未来很久之后阅读本文,请查看存储库中的自述文件以获取本文集的最新版本。
历史
* 2020 年 9 月 25 日:初始版本。
* 2020年11月17日:Blazor.CEC 库重大更改。ViewManager 更改为 Router,以及新的 Component 基类实现。
* 2021 年 3 月 31 日:对服务、项目结构和数据编辑进行了重大更新。