案例来源:仓库自带的演示项目 EasyAdminBlazor.Demo(不是伪代码,所有内容都能在仓库里逐行对照)。
本文把演示项目按接单的真实顺序走一遍:需求 → 数据库设计 → 后台模块 → 权限 → 数据权限 → Excel/文件 → 审批 → 前台数据 → 配置与上线。
一、需求:一个"企业官网 + 内容后台"的项目
演示项目的业务定位很清晰,就是一个常见的接单场景:
客户要一个企业官网(首页、产品、服务、新闻、联系我们),同时要一个后台能自己维护这些内容。
拆成模块:
| 模块 | 前台 | 后台 | 关联能力 |
|---|---|---|---|
| 产品 | 产品列表 / 产品详情 | 增删改查、上下架、图片 | 文件上传、Excel 导入导出 |
| 服务 | 首页服务区块 / 服务详情 | 增删改查、排序、启用 | 图标、图片、富文本 |
| 新闻(随笔) | 首页新闻 / 新闻详情 | 增删改查、分类、审核 | 两级审批、富文本、缩略图、草稿 |
| 新闻分类 | —— | 增删改查 | 关联字段 |
| 评论 | 新闻详情页 | 增删改查、审核标记 | 关联字段 |
| 留言反馈 | 联系我们 | 查看、标记已读、导出 | —— |
注意后台不只是"增删改查":审核走审批、图片走文件服务、批量录入走 Excel、内容需要草稿——这些恰好是 EasyAdminBlazor 的现成能力。
二、第一步:数据库设计
演示项目一共 6 张业务表:
product 产品
service 服务项目
feedback 留言反馈
blog_classify 新闻分类
blog_article 新闻(随笔)
blog_comment 评论
关系很简单:
blog_classify 1 ── n blog_article 1 ── n blog_comment
其余三张表相互独立。
实体怎么写
以"服务"为例:
/// <summary>服务项目</summary>
[Table(Name = "service")]
public partial class Service : EntityFull
{
[DisplayName("服务名称")]
[Column(StringLength = 100)]
public string Name { get; set; } = string.Empty;
/// <summary>图标(FontAwesome 类名,如 fa-solid fa-gears)</summary>
[DisplayName("图标")]
[Column(StringLength = 100)]
public string Icon { get; set; } = string.Empty;
[DisplayName("图片")]
[Column(StringLength = 400)]
public string Image { get; set; } = string.Empty;
[DisplayName("摘要")]
[Column(StringLength = 500)]
public string Excerpt { get; set; } = string.Empty;
[DisplayName("详情")]
[Column(StringLength = -2)]
public string Content { get; set; } = string.Empty;
[DisplayName("排序")]
public int Sort { get; set; }
[DisplayName("是否启用")]
public bool IsEnable { get; set; } = true;
}
几个约定(前面几篇反复讲过,这里再压缩一遍):
- 主键来自
EntityFull(long Id+ 雪花 ID),不用自己声明; [DisplayName]决定列表列头与表单标签;[Column(StringLength = -2)]是长文本(富文本正文字段);EntityFull自带创建人/创建时间/修改人/修改时间 + 软删除。
主键怎么定
演示数据里能看到固定的 Id(例如 510337460719685),这是为了演示数据可复现。正常开发不写 Id,插入时由 [Snowflake] 自动生成。
"新闻"要审批,所以基类不一样
其他实体继承 EntityFull,新闻继承审批基类:
[Table(Name = "blog_article")]
public partial class Article : ApprovalEntityFull
{
[DisplayName("随笔专栏")]
[Required]
public long? ClassifyId { get; set; }
public Classify Classify { get; set; } = default!;
[Column(StringLength = 200)]
[DisplayName("标题")]
public string Title { get; set; } = string.Empty;
[Column(StringLength = -2)]
[DisplayName("正文")]
[Required]
public string Content { get; set; } = string.Empty;
public DateTime? PublishTime { get; set; } = DateTime.Now;
...
}
换基类这一步,就把审批状态字段(ApprovalStatus / CurrentLevel)带进了实体——这就是为什么数据库设计阶段就要想清楚"哪些单据需要审批"。
三、第二步:从实体到后台模块
一个模块的标准产物是两个文件:列表页 + 编辑模板。
列表页
@page "/Admin/Product"
@* 页面组件与实体同名时,用别名避免组件类名遮蔽实体类型(CS0311/CS1061) *@
@using ProductEntity = EasyAdminBlazor.Test.Products.Product
<AdminTable TItem="ProductEntity" TKey="long"
OnBeforeQuery="OnBeforeQuery"
EditDialogSize="Size.ExtraLarge"
EnableSaveWithoutClose EnableDraft
ShowImportButton ShowExportButton ShowExtendButtons
IsPagination ShowSearch ShowAdvancedSearch IsMultipleSelect>
<TableColumns>
<TableColumn @bind-Field="context.Title" Filterable="true" Searchable="true" />
<TableColumn @bind-Field="context.Price" Filterable="true" />
<TableColumn @bind-Field="context.Stock" Filterable="true" />
<TableColumn @bind-Field="context.Image" Filterable="true">
<Template Context="v">
@if (!string.IsNullOrEmpty(v.Row.Image))
{
<img src="@v.Row.Image" />
}
</Template>
</TableColumn>
<TableColumn @bind-Field="context.IsOnSale" Filterable="true" />
<TableColumn @bind-Field="context.CreatedTime" Filterable="true" />
</TableColumns>
<EditTemplate>
<ProductEdit item="context" />
</EditTemplate>
</AdminTable>
这一页就同时具备了:分页、排序、模糊搜索、高级搜索、列头筛选、多选、导入、导出、草稿、保存不关闭、图片列预览。
留言反馈页更简单,连编辑模板都不需要(只读 + 标记已读 + 导出):
@page "/Admin/Feedback"
@using FeedbackEntity = EasyAdminBlazor.Test.Feedbacks.Feedback
<AdminTable TItem="FeedbackEntity" TKey="long" OnBeforeQuery="OnBeforeQuery" EditDialogSize="Size.Large"
ShowSearch ShowAdvancedSearch="false" ShowExportButton ShowExtendButtons IsPagination IsMultipleSelect>
<TableColumns>
<TableColumn @bind-Field="context.Name" Filterable="true" Searchable="true" />
<TableColumn @bind-Field="context.Phone" Filterable="true" Searchable="true" />
<TableColumn @bind-Field="context.Subject" Filterable="true" Searchable="true" />
<TableColumn @bind-Field="context.Content" Filterable="true" />
<TableColumn @bind-Field="context.IsRead" Filterable="true" />
<TableColumn @bind-Field="context.CreatedTime" Filterable="true" />
</TableColumns>
</AdminTable>
编辑模板
@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; }
}
到这里,一个模块的后台代码就写完了——没有 Controller、没有 Service、没有 DTO、没有前端请求封装。
页面写完了,还要建菜单
在第 01 篇强调过:页面存在 ≠ 能访问。演示项目在 Program.cs 里补种了菜单:
var demoRoot = fsql.Select<SysMenu>().Where(a => a.Label == "内容管理" && a.Path == "").First();
repo.Insert(new[]
{
new SysMenu
{
Label = "服务",
Path = "Admin/Service",
ParentId = demoRoot.Id,
Sort = 10004,
Type = SysMenuType.Menu,
Childs = new List<SysMenu>(cudButtons())
},
...
});
cudButtons() 生成 add / edit / remove 三个按钮——它们就是 AdminTable 按钮显隐和服务端校验的依据。
真实项目里菜单可以用后台"菜单管理"手工建,也可以像演示项目这样用代码补齐(适合交付多个环境时保持一致)。演示代码里还处理了"旧版本菜单路径带前导斜杠"的迁移,属于典型的升级兼容逻辑。
四、第三步:权限与数据权限
功能权限:菜单 + 按钮
建完菜单后,去 角色 → 分配菜单 勾选。之后:
- 侧边栏菜单只显示勾选过的项;
- 进入页面前
AuthPath会校验; - 页面里的添加/编辑/删除按钮按
AuthButton显隐; - 保存/删除时服务端会再校验一次(伪造请求也没用)。
演示项目里的角色页还有个细节值得学——角色-菜单关系变化后立即让权限缓存失效:
await _repo.UpdateAsync(select);
await admin.InvalidatePermissionCacheAsync();
数据权限:让"只能看自己的"变成默认行为
演示项目里的用户管理页就是这么开的:
<AdminTable ... TItem="SysUser" TKey="long" ShowToolbar EditDialogSize="Size.ExtraLarge"
ShowImportButton ShowExportButton ShowExtendButtons ShowAdvancedSearch
IsMultipleSelect IsPagination ShowSearch UseDataPermission ...>
只要 SysUser 实现了 IDataPermission(框架里已经实现),再加上 UseDataPermission,非管理员用户就只能看到自己组织范围内的用户。查询、修改、删除、Excel 导入四条路径都会被过滤(见第 09 篇)。
对客户来说,这就是"部门经理只能管本部门的人"——不需要你写一行过滤代码。
五、第四步:把 Excel 和文件接上
Excel
产品页开了 ShowImportButton ShowExportButton,于是自动获得:
- 下载导入模板(按可见列生成);
- 上传
.xlsx导入(默认 5MB 上限); - 导出当前筛选结果(按可见列投影,不拉全表字段)。
需要业务校验(比如"价格必须大于 0")就在 OnBeforeImportAsync 里加,权限过滤框架已经做了。
文件
产品和服务的图片字段用的是 AdminFileInput:
<AdminFileInput @bind-Value="item.Image" DisplayText="产品图片" />
上传后的文件自动:
- 落在
uploads/{租户}/{yyyy/MM/dd}/; - 校验扩展名 + Magic Number;
- 超过
MaxImageWidth时缩放; - 转 WebP(
KeepOriginalFile = false时删原图)。
客户只要在后台点"选择..."上传一张图,剩下的都是框架的事。
六、第五步:给新闻加两级审批
新闻是要"公司对外发布"的内容,所以演示项目给它配了两级审批:
builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
.AddEasyAdminBlazorApproval(o =>
{
// 演示:随笔走两级审批 —— 部门负责人 -> 管理员角色
o.Flows.Add(new ApprovalFlowConfig
{
BillType = typeof(Article).FullName!,
// 默认策略:审批通过后锁定(不能再修改)
// 想体验"改后重审、且只对关键字段重审",打开下面两行:
// AllowEditAfterApproved = true,
// ResubmitFields = [nameof(Article.Title), nameof(Article.Content)],
Levels =
[
new ApprovalLevelConfig { Level = 1, Name = "部门主管审批", Kind = ApproverKind.OrgLeader, Offset = 1 },
new ApprovalLevelConfig { Level = 2, Name = "管理员审批", Kind = ApproverKind.Role, Value = "Administrator" }
]
});
})
编辑器里加一个审批选项卡:
<TabItem Text="@CommonLocalizer["审批"]">
<ApprovalActions TItem="Article" Bill="Model" OnChanged="OnApprovalChanged" />
</TabItem>
列表里加一列状态:
<TableColumn @bind-Field="context.ApprovalStatus" Filterable="true">
<Template Context="v">@v.Row.ApprovalStatusText</Template>
</TableColumn>
因为实体是 ApprovalEntityFull,AdminTable 会自动接管:审批中的单据不能改也不能删。
演示项目还在启动时把管理员所在部门的负责人设成了管理员本人,方便体验"部门负责人"这一级:
// 演示审批:把管理员所在组织的负责人设为管理员本人,便于体验"部门负责人"审批节点
if (adminUser is not null && adminUser.OrgId > 0
&& fsql.Select<SysOrg>().Where(a => a.Id == adminUser.OrgId && a.ResponsibleUserId == null).Any())
{
fsql.Update<SysOrg>()
.Where(a => a.Id == adminUser.OrgId)
.Set(a => a.ResponsibleUserId, (long?)adminUser.Id)
.ExecuteAffrows();
}
注意:新版本默认不允许自己审自己。演示里把负责人设成管理员本人、审批人又是"管理员角色",在这种配置下自审默认被跳过,需要显式打开
AllowSelfApproval才能自审(见第 16 篇)。
七、第六步:前台和后台共用一套实体
演示项目的另一个亮点:前台 Razor Pages 直接注入仓储读同一批实体,不存在"后台改了前台读不到"的问题。
首页:
public class IndexModel(
IBaseRepository<Product> productRepository,
IBaseRepository<Service> serviceRepository,
IBaseRepository<Article> articleRepository) : PageModel
{
public List<Service> Services { get; set; } = [];
public List<Product> Products { get; set; } = [];
public List<Article> News { get; set; } = [];
public async Task OnGet()
{
Services = await serviceRepository.Select
.Where(x => x.IsEnable)
.OrderBy(x => x.Sort).OrderByDescending(x => x.CreatedTime)
.Take(3)
.ToListAsync();
Products = await productRepository.Select
.Where(x => x.IsOnSale)
.OrderByDescending(x => x.CreatedTime)
.Take(6)
.ToListAsync();
News = await articleRepository.Select
.Where(x => x.IsAudit)
.OrderByDescending(x => x.CreatedTime)
.Take(5)
.ToListAsync();
}
}
产品列表页(带分页):
public class ProductsModel(IBaseRepository<Product> productRepository) : PageModel
{
public List<Product> Products { get; set; } = [];
public int TotalCount { get; set; }
public async Task OnGet(int pageIndex = 1, int pageSize = 12)
{
var select = productRepository.Select
.Where(x => x.IsOnSale)
.OrderByDescending(x => x.CreatedTime);
TotalCount = (int)await select.CountAsync();
Products = await select
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
}
这就是"前后台同一个技术栈"的实际收益:实体、ORM、查询语法完全复用,不需要为前台再写一套 API 和 DTO。
八、第七步:配置与部署
appsettings.json 里真正要改的东西
{
"ConnectionStrings": {
"Default": "Data Source=easyadminblazor.db"
},
"FileSettings": {
"DirectoryName": "uploads",
"DateTimeDirectory": "yyyy/MM/dd",
"KeepOriginalFile": false,
"MaxImageWidth": 800,
"WebpQuality": 80
},
"Security": {
"MaxUploadSize": 10485760,
"MaxRemoteRedirects": 3,
"LoginFailureLimit": 5,
"AntiforgeryEnabled": true,
"RateLimitEnabled": true
},
"Scheduler": {
"TimeZoneId": "Asia/Shanghai"
}
}
生产环境把连接串换成 MySQL / SQL Server,并把 AutoSyncStructure 关掉(改由发布脚本管表结构):
FreeSqlBuilder = a => a
.UseConnectionString(DataType.Sqlite, configuration["ConnectionStrings:default"])
.UseMonitorCommand(cmd => System.Console.WriteLine($"[{DateTime.Now.ToString("HH:mm:ss")}] {cmd.CommandText}\r\n"))//监听SQL语句
.UseAutoSyncStructure(env.IsDevelopment())
演示项目用的是 env.IsDevelopment():开发环境自动建表,生产环境不自动改结构。这是最推荐的写法。
上线检查清单
| 检查项 | 说明 |
|---|---|
AdminRouteSecret | 换成只有自己人知道的安全码,别用默认值 |
AesKey | 换成长随机串,不要用示例值 |
UseAutoSyncStructure | 生产关闭,走发布脚本 |
| 默认管理员密码 | 首次登录会强制改密,确认这条流程没被改坏 |
| 数据库备份 | 至少每日备份;EntityFull 是软删除,误删可恢复但不是备份替代品 |
| 文件目录权限 | 应用账号对 wwwroot/uploads 有写权限;不要把上传目录暴露成可执行 |
| Redis | 多实例部署必须配;单实例可用内存缓存 |
| HTTPS | 后台必须走 HTTPS;AdminRouteSecret 只是路径混淆,不是安全边界 |
| 日志 | 配置 Serilog(见 doc/Serilog日志配置.md)并确认日志落盘 |
| 计划任务 | 确认任务页可见(装了 Scheduler 扩展)、时区配置正确 |
| 审批 | 确认审批人(组织负责人 / 角色)已经维护,否则提交时会提示"未找到审批人" |
详细的 IIS 部署步骤会单独写一篇(第 28 篇)。
九、这个项目实际写了多少代码
统计口径:EasyAdminBlazor.Demo 里 后台业务模块的实体 + 页面 + 编辑模板,按文件总行数计(含注释与空行)。
| 模块 | 实体 | 页面/模板 | 合计 |
|---|---|---|---|
| 产品 Product | 58 | 64 | 122 |
| 服务 Service | 58 | 64 | 122 |
| 留言 Feedback | 52 | 20 | 72 |
| 随笔 Article | 135 | 126 | 261 |
| 专栏 Classify | 37 | 17 | 54 |
| 评论 Comment | 43 | 33 | 76 |
| 合计 | 383 | 324 | 707 |
15 个文件、707 行,完成了 6 个模块的后台 —— 并且里面还包含了审批接入、Excel 导入导出、文件上传、富文本、草稿这些"额外能力"。
具体怎么得出的、和传统分层怎么对比,见下一篇《一个后台项目到底能省多少代码?》。
十、小结
用 EasyAdminBlazor 做一个后台项目的顺序,可以固化下来:
1. 需求 → 模块清单
2. 数据库设计 → 实体(EntityFull / ApprovalEntityFull)
3. 每个模块:AdminTable 页面 + EditTemplate
4. 建菜单 + 角色授权(功能权限)
5. 需要行级隔离的实体:实现 IDataPermission + UseDataPermission
6. 需要批量录入:ShowImportButton / ShowExportButton
7. 需要图片/附件:AdminFileInput
8. 需要审核:换审批基类 + 注册流程 + 审批选项卡
9. 前台页面复用同一套实体和仓储
10. 配置(连接串/文件/安全/时区)→ 部署 → 上线检查清单
演示项目把这条链路完整走了一遍,而且代码是可以直接跑的。接下一个项目时,把实体换掉、页面复制改字段,就是交付的起点。
如果你正在用 .NET 10 + Blazor 接后台项目,可以拿 EasyAdminBlazor 的演示项目当脚手架:它本身就是"企业官网 + 内容后台"的完整案例,前台后台共用一套实体,权限、数据权限、审批、Excel、文件都已接好。