FForm Platform
enzh-CN

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

更完整的扩展架构和发布边界请继续阅读: