如何自建扩展

EasyAdminBlazor 的官方扩展(审批、打印、计划任务、多租户、微信……)都是一个套路:一个独立类库 + 一个 AddEasyAdminBlazorXxx() 注册方法。宿主里加一行就装上,不引用就完全不存在——连代码都不进编译。本文把这套做法拆开讲,照着做,你也能把自己的功能封成同样的扩展。

一、先判断:该不该做成扩展

你的情况 建议
只有当前项目用、和业务强耦合 直接写进宿主项目,见开始一个新模块
多个项目要复用、要独立版本 做成扩展类库
要按项目/按客户开关某块功能 做成扩展(不引用即不存在,天然是"编译期开关")

一句话判据:这段功能你愿不愿意装到别的项目上——愿意,就做成扩展。

二、扩展的六个扩展点

想做的事 扩展点 谁在用
建表、补菜单、修历史数据 IExtensionInitializer 框架的 ExtensionInitializerHostedService 在启动时依次 await 每个实现
页面参与路由(Razor 组件) AddEasyAdminBlazorAssembly(assembly) UseEasyAdminBlazor<TApp>() 把这些程序集加进 AddAdditionalAssemblies
扩展不可用时自动隐藏菜单 IExtensionInitializer.MenuPaths + IsAvailable MainLayout 统一过滤,扩展自己不用写界面判断
替换框架的默认/空实现 RemoveAll<T>() + 注册自己的实现 见下表
往后台界面挂入口(如页脚) IAdminExtension 页脚渲染
后台"系统配置"页的配置项 IConfigSettingsContributor 参数配置页

框架里"留了空位"的服务(默认实现可用但不干事,扩展顶掉它):

接口 框架默认 官方扩展示例
ITenantService NullTenantService EasyAdminBlazor.MultiTenant
ISchedulerService NullSchedulerService EasyAdminBlazor.Scheduler
IPrintService 框架内的门面实现 EasyAdminBlazor.Print
IApprovalGateway NullApprovalGateway EasyAdminBlazor.Approval
IEmailService DefaultEmailService EasyAdminBlazor.Mail
ISmsService DefaultSmsService EasyAdminBlazor.AliyunSms
ICacheService MemoryCacheService EasyAdminBlazor.Redis / FusionCache
IEditorComponent / IChatComponent 占位实现 EasyAdminBlazor.HtmlEditor / Chat

覆盖姿势:框架统一用 TryAddXxx 注册默认实现,而 GetRequiredService<T>() 取的是"最后注册的那个",所以直接 AddXxx<T, 你的实现>() 就能顶掉。不过如果框架里有遍历 IEnumerable<T> 的用法,两个实现会同时存在——官方扩展(审批、多租户等)一律先 RemoveAll<T>() 再注册,照抄最稳。

除上表之外,还有两件"框架已经铺好路"的事:

  • 提供 Controller API:框架已经 AddControllers(),扩展里直接加 Controller 即可(宿主需要 app.MapControllers(),Demo 的 Program.cs 里有);
  • 提供静态资源:Razor 类库的 wwwroot 会作为静态资源打包,引用方用 _content/{包名}/... 访问,打印设计器、TinyMCE 都是这么发的。

三、15 分钟做一个最小扩展:公告管理

下面这个例子是完整可编译的扩展(实体 + 初始化器 + 页面 + 注册方法 + 语言包),可以直接照抄改名。示例代码在本仓库同一套 csproj 设置下编译通过(0 警告 0 错误)。

1) 建项目

dotnet new razorclasslib -n EasyAdminBlazor.Notice

EasyAdminBlazor.Notice.csproj(照官方扩展的写法:Razor SDK + 引用框架 + 打包信息):

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <IsPackable>true</IsPackable>
    <PackageId>EasyAdminBlazor.Notice</PackageId>
    <Version>1.0.0</Version>
    <Authors>you</Authors>
    <Description>EasyAdminBlazor 公告扩展</Description>
    <GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
  </PropertyGroup>

  <ItemGroup>
    <!-- 引用框架:扩展包与框架包版本建议保持一致 -->
    <ProjectReference Include="..\..\EasyAdminBlazor\EasyAdminBlazor.csproj" />
  </ItemGroup>

  <ItemGroup>
    <!-- 自带语言包(可选) -->
    <Content Remove="Locales\*.json" />
    <EmbeddedResource Include="Locales\*.json" />
  </ItemGroup>

</Project>

2) 写实体

using FreeSql.DataAnnotations;
using System.ComponentModel;

namespace EasyAdminBlazor.Notice;

/// <summary>公告</summary>
[Table(Name = "notice")]
public partial class Notice : EntityFull
{
    [DisplayName("标题")]
    [Column(StringLength = 100)]
    public string Title { get; set; } = string.Empty;

    [DisplayName("内容")]
    [Column(StringLength = -2)]
    public string Content { get; set; } = string.Empty;

    [DisplayName("是否启用")]
    public bool IsEnabled { get; set; } = true;
}

约定(沿框架既有习惯):继承 EntityFull(软删除 + 创建/修改审计),主键 long + 雪花 ID 由基类提供;表名用小写并带前缀,避免和宿主表撞名;字符串给 StringLength,长文本用 -2。

3) 写初始化器:建表 + 补菜单 + 声明菜单路径

using FreeSql;
using Microsoft.Extensions.Logging;

namespace EasyAdminBlazor.Notice;

public class NoticeInitializer(
    MainOrmHandle mainOrmHandle,
    ILogger<NoticeInitializer> logger) : IExtensionInitializer
{
    /// <summary>本扩展接管的菜单路径(建菜单与菜单显隐共用同一份)。</summary>
    private const string MenuPath = "Admin/Notice";

    public IReadOnlyCollection<string> MenuPaths => [MenuPath];

    /// <summary>扩展不可用(未启用/未配置)时返回 false,菜单会被自动隐藏。</summary>
    public bool IsAvailable => true;

    public async Task InitializeAsync(CancellationToken cancellationToken)
    {
        try
        {
            var orm = mainOrmHandle.Orm;

            // 生产环境宿主常关闭 AutoSyncStructure,扩展表必须自己同步
            orm.CodeFirst.SyncStructure<Notice>();

            await EnsureMenuAsync(orm);
        }
        catch (Exception ex)
        {
            // 初始化失败只告警,绝不抛出去:抛出会阻断站点启动
            logger.LogWarning(ex, "公告扩展初始化失败");
        }
    }

    private static async Task EnsureMenuAsync(IFreeSql orm)
    {
        // 幂等:先查后插,每次启动都会跑一遍
        var menu = await orm.Select<SysMenu>()
            .Where(x => x.Type == SysMenuType.Menu && x.PathLower == MenuPath.ToLower())
            .FirstAsync();

        if (menu is null)
        {
            await orm.Insert(new SysMenu
            {
                Label = "公告管理",
                Icon = "fa-bullhorn",
                Path = MenuPath,
                Sort = 10030,
                Type = SysMenuType.Menu,
                IsSystem = true
            }).ExecuteAffrowsAsync();

            menu = await orm.Select<SysMenu>()
                .Where(x => x.Type == SysMenuType.Menu && x.PathLower == MenuPath.ToLower())
                .FirstAsync();
        }

        if (menu is null) return;

        // 按钮权限:路径用 add / edit / remove,AdminTable 会自动按它们控制按钮显隐
        var buttons = new[]
        {
            (Path: "add", Label: "新增", Sort: 10031),
            (Path: "edit", Label: "编辑", Sort: 10032),
            (Path: "remove", Label: "删除", Sort: 10033)
        };

        foreach (var button in buttons)
        {
            var exists = await orm.Select<SysMenu>()
                .Where(x => x.ParentId == menu.Id && x.Type == SysMenuType.Button && x.PathLower == button.Path)
                .AnyAsync();
            if (exists) continue;

            await orm.Insert(new SysMenu
            {
                Label = button.Label,
                Path = button.Path,
                Sort = button.Sort,
                Type = SysMenuType.Button,
                IsSystem = true,
                ParentId = menu.Id
            }).ExecuteAffrowsAsync();
        }
    }
}

三个要点:

  1. MenuPaths 要和菜单 Path、页面 @page 三处一致——框架靠它决定"扩展不可用时该隐藏哪些菜单";
  2. 幂等(先查后插):初始化器每次启动都会跑一遍;
  3. 异常自己 catch:ExtensionInitializerHostedService 是 foreach { await ... },抛出去会阻断整个站点启动。

4) 写页面

@page "/Admin/Notice"
@inject IAggregateRootRepository<Notice> _repo

<AdminTable TItem="Notice" TKey="long" SortString="Id desc" IsPagination ShowSearch ShowExtendButtons IsMultipleSelect>
    <TableColumns>
        <TableColumn @bind-Field="context.Title" Filterable="true" Searchable="true" />
        <TableColumn @bind-Field="context.IsEnabled" />
        <TableColumn @bind-Field="context.CreatedUserName" />
        <TableColumn @bind-Field="context.CreatedTime" Sortable="true" />
    </TableColumns>
</AdminTable>
  • @page 的路径必须和菜单 Path 一致(这里是 Admin/Notice):页面的可访问性是拿当前路径去 SysMenu.PathLower 里匹配的(AdminContext.AuthPath),菜单里没有这个路径,非管理员进去就是"无权限访问"。
  • IAggregateRootRepository<T> 框架按开放泛型注册好了,扩展里的实体直接能用,不用自己注册仓储。
  • 建议给扩展带一份 _Imports.razor(把框架 EasyAdminBlazor/_Imports.razor 拷一份,再加自己实体的 @using),页面里就能直接用 AdminTable、SwalService、admin、CommonLocalizer 等。
  • 页面文件名别和实体同名:如果实体叫 Notice、页面也叫 Notice.razor,Razor 组件自身也叫 Notice,TItem="Notice" 会被解析成页面自己(编译报 CS0311),此时要么把 TItem 写成全名 EasyAdminBlazor.Notice.Notice,要么换个文件名(推荐)。框架自带页面就是靠"页面名 ≠ 实体名"避开的:SysUser 的页面叫 User.razor。

5) 写注册方法

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace EasyAdminBlazor;

public static class ServiceCollectionExtensions
{
    /// <summary>添加 EasyAdminBlazor 公告扩展。</summary>
    public static WebApplicationBuilder AddEasyAdminBlazorNotice(this WebApplicationBuilder builder)
    {
        // 1) 启动初始化(建表、补菜单)
        builder.Services.AddSingleton<IExtensionInitializer, Notice.NoticeInitializer>();

        // 2) 把扩展程序集加入路由与组件发现(页面能打开的关键一步)
        builder.AddEasyAdminBlazorAssembly(typeof(ServiceCollectionExtensions).Assembly);

        return builder;
    }
}
  • 注册方法放在 namespace EasyAdminBlazor;(官方扩展多数这么做),宿主里不用额外 using;
  • AddEasyAdminBlazorAssembly 不能漏:它把扩展程序集加进路由与组件发现,漏了页面直接 404;
  • 需要开关/配置的,在这里 builder.Services.Configure<NoticeOptions>(builder.Configuration.GetSection("Notice")),初始化器里用 IsAvailable => options.Value.Enabled,菜单就会跟着开关走。

6) 宿主里装一行

builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
       .AddEasyAdminBlazorNotice();

7) 打包

dotnet pack -c Release

得到 EasyAdminBlazor.Notice.1.0.0.nupkg,别的项目 dotnet add package 之后同样一行注册即可。

四、菜单与权限的硬约定

约定 说明
路径 一律 Admin/Xxx。页面 @page "/Admin/Xxx" 与菜单 Path = "Admin/Xxx" 字面一致;大小写不敏感(写入时框架 AOP 会自动补 PathLower,并去掉首尾 /)
类型 SysMenuType.Menu(页面菜单)、Button(按钮权限)、Crud(增删改查)、ExternalLink(外链)
按钮权限 <AdminTable> 会自动按 add / edit / remove 三个按钮路径判断显隐(admin.AuthButton("add")),所以给菜单挂三个 Type=Button 的子菜单即可被角色授权
挂载位置 不设 ParentId 就是顶级菜单;要挂到某个分组下,先查出父菜单 Id 再写 ParentId
谁能看到 新菜单默认只有管理员(超管跳过权限判断)能看到,其他角色要在"角色管理 → 分配菜单"里勾选
幂等 建菜单要"没有才建",否则每次启动都会多插一条
Sort 框架自带:1001 / 1002(分组)、10001+(子菜单与按钮);官方扩展挑了 10010、10020。自己挑一个没被占用的值即可

五、踩坑清单

  • 初始化器是单例:AddSingleton<IExtensionInitializer, XxxInitializer>(),构造函数里不能注入 Scoped 服务(AdminContext、IAggregateRootRepository<T> 等)。取主库 ORM 用 MainOrmHandle(框架的 IFreeSql 也是从它解析出来的单例);确实要用 Scoped 服务就注入 IServiceProvider 再 CreateScope()。
  • 生产环境不会自动建表:宿主通常写 UseAutoSyncStructure(env.IsDevelopment()),所以扩展表必须在初始化器里显式 orm.CodeFirst.SyncStructure<T>()——审批、计划任务扩展都是这么做的。
  • 多租户:租户库是另一个 FreeSql 实例,用 ITenantService.GetTenantFreeSql(code),或者直接用 AdminContext.Orm(一步拿到"当前请求该用的库")。扩展表在租户库里同样会按 AutoSyncStructure 自动创建;如果要在租户库里预建或补数据,记得对租户 ORM 也跑一遍。
  • 多语言:界面文案用 CommonLocalizer["中文句子"];语言包是嵌入资源 Locales/{culture}.json,根节点写 EasyAdminBlazor.Common,缺翻译会退回中文键本身。扩展可以像 Demo 那样自带一份(csproj 里 <EmbeddedResource Include="Locales\*.json" />)。
  • 卸载扩展会残留菜单:数据库里的菜单不会自己消失。官方扩展的路径在框架的 ExtensionMenuCatalog.Paths 里兜底隐藏;第三方扩展的残留菜单需要手工删(或者保留 IExtensionInitializer 声明,让 IsAvailable = false 时隐藏)。
  • 安全相关的活别自己造:远程抓取用 FileSecurityValidator.IsSafeRemoteUri(),上传校验用 FileSecurityValidator,密码用 PBKDF2Encrypt。
  • 仓库开了警告即错误(TreatWarningsAsErrors):dotnet build 有警告就等于失败,Nullable 要写干净。

六、照着官方扩展抄

你想看什么 看哪
最小扩展(只有页面 + 菜单) Extensions/EasyAdminBlazor.CodeGenerator/ServiceCollectionExtensions.cs
建表 + 建菜单 + 按钮权限 Extensions/EasyAdminBlazor.Scheduler/SchedulerInitializer.cs
后台参数配置页的配置项 Extensions/EasyAdminBlazor.Mail/SmtpConfigSettingsContributor.cs
挂到后台页脚(IAdminExtension) Extensions/EasyAdminBlazor.Upgrade/Infrastructure/UpgradeExtension.cs
替换框架服务、提供 Controller Extensions/EasyAdminBlazor.MultiTenant/ServiceCollectionExtensions.cs、Extensions/EasyAdminBlazor.Captcha/CaptchaController.cs
静态资源(js/css) Extensions/EasyAdminBlazor.Print/wwwroot、Extensions/EasyAdminBlazor.HtmlEditor/wwwroot
用"当前租户的库" Extensions/EasyAdminBlazor.MultiTenant/MultiTenantService.cs

七、自检清单

  • 有 AddEasyAdminBlazorXxx() 注册方法,宿主一行即装
  • 调了 AddEasyAdminBlazorAssembly(...)(页面能打开)
  • 菜单 Path、页面 @page、IExtensionInitializer.MenuPaths 三处一致
  • 初始化器幂等、try/catch、不会阻断启动
  • 新表显式 SyncStructure(生产环境不会自动建表)
  • 未启用时 IsAvailable 返回 false(菜单自动隐藏)
  • 页面文件名与实体名不同(避免同名解析成页面组件)
  • dotnet build 零警告(仓库开了警告即错误)

相关文档:扩展集成、开始一个新模块、菜单管理、角色管理、多语言、多租户