Todo 模块完整实战
从零构建一个带数据库、ORM、权限、API、Action、Trigger、客户端控件和部署流程的完整模块。
从零开发 Todo List 模块:FormPlatform 二次开发实战
> 文档导航:适用于任意新模块的正式双语流程见
> 模块开发 Step-by-Step / English。
> 本文继续作为 Todo 示例的完整源码级实战。
1. 本教程的目标
本教程面向第一次接触 FormPlatform 二次开发的初级开发者。完成全部步骤后,
你将得到一个可安装的 Todo List 模块,它包含:
- PostgreSQL 数据表 `todo_items`;
- FormPlatform Data Model 配置;
- 使用 FormPlatform ORM 的查询、新增、修改和删除;
- 每个用户只能访问自己的 Todo 数据;
- 客户模块自己的 `appsettings.todo.json`;
- 一个 SDK 中的 `IFormPlatformSdkModule` 服务器入口;
- 一组 `/api/todo/items` Minimal API;
- 一个 Form Server Action;
- 一个 Before Trigger;
- 一个出现在 Form Designer 控件区的 Todo List 控件;
- 控件自己的 General 属性编辑器和 Events;
- 一个 `/todo` Vue Router 页面;
- 客户 Action API 和 Form Runtime API。
本教程中的第三方模块是“可信模块”。它和 FormPlatform 在同一个 .NET 进程中
运行,可以使用应用能够使用的服务和数据库。FormPlatform 不向模块开发者提供
平台源码,但模块代码必须由部署管理员审核。
2. 开始前需要准备什么
请确认已有:
- 一个能够正常运行的 FormPlatform 发布目录;
```
dotnet publish -f net10.0 -c Release -o ./bin/release/FormPlatform-sdk
```
- .NET 10 SDK;
- PostgreSQL 数据库;
- 一个可以登录 FormPlatform 的系统用户;
- 代码编辑器,例如 Visual Studio、Rider 或 VS Code;
- PowerShell。
本教程假设 FormPlatform 发布目录为:
C:\FormPlatform
客户模块源码目录为:
C:\FormPlatformDevelopment\TodoModule
如果你的目录不同,请替换后续命令中的路径。
3. 先了解最终目录结构
开发目录:
C:\FormPlatformDevelopment\TodoModule\
TodoModule.csproj
TodoModule.cs
TodoService.cs
TodoActions.cs
module.json
appsettings.todo.json
client\
index.js
todo.css
部署后:
C:\FormPlatform\
FormPlatform.dll
FormPlatform.Sdk.dll
FormPlatform.Sdk.xml
FormPlatform.Extension.Abstractions.dll
appsettings.json
Modules\
Todo\
module.json
TodoModule.dll
TodoModule.deps.json
appsettings.todo.json
client\
index.js
todo.css
服务器模块通过 `Modules/Todo/module.json` 被发现。客户端模块通过部署目录
`appsettings.json` 中的 `FormPlatformExtensions:ClientModules` 被加载。
4. 第一步:定义模块数据库迁移
当前架构不要让安装人员手工运行下列 SQL。应把 PostgreSQL、MySQL、SQL Server
版本分别放入 `TodoMigrations : IDatabaseMigrationModule`,并在
`ConfigureServices` 中注册。平台启动时会加数据库锁、检查 checksum、在事务中执行并
写入 `app_schema_migrations`。完整规则见
SDK、迁移和测试专题参考。下列 PostgreSQL DDL 只作为表结构参考:
连接 FormPlatform 使用的 PostgreSQL 数据库,执行:
CREATE TABLE IF NOT EXISTS public.todo_items
(
id varchar(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
title varchar(200) NOT NULL,
description text NULL,
is_completed boolean NOT NULL DEFAULT false,
priority integer NOT NULL DEFAULT 3,
due_at timestamptz(0) NULL,
created_by varchar(36) NOT NULL,
created_at timestamptz(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_todo_items_priority CHECK (priority BETWEEN 1 AND 5)
);
CREATE INDEX IF NOT EXISTS ix_todo_items_owner_completed
ON public.todo_items (created_by, is_completed, created_at DESC);
字段说明:
| 字段 | 用途 |
|---|---|
| `id` | 数据库自动产生的字符串 UUID。 |
| `title` | Todo 标题,不能为空。 |
| `description` | 可选说明。 |
| `is_completed` | 是否完成。 |
| `priority` | 1 到 5。 |
| `due_at` | 可选截止时间,使用 `timestamptz(0)`。 |
| `created_by` | 创建 Todo 的 FormPlatform 用户 ID。 |
| `created_at` | 数据库自动产生的创建时间。 |
| `updated_at` | 程序维护的更新时间。 |
为什么使用 `timestamptz(0)`:
- C# 使用 `DateTimeOffset`;
- 客户端可以提交带时区或 UTC 的 ISO 时间;
- PostgreSQL 统一保存时间点;
- `(0)` 表示不保存小数秒。
5. 第二步:准备平台 SDK 文件
从 FormPlatform 发布目录中准备:
C:\FormPlatform\FormPlatform.Sdk.dll
C:\FormPlatform\FormPlatform.Extension.Abstractions.dll
为了让项目文件更清晰,可以建立:
C:\FormPlatformDevelopment\sdk\
把上面两个 DLL 和平台发布的 XML API 文档复制到 `sdk`。最终类似:
C:\FormPlatformDevelopment\sdk\
FormPlatform.Sdk.dll
FormPlatform.Sdk.xml
FormPlatform.Extension.Abstractions.dll
FormPlatform.Extension.Abstractions.xml
XML 文档不是运行必需的,但 IDE 可以显示接口说明。
6. 第三步:建立模块项目
在 PowerShell 中执行:
New-Item -ItemType Directory -Path C:\FormPlatformDevelopment\TodoModule -Force
Set-Location C:\FormPlatformDevelopment\TodoModule
dotnet new classlib --framework net10.0
删除模板生成的 `Class1.cs`。
将 `TodoModule.csproj` 修改为:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>TodoModule</AssemblyName>
<RootNamespace>TodoModule</RootNamespace>
<GenerateDependencyFile>true</GenerateDependencyFile>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<Reference Include="FormPlatform.Sdk"
HintPath="..\sdk\FormPlatform.Sdk.dll"
Private="false" />
<Reference Include="FormPlatform.Extension.Abstractions"
HintPath="..\sdk\FormPlatform.Extension.Abstractions.dll"
Private="false" />
</ItemGroup>
<ItemGroup>
<None Update="module.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.todo.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="client\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
注意:
- `Private="false"` 很重要,避免把平台 DLL 再复制进模块目录;
- `GenerateDependencyFile` 会生成 `TodoModule.deps.json`,模块加载器可据此定位模块自己的依赖;
- 客户模块必须使用与平台兼容的 .NET 和 SDK 版本;
- 更新 FormPlatform Runtime 后,应使用新 SDK DLL重新构建客户模块。
7. 第四步:建立 module.json
在项目根目录建立 `module.json`:
{
"entryAssembly": "TodoModule.dll",
"type": "TodoModule.TodoModuleEntry"
}
含义:
- `entryAssembly` 是模块的入口 DLL;
- `type` 是实现 `IFormPlatformSdkModule` 的完整类型名;
- 文件名和命名空间必须与后面的 C# 代码一致。
8. 第五步:建立模块配置和 Data Model
建立 `appsettings.todo.json`:
{
"TodoModule": {
"DefaultPageSize": 20,
"MaximumPageSize": 100
},
"DataAccess": {
"Entities": {
"TodoItem": {
"Schema": "public",
"TableName": "todo_items",
"Attributes": [
{
"PropertyName": "id",
"ColumnName": "id",
"ValueKind": "String",
"IsNullable": false,
"IsKey": true,
"IsGenerated": true,
"CanRead": true,
"CanWrite": true,
"MaxLength": 36
},
{
"PropertyName": "title",
"ColumnName": "title",
"ValueKind": "String",
"IsNullable": false,
"MaxLength": 200
},
{
"PropertyName": "description",
"ColumnName": "description",
"ValueKind": "String",
"IsNullable": true
},
{
"PropertyName": "isCompleted",
"ColumnName": "is_completed",
"ValueKind": "Boolean",
"IsNullable": false
},
{
"PropertyName": "priority",
"ColumnName": "priority",
"ValueKind": "Int32",
"IsNullable": false
},
{
"PropertyName": "dueAt",
"ColumnName": "due_at",
"ValueKind": "DateTimeOffset",
"IsNullable": true
},
{
"PropertyName": "createdBy",
"ColumnName": "created_by",
"ValueKind": "String",
"IsNullable": false,
"MaxLength": 36
},
{
"PropertyName": "createdAt",
"ColumnName": "created_at",
"ValueKind": "DateTimeOffset",
"IsNullable": false,
"IsGenerated": true,
"CanRead": true,
"CanWrite": true
},
{
"PropertyName": "updatedAt",
"ColumnName": "updated_at",
"ValueKind": "DateTimeOffset",
"IsNullable": false
}
]
}
}
}
}
重要概念:
- `PropertyName` 是 C#、ORM、Filter 和 JSON 使用的逻辑属性名;
- `ColumnName` 是数据库列名;
- 查询和更新代码只使用 `PropertyName`;
- `IsGenerated=true` 表示由数据库产生,Insert 后 ORM 会读取返回值;
- `createdAt` 和 `id` 因此不用由客户代码提供;
- `createdBy` 不能相信浏览器传来的值,必须由服务器身份产生。
这里没有重复配置数据库连接。`TodoItem` 默认使用调用
`BeginAsync("Management")` 时选择的 Management 数据库。
如果 Todo 表在另一个数据库,应在安全配置中增加 Data Source,例如:
{
"DataAccess": {
"DataSources": {
"TodoDatabase": {
"Provider": "PostgreSql",
"ConnectionString": "从秘密配置提供,不要提交到 Git"
}
}
}
}
然后代码改为:
await workFactory.BeginAsync("TodoDatabase", ct);
9. 第六步:编写 TodoService.cs
建立 `TodoService.cs`:
using FormPlatform.DataAccess.Metadata;
using FormPlatform.DataAccess.Persistence;
using FormPlatform.DataAccess.Query;
using FormPlatform.DataAccess.Runtime;
using FormPlatform.DataAccess.Transactions;
using Microsoft.Extensions.Options;
namespace TodoModule;
public sealed class TodoOptions
{
public int DefaultPageSize { get; set; } = 20;
public int MaximumPageSize { get; set; } = 100;
}
public sealed record TodoSaveRequest(
string? Title,
string? Description,
bool IsCompleted = false,
int Priority = 3,
DateTimeOffset? DueAt = null);
public sealed record TodoPage(
IReadOnlyList<IReadOnlyDictionary<string, object?>> Items,
long Total,
int Offset,
int Limit);
public sealed class TodoService(
IEntityModelResolver models,
IDynamicRepository repository,
IUnitOfWorkFactory workFactory,
IOptionsMonitor<TodoOptions> options)
{
private const string EntityName = "TodoItem";
private const string DataSource = "Management";
public async Task<TodoPage> QueryAsync(
string userId,
int? offset,
int? limit,
string? search,
bool showCompleted,
CancellationToken ct)
{
var model = await models.ResolveAsync(EntityName, ct);
var safeOffset = Math.Max(0, offset ?? 0);
var maximum = Math.Clamp(options.CurrentValue.MaximumPageSize, 1, 500);
var defaultSize = Math.Clamp(options.CurrentValue.DefaultPageSize, 1, maximum);
var safeLimit = Math.Clamp(limit ?? defaultSize, 1, maximum);
var filters = new List<FilterNode>
{
new FilterCondition("createdBy", FilterOperator.Equal, userId)
};
if (!showCompleted)
filters.Add(new FilterCondition(
"isCompleted",
FilterOperator.Equal,
false));
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim();
filters.Add(FilterGroup.Or(
new FilterCondition(
"title",
FilterOperator.ContainsIgnoreCase,
term),
new FilterCondition(
"description",
FilterOperator.ContainsIgnoreCase,
term)));
}
var filter = new FilterGroup(FilterLogic.And, filters);
await using var work = await workFactory.BeginAsync(DataSource, ct);
var total = await repository.CountAsync(model, filter, work, ct);
var rows = await repository.QueryAsync(
model,
new QuerySpec(
Filter: filter,
OrderBy:
[
new SortTerm("createdAt", SortDirection.Descending),
new SortTerm("id", SortDirection.Ascending)
],
Offset: safeOffset,
Limit: safeLimit,
Select:
[
"id", "title", "description", "isCompleted",
"priority", "dueAt", "createdAt", "updatedAt"
]),
work,
ct);
await work.CommitAsync(ct);
return new TodoPage(
rows.Select(ToDictionary).ToArray(),
total,
safeOffset,
safeLimit);
}
public async Task<IReadOnlyDictionary<string, object?>> SaveAsync(
string userId,
string? id,
TodoSaveRequest request,
CancellationToken ct)
{
var title = request.Title?.Trim();
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("Title is required.");
if (title.Length > 200)
throw new InvalidOperationException("Title cannot exceed 200 characters.");
if (request.Priority is < 1 or > 5)
throw new InvalidOperationException("Priority must be between 1 and 5.");
var model = await models.ResolveAsync(EntityName, ct);
var now = DateTimeOffset.UtcNow;
await using var work = await workFactory.BeginAsync(DataSource, ct);
try
{
DynamicEntity entity;
if (string.IsNullOrWhiteSpace(id))
{
entity = new DynamicEntity(
model,
new Dictionary<string, object?>
{
["title"] = title,
["description"] = EmptyToNull(request.Description),
["isCompleted"] = request.IsCompleted,
["priority"] = request.Priority,
["dueAt"] = request.DueAt,
["createdBy"] = userId,
["updatedAt"] = now
});
await repository.InsertAsync(entity, work, ct);
}
else
{
entity = await FindOwnedAsync(model, id, userId, work, ct)
?? throw new KeyNotFoundException("Todo item was not found.");
await repository.PatchAsync(
entity,
new Dictionary<string, object?>
{
["title"] = title,
["description"] = EmptyToNull(request.Description),
["isCompleted"] = request.IsCompleted,
["priority"] = request.Priority,
["dueAt"] = request.DueAt,
["updatedAt"] = now
},
work,
ct);
}
await work.CommitAsync(ct);
return ToDictionary(entity);
}
catch
{
await work.RollbackAsync(ct);
throw;
}
}
public async Task<bool> DeleteAsync(
string userId,
string id,
CancellationToken ct)
{
var model = await models.ResolveAsync(EntityName, ct);
await using var work = await workFactory.BeginAsync(DataSource, ct);
try
{
var entity = await FindOwnedAsync(model, id, userId, work, ct);
if (entity is null)
{
await work.CommitAsync(ct);
return false;
}
await repository.DeleteAsync(entity, work, ct);
await work.CommitAsync(ct);
return true;
}
catch
{
await work.RollbackAsync(ct);
throw;
}
}
private async Task<DynamicEntity?> FindOwnedAsync(
EntityModel model,
string id,
string userId,
IUnitOfWork work,
CancellationToken ct) =>
(await repository.QueryAsync(
model,
new QuerySpec(
Filter: FilterGroup.And(
new FilterCondition("id", FilterOperator.Equal, id),
new FilterCondition("createdBy", FilterOperator.Equal, userId)),
Limit: 1),
work,
ct)).FirstOrDefault();
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static IReadOnlyDictionary<string, object?> ToDictionary(
DynamicEntity entity) =>
entity.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase);
}
9.1 这段代码的重要原则
1. 浏览器不能提交 `createdBy`,服务器使用登录用户 ID。
2. 查询、更新、删除都同时过滤 `id + createdBy`。
3. 只使用逻辑属性名,不使用数据库列名。
4. 每个 ORM 操作都使用 `IUnitOfWork`。
5. 成功时 `CommitAsync`,失败时 `RollbackAsync`。
6. 查询明确设置 `Select`,不读取无用的 `createdBy`。
7. Limit 在服务器端限制,不能完全相信浏览器。
8. Insert 后数据库生成的 `id`、`createdAt` 会写回 `DynamicEntity`。
10. 第七步:编写 TodoActions.cs
建立 `TodoActions.cs`:
using System.Text.Json;
using FormPlatform.DataAccess.Metadata;
using FormPlatform.DataAccess.Runtime;
using FormPlatform.DataAccess.Triggers;
namespace TodoModule;
public sealed class TodoActions(TodoService service) : IServerActionsProvider
{
public IReadOnlyCollection<string> FormActions =>
["CreateTodoFromForm"];
public IReadOnlyCollection<string> TriggerActions =>
["NormalizeTodoTitle"];
public void ValidateTrigger(
string action,
EntityModel model,
JsonElement? options)
{
if (!action.Equals("NormalizeTodoTitle", StringComparison.OrdinalIgnoreCase))
throw new KeyNotFoundException($"Unknown trigger action '{action}'.");
model.Attribute("title");
}
public async ValueTask<FormActionResult> ExecuteFormActionAsync(
string action,
FormActionContext context,
CancellationToken cancellationToken = default)
{
if (!action.Equals("CreateTodoFromForm", StringComparison.OrdinalIgnoreCase))
throw new KeyNotFoundException($"Unknown form action '{action}'.");
if (string.IsNullOrWhiteSpace(context.UserId))
throw new InvalidOperationException("Login is required.");
var request = new TodoSaveRequest(
Title: Text(context.Data, "title"),
Description: Text(context.Data, "description"),
IsCompleted: Boolean(context.Data, "isCompleted"),
Priority: Integer(context.Data, "priority", 3),
DueAt: DateTimeOffsetValue(context.Data, "dueAt"));
var saved = await service.SaveAsync(
context.UserId,
id: null,
request,
cancellationToken);
return new FormActionResult(
JsonSerializer.SerializeToElement(saved),
new TriggerClientResult(
[
new TriggerNotification(
"Todo created.",
"success",
"Todo")
]));
}
public ValueTask<TriggerResult> ExecuteTriggerAsync(
string action,
EntityModel model,
List<dynamic> entities,
TriggerExecutionContext context,
JsonElement? options,
CancellationToken cancellationToken = default)
{
if (!action.Equals("NormalizeTodoTitle", StringComparison.OrdinalIgnoreCase))
throw new KeyNotFoundException($"Unknown trigger action '{action}'.");
var validation = new TriggerValidationResult();
foreach (var entity in entities.Cast<DynamicEntity>())
{
var title = entity["title"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(title))
{
validation.AddValidationError(
entity,
"title",
"Title is required.");
continue;
}
entity["title"] = title;
}
return ValueTask.FromResult(
validation.IsEmpty
? TriggerResult.Success()
: TriggerResult.ValidationFailed(validation));
}
private static string? Text(JsonElement data, string name) =>
data.TryGetProperty(name, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
private static bool Boolean(JsonElement data, string name) =>
data.TryGetProperty(name, out var value)
&& value.ValueKind is JsonValueKind.True or JsonValueKind.False
&& value.GetBoolean();
private static int Integer(JsonElement data, string name, int fallback) =>
data.TryGetProperty(name, out var value)
&& value.TryGetInt32(out var result)
? result
: fallback;
private static DateTimeOffset? DateTimeOffsetValue(
JsonElement data,
string name) =>
data.TryGetProperty(name, out var value)
&& value.ValueKind == JsonValueKind.String
&& DateTimeOffset.TryParse(value.GetString(), out var result)
? result
: null;
}
这个 Provider 增加两个能力:
- `CreateTodoFromForm`:表单 Mapping 选择 Server Action 时使用;
- `NormalizeTodoTitle`:Data Model Trigger 中使用,保存前去掉标题首尾空格并验证。
Action 名称必须在整个应用中唯一。如果另一个模块注册相同名称,
`ServerActionRegistry` 会拒绝建立不明确的映射。
11. 第八步:编写 TodoModule.cs
建立 `TodoModule.cs`:
using System.Security.Claims;
using FormPlatform.DataAccess.Triggers;
using FormPlatform.Extension.Abstractions;
using FormPlatform.Sdk;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace TodoModule;
public sealed class TodoModuleEntry : IFormPlatformSdkModule
{
public void ConfigureConfiguration(
ConfigurationManager configuration,
IHostEnvironment environment)
{
var moduleDirectory = Path.GetDirectoryName(
typeof(TodoModuleEntry).Assembly.Location)!;
configuration.AddJsonFile(
Path.Combine(moduleDirectory, "appsettings.todo.json"),
optional: false,
reloadOnChange: true);
}
public void ConfigureServices(
IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment)
{
services.Configure<TodoOptions>(
configuration.GetSection("TodoModule"));
services.AddSingleton<TodoService>();
services.AddSingleton<IServerActionsProvider, TodoActions>();
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var api = endpoints.MapGroup("/api/todo/items")
.RequireAuthorization();
api.MapGet("/", QueryAsync);
api.MapPost("/", CreateAsync);
api.MapPut("/{id}", UpdateAsync);
api.MapDelete("/{id}", DeleteAsync);
}
private static async Task<IResult> QueryAsync(
int? offset,
int? limit,
string? search,
bool? showCompleted,
ClaimsPrincipal principal,
TodoService service,
CancellationToken ct)
{
var userId = RequireUserId(principal);
var page = await service.QueryAsync(
userId,
offset,
limit,
search,
showCompleted ?? true,
ct);
return Results.Ok(page);
}
private static async Task<IResult> CreateAsync(
TodoSaveRequest request,
ClaimsPrincipal principal,
TodoService service,
CancellationToken ct)
{
try
{
return Results.Created(
"/api/todo/items",
await service.SaveAsync(
RequireUserId(principal),
id: null,
request,
ct));
}
catch (InvalidOperationException exception)
{
return Validation(exception.Message);
}
}
private static async Task<IResult> UpdateAsync(
string id,
TodoSaveRequest request,
ClaimsPrincipal principal,
TodoService service,
CancellationToken ct)
{
try
{
return Results.Ok(await service.SaveAsync(
RequireUserId(principal),
id,
request,
ct));
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
catch (InvalidOperationException exception)
{
return Validation(exception.Message);
}
}
private static async Task<IResult> DeleteAsync(
string id,
ClaimsPrincipal principal,
TodoService service,
CancellationToken ct) =>
await service.DeleteAsync(
RequireUserId(principal),
id,
ct)
? Results.NoContent()
: Results.NotFound();
private static string RequireUserId(ClaimsPrincipal principal) =>
principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException("Login is required.");
private static IResult Validation(string message) =>
Results.ValidationProblem(
new Dictionary<string, string[]>
{
["todo"] = [message]
});
}
11.1 为什么 API还要检查 userId
`.RequireAuthorization()` 只保证“用户已经登录”,不会自动限制他只能读取自己的
记录。因此 Service 的所有查询条件还必须包含:
new FilterCondition("createdBy", FilterOperator.Equal, userId)
这是数据归属授权,不能只在 Vue 中隐藏按钮。
12. 第九步:第一次构建服务器模块
在 Todo 项目目录执行:
dotnet build -c Release
如果出现“无法解析 FormPlatform”错误,请检查:
- `..\sdk\FormPlatform.Sdk.dll` 是否存在;
- SDK DLL是否来自当前部署版本;
- TargetFramework 是否为 `net10.0`;
- 是否同时引用了 `FormPlatform.Extension.Abstractions.dll`。
构建输出通常位于:
bin\Release\net10.0\
13. 第十步:部署服务器模块
建立部署目录:
New-Item -ItemType Directory -Path C:\FormPlatform\Modules\Todo\client -Force
复制:
Copy-Item .\bin\Release\net10.0\TodoModule.dll C:\FormPlatform\Modules\Todo\
Copy-Item .\bin\Release\net10.0\TodoModule.deps.json C:\FormPlatform\Modules\Todo\
Copy-Item .\module.json C:\FormPlatform\Modules\Todo\
Copy-Item .\appsettings.todo.json C:\FormPlatform\Modules\Todo\
不要把这些文件复制进去:
FormPlatform.dll
FormPlatform.Sdk.dll
FormPlatform.Extension.Abstractions.dll
它们应由主应用提供,模块不能携带另一版本的平台程序集。
暂时先不要启动。下一步加入客户端文件。
14. 第十一步:编写 client/todo.css
建立 `client/todo.css`:
.todo-module {
border: 1px solid #dbe4f0;
border-radius: 12px;
background: #ffffff;
padding: 16px;
color: #172033;
}
.todo-module__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.todo-module__title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.todo-module__form {
display: grid;
grid-template-columns: minmax(0, 1fr) 100px auto;
gap: 8px;
margin-bottom: 12px;
}
.todo-module__input,
.todo-module__select {
min-height: 38px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 7px 10px;
background: #ffffff;
}
.todo-module__button {
min-height: 38px;
border: 0;
border-radius: 8px;
padding: 7px 12px;
background: #2563eb;
color: #ffffff;
cursor: pointer;
}
.todo-module__button:disabled {
opacity: 0.55;
cursor: default;
}
.todo-module__list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.todo-module__item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 10px;
}
.todo-module__item--completed .todo-module__item-title {
color: #64748b;
text-decoration: line-through;
}
.todo-module__item-title {
font-weight: 600;
}
.todo-module__meta,
.todo-module__empty {
color: #64748b;
font-size: 12px;
}
.todo-module__error {
margin-bottom: 8px;
color: #b91c1c;
font-size: 13px;
}
.todo-module__delete {
border: 0;
background: transparent;
color: #dc2626;
cursor: pointer;
}
@media (max-width: 640px) {
.todo-module__form {
grid-template-columns: 1fr;
}
}
客户 CSS 使用 `todo-module` 前缀,减少与平台和其它模块发生样式冲突的可能。
15. 第十二步:编写 client/index.js
建立 `client/index.js`:
export default {
install(api) {
const {
h,
ref,
computed,
onMounted,
watch
} = api.Vue
const refreshVersion = ref(0)
const ensureStyle = () => {
const id = 'todo-module-style'
if (document.getElementById(id)) return
const link = document.createElement('link')
link.id = id
link.rel = 'stylesheet'
link.href = '/extension-assets/Todo/todo.css'
document.head.appendChild(link)
}
const request = async (url, options = {}) =>
window.FormPlatform.API.fetch(url, options)
const emitConfiguredEvent = (eventActions, name, detail) => {
window.runConfiguredEvent?.(
eventActions,
name,
{ detail })
}
const TodoList = {
name: 'TodoList',
props: {
title: { type: String, default: 'My Todo List' },
pageSize: { type: Number, default: 20 },
showCompleted: { type: Boolean, default: true },
designerMode: { type: Boolean, default: false },
eventActions: { type: Object, default: () => ({}) },
componentClass: { type: [String, Array, Object], default: '' },
componentStyle: { type: [String, Array, Object], default: null }
},
setup(props) {
const items = ref([])
const newTitle = ref('')
const priority = ref(3)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const translatedTitle = computed(() =>
props.title || window.FormPlatform.API.t('todo.title'))
const visibleItems = computed(() =>
props.showCompleted
? items.value
: items.value.filter(item => !item.isCompleted))
const previewItems = [
{ id: 'preview-1', title: 'Review requirements', priority: 2, isCompleted: false },
{ id: 'preview-2', title: 'Build the module', priority: 3, isCompleted: true },
{ id: 'preview-3', title: 'Deploy and verify', priority: 4, isCompleted: false }
]
const load = async () => {
if (props.designerMode) {
items.value = previewItems
return
}
loading.value = true
error.value = ''
try {
const query = new URLSearchParams({
offset: '0',
limit: String(props.pageSize),
showCompleted: String(props.showCompleted)
})
const page = await request(`/api/todo/items?${query}`)
items.value = page.items || []
} catch (exception) {
error.value = exception?.message || 'Unable to load Todo items.'
} finally {
loading.value = false
}
}
const add = async () => {
const title = newTitle.value.trim()
if (!title || saving.value || props.designerMode) return
saving.value = true
error.value = ''
try {
const saved = await request('/api/todo/items', {
method: 'POST',
body: JSON.stringify({
title,
description: null,
isCompleted: false,
priority: Number(priority.value),
dueAt: null
})
})
newTitle.value = ''
items.value = [saved, ...items.value]
emitConfiguredEvent(
props.eventActions,
'onTodoCreated',
{ item: saved })
} catch (exception) {
error.value = exception?.message || 'Unable to create Todo item.'
} finally {
saving.value = false
}
}
const toggle = async item => {
if (props.designerMode) return
const previous = item.isCompleted
item.isCompleted = !previous
error.value = ''
try {
const saved = await request(
`/api/todo/items/${encodeURIComponent(item.id)}`,
{
method: 'PUT',
body: JSON.stringify({
title: item.title,
description: item.description,
isCompleted: item.isCompleted,
priority: item.priority,
dueAt: item.dueAt
})
})
Object.assign(item, saved)
emitConfiguredEvent(
props.eventActions,
'onTodoChanged',
{ item: saved })
} catch (exception) {
item.isCompleted = previous
error.value = exception?.message || 'Unable to update Todo item.'
}
}
const remove = async item => {
if (props.designerMode) return
error.value = ''
try {
await request(
`/api/todo/items/${encodeURIComponent(item.id)}`,
{ method: 'DELETE' })
items.value = items.value.filter(value => value.id !== item.id)
emitConfiguredEvent(
props.eventActions,
'onTodoDeleted',
{ item })
} catch (exception) {
error.value = exception?.message || 'Unable to delete Todo item.'
}
}
watch(
() => [props.pageSize, props.showCompleted, refreshVersion.value],
load)
onMounted(load)
return () => h(
'section',
{
class: ['todo-module', props.componentClass],
style: props.componentStyle
},
[
h('div', { class: 'todo-module__header' }, [
h('h2', { class: 'todo-module__title' }, translatedTitle.value),
h(
'span',
{ class: 'todo-module__meta' },
loading.value ? 'Loading…' : `${visibleItems.value.length} item(s)`)
]),
!props.designerMode
? h('form', {
class: 'todo-module__form',
onSubmit: event => {
event.preventDefault()
add()
}
}, [
h('input', {
class: 'todo-module__input',
value: newTitle.value,
maxlength: 200,
placeholder: 'What needs to be done?',
onInput: event => { newTitle.value = event.target.value }
}),
h('select', {
class: 'todo-module__select',
value: priority.value,
onChange: event => { priority.value = Number(event.target.value) }
}, [1, 2, 3, 4, 5].map(value =>
h('option', { value }, `P${value}`))),
h('button', {
class: 'todo-module__button',
type: 'submit',
disabled: saving.value || !newTitle.value.trim()
}, saving.value ? 'Adding…' : 'Add')
])
: null,
error.value
? h('div', { class: 'todo-module__error' }, error.value)
: null,
visibleItems.value.length
? h('ul', { class: 'todo-module__list' },
visibleItems.value.map(item =>
h('li', {
key: item.id,
class: [
'todo-module__item',
item.isCompleted && 'todo-module__item--completed'
]
}, [
h('input', {
type: 'checkbox',
checked: item.isCompleted,
disabled: props.designerMode,
onChange: () => toggle(item)
}),
h('div', {}, [
h('div', { class: 'todo-module__item-title' }, item.title),
h(
'div',
{ class: 'todo-module__meta' },
`Priority ${item.priority}`)
]),
!props.designerMode
? h('button', {
class: 'todo-module__delete',
type: 'button',
onClick: () => remove(item)
}, 'Delete')
: null
])))
: h(
'div',
{ class: 'todo-module__empty' },
loading.value ? 'Loading…' : 'No Todo items.')
])
}
}
const TodoProperties = {
props: {
component: { type: Object, required: true }
},
emits: ['update'],
setup(props, { emit }) {
const update = changes => emit('update', {
props: {
...props.component.props,
...changes
}
})
return () => h('div', { class: 'space-y-4' }, [
h('label', { class: 'block text-sm' }, [
h('span', { class: 'mb-1 block' }, 'Title'),
h('input', {
class: 'w-full rounded border border-slate-300 px-3 py-2',
value: props.component.props?.title || '',
onInput: event => update({ title: event.target.value })
})
]),
h('label', { class: 'block text-sm' }, [
h('span', { class: 'mb-1 block' }, 'Page size'),
h('input', {
class: 'w-full rounded border border-slate-300 px-3 py-2',
type: 'number',
min: 1,
max: 100,
value: props.component.props?.pageSize || 20,
onInput: event => update({
pageSize: Math.max(1, Math.min(100, Number(event.target.value) || 20))
})
})
]),
h('label', { class: 'flex items-center gap-2 text-sm' }, [
h('input', {
type: 'checkbox',
checked: props.component.props?.showCompleted !== false,
onChange: event => update({ showCompleted: event.target.checked })
}),
h('span', {}, 'Show completed items')
])
])
}
}
ensureStyle()
api.registerMessages('todo-module', {
en: {
'todo.title': 'My Todo List'
},
'zh-CN': {
'todo.title': '我的待办事项'
}
})
api.registerControl({
type: 'todoList',
component: TodoList,
propertyEditor: TodoProperties,
group: 'collections',
label: 'Todo List',
icon: '✓',
description: 'Todo List supplied by the Todo module.',
dataComponent: false,
create: () => ({
props: {
title: 'My Todo List',
pageSize: 20,
showCompleted: true
}
}),
events: [
{ name: 'onTodoCreated', label: 'Todo created' },
{ name: 'onTodoChanged', label: 'Todo changed' },
{ name: 'onTodoDeleted', label: 'Todo deleted' }
]
})
api.registerRoute({
path: '/todo',
name: 'todo-module-page',
component: TodoList,
props: {
title: 'My Todo List',
pageSize: 50,
showCompleted: true
},
meta: {
requiresAuth: true
}
})
api.extendActionApi(
'createTodo',
async ({ context }, title, priority = 3) => {
const saved = await context.api.fetch('/api/todo/items', {
method: 'POST',
body: JSON.stringify({
title,
description: null,
isCompleted: false,
priority,
dueAt: null
})
})
refreshVersion.value += 1
context.api.notify({
type: 'success',
message: `Todo "${saved.title}" created.`
})
return saved
})
api.extendRuntimeApi(runtime => {
runtime.todo = {
setFormTitle(title) {
const component = runtime.components().find(
item => item.other?.propertyName === 'title')
if (!component) return false
runtime.setValue(component.id, title)
return true
},
snapshot() {
return { ...runtime.data }
}
}
})
}
}
15.1 为什么 Designer 不请求真实 API
控件判断:
props.designerMode
Designer 中显示三条模拟数据,不调用 `/api/todo/items`。这样:
- 未登录的独立 Designer 不会收到 401;
- 设计人员不会误改真实数据;
- 设计模式始终能看到完整控件外观;
- 减少无意义的服务器请求。
15.2 为什么 dataComponent 是 false
Todo List 自己通过 API维护多条记录,不代表主表单的一个字段。因此:
dataComponent: false
如果你开发的是一个真正的数据输入控件,例如颜色选择器,则应该使用:
dataComponent: true
并实现:
props: ['value']
emits: ['update:value']
16. 第十三步:复制客户端文件
执行:
Copy-Item .\client\index.js C:\FormPlatform\Modules\Todo\client\
Copy-Item .\client\todo.css C:\FormPlatform\Modules\Todo\client\
这个 JavaScript 直接使用平台提供的 Vue runtime,不需要执行 `npm install` 或
重新构建 FormPlatform 前端。
以后如果使用 `.vue` SFC 开发,应在客户自己的项目中用 Vite 构建,并只把生成的
JavaScript/CSS 复制进 `Modules/Todo/client`。
17. 第十四步:在主 appsettings.json 启用客户端模块
修改部署目录:
C:\FormPlatform\appsettings.json
确保包含:
{
"FormPlatformExtensions": {
"ModulesDirectory": "Modules",
"ClientModules": [
"/extension-assets/Todo/index.js"
]
}
}
如果已经有其它客户端模块,不要覆盖,继续追加:
"ClientModules": [
"/extension-assets/Customer/index.js",
"/extension-assets/Todo/index.js"
]
`ClientModules` 是集中列表。不要让每个模块自己的 JSON 都定义这个数组,否则
JSON 配置合并时,后加载的数组可能替换前面的数组。
18. 第十五步:启动并检查服务器模块
从 FormPlatform 部署目录启动:
Set-Location C:\FormPlatform
dotnet FormPlatform.dll
开发源码环境也可以使用:
dotnet run --project src/FormPlatform.Host/FormPlatform.csproj --urls http://localhost:5080
启动失败时先看日志。常见情况:
18.1 找不到 module.json
检查:
Modules/Todo/module.json
以及主配置:
"ModulesDirectory": "Modules"
18.2 找不到 TodoModule.dll
确认 `entryAssembly` 与真实文件名完全一致:
"entryAssembly": "TodoModule.dll"
18.3 找不到 IFormPlatformSdkModule 实现
确认完整类型名:
"type": "TodoModule.TodoModuleEntry"
同时确认类是:
public sealed class TodoModuleEntry : IFormPlatformSdkModule
18.4 程序集版本不匹配
使用当前部署目录对应的 `FormPlatform.Sdk.dll` 和 Abstractions DLL重新构建模块。
不要把旧平台 DLL复制进模块目录。
19. 第十六步:在浏览器中测试 API
先登录 FormPlatform,然后在浏览器开发者工具 Console 中执行:
await window.FormPlatform.API.fetch('/api/todo/items')
应该返回类似:
{
"items": [],
"total": 0,
"offset": 0,
"limit": 20
}
新增:
await window.FormPlatform.API.fetch('/api/todo/items', {
method: 'POST',
body: JSON.stringify({
title: 'Learn FormPlatform modules',
description: 'Complete the Todo tutorial',
isCompleted: false,
priority: 2,
dueAt: null
})
})
再次查询:
await window.FormPlatform.API.fetch('/api/todo/items')
在 PostgreSQL 中也可以检查:
SELECT id, title, is_completed, priority, created_by, created_at
FROM public.todo_items
ORDER BY created_at DESC;
确认 `created_by` 是当前登录用户的 ID,而不是浏览器提交的任意值。
20. 第十七步:测试独立 Todo 页面
访问:
http://localhost:5080/todo
应该能够:
- 新增 Todo;
- 标记完成/未完成;
- 删除 Todo;
- 刷新后仍从数据库读取;
- 另一个登录用户看不到前一个用户的数据。
如果 `/todo` 被重定向到登录页,这是正常的,因为路由声明:
meta: { requiresAuth: true }
21. 第十八步:在 Form Designer 使用控件
1. 登录系统;
2. 打开 Form Designer;
3. 展开 Collections;
4. 找到 `Todo List`;
5. 把它拖入表单;
6. Designer 应显示三条模拟 Todo;
7. 点击控件设置;
8. 在 General tab 修改 Title、Page size 和 Show completed items;
9. Style、Events、Tooltip、Other tab 应继续存在;
10. 保存表单;
11. 打开 Viewer,控件应读取当前用户的真实 Todo。
如果控件区没有 Todo List:
1. 打开 Network;
2. 检查 `GET /api/platform/client-extensions`;
3. 它应包含 `/extension-assets/Todo/index.js`;
4. 检查该 JavaScript 请求是否为 200;
5. 检查 Console 是否出现安装错误;
6. 确认 `ClientModules` JSON 逗号和括号正确;
7. 强制刷新浏览器缓存。
22. 第十九步:配置控件 Events
Todo 控件声明:
onTodoCreated
onTodoChanged
onTodoDeleted
在 Events tab 启用 `onTodoCreated`,Action Code 可以写:
export default {
onTodoCreated({ event, api }) {
const item = event?.detail?.item
api.notify({
type: 'success',
message: `Created: ${item?.title || ''}`
})
}
}
然后把事件 Action 设置为:
onTodoCreated
注意:自定义控件声明事件名称后,还需要在控件内部主动调用:
window.runConfiguredEvent(eventActions, eventName, eventObject)
平台不会猜测客户控件的哪个内部动作代表什么业务事件。
23. 第二十步:使用扩展的客户端 Action API
Todo 模块注册了:
api.extendActionApi('createTodo', ...)
表单 Action Code 可以调用:
export default {
async createTodoForCurrentUser({ api, data }) {
await api.createTodo(data.title, 2)
}
}
也可以在浏览器 Console 中测试:
await window.FormPlatform.API.createTodo('Created from Action API', 4)
如果 Todo List 控件已显示,它会因为共享的响应式 `refreshVersion` 自动重新加载。
24. 第二十一步:使用扩展的 Form Runtime API
Todo 模块为每个 Form Runtime增加:
runtime.todo.setFormTitle(title)
runtime.todo.snapshot()
Action Code 示例:
export default {
fillTodoTitle({ api }) {
api.runtime?.todo?.setFormTitle('A title from runtime extension')
},
inspectForm({ api }) {
console.log(api.runtime?.todo?.snapshot())
}
}
`api.runtime` 只有在活动 FormReader 准备完成后才有值,所以使用可选链:
api.runtime?.todo
扩展 Runtime 时建议增加自己的命名空间对象,不要覆盖平台核心方法:
runtime.todo = { ... }
25. 第二十二步:在普通表单中使用 Server Action
可以建立一个普通表单,加入:
- Input,Property Name = `title`;
- TextArea,Property Name = `description`;
- Dropdown/Radio,Property Name = `priority`;
- Calendar,Property Name = `dueAt`。
在 Form and Data Mapping 中选择:
Target kind: Server Action
Action: CreateTodoFromForm
提交表单时,`TodoActions.ExecuteFormActionAsync` 会被调用。服务器从
`context.UserId` 获取当前用户,不允许浏览器指定 `createdBy`。
如果 `title` 为空,TodoService 返回 validation error。成功后返回 Todo 数据和
success notification。
26. 第二十三步:在 Data Model Mapping 中使用 Trigger
如果另一个表单直接映射 `TodoItem` Data Model,可在 Main Entity Trigger 中增加:
Trigger: BeforeInsert, BeforeUpdate
Action: NormalizeTodoTitle
Parameter: {}
保存前它会:
- 对 `title` 执行 `Trim()`;
- 空标题返回字段 validation;
- validation 失败时终止提交。
注意:如果表单直接映射 TodoItem,还必须确保 `createdBy` 由受信任的服务器 Trigger
赋值,并且读写 API执行 owner filter。初学者更适合先使用本教程的 Todo API 或
`CreateTodoFromForm`,不要直接让浏览器维护 `createdBy`。
27. 第二十四步:开发环境快速更新流程
只修改客户端 `index.js` 或 CSS:
Copy-Item .\client\index.js C:\FormPlatform\Modules\Todo\client\ -Force
Copy-Item .\client\todo.css C:\FormPlatform\Modules\Todo\client\ -Force
然后刷新浏览器。通常不需要重启 .NET,也不需要重新构建 FormPlatform Vue。
修改 C#:
dotnet build -c Release
停止 FormPlatform,复制新的 Todo DLL,再启动。Windows 正在运行的进程可能锁定
DLL,所以不要在进程运行时直接覆盖。
修改 `module.json`、服务注册或 API 路由后也必须重启。
28. 第二十五步:生产部署建议
生产更新顺序:
1. 备份数据库;
2. 在测试环境使用相同 FormPlatform Runtime测试 Todo 模块;
3. 停止 FormPlatform 服务;
4. 备份当前 `Modules/Todo`;
5. 原子替换整个 Todo 模块目录;
6. 确认 DLL 和配置的文件权限;
7. 启动服务;
8. 检查启动日志;
9. 测试查询、新增、修改和删除;
10. 测试普通用户之间的数据隔离;
11. 测试 Designer 控件和 Viewer;
12. 失败时恢复上一个模块目录和兼容数据库结构。
不要提供网页让用户上传 DLL。服务器模块拥有应用进程权限,安装模块必须是部署
管理员控制的运维操作。
29. 使用 `.vue` 文件开发更复杂的控件
本教程使用纯 JavaScript render function,因此无需单独构建。如果控件较复杂,
建议建立客户自己的 Vue + Vite 项目。
`vite.config.js` 的核心配置:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: 'src/index.js',
name: 'TodoControls',
formats: ['iife']
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'window.FormPlatform.Extensions.Vue'
}
}
}
}
})
客户构建必须避免打包第二份 Vue,否则可能出现:
- inject/provide 失效;
- 响应式对象属于不同 Vue runtime;
- DevTools显示异常;
- component 生命周期行为不一致。
IIFE 入口可直接注册:
window.FormPlatform.Extensions.registerControl({
type: 'todoList',
component: TodoList,
// ...
})
客户保留 `.vue` 源码,只把 `dist` 中的 JS/CSS 交付到
`Modules/Todo/client`。
30. 常见问题排查
30.1 API返回 401
原因:用户未登录,或者请求未携带同源 Cookie。
客户控件应使用:
window.FormPlatform.API.fetch(...)
或:
fetch(url, { credentials: 'same-origin' })
Designer 应使用模拟数据,不读取需要登录的运行时 API。
30.2 API返回 404
检查服务器模块是否加载,`MapEndpoints` 是否执行,URL 是否正确。服务器 DLL更新
后必须重启。
30.3 找不到 Entity `TodoItem`
检查:
- `appsettings.todo.json` 是否复制到模块目录;
- `ConfigureConfiguration` 是否使用程序集实际目录;
- JSON 中是否为 `DataAccess:Entities:TodoItem`;
- JSON 是否有效;
- 是否在启动后才复制配置文件。配置源在启动时加入,所以首次安装后必须重启。
30.4 数据库提示列不存在
核对 `ColumnName` 与 PostgreSQL 表字段。ORM 代码使用 `PropertyName`,数据库
实际使用 `ColumnName`。
30.5 DateTimeOffset 异常
PostgreSQL 使用:
timestamptz(0)
C# 使用:
DateTimeOffset
DateTimeOffset?
服务器生成当前时间时使用:
DateTimeOffset.UtcNow
30.6 控件没有出现在 Designer
检查:
await fetch('/api/platform/client-extensions').then(r => r.json())
然后检查:
await import('/extension-assets/Todo/index.js')
如果第二句提示重复注册,这是因为页面已经加载过模块,说明文件本身可以访问。
30.7 CSS 没有生效
`ClientModules` 只加载 JavaScript,不自动加载 CSS。本教程的 `ensureStyle()` 会加入:
/extension-assets/Todo/todo.css
检查 Network 中 CSS 是否为 200。
30.8 修改客户端文件后仍是旧版本
强制刷新浏览器,或在开发阶段临时修改 URL:
"/extension-assets/Todo/index.js?v=2"
生产环境建议使用版本化文件名,例如:
index-1.0.1.js
30.9 一个用户看到了另一个用户的数据
这是严重的服务器授权问题。立刻检查所有 Query、Update、Delete 是否都包含
`createdBy == userId`。不要依赖客户端 Filter。
30.10 Server Action 没有出现在列表
检查:
- `TodoActions` 是否实现 `IServerActionsProvider`;
- 是否调用 `services.AddSingleton<IServerActionsProvider, TodoActions>()`;
- Action 名称是否与其它 Provider 重复;
- 服务器是否在复制 DLL 后重启。
31. 完成检查清单
开发完成后逐项确认:
- [ ] `todo_items` 表和索引已建立;
- [ ] ID 由数据库自动产生;
- [ ] `appsettings.todo.json` 注册 `TodoItem`;
- [ ] 模块只引用编译后的平台 SDK;
- [ ] `module.json` 类型名正确;
- [ ] Query 使用 projection 和服务器分页上限;
- [ ] Insert、Update、Delete 使用 Unit of Work;
- [ ] 所有数据操作按当前用户过滤;
- [ ] API显式要求授权;
- [ ] Server Action 和 Trigger 名称唯一;
- [ ] Designer 使用模拟数据;
- [ ] Viewer 使用真实 API;
- [ ] 控件有自己的 CSS 前缀;
- [ ] 自定义 Event由控件主动触发;
- [ ] Action API不能覆盖平台 API;
- [ ] Runtime扩展使用 `runtime.todo` 命名空间;
- [ ] 客户端没有密码或数据库连接;
- [ ] 服务器 DLL更新后重启应用;
- [ ] 不通过网页上传模块 DLL;
- [ ] 使用第二个用户验证数据隔离。
32. 下一步可以做什么
成功完成 Todo 模块后,可以继续尝试:
1. 加入截止时间编辑器;
2. 用 AsyncSelect 选择 Todo 分类;
3. 建立 `todo_categories` Data Model 和 Reference;
4. 使用 DataGrid显示 Todo;
5. 加入批量完成和批量删除 API;
6. 使用 Server Action 从普通表单建立 Todo;
7. 用 Trigger 自动设置审计字段;
8. 使用 i18n 翻译全部控件文字;
9. 使用 Vite + `.vue` 重写控件;
10. 为 TodoService 编写单元测试和 API集成测试。
理解这个 Todo 模块后,你已经掌握了 FormPlatform 二次开发的完整基本链路:
客户配置
→ Data Model
→ ORM/事务
→ Service
→ Server Action/Trigger/API
→ 客户端扩展加载器
→ Designer 控件
→ Viewer/Router
→ Action API/Runtime API
更完整的扩展架构和发布边界请继续阅读:
- 私有化扩展架构
- `samples/FormPlatform.Extension.Sample`