用 EasyAdminBlazor 接一个真实后台项目:从数据库设计到正式上线

5 阅读11分钟

案例来源:仓库自带的演示项目 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 里 后台业务模块的实体 + 页面 + 编辑模板,按文件总行数计(含注释与空行)。

模块实体页面/模板合计
产品 Product5864122
服务 Service5864122
留言 Feedback522072
随笔 Article135126261
专栏 Classify371754
评论 Comment433376
合计383324707

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、文件都已接好。