动作、触发器与客户端扩展
选择最小且安全的扩展方式:表单 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` 是必需门禁;不得用删测试或降低契约来迁就不兼容改动。