FForm Platform
enzh-CN

动作、触发器与客户端扩展

选择最小且安全的扩展方式:表单 JavaScript、受限脚本或编译模块。

脚本型扩展

脚本型扩展用于降低简单业务规则的开发门槛;它补充而不替代已编译的 FormPlatform 模块。

扩展类型存放位置执行位置适用场景
表单 Action Code表单 metadata 的 `actionCode`浏览器 ES module客户端交互、界面状态、浏览器 API
Server Action Script`App_Data/script-extensions/*.js`服务端 Jint JavaScript 解释器转换提交数据、返回 toast/state
Trigger Script`App_Data/script-extensions/*.js`服务端 Jint JavaScript 解释器在持久化生命周期中校验或修改当前实体
已编译模块模块 DLL完整可信 .NET Host数据库/API 集成、后台任务、特权服务

启用

默认关闭。请通过 `appsettings.Production.json`、环境变量、systemd credential 或其它正常配置源加入以下配置;脚本绝不能放在 `wwwroot`:

"ScriptExtensions": {
  "Enabled": true,
  "Directory": "App_Data/script-extensions",
  "TimeoutMilliseconds": 250,
  "MaxStatements": 10000,
  "MaxRecursionDepth": 64,
  "MaxMemoryBytes": 8388608,
  "MaxScriptSizeBytes": 65536,
  "MaxResultSizeBytes": 262144
}

将 `deploy/script-extensions.example` 中两个示例文件复制到上述目录,然后重启 FormPlatform。脚本只在启动时读取一次,因此 Action 注册稳定可预测。目录不存在不会造成错误;但错误 manifest、重复 Action 名、缺少脚本文件,或与内置/模块 Action 重名,会使启动以明确错误停止。

每一个 `*.script.json` 声明它提供的名称:

{
  "name": "CustomerRules",
  "scriptFile": "customer-rules.js",
  "formActions": ["NormalizeCustomer"],
  "triggerActions": ["ValidateCustomer"],
  "enabled": true
}

Action 名必须以字母开头,其余只能使用字母、数字、`.`、`_`、`-`。名称在内置 Action、脚本和编译模块之间全局唯一。

JavaScript 文件是一个对象表达式,可包含 `formActions`、`triggers` 或二者。处理函数键名必须与 manifest 完全相同。服务端脚本必须同步执行;Promise 会被拒绝。

({
  formActions: {
    NormalizeCustomer(input) {
      return { data: { ...input.data, fullName: input.data.fullName.trim() } };
    }
  },
  triggers: {
    ValidateCustomer(input) {
      const errors = input.entities[0].fullName ? [] : [
        { attribute: 'fullName', message: 'Full name is required.' }
      ];
      return { entities: input.entities.map(() => ({})), validation: errors };
    }
  }
})

在表单中配置

无需增加新的 Designer 控件。现有 mapping 编辑器通过 `/api/admin/form-mappings/actions` 获取可用 Action,已注册的脚本名称会与现有 Server Action 一起出现。

配置 **Server Action Script**:

Target kind: ServerAction
Action:      NormalizeCustomer
Options:     { "source": "customer-form" }

处理函数收到:

{
  form: { id, name, displayName },
  user: { id },             // 匿名访问时为 null
  data: { /* 表单提交 JSON */ },
  options: { /* mapping Options JSON */ }
}

返回对象的 `data` 必须是对象,并成为 Server Action 结果;省略 `data` 时返回原提交数据。`notifications` 和 `state` 是可选浏览器效果:

{
  data: { /* 返回表单 JSON */ },
  notifications: [
    { message: 'Saved.', type: 'success', title: 'Customer', durationMs: 4000 }
  ],
  state: { selectedCustomerId: '...' }
}

配置 **Trigger Script**:在现有 Form and Data Mapping 生命周期编辑器中添加:

Events: Validate, BeforeInsert
Action: ValidateCustomer
Options: { "minimumLength": 2 }

输入格式:

{
  operation: 'Insert',
  user: { id },
  entity: { name, table },
  entities: [ { /* 当前实体字段 */ } ],
  options: { /* trigger Options JSON */ }
}

Trigger 可返回:

{
  // 可选;如果提供,必须与输入实体一一对应。
  // 只能修改可写、非主键、非 generated、非 concurrency 的字段。
  entities: [ { updatedBy: '...', updatedDate: '2026-08-25T00:00:00.000Z' } ],
  validation: [ { attribute: 'fullName', message: 'Full name is required.' } ],
  terminate: false,
  message: '可选的生命周期提示'
}

当前 Trigger 管道会按既有规则把 `message` 作为生命周期提示;Trigger 不可直接返回任意客户端代码或访问数据库。若规则需要调用外部 API、查询无关数据、队列、DI,请使用 Server Action 或编译模块。

安全边界

每一次调用都会创建新的 Jint engine。Host 仅通过 JSON 字符串传入/传出数据,并且绝不调用 Jint 的 `AllowClr`。因此脚本没有 .NET 服务、数据库连接、`HttpClient`、文件系统、反射 API、密钥或认证 cookie。运行时还限制执行时间、语句数、递归深度、源码大小和结果大小。

这是一层**能力受限的扩展接口**,不是针对恶意代码的强隔离。运行时限制可降低意外的死循环风险,但不能被视为对恶意 JavaScript 的完整拒绝服务防护。能够编辑 `App_Data/script-extensions` 的人已拥有部署级文件系统权限,必须被视为可信。不要把任意租户 JavaScript 当成安全的多租户能力。需要执行不可信代码时,应使用独立 worker/container,并以狭窄的 HTTP/RPC 协议通信。

表单 Action Code 仍然是浏览器代码:表单访问者可以看到它,因此绝不能放密钥,也不能作为授权依据。授权、校验和持久化规则必须在 Server Action、Trigger 或编译模块中落实。

如何选择

  • 表单 Action Code:纯 UI 行为,例如改变控件可见性、调用浏览器端数据。
  • Trigger Script:对正在保存的实体执行简短、同步的规则。
  • Server Action Script:小型数据转换或服务端生成 toast/state。
  • 编译模块:需要额外数据库查询、外部 HTTP、电邮、文件、队列、后台任务、可复用 API 或更强安全边界时使用。

---

FormPlatform 客户端组件契约

English: COMPONENT_CONTRACTS.md

组件契约是 Designer、Preview、Viewer、Form Runtime 和第三方控件之间的稳定边界。它不是 Vue `defineProps` 的替代品,而是平台可检查的公共语义:组件是否产生表单数据、接收和发出什么值、参与哪种验证、三种模式如何工作,以及 Schema/运行值如何序列化。

1. 唯一来源

内置契约位于 `ClientApp/src/components/componentContracts.js`。`formComponentRegistry.js` 负责组件实现,二者必须一一对应;Vitest 会拒绝“有实现无契约”或“有契约无实现”。`formRuntime.js` 也从契约判断 data component,不再维护另一份类型名单;Events tab 从同一契约取得内置事件。

每份契约包含:

字段含义
`category``data`、`action`、`navigation`、`host`、`container`、`collection` 或 `display`
`dataComponent`是否进入 Form Runtime data、映射和字段验证
`props` / `emits`运行时公共输入和 Vue 输出
`events`Events tab 可配置事件
`value`value prop、emit、接受类型、空值和提交语义
`validation``none`、`field` 或 `page-and-form`
`modes`Designer、Preview、Viewer 的行为
`serialization`Schema 保存区和运行值归属

当前内置范围:

  • data:Input、TextArea、Dropdown、AsyncSelect、Tree、Radio、Checkbox、Calendar、GeoLocation;
  • action/navigation/host:Button、Menu、Breadcrumb、SystemSlot;
  • container:Div、Form、FormGroup、Tabs、Pagination、GridLayout、CustomBlock、Table;
  • collection:DataGrid、ItemRenderer、Spreadsheet;
  • display:QrCode、StaticContent、Header、Label、Message、Image、Statistic。

2. 关键值语义

  • 普通 data component 使用 `value` / `update:value`,由 `other.propertyName` 映射提交字段。
  • Checkbox 使用 `checked` / `update:checked`;选中提交配置的 `props.value`,空值或 `false` 配置按 `true` 处理,未选中提交 `null`。
  • Radio 允许数据库 Boolean 与字符串选项 `"true"` / `"false"` 等值比较;`allowDeselect` 再点当前项返回 `null`。
  • GeoLocation 是只读的浏览器/设备测量控件。默认提交 JSON 位置快照,也可提交 `纬度,经度`;打开已有记录时,非空的已保存值不会被自动定位覆盖。
  • Hidden data component 不验证,也不进入可见提交字段。
  • DataGrid/ItemRenderer 的记录属于外部 collection,不被误当作主表单字段序列化。

这些规则实现在 `componentValueModel.js`,FormRenderer、FormTable、Designer 和具体控件共用同一代码。

3. 三种模式

  • Designer:控件不可产生业务副作用;容器呈现 dropzone,DataGrid 使用 mock data,SystemSlot 显示占位内容。
  • Preview:运行 UI、条件和验证,但由预览 Host 决定是否允许外部数据访问和持久化。
  • Viewer:完整运行时行为、API、Action、Trigger 和提交。

契约的 mode 字符串用于文档、检查和将来的自动化工具;实际 Vue 代码仍必须遵守该语义。

4. 第三方控件

新控件在 `registerControl` 时应提供显式契约:

api.registerControl({
  type: 'rating',
  component: () => import('./RatingControl.vue'),
  loader: true,
  dataComponent: true,
  events: ['onChange', 'onFocus', 'onBlur'],
  contract: {
    category: 'data',
    dataComponent: true,
    props: ['tableId', 'designerMode', 'value', 'readOnly', 'validationClass'],
    emits: ['update:value'],
    events: ['onChange', 'onFocus', 'onBlur'],
    value: {
      prop: 'value',
      emit: 'update:value',
      acceptedTypes: ['number', 'null'],
      emptyValue: null
    },
    validation: 'field',
    modes: { designer: 'preview', preview: 'runtime', viewer: 'runtime' },
    serialization: { runtimeValue: 'form-data-by-propertyName' }
  }
})

旧扩展没有 `contract` 时平台会根据 `dataComponent` 和 `events` 生成兼容契约,但新模块不得依赖这个降级路径。

5. 自动化测试

运行:

cd src/FormPlatform.Host/ClientApp
npm ci
npm run test:unit

当前 Vitest 覆盖 substitution 类型/格式/依赖、Application/Form Action Context 隔离、required/custom validation、验证缓存和 debounce、Pagination 三种规则、Checkbox/Radio Boolean/null、DataGrid column/filter/sort,以及所有内置组件契约完整性。

6. 修改清单

新增或改变控件时必须同时检查:

1. registry 与 component contract;

2. `defineProps` / `defineEmits` 与契约;

3. Events tab 与 contract events;

4. 默认值、空值、Boolean 和多选序列化;

5. required/custom validation、hidden/readOnly;

6. Designer、Preview、Viewer;

7. substitution 与 i18n;

8. Vitest;复杂交互再补 Playwright。

CI 的 `Frontend unit tests and component contracts` 是必需门禁;不得用删测试或降低契约来迁就不兼容改动。