EasyAdminBlazor 表单开发实战:从简单 CRUD 到复杂业务表单

Original 2026-09-25 810 views

AdminTable 解决了"列表",表单这块框架直接沿用 BootstrapBlazor 的行为:不定义 EditTemplate 时,走 BootstrapBlazor 的自动表单生成;定义了 EditTemplate,就完全用你的模板。这篇讲这两种方式分别怎么用、怎么选。


一、自动表单与 EditTemplate:默认与覆盖

不定义 EditTemplate 时,AdminTable 的编辑弹窗走的是 BootstrapBlazor 的自动表单:按实体上的 [Display]、[Required]、[MaxLength] 等注解自动生成字段和校验。

一旦你定义了 EditTemplate,自动生成就被完全替换掉,弹窗里的内容由你的 Razor 决定。

也就是说,这不是"框架不支持自动表单",而是"默认自动,需要时覆盖"。

<AdminTable TItem="ProductEntity" TKey="long" EditDialogSize="Size.ExtraLarge" ...>
    <TableColumns>
        ...
    </TableColumns>
    <EditTemplate>
        <ProductEdit item="context" />
    </EditTemplate>
</AdminTable>

EditTemplate 里的 context 就是当前编辑的实体。用了它之后:

  • 布局完全自由(栅格、分组、标签页);
  • 可以用任何组件(框架自带的、BootstrapBlazor 的、第三方甚至自写的);
  • 条件显示、联动、动态字段都是普通 C# 逻辑,不需要学一套 DSL;
  • 不需要为每种字段类型维护"控件映射表"。

如果你不想手写字段,又想要一个可编辑的起点,代码生成器(/Admin/CrudGenerator)会把常见字段的样板 Razor 生成出来。它本质上是把自动表单的默认结果固化成一个可编辑的文件,改起来没有额外心智负担。


二、编辑弹窗的装配

1. 弹窗底部按钮

AdminTable 会自动注入底部模板:

EditFooterTemplate = context => __builder =>
{
    <EditContextCapture @ref="_editContextCapture" />
    @if (EnableDraft)
    {
        <Dropdown Color="Color.Secondary" ShowSplit="true" TValue="int"
                  Items="@(new List<SelectedItem>
                  {
                      new("5", "5s"),
                      new("10", "10s"),
                      new("20", "20s"),
                      new("30", "30s") { Active = DraftAutoSaveInterval == 30 }
                  })"
                  OnClick="@(async () => await ManualDraftSave())"
                  OnSelectedItemChanged="@(async item =>
                  {
                      DraftAutoSaveInterval = int.Parse(item.Value);
                      ...
                  })">
            <ButtonTemplate>
                <i class="fa-regular fa-floppy-disk"></i>
                <span id="draft-save-btn">@CommonLocalizer["草稿"]</span>
            </ButtonTemplate>
        </Dropdown>
    }
    <DialogCloseButton />
    @if (EnableSaveWithoutClose)
    {
        <Button @ref="_saveButton" OnClick="@(async () => await OnSaveWithoutClose(context))" Color="@Color.Primary" Text="@(CommonLocalizer["保存"])" Icon="fa-solid fa-floppy-disk" />
    }
    <Button @ref="_saveCloseButton" OnClick="@(async () => await OnSaveAndCloseAsync(context))" Color="@Color.Primary" Text="@(CommonLocalizer[EnableSaveWithoutClose? "保存并关闭" : "保存"])" Icon="fa-solid fa-check" />
};

三个按钮的语义:

按钮 出现条件 行为
草稿 EnableDraft="true" 手动保存草稿;下拉可切自动保存间隔(5/10/20/30 秒)
保存 EnableSaveWithoutClose="true" 保存后不关弹窗,可以继续改/继续填下一条
保存(并关闭) 始终 保存成功后刷新列表并关闭弹窗

2. 保存前一定先校验

private async Task OnSaveAndCloseAsync(TItem item)
{
    if (_saving) return;
    _saving = true;
    ApplySavingState(true);
    try
    {
        if (_editContextCapture?.Validate() != true)
        {
            return;
        }

        var changedType = ResolveChangedType(item);

        if (await OnSaveAsync!(item, changedType))
        {
            await MessageService.Success(CommonLocalizer["已保存"]);
            ...
        }
    }
    ...
}

EditContextCapture 的作用就是拿到 ValidateForm 下发的 EditContext 并手动触发校验:

/// <summary>
/// 捕获 ValidateForm 下发的 EditContext,以手动触发客户端验证。
/// </summary>
public class EditContextCapture : ComponentBase
{
    [CascadingParameter]
    private EditContext EditContext { get; set; } = default!;

    public EditContext? CurrentEditContext => EditContext;

    /// <summary>触发客户端验证,返回验证结果。</summary>
    public bool Validate() => EditContext?.Validate() ?? false;

    /// <summary>
    /// 重置验证状态,标记所有字段为未修改。
    /// 在"保存不关闭"场景下使用,避免旧验证状态残留。
    /// </summary>
    public void ResetValidation() => EditContext?.MarkAsUnmodified();
}

所以数据注解是生效的:实体上的 [Required]、[MaxLength]、[Range] 会在保存前被校验,不通过就不落库。无论你用的是自动表单还是 EditTemplate,这套校验机制都一样。

3. 增删改的类型判定

保存时不能靠"Id == 0 就是新增"来判断——源码里有一个很实际的坑:

// 插入失败时 FreeSql 可能已为 [Snowflake] 主键回填了雪花 Id,
// 保存时若按 Id 是否为 0 推断变更类型,会把重试误判成"编辑"。
// 这里记录弹窗的真实类型,保存时以此为准。
if (option is ITableEditDialogOption<TItem> editDialogOption)
{
    _editDialogChangedType = editDialogOption.ItemChangedType;
    _editDialogChangedTypeCaptured = true;
}

框架在弹窗打开时记录真实的变更类型,保存时以它为准。自己写 OnSaveAsync 时也要注意这一点:如果你用 Id == 0 判断新增/编辑,在"插入失败后重试"的场景下会出错。

4. 保存中的按钮状态

private void ApplySavingState(bool saving)
{
    SetButtonSaving(_saveButton, saving, "fa-solid fa-floppy-disk");
    SetButtonSaving(_saveCloseButton, saving, "fa-solid fa-check");
}

"保存"和"保存并关闭"会同时进入 loading 并禁用,避免用户连点两次造成重复提交。

5. 保存不关闭:新记录会切换到编辑态

// 保存后对话框不关闭,重新启动草稿自动保存
if (EnableDraft)
{
    var oldDraftKey = _draftKey;
    _draftKey = GetDraftKey(changedType, item);
    await JS.InvokeVoidAsync("EasyAdminBlazorJS.removeDraft", [oldDraftKey]);
    await JS.InvokeVoidAsync("EasyAdminBlazorJS.removeDraft", [_draftKey]);
    _lastSavedJson = null;
    _restartDraftAfterClose = true;
}

await CloseEditDialogAsync();
_editDialogChangedType = ItemChangedType.Update;
_editDialogChangedTypeCaptured = true;
await ShowEditDialog(ItemChangedType.Update);

细节值得注意:新增保存成功后会以编辑态重新打开同一条记录,而不是清空表单。这样用户能立刻看到刚保存的数据,也能接着改。同时草稿键会从 add 迁移到 edit_{id},旧草稿被清理。


三、从最简单的表单开始

不定义 EditTemplate 时,你不需要写任何表单代码。框架会按实体注解自动生成。

如果字段有特殊的布局或组件要求,再用 EditTemplate 覆盖:

@using ProductEntity = EasyAdminBlazor.Test.Products.Product

<div class="row form-inline g-3">
    <div class="col-12 col-sm-6">
        <label class="form-label">产品名称</label>
        <input @bind="item.Title" type="text" class="form-control" maxlength="100" />
    </div>
    <div class="col-12 col-sm-6">
        <input @bind="item.Price" type="number" step="0.01" class="form-control" />
    </div>
    <div class="col-12 col-sm-6">
        <Checkbox @bind-Value="item.IsOnSale" DisplayText="上架" />
    </div>
    <div class="col-12 col-sm-6">
        <AdminFileInput @bind-Value="item.Image" DisplayText="产品图片" />
    </div>
    <div class="col-12">
        <Textarea @bind-Value="item.Excerpt" maxlength="500"></Textarea>
    </div>
    <div class="col-12">
        <AdminEditor @bind-Value="item.Content" DisplayText="产品详情" />
    </div>
</div>

@code {
    [Parameter]
    [NotNull]
    public ProductEntity? item { get; set; }
}

原生 <input @bind> 和 BootstrapBlazor 组件可以混用。区别在于:

写法 是否参与 EditContext 校验 说明
<input @bind="item.X"> 否 简单直接,但 [Required] 等注解不会自动提示
<BootstrapInput @bind-Value="item.X"> 是 推荐,能显示校验消息
<AdminFileInput @bind-Value="..."> 是 继承 ValidateBase<string>

如果要让数据注解生效,把字段换成 BootstrapBlazor 的输入组件(或 ValidateBase 家族的组件)即可。


四、字段组件速查

框架自带的后台字段组件,参数以源码为准:

组件 参数 用途
AdminFileInput @bind-Value、DisplayText 文件路径 + 选择器 + 图片预览
AdminEditor @bind-Value、DisplayText 富文本(未装 HtmlEditor 扩展时是普通文本框)
AdminDictSelect @bind-Value、ParentName(必填)、CacheDuration(默认 30 秒) 字典单选
AdminDictMultiSelect @bind-Value、ParentName、CacheDuration 字典多选
AdminMultiSelect TItem、@bind-Value、GetText、Where、UseDataPermission 实体多选(弹窗表格)
AdminSelectEntity TItem、TKey、@bind-Value、GetText、Where、UseDataPermission 实体单选
AdminSelectTable TItem、@bind-Value/@bind-ValueId、GetText、TableColumns 实体单选(列可定制)
AdminTree TItem、@bind-Value、GetText、Where、SortString 树形选择(组织、菜单)
AdminCheckboxListGeneric TItem、TValue、Items、@bind-Value 复选框列表

示例:字典 + 实体选择 + 富文本的组合

<div class="row form-inline g-3">
    <div class="col-12 col-sm-6">
        <AdminDictSelect @bind-Value="Model.OrderType" ParentName="order_type" DisplayText="订单类型" />
    </div>
    <div class="col-12 col-sm-6">
        <AdminSelectEntity TItem="SysUser" TKey="long"
                           @bind-ValueId="Model.OwnerId"
                           GetText="u => u.Nickname"
                           UseDataPermission="true"
                           DisplayText="负责人" />
    </div>
    <div class="col-12">
        <AdminEditor @bind-Value="Model.Remark" DisplayText="备注" />
    </div>
</div>

关于字典缓存要有个预期:CacheDuration 默认 30 秒,字典项改动后最多 30 秒生效;如果是长时间打开的标签页,可能需要手动刷新页面。


五、复杂表单的三种组织方式

1. 标签页分组

字段多的时候用 Tab 分组,这是仓库里的真实做法:

@inject CommonLocalizer CommonLocalizer
<Tab IsCard>
    <TabItem Text="@CommonLocalizer["随笔"]">
        <!-- 主体字段:专栏、标题、类型、关键字 -->
    </TabItem>
    <TabItem Text="@CommonLocalizer["设置"]">
        <!-- 浏览量、评论数、推荐、置顶等 -->
    </TabItem>
    <TabItem Text="@CommonLocalizer["审批"]">
        <ApprovalActions TItem="Article" Bill="Model" OnChanged="OnApprovalChanged" />
    </TabItem>
</Tab>

把审批面板放进一个 Tab,是接入审批模块最自然的方式(见第 16 篇)。

2. 条件显示

条件显示就是普通的 @if,不需要额外机制:

<Select @bind-Value="Model.OrderType" Items="@orderTypes" />

@if (Model.OrderType == "custom")
{
    <BootstrapInput @bind-Value="Model.CustomNote" DisplayText="自定义说明" />
}

更复杂的联动(选 A 自动填 B)就在 OnValueChanged 里改数据并 StateHasChanged():

private async Task OnOwnerChanged(long? userId)
{
    Model.OwnerId = userId;
    if (userId is > 0)
    {
        var user = await repo.Select.Where(x => x.Id == userId).FirstAsync();
        Model.OwnerName = user?.Nickname ?? string.Empty;
    }
    await InvokeAsync(StateHasChanged);
}

3. 大表单拆组件

字段特别多时,把一组字段拆成一个子组件,用 [Parameter] 传实体:

// OwnerSection.razor
[Parameter][NotNull] public Order? Model { get; set; }

这样每个文件保持在可读的规模,也便于多人协作。


六、保存前后的业务校验

客户端校验之外,服务端还要有业务校验,写在 OnBeforeSaveAsync:

private async Task OnBeforeSaveAsync(AdminSaveEventArgs<SysUser> e)
{
    if (await _repo.Select.Where(a => a.Username == e.Item.Username && a.Id != e.Item.Id).AnyAsync())
    {
        await SwalService.Error(CommonLocalizer["用户名已存在"]);
        e.Cancel = true;   // 取消保存
    }
}
public class AdminSaveEventArgs<TItem> where TItem : class
{
    public ItemChangedType ChangedType { get; set; }
    public required TItem Item { get; set; }
    public bool Cancel { get; set; }
}

OnBeforeSaveAsync 里可以做:唯一性校验、跨字段校验、根据输入补全派生字段、按需修改 e.Item。设置 e.Cancel = true 就中止保存。

保存成功后(OnFinishSaveAsync)适合做副作用:发通知、写审计、触发审批提交、刷新关联数据。


七、性能与体验建议

  1. 大表单不要把所有关联数据都在弹窗打开时加载。 用 AdminSelectEntity 这类"用时才弹窗"的组件,比一次性加载几千条选项要好。
  2. 富文本字段注意体积。 AdminEditor 绑定的是 HTML 字符串,长正文会让草稿序列化和网络传输变重;草稿自动保存间隔不宜太短。
  3. EnableSaveWithoutClose + EnableDraft 组合适合"连续录入"场景(比如录入一批商品)。
  4. EditDialogSize 按字段数量选:Medium / Large / ExtraLarge,标签页表单一般用 ExtraLarge。
  5. 保存中禁用按钮(框架已做)比"乐观更新"更适合后台:用户能确定数据是否真的写进去了。

八、常见问题

现象 原因 处理
编辑弹窗里字段是自动生成的 没有定义 EditTemplate 这是默认行为;要自定义就加 EditTemplate
必填校验不显示 用了原生 <input> 而不是 BootstrapBlazor 组件 换成 BootstrapInput / ValidateBase 家族
点了保存没反应 校验没过(Validate() 返回 false) 看字段上的校验提示
新增后变成了编辑态 EnableSaveWithoutClose 的预期行为 这是设计如此;不需要就不开这个开关
保存失败后重新保存变成"编辑" 按 Id 判断变更类型 用框架的变更类型捕获,或按自己的标志位判断
关联下拉为空 字典/实体无数据,或数据权限过滤掉了 检查字典分类、实体数据、UseDataPermission
草稿恢复提示一直弹 草稿键有残留 框架会在保存后清理;自定义逻辑不要复用同一个键

九、小结

EasyAdminBlazor 的表单设计可以概括成一句话:默认走 BootstrapBlazor 的自动表单,需要时用 EditTemplate 覆盖;弹窗、校验、保存、草稿、权限这些通用部分,框架替你做好。

关注点 由谁负责
自动表单 框架(不定义 EditTemplate 时,按实体注解生成)
弹窗、按钮、loading 框架(EditFooterTemplate + ApplySavingState)
校验触发 框架(EditContextCapture.Validate())
校验规则 实体的数据注解 + BootstrapBlazor 输入组件
字段与布局 页面(EditTemplate,可选)
业务校验 页面(OnBeforeSaveAsync)
保存后副作用 页面(OnFinishSaveAsync)
草稿 框架(EnableDraft,可调间隔)

如果你正在用 .NET 10 + Blazor 做后台,表单是最花时间的部分。EasyAdminBlazor 默认给你自动表单,复杂业务表单可以在 EditTemplate 里自由发挥,保存、校验、草稿这些通用机制框架已经替你做好了。