如何自建扩展
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();
}
}
}
三个要点:
MenuPaths要和菜单Path、页面@page三处一致——框架靠它决定"扩展不可用时该隐藏哪些菜单";- 幂等(先查后插):初始化器每次启动都会跑一遍;
- 异常自己 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零警告(仓库开了警告即错误)