BlazorForms 低代码开源框架。第二部分:CrmLight 项目
利用 C# 的类型安全优势,基于流程、表单和规则开发应用程序
本系列中所有 BlazorForms 文章
- BlazorForms 低代码开源框架。第一部分:简介和种子项目
- BlazorForms 低代码开源框架。第二部分:CrmLight 项目
- BlazorForms 低代码开源框架。第三部分:CrmLight 潜在客户看板
我们在这里部署了一个可工作的解决方案
请注意,它没有数据库,所有数据都保存在内存中,供所有用户共享,并且在一段时间不活动后,容器会关闭,所有数据都会丢失。
我们将其部署在具有最低 CPU 和内存要求的 Linux Docker 容器中,但运行速度非常快。
引言
本文继续介绍 PRO CODERS PTY LTD 开发的 BlazorForms 框架的系列文章,该框架已作为开源项目以 MIT 许可证共享。
在前一篇文章“BlazorForms 低代码开源框架介绍和种子项目”中,我介绍了这个框架,旨在简化 Blazor UI 开发,并允许创建简单且可维护的 C# 代码。
该框架的主要思想是提供一种将逻辑与 UI 分离的模式,并强制开发人员将逻辑保存在 Flows 和 Rules 中。Forms 仅包含 Model 和 UI 控件之间的绑定。
除非您需要高度自定义,否则无需直接操作 UI。这意味着 Flows 和 Rules 中的逻辑不依赖于 UI,并且 100% 可单元测试。
为了最大限度地减少初始工作量,我们创建了以下种子项目,这些项目在 GitHub 上可用,我将 CrmLight
项目版本 0.7.0 复制到了我的博客存储库中。
从 GitHub 下载此博客文章代码
GitHub 上的 BlazorForms 项目
CrmLight 项目
创建 CrmLightDemoApp
项目是为了演示如何实现比基本种子项目中介绍的更复杂的场景。CrmLight Flows 使用 Repositories,这些 Repositories 具有完整的 CRUD 操作实现以及一些扩展。
数据和 Repositories
应用程序处理多个实体和关系
Company
人员
PersonCompanyLink
PersonCompanyLinkType
它们之间的关系可以在图表中显示
为了实现数据访问,我们使用了经典的 Repository 模式,这意味着每个实体都有一个专门的 Repository。但是,没有必要多次实现相同的 CRUD 操作,所以我们使用了泛型。
如果您查看解决方案资源管理器,您将看到简化的 Onion 架构文件夹结构
其中 IRepository.cs 为所有 Repositories 定义了通用接口
namespace CrmLightDemoApp.Onion.Domain.Repositories
{
public interface IRepository<T>
where T : class
{
Task<List<T>> GetAllAsync();
IQueryable<T> GetAllQuery();
Task<List<T>> RunQueryAsync(IQueryable<T> query);
Task<T> GetByIdAsync(int id);
Task<int> CreateAsync(T data);
Task UpdateAsync(T data);
Task DeleteAsync(int id);
Task SoftDeleteAsync(int id);
Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids);
}
}
而 LocalCacheRepository.cs 实现该接口
using BlazorForms.Shared;
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
namespace CrmLightDemoApp.Onion.Infrastructure
{
// this is repository emulator that stores all data in memory
// it stores and retrieves object copies, like a real database
public class LocalCacheRepository<T> : IRepository<T>
where T : class, IEntity
{
protected int _id = 0;
protected readonly List<T> _localCache = new List<T>();
public async Task<int> CreateAsync(T data)
{
_id++;
data.Id = _id;
_localCache.Add(data.GetCopy());
return _id;
}
public async Task DeleteAsync(int id)
{
_localCache.Remove(_localCache.Single(x => x.Id == id));
}
public async Task<T> GetByIdAsync(int id)
{
return _localCache.Single(x => x.Id == id).GetCopy();
}
public async Task<List<T>> GetAllAsync()
{
return _localCache.Where
(x => !x.Deleted).Select(x => x.GetCopy()).ToList();
}
public async Task UpdateAsync(T data)
{
await DeleteAsync(data.Id);
_localCache.Add(data.GetCopy());
}
public async Task SoftDeleteAsync(int id)
{
_localCache.Single(x => x.Id == id).Deleted = true;
}
public async Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids)
{
return _localCache.Where(x => ids.Contains(x.Id)).Select
(x => x.GetCopy()).ToList();
}
public IQueryable<T> GetAllQuery()
{
return _localCache.AsQueryable();
}
public async Task<List<T>> RunQueryAsync(IQueryable<T> query)
{
return query.ToList();
}
}
}
如您所见,我们在项目中不使用任何数据库,而是将所有数据保存在内存模拟器中。这应该简化演示的运行体验,同时确保开发的 Repositories 可用于单元测试。
为了简化代码,我们使用了 BlazorForms.Shared
中的 GetCopy
扩展方法,它使用反射创建一个新实例并将所有 public
属性复制过去。
专门的 Repositories 会预填充一些数据,您可以在运行应用程序时看到这些数据。
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
namespace CrmLightDemoApp.Onion.Infrastructure
{
public class CompanyRepository : LocalCacheRepository<Company>, ICompanyRepository
{
public CompanyRepository()
{
// pre fill some data
_localCache.Add(new Company { Id = 1, Name = "Mizeratti Pty Ltd",
RegistrationNumber = "99899632221",
EstablishedDate = new DateTime(1908, 1, 17) });
_localCache.Add(new Company { Id = 2, Name = "Alpha Pajero",
RegistrationNumber = "89963222172",
EstablishedDate = new DateTime(1956, 5, 14) });
_localCache.Add(new Company { Id = 3, Name = "Zeppelin Ltd Inc",
RegistrationNumber = "63222172899",
EstablishedDate = new DateTime(2019, 11, 4) });
_localCache.Add(new Company { Id = 4, Name = "Perpetuum Automotives Inc",
RegistrationNumber = "22217289963",
EstablishedDate = new DateTime(2010, 1, 7) });
_id = 10;
}
}
}
PersonCompanyRepository.cs 包含连接多个实体的方法,并返回 PersonCompanyLinkDetails
组合对象。
public async Task<List<PersonCompanyLinkDetails>> GetByCompanyIdAsync(int companyId)
{
var list = _localCache.Where(x => !x.Deleted &&
x.CompanyId == companyId).Select(x =>
{
var item = new PersonCompanyLinkDetails();
x.ReflectionCopyTo(item);
return item;
}).ToList();
var company = await _companyRepository.GetByIdAsync(companyId);
var personIds = list.Select(x => x.PersonId).Distinct().ToList();
var persons = (await _personRepository.GetListByIdsAsync
(personIds)).ToDictionary(x => x.Id, x => x);
var linkIds = list.Select(x => x.LinkTypeId).Distinct().ToList();
var links = (await _personCompanyLinkTypeRepository.
GetListByIdsAsync(linkIds)).ToDictionary(x => x.Id, x => x);
foreach (var item in list)
{
item.LinkTypeName = links[item.LinkTypeId].Name;
item.PersonFullName = $"{persons[item.PersonId].FirstName}
{persons[item.PersonId].LastName}";
item.PersonFirstName = persons[item.PersonId].FirstName;
item.PersonLastName = persons[item.PersonId].LastName;
item.CompanyName = company.Name;
}
return list;
}
业务逻辑
Services 文件夹包含 BlazorForms 相关的代码——应用程序的业务逻辑。
Flow Model
在开始查看 Flows 之前,我想提一下,我们不使用领域实体作为 Model,而是使用业务 Model 类,这些类可能与领域实体具有相同的属性。这样做,我们可以用对 Flow 处理逻辑有用的额外属性来扩展 Flow Model。例如,CompanyModel
继承了 Company 实体的所有属性,同时还具有将在 Flow 逻辑中使用的特殊属性。
public class CompanyModel : Company, IFlowModel
{
public virtual List<PersonCompanyLinkDetailsModel>
PersonCompanyLinks { get; set; } = new List<PersonCompanyLinkDetailsModel>();
public virtual List<PersonCompanyLinkDetailsModel>
PersonCompanyLinksDeleted { get; set; } = new List<PersonCompanyLinkDetailsModel>();
public virtual List<PersonCompanyLinkType> AllLinkTypes { get; set; }
public virtual List<PersonModel> AllPersons { get; set; }
}
List Flow
List Flow 是一个简化的 Flow,用于在 UI 表格形式中显示记录列表。它没有 Define body,但它有一个 LoadDataAsync
方法来检索记录数据。
public class CompanyListFlow : ListFlowBase<CompanyListModel, FormCompanyList>
{
private readonly ICompanyRepository _companyRepository;
public CompanyListFlow(ICompanyRepository companyRepository)
{
_companyRepository = companyRepository;
}
public override async Task<CompanyListModel>
LoadDataAsync(QueryOptions queryOptions)
{
var q = _companyRepository.GetAllQuery();
if (!string.IsNullOrWhiteSpace(queryOptions.SearchString))
{
q = q.Where(x => x.Name.Contains
(queryOptions.SearchString, StringComparison.OrdinalIgnoreCase)
|| (x.RegistrationNumber != null &&
x.RegistrationNumber.Contains(queryOptions.SearchString,
StringComparison.OrdinalIgnoreCase)) );
}
if (queryOptions.AllowSort && !string.IsNullOrWhiteSpace
(queryOptions.SortColumn) && queryOptions.SortDirection != SortDirection.None)
{
q = q.QueryOrderByDirection(queryOptions.SortDirection,
queryOptions.SortColumn);
}
var list = (await _companyRepository.RunQueryAsync(q)).Select(x =>
{
var item = new CompanyModel();
x.ReflectionCopyTo(item);
return item;
}).ToList();
var result = new CompanyListModel { Data = list };
return result;
}
}
如您所见,CompanyListFlow
通过依赖注入在构造函数中接收 ICompanyRepository
,并使用它来检索数据。
QueryOptions
参数可能包含搜索模式和/或排序信息,我们使用这些信息来组合查询,添加 Where
和 OrderBy
子句——这要归功于我们 Repository 的灵活性,它具有 GetAllQuery
和 RunQueryAsync
方法。
运行查询后,我们遍历返回的记录,并使用扩展方法 ReflectionCopyTo
将返回的 Company
实体的所有属性复制到 CompanyModel
业务对象(继承自 Company
)(顺便说一句,您也可以为此使用 AutoMapper
,但我个人认为它的语法过于复杂)。
List Form
定义 Company
UI 表的最后一部分是 List Form,它定义了列和导航。
public class FormCompanyList : FormListBase<CompanyListModel>
{
protected override void Define(FormListBuilder<CompanyListModel> builder)
{
builder.List(p => p.Data, e =>
{
e.DisplayName = "Companies";
e.Property(p => p.Id).IsPrimaryKey();
e.Property(p => p.Name);
e.Property(p => p.RegistrationNumber).Label("Reg. No.");
e.Property(p => p.EstablishedDate).Label("Established date").
Format("dd/MM/yyyy");
e.ContextButton("Details", "company-edit/{0}");
e.NavigationButton("Add", "company-edit/0");
});
}
}
IsPrimaryKey()
标记了一个包含记录主键的列,该主键将作为参数提供给 ContextButton
导航链接格式字符串“company-edit/{0}
”。
我们还为 DateTime
列提供了列标签和日期格式。
为了渲染表单,我们在 Pages 文件夹的 CompanyList.razor
中添加了 FlowListForm
控件。
@page "/company-list"
<FlowListForm FlowType="@typeof(CrmLightDemoApp.Onion.
Services.Flow.CompanyListFlow).FullName" Options="GlobalSettings.ListFormOptions" />
@code {
}
您可以运行应用程序并搜索和排序公司。
如果您单击行,您将被导航到 Company 编辑页面,并将记录主键作为参数提供,但如果您单击 **Add** 按钮,Company 编辑页面将收到 0 作为主键参数,这意味着——添加新记录。
CompanyEdit.razor
接受参数 Pk
并将其提供给 FlowEditForm
。
@page "/company-edit/{pk}"
<FlowEditForm FlowName="@typeof(CrmLightDemoApp.Onion.Services.
Flow.CompanyEditFlow).FullName" Pk="@Pk"
Options="GlobalSettings.EditFormOptions"
NavigationSuccess="/company-list" />
@code {
[Parameter]
public string Pk { get; set; }
}
Edit Flow
CompanyEditFlow
类定义了两种主要情况——对于非零 ItemKey
(提供的 Pk
),应执行 LoadData
方法,并向用户显示 FormCompanyView
。FormCompanyView
具有 **Delete** 按钮,如果按下该按钮,则执行 DeleteData
方法。
第二种情况——零 ItemKey
或在 FormCompanyView
上按了 **Edit** 按钮——在这种情况下,将执行 LoadRelatedData
方法,并向用户显示 FormCompanyEdit
。
public override void Define()
{
this
.If(() => _flowContext.Params.ItemKeyAboveZero)
.Begin(LoadData)
.NextForm(typeof(FormCompanyView))
.EndIf()
.If(() => _flowContext.ExecutionResult.FormLastAction ==
ModelBinding.DeleteButtonBinding)
.Next(DeleteData)
.Else()
.If(() => _flowContext.ExecutionResult.FormLastAction ==
ModelBinding.SubmitButtonBinding || !_flowContext.Params.ItemKeyAboveZero)
.Next(LoadRelatedData)
.NextForm(typeof(FormCompanyEdit))
.Next(SaveData)
.EndIf()
.EndIf()
.End();
}
LoadData
方法使用 Company 详细信息(包括 PersonCompanyLinks
——Company
和 Person
实体之间的引用)填充 Flow Model。
public async Task LoadData()
{
if (_flowContext.Params.ItemKeyAboveZero)
{
var item = await _companyRepository.GetByIdAsync(_flowContext.Params.ItemKey);
// item and Model have different types - we use reflection
// to copy similar properties
item.ReflectionCopyTo(Model);
Model.PersonCompanyLinks =
(await _personCompanyRepository.GetByCompanyIdAsync(Model.Id))
.Select(x =>
{
var item = new PersonCompanyLinkDetailsModel();
x.ReflectionCopyTo(item);
return item;
}).ToList();
}
}
DeleteData
方法仅通过 Repository 方法 SoftDeleteAsync
删除 Company
,该方法将实体的 Deleted
标志更改为 true
。
LoadRelatedData
方法填充 AllLinkTypes
和 AllPersons
集合,这些集合将用于 Dropdown
和 DropdownSearch
控件。
如果用户按下了 FormCompanyEdit
上的 **Submit** 按钮,则执行 SaveData
方法。该方法会更新 ID 大于零的 Company
记录,否则会在 ID 为零时插入(添加新 Company
的情况)。此方法还会遍历 PersonCompanyLinksDeleted
和 PersonCompanyLinks
集合,删除用户删除的记录,并插入和更新用户添加和更改的记录。
public async Task SaveData()
{
if (_flowContext.Params.ItemKeyAboveZero)
{
await _companyRepository.UpdateAsync(Model);
}
else
{
Model.Id = await _companyRepository.CreateAsync(Model);
}
foreach (var item in Model.PersonCompanyLinksDeleted)
{
if (item.Id != 0)
{
await _personCompanyRepository.SoftDeleteAsync(item.Id);
}
}
foreach (var item in Model.PersonCompanyLinks)
{
if (item.Id == 0)
{
item.CompanyId = Model.Id;
await _personCompanyRepository.CreateAsync(item);
}
else if (item.Changed)
{
await _personCompanyRepository.UpdateAsync(item);
}
}
}
编辑表单
FormCompanyView
定义了 Company
的只读表示,它以表格形式显示所有 Company
属性和 PersonCompanyLinks
。我还为 **Delete** 按钮添加了确认消息。
public class FormCompanyView : FormEditBase<CompanyModel>
{
protected override void Define(FormEntityTypeBuilder<CompanyModel> f)
{
f.DisplayName = "Company View";
f.Property(p => p.Name).Label("Name").IsReadOnly();
f.Property(p => p.RegistrationNumber).Label("Reg. No.").IsReadOnly();
f.Property(p => p.EstablishedDate).Label("Established date").IsReadOnly();
f.Table(p => p.PersonCompanyLinks, e =>
{
e.DisplayName = "Associations";
e.Property(p => p.LinkTypeName).Label("Type");
e.Property(p => p.PersonFullName).Label("Person");
});
f.Button(ButtonActionTypes.Close, "Close");
f.Button(ButtonActionTypes.Delete, "Delete")
.Confirm(ConfirmType.Continue, "Delete this Company?", ConfirmButtons.YesNo);
f.Button(ButtonActionTypes.Submit, "Edit");
}
}
FormCompanyEdit
包含用于编辑 Company
属性的输入控件,以及用于编辑 PersonCompanyLinks
的控件,这些控件在 Repeater 控件中定义,该控件呈现一个可编辑的网格,其中可以添加、更改或删除记录。
public class FormCompanyEdit : FormEditBase<CompanyModel>
{
protected override void Define(FormEntityTypeBuilder<CompanyModel> f)
{
f.DisplayName = "Company Edit";
f.Confirm(ConfirmType.ChangesWillBeLost,
"If you leave before saving, your changes will be lost.",
ConfirmButtons.OkCancel);
f.Property(p => p.Name).Label("Name").IsRequired();
f.Property(p => p.RegistrationNumber).Label("Reg. No.").IsRequired();
f.Property(p => p.EstablishedDate).Label("Established date").IsRequired();
f.Repeater(p => p.PersonCompanyLinks, e =>
{
e.DisplayName = "Associations";
e.Property(p => p.Id).IsReadOnly().Rule(typeof
(FormCompanyEdit_ItemDeletingRule), FormRuleTriggers.ItemDeleting);
e.PropertyRoot(p => p.LinkTypeId).Dropdown
(p => p.AllLinkTypes, m => m.Id,
m => m.Name).IsRequired().Label("Type")
.Rule(typeof(FormCompanyEdit_ItemChangedRule),
FormRuleTriggers.ItemChanged);
e.PropertyRoot(p => p.PersonId).DropdownSearch
(e => e.AllPersons, m => m.Id, m =>
m.FullName).IsRequired().Label("Person")
.Rule(typeof(FormCompanyEdit_ItemChangedRule),
FormRuleTriggers.ItemChanged);
}).Confirm(ConfirmType.DeleteItem, "Delete this association?",
ConfirmButtons.YesNo);
f.Button(ButtonActionTypes.Cancel, "Cancel");
f.Button(ButtonActionTypes.Submit, "Save");
}
}
为不保存数据而离开表单定义了确认消息;为在 Repeater 中删除 PersonCompanyLinks
记录定义了另一个消息。
PropertyRoot()
函数与 Property()
相同,但可以在 Repeater
中使用,当您需要引用根 Model 类中的集合时。
表单还有两个 Rules。
FormCompanyEdit_ItemDeletingRule
在用户删除 Repeater
记录时触发,此 Rule
将已删除的记录存储在一个特殊的 Model
集合中,我们将在 SaveData
方法中使用它。
public class FormCompanyEdit_ItemDeletingRule : FlowRuleBase<CompanyModel>
{
public override string RuleCode => "CMP-1";
public override void Execute(CompanyModel model)
{
// preserve all deleted items
model.PersonCompanyLinksDeleted.Add
(model.PersonCompanyLinks[RunParams.RowIndex]);
}
}
FormCompanyEdit_ItemChangedRule
在用户在 Repeater
中更改 LinkType
或 Person
属性时触发,此规则会更新 Changed
标志。这也会在 Flow 的 SaveData
方法中使用。
public class FormCompanyEdit_ItemChangedRule : FlowRuleBase<CompanyModel>
{
public override string RuleCode => "CMP-2";
public override void Execute(CompanyModel model)
{
model.PersonCompanyLinks[RunParams.RowIndex].Changed = true;
}
}
当用户单击 **Edit** 按钮时,他们将看到
我应该再次提到,Forms 不包含任何业务逻辑,它们只定义 Model 如何绑定到控件和 Rules。Forms 也不能加载或保存任何数据。当您需要保存/加载数据时,您应该使用 Flows。当您需要进行复杂的验证、标记已更改的记录或重新加载某些数据时,您应该使用 Rules。遵循这些建议,您的代码将易于理解和维护,没有任何问题。
Person
和 PersonCompanyType
的 Flows 和 Forms 遵循相同的方法,您可以在从 GitHub 下载的解决方案中看到它们的代码。
添加 SQL 数据库
为了完成这篇文章,我想用 SqlRepository
替换 LocalCacheRepository
,它将数据存储在 SQL 数据库中。最终解决方案已添加到 CrmLightDemoApp.Sql\ 文件夹中。
为了开始使用 SQL,我添加了 Microsoft.EntityFrameworkCore.Tools
和 Microsoft.EntityFrameworkCore.SqlServer
包,然后将 CrmContext.cs 添加到 Onion\Infrastructure\ 文件夹中。
using CrmLightDemoApp.Onion.Domain;
using Microsoft.EntityFrameworkCore;
namespace CrmLightDemoApp.Onion.Infrastructure
{
public class CrmContext : DbContext
{
public DbSet<Company> Company { get; set; }
public DbSet<Person> Person { get; set; }
public DbSet<PersonCompanyLink> PersonCompanyLink { get; set; }
public DbSet<PersonCompanyLinkType> PersonCompanyLinkType { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlServer("Server =
(localdb)\\mssqllocaldb; Database=CrmLightDb1;
Trusted_Connection=True;MultipleActiveResultSets=true");
}
}
这是 SQL Express mssqllocaldb
,它应该可以在任何 Windows 机器上运行,但如果您愿意,可以在连接字符串中指定您自己的实际 SQL Server 数据库。
然后,我需要修改我的 Company
、Person
和 PersonCompanyLinkType
实体,以包含对 PersonCompanyLink
表的引用,这对于 EF Model-First 方法是必需的,在该方法中,数据库架构是从您的实体生成的。
然后,我在程序包管理器控制台中创建了一个 EF 迁移,运行了命令
Add-Migration InitialCreate
接下来,如果您想运行该应用程序,则必须在程序包管理器控制台中运行以下命令。
Update-Database
此命令将在目标数据库中创建表和关系。
更改 Repository
创建了 SqlRepository.cs 代替 LocalCacheRepository
。它使用 CrmContext
在 SQL 数据库中执行查询。
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
using Microsoft.EntityFrameworkCore;
namespace CrmLightDemoApp.Onion.Infrastructure
{
public class SqlRepository<T> : IRepository<T>
where T : class, IEntity, new()
{
public async Task<int> CreateAsync(T data)
{
using var db = new CrmContext();
db.Set<T>().Add(data);
await db.SaveChangesAsync();
return data.Id;
}
public async Task DeleteAsync(int id)
{
using var db = new CrmContext();
var table = db.Set<T>();
var entity = new T { Id = id };
table.Attach(entity);
table.Remove(entity);
await db.SaveChangesAsync();
}
public async Task<T> GetByIdAsync(int id)
{
using var db = new CrmContext();
return await db.Set<T>().SingleAsync(x => x.Id == id);
}
public async Task<List<T>> GetAllAsync()
{
using var db = new CrmContext();
return await db.Set<T>().Where(x => !x.Deleted).ToListAsync();
}
public async Task UpdateAsync(T data)
{
using var db = new CrmContext();
db.Set<T>().Update(data);
await db.SaveChangesAsync();
}
public async Task SoftDeleteAsync(int id)
{
using var db = new CrmContext();
var record = await db.Set<T>().SingleAsync(x => x.Id == id);
record.Deleted = true;
await db.SaveChangesAsync();
}
public async Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids)
{
using var db = new CrmContext();
return await db.Set<T>().Where(x => ids.Contains(x.Id)).ToListAsync();
}
public ContextQuery<T> GetContextQuery()
{
var db = new CrmContext();
return new ContextQuery<T>(db, db.Set<T>().Where(x => !x.Deleted));
}
public async Task<List<T>> RunContextQueryAsync(ContextQuery<T> query)
{
return await query.Query.ToListAsync();
}
}
}
不幸的是,我需要更改 IRepository<>
接口,因为我最初的设计不允许在 Repository 外部组装搜索和排序查询,现在使用了 GetContextQuery
和 RunContextQueryAsync
方法,它们使用可处置类 ContextQuery<>
。
现在,如果您运行应用程序,初始数据库将为空,您需要使用 UI 填充 Person
、Company
和 PersonCompanyLink
表。
摘要
在本文中,我介绍了 BlazorForms
开源框架的 CrmLight
种子项目。它展示了一种将数据库 Repositories、应用程序业务逻辑和用户界面连接在一起的直接方法。最后,我将解决方案从使用内存模拟 Repositories 转换为真实的 SQL 数据库,以便最终解决方案可以成为一些实际项目的良好起点。
在未来的文章中,我将重点介绍更复杂的场景以及其他类型的 Flows,并介绍更多可以使用 BlazorForms
实现的用例。
您可以在我的 GitHub 上找到完整的解决方案代码,文件夹为 Story-09-BlazorForms-CrmLight。
感谢您的阅读,请记住,如果您在实施过程中需要任何帮助,可以随时与我们联系。
https://procoders.com.au/contact-us/
历史
- 2023 年 1 月 12 日:初始版本