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)适合做副作用:发通知、写审计、触发审批提交、刷新关联数据。
七、性能与体验建议
- 大表单不要把所有关联数据都在弹窗打开时加载。 用
AdminSelectEntity这类"用时才弹窗"的组件,比一次性加载几千条选项要好。 - 富文本字段注意体积。
AdminEditor绑定的是 HTML 字符串,长正文会让草稿序列化和网络传输变重;草稿自动保存间隔不宜太短。 EnableSaveWithoutClose+EnableDraft组合适合"连续录入"场景(比如录入一批商品)。EditDialogSize按字段数量选:Medium/Large/ExtraLarge,标签页表单一般用ExtraLarge。- 保存中禁用按钮(框架已做)比"乐观更新"更适合后台:用户能确定数据是否真的写进去了。
八、常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 编辑弹窗里字段是自动生成的 | 没有定义 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 里自由发挥,保存、校验、草稿这些通用机制框架已经替你做好了。