先确认要走哪条路。 Known 的默认路径是运行时 Schema 渲染——标准 CRUD 只写一份
*.schema.json就换来页面、菜单、建表与增删改查,你只写后端钩子、不生成任何.razor(详见 运行时 Schema 渲染)。本篇讲的是手写路线:当你确实要把实体 / 服务 / 页面当普通代码放进自己仓库长期演进时(复杂交互、脱离 Schema 约束、代码审计需要等),一个模块该怎么落地。想少维护产物、想随包发布业务模型的,请先看运行时 Schema 那篇。
这一篇不重新搭工程。前提是你已经跟着 五分钟跑起来 用 known new MyApp 生成了项目,dotnet run --project MyAppWeb 能登录进后台。我们要做的只有一件事:在现成的 MyApp 业务插件里加一个「订单」模块——加一个实体、加一个服务、加一个页面,三个文件而已。保存、重启,左侧菜单里就多出一项「订单」,数据库里就多出一张 Order 表,你可以直接在里面增删改查。
之所以能这么"顺手一放就生效",是因为 MyApp/AppConfig.cs 里有一句 Config.AddModule(assembly):启动时它会扫描这个程序集里所有带框架特性的类型——[Table] 的实体自动建表、[WebApi]/[Service] 自动接好客户端与服务的通路、[Menu] 的页面自动挂上导航。你不需要回任何地方登记路由或菜单。
下面所有代码里的类型(
Database、Result、PagingCriteria、EntityBase、[Table]、[Menu]、Table/Modal/Input组件……)都由工程里现成的_Imports.razor.cs全局引用提供,你不用自己写using。
步骤一 · 加实体(也就是一张表)
新建 MyApp/Entities/Order.cs:
namespace MyApp.Entities;
/// 订单:Customer/Amount/Status 三个业务字段,其余公共字段继承自 EntityBase。
[Table("Order")]
[DisplayName("订单")]
public class Order : EntityBase
{
[Required, MaxLength(50)]
[DisplayName("客户")]
public string Customer { get; set; }
[DisplayName("金额")]
public decimal Amount { get; set; }
[DisplayName("状态")]
public OrderStatus Status { get; set; }
}
public enum OrderStatus { Draft, Submitted, Paid, Cancelled }
三件事值得留意:
- 主键不用自己写。 继承
EntityBase就自带了Id(string,存 GUID),以及一串审计字段CreateBy/CreateTime/ModifyBy/ModifyTime等——列表里那个"创建时间"列就是现成的CreateTime。这是 Known 全框架的统一约定:Id 和外键一律是varchar(50)存 GUID 的字符串,别用int、也别用Guid类型。 [Table("Order")]决定表名。 框架启动时会按实体自动建表、给缺的列补上(EnsureSchemaAsync,只做新增、不会删改你已有的列)。所以这张表你不用手写任何建表 SQL,重启跑起来它就在库里了。[Required]/[MaxLength]/[DisplayName]都是 .NET 自带的标准特性,[DisplayName]的中文会直接用作字段标题。
步骤二 · 加服务(接口 + 实现,写在同一个文件)
按框架约定,业务服务的接口用 I + 业务名 + Service 命名,并且接口和它的默认实现放在同一个 .cs 文件里:接口标 [WebApi]、实现类标 [Service]。新建 MyApp/Services/OrderService.cs:
namespace MyApp.Services;
[WebApi]
public interface IOrderService
{
/// 按条件分页查询,返回当前页数据 + 总数。
Task<PagingResult<Order>> QueryOrdersAsync(PagingCriteria criteria);
/// 按主键取单条,无数据返回 null。
Task<Order> GetOrderAsync(string id);
/// 新增或修改(Id 为空视为新增)。
Task<Result> SaveOrderAsync(Order entity);
/// 批量删除。
Task<Result> DeleteOrdersAsync(List<string> ids);
}
[Service]
class OrderService(Database db) : IOrderService
{
public Task<PagingResult<Order>> QueryOrdersAsync(PagingCriteria criteria)
=> db.QueryPageAsync<Order>(criteria);
public Task<Order> GetOrderAsync(string id)
=> db.QueryByIdAsync<Order>(id);
public async Task<Result> SaveOrderAsync(Order entity)
{
if (entity == null)
return Result.Error("订单不能为空!");
// Id 为空 → 新增(new 一个干净实体);否则先按主键取回库里带跟踪的原实体。
// 关键点:不要直接存前端传来的 entity —— 它没有 Version 与审计快照,
// 会破坏乐观锁和"按变更列 UPDATE"。取回原实体再把表单字段拷进去才对。
var model = await db.QueryByIdAsync<Order>(entity.Id);
model ??= new Order();
model.FillModel(entity); // 把业务字段拷进实体,保留 Original 供列比对
var vr = model.Validate(); // 依实体上的 [Required]/[MaxLength] 等校验,返回 Result
if (!vr.IsValid)
return vr;
// 保存包进事务:有则按变更列 UPDATE、无则 INSERT,审计列自动填;再回写新 Id 给前端
return await db.TransactionAsync("保存", async tx =>
{
await tx.SaveAsync(model);
entity.Id = model.Id;
}, entity);
}
public async Task<Result> DeleteOrdersAsync(List<string> ids)
{
if (ids == null || ids.Count == 0)
return Result.Error("请选择要删除的订单!");
// 批量删除也走事务,一条条按主键删,整体原子
return await db.TransactionAsync("删除", async tx =>
{
foreach (var id in ids)
await tx.DeleteAsync<Order>(id);
});
}
}
对照几个真实存在的 API,避免踩坑:
Database直接用主构造函数注入(class OrderService(Database db))——这是框架里服务实现的标准写法,实现类本身通常都不加public。- 分页返回的是
PagingResult<Order>(有Total、Data、IsValid、Message),不是"包一层的Result<T>"。Result在 Known 里是非泛型的,只表示一次写操作的成败:成功用Result.Success("消息"),失败用Result.Error("消息"),判断看IsValid、取文案看Message。 - 常用数据方法就这几个:分页查询
QueryPageAsync<T>(criteria)(PageIndex/PageSize/OrderBys/Parameters全塞进PagingCriteria,框架自己拼 SQL 分页排序过滤)、按主键QueryByIdAsync<T>(id)、按条件取单条QueryAsync<T>(表达式)、取列表QueryListAsync<T>(表达式)、保存SaveAsync(实体)(有则按变更列 UPDATE、无则 INSERT)、按主键删除DeleteAsync<T>(id)。 - 服务层的标准写死套路:查询就是一行
=> db.QueryPageAsync<Order>(criteria);保存固定走"QueryByIdAsync取回带跟踪的原实体 →FillModel拷字段 →Validate()校验 →TransactionAsync里SaveAsync并回写Id";删除固定走"TransactionAsync里foreachDeleteAsync<T>(id)"。写/删都包在TransactionAsync("保存"/"删除", …)里保证原子。InsertAsync(实体, newId: true)主要留给播种(SeedAsync)用,日常保存交给SaveAsync。
步骤三 · 加页面(列表 + 新增/编辑 + 删除)
新建 MyApp/Pages/Order.razor。这一页用一个 [Menu] 特性把自己挂上侧栏,用 <Table> 显示当前页数据、<Modal> 弹窗做新增和编辑:
@page "/order"
@inherits BasePage
@attribute [Menu("订单", Icon = "shopping-cart", Order = 10)]
@inject IOrderService Orders
<ConsolePage Title="订单管理" Description="你的第一个 CRUD 页面:列表、新增、编辑、删除都在这里。"
Searchable="true" @bind-SearchText="keyword" OnSearch="SearchAsync" OnSearchClear="ResetAsync"
SearchPlaceholder="搜索客户名…">
<HeaderActions>
<Button Kind="primary" Icon="plus" Text="新增订单" OnClick="OpenAdd" />
</HeaderActions>
<ChildContent>
<Table TItem="Order" Columns="columns" Items="items" Loading="loading"
Striped="true" ActionTitle="操作"
ShowPagination="true" Total="total" PageSize="pageSize"
Page="pageIndex" PageChanged="OnPageChangedAsync">
<RowActions Context="row">
<Button Kind="soft" Size="xs" Text="编辑" OnClick="() => OpenEdit(row)" />
<Popconfirm Text="@($"确定删除客户「{row.Customer}」的订单?")" OkKind="danger" OnConfirm="() => DeleteAsync(row)">
<Button Kind="outline" Size="xs" Text="删除" />
</Popconfirm>
</RowActions>
</Table>
</ChildContent>
</ConsolePage>
<Modal Title="@modalTitle" Icon="shopping-cart" Visible="editVisible" VisibleChanged="v => editVisible = v">
<ChildContent>
@if (editing != null)
{
<div class="order-form">
<div class="order-field">
<span class="order-label">客户</span>
<Input Value="@editing.Customer" ValueChanged="v => editing.Customer = v" Live="true" Placeholder="客户名称" />
</div>
<div class="order-field">
<span class="order-label">金额</span>
<Input Value="@editing.Amount.ToString()" ValueChanged="v => editing.Amount = decimal.TryParse(v, out var a) ? a : 0" Live="true" Placeholder="订单金额" />
</div>
</div>
}
</ChildContent>
<Footer>
<Button Kind="outline" Text="取消" OnClick="() => editVisible = false" />
<Button Kind="primary" Icon="check" Text="保存" Loading="saving" OnClick="SaveAsync" />
</Footer>
</Modal>
<style>
.order-form { display: flex; flex-direction: column; gap: 14px; }
.order-field { display: flex; flex-direction: column; gap: 6px; }
.order-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); }
</style>
@code {
// 列定义:Field 填的是实体的“属性名”,表格靠反射按它取值
private List<TableColumn> columns =
[
new() { Title = "客户", Field = "Customer" },
new() { Title = "金额", Field = "Amount", Align = "right", Width = "120px" },
new() { Title = "状态", Field = "Status", Width = "120px" },
new() { Title = "创建时间", Field = "CreateTime", Width = "180px" },
];
private List<Order> items = [];
private string keyword;
private int pageIndex = 1;
private int total;
private const int pageSize = 20;
private bool loading;
private bool saving;
private Order editing;
private bool editVisible;
private bool isNew;
private string modalTitle => isNew ? "新增订单" : "编辑订单";
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
loading = true;
try
{
var criteria = new PagingCriteria { PageIndex = pageIndex, PageSize = pageSize };
// 关键字过滤:参数名以 @ 开头、与实体字段对应;这里用 like 做“包含”匹配
if (!string.IsNullOrWhiteSpace(keyword))
criteria.Parameters["@Customer"] = $"%{keyword.Trim()}%";
var result = await Orders.QueryOrdersAsync(criteria);
items = result.Data ?? [];
total = result.Total;
if (!result.IsValid)
UI.Error(result.Message);
}
finally
{
loading = false;
StateHasChanged();
}
}
private async Task SearchAsync() { pageIndex = 1; await LoadAsync(); }
private async Task ResetAsync() { keyword = null; pageIndex = 1; await LoadAsync(); }
private async Task OnPageChangedAsync(int page) { pageIndex = page; await LoadAsync(); }
private void OpenAdd() { editing = new Order(); isNew = true; editVisible = true; }
private void OpenEdit(Order row)
{
// 拷一份来编辑,取消时不影响表格里的原数据
editing = new Order { Id = row.Id, Customer = row.Customer, Amount = row.Amount, Status = row.Status };
isNew = false;
editVisible = true;
}
private async Task SaveAsync()
{
if (editing == null) return;
saving = true;
try
{
var result = await Orders.SaveOrderAsync(editing);
if (result.IsValid)
{
editVisible = false;
UI.Success(result.Message);
await LoadAsync();
}
else
{
UI.Error(result.Message);
}
}
finally
{
saving = false;
}
}
private async Task DeleteAsync(Order row)
{
var result = await Orders.DeleteOrdersAsync([row.Id]);
if (result.IsValid)
{
UI.Success(result.Message);
// 删掉当前页最后一条时回退一页,避免停在空页
if (items.Count == 1 && pageIndex > 1)
pageIndex--;
await LoadAsync();
}
else
{
UI.Error(result.Message);
}
}
}
读懂这页的三把钥匙:
- 分页在服务端。
<Table>只拿到"当前页这一List(Items)",靠Total画页码、翻页时触发你写的PageChanged(OnPageChangedAsync换页码后重新LoadAsync)。所以别指望把一整张表一次性塞进Items。 Field写属性名。TableColumn没有"列 key"概念,它靠反射读你Field指定的属性(如Customer、CreateTime)来取值,名字要和实体属性对得上。UI/Local是BasePage自带的。 页面@inherits BasePage后就能直接UI.Success(...)/UI.Error(...)弹提示,无需自己注入。(顺带一提,所有用户可见中文都会经Local本地化,缺译时安全回退原文。)
步骤四 · 重启,看它自己长出来
回到项目根目录,重新运行:
cd MyApp
dotnet run --project MyAppWeb
登录后你会看到:
- 左侧菜单多了一项带购物车图标的 「订单」(就是那句
[Menu("订单", Icon = "shopping-cart", ...)]被Config.AddModule扫到的结果); - 点进去是一张空表——但数据库里已经自动建好了
Order表; - 点右上角「新增订单」填两行保存,列表立刻出现这条记录;编辑、删除、翻页、按客户名搜索都能用。
不用改 Program.cs、不用新建程序集、不用登记路由或菜单——文件放进 MyApp/,特性写好,重启即生效。而且同一份 MyApp 代码在 MyAppWasm(纯前端)、MyAppWin(桌面)里跑起来也不需要任何改动:页面注入的 IOrderService 会按运行形态自动走 HTTP 代理或本地直调,这就是 [WebApi] / [Service] 同构带来的好处。
常见坑
- 菜单/表没出现:
Config.AddModule是启动时扫描的,加完文件要重新dotnet run;热重载只更界面、不一定触发重新扫特性。 - 列表某列一直是空:
TableColumn.Field拼错,或指向了实体上没有的属性——它是按属性名反射取值的,大小写要一致。 - Id 用成了 int / Guid:框架统一用
string(varchar(50))存 GUID 作主键与外键,EntityBase.Id已经是string,别在自己的实体里重定义成int。 - 给
Result找.Data:Result是非泛型的成败对象,只用来收写操作结果;查询数据请返回PagingResult<T>(Data/Total)或直接的实体类型。
下一步
- 上面实体、服务、页面三个文件都能手写;但标准 CRUD 更省心的默认做法是不写页面代码、让运行时按一份 JSON Schema 直接渲染出来:运行时 Schema 渲染。确实要把代码拿到手自己接管时,再用 代码生成(Eject 逃生舱)
- 想看懂每个目录、每种宿主分别负责什么:项目结构导览
- 服务端分页、列排序、关键词筛选的完整玩法:表格分页与筛选
- 给
Order.Customer加必填、格式校验:表单校验 - 按 Web / 桌面 / 纯前端三种形态分别深入:三种运行形态
扫码关注公众号
扫码加入 QQ 群