大多数标准 CRUD 你不需要这一页。Known 的默认做法是让运行时按 Schema 直接把页面渲染出来、不生成任何代码——见 运行时 Schema 渲染。本页讲的是那条"逃生通道":当你想把实体、服务、页面当成普通手写代码拿到自己手里长期演进时,用
known build把同一份 Schema 出成一套可自由增删改的代码。
Known 的代码生成器把「建模」压缩成一份 JSON:你只描述有哪些实体、每个实体有哪些字段,known build 就把它渲染成一套能编译、能运行、能直接增删改查的 Known 代码——实体自动建表、服务自动注册进 DI、页面自动挂进侧边菜单,全程零手工接线。产出的每个文件都对齐框架户式(服务接口 I{Name}Service、方法名按实体取、FillModel → Validate → TransactionAsync 的保存套路、BasePage + ConsolePage + Table + Popconfirm + Modal 的页面骨架),你可以像读手写代码一样读它、改它。
先看整段流程跑起来是什么样(约 50 秒,从建模到登录后台看到数据落库):
三步跑通
前置是 .NET 10 SDK 与本仓库源码。命令行有两种调用方式,任选其一:
# 方式 A:直接用源码跑(无需安装)
dotnet run --project Known.Cli -c Release -- <命令> <参数...>
# 方式 B:装成本地 dotnet tool,命令名 known
dotnet pack Known.Cli -c Release -o ./nugpkg
dotnet tool install --add-source ./nugpkg --global Known.Cli
下面统一写作 known ...。
1. 生成起步脚手架
known new OrderDemo
产出 OrderDemo/ 目录与 OrderDemo.slnx,内含四个工程:业务插件 OrderDemo/(页面、服务、数据都写在这里,也是本次出码落点)、纯前端宿主 OrderDemoWasm/、全栈一体宿主 OrderDemoWeb/(推荐先用)、桌面宿主 OrderDemoWin/。默认 SQLite,换库用 --db sqlserver|mysql|postgres|oracle|dm|access。
2. 按 Schema 出码
先写一份元数据 samples/order.schema.json:
{
"name": "OrderDemo",
"entities": [
{
"name": "Order",
"displayName": "订单",
"table": "biz_Order",
"fields": [
{ "name": "OrderNo", "label": "单号", "type": "string", "required": true, "maxLength": 50 },
{ "name": "Customer", "label": "客户", "type": "string", "maxLength": 50 },
{ "name": "Quantity", "label": "数量", "type": "int", "required": true },
{ "name": "Amount", "label": "金额", "type": "decimal","required": true },
{ "name": "Status", "label": "状态", "type": "string", "maxLength": 20 }
]
},
{
"name": "Customer",
"displayName": "客户",
"table": "biz_Customer",
"fields": [
{ "name": "Name", "label": "姓名", "type": "string", "required": true, "maxLength": 50 },
{ "name": "Phone", "label": "电话", "type": "string", "maxLength": 30 },
{ "name": "Vip", "label": "会员", "type": "bool" }
]
}
]
}
把它生成进业务插件工程:
known build --schema samples/order.schema.json --project OrderDemo/OrderDemo --force
known build 为每个实体产出「四件套」,并首次创建可手写的扩展点:
| 文件 | 状态 | 作用 |
|---|---|---|
Entities/Order.cs |
Generated | [Table] POCO,继承 EntityBase(partial) |
Services/IOrderService.cs |
Generated | [WebApi] 契约,前后端同构消费;方法名按实体取 |
Services/OrderService.cs |
Generated | [Service] 实现,直连 Database,走标准事务套路 |
Pages/OrderList.razor |
Generated | [Menu] 挂载、@inherits BasePage 的分页 + 增删改页 |
Entities/OrderCustom.cs |
Seeded | 用户扩展分部,首次创建后不再被覆盖 |
Services/OrderServiceCustom.cs |
Seeded | 服务扩展分部,首次创建后不再被覆盖 |
3. 构建并运行
cd OrderDemo
dotnet build OrderDemo.slnx -c Release # 0 警告 0 错误
dotnet run --project OrderDemoWeb # 启动即自动建表并挂载菜单
浏览器打开后,用演示账号 admin / admin123 登录,侧边菜单会自动出现 订单 与 客户 两项。
出码产出的代码长什么样
关键在于:生成的代码就是你会手写的那套,没有私有基类、没有魔法运行时。对照看更放心。
实体(一张表)
namespace OrderDemo.Entities;
/// 订单。
[Table("biz_Order")]
[DisplayName("订单")]
public partial class Order : EntityBase
{
[Required] [MaxLength(50)] [DisplayName("单号")]
public string OrderNo { get; set; }
[MaxLength(50)] [DisplayName("客户")]
public string Customer { get; set; }
[Required] [DisplayName("数量")]
public int Quantity { get; set; }
[Required] [DisplayName("金额")]
public decimal Amount { get; set; }
[MaxLength(20)] [DisplayName("状态")]
public string Status { get; set; }
}
主键与审计列(Id / CreateBy / CreateTime / Version …)由 EntityBase 提供,无需自己写。[Table("biz_Order")] 决定表名——启动时 EnsureSchemaAsync 按实体自动建表、给缺的列补上(非破坏式,只 CREATE TABLE / ADD COLUMN,不删改已有列)。
服务契约([WebApi])
[WebApi]
public interface IOrderService
{
Task<PagingResult<Order>> QueryOrdersAsync(PagingCriteria criteria);
Task<Order> GetOrderAsync(string id);
Task<Result> SaveOrderAsync(Order entity);
Task<Result> DeleteOrdersAsync(List<string> ids);
}
接口标 [WebApi],就同时可被前端 ServiceProxy 与后端 WebApiInvoker 同构消费。方法名按实体取(QueryOrdersAsync / SaveOrderAsync …),写操作统一返回非泛型 Result(看 IsValid / Message)。
服务实现([Service],标准事务套路)
[Service]
public partial class OrderService(Database db) : IOrderService
{
public Task<PagingResult<Order>> QueryOrdersAsync(PagingCriteria criteria)
=> db.QueryPageAsync<Order>(criteria ?? new PagingCriteria { OrderBys = ["CreateTime desc"] });
public Task<Order> GetOrderAsync(string id)
=> db.QueryByIdAsync<Order>(id);
public async Task<Result> SaveOrderAsync(Order entity)
{
if (entity == null)
return Result.Error("订单不能为空!");
// Id 为空 → 新增;否则先取回库里带跟踪的原实体,再拷入业务字段,保留 Original 供按变更列 UPDATE。
var model = string.IsNullOrWhiteSpace(entity.Id)
? null
: await db.QueryByIdAsync<Order>(entity.Id);
model ??= new Order();
model.FillModel(entity);
var vr = model.Validate();
if (!vr.IsValid)
return vr;
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);
});
}
}
保存刻意不直接存前端传来的实体——它没有 Version 与审计快照,直接存会破坏乐观锁和"按变更列 UPDATE"。正确做法是取回带跟踪的原实体、FillModel 拷字段、Validate() 校验,再包进 TransactionAsync。这套正是框架内置服务(如语言管理)一直在用的写法。
页面(BasePage + ConsolePage + Table + Popconfirm + Modal)
@page "/order"
@inherits BasePage
@attribute [Menu("订单", Icon = "list", Order = 10)]
@inject IOrderService Orders
<ConsolePage Title="订单管理" Description="由 Known 代码生成产出的标准 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="确定删除这条订单吗?" OkKind="danger" OnConfirm="() => DeleteAsync(row)">
<Button Kind="outline" Size="xs" Text="删除" />
</Popconfirm>
</RowActions>
</Table>
</ChildContent>
</ConsolePage>
<Modal Title="@modalTitle" Icon="edit" Visible="editVisible" VisibleChanged="v => editVisible = v">
<ChildContent>
@if (editing != null)
{
<div class="gen-form">
<div class="gen-field">
<span class="gen-label">单号 *</span>
<Input Value="@editing.OrderNo" ValueChanged="v => editing.OrderNo = v" Live="true" Placeholder="请输入单号" />
</div>
<div class="gen-field">
<span class="gen-label">数量 *</span>
<Number Value="@((double)editing.Quantity)" ValueChanged="v => editing.Quantity = (int)v" />
</div>
@* …其余字段按类型映射… *@
</div>
}
</ChildContent>
<Footer>
<Button Kind="outline" Text="取消" OnClick="() => editVisible = false" />
<Button Kind="primary" Icon="check" Text="保存" Loading="saving" OnClick="SaveAsync" />
</Footer>
</Modal>
@code 里是标准的「服务端分页 + 关键字过滤 + 弹窗保存」:LoadAsync 组 PagingCriteria(PageIndex/PageSize/OrderBys,关键字用 Parameters["@OrderNo"] = "%kw%" 走 like),SaveAsync 调 Orders.SaveOrderAsync(editing) 后按 result.IsValid 弹 UI.Success / UI.Error。UI 与 Local 由 BasePage 自带,无需自己注入。
字段类型 → 表单控件映射
Schema type |
C# 类型 | 生成控件 |
|---|---|---|
string |
string |
Input(带 MaxLength) |
int / decimal |
int / decimal |
Number 步进器(显式 double 转换绑定) |
bool |
bool |
Switch |
datetime |
DateTime |
DateInput |
字段选项:name(必填,合法 C# 标识符)、label(列头与表单标题)、type(别名宽松匹配)、required、maxLength、hideInList(列表页隐藏该列,仍进表单)。JSON 允许 // 注释与尾逗号;非法标识符会直接报错而非生成坏代码。
为什么"放进去就生效"
脚手架的 AppConfig.cs 里有一句 Config.AddModule(assembly):启动时它扫描本程序集所有带框架特性的类型——[Table] 自动进实体表并建表、[WebApi] / [Service] 自动接好客户端与服务的通路、[Menu] 的页面自动挂上导航。所以你不需要回任何地方登记路由或菜单。同一份插件代码在 OrderDemoWasm(纯前端)、OrderDemoWin(桌面)里跑起来也不用改:页面注入的 IOrderService 会按运行形态自动走 HTTP 代理或本地直调。
Generated 与 Seeded 的边界
Generated 文件带 auto-generated 头,Schema 变更后重跑 known build 会按内容差异覆盖(--force 即便一致也强写)。Seeded(*Custom.cs)是你的手写落点,代码生成永不覆盖——计算属性、派生查询、集成钩子都往这里放。这条边界为「同一份 Schema 反复出码到已有项目」预留了用户代码与生成代码互不踩踏的空间。
需要意识到:一旦你跑这条命令、把产物纳入仓库并开始手改,就等于从"运行时按 Schema 渲染"里 eject 了出来——此后这个实体的页面与服务代码归你维护,改模型不再只是编辑一份 JSON。所以只在确实要接管代码时才用它;标准场景留在运行时渲染更省心。
常见坑
- 菜单/表没出现:
Config.AddModule是启动时扫描的,加完文件要重新dotnet run;热重载只更界面、不一定触发重扫特性。 --project定位失败:省略时命令会在当前目录及一层子目录里按AppConfig.cs特征找插件工程;命中多个就用--project指定其一。- 列一直空:
TableColumn.Field按属性名反射取值,大小写要和实体属性一致(生成代码已对齐,手改时留意)。 - init / pull / merge / apply 仍是占位:目前只有
new与build落地,联网同步类命令尚未实现。
相关
- 标准 CRUD 的默认做法、不生成任何页面代码:运行时 Schema 渲染
- 想先手写一个 CRUD 页面、理解每个文件为何这么写:写第一个 CRUD 页面
- 看懂每个目录、每种宿主分别负责什么:项目结构导览
- 服务端分页、列排序、关键词筛选的完整玩法:表格分页与筛选
- 给字段加必填 / 格式校验:表单校验
扫码关注公众号
扫码加入 QQ 群