在 Blazor 中构建数据库应用程序 - 第三部分 - UI 中的 CRUD 编辑和查看操作






4.67/5 (6投票s)
如何在 Blazor 数据库应用程序中构建 CRUD 查看器和编辑器演示/UI 层
引言
这是系列文章中的第三篇,探讨如何在 Blazor 中构建和构建数据库应用程序。 迄今为止的文章是
- 项目结构和框架。
- 服务 - 构建 CRUD 数据层。
- 视图组件 - UI 中的 CRUD 编辑和查看操作。
- UI 组件 - 构建 HTML/CSS 控件。
- 视图组件 - UI 中的 CRUD 列表操作。
本文详细介绍了构建可重用的 CRUD 演示层组件,特别是编辑和查看功能。 自首次发布以来,发生了重大变化。
我发现有趣的是,大多数程序员都试图通过构建控件生成器而不是样板化其他所有内容来自动化编辑和查看表单。 大多数表单对其记录集来说是唯一的。 某些字段可以分组并放在同一行上。 文本字段的长度根据所需的字符数而变化。 构建一个工厂来处理这个问题,再加上控件、数据类实例和验证之间的链接所带来的额外复杂性,似乎不值得。 配置数据集比它试图模仿的表单更复杂。 由于这些原因,这里没有表单生成器,只有一组库 UI 组件类来标准化表单构建。
示例项目、代码和链接
文章的存储库已移至 CEC.Database 存储库。 您可以将其用作开发自己应用程序的模板。 以前的存储库已过时,将被删除。
存储库中的 /SQL 中有一个用于构建数据库的 SQL 脚本。 应用程序可以使用真实的 SQL 数据库或内存中的 SQLite 数据库。
您可以在同一个网站上看到该项目的 Server 和 WASM 版本正在运行。.
表单中使用了几个自定义控件。 这些控件的详细信息在单独的文章中介绍
基本表单
所有 UI 组件都继承自 ComponentBase。   所有源文件都可以在 Github 网站上查看,我还在文章中的适当位置包含了对特定代码文件的引用或链接。   在大多数地方,您需要通读代码以获取功能的详细注释。
RecordFormBase
RecordFormBase 是记录表单使用的抽象基类。   它继承自 ComponentBase。   记录表单可以在多种上下文中创建
- 作为 RouteView 中的根组件,其中 RouteView 通过参数将 Id传递给表单。
- 在列表或其他组件中的模式对话框中。   ID 通过 DialogOptions类传递给表单。
- 作为另一个组件(如列表)中的内联编辑器,其中组件通过参数将 Id传递给表单。
RecordFormBase 旨在检测其上下文,特别是通过检查级联的 IModalDialog 对象来检测模式对话框上下文。   有两组依赖项
- 记录的 Id。 如果表单托管在 RouteView 或其他组件中,则将其作为Parameter传递,或者在IModalDialog的公共ModalOptions属性中传递。 请注意,Id 为 -1 表示新记录,而 0 表示默认记录(0 是int的默认值)。
- 退出机制。 这可以是
- 如果它在模式上下文中,则通过调用 Modal上的 close。
- 如果已注册,则通过调用 ExitAction委托。
- 默认 - 退出到根目录。
// Blazor.SPA/Components/Base/RecordFormBase.cs
    public class RecordFormBase<TRecord> : ComponentBase  where TRecord : class, IDbRecord<TRecord>, new()
    {
        [CascadingParameter] public IModalDialog Modal { get; set; }
        [Parameter] public int ID {get; set;}
        [Parameter] public EventCallback ExitAction { get; set; }
        [Inject] protected NavigationManager NavManager { get; set; }
        protected IFactoryControllerService<TRecord> Service { get; set; }
        protected virtual bool IsLoaded => this.Service != null && this.Service.Record != null;
        protected virtual bool HasServices => this.Service != null;
        protected bool _isModal => this.Modal != null;
        protected int _modalId = 0;
        protected int _Id => _modalId != 0 ? _modalId : this.ID;
        protected async override Task OnInitializedAsync()
        {
            await LoadRecordAsync();
            await base.OnInitializedAsync();
        }
        protected virtual async Task LoadRecordAsync()
        {
            this.TryGetModalID();
            await this.Service.GetRecordAsync(this._Id);
        }
        protected virtual bool TryGetModalID()
        {
            if (this._isModal && this.Modal.Options.TryGet<int>("Id", out int value))
            {
                this._modalId = value;
                return true;
            }
            return false;
        }
        protected virtual void Exit()
        {
            if (this._isModal)
                this.Modal.Close(ModalResult.OK());
            else if (ExitAction.HasDelegate)
                ExitAction.InvokeAsync();
            else
                this.NavManager.NavigateTo("/");
        }
    }
EditRecordFormBase
EditRecordFormBase 是编辑器表单的基类。  它继承自 RecordFormBase 并实现编辑功能。
它
- 管理 EditContext。
- 具有一组布尔属性来跟踪状态并管理按钮显示/禁用状态。
- 保存记录。
当表单脏时,Dirty 属性与模式对话框交互以锁定它(不允许退出并关闭导航)。
// Blazor.SPA/Components/Base/EditRecordFormBase.cs
public abstract class EditRecordFormBase<TRecord> : RecordFormBase<TRecord>, IDisposable where TRecord : class, IDbRecord<TRecord>, new()
{
    /// Edit Context for the Editor - built from the service record
    protected EditContext EditContext { get; set; }
    /// Property tracking the Edit state of the form
    protected bool IsDirty
    {
        get => this._isDirty;
        set
        {
            if (value != this.IsDirty)
            {
                this._isDirty = value;
                if (this._isModal) this.Modal.Lock(value);
            }
        }
    }
    /// model used by the Edit Context
    protected TRecord Model => this.Service?.Record ?? null;
    /// Reference to the form EditContextState control
    protected EditFormState EditFormState { get; set; }
下一组属性是代码和 Razor 按钮用于控制显示/禁用状态的状态属性。
    protected bool _isNew => this.Service?.IsNewRecord ?? true;
    protected bool _isDirty = false;
    protected bool _isValid = true;
    protected bool _saveDisabled => !this.IsDirty || !this._isValid;
    protected bool _deleteDisabled => this._isNew || this._confirmDelete;
    protected bool _isLoaded = false;
    protected bool _dirtyExit = false;
    protected bool _confirmDelete = false;
    protected bool _isInlineDirty => (!this._isModal) && this._isDirty;
    protected string _saveButtonText => this._isNew ? "Save" : "Update";
LoadRecordAsync 调用基类以获取记录,创建 EditContext 并注册到 EditContext.OnFieldChanged。   其他方法处理状态更改。
    protected async override Task OnInitializedAsync()
        => await LoadRecordAsync();
    /// Method to load the record
    /// calls the base method to load the record and then sets up the EditContext
    protected override async Task LoadRecordAsync()
    {
        await base.OnInitializedAsync();
        this.EditContext = new EditContext(this.Model);
        _isLoaded = true;
        this.EditContext.OnFieldChanged += FieldChanged;
        if (!this._isNew)
            this.EditContext.Validate();
    }
    /// Event handler for EditContext OnFieldChanged Event
    protected void FieldChanged(object sender, FieldChangedEventArgs e)
    {
        this._dirtyExit = false;
        this._confirmDelete = false;
    }
    /// Method to change edit state
    protected void EditStateChanged(bool dirty)
        => this.IsDirty = dirty;
    /// Method to change the Validation state
    protected void ValidStateChanged(bool valid)
        => this._isValid = valid;
    /// IDisposable Interface Implementation
    public void Dispose()
        => this.EditContext.OnFieldChanged -= FieldChanged;
最后是按钮事件处理程序,用于控制保存和退出脏表单。
    /// Method to handle EditForm submission
    protected async void HandleValidSubmit()
    {
        await this.Service.SaveRecordAsync();
        this.EditFormState.UpdateState();
        this._dirtyExit = false;
        await this.InvokeAsync(this.StateHasChanged);
    }
    /// Handler for Delete action
    protected void Delete()
    {
        if (!this._isNew)
            this._confirmDelete = true;
    }
    /// Handler for Delete confirmation
    protected async void ConfirmDelete()
    {
        if (this._confirmDelete)
        {
            await this.Service.DeleteRecordAsync();
            this.IsDirty = false;
            this.DoExit();
        }
    }
    /// Handler for a confirmed exit - i.e.  dirty exit
    protected void ConfirmExit()
    {
        this.IsDirty = false;
        this.DoExit();
    }
    /// Handler to Exit the form, dependant on it context
    protected void DoExit(ModalResult result = null)
    {
        result = result ?? ModalResult.OK();
        if (this._isModal)
            this.Modal.Close(result);
        if (ExitAction.HasDelegate)
            ExitAction.InvokeAsync();
        else
            this.NavManager.NavigateTo("/");
    }
}
实现表单
WeatherForecastViewerForm
WeatherForecastViewerForm 的代码非常简单。
- 继承自 RecordFormBase并将TRecord设置为WeatherForecast。
- 获取 WeatherForecastControllerService并将其分配给基类的Service属性。
public partial class WeatherForecastViewerForm : RecordFormBase<WeatherForecast>
{
    [Inject] private WeatherForecastControllerService ControllerService { get; set; }
    protected async override Task OnInitializedAsync()
    {
        this.Service = this.ControllerService;
        await base.OnInitializedAsync();
    }
}
大部分工作在 Razor 代码中完成。
- 没有 Html 代码,它都是组件。 我们将在下一篇文章中详细介绍 UI 组件。
- 布局基于 Bootstrap 网格。
- 列大小决定了控件大小。
- UILoader仅当我们有记录要显示时才加载其内容。
@namespace Blazor.Database.Components
@inherits RecordFormBase<WeatherForecast>
<UIContainer>
    <UIFormRow>
        <UIColumn>
            <h2>Weather Forecast Viewer</h2>
        </UIColumn>
    </UIFormRow>
</UIContainer>
<UILoader Loaded="this.IsLoaded">
    <UIContainer>
        <UIFormRow>
            <UILabelColumn>
                Date
            </UILabelColumn>
            <UIInputColumn Cols="3">
                <InputReadOnlyText Value="@this.ControllerService.Record.Date.ToShortDateString()"></InputReadOnlyText>
            </UIInputColumn>
            <UIColumn Cols="7"></UIColumn>
        </UIFormRow>
        <UIFormRow>
            <UILabelColumn>
                Temperature °C
            </UILabelColumn>
            <UIInputColumn Cols="2">
                <InputReadOnlyText Value="@this.ControllerService.Record.TemperatureC.ToString()"></InputReadOnlyText>
            </UIInputColumn>
            <UIColumn Cols="8"></UIColumn>
        </UIFormRow>
        <UIFormRow>
            <UILabelColumn>
                Temperature °f
            </UILabelColumn>
            <UIInputColumn Cols="2">
                <InputReadOnlyText Value="@this.ControllerService.Record.TemperatureF.ToString()"></InputReadOnlyText>
            </UIInputColumn>
            <UIColumn Cols="8"></UIColumn>
        </UIFormRow>
        <UIFormRow>
            <UILabelColumn>
                Summary
            </UILabelColumn>
            <UIInputColumn Cols="9">
                <InputReadOnlyText Value="@this.ControllerService.Record.Summary"></InputReadOnlyText>
            </UIInputColumn>
        </UIFormRow>
    </UIContainer>
</UILoader>
<UIContainer>
    <UIFormRow>
        <UIButtonColumn>
            <UIButton AdditionalClasses="btn-secondary" ClickEvent="this.Exit">Exit</UIButton>
        </UIButtonColumn>
    </UIFormRow>
</UIContainer>
WeatherForecastEditorForm
WeatherForecastEditorForm 类似于 WeatherForecastViewerForm。
代码再次非常简单。
- 继承自 EditRecordFormBase并将TRecord设置为WeatherForecast。
- 获取 WeatherForecastControllerService并将其分配给基类的Service属性。
public partial class WeatherForecastViewerForm : RecordFormBase<WeatherForecast>
{
    [Inject] private WeatherForecastControllerService ControllerService { get; set; }
    protected async override Task OnInitializedAsync()
    {
        this.Service = this.ControllerService;
        await base.OnInitializedAsync();
    }
}
Razor 文件如下所示。 它基于标准的 Blazor EditForm,带有一些附加控件。 对查看器所做的相同注释也适用于此处。 此外
- InlineDialog是一个表单锁定控件。 它由- _isInlineDirty属性启用。 转到演示站点并编辑记录以查看其运行情况。 仅当表单不在模式上下文中时才启用它。
- EditFormState是一个跟踪表单状态的控件,即记录与表单加载时的原始记录的状态。 它与- InlineDialog链接以控制表单锁定。
- ValidationFormState是一个自定义验证控件。
- 按钮与布尔控制属性绑定以管理其状态。
自定义控件在链接部分引用的单独文章中介绍。 我没有进一步抽象它,因此您可以看到一个完整的表单在运行。 您可以将所有 Title、Edit Form 和按钮部分移动到 FormWrapper 组件中。
@namespace Blazor.Database.Components
@inherits EditRecordFormBase<WeatherForecast>
<InlineDialog Lock="this._isInlineDirty" Transparent="false">
    <UIContainer>
        <UIFormRow>
            <UIColumn>
                <h2>Weather Forecast Editor</h2>
            </UIColumn>
        </UIFormRow>
    </UIContainer>
    <UILoader Loaded="this._isLoaded">
        <EditForm EditContext="this.EditContext" OnValidSubmit="HandleValidSubmit" class=" px-2 py-3">
            <EditFormState @ref="this.EditFormState"  EditStateChanged="this.EditStateChanged"></EditFormState>
            <ValidationFormState ValidStateChanged="this.ValidStateChanged"></ValidationFormState>
            <UIContainer>
                <UIFormRow>
                    <UILabelColumn>
                        Record ID
                    </UILabelColumn>
                    <UIInputColumn Cols="3">
                        <InputReadOnlyText Value="@this.Model.ID.ToString()" />
                    </UIInputColumn>
                    <UIColumn Cols="3"></UIColumn>
                    <UIValidationColumn>
                        <ValidationMessage For=@(() => this.Model.Date) />
                    </UIValidationColumn>
                </UIFormRow>
                <UIFormRow>
                    <UILabelColumn>
                        Date
                    </UILabelColumn>
                    <UIInputColumn Cols="3">
                        <InputDate class="form-control" @bind-Value="this.Model.Date"></InputDate>
                    </UIInputColumn>
                    <UIColumn Cols="3"></UIColumn>
                    <UIValidationColumn>
                        <ValidationMessage For=@(() => this.Model.Date) />
                    </UIValidationColumn>
                </UIFormRow>
                <UIFormRow>
                    <UILabelColumn>
                        Temperature °C
                    </UILabelColumn>
                    <UIInputColumn Cols="2">
                        <InputNumber class="form-control" @bind-Value="this.Model.TemperatureC"></InputNumber>
                    </UIInputColumn>
                    <UIColumn Cols="4"></UIColumn>
                    <UIValidationColumn>
                        <ValidationMessage For=@(() => this.Model.TemperatureC) />
                    </UIValidationColumn>
                </UIFormRow>
                <UIFormRow>
                    <UILabelColumn>
                        Summary
                    </UILabelColumn>
                    <UIInputColumn>
                        <InputText class="form-control" @bind-Value="this.Model.Summary"></InputText>
                    </UIInputColumn>
                    <UIValidationColumn>
                        <ValidationMessage For=@(() => this.Model.Summary) />
                    </UIValidationColumn>
                </UIFormRow>
            </UIContainer>
            <UIContainer>
                <UIFormRow>
                    <UIButtonColumn>
                        <UIButton Show="true" Disabled="this._deleteDisabled" AdditionalClasses="btn-outline-danger" ClickEvent="() => Delete()">Delete</UIButton>
                        <UIButton Show="this._confirmDelete" AdditionalClasses="btn-danger" ClickEvent="() => this.ConfirmDelete()">Confirm Delete</UIButton>
                        <UIButton Show="true" Disabled="this._saveDisabled" Type="submit" AdditionalClasses="btn-success">@this._saveButtonText</UIButton>
                        <UIButton Show="this._dirtyExit" AdditionalClasses="btn-danger" ClickEvent="() => this.ConfirmExit()">Exit Without Saving</UIButton>
                        <UIButton Show="true" Disabled="this._dirtyExit" AdditionalClasses="btn-dark" ClickEvent="() => this.Exit()">Exit</UIButton>
                    </UIButtonColumn>
                </UIFormRow>
            </UIContainer>
        </EditForm>
    </UILoader>
</InlineDialog>
RouteView 实现
查看器的 RouteView 实现如下所示。
- 使用 ID Parameter声明Route。
- 声明表单 WeatherForecastViewerForm。
- 将 ID传递给表单,并将委托附加到ExitAction,该委托返回到 fetchdata 视图。
// WeatherViewer.razor
@page "/weather/view/{ID:int}"
<WeatherForecastViewerForm ID="this.ID" ExitAction="this.ExitToList"></WeatherForecastViewerForm>
@code {
    [Parameter] public int ID { get; set; }
    [Inject] public NavigationManager NavManager { get; set; }
    private void ExitToList()
        => this.NavManager.NavigateTo("/fetchdata");
}
编辑器完全相同,但声明了表单 WeatherForecastEditorForm。
// WeatherEditor.razor
@page "/weather/edit/{ID:int}"
<WeatherForecastEditorForm ID="this.ID" ExitAction="this.ExitToList"></WeatherForecastEditorForm>
@code {
    [Inject] public NavigationManager NavManager { get; set; }
    [Parameter] public int ID { get; set; }
    private void ExitToList()
        => this.NavManager.NavigateTo("/fetchdata");
}
总结
本文到此结束。 我们已经展示了如何将样板代码构建到基本表单中,以及如何实现查看器和编辑器表单。 我们将在单独的文章中更详细地介绍列表表单以及如何在其中调用查看器和编辑器。
一些需要注意的关键点
- Blazor Server 和 Blazor WASM 代码相同。
- 几乎所有功能都在库组件中实现。 大部分应用程序代码是用于单个记录字段的 Razor 标记。
- Razor 文件包含控件,而不是 HTML。
- 异步用于通过。
如果您在未来很久之后阅读本文,请查看存储库中的自述文件以获取本文集的最新版本。
历史
* 2020 年 9 月 19 日:初始版本。
* 2020年11月17日:Blazor.CEC 库重大更改。ViewManager 更改为 Router,以及新的 Component 基类实现。
* 2021 年 3 月 29 日:对服务、项目结构和数据编辑进行了重大更新。

