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

案例来源:仓库自带的演示项目 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" height="36" alt="产品图" />
                }
            </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、文件都已接好。

posted @ 2026-10-07 08:06  EasyAdminBlazor  阅读(6)  评论(0)    收藏  举报