65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2020 年 9 月 24 日

CPOL

4分钟阅读

viewsIcon

19424

如何在 Blazor 数据库应用程序中构建 CRUD 列表表示/UI 层

引言

本文是关于构建 Blazor 数据库应用程序系列文章的第五篇。到目前为止的文章是

  1. 项目结构和框架。
  2. 服务 - 构建 CRUD 数据层。
  3. 视图组件 - UI 中的 CRUD 编辑和查看操作。
  4. UI 组件 - 构建 HTML/CSS 控件。
  5. 视图组件 - 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 交互:PaginatorControlSortControl

您可以在列表表单中使用 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 方法。

  1. 经典的网页方法,使用不同的 RouteViews (Pages) 来查看和编辑记录。
  2. 模态对话框方法 - 在列表 RouteView 中打开和关闭模态对话框。
  3. 内联对话框方法 - 在 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 标记。请注意

  1. 标题中的 SortControl 和构建带有可排序列的标题的 UIDataTableHeaderColumn 组件。
  2. 底部按钮行中的 PaginatorControl,链接到 Service.Paginator。分页是事件驱动的。PaginatorControl 的分页请求由控制器服务中的 Paginator 直接处理。更新会在服务中触发 ListChanged 事件,该事件会在列表表单中触发 UI 更新。
  3. 如果表单使用模态对话框,则会添加 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>

总结

本篇到此结束。需要注意的一些关键点:

  1. Blazor Server 和 Blazor WASM 代码库之间没有区别。
  2. 90% 以上的功能作为样板通用代码在库组件中实现。大部分应用程序代码是用于单个记录表单的 Razor 标记。
  3. 异步功能贯穿始终。

如果您在未来很久之后阅读本文,请查看存储库中的自述文件以获取本文集的最新版本。

历史

* 2020 年 9 月 25 日:初始版本。

* 2020年11月17日:Blazor.CEC 库重大更改。ViewManager 更改为 Router,以及新的 Component 基类实现。

* 2021 年 3 月 31 日:对服务、项目结构和数据编辑进行了重大更新。

© . All rights reserved.