Merge branch '2.0' into 2.0-ai

This commit is contained in:
xilesun 2025-06-25 10:35:59 +08:00
commit 7a8b1fe22a
476 changed files with 22084 additions and 3435 deletions

View File

@ -36,7 +36,7 @@ jobs:
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
with: with:
context: ./docker/nocobase context: ./docker/nocobase
file: ./docker/nocobase/Dockerfile-cn file: ./docker/nocobase/Dockerfile-full
build-args: | build-args: |
CNA_VERSION=${{ inputs.tag_name }} CNA_VERSION=${{ inputs.tag_name }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

58
.github/workflows/build-pr-docker.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Build pr docker
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
push-docker:
runs-on: ubuntu-latest
services:
verdaccio:
image: verdaccio/verdaccio:5
ports:
- 4873:4873
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Aliyun Container Registry (Public)
uses: docker/login-action@v2
with:
registry: ${{ secrets.ALI_DOCKER_PUBLIC_REGISTRY }}
username: ${{ secrets.ALI_DOCKER_USERNAME }}
password: ${{ secrets.ALI_DOCKER_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
nocobase/nocobase
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
file: Dockerfile
build-args: |
VERDACCIO_URL=http://localhost:4873/
COMMIT_HASH=${GITHUB_SHA}
push: true
tags: ${{ secrets.ALI_DOCKER_PUBLIC_REGISTRY }}/nocobase/nocobase:2.0.0-alpha

View File

@ -216,7 +216,7 @@ jobs:
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
with: with:
context: ./docker/nocobase context: ./docker/nocobase
file: ./docker/nocobase/Dockerfile-cn file: ./docker/nocobase/Dockerfile-full
build-args: | build-args: |
CNA_VERSION=${{ steps.get-info.outputs.defaultTag }} CNA_VERSION=${{ steps.get-info.outputs.defaultTag }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@ -5,6 +5,148 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v1.7.17](https://github.com/nocobase/nocobase/compare/v1.7.16...v1.7.17) - 2025-06-23
### 🐛 Bug Fixes
- **[client]**
- incorrect range limitation on date fields with time ([#7107](https://github.com/nocobase/nocobase/pull/7107)) by @katherinehhh
- When URL query parameter variables are empty, the data scope conditions are not removed ([#7104](https://github.com/nocobase/nocobase/pull/7104)) by @zhangzhonghe
- **[Mobile]** Fix mobile popup z-index issue ([#7110](https://github.com/nocobase/nocobase/pull/7110)) by @zhangzhonghe
- **[Calendar]** date field issue in quick create form of calendar block ([#7106](https://github.com/nocobase/nocobase/pull/7106)) by @katherinehhh
## [v1.7.16](https://github.com/nocobase/nocobase/compare/v1.7.15...v1.7.16) - 2025-06-19
### 🐛 Bug Fixes
- **[Workflow]**
- Fix incorrectly executed checking on big integer number ([#7099](https://github.com/nocobase/nocobase/pull/7099)) by @mytharcher
- Fix stats cascade deleted by non-current workflow version ([#7103](https://github.com/nocobase/nocobase/pull/7103)) by @mytharcher
- **[Action: Import records]** Resolve login failure issue after batch import of usernames and passwords ([#7076](https://github.com/nocobase/nocobase/pull/7076)) by @aaaaaajie
- **[Workflow: Approval]** Only participants can view (get) detail of approval by @mytharcher
## [v1.7.15](https://github.com/nocobase/nocobase/compare/v1.7.14...v1.7.15) - 2025-06-18
### 🐛 Bug Fixes
- **[client]**
- Use independent variable scope for each field ([#7012](https://github.com/nocobase/nocobase/pull/7012)) by @mytharcher
- Assign field values: Unable to clear data for relation fields ([#7086](https://github.com/nocobase/nocobase/pull/7086)) by @zhangzhonghe
- Table column text alignment function is not working ([#7094](https://github.com/nocobase/nocobase/pull/7094)) by @zhangzhonghe
- **[Workflow]** Fix incorrectly executed checking on big integer number ([#7091](https://github.com/nocobase/nocobase/pull/7091)) by @mytharcher
- **[File manager]** Fix attachments field can not be updated in approval process ([#7093](https://github.com/nocobase/nocobase/pull/7093)) by @mytharcher
- **[Workflow: Approval]** Use comparison instead of implicit logic to avoid type issues by @mytharcher
## [v1.7.14](https://github.com/nocobase/nocobase/compare/v1.7.13...v1.7.14) - 2025-06-17
### 🚀 Improvements
- **[client]** Auto-hide grid card block action bar when empty ([#7069](https://github.com/nocobase/nocobase/pull/7069)) by @zhangzhonghe
- **[Verification]** Remove verifier options from the response of the `verifiers:listByUser` API ([#7090](https://github.com/nocobase/nocobase/pull/7090)) by @2013xile
### 🐛 Bug Fixes
- **[database]** support association updates in updateOrCreate and firstOrCreate ([#7088](https://github.com/nocobase/nocobase/pull/7088)) by @chenos
- **[client]**
- URL query parameter variables not working in public form field default value ([#7084](https://github.com/nocobase/nocobase/pull/7084)) by @katherinehhh
- style condition on subtable column fields not applied correctly ([#7083](https://github.com/nocobase/nocobase/pull/7083)) by @katherinehhh
- Filtering through relationship collection fields in filter forms is invalid ([#7070](https://github.com/nocobase/nocobase/pull/7070)) by @zhangzhonghe
- **[Collection field: Many to many (array)]** Updating a many to many (array) field throws an error when the `updatedBy` field is present ([#7089](https://github.com/nocobase/nocobase/pull/7089)) by @2013xile
- **[Public forms]** Public forms: Fix unauthorized access issue on form submission ([#7085](https://github.com/nocobase/nocobase/pull/7085)) by @zhangzhonghe
## [v1.7.13](https://github.com/nocobase/nocobase/compare/v1.7.12...v1.7.13) - 2025-06-17
### 🚀 Improvements
- **[client]** Logo container width adapts to content type (fixed 168px for images, auto width for text) ([#7075](https://github.com/nocobase/nocobase/pull/7075)) by @Cyx649312038
- **[Workflow: Approval]** Add extra field option for re-assignees list by @mytharcher
### 🐛 Bug Fixes
- **[client]**
- required validation message in subtable persists when switching page ([#7080](https://github.com/nocobase/nocobase/pull/7080)) by @katherinehhh
- decimal point lost after switching amount component from mask to inputNumer ([#7077](https://github.com/nocobase/nocobase/pull/7077)) by @katherinehhh
- incorrect Markdown (Vditor) rendering in subtable ([#7074](https://github.com/nocobase/nocobase/pull/7074)) by @katherinehhh
- **[Collection field: Sequence]** Fix string based bigint sequence calculation ([#7079](https://github.com/nocobase/nocobase/pull/7079)) by @mytharcher
- **[Backup manager]** unknow command error when restoring MySQL backups on windows platform by @gchust
## [v1.7.12](https://github.com/nocobase/nocobase/compare/v1.7.11...v1.7.12) - 2025-06-16
### 🚀 Improvements
- **[client]** add "empty" and "not empty" options to checkbox field linkage rules ([#7073](https://github.com/nocobase/nocobase/pull/7073)) by @katherinehhh
### 🐛 Bug Fixes
- **[client]** After creating the reverse relation field, the option "Create reverse relation field in the target data table" in the association field settings was not checked. ([#6914](https://github.com/nocobase/nocobase/pull/6914)) by @aaaaaajie
- **[Data source manager]** Scope changes now take effect immediately for all related roles. ([#7065](https://github.com/nocobase/nocobase/pull/7065)) by @aaaaaajie
- **[Access control]** Fixed an issue where the app blocked entry when no default role existed ([#7059](https://github.com/nocobase/nocobase/pull/7059)) by @aaaaaajie
- **[Workflow: Custom action event]** Fix variable of redirect url not parsed by @mytharcher
## [v1.7.11](https://github.com/nocobase/nocobase/compare/v1.7.10...v1.7.11) - 2025-06-15
### 🎉 New Features
- **[Text copy]** Support one-click copying of text field content ([#6954](https://github.com/nocobase/nocobase/pull/6954)) by @zhangzhonghe
### 🐛 Bug Fixes
- **[client]**
- association field selector does not clear selected data after submission ([#7067](https://github.com/nocobase/nocobase/pull/7067)) by @katherinehhh
- Fix upload size hint ([#7057](https://github.com/nocobase/nocobase/pull/7057)) by @mytharcher
- **[server]** Cannot read properties of undefined (reading 'setMaaintainingMessage') ([#7064](https://github.com/nocobase/nocobase/pull/7064)) by @chenos
- **[Workflow: Loop node]** Fix loop branch runs when condition not satisfied ([#7063](https://github.com/nocobase/nocobase/pull/7063)) by @mytharcher
- **[Workflow: Approval]**
- Fix todo stats not updated when execution canceled by @mytharcher
- Fix trigger variable when filter by type by @mytharcher
## [v1.7.10](https://github.com/nocobase/nocobase/compare/v1.7.9...v1.7.10) - 2025-06-12
### 🐛 Bug Fixes
- **[client]**
- Fix the issue where linkage rules cause infinite loop ([#7050](https://github.com/nocobase/nocobase/pull/7050)) by @zhangzhonghe
- Fix: use optional chaining to safely reject requests in APIClient when handler may be undefined ([#7054](https://github.com/nocobase/nocobase/pull/7054)) by @sheldon66
- auto-closing issue when configuring fields in the secondary popup form ([#7052](https://github.com/nocobase/nocobase/pull/7052)) by @katherinehhh
- **[Data visualization]** incorrect display of between date field in chart filter ([#7051](https://github.com/nocobase/nocobase/pull/7051)) by @katherinehhh
- **[API documentation]** non-NocoBase official plugins fail to display API documentation ([#7045](https://github.com/nocobase/nocobase/pull/7045)) by @chenzhizdt
- **[Action: Import records]** Fixed xlsx import to restrict textarea fields from accepting non-string formatted data ([#7049](https://github.com/nocobase/nocobase/pull/7049)) by @aaaaaajie
## [v1.7.9](https://github.com/nocobase/nocobase/compare/v1.7.8...v1.7.9) - 2025-06-11 ## [v1.7.9](https://github.com/nocobase/nocobase/compare/v1.7.8...v1.7.9) - 2025-06-11
### 🐛 Bug Fixes ### 🐛 Bug Fixes

View File

@ -5,6 +5,148 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。 并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。
## [v1.7.17](https://github.com/nocobase/nocobase/compare/v1.7.16...v1.7.17) - 2025-06-23
### 🐛 修复
- **[client]**
- 修复日期字段在含时间格式下的范围约束错误 ([#7107](https://github.com/nocobase/nocobase/pull/7107)) by @katherinehhh
- URL 查询参数变量为空时,数据范围的条件没有被移除 ([#7104](https://github.com/nocobase/nocobase/pull/7104)) by @zhangzhonghe
- **[移动端]** 修复移动端弹窗的层级问题 ([#7110](https://github.com/nocobase/nocobase/pull/7110)) by @zhangzhonghe
- **[日历]** 修复日历区块快速创建事项时,表单日期字段异常问题 ([#7106](https://github.com/nocobase/nocobase/pull/7106)) by @katherinehhh
## [v1.7.16](https://github.com/nocobase/nocobase/compare/v1.7.15...v1.7.16) - 2025-06-19
### 🐛 修复
- **[工作流]**
- 修复已执行数在大整型数时检查错误的问题 ([#7099](https://github.com/nocobase/nocobase/pull/7099)) by @mytharcher
- 修复统计数据被不是主版本的工作流级联删除的问题 ([#7103](https://github.com/nocobase/nocobase/pull/7103)) by @mytharcher
- **[操作:导入记录]** 修复批量导入用户名和密码后无法登录的问题 ([#7076](https://github.com/nocobase/nocobase/pull/7076)) by @aaaaaajie
- **[工作流:审批]** 限制只有参与者可以查看审批详情 by @mytharcher
## [v1.7.15](https://github.com/nocobase/nocobase/compare/v1.7.14...v1.7.15) - 2025-06-18
### 🐛 修复
- **[client]**
- 对每个字段使用独立的变量范围 ([#7012](https://github.com/nocobase/nocobase/pull/7012)) by @mytharcher
- 字段赋值:关系字段无法被清空数据 ([#7086](https://github.com/nocobase/nocobase/pull/7086)) by @zhangzhonghe
- 表格列的文本对齐功能无效 ([#7094](https://github.com/nocobase/nocobase/pull/7094)) by @zhangzhonghe
- **[工作流]** 修复已执行数在大整型数时检查错误的问题 ([#7091](https://github.com/nocobase/nocobase/pull/7091)) by @mytharcher
- **[文件管理器]** 修复审批处理中附件字段无法被更新的问题 ([#7093](https://github.com/nocobase/nocobase/pull/7093)) by @mytharcher
- **[工作流:审批]** 使用比较代替隐式逻辑以避免类型问题 by @mytharcher
## [v1.7.14](https://github.com/nocobase/nocobase/compare/v1.7.13...v1.7.14) - 2025-06-17
### 🚀 优化
- **[client]** 网格卡片区块操作栏为空时自动隐藏 ([#7069](https://github.com/nocobase/nocobase/pull/7069)) by @zhangzhonghe
- **[验证]** 移除 `verifiers:listByUser` 接口中响应的认证器配置信息 ([#7090](https://github.com/nocobase/nocobase/pull/7090)) by @2013xile
### 🐛 修复
- **[database]** 修复 updateOrCreate 和 firstOrCreate 不支持关系更新的问题 ([#7088](https://github.com/nocobase/nocobase/pull/7088)) by @chenos
- **[client]**
- 修复公开表单字段默认值中 URL 查询参数变量无效的问题 ([#7084](https://github.com/nocobase/nocobase/pull/7084)) by @katherinehhh
- 修复 子表格列字段 style 条件判断无效的问题 ([#7083](https://github.com/nocobase/nocobase/pull/7083)) by @katherinehhh
- 筛选表单中,通过关系表字段筛选无效 ([#7070](https://github.com/nocobase/nocobase/pull/7070)) by @zhangzhonghe
- **[数据表字段:多对多 (数组)]** 存在 `updatedBy` 字段的时,更新多对多(数组)字段报错 ([#7089](https://github.com/nocobase/nocobase/pull/7089)) by @2013xile
- **[公开表单]** 公开表单:修复提交表单时报无权限的问题 ([#7085](https://github.com/nocobase/nocobase/pull/7085)) by @zhangzhonghe
## [v1.7.13](https://github.com/nocobase/nocobase/compare/v1.7.12...v1.7.13) - 2025-06-17
### 🚀 优化
- **[client]** Logo 容器宽度根据内容类型自适应(图片固定 168px文本自动宽度 ([#7075](https://github.com/nocobase/nocobase/pull/7075)) by @Cyx649312038
- **[工作流:审批]** 为转签、加签的人员选择列表增加额外字段显示的配置项 by @mytharcher
### 🐛 修复
- **[client]**
- 修复子表格字段切换页面后必填提示不消失的问题 ([#7080](https://github.com/nocobase/nocobase/pull/7080)) by @katherinehhh
- 修复金额字段组件从掩码改为数字后小数点丢失的问题 ([#7077](https://github.com/nocobase/nocobase/pull/7077)) by @katherinehhh
- 修复子表格中 MarkdownVditor字段组件渲染不正确的问题 ([#7074](https://github.com/nocobase/nocobase/pull/7074)) by @katherinehhh
- **[数据表字段:自动编码]** 修复基于字符串的大整数序列计算 ([#7079](https://github.com/nocobase/nocobase/pull/7079)) by @mytharcher
- **[备份管理器]** windows 平台下,还原 MySQL 应用时提示无法识别的命令错误 by @gchust
## [v1.7.12](https://github.com/nocobase/nocobase/compare/v1.7.11...v1.7.12) - 2025-06-16
### 🚀 优化
- **[client]** checkbox 字段联动条件判断支持 "为空”和“不为空” ([#7073](https://github.com/nocobase/nocobase/pull/7073)) by @katherinehhh
### 🐛 修复
- **[client]** 创建反向关系字段后,编辑关系字段设置项“在目标数据表里创建反向关系字段”未勾选 ([#6914](https://github.com/nocobase/nocobase/pull/6914)) by @aaaaaajie
- **[数据源管理]** 修改权限的数据范围后,相关角色同步生效 ([#7065](https://github.com/nocobase/nocobase/pull/7065)) by @aaaaaajie
- **[权限控制]** 修复了在没有默认角色时无法进入应用的问题 ([#7059](https://github.com/nocobase/nocobase/pull/7059)) by @aaaaaajie
- **[工作流:自定义操作事件]** 修复操作成功后配置中的重定向链接变量未解析的问题 by @mytharcher
## [v1.7.11](https://github.com/nocobase/nocobase/compare/v1.7.10...v1.7.11) - 2025-06-15
### 🎉 新特性
- **[文本复制]** 支持一键复制文本字段内容 ([#6954](https://github.com/nocobase/nocobase/pull/6954)) by @zhangzhonghe
### 🐛 修复
- **[client]**
- 关系字段数据选择器提交后未清空选中数据 ([#7067](https://github.com/nocobase/nocobase/pull/7067)) by @katherinehhh
- 修复上传组件的大小提示文字 ([#7057](https://github.com/nocobase/nocobase/pull/7057)) by @mytharcher
- **[server]** Cannot read properties of undefined (reading 'setMaaintainingMessage') ([#7064](https://github.com/nocobase/nocobase/pull/7064)) by @chenos
- **[工作流:循环节点]** 修复循环分支在条件未满足时仍然执行的问题 ([#7063](https://github.com/nocobase/nocobase/pull/7063)) by @mytharcher
- **[工作流:审批]**
- 修复待办统计在执行计划取消后未更新的问题 by @mytharcher
- 修复触发器变量中按类型过滤的缺陷 by @mytharcher
## [v1.7.10](https://github.com/nocobase/nocobase/compare/v1.7.9...v1.7.10) - 2025-06-12
### 🐛 修复
- **[client]**
- 修复联动规则卡死的问题 ([#7050](https://github.com/nocobase/nocobase/pull/7050)) by @zhangzhonghe
- 修复:在 APIClient 中添加可选链以避免 handler 未定义时报错 ([#7054](https://github.com/nocobase/nocobase/pull/7054)) by @sheldon66
- 修复二级弹窗配置表单字段时自动关闭弹窗的问题 ([#7052](https://github.com/nocobase/nocobase/pull/7052)) by @katherinehhh
- **[数据可视化]** 修复图表区块中筛选表单的日期字段设置为“介于”时组件未正确显示的问题 ([#7051](https://github.com/nocobase/nocobase/pull/7051)) by @katherinehhh
- **[API 文档]** 非 NocoBase 官方插件无法展示API文档 ([#7045](https://github.com/nocobase/nocobase/pull/7045)) by @chenzhizdt
- **[操作:导入记录]** 导入 xlsx 禁止多行文本字段插入非字符串格式数据 ([#7049](https://github.com/nocobase/nocobase/pull/7049)) by @aaaaaajie
## [v1.7.9](https://github.com/nocobase/nocobase/compare/v1.7.8...v1.7.9) - 2025-06-11 ## [v1.7.9](https://github.com/nocobase/nocobase/compare/v1.7.8...v1.7.9) - 2025-06-11
### 🐛 修复 ### 🐛 修复

View File

@ -1,19 +0,0 @@
#!/bin/sh
set -e
nginx
echo 'nginx started';
cd /app/nocobase && yarn nocobase db:auth --retry=30
cd /app/nocobase && yarn nocobase install -s
cd /app/nocobase && yarn nocobase upgrade -S
cd /app/nocobase && yarn start
# Run command with node if the first argument contains a "-" or is not a system command. The last
# part inside the "{}" is a workaround for the following bug in ash/dash:
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=874264
if [ "${1#-}" != "${1}" ] || [ -z "$(command -v "${1}")" ] || { [ -f "${1}" ] && ! [ -x "${1}" ]; }; then
set -- node "$@"
fi
exec "$@"

View File

@ -1,43 +0,0 @@
log_format apm '"$time_local" client=$remote_addr '
'method=$request_method request="$request" '
'request_length=$request_length '
'status=$status bytes_sent=$bytes_sent '
'body_bytes_sent=$body_bytes_sent '
'referer=$http_referer '
'user_agent="$http_user_agent" '
'upstream_addr=$upstream_addr '
'upstream_status=$upstream_status '
'request_time=$request_time '
'upstream_response_time=$upstream_response_time '
'upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time';
server {
listen 80;
server_name _;
root /app/nocobase/packages/app/client/dist;
index index.html;
client_max_body_size 0;
access_log /var/log/nginx/nocobase.log apm;
location /storage/uploads/ {
alias /app/nocobase/storage/uploads/;
autoindex off;
}
location / {
root /app/nocobase/packages/app/client/dist;
try_files $uri $uri/ /index.html;
}
location ^~ /api/ {
proxy_pass http://127.0.0.1:13000/api/;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"npmClientArgs": ["--ignore-engines"], "npmClientArgs": ["--ignore-engines"],

View File

@ -1,13 +1,13 @@
{ {
"name": "@nocobase/acl", "name": "@nocobase/acl",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"description": "", "description": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/resourcer": "1.8.0-alpha.5", "@nocobase/resourcer": "1.8.0-alpha.9",
"@nocobase/utils": "1.8.0-alpha.5", "@nocobase/utils": "1.8.0-alpha.9",
"minimatch": "^5.1.1" "minimatch": "^5.1.1"
}, },
"repository": { "repository": {

View File

@ -1,14 +1,14 @@
{ {
"name": "@nocobase/actions", "name": "@nocobase/actions",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"description": "", "description": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/cache": "1.8.0-alpha.5", "@nocobase/cache": "1.8.0-alpha.9",
"@nocobase/database": "1.8.0-alpha.5", "@nocobase/database": "1.8.0-alpha.9",
"@nocobase/resourcer": "1.8.0-alpha.5" "@nocobase/resourcer": "1.8.0-alpha.9"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -9,4 +9,7 @@
import { proxyToRepository } from './proxy-to-repository'; import { proxyToRepository } from './proxy-to-repository';
export const firstOrCreate = proxyToRepository(['values', 'filterKeys'], 'firstOrCreate'); export const firstOrCreate = proxyToRepository(
['values', 'filterKeys', 'whitelist', 'blacklist', 'updateAssociationValues', 'targetCollection'],
'firstOrCreate',
);

View File

@ -9,4 +9,7 @@
import { proxyToRepository } from './proxy-to-repository'; import { proxyToRepository } from './proxy-to-repository';
export const updateOrCreate = proxyToRepository(['values', 'filterKeys'], 'updateOrCreate'); export const updateOrCreate = proxyToRepository(
['values', 'filterKeys', 'whitelist', 'blacklist', 'updateAssociationValues', 'targetCollection'],
'updateOrCreate',
);

View File

@ -1,17 +1,17 @@
{ {
"name": "@nocobase/app", "name": "@nocobase/app",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"description": "", "description": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/database": "1.8.0-alpha.5", "@nocobase/database": "1.8.0-alpha.9",
"@nocobase/preset-nocobase": "1.8.0-alpha.5", "@nocobase/preset-nocobase": "1.8.0-alpha.9",
"@nocobase/server": "1.8.0-alpha.5" "@nocobase/server": "1.8.0-alpha.9"
}, },
"devDependencies": { "devDependencies": {
"@nocobase/client": "1.8.0-alpha.5" "@nocobase/client": "1.8.0-alpha.9"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,16 +1,16 @@
{ {
"name": "@nocobase/auth", "name": "@nocobase/auth",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"description": "", "description": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/actions": "1.8.0-alpha.5", "@nocobase/actions": "1.8.0-alpha.9",
"@nocobase/cache": "1.8.0-alpha.5", "@nocobase/cache": "1.8.0-alpha.9",
"@nocobase/database": "1.8.0-alpha.5", "@nocobase/database": "1.8.0-alpha.9",
"@nocobase/resourcer": "1.8.0-alpha.5", "@nocobase/resourcer": "1.8.0-alpha.9",
"@nocobase/utils": "1.8.0-alpha.5", "@nocobase/utils": "1.8.0-alpha.9",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"jsonwebtoken": "^9.0.2" "jsonwebtoken": "^9.0.2"
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/build", "name": "@nocobase/build",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"description": "Library build tool based on rollup.", "description": "Library build tool based on rollup.",
"main": "lib/index.js", "main": "lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",

View File

@ -1,12 +1,12 @@
{ {
"name": "@nocobase/cache", "name": "@nocobase/cache",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"description": "", "description": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/lock-manager": "1.8.0-alpha.5", "@nocobase/lock-manager": "1.8.0-alpha.9",
"bloom-filters": "^3.0.1", "bloom-filters": "^3.0.1",
"cache-manager": "^5.2.4", "cache-manager": "^5.2.4",
"cache-manager-redis-yet": "^4.1.2" "cache-manager-redis-yet": "^4.1.2"

View File

@ -117,4 +117,19 @@ describe('cache', () => {
expect(val2).toBe(obj); expect(val2).toBe(obj);
expect(await cache.get('key')).toMatchObject(obj); expect(await cache.get('key')).toMatchObject(obj);
}); });
it('redis cache wrap null throw error', async () => {
if (!process.env.CACHE_REDIS_URL) {
return;
}
const cacheManager = new CacheManager({
stores: {
redis: {
url: process.env.CACHE_REDIS_URL,
},
},
});
const c = await cacheManager.createCache({ name: 'test', store: 'redis' });
expect(async () => c.wrap('test', async () => null)).rejects.toThrowError('"null" is not a cacheable value');
});
}); });

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/cli", "name": "@nocobase/cli",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"description": "", "description": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./src/index.js", "main": "./src/index.js",
@ -8,7 +8,7 @@
"nocobase": "./bin/index.js" "nocobase": "./bin/index.js"
}, },
"dependencies": { "dependencies": {
"@nocobase/app": "1.8.0-alpha.5", "@nocobase/app": "1.8.0-alpha.9",
"@nocobase/license-kit": "^0.2.3", "@nocobase/license-kit": "^0.2.3",
"@types/fs-extra": "^11.0.1", "@types/fs-extra": "^11.0.1",
"@umijs/utils": "3.5.20", "@umijs/utils": "3.5.20",
@ -27,7 +27,7 @@
"tsx": "^4.19.0" "tsx": "^4.19.0"
}, },
"devDependencies": { "devDependencies": {
"@nocobase/devtools": "1.8.0-alpha.5" "@nocobase/devtools": "1.8.0-alpha.9"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -115,7 +115,7 @@ exports.postCheck = async (opts) => {
const port = opts.port || process.env.APP_PORT; const port = opts.port || process.env.APP_PORT;
const result = await exports.isPortReachable(port); const result = await exports.isPortReachable(port);
if (result) { if (result) {
console.error(chalk.red(`post already in use ${port}`)); console.error(chalk.red(`Port ${port} already in use`));
process.exit(1); process.exit(1);
} }
}; };

View File

@ -10,6 +10,7 @@ console.log('process.env.DOC_LANG', lang);
export default defineConfig({ export default defineConfig({
hash: true, hash: true,
mfsu:false,
alias: { alias: {
...umiConfig.alias, ...umiConfig.alias,
}, },
@ -82,6 +83,10 @@ export default defineConfig({
// }, // },
// ], // ],
// }, // },
{
title: 'Quickstart',
link: '/core/flow-models/quickstart',
},
{ {
title: 'FlowEngine', title: 'FlowEngine',
type: 'group', type: 'group',
@ -140,10 +145,6 @@ export default defineConfig({
title: 'Flow Models', title: 'Flow Models',
type: 'group', type: 'group',
children: [ children: [
{
title: 'Quickstart',
link: '/core/flow-models/quickstart',
},
{ {
title: 'Overview', title: 'Overview',
link: '/core/flow-models', link: '/core/flow-models',

View File

@ -1,198 +0,0 @@
[
{
"key": "h7b9i8khc3q",
"name": "users",
"inherit": false,
"hidden": false,
"description": null,
"category": [],
"namespace": "users.users",
"duplicator": {
"dumpable": "optional",
"with": "rolesUsers"
},
"sortable": "sort",
"model": "UserModel",
"createdBy": true,
"updatedBy": true,
"logging": true,
"from": "db2cm",
"title": "{{t(\"Users\")}}",
"rawTitle": "{{t(\"Users\")}}",
"fields": [
{
"uiSchema": {
"type": "number",
"title": "{{t(\"ID\")}}",
"x-component": "InputNumber",
"x-read-pretty": true,
"rawTitle": "{{t(\"ID\")}}"
},
"key": "ffp1f2sula0",
"name": "id",
"type": "bigInt",
"interface": "id",
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"autoIncrement": true,
"primaryKey": true,
"allowNull": false
},
{
"uiSchema": {
"type": "string",
"title": "{{t(\"Nickname\")}}",
"x-component": "Input",
"rawTitle": "{{t(\"Nickname\")}}"
},
"key": "vrv7yjue90g",
"name": "nickname",
"type": "string",
"interface": "input",
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null
},
{
"uiSchema": {
"type": "string",
"title": "{{t(\"Username\")}}",
"x-component": "Input",
"x-validator": {
"username": true
},
"required": true,
"rawTitle": "{{t(\"Username\")}}"
},
"key": "2ccs6evyrub",
"name": "username",
"type": "string",
"interface": "input",
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"unique": true
},
{
"uiSchema": {
"type": "string",
"title": "{{t(\"Email\")}}",
"x-component": "Input",
"x-validator": "email",
"required": true,
"rawTitle": "{{t(\"Email\")}}"
},
"key": "rrskwjl5wt1",
"name": "email",
"type": "string",
"interface": "email",
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"unique": true
},
{
"key": "t09bauwm0wb",
"name": "roles",
"type": "belongsToMany",
"interface": "m2m",
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"target": "roles",
"foreignKey": "userId",
"otherKey": "roleName",
"onDelete": "CASCADE",
"sourceKey": "id",
"targetKey": "name",
"through": "rolesUsers",
"uiSchema": {
"type": "array",
"title": "{{t(\"Roles\")}}",
"x-component": "AssociationField",
"x-component-props": {
"multiple": true,
"fieldNames": {
"label": "title",
"value": "name"
}
}
}
}
]
},
{
"key": "pqnenvqrzxr",
"name": "roles",
"inherit": false,
"hidden": false,
"description": null,
"category": [],
"namespace": "acl.acl",
"duplicator": {
"dumpable": "required",
"with": "uiSchemas"
},
"autoGenId": false,
"model": "RoleModel",
"filterTargetKey": "name",
"sortable": true,
"from": "db2cm",
"title": "{{t(\"Roles\")}}",
"rawTitle": "{{t(\"Roles\")}}",
"fields": [
{
"uiSchema": {
"type": "string",
"title": "{{t(\"Role UID\")}}",
"x-component": "Input",
"rawTitle": "{{t(\"Role UID\")}}"
},
"key": "jbz9m80bxmp",
"name": "name",
"type": "uid",
"interface": "input",
"description": null,
"collectionName": "roles",
"parentKey": null,
"reverseKey": null,
"prefix": "r_",
"primaryKey": true
},
{
"uiSchema": {
"type": "string",
"title": "{{t(\"Role name\")}}",
"x-component": "Input",
"rawTitle": "{{t(\"Role name\")}}"
},
"key": "faywtz4sf3u",
"name": "title",
"type": "string",
"interface": "input",
"description": null,
"collectionName": "roles",
"parentKey": null,
"reverseKey": null,
"unique": true,
"translation": true
},
{
"key": "1enkovm9sye",
"name": "description",
"type": "string",
"interface": null,
"description": null,
"collectionName": "roles",
"parentKey": null,
"reverseKey": null
}
]
}
]

View File

@ -1,98 +0,0 @@
import {
Application,
ApplicationOptions,
CardItem,
Plugin,
CollectionPlugin,
DataBlockProvider,
DEFAULT_DATA_SOURCE_KEY,
DEFAULT_DATA_SOURCE_TITLE,
LocalDataSource,
} from '@nocobase/client';
import MockAdapter from 'axios-mock-adapter';
import { ComponentType } from 'react';
import collections from './collections.json';
const defaultMocks = {
'users:list': {
data: [
{
id: '1',
username: 'jack',
nickname: 'Jack Ma',
email: 'test@gmail.com',
},
{
id: '2',
username: 'jim',
nickname: 'Jim Green',
},
{
id: '3',
username: 'tom',
nickname: 'Tom Cat',
email: 'tom@gmail.com',
},
],
},
'roles:list': {
data: [
{
name: 'root',
title: 'Root',
description: 'Root',
},
{
name: 'admin',
title: 'Admin',
description: 'Admin description',
},
],
},
};
export function createApp(
Demo: ComponentType<any>,
options: ApplicationOptions = {},
mocks: Record<string, any> = defaultMocks,
) {
class MyPlugin extends Plugin {
async load() {
this.app.dataSourceManager.addDataSource(LocalDataSource, {
key: DEFAULT_DATA_SOURCE_KEY,
displayName: DEFAULT_DATA_SOURCE_TITLE,
collections: collections as any,
});
}
}
const app = new Application({
apiClient: {
baseURL: 'http://localhost:8000',
},
providers: [Demo],
...options,
components: {
...options.components,
DataBlockProvider,
CardItem,
},
plugins: [CollectionPlugin, MyPlugin, ...(options.plugins || [])],
designable: true,
});
const mock = new MockAdapter(app.apiClient.axios);
Object.entries(mocks).forEach(([url, data]) => {
mock.onGet(url).reply(async (config) => {
const res = typeof data === 'function' ? data(config) : data;
return [200, res];
});
mock.onPost(url).reply(async (config) => {
const res = typeof data === 'function' ? data(config) : data;
return [200, res];
});
});
const Root = app.getRootComponent();
return Root;
}

View File

@ -0,0 +1,95 @@
import * as icons from '@ant-design/icons';
import { Plugin } from '@nocobase/client';
import { defineFlow, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
import { Button } from 'antd';
import React from 'react';
import { createApp } from './createApp';
// 自定义模型类,继承自 FlowModel
class MyModel extends FlowModel {
render() {
console.log('Rendering MyModel with props:', this.props);
return (
<Button
{...this.props}
onClick={(event) => {
this.dispatchEvent('onClick', { event });
}}
/>
);
}
}
const myPropsFlow = defineFlow({
key: 'myPropsFlow',
auto: true,
title: '按钮配置',
steps: {
setProps: {
title: '按钮属性设置',
uiSchema: {
title: {
type: 'string',
title: '按钮标题',
'x-component': 'Input',
'x-decorator': 'FormItem',
},
type: {
type: 'string',
title: '类型',
'x-component': 'Select',
'x-decorator': 'FormItem',
enum: [
{ label: '主要', value: 'primary' },
{ label: '次要', value: 'default' },
{ label: '危险', value: 'danger' },
{ label: '虚线', value: 'dashed' },
{ label: '链接', value: 'link' },
{ label: '文本', value: 'text' },
],
},
icon: {
type: 'string',
title: '图标',
'x-component': 'Select',
'x-decorator': 'FormItem',
enum: [
{ label: '搜索', value: 'SearchOutlined' },
{ label: '添加', value: 'PlusOutlined' },
{ label: '删除', value: 'DeleteOutlined' },
{ label: '编辑', value: 'EditOutlined' },
{ label: '设置', value: 'SettingOutlined' },
],
},
},
defaultParams: {
type: 'primary',
title: 'Primary Button',
},
// 步骤处理函数,设置模型属性
handler(ctx, params) {
console.log('Setting props:', params);
ctx.model.setProps('children', params.title);
ctx.model.setProps('type', params.type);
ctx.model.setProps('icon', params.icon ? React.createElement(icons[params.icon]) : undefined);
},
},
},
});
MyModel.registerFlow(myPropsFlow);
class PluginDemo extends Plugin {
async load() {
this.flowEngine.registerModels({ MyModel });
const model = this.flowEngine.createModel({
use: 'MyModel',
});
this.router.add('root', {
path: '/',
element: <FlowModelRenderer model={model} showFlowSettings />,
});
}
}
export default createApp({ plugins: [PluginDemo] });

View File

@ -0,0 +1,89 @@
import * as icons from '@ant-design/icons';
import { Plugin } from '@nocobase/client';
import { defineAction, defineFlow, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
import { Button } from 'antd';
import React from 'react';
import { createApp } from './createApp';
// 自定义模型类,继承自 FlowModel
class MyModel extends FlowModel {
render() {
return (
<Button
{...this.props}
onClick={(event) => {
this.dispatchEvent('click', { event });
}}
>
</Button>
);
}
}
const myEventFlow = defineFlow({
key: 'myEventFlow',
on: {
eventName: 'click',
},
steps: {
confirm: {
use: 'confirm',
},
next: {
handler(ctx) {
ctx.globals.message.success(`继续执行后续操作`);
},
},
},
});
MyModel.registerFlow(myEventFlow);
const myConfirm = defineAction({
name: 'confirm',
uiSchema: {
title: {
type: 'string',
title: 'Confirm title',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
content: {
type: 'string',
title: 'Confirm content',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
},
defaultParams: {
title: 'Confirm Deletion',
content: 'Are you sure you want to delete this record?',
},
async handler(ctx, params) {
const confirmed = await ctx.globals.modal.confirm({
title: params.title,
content: params.content,
});
if (!confirmed) {
ctx.globals.message.info('Action cancelled.');
return ctx.exit();
}
},
});
class PluginDemo extends Plugin {
async load() {
this.flowEngine.registerModels({ MyModel });
this.flowEngine.registerAction(myConfirm);
const model = this.flowEngine.createModel({
use: 'MyModel',
});
this.router.add('root', {
path: '/',
element: <FlowModelRenderer model={model} showFlowSettings />,
});
}
}
export default createApp({ plugins: [PluginDemo] });

View File

@ -0,0 +1,9 @@
import { Application, Plugin } from '@nocobase/client';
export function createApp({ plugins = [] }: { plugins?: Array<typeof Plugin> } = {}) {
const app = new Application({
router: { type: 'memory', initialEntries: ['/'] },
plugins: [...plugins],
});
return app.getRootComponent();
}

View File

@ -0,0 +1,88 @@
import { Plugin } from '@nocobase/client';
import { defineFlow, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
import { Button, ConfigProvider, theme } from 'antd';
import React from 'react';
import { createApp } from './createApp';
class MyPopupModel extends FlowModel {
render() {
return (
<Button
{...this.props}
onClick={(event) => {
this.dispatchEvent('onClick', { event });
}}
>
</Button>
);
}
}
const myEventFlow = defineFlow({
key: 'myEventFlow',
on: {
eventName: 'onClick',
},
steps: {
step1: {
handler(ctx, params) {
ctx.globals.drawer.open({
title: '命令式 Drawer',
content: (
<div>
<p> Drawer1 </p>
<Button
onClick={() => {
ctx.globals.drawer.open({
title: '命令式 Drawer',
content: (
<div>
<p> Drawer2 </p>
</div>
),
});
}}
>
Show
</Button>
</div>
),
});
},
},
},
});
MyPopupModel.registerFlow(myEventFlow);
function CustomConfigProvider({ children }) {
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#52c41a',
},
}}
>
{children}
</ConfigProvider>
);
}
class PluginDemo extends Plugin {
async load() {
this.flowEngine.registerModels({ MyPopupModel });
const model = this.flowEngine.createModel({
use: 'MyPopupModel',
});
this.router.add('root', {
path: '/',
element: <FlowModelRenderer model={model} showFlowSettings />,
});
this.app.providers.unshift([CustomConfigProvider, {}]);
}
}
export default createApp({ plugins: [PluginDemo] });

View File

@ -0,0 +1,38 @@
// components/drawer/useDrawer/DrawerComponent.tsx
import { Drawer } from 'antd';
import React, { forwardRef, useImperativeHandle, useState } from 'react';
interface DrawerComponentProps extends React.ComponentProps<typeof Drawer> {
afterClose?: () => void;
content?: React.ReactNode;
}
const DrawerComponent = forwardRef<unknown, DrawerComponentProps>(({ afterClose, ...props }, ref) => {
const [visible, setVisible] = useState(true);
const [config, setConfig] = useState(props);
useImperativeHandle(ref, () => ({
destroy: () => setVisible(false),
update: (newConfig) => setConfig((prev) => ({ ...prev, ...newConfig })),
}));
return (
<Drawer
{...config}
open={visible}
onClose={(e) => {
setVisible(false);
config.onClose?.(e);
}}
afterOpenChange={(open) => {
if (!open) {
afterClose?.();
}
}}
>
{config.content}
</Drawer>
);
});
export default DrawerComponent;

View File

@ -0,0 +1,47 @@
import * as React from 'react';
import DrawerComponent from './DrawerComponent';
import usePatchElement from './usePatchElement';
let uuid = 0;
function useDrawer() {
const holderRef = React.useRef(null);
const open = (config) => {
uuid += 1;
const drawerRef = React.createRef<{ destroy: () => void; update: (config: any) => void }>();
// eslint-disable-next-line prefer-const
let closeFunc: (() => void) | undefined;
const drawer = (
<DrawerComponent
key={`drawer-${uuid}`}
ref={drawerRef}
{...config}
afterClose={() => {
closeFunc?.();
config.onClose?.();
}}
/>
);
closeFunc = holderRef.current?.patchElement(drawer);
return {
destroy: () => drawerRef.current?.destroy(),
update: (newConfig) => drawerRef.current?.update(newConfig),
};
};
const api = React.useMemo(() => ({ open }), []);
const ElementsHolder = React.memo(
React.forwardRef((props, ref) => {
const [elements, patchElement] = usePatchElement();
React.useImperativeHandle(ref, () => ({ patchElement }), []);
return <>{elements}</>;
}),
);
return [api, <ElementsHolder key="drawer-holder" ref={holderRef} />];
}
export default useDrawer;

View File

@ -0,0 +1,18 @@
import * as React from 'react';
export default function usePatchElement(): [React.ReactElement[], (element: React.ReactElement) => () => void] {
const [elements, setElements] = React.useState<React.ReactElement[]>([]);
const patchElement = React.useCallback((element: React.ReactElement) => {
// append a new element to elements (and create a new ref)
setElements((originElements) => [...originElements, element]);
// return a function that removes the new element out of elements (and create a new ref)
// it works a little like useEffect
return () => {
setElements((originElements) => originElements.filter((ele) => ele !== element));
};
}, []);
return [elements, patchElement];
}

View File

@ -1 +1,13 @@
# Flow Actions # Flow Actions
## 基础示例
<code src="./demos/basic.tsx"></code>
## 弹窗
<code src="./demos/popup.tsx"></code>
## Confirm
<code src="./demos/confirm.tsx"></code>

View File

@ -0,0 +1,82 @@
import React from 'react';
import LazyDropdown, { Item } from './LazyDropdown';
const items: () => Promise<Item[]> = async () => {
await new Promise((resolve) => setTimeout(resolve, 1500)); // 模拟延迟
return [
{
key: 'dashboard',
label: '仪表盘',
children: [
{ key: 'overview', label: '概览' },
{ key: 'analytics', label: '分析' },
],
},
{
type: 'divider',
},
{
key: 'settings',
label: '设置',
type: 'group',
children: async () => {
console.log('Loading settings...');
await new Promise((resolve) => setTimeout(resolve, 500)); // 模拟延迟
return [
{ key: 'profile', label: '个人资料' },
{ key: 'preferences', label: '偏好设置' },
];
},
},
{
type: 'divider',
},
{
key: 'reports',
label: '报表',
children: async () => {
await new Promise((resolve) => setTimeout(resolve, 500)); // 模拟延迟
return [
{
key: 'sales',
label: '销售报表',
children: async () => {
await new Promise((resolve) => setTimeout(resolve, 500)); // 模拟延迟
return [
{ key: 'sales-1', label: '销售 子项 1' },
{ key: 'sales-2', label: '销售 子项 2' },
];
},
},
{
key: 'finance',
label: '财务报表',
children: async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
return [
{ key: 'finance-1', label: '财务 子项 1' },
{ key: 'finance-2', label: '财务 子项 2' },
];
},
},
];
},
},
];
};
export default function App() {
console.log('App rendered');
return (
<LazyDropdown
menu={{
onClick(info) {
console.log('Menu item clicked:', info);
},
items,
}}
>
<a>AA</a>
</LazyDropdown>
);
}

View File

@ -0,0 +1,203 @@
import { Dropdown, DropdownProps, Menu, Spin } from 'antd';
import React, { useEffect, useState } from 'react';
// 菜单项类型定义
export type Item = {
key?: string;
type?: 'group' | 'divider'; // 支持 group 类型
label?: React.ReactNode;
children?: Item[] | (() => Item[] | Promise<Item[]>);
[key: string]: any; // 允许其他属性
};
export type ItemsType = Item[] | (() => Item[] | Promise<Item[]>);
interface LazyDropdownMenuProps extends Omit<DropdownProps['menu'], 'items'> {
items: ItemsType;
}
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
const [loadedChildren, setLoadedChildren] = useState<Record<string, Item[]>>({});
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(new Set());
const [menuVisible, setMenuVisible] = useState(false);
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
const [rootItems, setRootItems] = useState<Item[]>([]);
const [rootLoading, setRootLoading] = useState(false);
const getKeyPath = (path: string[], key: string) => [...path, key].join('/');
// 通用的异步/同步 children 解析
const resolveChildren = async (children: Item[] | (() => Item[] | Promise<Item[]>)) => {
if (typeof children === 'function') {
const res = children();
return res instanceof Promise ? await res : res;
}
return children;
};
const handleLoadChildren = async (keyPath: string, loader: () => Item[] | Promise<Item[]>) => {
if (loadedChildren[keyPath] || loadingKeys.has(keyPath)) return;
setLoadingKeys((prev) => new Set(prev).add(keyPath));
try {
const children = loader();
const resolved = children instanceof Promise ? await children : children;
setLoadedChildren((prev) => ({ ...prev, [keyPath]: resolved }));
} catch (err) {
console.error(`Failed to load children for ${keyPath}`, err);
} finally {
setLoadingKeys((prev) => {
const next = new Set(prev);
next.delete(keyPath);
return next;
});
}
};
// 收集所有异步 group
const collectAsyncGroups = (items: Item[], path: string[] = []): [string, () => Item[] | Promise<Item[]>][] => {
const result: [string, () => Item[] | Promise<Item[]>][] = [];
for (const item of items) {
const keyPath = getKeyPath(path, item.key);
if (item.type === 'group' && typeof item.children === 'function') {
result.push([keyPath, item.children]);
}
if (Array.isArray(item.children)) {
result.push(...collectAsyncGroups(item.children, [...path, item.key]));
}
}
return result;
};
// 加载根 items支持同步/异步函数
useEffect(() => {
const loadRootItems = async () => {
let items: Item[];
if (typeof menu.items === 'function') {
setRootLoading(true);
try {
const res = menu.items();
items = res instanceof Promise ? await res : res;
} finally {
setRootLoading(false);
}
} else {
items = menu.items;
}
setRootItems(items);
};
if (menuVisible) {
loadRootItems();
}
}, [menu.items, menuVisible]);
// 自动加载所有 group 的异步 children
useEffect(() => {
if (!menuVisible || !rootItems.length) return;
const asyncGroups = collectAsyncGroups(rootItems);
for (const [keyPath, loader] of asyncGroups) {
if (!loadedChildren[keyPath] && !loadingKeys.has(keyPath)) {
handleLoadChildren(keyPath, loader);
}
}
}, [menuVisible, rootItems]);
// 递归解析 items支持 children 为同步/异步函数
const resolveItems = (items: Item[], path: string[] = []): any[] => {
return items.map((item) => {
const keyPath = getKeyPath(path, item.key);
const isGroup = item.type === 'group';
const hasAsyncChildren = typeof item.children === 'function';
const isLoading = loadingKeys.has(keyPath);
const loaded = loadedChildren[keyPath];
// 非 group 的异步 children鼠标悬浮时加载
const shouldLoadChildren =
!isGroup && menuVisible && openKeys.has(keyPath) && hasAsyncChildren && !loaded && !isLoading;
if (shouldLoadChildren) {
handleLoadChildren(keyPath, item.children as () => Item[] | Promise<Item[]>);
}
let children: Item[] | undefined;
if (hasAsyncChildren) {
children = loaded ?? [];
} else if (Array.isArray(item.children)) {
children = item.children;
}
if (hasAsyncChildren && !loaded) {
children = [
{
key: `${keyPath}-loading`,
label: <Spin size="small" />,
disabled: true,
} as Item,
];
}
if (isGroup) {
return {
type: 'group',
key: item.key,
label: item.label,
children: children ? resolveItems(children, [...path, item.key]) : [],
};
}
if (item.type === 'divider') {
return { type: 'divider', key: item.key };
}
return {
key: item.key,
label: item.label,
onClick: (info) => {
if (children) {
return;
}
menu.onClick?.({
...info,
originalItem: item,
} as any); // 👈 强制扩展类型
},
onMouseEnter: () => {
setOpenKeys((prev) => {
if (prev.has(keyPath)) return prev;
const next = new Set(prev);
next.add(keyPath);
return next;
});
},
children: children && children.length > 0 ? resolveItems(children, [...path, item.key]) : undefined,
};
});
};
return (
<Dropdown
{...props}
dropdownRender={() =>
rootLoading && rootItems.length === 0 ? (
<Menu
items={[
{
key: `root-loading`,
label: <Spin size="small" />,
disabled: true,
},
]}
/>
) : (
<Menu {...menu} onClick={() => {}} items={resolveItems(rootItems)} />
)
}
onOpenChange={(visible) => setMenuVisible(visible)}
>
{props.children}
</Dropdown>
);
};
export default LazyDropdown;

View File

@ -0,0 +1,157 @@
import { Application, Plugin } from '@nocobase/client';
import { FlowModel, FlowModelRenderer, AddSubModelButton } from '@nocobase/flow-engine';
import { Button, Card } from 'antd';
import React from 'react';
class SubModel1 extends FlowModel {
render() {
return <Card style={{ marginBottom: 24 }}>{this.props.children}</Card>;
}
}
SubModel1.registerFlow({
key: 'myflow',
auto: true,
title: '子模型 1',
steps: {
step1: {
title: '步骤 1',
paramsRequired: true,
uiSchema: {
title: {
'x-component': 'Input',
'x-component-props': {
placeholder: '请输入标题',
},
title: '标题',
'x-decorator': 'FormItem',
},
},
handler(ctx, params) {
ctx.model.setProps('children', params.title);
},
},
},
});
class MyModel extends FlowModel {
// 渲染模型内容
render() {
return (
<div>
{this.mapSubModels('items', (item) => (
<FlowModelRenderer model={item} showFlowSettings />
))}
<div />
<AddSubModelButton
model={this}
subModelKey={'items'}
items={async () => {
await new Promise((resolve) => setTimeout(resolve, 1500));
return [
{
key: 'subModel1',
label: '子模型 1',
disabled: true,
icon: <span>🔧</span>,
createModelOptions: {
use: 'SubModel1',
stepParams: {
myflow: {
step1: {
title: '子模型 1',
},
},
},
},
},
{
key: 'subModel2',
label: '子模型 2',
icon: <span>🛠</span>,
createModelOptions: {
use: 'SubModel1',
stepParams: {
myflow: {
step1: {
title: '子模型 2',
},
},
},
},
},
{
key: 'b-group',
label: '模型 B 组',
icon: <span>🛠</span>,
children: async () => {
await new Promise((resolve) => setTimeout(resolve, 1500));
return [
{
key: 'b1',
label: '模型 B1',
icon: <span>🛠</span>,
createModelOptions: {
use: 'SubModel1',
stepParams: {
myflow: {
step1: {
title: '子模型 B1',
},
},
},
},
},
{
key: 'b2',
label: '模型 B2',
icon: <span>🛠</span>,
createModelOptions: {
use: 'SubModel1',
stepParams: {
myflow: {
step1: {
title: '子模型 B2',
},
},
},
},
},
];
},
},
];
}}
>
<Button></Button>
</AddSubModelButton>
</div>
);
}
}
// 插件类,负责注册模型、仓库,并加载或创建模型实例
class PluginHelloModel extends Plugin {
async load() {
// 注册自定义模型
this.flowEngine.registerModels({ MyModel, SubModel1 });
// 加载或创建模型实例(如不存在则创建并初始化)
const model = this.flowEngine.createModel({
uid: 'my-model',
use: 'MyModel',
});
// 注册路由,渲染模型
this.router.add('root', {
path: '/',
element: <FlowModelRenderer model={model} />,
});
}
}
// 创建应用实例,注册插件
const app = new Application({
router: { type: 'memory', initialEntries: ['/'] },
plugins: [PluginHelloModel],
});
export default app.getRootComponent();

View File

@ -6,7 +6,7 @@ import React from 'react';
// 实现一个本地存储的模型仓库,负责模型的持久化 // 实现一个本地存储的模型仓库,负责模型的持久化
class FlowModelRepository implements IFlowModelRepository<FlowModel> { class FlowModelRepository implements IFlowModelRepository<FlowModel> {
// 从本地存储加载模型数据 // 从本地存储加载模型数据
async load(uid: string) { async findOne({ uid }) {
const data = localStorage.getItem(`flow-model:${uid}`); const data = localStorage.getItem(`flow-model:${uid}`);
if (!data) return null; if (!data) return null;
return JSON.parse(data); return JSON.parse(data);

View File

@ -1,6 +1,6 @@
# FlowAction # FlowAction
`FlowAction` 是 NocoBase 流引擎中用于定义和注册流步骤可复用操作Action的核心对象。每个操作Action封装一段可执行的业务逻辑,可在多个流步骤中复用支持参数配置、UI 配置和类型推断。 `FlowAction` 是 NocoBase 流引擎中用于定义和注册流步骤可复用操作Action的核心对象。每个操作封装一段可执行的业务逻辑可在多个流步骤中复用支持参数配置、UI 配置和类型推断。
--- ---
@ -10,48 +10,50 @@
interface ActionDefinition { interface ActionDefinition {
name: string; // 操作唯一标识,必须唯一 name: string; // 操作唯一标识,必须唯一
title?: string; // 操作显示名称(可选) title?: string; // 操作显示名称(可选)
uiSchema?: Record<string, ISchema>; // (可选)用于参数配置界面渲染 uiSchema?: Record<string, ISchema>; // (可选)参数配置界面渲染
defaultParams?: Record<string, any>; // (可选)默认参数 defaultParams?: Record<string, any>; // (可选)默认参数
paramsRequired?: boolean; // (可选)是否需要参数配置为true时添加模型前会打开配置对话框 paramsRequired?: boolean; // (可选)是否需要参数配置
hideInSettings?: boolean; // (可选)是否在设置菜单中隐藏该步骤 hideInSettings?: boolean; // (可选)是否在设置菜单中隐藏
handler: (ctx: FlowContext, params: any) => Promise<any> | any; // 操作执行逻辑 handler: (ctx: FlowContext, params: any) => Promise<any> | any; // 操作执行逻辑
} }
``` ```
--- ---
## 定义操作的方式 ## 使用说明
### 1. 使用 defineAction 工具函数 ### 1. 定义 Action
推荐方式,结构清晰、类型推断友好: #### 方式一:使用 defineAction 工具函数(推荐)
结构清晰,类型推断友好:
```ts ```ts
const myAction = defineAction({ const myAction = defineAction({
name: 'actionName', name: 'myAction',
title: '操作显示名称', title: '操作显示名称',
uiSchema: {}, uiSchema: {},
defaultParams: {}, defaultParams: {},
paramsRequired: true, // 添加模型前强制打开配置对话框 paramsRequired: true,
hideInSettings: false, // 在设置菜单中显示 hideInSettings: false,
async handler(ctx, params) { async handler(ctx, params) {
// 操作逻辑 // 操作逻辑
}, },
}); });
``` ```
### 2. 实现 ActionDefinition 接口 #### 方式二:实现 ActionDefinition 接口
适合需要扩展属性或方法的场景: 复杂场景时可以通过定义 Action 类来处理更复杂的操作
```ts ```ts
class MyAction implements ActionDefinition { class MyAction implements ActionDefinition {
name = 'actionName'; name = 'myAction';
title = '操作显示名称'; title = '操作显示名称';
uiSchema = {}; uiSchema = {};
defaultParams = {}; defaultParams = {};
paramsRequired = true; // 添加模型前强制打开配置对话框 paramsRequired = true;
hideInSettings = false; // 在设置菜单中显示 hideInSettings = false;
async handler(ctx, params) { async handler(ctx, params) {
// 操作逻辑 // 操作逻辑
} }
@ -60,59 +62,115 @@ class MyAction implements ActionDefinition {
--- ---
## 注册操作 ### 2. 注册到 FlowEngine 里
注册后可在流步骤中通过 `use` 字段复用:
```ts ```ts
flowEngine.registerAction({
name: 'actionName',
title: '操作显示名称',
uiSchema: {},
defaultParams: {},
paramsRequired: true, // 添加模型前强制打开配置对话框
hideInSettings: false, // 在设置菜单中显示
handler(ctx, params) {
// 操作逻辑
},
});
flowEngine.registerAction(myAction); // 注册 defineAction 返回的对象 flowEngine.registerAction(myAction); // 注册 defineAction 返回的对象
flowEngine.registerAction(new MyAction()); // 注册类实例 flowEngine.registerAction(new MyAction()); // 注册类实例
``` ```
--- ---
## 在流中复用操作 ### 3. 在流中使用
在流步骤定义中通过 `use` 字段引用已注册的操作: 在流步骤定义中通过 `use` 字段引用已注册的操作:
```ts ```ts
steps: { steps: {
step1: { step1: {
use: 'actionName', // 复用已注册的操作 use: 'myAction', // 复用已注册的操作
defaultParams: {}, defaultParams: {},
paramsRequired: true, // 可以在步骤级别覆盖操作的paramsRequired设置 paramsRequired: true, // 可覆盖操作的 paramsRequired
hideInSettings: false, // 可以在步骤级别覆盖操作的hideInSettings设置 hideInSettings: false, // 可覆盖操作的 hideInSettings
}, },
} }
``` ```
--- ---
## 配置选项说明 ## 参数配置详解
### name
- **类型**: `string`
- **说明**: 操作唯一标识,必须全局唯一。建议使用有业务含义的英文名,便于维护和复用。
### title
- **类型**: `string`
- **说明**: 操作的显示名称,通常用于界面展示。支持多语言配置。
### defaultParams
- **类型**: `Record<string, any>``(ctx) => Record<string, any>`
- **说明**: 操作参数的默认值。支持静态对象或函数(可根据 context 动态生成)。
- **作用**: 作为 handler 的 params 默认值。
**静态用法:**
```ts
{
defaultParams: { key1: 'val1' },
async handler(ctx, params) {
console.log(params.key1); // val1
},
}
```
**动态用法:**
```ts
{
defaultParams(ctx) {
return { key1: 'val1' }
},
async handler(ctx, params) {
console.log(params.key1); // val1
},
}
```
### handler
- **类型**: `(ctx: FlowContext, params: any) => Promise<any> | any`
- **说明**: 操作的核心执行逻辑。支持异步和同步函数。`ctx` 提供当前流上下文,`params` 为参数对象。
### uiSchema
- **类型**: `Omit<FormilySchema, 'default'>`
- **说明**: 用于参数的可视化配置表单。推荐与 defaultParams 配合使用,提升用户体验。
- **注意**: uiSchema 不支持 default 参数,避免与 defaultParams 重复。
### paramsRequired ### paramsRequired
- **类型**: `boolean` - **类型**: `boolean`
- **默认值**: `false` - **默认值**: `false`
- **说明**: 当设置为 `true` 时,在添加该步骤模型前会强制打开参数配置对话框,确保用户配置必要的参数。适用于需要用户必须配置参数才能正常工作的操作。 - **说明**: 为 `true` 时,添加步骤前会强制打开参数配置对话框,确保用户配置必要参数。适用于参数必填的场景
### hideInSettings ### hideInSettings
- **类型**: `boolean` - **类型**: `boolean`
- **默认值**: `false` - **默认值**: `false`
- **说明**: 当设置为 `true` 时,该步骤将在设置菜单中隐藏,用户无法通过 Settings 界面直接添加该步骤。适用于初始化配置场景。 - **说明**: 为 `true` 时,该步骤在设置菜单中隐藏,用户无法通过 Settings 界面直接添加。适用于初始化配置或内部步骤。
---
## 最佳实践
- 推荐优先使用 `defineAction` 工具函数定义操作,结构更清晰,类型推断更友好。
- `name` 字段建议采用有业务含义的英文名,避免重名。
- `uiSchema``defaultParams` 配合使用,提升参数配置体验。
- 对于需要用户强制配置参数的操作,设置 `paramsRequired: true`
- 内部或自动化步骤可设置 `hideInSettings: true`,避免用户误操作。
---
## 常见问题与注意事项
- **Q: defaultParams 和 uiSchema 有什么区别?**
> defaultParams 用于设置参数默认值uiSchema 用于渲染参数配置表单。两者配合使用,互不冲突。
- **Q: uiSchema 为什么不支持 default**
> 1. 为避免与 defaultParams 重复uiSchema 仅用于表单结构描述,不处理默认值;
> 2. 使用 defaultParams 处理可以有更好的 ts 类型提示;
> 3. uiSchema 的结构可能较为复杂,解析 uiSchema 来提取 default 值非常繁琐且容易出错,因此不建议在 uiSchema 中处理 default
--- ---
@ -121,3 +179,4 @@ steps: {
- **FlowAction** 让流步骤逻辑高度复用,便于维护和扩展。 - **FlowAction** 让流步骤逻辑高度复用,便于维护和扩展。
- 支持多种定义方式,适应不同复杂度的业务场景。 - 支持多种定义方式,适应不同复杂度的业务场景。
- 可通过 `uiSchema``defaultParams` 配置参数界面和默认值,提升易用性。 - 可通过 `uiSchema``defaultParams` 配置参数界面和默认值,提升易用性。
- 合理使用 `paramsRequired``hideInSettings`,提升操作安全性和灵活性。

View File

@ -7,18 +7,34 @@
## 核心结构 ## 核心结构
```ts ```ts
interface FlowDefinition { interface FlowDefinition<TModel extends FlowModel = FlowModel> {
key: string; // 流唯一标识 key: string; // 流唯一标识
on?: { event: string }; // 可选:事件触发配置 title?: string; // 可选:流显示名称
auto?: boolean; // 可选:是否自动运行 auto?: boolean; // 可选:是否自动运行
steps: Record<string, StepDefinition>; // 流步骤定义 sort?: number; // 可选:流执行排序,数字越小越先执行,默认为 0可为负数
on?: { eventName: string }; // 可选:事件触发配置
steps: Record<string, StepDefinition<TModel>>; // 流步骤定义
} }
interface StepDefinition { // 步骤定义支持两种类型ActionStepDefinition 和 InlineStepDefinition
use?: string; // 可选:引用已注册的全局 Action interface ActionStepDefinition<TModel extends FlowModel = FlowModel> {
defaultParams?: any; // 默认参数 use: string; // 引用已注册的全局 Action 名称
uiSchema?: any; // 可选:用于 FlowSettings 配置界面 title?: string; // 可选:步骤显示名称
handler?: (ctx: any, params: any) => Promise<any>; // 可选:步骤处理函数 isAwait?: boolean; // 可选:是否等待步骤执行完成,默认为 true
defaultParams?: Record<string, any> | ((ctx: ParamsContext<TModel>) => Record<string, any> | Promise<Record<string, any>>); // 可选:默认参数,支持静态对象或动态函数
uiSchema?: Record<string, ISchema>; // 可选:用于 FlowSettings 配置界面
paramsRequired?: boolean; // 可选:是否需要参数配置,为 true 时添加模型前会打开配置对话框
hideInSettings?: boolean; // 可选:是否在设置菜单中隐藏该步骤
}
interface InlineStepDefinition<TModel extends FlowModel = FlowModel> {
handler: (ctx: FlowContext<TModel>, params: any) => Promise<any> | any; // 步骤处理函数
title?: string; // 可选:步骤显示名称
isAwait?: boolean; // 可选:是否等待步骤执行完成,默认为 true
defaultParams?: Record<string, any> | ((ctx: ParamsContext<TModel>) => Record<string, any> | Promise<Record<string, any>>); // 可选:默认参数,支持静态对象或动态函数
uiSchema?: Record<string, ISchema>; // 可选:用于 FlowSettings 配置界面
paramsRequired?: boolean; // 可选:是否需要参数配置,为 true 时添加模型前会打开配置对话框
hideInSettings?: boolean; // 可选:是否在设置菜单中隐藏该步骤
} }
``` ```
@ -38,20 +54,41 @@ type MyFlowSteps = {
const myFlow = defineFlow<MyFlowSteps>({ const myFlow = defineFlow<MyFlowSteps>({
key: 'myFlow', key: 'myFlow',
on: { event: 'user.created' }, // 监听 user.created 事件自动触发 title: '我的流程',
auto: true, // 自动执行
sort: 100, // 执行顺序
on: { eventName: 'user.created' }, // 监听 user.created 事件自动触发
steps: { steps: {
step1: { step1: {
defaultParams: {}, title: '步骤1',
// 静态默认参数
defaultParams: {
name: 'test'
},
async handler(ctx, params) { async handler(ctx, params) {
// 步骤 1 的处理逻辑 // 步骤 1 的处理逻辑
ctx.logger.info('执行步骤1', params);
// 例如console.log(params.name); // 例如console.log(params.name);
} }
}, },
step2: { step2: {
uiSchema: {}, // 可用于 UI 配置 title: '步骤2',
defaultParams: {}, uiSchema: {
age: {
type: 'number',
title: '年龄',
'x-component': 'InputNumber',
}
}, // 可用于 UI 配置
// 动态默认参数 - 根据模型状态生成
defaultParams: (ctx) => ({
name: ctx.model.name,
timestamp: Date.now(),
}),
async handler(ctx, params) { async handler(ctx, params) {
// 步骤 2 的处理逻辑 // 步骤 2 的处理逻辑
ctx.logger.info('执行步骤2', params);
// 可以访问前一步的结果ctx.stepResults.step1
// 例如console.log(params.age); // 例如console.log(params.age);
} }
}, },
@ -70,6 +107,9 @@ MyFlowModel.registerFlow(myFlow); // 注册流
```ts ```ts
class MyFlowDefinition implements FlowDefinition { class MyFlowDefinition implements FlowDefinition {
key = 'MyFlowDefinition'; key = 'MyFlowDefinition';
title = '我的复杂流程';
auto = true;
sort = 0;
steps = { steps = {
step1: { step1: {
@ -156,8 +196,8 @@ myModel.setStepParams('myFlow', 'step1', { name: '小明' });
```ts ```ts
await myModel.applyFlow('myFlow'); // 主动执行指定流 await myModel.applyFlow('myFlow'); // 主动执行指定流
myModel.dispatchEvent('user.created'); // 分发事件触发流(如流配置了 on.event myModel.dispatchEvent('user.created'); // 分发事件触发流(如流配置了 on.eventName
await myModel.applyAutoFlows(); // 执行所有 auto=true 的流 await myModel.applyAutoFlows(); // 执行所有 auto=true 的流,按 sort 排序
``` ```
--- ---
@ -169,8 +209,10 @@ await myModel.applyAutoFlows(); // 执行所有 auto=true 的流
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| ----------- | -------------------------------- | ---------------------------------- | | ----------- | -------------------------------- | ---------------------------------- |
| `key` | `string` | 流唯一标识,必须配置 | | `key` | `string` | 流唯一标识,必须配置 |
| `on` | `{ event: string }` | (可选)事件触发配置 | | `title` | `string` | (可选)流显示名称 |
| `on` | `{ eventName: string }` | (可选)事件触发配置 |
| `auto` | `boolean` | (可选)是否在 `applyAutoFlows()` 中自动执行流 | | `auto` | `boolean` | (可选)是否在 `applyAutoFlows()` 中自动执行流 |
| `sort` | `number` | (可选)流执行排序,数字越小越先执行,默认为 0可为负数 |
| `steps` | `Record<string, StepDefinition>` | 步骤集合,键为步骤名,值为步骤定义 | | `steps` | `Record<string, StepDefinition>` | 步骤集合,键为步骤名,值为步骤定义 |
### StepDefinition 配置速查表 ### StepDefinition 配置速查表
@ -178,8 +220,12 @@ await myModel.applyAutoFlows(); // 执行所有 auto=true 的流
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --------------- | -------------------------------------- | ------------------------------------- | | --------------- | -------------------------------------- | ------------------------------------- |
| `use` | `string` | (可选)引用已注册的全局 Action | | `use` | `string` | (可选)引用已注册的全局 Action |
| `defaultParams` | `any` | 步骤的默认参数 | | `title` | `string` | (可选)步骤显示名称 |
| `isAwait` | `boolean` | (可选)是否等待步骤执行完成,默认为 true |
| `defaultParams` | `Record<string, any>` \| `(ctx: ParamsContext) => Record<string, any> \| Promise<Record<string, any>>` | (可选)步骤的默认参数,支持静态对象或动态函数 |
| `uiSchema` | `any` | (可选)用于 FlowSettings UI 渲染 | | `uiSchema` | `any` | (可选)用于 FlowSettings UI 渲染 |
| `paramsRequired`| `boolean` | (可选)是否需要参数配置,为 true 时添加模型前会打开配置对话框 |
| `hideInSettings`| `boolean` | (可选)是否在设置菜单中隐藏该步骤 |
| `handler` | `(ctx, params) => Promise<any>` | (可选)步骤执行逻辑,若未定义则使用 `use` 指定的全局 Action | | `handler` | `(ctx, params) => Promise<any>` | (可选)步骤执行逻辑,若未定义则使用 `use` 指定的全局 Action |
--- ---

View File

@ -82,6 +82,116 @@
- **flowSettings.openStepSettingsDialog(props: StepSettingsDialogProps)** - **flowSettings.openStepSettingsDialog(props: StepSettingsDialogProps)**
显示单个步骤的配置界面。 显示单个步骤的配置界面。
- **flowSettings.openRequiredParamsStepFormDialog(props: StepFormDialogProps)**
显示多个需要配置参数的步骤的分步表单界面。
#### 工具栏扩展 (Toolbar Extensions)
FlowSettings 支持在右上角悬浮工具栏中添加自定义项目组件:
- **flowSettings.addToolbarItem(config: ToolbarItemConfig): void**
添加单个工具栏项目。
- **flowSettings.addToolbarItems(configs: ToolbarItemConfig[]): void**
批量添加工具栏项目。
- **flowSettings.removeToolbarItem(key: string): void**
移除指定的工具栏项目。
- **flowSettings.getToolbarItems(): ToolbarItemConfig[]**
获取所有工具栏项目。
- **flowSettings.clearToolbarItems(): void**
清空所有工具栏项目。
**ToolbarItemConfig 接口:**
```typescript
interface ToolbarItemConfig {
key: string; // 项目唯一标识
component: React.ComponentType<{ model: FlowModel }>; // 项目组件
visible?: (model: FlowModel) => boolean; // 显示条件函数
sort?: number; // 排序权重,数字越小或越晚添加的越靠右
}
```
**使用示例:**
```typescript
import { useFlowEngine } from '@nocobase/flow-engine';
import { CopyOutlined } from '@ant-design/icons';
import { Tooltip, message } from 'antd';
const CopyIcon: React.FC<{ model: FlowModel }> = ({ model }) => {
const handleCopy = () => {
navigator.clipboard.writeText(model.uid);
message.success('UID 已复制');
};
return (
<Tooltip title="复制 UID">
<CopyOutlined
onClick={handleCopy}
style={{ cursor: 'pointer', fontSize: 12 }}
/>
</Tooltip>
);
};
// 注册工具栏项目
const MyComponent = () => {
const flowEngine = useFlowEngine();
useEffect(() => {
flowEngine.flowSettings.addToolbarItem({
key: 'copy',
component: CopyIcon,
sort: 10
});
}, [flowEngine]);
return <div>My Component</div>;
};
```
**注意事项:**
- 工具栏项目组件内部需要处理所有逻辑(点击、菜单、状态等)
- 使用 Tooltip 提供操作说明,提升用户体验
#### 步骤上下文 (Step Context)
FlowSettings 为配置组件提供了上下文功能,使组件能够访问当前步骤的相关信息:
- **useStepSettingContext(): StepSettingContextType**
React Hook用于在配置组件中获取当前步骤的上下文信息包括
- `model`: 当前的 FlowModel 实例
- `globals`: 全局上下文数据
- `app`: FlowEngine 应用实例
- `step`: 当前步骤定义
- `flow`: 当前流程定义
- `flowKey`: 流程标识
- `stepKey`: 步骤标识
**使用示例:**
```typescript
import { useStepSettingContext } from '@nocobase/flow-engine';
const MyCustomSettingField = () => {
const { model, step, flow, flowKey, stepKey } = useStepSettingContext();
// 基于当前步骤信息进行自定义逻辑
const handleAction = () => {
console.log('当前步骤:', step.title);
console.log('所属流程:', flow.title);
};
return <Input />;
};
```
**注意:**
- 在单步骤配置对话框中,上下文提供完整的步骤信息
- 在多步骤表单中,上下文会随着步骤切换动态更新
- 上下文同时也会添加到 SchemaField 的 scope 中,可在 uiSchema 中直接使用
--- ---
## 示例 ## 示例

View File

@ -1,2 +1,55 @@
# FlowHooks # FlowHooks
Flow Engine 提供了一系列 React Hooks 来简化 FlowModel 的使用和流程执行。
## Model Hooks
### useFlowModel
从 React Context 中获取 FlowModel 实例,避免 prop drilling。
```tsx
import { FlowModelProvider, useFlowModel } from '@nocobase/flow-engine';
// 提供 model 上下文
<FlowModelProvider model={model}>
<ChildComponent />
</FlowModelProvider>
// 在子组件中获取 model
const ChildComponent = () => {
const model = useFlowModel<MyFlowModel>();
return <div>{model.uid}</div>;
};
```
**参数**:无
**返回值**`T extends FlowModel` - FlowModel 实例
**异常**:如果在 FlowModelProvider 外部使用会抛出错误
### useFlowModelById
根据 UID 获取或创建 FlowModel 实例。
```tsx
import { useFlowModelById } from '@nocobase/flow-engine';
const MyComponent = () => {
const model = useFlowModelById<MyFlowModel>(
'my-model-uid',
'MyFlowModel',
{ defaultFlow: { step1: { name: 'test' } } }
);
return <div>{model.props.name}</div>;
};
```
**参数**
- `uid: string` - 模型唯一标识
- `modelClassName?: string` - 模型类名(用于创建新实例)
- `stepParams?: StepParams` - 初始步骤参数
**返回值**`T extends FlowModel` - FlowModel 实例
## 流程执行 Hooks

View File

@ -4,7 +4,7 @@
## 主要方法 ## 主要方法
- **load(uid: string): Promise<FlowModel \| null>** - **findOne(query: Query): Promise<FlowModel \| null>**
根据唯一标识符 uid 从远程加载模型数据。 根据唯一标识符 uid 从远程加载模型数据。
- **save(model: FlowModel): Promise<any>** - **save(model: FlowModel): Promise<any>**
@ -19,7 +19,8 @@
class FlowModelRepository implements IFlowModelRepository<FlowModel> { class FlowModelRepository implements IFlowModelRepository<FlowModel> {
constructor(private app: Application) {} constructor(private app: Application) {}
async load(uid: string) { async findOne(query) {
const { uid, parentId } = query;
// 实现:根据 uid 获取模型 // 实现:根据 uid 获取模型
return null; return null;
} }

View File

@ -110,6 +110,9 @@
- **addSubModel(subKey: string, options): FlowModel** - **addSubModel(subKey: string, options): FlowModel**
创建并添加一个子模型到数组字段(如 tabs、columns 创建并添加一个子模型到数组字段(如 tabs、columns
- **findSubModel\<K, R\>(subKey: K, callback: (model) => R): R**
查找子模型
- **mapSubModels\<K, R\>(subKey: K, callback: (model) => R): R[]** - **mapSubModels\<K, R\>(subKey: K, callback: (model) => R): R[]**
遍历指定 key 的子模型,对每个子模型执行 callback 函数,并返回结果数组。 遍历指定 key 的子模型,对每个子模型执行 callback 函数,并返回结果数组。
- 支持完整的类型推导callback 参数会自动推导为正确的模型类型 - 支持完整的类型推导callback 参数会自动推导为正确的模型类型
@ -207,127 +210,3 @@ interface DefaultStructure {
subModels?: Record<string, FlowModel | FlowModel[]>; subModels?: Record<string, FlowModel | FlowModel[]>;
} }
``` ```
---
## 子模型添加按钮组件
为方便在界面中动态添加子模型,框架提供了 4 个 React 按钮组件:
1. `AddSubModelButton`(通用)
2. `AddBlockButton`(添加区块模型)
3. `AddFieldButton`(添加字段模型)
4. `AddActionButton`(添加 Action 模型)
### 通用添加子模型
`AddSubModelButton` 是最基础的按钮组件,用于向任意父模型添加任意类型的子模型。其余三个按钮组件都基于它做了场景化封装。
#### 主要 Props
| Prop | 类型 | 说明 |
|------|------|------|
| `model` | `FlowModel` **(必填)** | 当前父模型实例 |
| `items` | `AddSubModelMenuItem[]` **(必填)** | 可供选择的子模型类型列表 |
| `subModelType` | `'object' \| 'array'` | 指定子模型是对象字段还是数组字段,默认为 `'array'` |
| `subModelKey` | `string` | 子模型在父模型中的字段名 |
| `ParentModelClass` | `string \| ModelConstructor` | 父模型类名(用于过滤支持的子模型类型) |
| `onModelAdded` | `(subModel, item) => Promise<void>` | 添加成功后的回调,可返回 Promise 以执行异步逻辑 |
| `children` | `ReactNode` | 按钮文案,默认 `"Add"` |
| `buildSubModelParams` | `(item) => CreateModelOptions \| FlowModel` | 自定义子模型创建参数 |
#### 菜单项定义 `AddSubModelMenuItem`
```ts
interface AddSubModelMenuItem {
key: string; // 唯一键
label: string; // 菜单展示文案
icon?: ReactNode; // 可选图标
item: typeof FlowModel; // 对应的模型类
use: string; // createModel 时的 use 值
}
```
#### 组件行为
**鼠标悬停** 方式展示下拉菜单,用户点击菜单项后执行相应的子模型添加逻辑。
#### 使用示例
```ts
<AddSubModelButton
model={parentModel}
subModelKey="tabs"
subModelType="array"
items={[
{
key: 'TabFlowModel',
label: '选项卡',
item: TabFlowModel,
use: 'TabFlowModel',
},
]}
/>
```
### 添加区块子模型
`AddBlockButton` 专门用于向父模型添加**区块子模型**。相比 `AddSubModelButton`,它会自动根据 `ParentModelClass` 检索所有合法的区块模型类并构造菜单,不需要手动传入 `items`
#### 额外 Props
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `ParentModelClass` | `string` | `'BlockFlowModel'` | 区块模型的父类名 |
#### 使用示例
```ts
<AddBlockButton
model={gridModel}
// 其余参数均可使用默认值
/>
```
### 添加字段子模型
`AddFieldButton` 用于为 **字段** 相关的父模型(如表格列、表单项)快速添加对应的字段子模型。会自动根据 `collection` 中的所有 CollectionField 自动匹配合适的模型类并构造菜单,不需要手动传入 `items`
#### 额外 Props
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `collection` | `Collection` **(必填)** | 字段所属的数据表集合 |
| `ParentModelClass` | `string` | `'FieldFlowModel'` | 字段模型的父类名 |
| `buildSubModelParams` | `(item) => CreateModelOptions \| FlowModel` | 自定义创建逻辑 |
#### 使用示例
```ts
<AddFieldButton
model={tableColumnModel}
collection={postCollection}
ParentModelClass={CollectionFieldFlowModel}
buildSubModelParams={buildColumnSubModelParams}
onModelAdded={onModelAdded}
/>
```
### 添加 Action 子模型
`AddActionButton` 用于向父模型添加**Action 子模型**。会自动根据 `ParentModelClass` 检索所有合法的 Action 模型类并构造菜单,不需要手动传入 `items`
#### 额外 Props
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `ParentModelClass` | `string` | `'ActionFlowModel'` | 动作模型的父类名 |
#### 使用示例
```ts
<AddActionButton
model={blockModel}
ParentModelClass={ActionFlowModel}
/>
```

View File

@ -111,6 +111,7 @@ console.log(apiResource.getData());
- `setSourceId(sourceId) / getSourceId()`: 设置/获取源对象 ID。 - `setSourceId(sourceId) / getSourceId()`: 设置/获取源对象 ID。
- `setDataSourceKey(dataSourceKey) / getDataSourceKey()`: 设置/获取数据源标识(通过 header - `setDataSourceKey(dataSourceKey) / getDataSourceKey()`: 设置/获取数据源标识(通过 header
- `setFilter(filter) / getFilter()`: 设置/获取过滤条件。 - `setFilter(filter) / getFilter()`: 设置/获取过滤条件。
- `addFilterGroup(key, filter) / removeFilterGroup(key)`: 设置/移除条件组。
- `setAppends(appends) / getAppends()`: 设置/获取附加字段。 - `setAppends(appends) / getAppends()`: 设置/获取附加字段。
- `addAppends(appends) / removeAppends(appends)`: 添加/移除附加字段。 - `addAppends(appends) / removeAppends(appends)`: 添加/移除附加字段。
- `setFilterByTk(filterByTk) / getFilterByTk()`: 设置/获取主键过滤条件。 - `setFilterByTk(filterByTk) / getFilterByTk()`: 设置/获取主键过滤条件。

View File

@ -1,6 +1,6 @@
# FlowSubModel # FlowSubModel
在 NocoBase 流引擎中,**子模型SubModel**是构建复杂模型树结构的核心能力。通过子模型机制,可以灵活地实现模型的嵌套、分组、组合等多层级结构,满足各种业务场景下的需求。 在 NocoBase 流引擎中,**子模型SubModel** 是构建复杂模型树结构的核心能力。通过子模型机制,可以灵活地实现模型的嵌套、分组、组合等多层级结构,满足各种业务场景下的需求。
--- ---
@ -41,9 +41,16 @@ FlowModel 提供了丰富的 API 用于子模型的创建、添加、遍历和
| `setParent(parent)` | 设置父模型 | | `setParent(parent)` | 设置父模型 |
| `createRootModel(options)` | 创建根模型(通常由 flowEngine 调用) | | `createRootModel(options)` | 创建根模型(通常由 flowEngine 调用) |
**注意事项**
- 推荐通过 `setSubModel``addSubModel` 方法管理子模型,避免直接操作 `subModels` 字段。
- 子模型字段不存在时会自动初始化为合适的类型(对象或数组)。
- 子模型的类型和结构建议通过泛型参数进行类型约束,提升类型安全和开发体验。
- 组件添加子模型时,通常会自动维护父子关系和数据同步。
--- ---
## 典型用法示例 ## 用法示例
```ts ```ts
// 创建根模型 // 创建根模型
@ -67,14 +74,14 @@ model.mapSubModels('tabs', (tab) => {
## 子模型的父子关系 ## 子模型的父子关系
- 每个子模型都自动维护对父模型的引用(`parent`)。 - 每个子模型都自动维护对父模型的引用(`parent`)。
- 父模型通过 `subModels` 字段管理所有子模型。 - 父模型通过 `subModels` 管理所有子模型。
- 通过 `setParent` 方法可手动设置父模型,但一般无需手动操作。 - 通过 `setParent` 方法可手动设置父模型,但一般无需手动操作。
--- ---
## 子模型的使用场景 ## 子模型的使用场景
作为组件渲染 ### 作为组件渲染
```tsx | pure ```tsx | pure
model.mapSubModels('tabs', (tab) => { model.mapSubModels('tabs', (tab) => {
@ -82,7 +89,7 @@ model.mapSubModels('tabs', (tab) => {
}); });
``` ```
作为属性值使用 ### 作为属性值使用
```tsx | pure ```tsx | pure
await model.applySubModelsAutoFlows(ctx); await model.applySubModelsAutoFlows(ctx);
@ -92,150 +99,19 @@ const columns = model.mapSubModels('columns', (column) => column.getProps());
--- ---
## 子模型操作相关组件 ## 子模型管理组件
NocoBase 提供了多种 React 组件,方便在界面上动态添加、删除子模型,提升开发体验: NocoBase 提供了子模型管理组件,方便在界面上动态添加、删除子模型,提升开发体验:
### 1. AddSubModelButton通用添加按钮 - AddSubModelButton
- AddBlockModelButton
- AddFieldModelButton
- AddActionModelButton
- 用于向任意父模型添加任意类型的子模型。 <code src="./demos/flow-sub-model.tsx"></code>
- 支持自定义菜单项、回调、按钮内容等。
- 适用于绝大多数子模型添加场景。
**主要 Props**
| Prop | 类型 | 说明 |
|------|------|------|
| `model` | `FlowModel` **(必填)** | 当前父模型实例 |
| `items` | `AddSubModelMenuItem[]` **(必填)** | 可供选择的子模型类型列表 |
| `subModelType` | `'object' \| 'array'` | 指定子模型是对象字段还是数组字段,默认为 `'array'` |
| `subModelKey` | `string` | 子模型在父模型中的字段名 |
| `ParentModelClass` | `string \| ModelConstructor` | 父模型类名(用于过滤支持的子模型类型) |
| `onModelAdded` | `(subModel, item) => Promise<void>` | 添加成功后的回调,可返回 Promise 以执行异步逻辑 |
| `children` | `ReactNode` | 按钮文案,默认 `"Add"` |
| `buildSubModelParams` | `(item) => CreateModelOptions \| FlowModel` | 自定义子模型创建参数 |
**菜单项定义:**
```ts
interface AddSubModelMenuItem {
key: string; // 唯一键
label: string; // 菜单展示文案
icon?: ReactNode; // 可选图标
item: typeof FlowModel; // 对应的模型类
use: string; // createModel 时的 use 值
}
```
**使用示例:**
```tsx | pure
const currentModel = new MyModel();
<AddSubModelButton
model={currentModel}
subModelKey="tabs"
subModelType="array"
items={[
{
key: 'key1',
icon: <Icon />,
label: '子模型1',
options: {
use: 'TabModel',
stepParams: {},
},
},
]}
/>
// 等价于
currentModel.addSubModel('tabs', {
use: 'TabModel',
stepParams: {},
});
```
--- ---
### 2. AddBlockButton添加区块子模型 ## 总结
- 专用于向父模型添加**区块子模型**。
- 自动根据 `ParentModelClass` 检索所有合法的区块模型类并构造菜单,无需手动传入 `items`
**额外 Props**
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `ParentModelClass` | `string` | `'BlockFlowModel'` | 区块模型的父类名 |
**使用示例:**
```tsx | pure
<AddBlockButton
model={gridModel}
// 其余参数均可使用默认值
/>
```
---
### 3. AddFieldButton添加字段子模型
- 用于为**字段相关父模型**(如表格列、表单项)快速添加字段子模型。
- 自动根据 `collection` 匹配合适的模型类,无需手动传入 `items`
**额外 Props**
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `collection` | `Collection` **(必填)** | 字段所属的数据表集合 |
| `ParentModelClass` | `string` | `'FieldFlowModel'` | 字段模型的父类名 |
| `buildSubModelParams` | `(item) => CreateModelOptions \| FlowModel` | 自定义创建逻辑 |
**使用示例:**
```tsx | pure
<AddFieldButton
model={tableColumnModel}
collection={postCollection}
ParentModelClass={CollectionFieldFlowModel}
buildSubModelParams={buildColumnSubModelParams}
onModelAdded={onModelAdded}
/>
```
---
### 4. AddActionButton添加 Action 子模型)
- 用于向父模型添加**Action 子模型**。
- 自动根据 `ParentModelClass` 检索所有合法的 Action 模型类并构造菜单,无需手动传入 `items`
**额外 Props**
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `ParentModelClass` | `string` | `'ActionFlowModel'` | 动作模型的父类名 |
**使用示例:**
```tsx | pure
<AddActionButton
model={blockModel}
ParentModelClass={ActionFlowModel}
/>
```
---
## 注意事项
- 推荐通过 `setSubModel``addSubModel` 方法管理子模型,避免直接操作 `subModels` 字段。
- 子模型字段不存在时会自动初始化为合适的类型(对象或数组)。
- 子模型的类型和结构建议通过泛型参数进行类型约束,提升类型安全和开发体验。
- 组件添加子模型时,通常会自动维护父子关系和数据同步。
---
通过子模型机制与配套组件NocoBase 支持灵活的模型树结构和动态 UI 组织,是低代码建模和流程引擎的基础能力之一。 通过子模型机制与配套组件NocoBase 支持灵活的模型树结构和动态 UI 组织,是低代码建模和流程引擎的基础能力之一。

View File

@ -1,11 +1,18 @@
import { Application, Plugin } from '@nocobase/client'; import { Application, Plugin } from '@nocobase/client';
import { Collection, DataSource, DataSourceManager, Field, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine'; import {
Collection,
CollectionField,
DataSource,
DataSourceManager,
FlowModel,
FlowModelRenderer,
} from '@nocobase/flow-engine';
import { Button, Dropdown, Input } from 'antd'; import { Button, Dropdown, Input } from 'antd';
import React from 'react'; import React from 'react';
const dsm = new DataSourceManager(); const dsm = new DataSourceManager();
const ds = new DataSource({ const ds = new DataSource({
name: 'main', key: 'main',
displayName: 'Main', displayName: 'Main',
description: 'This is the main data source', description: 'This is the main data source',
}); });
@ -47,7 +54,7 @@ ds.addCollection({
}); });
class FieldModel extends FlowModel { class FieldModel extends FlowModel {
field: Field; field: CollectionField;
render() { render() {
return ( return (
<div> <div>
@ -94,7 +101,7 @@ class ConfigureFieldsFlowModel extends FlowModel<S> {
getFieldMenuItems() { getFieldMenuItems() {
return this.collection.mapFields((field) => { return this.collection.mapFields((field) => {
return { return {
key: `${this.collection.dataSource.name}.${this.collection.name}.${field.name}`, key: `${this.collection.dataSource.key}.${this.collection.name}.${field.name}`,
label: field.title, label: field.title,
}; };
}); });

View File

@ -70,7 +70,7 @@ class ConfigureFieldsFlowModel extends FlowModel {
<Button <Button
onClick={() => { onClick={() => {
dsm.addDataSource({ dsm.addDataSource({
name: `ds-${uid()}`, key: `ds-${uid()}`,
displayName: `ds-${uid()}`, displayName: `ds-${uid()}`,
}); });
}} }}

View File

@ -0,0 +1,174 @@
import { Input, Select } from '@formily/antd-v5';
import { Application, Plugin } from '@nocobase/client';
import { FlowModel, FlowModelRenderer, FlowsFloatContextMenu } from '@nocobase/flow-engine';
import { Card, Space, Button } from 'antd';
import React from 'react';
class SimpleProductModel extends FlowModel {
render() {
const { name = '新产品', category = 'electronics', price = 0 } = this.props;
return (
<Card title={name} style={{ width: 250, margin: 8 }}>
<p>
<strong>:</strong> {category}
</p>
<p>
<strong>:</strong> ¥{price}
</p>
</Card>
);
}
}
// 注册简单的配置流程
SimpleProductModel.registerFlow('configFlow', {
title: '产品配置',
auto: true,
steps: {
// 第一步:设置产品名称和分类
basicInfo: {
title: '基础信息',
uiSchema: {
name: {
type: 'string',
title: '产品名称',
'x-component': Input,
},
category: {
type: 'string',
title: '分类',
'x-component': Select,
enum: [
{ label: '电子产品', value: 'electronics' },
{ label: '服装', value: 'fashion' },
{ label: '图书', value: 'books' },
],
},
},
defaultParams: {
name: '新产品',
category: 'electronics',
},
handler(ctx, params) {
ctx.model.setProps({
name: params.name,
category: params.category,
});
},
},
// 第二步:设置价格 - 使用动态 defaultParams
priceConfig: {
title: '价格设置',
uiSchema: {
price: {
type: 'number',
title: '价格',
'x-component': 'Input',
'x-component-props': {
min: 0,
},
},
},
// 🔥 关键:动态 defaultParams - 根据分类自动设置默认价格
defaultParams: (ctx) => {
const category = ctx.model.getProps().category || 'electronics';
const priceMap = {
electronics: 999, // 电子产品默认999元
fashion: 299, // 服装默认299元
books: 49, // 图书默认49元
};
return {
price: priceMap[category] || 199,
};
},
handler(ctx, params) {
ctx.model.setProps('price', params.price);
},
},
},
});
class PluginDynamicDefaultParams extends Plugin {
async load() {
this.flowEngine.registerModels({ SimpleProductModel });
// 创建一个简单的产品模型
const model = this.flowEngine.createModel({
uid: 'simple-product',
use: 'SimpleProductModel',
props: { name: '示例产品', category: 'electronics', price: 0 },
});
await model.applyAutoFlows();
this.router.add('root', {
path: '/',
element: (
<div style={{ padding: 20 }}>
<h2> defaultParams </h2>
<p>
defaultParams
<br />
1.
<br />
2.
</p>
<div style={{ marginTop: 20 }}>
<FlowsFloatContextMenu model={model}>
<FlowModelRenderer model={model} />
</FlowsFloatContextMenu>
</div>
<Card>
<Space direction="vertical">
<Button
onClick={() => {
model.setStepParams('configFlow', 'basicInfo', {
name: '智能手机',
category: 'electronics',
});
model.applyFlow('configFlow');
}}
>
(999)
</Button>
<Button
onClick={() => {
model.setStepParams('configFlow', 'basicInfo', {
name: '时尚T恤',
category: 'fashion',
});
model.applyFlow('configFlow');
}}
>
(299)
</Button>
<Button
onClick={() => {
model.setStepParams('configFlow', 'basicInfo', {
name: '编程指南',
category: 'books',
});
model.applyFlow('configFlow');
}}
>
(49)
</Button>
</Space>
</Card>
</div>
),
});
}
}
// 创建应用实例
const app = new Application({
router: { type: 'memory', initialEntries: ['/'] },
plugins: [PluginDynamicDefaultParams],
});
export default app.getRootComponent();

View File

@ -0,0 +1,83 @@
import { Input } from '@formily/antd-v5';
import { Application, Plugin } from '@nocobase/client';
import { FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
import { Card, Space, Button } from 'antd';
import React from 'react';
/**
* ForkFlowModel
* 1. master uid = 'shared-uid' stepParams
* 2. fork1fork2 props.title name
*/
class HelloFlowModel extends FlowModel {
render() {
const { name, color } = this.getProps();
return (
<Card>
<div style={{ marginTop: 8 }}>name: {name}</div>
<div style={{ marginTop: 8, color: color || '#333' }}>color: {color}</div>
</Card>
);
}
}
// 一个简单的 flow用来把 stepParams.name 写入 props.name
HelloFlowModel.registerFlow('setNameFlow', {
auto: true,
steps: {
step1: {
uiSchema: {
name: {
type: 'string',
title: 'Name',
'x-component': Input,
},
},
defaultParams: {
name: 'NocoBase',
},
handler(ctx, params) {
ctx.model.setProps('name', params.name);
},
},
},
});
class PluginForkDemo extends Plugin {
async load() {
this.flowEngine.registerModels({ HelloFlowModel });
// 创建 master
const master = this.flowEngine.createModel({
uid: 'shared-uid',
use: 'HelloFlowModel',
stepParams: {
setNameFlow: {
step1: { name: 'NocoBase' },
},
},
});
// 创建两个 fork
const fork1 = master.createFork({ title: 'Fork A', color: 'red' });
const fork2 = master.createFork({ title: 'Fork B', color: 'blue' });
this.router.add('root', {
path: '/',
element: (
<Space>
<FlowModelRenderer model={master} showFlowSettings />
<FlowModelRenderer model={fork1} />
<FlowModelRenderer model={fork2} />
</Space>
),
});
}
}
const app = new Application({
router: { type: 'memory', initialEntries: ['/'] },
plugins: [PluginForkDemo],
});
export default app.getRootComponent();

View File

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { Application, Plugin } from '@nocobase/client'; import { Application, Plugin } from '@nocobase/client';
import { useFlowModel, FlowContext, FlowModel, withFlowModel, FlowsSettings } from '@nocobase/flow-engine'; import { useFlowModelById, FlowContext, FlowModel, withFlowModel, FlowsSettings } from '@nocobase/flow-engine';
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import Handlebars from 'handlebars'; import Handlebars from 'handlebars';
const Demo = () => { const Demo = () => {
const uid = 'markdown-block'; const uid = 'markdown-block';
const model = useFlowModel<FlowModel>(uid, 'MarkdownModel'); const model = useFlowModelById<FlowModel>(uid, 'MarkdownModel');
return ( return (
<div style={{ padding: 24, background: '#f5f5f5', borderRadius: 8 }}> <div style={{ padding: 24, background: '#f5f5f5', borderRadius: 8 }}>
<MarkdownBlock model={model} /> <MarkdownBlock model={model} />

View File

@ -4,7 +4,7 @@ import React from 'react';
class FlowModelRepository implements IFlowModelRepository<FlowModel> { class FlowModelRepository implements IFlowModelRepository<FlowModel> {
constructor(private app: Application) {} constructor(private app: Application) {}
async load(uid: string) { async findOne({ uid, parentId }) {
// implement fetching a model by id // implement fetching a model by id
return null; return null;
} }

View File

@ -1,5 +1,5 @@
import * as icons from '@ant-design/icons'; import * as icons from '@ant-design/icons';
import { FormItem, Input, Select } from '@formily/antd-v5'; import { FormItem, Input, NumberPicker, Select } from '@formily/antd-v5';
import { Application, Plugin } from '@nocobase/client'; import { Application, Plugin } from '@nocobase/client';
import { defineFlow, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine'; import { defineFlow, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
import { Button, Modal } from 'antd'; import { Button, Modal } from 'antd';
@ -83,6 +83,20 @@ const myEventFlow = defineFlow({
}, },
title: '按钮事件', title: '按钮事件',
steps: { steps: {
modalWidth: {
title: '弹窗宽度配置',
uiSchema: {
width: {
type: 'string',
title: '弹窗宽度',
'x-decorator': 'FormItem',
'x-component': 'NumberPicker',
},
},
handler(ctx, params) {
return params.width || 520;
},
},
confirm: { confirm: {
title: '确认操作配置', title: '确认操作配置',
uiSchema: { uiSchema: {
@ -105,6 +119,7 @@ const myEventFlow = defineFlow({
}, },
handler(ctx, params) { handler(ctx, params) {
Modal.confirm({ Modal.confirm({
width: ctx.stepResults.modalWidth,
...params, ...params,
}); });
}, },

View File

@ -6,7 +6,9 @@ import { Button, Tabs } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React from 'react'; import React from 'react';
class FlowModelRepository implements IFlowModelRepository<FlowModel<{parent: never, subModels: { tabs: TabFlowModel[] } }>> { class FlowModelRepository
implements IFlowModelRepository<FlowModel<{ parent: never; subModels: { tabs: TabFlowModel[] } }>>
{
get models() { get models() {
const models = new Map(); const models = new Map();
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
@ -22,8 +24,12 @@ class FlowModelRepository implements IFlowModelRepository<FlowModel<{parent: nev
return models; return models;
} }
async findOne(query) {
return this.load(query.uid);
}
// 从本地存储加载模型数据 // 从本地存储加载模型数据
async load(uid: string) { async load({ uid }) {
const data = localStorage.getItem(`flow-model:${uid}`); const data = localStorage.getItem(`flow-model:${uid}`);
if (!data) return null; if (!data) return null;
const json: FlowModel = JSON.parse(data); const json: FlowModel = JSON.parse(data);
@ -57,7 +63,10 @@ class FlowModelRepository implements IFlowModelRepository<FlowModel<{parent: nev
localStorage.setItem(`flow-model:${subModel.uid}`, JSON.stringify(subModel.serialize())); localStorage.setItem(`flow-model:${subModel.uid}`, JSON.stringify(subModel.serialize()));
}); });
} else if (model.subModels[subModelKey] instanceof FlowModel) { } else if (model.subModels[subModelKey] instanceof FlowModel) {
localStorage.setItem(`flow-model:${model.subModels[subModelKey].uid}`, JSON.stringify(model.subModels[subModelKey].serialize())); localStorage.setItem(
`flow-model:${model.subModels[subModelKey].uid}`,
JSON.stringify(model.subModels[subModelKey].serialize()),
);
} }
} }
return data; return data;
@ -72,8 +81,7 @@ class FlowModelRepository implements IFlowModelRepository<FlowModel<{parent: nev
class TabFlowModel extends FlowModel {} class TabFlowModel extends FlowModel {}
class HelloFlowModel extends FlowModel<{parent: never, subModels: { tabs: TabFlowModel[] } }> { class HelloFlowModel extends FlowModel<{ parent: never; subModels: { tabs: TabFlowModel[] } }> {
addTab(tab: any) { addTab(tab: any) {
// 使用新的 addSubModel API 添加子模型 // 使用新的 addSubModel API 添加子模型
const model = this.addSubModel('tabs', tab); const model = this.addSubModel('tabs', tab);
@ -88,7 +96,7 @@ class HelloFlowModel extends FlowModel<{parent: never, subModels: { tabs: TabFlo
items={this.subModels.tabs?.map((tab) => ({ items={this.subModels.tabs?.map((tab) => ({
key: tab.getProps().key, key: tab.getProps().key,
label: tab.getProps().label, label: tab.getProps().label,
children: tab.render() children: tab.render(),
}))} }))}
tabBarExtraContent={ tabBarExtraContent={
<Button <Button
@ -98,7 +106,7 @@ class HelloFlowModel extends FlowModel<{parent: never, subModels: { tabs: TabFlo
use: 'TabFlowModel', use: 'TabFlowModel',
uid: tabId, uid: tabId,
props: { key: tabId, label: `Tab - ${tabId}` }, props: { key: tabId, label: `Tab - ${tabId}` },
}) });
}} }}
> >
Add Tab Add Tab
@ -134,7 +142,7 @@ class PluginHelloModel extends Plugin {
props: { key: 'tab-2', label: 'Tab 2' }, props: { key: 'tab-2', label: 'Tab 2' },
}, },
], ],
} },
}); });
this.router.add('root', { path: '/', element: <FlowModelRenderer model={model} /> }); this.router.add('root', { path: '/', element: <FlowModelRenderer model={model} /> });
} }

View File

@ -1,5 +1,5 @@
import { FlowModel } from '@nocobase/flow-engine'; import { FlowModel } from '@nocobase/flow-engine';
import { Modal } from 'antd'; import { Button, Modal } from 'antd';
import React from 'react'; import React from 'react';
export class ActionModel extends FlowModel { export class ActionModel extends FlowModel {
@ -8,7 +8,23 @@ export class ActionModel extends FlowModel {
} }
render() { render() {
return <a {...this.props}>{this.props.title || 'Untitle'}</a>; return <Button {...this.props}>{this.props.title || 'Untitle'}</Button>;
}
}
export class LinkActionModel extends ActionModel {
render() {
return (
<Button type="link" {...this.props}>
{this.props.title || 'View'}
</Button>
);
}
}
export class DeleteActionModel extends ActionModel {
render() {
return <Button {...this.props}>{this.props.title || 'Delete'}</Button>;
} }
} }
@ -17,12 +33,24 @@ ActionModel.registerFlow({
auto: true, auto: true,
steps: { steps: {
step1: { step1: {
uiSchema: {
title: {
type: 'string',
title: '标题',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: '请输入标题',
},
},
},
handler(ctx, params) { handler(ctx, params) {
ctx.model.setProps('title', params.title); ctx.model.setProps('title', params.title);
ctx.model.onClick = (e) => { ctx.model.onClick = (e) => {
ctx.model.dispatchEvent('click', { ctx.model.dispatchEvent('click', {
event: e, event: e,
record: ctx.extra.record, record: ctx.extra.record,
...ctx.extra,
}); });
}; };
}, },
@ -30,7 +58,7 @@ ActionModel.registerFlow({
}, },
}); });
ActionModel.registerFlow({ LinkActionModel.registerFlow({
key: 'event1', key: 'event1',
on: { on: {
eventName: 'click', eventName: 'click',
@ -38,7 +66,7 @@ ActionModel.registerFlow({
steps: { steps: {
step1: { step1: {
handler(ctx, params) { handler(ctx, params) {
Modal.confirm({ ctx.globals.modal.confirm({
title: `${ctx.extra.record?.id}`, title: `${ctx.extra.record?.id}`,
content: 'Are you sure you want to perform this action?', content: 'Are you sure you want to perform this action?',
onOk: async () => {}, onOk: async () => {},
@ -47,3 +75,21 @@ ActionModel.registerFlow({
}, },
}, },
}); });
DeleteActionModel.registerFlow({
key: 'event1',
on: {
eventName: 'click',
},
steps: {
step1: {
handler(ctx, params) {
ctx.globals.modal.confirm({
title: `Selected Rows`,
content: <pre>{JSON.stringify(ctx.extra.currentResource?.getSelectedRows(), null, 2)}</pre>,
// onOk: async () => {},
});
},
},
},
});

View File

@ -3,7 +3,7 @@ import { DataSource, DataSourceManager } from '@nocobase/flow-engine';
export const dsm = new DataSourceManager(); export const dsm = new DataSourceManager();
const ds = new DataSource({ const ds = new DataSource({
name: 'main', key: 'main',
displayName: 'Main', displayName: 'Main',
description: 'This is the main data source', description: 'This is the main data source',
}); });

View File

@ -1,11 +1,12 @@
import { Plugin } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import { FlowModelRenderer } from '@nocobase/flow-engine'; import { FlowModelRenderer } from '@nocobase/flow-engine';
import actions from 'packages/plugins/@nocobase/plugin-workflow/src/server/actions';
import React from 'react'; import React from 'react';
import { createApp } from '../createApp'; import { createApp } from '../createApp';
import { FormItemModel } from '../form/form-item-model'; import { FormItemModel } from '../form/form-item-model';
import { FormModel } from '../form/form-model'; import { FormModel } from '../form/form-model';
import { SubmitActionModel } from '../form/submit-action-model'; import { SubmitActionModel } from '../form/submit-action-model';
import { ActionModel } from './action-model'; import { ActionModel, DeleteActionModel, LinkActionModel } from './action-model';
import { dsm } from './data-source-manager'; import { dsm } from './data-source-manager';
import { TableColumnActionsModel, TableColumnModel } from './table-column-model'; import { TableColumnActionsModel, TableColumnModel } from './table-column-model';
import { TableModel } from './table-model'; import { TableModel } from './table-model';
@ -14,6 +15,8 @@ class PluginDemo extends Plugin {
async load() { async load() {
this.flowEngine.context.dsm = dsm; this.flowEngine.context.dsm = dsm;
this.flowEngine.registerModels({ this.flowEngine.registerModels({
DeleteActionModel,
LinkActionModel,
FormModel, FormModel,
FormItemModel, FormItemModel,
SubmitActionModel, SubmitActionModel,
@ -33,13 +36,25 @@ class PluginDemo extends Plugin {
}, },
}, },
subModels: { subModels: {
actions: [
{
use: 'DeleteActionModel',
stepParams: {
default: {
step1: {
title: 'Delete',
},
},
},
},
],
columns: [ columns: [
{ {
use: 'TableColumnActionsModel', use: 'TableColumnActionsModel',
subModels: { subModels: {
actions: [ actions: [
{ {
use: 'ActionModel', use: 'LinkActionModel',
stepParams: { stepParams: {
default: { default: {
step1: { step1: {
@ -49,7 +64,7 @@ class PluginDemo extends Plugin {
}, },
}, },
{ {
use: 'ActionModel', use: 'LinkActionModel',
stepParams: { stepParams: {
default: { default: {
step1: { step1: {

View File

@ -1,13 +1,13 @@
import { EditOutlined } from '@ant-design/icons'; import { EditOutlined } from '@ant-design/icons';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Field, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine'; import { CollectionField, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
import { Space } from 'antd'; import { Space } from 'antd';
import React from 'react'; import React from 'react';
import { FormModel } from '../form/form-model'; import { FormModel } from '../form/form-model';
import { ActionModel } from './action-model'; import { ActionModel } from './action-model';
export class TableColumnModel extends FlowModel { export class TableColumnModel extends FlowModel {
field: Field; field: CollectionField;
fieldPath: string; fieldPath: string;
getColumnProps() { getColumnProps() {
@ -75,7 +75,12 @@ export class TableColumnActionsModel extends TableColumnModel {
return (value, record, index) => ( return (value, record, index) => (
<Space> <Space>
{this.mapSubModels('actions', (action: ActionModel) => ( {this.mapSubModels('actions', (action: ActionModel) => (
<FlowModelRenderer key={action.uid} model={action} extraContext={{ record }} /> <FlowModelRenderer
key={action.uid}
model={action.createFork({}, `${record.id || index}`)}
showFlowSettings
extraContext={{ record }}
/>
))} ))}
</Space> </Space>
); );

View File

@ -1,12 +1,15 @@
import { Collection, FlowModel, MultiRecordResource } from '@nocobase/flow-engine'; import { SettingOutlined } from '@ant-design/icons';
import { Button, Dropdown, Table } from 'antd'; import { AddActionModel, Collection, FlowModel, FlowModelRenderer, MultiRecordResource } from '@nocobase/flow-engine';
import { Button, Dropdown, Space, Table } from 'antd';
import React from 'react'; import React from 'react';
import { ActionModel } from './action-model';
import { api } from './api'; import { api } from './api';
import { TableColumnModel } from './table-column-model'; import { TableColumnModel } from './table-column-model';
type S = { type S = {
subModels: { subModels: {
columns: TableColumnModel[]; columns: TableColumnModel[];
actions: ActionModel[];
}; };
}; };
@ -36,7 +39,7 @@ export class TableModel extends FlowModel<S> {
}, },
items: this.collection.mapFields((field) => { items: this.collection.mapFields((field) => {
return { return {
key: `${this.collection.dataSource.name}.${this.collection.name}.${field.name}`, key: `${this.collection.dataSource.key}.${this.collection.name}.${field.name}`,
label: field.title, label: field.title,
}; };
}), }),
@ -51,10 +54,43 @@ export class TableModel extends FlowModel<S> {
render() { render() {
return ( return (
<div> <div>
<Space style={{ marginBottom: 16 }}>
{this.mapSubModels('actions', (action) => (
<FlowModelRenderer
model={action}
showFlowSettings
extraContext={{ currentModel: this, currentResource: this.resource }}
/>
))}
<AddActionModel
model={this}
subModelKey={'actions'}
items={() => [
{
key: 'action1',
label: 'Delete',
createModelOptions: {
use: 'DeleteActionModel',
},
},
]}
>
<Button type="primary" icon={<SettingOutlined />}>
Configure actions
</Button>
</AddActionModel>
</Space>
<Table <Table
rowKey="id" rowKey="id"
dataSource={this.resource.getData()} dataSource={this.resource.getData()}
columns={this.getColumns()} columns={this.getColumns()}
rowSelection={{
type: 'checkbox',
onChange: (_, selectedRows) => {
this.resource.setSelectedRows(selectedRows);
},
selectedRowKeys: this.resource.getSelectedRows().map((row) => row.id),
}}
pagination={{ pagination={{
current: this.resource.getMeta('page'), current: this.resource.getMeta('page'),
pageSize: this.resource.getMeta('pageSize'), pageSize: this.resource.getMeta('pageSize'),

View File

@ -20,6 +20,10 @@
<code src="./demos/register-flow.tsx"></code> <code src="./demos/register-flow.tsx"></code>
## 动态默认配置参数
<code src="./demos/dynamic-default-params.tsx"></code>
## table block ## table block
<code src="./demos/table-block.tsx"></code> <code src="./demos/table-block.tsx"></code>
@ -84,3 +88,6 @@
<code src="./demos/open-required-step-params-dialog.tsx"></code> <code src="./demos/open-required-step-params-dialog.tsx"></code>
## fork 模型共享示例
<code src="./demos/fork-flow-model.tsx"></code>

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/client", "name": "@nocobase/client",
"version": "1.8.0-alpha.5", "version": "1.8.0-alpha.9",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "lib/index.js", "main": "lib/index.js",
"module": "es/index.mjs", "module": "es/index.mjs",
@ -26,9 +26,9 @@
"@formily/reactive-react": "^2.2.27", "@formily/reactive-react": "^2.2.27",
"@formily/shared": "^2.2.27", "@formily/shared": "^2.2.27",
"@formily/validator": "^2.2.27", "@formily/validator": "^2.2.27",
"@nocobase/evaluators": "1.8.0-alpha.5", "@nocobase/evaluators": "1.8.0-alpha.9",
"@nocobase/sdk": "1.8.0-alpha.5", "@nocobase/sdk": "1.8.0-alpha.9",
"@nocobase/utils": "1.8.0-alpha.5", "@nocobase/utils": "1.8.0-alpha.9",
"ahooks": "^3.7.2", "ahooks": "^3.7.2",
"antd": "5.24.2", "antd": "5.24.2",
"antd-style": "3.7.1", "antd-style": "3.7.1",
@ -65,6 +65,8 @@
"react-router-dom": "^6.11.2", "react-router-dom": "^6.11.2",
"react-to-print": "^2.14.7", "react-to-print": "^2.14.7",
"sanitize-html": "2.13.0", "sanitize-html": "2.13.0",
"tabulator-tables": "^6.3.1",
"@types/tabulator-tables": "^6.2.6",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -72,7 +72,7 @@ export class APIClient extends APIClientSDK {
api.notification = this.notification; api.notification = this.notification;
const handlers = []; const handlers = [];
for (const handler of this.axios.interceptors.response['handlers']) { for (const handler of this.axios.interceptors.response['handlers']) {
if (handler.rejected['_name'] === 'handleNotificationError') { if (handler?.rejected?.['_name'] === 'handleNotificationError') {
handlers.push({ handlers.push({
...handler, ...handler,
rejected: api.handleNotificationError.bind(api), rejected: api.handleNotificationError.bind(api),

View File

@ -40,7 +40,7 @@ import { DataSourceApplicationProvider } from '../data-source/components/DataSou
import { DataBlockProvider } from '../data-source/data-block/DataBlockProvider'; import { DataBlockProvider } from '../data-source/data-block/DataBlockProvider';
import { DataSourceManager, type DataSourceManagerOptions } from '../data-source/data-source/DataSourceManager'; import { DataSourceManager, type DataSourceManagerOptions } from '../data-source/data-source/DataSourceManager';
import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine'; import { FlowEngine, FlowEngineGlobalsContextProvider, FlowEngineProvider } from '@nocobase/flow-engine';
import type { CollectionFieldInterfaceFactory } from '../data-source'; import type { CollectionFieldInterfaceFactory } from '../data-source';
import { OpenModeProvider } from '../modules/popup/OpenModeProvider'; import { OpenModeProvider } from '../modules/popup/OpenModeProvider';
import { AppSchemaComponentProvider } from './AppSchemaComponentProvider'; import { AppSchemaComponentProvider } from './AppSchemaComponentProvider';
@ -273,8 +273,14 @@ export class Application {
this.use(AntdAppProvider); this.use(AntdAppProvider);
this.use(DataSourceApplicationProvider, { dataSourceManager: this.dataSourceManager }); this.use(DataSourceApplicationProvider, { dataSourceManager: this.dataSourceManager });
this.use(OpenModeProvider); this.use(OpenModeProvider);
this.flowEngine.context['app'] = this; this.flowEngine.setContext({
app: this,
api: this.apiClient,
i18n: this.i18n,
router: this.router.router,
});
this.use(FlowEngineProvider, { engine: this.flowEngine }); this.use(FlowEngineProvider, { engine: this.flowEngine });
this.use(FlowEngineGlobalsContextProvider);
} }
private addReactRouterComponents() { private addReactRouterComponents() {

View File

@ -9,7 +9,7 @@
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Divider, Empty, Input, MenuProps } from 'antd'; import { Divider, Empty, Input, MenuProps } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCompile } from '../../../'; import { useCompile } from '../../../';

View File

@ -19,9 +19,10 @@ import { useDetailsProps } from '../modules/blocks/data-blocks/details-single/ho
import { FormItemSchemaToolbar } from '../modules/blocks/data-blocks/form/FormItemSchemaToolbar'; import { FormItemSchemaToolbar } from '../modules/blocks/data-blocks/form/FormItemSchemaToolbar';
import { useCreateFormBlockDecoratorProps } from '../modules/blocks/data-blocks/form/hooks/useCreateFormBlockDecoratorProps'; import { useCreateFormBlockDecoratorProps } from '../modules/blocks/data-blocks/form/hooks/useCreateFormBlockDecoratorProps';
import { useCreateFormBlockProps } from '../modules/blocks/data-blocks/form/hooks/useCreateFormBlockProps'; import { useCreateFormBlockProps } from '../modules/blocks/data-blocks/form/hooks/useCreateFormBlockProps';
import { useDataFormItemProps } from '../modules/blocks/data-blocks/form/hooks/useDataFormItemProps';
import { useEditFormBlockDecoratorProps } from '../modules/blocks/data-blocks/form/hooks/useEditFormBlockDecoratorProps'; import { useEditFormBlockDecoratorProps } from '../modules/blocks/data-blocks/form/hooks/useEditFormBlockDecoratorProps';
import { useEditFormBlockProps } from '../modules/blocks/data-blocks/form/hooks/useEditFormBlockProps'; import { useEditFormBlockProps } from '../modules/blocks/data-blocks/form/hooks/useEditFormBlockProps';
import { useDataFormItemProps } from '../modules/blocks/data-blocks/form/hooks/useDataFormItemProps'; import { useGridCardActionBarProps } from '../modules/blocks/data-blocks/grid-card/hooks/useGridCardActionBarProps';
import { import {
useGridCardBlockDecoratorProps, useGridCardBlockDecoratorProps,
useGridCardBlockItemProps, useGridCardBlockItemProps,
@ -97,6 +98,7 @@ export const BlockSchemaComponentProvider: React.FC = (props) => {
useGridCardBlockProps, useGridCardBlockProps,
useFormItemProps, useFormItemProps,
useDataFormItemProps, useDataFormItemProps,
useGridCardActionBarProps,
}} }}
> >
{props.children} {props.children}
@ -161,6 +163,7 @@ export class BlockSchemaComponentPlugin extends Plugin {
useGridCardBlockItemProps, useGridCardBlockItemProps,
useFormItemProps, useFormItemProps,
useDataFormItemProps, useDataFormItemProps,
useGridCardActionBarProps,
}); });
} }
} }

View File

@ -63,7 +63,7 @@ interface Props {
expandFlag?: boolean; expandFlag?: boolean;
dragSortBy?: string; dragSortBy?: string;
association?: string; association?: string;
enableIndexÏColumn?: boolean; enableIndexColumn?: boolean;
} }
const InternalTableBlockProvider = (props: Props) => { const InternalTableBlockProvider = (props: Props) => {
@ -77,7 +77,7 @@ const InternalTableBlockProvider = (props: Props) => {
fieldNames, fieldNames,
collection, collection,
association, association,
enableIndexÏColumn, enableIndexColumn,
} = props; } = props;
const field: any = useField(); const field: any = useField();
const { resource, service } = useBlockRequestContext(); const { resource, service } = useBlockRequestContext();
@ -136,7 +136,7 @@ const InternalTableBlockProvider = (props: Props) => {
setExpandFlag: setExpandFlagValue, setExpandFlag: setExpandFlagValue,
heightProps, heightProps,
association, association,
enableIndexÏColumn, enableIndexColumn,
}), }),
[ [
allIncludesChildren, allIncludesChildren,
@ -153,7 +153,7 @@ const InternalTableBlockProvider = (props: Props) => {
setExpandFlagValue, setExpandFlagValue,
showIndex, showIndex,
association, association,
enableIndexÏColumn, enableIndexColumn,
], ],
); );

View File

@ -197,9 +197,7 @@ export function useCollectValuesToSubmit() {
if (isVariable(value)) { if (isVariable(value)) {
const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {}; const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {};
if (parsedValue !== null && parsedValue !== undefined) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
}
} else if (value !== '') { } else if (value !== '') {
assignedValues[key] = value; assignedValues[key] = value;
} }
@ -385,9 +383,7 @@ export const useAssociationCreateActionProps = () => {
if (isVariable(value)) { if (isVariable(value)) {
const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {}; const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {};
if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
}
} else if (value !== '') { } else if (value !== '') {
assignedValues[key] = value; assignedValues[key] = value;
} }
@ -658,9 +654,7 @@ export const useCustomizeUpdateActionProps = () => {
if (isVariable(value)) { if (isVariable(value)) {
const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {}; const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {};
if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
}
} else if (value !== '') { } else if (value !== '') {
assignedValues[key] = value; assignedValues[key] = value;
} }
@ -771,9 +765,7 @@ export const useCustomizeBulkUpdateActionProps = () => {
if (isVariable(value)) { if (isVariable(value)) {
const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {}; const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {};
if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
}
} else if (value !== '') { } else if (value !== '') {
assignedValues[key] = value; assignedValues[key] = value;
} }
@ -999,9 +991,7 @@ export const useUpdateActionProps = () => {
if (isVariable(value)) { if (isVariable(value)) {
const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {}; const { value: parsedValue } = (await variables?.parseVariable(value, localVariables)) || {};
if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField });
}
} else if (value !== '') { } else if (value !== '') {
assignedValues[key] = value; assignedValues[key] = value;
} }

View File

@ -193,6 +193,8 @@ export const EditFieldAction = (props) => {
defaultValues.reverseField = interfaceConf?.default?.reverseField; defaultValues.reverseField = interfaceConf?.default?.reverseField;
set(defaultValues.reverseField, 'name', `f_${uid()}`); set(defaultValues.reverseField, 'name', `f_${uid()}`);
set(defaultValues.reverseField, 'uiSchema.title', record.__parent?.title); set(defaultValues.reverseField, 'uiSchema.title', record.__parent?.title);
} else {
defaultValues.autoCreateReverseField = true;
} }
const schema = getSchema(interfaceConf, defaultValues, record, compile, getContainer); const schema = getSchema(interfaceConf, defaultValues, record, compile, getContainer);
setSchema(schema); setSchema(schema);

View File

@ -398,7 +398,7 @@ export const useFilterAction = () => {
}; };
}; };
export const useCreateAction = (actionCallback?: (values: any) => void) => { export const useCreateAction = (actionCallback?: (values: any, collections: any[]) => void) => {
const form = useForm(); const form = useForm();
const field = useField(); const field = useField();
const ctx = useActionContext(); const ctx = useActionContext();
@ -410,9 +410,14 @@ export const useCreateAction = (actionCallback?: (values: any) => void) => {
await form.submit(); await form.submit();
field.data = field.data || {}; field.data = field.data || {};
field.data.loading = true; field.data.loading = true;
let collections = [];
if (!form.values.addAllCollections) {
collections = form.values.collections;
}
delete form.values.collections;
const res = await resource.create({ values: form.values }); const res = await resource.create({ values: form.values });
ctx.setVisible(false); ctx.setVisible(false);
actionCallback?.(res?.data?.data); await actionCallback?.(res?.data?.data, collections);
await form.reset(); await form.reset();
field.data.loading = false; field.data.loading = false;
refresh(); refresh();

View File

@ -9,7 +9,8 @@
import { Plugin } from '../application/Plugin'; import { Plugin } from '../application/Plugin';
import { InheritanceCollectionMixin } from './mixins/InheritanceCollectionMixin'; import { DataSource } from '../data-source/data-source/DataSource';
import { DEFAULT_DATA_SOURCE_KEY, DEFAULT_DATA_SOURCE_TITLE } from '../data-source/data-source/DataSourceManager';
import { import {
CheckboxFieldInterface, CheckboxFieldInterface,
CheckboxGroupFieldInterface, CheckboxGroupFieldInterface,
@ -53,14 +54,13 @@ import {
UrlFieldInterface, UrlFieldInterface,
UUIDFieldInterface, UUIDFieldInterface,
} from './interfaces'; } from './interfaces';
import { InheritanceCollectionMixin } from './mixins/InheritanceCollectionMixin';
import { import {
GeneralCollectionTemplate, GeneralCollectionTemplate,
SqlCollectionTemplate, SqlCollectionTemplate,
TreeCollectionTemplate, TreeCollectionTemplate,
ViewCollectionTemplate, ViewCollectionTemplate,
} from './templates'; } from './templates';
import { DEFAULT_DATA_SOURCE_KEY, DEFAULT_DATA_SOURCE_TITLE } from '../data-source/data-source/DataSourceManager';
import { DataSource } from '../data-source/data-source/DataSource';
class MainDataSource extends DataSource { class MainDataSource extends DataSource {
async getDataSource() { async getDataSource() {
@ -72,6 +72,7 @@ class MainDataSource extends DataSource {
const collections = service?.data?.data || []; const collections = service?.data?.data || [];
return { return {
key: 'main',
collections, collections,
}; };
} }

View File

@ -24,7 +24,7 @@ export class CheckboxFieldInterface extends CollectionFieldInterface {
'x-component': 'Checkbox', 'x-component': 'Checkbox',
}, },
}; };
availableTypes = ['boolean', 'integer', 'bigInt']; availableTypes = ['boolean', 'integer', 'bigInt', 'bit'];
hasDefaultValue = true; hasDefaultValue = true;
properties = { properties = {
...defaultProps, ...defaultProps,

View File

@ -242,6 +242,8 @@ export const boolean = [
}, },
}, },
}, },
{ label: "{{ t('is empty') }}", value: '$empty', noValue: true },
{ label: "{{ t('is not empty') }}", value: '$notEmpty', noValue: true },
]; ];
export const tableoid = [ export const tableoid = [

View File

@ -159,7 +159,7 @@ export class InheritanceCollectionMixin extends Collection {
const targetField = filterFields.find((k) => { const targetField = filterFields.find((k) => {
return k.name === v.name; return k.name === v.name;
}); });
return targetField.collectionName !== this.name; return targetField?.collectionName !== this.name;
}); });
return this.parentCollectionFields[parentCollectionName]; return this.parentCollectionFields[parentCollectionName];
} }

View File

@ -58,9 +58,13 @@ export const fieldComponentSettingsItem: SchemaSettingsItemType = {
value: fieldSchema['x-component-props']?.['component'] || options[0]?.value, value: fieldSchema['x-component-props']?.['component'] || options[0]?.value,
onChange(component) { onChange(component) {
const componentOptions = options.find((item) => item.value === component); const componentOptions = options.find((item) => item.value === component);
const baseProps = componentOptions?.useProps?.() || {};
const componentProps = { const componentProps = {
component, component,
...(componentOptions?.useProps?.() || {}), ...baseProps,
...(component === collectionField['uiSchema']['x-component']
? collectionField['uiSchema']['x-component-props']
: {}),
}; };
_.set(fieldSchema, 'x-component-props', componentProps); _.set(fieldSchema, 'x-component-props', componentProps);
field.componentProps = componentProps; field.componentProps = componentProps;

View File

@ -83,8 +83,18 @@ export abstract class DataSource {
abstract getDataSource(): Promise<Omit<Partial<DataSourceOptions>, 'key'>> | Omit<Partial<DataSourceOptions>, 'key'>; abstract getDataSource(): Promise<Omit<Partial<DataSourceOptions>, 'key'>> | Omit<Partial<DataSourceOptions>, 'key'>;
get flowEngineDataSourceManager() {
return this.app.flowEngine?.context?.dataSourceManager;
}
async reload() { async reload() {
const dataSource = await this.getDataSource(); const dataSource = await this.getDataSource();
const flowEngineDataSourceManager = this.flowEngineDataSourceManager;
if (flowEngineDataSourceManager) {
flowEngineDataSourceManager.upsertDataSource(this.options);
const ds = flowEngineDataSourceManager.getDataSource(this.key);
ds.upsertCollections(dataSource.collections || []);
}
this.setOptions(dataSource); this.setOptions(dataSource);
this.collectionManager.setCollections(dataSource.collections || []); this.collectionManager.setCollections(dataSource.collections || []);
this.reloadCallbacks.forEach((callback) => callback(dataSource.collections)); this.reloadCallbacks.forEach((callback) => callback(dataSource.collections));

View File

@ -118,11 +118,17 @@ export const transformToFilter = (
) { ) {
return true; return true;
} }
if (value?.type) {
const collectionField = getCollectionJoinField(`${collectionName}.${path}`);
if (
['datetime', 'datetimeNoTz', 'date', 'unixTimestamp', 'createdAt', 'updatedAt'].includes(
collectionField?.interface,
)
) {
return true; return true;
} }
const collectionField = getCollectionJoinField(`${collectionName}.${path}`);
if (collectionField?.target) { if (collectionField?.target) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return true; return true;

View File

@ -9,6 +9,7 @@
import { FlowModel, IFlowModelRepository } from '@nocobase/flow-engine'; import { FlowModel, IFlowModelRepository } from '@nocobase/flow-engine';
import _ from 'lodash'; import _ from 'lodash';
import { Application } from '../application';
export class MockFlowModelRepository implements IFlowModelRepository<FlowModel> { export class MockFlowModelRepository implements IFlowModelRepository<FlowModel> {
get models() { get models() {
@ -26,6 +27,26 @@ export class MockFlowModelRepository implements IFlowModelRepository<FlowModel>
return models; return models;
} }
async findOne(query) {
const { uid, parentId } = query;
if (uid) {
return this.load(uid);
} else if (parentId) {
return this.loadByParentId(parentId);
}
return null;
}
async loadByParentId(parentId: string) {
for (const model of this.models.values()) {
if (model.parentId == parentId) {
console.log('Loading model by parentId:', parentId, model);
return this.load(model.uid);
}
}
return null;
}
// 从本地存储加载模型数据 // 从本地存储加载模型数据
async load(uid: string) { async load(uid: string) {
const data = localStorage.getItem(`flow-model:${uid}`); const data = localStorage.getItem(`flow-model:${uid}`);
@ -43,7 +64,9 @@ export class MockFlowModelRepository implements IFlowModelRepository<FlowModel>
json.subModels[model.subKey].push(subModel); json.subModels[model.subKey].push(subModel);
} else if (model.subType === 'object') { } else if (model.subType === 'object') {
const subModel = await this.load(model.uid); const subModel = await this.load(model.uid);
json.subModels[model.subKey] = subModel; if (subModel) {
json.subModels[model.subKey] = subModel;
}
} }
} }
} }
@ -77,3 +100,32 @@ export class MockFlowModelRepository implements IFlowModelRepository<FlowModel>
return true; return true;
} }
} }
export class FlowModelRepository implements IFlowModelRepository<FlowModel> {
constructor(private app: Application) {}
async findOne(query) {
const response = await this.app.apiClient.request({
url: 'flowModels:findOne',
params: _.pick(query, ['uid', 'parentId']),
});
return response.data?.data;
}
async save(model: FlowModel) {
const response = await this.app.apiClient.request({
method: 'POST',
url: 'flowModels:save',
data: model.serialize(),
});
return response.data?.data;
}
async destroy(uid: string) {
await this.app.apiClient.request({
method: 'POST',
url: 'flowModels:destroy',
params: { filterByTk: uid },
});
return true;
}
}

View File

@ -7,49 +7,65 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { FlowModelRenderer, useFlowEngine, useFlowModel } from '@nocobase/flow-engine'; import { FlowModelRenderer, useFlowEngine, useFlowModelById } from '@nocobase/flow-engine';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { Spin } from 'antd'; import { Spin } from 'antd';
import React from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
function InternalFlowPage({ uid }) { function InternalFlowPage({ uid, sharedContext }) {
const model = useFlowModel(uid); const model = useFlowModelById(uid);
return <FlowModelRenderer model={model} showFlowSettings hideRemoveInSettings />; return (
<FlowModelRenderer
model={model}
sharedContext={sharedContext}
showFlowSettings={{ showBackground: false, showBorder: false }}
hideRemoveInSettings
/>
);
} }
export const FlowPage = () => { export const FlowRoute = () => {
const params = useParams(); const params = useParams();
return <FlowPageComponent uid={params.name} />; return <FlowPage uid={`r_${params.name}`} />;
}; };
export const FlowPageComponent = ({ uid }) => { export const FlowPage = (props) => {
const { uid, parentId, sharedContext } = props;
const flowEngine = useFlowEngine(); const flowEngine = useFlowEngine();
const { loading } = useRequest( const { loading, data } = useRequest(
() => { async () => {
return flowEngine.loadOrCreateModel({ const options = {
uid: uid, uid,
use: 'PageFlowModel', use: 'PageModel',
subModels: { subModels: {
tabs: [ tabs: [
{ {
use: 'PageTabFlowModel', use: 'PageTabModel',
subModels: { subModels: {
grid: { grid: {
use: 'BlockGridFlowModel', use: 'BlockGridModel',
}, },
}, },
}, },
], ],
}, },
}); };
if (!uid && parentId) {
options['async'] = true;
options['parentId'] = parentId;
options['subKey'] = 'page';
options['subType'] = 'object';
}
const data = await flowEngine.loadOrCreateModel(options);
return data;
}, },
{ {
refreshDeps: [uid], refreshDeps: [uid || parentId],
}, },
); );
if (loading) { if (loading || !data?.uid) {
return <Spin />; return <Spin />;
} }
return <InternalFlowPage uid={uid} />; return <InternalFlowPage uid={data.uid} sharedContext={sharedContext} />;
}; };

View File

@ -0,0 +1,96 @@
import { useGlobalVariable } from '../../application/hooks/useGlobalVariable';
import { BlocksSelector } from '../../schema-component/antd/action/Action.Designer';
import { useAfterSuccessOptions } from '../../schema-component/antd/action/hooks/useGetAfterSuccessVariablesOptions';
const fieldNames = {
value: 'value',
label: 'label',
};
const useVariableProps = () => {
const environmentVariables = useGlobalVariable('$env');
const scope = useAfterSuccessOptions();
return {
scope: [environmentVariables, ...scope].filter(Boolean),
fieldNames,
};
};
export const afterSuccessAction = {
title: '提交成功后',
uiSchema: {
successMessage: {
title: 'Popup message',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {},
},
manualClose: {
title: 'Message popup close method',
enum: [
{ label: 'Automatic close', value: false },
{ label: 'Manually close', value: true },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': {},
},
redirecting: {
title: 'Then',
'x-hidden': true,
enum: [
{ label: 'Stay on current page', value: false },
{ label: 'Redirect to', value: true },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': {},
'x-reactions': {
target: 'redirectTo',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
},
actionAfterSuccess: {
title: 'Action after successful submission',
enum: [
{ label: 'Stay on the current popup or page', value: 'stay' },
{ label: 'Return to the previous popup or page', value: 'previous' },
{ label: 'Redirect to', value: 'redirect' },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': {},
'x-reactions': {
target: 'redirectTo',
fulfill: {
state: {
visible: "{{$self.value==='redirect'}}",
},
},
},
},
redirectTo: {
title: 'Link',
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
// eslint-disable-next-line react-hooks/rules-of-hooks
'x-use-component-props': () => useVariableProps(),
},
blocksToRefresh: {
type: 'array',
title: 'Refresh data blocks',
'x-decorator': 'FormItem',
'x-use-decorator-props': () => {
return {
tooltip: 'After successful submission, the selected data blocks will be automatically refreshed.',
};
},
'x-component': BlocksSelector,
// 'x-hidden': isInBlockTemplateConfigPage, // 模板配置页面暂不支持该配置
},
},
handler(ctx, params) {},
};

View File

@ -0,0 +1,54 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { defineAction } from '@nocobase/flow-engine';
export const confirm = defineAction({
name: 'confirm',
title: '二次确认',
uiSchema: {
enable: {
type: 'boolean',
title: 'Enable secondary confirmation',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
title: {
type: 'string',
title: 'Title',
default: 'Delete record',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
content: {
type: 'string',
title: 'Content',
default: 'Are you sure you want to delete it?',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
},
defaultParams: {
enable: true,
title: 'Delete record',
content: 'Are you sure you want to delete it?',
},
async handler(ctx, params) {
if (params.enable) {
const confirmed = await ctx.globals.modal.confirm({
title: params.title,
content: params.content,
});
if (!confirmed) {
ctx.exit();
}
}
},
});

View File

@ -0,0 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './confirm';
export * from './popup';
//

View File

@ -0,0 +1,94 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { css } from '@emotion/css';
import { Variable } from '../../schema-component/antd/variable/Variable';
export const openLinkAction = {
title: '编辑链接',
uiSchema: {
url: {
title: 'URL',
'x-decorator': 'FormItem',
'x-component': Variable.TextArea,
description: 'Do not concatenate search params in the URL',
},
params: {
type: 'array',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: `Search parameters`,
items: {
type: 'object',
properties: {
space: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
style: {
flexWrap: 'nowrap',
maxWidth: '100%',
},
className: css`
& > .ant-space-item:first-child,
& > .ant-space-item:last-child {
flex-shrink: 0;
}
`,
},
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: `{{t("Name")}}`,
},
},
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': Variable.TextArea,
'x-component-props': {
placeholder: `{{t("Value")}}`,
useTypedConstant: true,
changeOnSelect: true,
},
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: 'Add parameter',
'x-component': 'ArrayItems.Addition',
},
},
},
openInNewWindow: {
type: 'boolean',
'x-content': 'Open in new window',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
},
handler(ctx, params) {
ctx.globals.modal.confirm({
title: `TODO`,
content: JSON.stringify(params, null, 2),
});
},
};

View File

@ -0,0 +1,65 @@
import React from 'react';
import { FlowPage } from '../FlowPage';
export const openModeAction = {
title: '打开方式',
uiSchema: {
mode: {
type: 'string',
title: '打开方式',
enum: [
{ label: 'Drawer', value: 'drawer' },
{ label: 'Modal', value: 'modal' },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
size: {
type: 'string',
title: '弹窗尺寸',
enum: [
{ label: '小', value: 'small' },
{ label: '中', value: 'medium' },
{ label: '大', value: 'large' },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
},
defaultParams(ctx) {
return {
mode: 'drawer',
size: 'medium',
};
},
handler(ctx, params) {
// eslint-disable-next-line prefer-const
let currentDrawer: any;
function DrawerContent() {
return (
<div>
<FlowPage
parentId={ctx.model.uid}
sharedContext={{
...ctx.extra,
currentDrawer,
}}
/>
</div>
);
}
const sizeToWidthMap: Record<string, number> = {
small: 480,
medium: 800,
large: 1200,
};
currentDrawer = ctx.globals[params.mode].open({
title: '命令式 Drawer',
width: sizeToWidthMap[params.size],
content: <DrawerContent />,
});
},
};

View File

@ -0,0 +1,74 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { defineAction } from '@nocobase/flow-engine';
import React from 'react';
import { FlowPage } from '../FlowPage';
export const popup = defineAction({
name: 'popup',
title: '弹窗配置',
uiSchema: {
mode: {
type: 'string',
title: '打开方式',
enum: [
{ label: 'Drawer', value: 'drawer' },
{ label: 'Modal', value: 'modal' },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
size: {
type: 'string',
title: '弹窗尺寸',
enum: [
{ label: '小', value: 'small' },
{ label: '中', value: 'medium' },
{ label: '大', value: 'large' },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
},
defaultParams: {
mode: 'drawer',
size: 'medium',
},
handler(ctx, params) {
// eslint-disable-next-line prefer-const
let currentDrawer: any;
function DrawerContent() {
return (
<div>
<FlowPage
parentId={ctx.model.uid}
sharedContext={{
...params.sharedContext,
currentDrawer,
}}
/>
</div>
);
}
const sizeToWidthMap: Record<string, number> = {
small: 480,
medium: 800,
large: 1200,
};
currentDrawer = ctx.globals[params.mode || 'drawer'].open({
title: '命令式 Drawer',
width: sizeToWidthMap[params.size || 'medium'],
content: <DrawerContent />,
});
},
});

View File

@ -0,0 +1,22 @@
export const refreshOnCompleteAction = {
title: '执行后刷新数据',
uiSchema: {
enable: {
type: 'boolean',
title: 'Enable refresh',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
},
defaultParams(ctx) {
return {
enable: true,
};
},
async handler(ctx, params) {
if (params.enable) {
await ctx.extra.currentResource.refresh();
ctx.globals.message.success('Data refreshed successfully.');
}
},
};

View File

@ -0,0 +1,44 @@
export const secondaryConfirmationAction = {
title: '二次确认',
uiSchema: {
enable: {
type: 'boolean',
title: 'Enable secondary confirmation',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
title: {
type: 'string',
title: 'Title',
default: 'Delete record',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
content: {
type: 'string',
title: 'Content',
default: 'Are you sure you want to delete it?',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
},
defaultParams(ctx) {
return {
enable: true,
title: 'Delete record',
content: 'Are you sure you want to delete it?',
};
},
async handler(ctx, params) {
if (params.enable) {
const confirmed = await ctx.globals.modal.confirm({
title: params.title,
content: params.content,
});
if (!confirmed) {
ctx.exit();
}
}
},
};

View File

@ -0,0 +1,98 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Popover } from 'antd';
import React, { CSSProperties, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
const getContentWidth = (el: HTMLElement) => {
if (el) {
const range = document.createRange();
range.selectNodeContents(el);
const contentWidth = range.getBoundingClientRect().width;
return contentWidth;
}
};
const ellipsisDefaultStyle: CSSProperties = {
overflow: 'hidden',
overflowWrap: 'break-word',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
wordBreak: 'break-all',
};
const isOverflowTooltip = (el: HTMLElement) => {
if (!el) return false;
const contentWidth = getContentWidth(el);
const offsetWidth = el.offsetWidth;
return contentWidth > offsetWidth;
};
interface IEllipsisWithTooltipProps {
ellipsis: boolean;
popoverContent: unknown;
children: any;
role?: string;
}
const popoverStyle = {
width: 300,
overflow: 'auto',
maxHeight: 400,
};
export const EllipsisWithTooltip = forwardRef((props: Partial<IEllipsisWithTooltipProps>, ref: any) => {
const [ellipsis, setEllipsis] = useState(false);
const [visible, setVisible] = useState(false);
const elRef: any = useRef();
useImperativeHandle(
ref,
() => {
return {
setPopoverVisible: setVisible,
};
},
[],
);
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
const el = e.target as any;
const isShowTooltips = isOverflowTooltip(elRef.current);
if (isShowTooltips) {
setEllipsis(el.scrollWidth >= el.clientWidth);
}
}, []);
const divContent = useMemo(
() =>
props.ellipsis ? (
<div ref={elRef} role={props.role} style={ellipsisDefaultStyle} onMouseEnter={handleMouseEnter}>
{props.children}
</div>
) : (
props.children
),
[handleMouseEnter, props.children, props.ellipsis, props.role],
);
if (!props.ellipsis || !ellipsis) {
return divContent;
}
return (
<Popover
open={ellipsis && visible}
onOpenChange={(visible) => {
setVisible(ellipsis && visible);
}}
content={<div style={popoverStyle}>{props.popoverContent || props.children}</div>}
>
{divContent}
</Popover>
);
});
EllipsisWithTooltip.displayName = 'EllipsisWithTooltip';

View File

@ -0,0 +1,140 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { css } from '@emotion/css';
import dayjs from 'dayjs';
import { connect, mapProps } from '@formily/react';
import { useBoolean } from 'ahooks';
import { Input, Radio, Space, theme } from 'antd';
import React, { useEffect, useState, useMemo } from 'react';
const date = dayjs();
const spaceCSS = css`
width: 100%;
& > .ant-space-item {
flex: 1;
}
`;
const DateFormatCom = (props?) => {
const date = dayjs();
return (
<div style={{ display: 'inline-flex' }}>
<span>{props.format}</span>
<DateTimeFormatPreview content={date.format(props.format)} />
</div>
);
};
const DateTimeFormatPreview = ({ content }) => {
const { token } = theme.useToken();
return (
<span
style={{
display: 'inline-block',
background: token.colorBgTextHover,
marginLeft: token.marginMD,
lineHeight: '1',
padding: token.paddingXXS,
borderRadius: token.borderRadiusOuter,
}}
>
{content}
</span>
);
};
const InternalExpiresRadio = (props) => {
console.log(props);
const { onChange, defaultValue, formats, picker } = props;
const [isCustom, { setFalse, setTrue }] = useBoolean(props.value && !formats.includes(props.value));
const [targetValue, setTargetValue] = useState(
props.value && !formats.includes(props.value) ? props.value : defaultValue,
);
const [customFormatPreview, setCustomFormatPreview] = useState(targetValue ? date.format(targetValue) : null);
const onSelectChange = (v) => {
if (v.target.value === 'custom') {
setTrue();
onChange(targetValue);
} else {
setFalse();
onChange(v.target.value);
}
};
useEffect(() => {
if (!formats.includes(props.value)) {
setTrue();
} else {
setFalse();
}
setTargetValue(props.value && !formats.includes(props.value) ? props.value : defaultValue);
}, [props.value]);
useEffect(() => {
setCustomFormatPreview(targetValue ? date.format(targetValue) : null);
}, [targetValue]);
return (
<Space className={spaceCSS}>
<Radio.Group value={isCustom ? 'custom' : props.value} onChange={onSelectChange}>
<Space direction="vertical">
{props.options.map((v) => {
if (v.value === 'custom') {
return (
<Radio value={v.value} key={v.value}>
<Input
style={{ width: '150px' }}
value={targetValue}
onChange={(e) => {
if (e.target.value) {
setCustomFormatPreview(date.format(e.target.value));
} else {
setCustomFormatPreview(null);
}
if (isCustom) {
onChange(e.target.value);
}
setTargetValue(e.target.value);
}}
/>
<DateTimeFormatPreview content={customFormatPreview} />
</Radio>
);
}
if (!picker || picker === 'date') {
return (
<Radio value={v.value} key={v.value} aria-label={v.value}>
<span role="button" aria-label={v.value}>
{v.label}
</span>
</Radio>
);
}
})}
</Space>
</Radio.Group>
</Space>
);
};
const ExpiresRadio = connect(
InternalExpiresRadio,
mapProps(
{
dataSource: 'options',
},
(props) => {
return {
...props,
};
},
),
);
export { ExpiresRadio, DateFormatCom };

View File

@ -0,0 +1,197 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { isValid } from '@formily/shared';
import { toFixedByStep } from '@nocobase/utils/client';
import BigNumber from 'bignumber.js';
import { format } from 'd3-format';
import * as math from 'mathjs';
import React, { useMemo } from 'react';
import { toString } from 'lodash';
import { connect, mapProps } from '@formily/react';
function countDecimalPlaces(value) {
const strValue = toString(value);
// 检查是否包含小数点
if (!strValue.includes('.')) return 0;
// 获取小数部分并去除末尾的零
const decimalPart = strValue.split('.')[1].replace(/0+$/, '');
return decimalPart.length;
}
const separators = {
'0,0.00': { thousands: ',', decimal: '.' },
'0.0,00': { thousands: '.', decimal: ',' },
'0 0,00': { thousands: ' ', decimal: '.' },
'0.00': { thousands: '', decimal: '.' }, // 没有千位分隔符
};
//分隔符换算
function formatNumberWithSeparator(value, format = '0.00', step = 1, formatStyle?) {
if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) {
return formatBigNumberWithSeparator(value, format, step, formatStyle);
}
let number = value;
if (formatStyle) {
number = Number(value);
}
let formattedNumber = '';
if (separators[format]) {
const { thousands, decimal } = separators[format];
formattedNumber = number
.toLocaleString('en-US', {
style: 'decimal',
minimumFractionDigits: step,
maximumFractionDigits: step,
})
.replace(/,/g, 'comma_placeholder')
.replace(/\./g, 'dot_placeholder')
.replace(/comma_placeholder/g, thousands)
.replace(/dot_placeholder/g, decimal);
} else {
formattedNumber = number.toString();
}
return formattedNumber;
}
//大字段分隔符换算
function formatBigNumberWithSeparator(value, format = '0.00', step = 1, formatStyle?) {
let number = value;
if (formatStyle) {
number = new BigNumber(value).toString();
}
let formattedNumber = '';
if (separators[format]) {
const { thousands, decimal } = separators[format];
const [integerPart, initFractionalPart] = number.toString().split('.');
let fractionalPart = initFractionalPart;
// 格式化整数部分
const formattedIntegerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousands);
// 处理小数部分
if (fractionalPart && step) {
fractionalPart = fractionalPart.substring(0, step);
formattedNumber = `${formattedIntegerPart}${decimal}${fractionalPart}`;
} else {
formattedNumber = formattedIntegerPart;
}
} else {
formattedNumber = number.toString();
}
return formattedNumber;
}
//单位换算
function formatUnitConversion(value, operator = '*', multiplier?: number) {
if (!multiplier) {
return value;
}
let result;
if (operator === '*') {
result = value * multiplier;
} else if (operator === '/') {
if (multiplier !== 0) {
result = value / multiplier;
} else {
console.error('Error: Division by zero.');
return null;
}
} else {
console.error("Error: Invalid operator. Use '*' for multiplication or '/' for division.");
return null;
}
return math.round(result, 9);
}
//科学计数法显示
function scientificNotation(number, decimalPlaces, separator = '.') {
const formatter = format(`.${decimalPlaces}e`);
const formattedNumber = formatter(number).replace('.', separator);
// 匹配科学计数法中的指数部分,判断正负情况
const result = formattedNumber.replace(/e([+-]?\d+)/, (match, exponent) => {
if (exponent.startsWith('+')) {
// 正数指数,不显示符号
return `×10<sup>${exponent.slice(1)}</sup>`;
} else {
// 负数指数,显示 "-" 符号
return `×10<sup>-${exponent.slice(1)}</sup>`;
}
});
return result;
}
function formatNumber(props) {
const { step, formatStyle = 'normal', value, unitConversion, unitConversionType, separator = '0,0.00' } = props;
if (!isValid(value)) {
return null;
}
//单位换算
const unitData = formatUnitConversion(value, unitConversionType, unitConversion);
//精度换算
const precisionData = toFixedByStep(unitData, step);
let result;
//分隔符换算
result = formatNumberWithSeparator(precisionData, separator, countDecimalPlaces(step), formatStyle);
if (formatStyle === 'scientifix') {
//科学计数显示
result = scientificNotation(Number(unitData), countDecimalPlaces(step), separators?.[separator]?.['decimal']);
}
return result;
}
interface InputNumberReadPrettyProps {
formatStyle?: 'normal' | 'scientifix';
unitConversion?: number;
/**
* @default '*'
*/
unitConversionType?: '*' | '/';
/**
* @default '0.00'
*/
separator?: '0,0.00' | '0.0,00' | '0 0,00' | '0.00';
step?: number;
value?: any;
addonBefore?: React.ReactNode;
addonAfter?: React.ReactNode;
}
export const InputNumberReadPretty = connect(
(props: InputNumberReadPrettyProps) => {
const { step, formatStyle, value, addonBefore, addonAfter, unitConversion, unitConversionType, separator } = props;
const result = useMemo(() => {
return formatNumber({ step, formatStyle, value, unitConversion, unitConversionType, separator });
}, [step, formatStyle, value, unitConversion, unitConversionType, separator]);
if (!result) {
return null;
}
return (
<div>
{addonBefore}
<span dangerouslySetInnerHTML={{ __html: result }} />
{addonAfter}
</div>
);
},
mapProps((props) => {
return {
...props,
};
}),
);

View File

@ -0,0 +1,11 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './EllipsisWithTooltip';
export * from './ExpiresRadio';

View File

@ -0,0 +1,169 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { css } from '@emotion/css';
import { getPickerFormat } from '@nocobase/utils/client';
import { ExpiresRadio, DateFormatCom } from '../components';
export const DateTimeFormat = {
title: 'Date display format',
name: 'dateDisplayFormat',
uiSchema: {
picker: {
type: 'string',
title: '{{t("Picker")}}',
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
description: '{{ t("Switching the picker, the value and default value will be cleared") }}',
enum: [
{
label: '{{t("Date")}}',
value: 'date',
},
{
label: '{{t("Month")}}',
value: 'month',
},
{
label: '{{t("Quarter")}}',
value: 'quarter',
},
{
label: '{{t("Year")}}',
value: 'year',
},
],
},
dateFormat: {
type: 'string',
title: '{{t("Date format")}}',
'x-component': ExpiresRadio,
'x-decorator': 'FormItem',
'x-decorator-props': {},
'x-component-props': {
className: css`
.ant-radio-wrapper {
display: flex;
margin: 5px 0px;
}
`,
defaultValue: 'dddd',
formats: ['MMMM Do YYYY', 'YYYY-MM-DD', 'MM/DD/YY', 'YYYY/MM/DD', 'DD/MM/YYYY'],
},
enum: [
{
label: DateFormatCom({ format: 'MMMM Do YYYY' }),
value: 'MMMM Do YYYY',
},
{
label: DateFormatCom({ format: 'YYYY-MM-DD' }),
value: 'YYYY-MM-DD',
},
{
label: DateFormatCom({ format: 'MM/DD/YY' }),
value: 'MM/DD/YY',
},
{
label: DateFormatCom({ format: 'YYYY/MM/DD' }),
value: 'YYYY/MM/DD',
},
{
label: DateFormatCom({ format: 'DD/MM/YYYY' }),
value: 'DD/MM/YYYY',
},
{
label: 'custom',
value: 'custom',
},
],
'x-reactions': [
(field) => {
const { picker } = field.form.values;
field.value = getPickerFormat(picker);
field.setComponentProps({ picker });
},
],
},
showTime: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': '{{t("Show time")}}',
'x-reactions': [
{
dependencies: ['picker'],
fulfill: {
state: {
hidden: `{{ $form.values.picker !== 'date' || collectionField.type!== 'date' }}`,
},
},
},
],
},
timeFormat: {
type: 'string',
title: '{{t("Time format")}}',
'x-component': ExpiresRadio,
'x-decorator': 'FormItem',
'x-decorator-props': {
className: css`
margin-bottom: 0px;
`,
},
'x-component-props': {
className: css`
color: red;
.ant-radio-wrapper {
display: flex;
margin: 5px 0px;
}
`,
defaultValue: 'h:mm a',
formats: ['hh:mm:ss a', 'HH:mm:ss'],
timeFormat: true,
},
'x-reactions': [
(field) => {
const { showTime } = field.form.values || {};
field.hidden = !showTime;
},
],
enum: [
{
label: DateFormatCom({ format: 'hh:mm:ss a' }),
value: 'hh:mm:ss a',
},
{
label: DateFormatCom({ format: 'HH:mm:ss' }),
value: 'HH:mm:ss',
},
{
label: 'custom',
value: 'custom',
},
],
},
},
handler(ctx, params) {
ctx.model.flowEngine.flowSettings.registerScopes({
collectionField: ctx.model.collectionField,
});
ctx.model.setProps({ ...params });
},
defaultParams: (ctx) => {
const { showTime, dateFormat, timeFormat, picker } = ctx.model.field.componentProps || {};
return {
picker: picker || 'date',
dateFormat: dateFormat || 'YYYY-MM-DD',
timeFormat: timeFormat,
showTime,
};
},
};

View File

@ -0,0 +1,106 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Form, GeneralField, isVoidField } from '@formily/core';
import { RenderPropsChildren, SchemaComponentsContext } from '@formily/react';
import { toJS } from '@formily/reactive';
import { observer } from '@formily/reactive-react';
import { FormPath, isFn } from '@formily/shared';
import React, { Fragment, useContext } from 'react';
interface IReactiveFieldProps {
field: GeneralField;
children?: RenderPropsChildren<GeneralField>;
}
const mergeChildren = (children: RenderPropsChildren<GeneralField>, content: React.ReactNode) => {
if (!children && !content) return;
if (isFn(children)) return;
return (
<Fragment>
{children}
{content}
</Fragment>
);
};
const isValidComponent = (target: any) => target && (typeof target === 'object' || typeof target === 'function');
const renderChildren = (children: RenderPropsChildren<GeneralField>, field?: GeneralField, form?: Form) =>
isFn(children) ? children(field, form) : children;
const ReactiveInternal: React.FC<IReactiveFieldProps> = (props) => {
const components = useContext(SchemaComponentsContext);
if (!props.field) {
return <Fragment>{renderChildren(props.children)}</Fragment>;
}
const field = props.field;
const content = mergeChildren(
renderChildren(props.children, field, field.form),
field.content ?? field.componentProps.children,
);
if (field.display !== 'visible') return null;
const getComponent = (target: any) => {
return isValidComponent(target) ? target : FormPath.getIn(components, target) ?? target;
};
const renderDecorator = (children: React.ReactNode) => {
if (!field.decoratorType) {
return <Fragment>{children}</Fragment>;
}
return React.createElement(getComponent(field.decoratorType), toJS(field.decoratorProps), children);
};
const renderComponent = () => {
if (!field.componentType) return content;
const value = !isVoidField(field) ? field.value : undefined;
const onChange = !isVoidField(field)
? (...args: any[]) => {
field.onInput(...args);
field.componentProps?.onChange?.(...args);
}
: field.componentProps?.onChange;
const onFocus = !isVoidField(field)
? (...args: any[]) => {
field.onFocus(...args);
field.componentProps?.onFocus?.(...args);
}
: field.componentProps?.onFocus;
const onBlur = !isVoidField(field)
? (...args: any[]) => {
field.onBlur(...args);
field.componentProps?.onBlur?.(...args);
}
: field.componentProps?.onBlur;
const disabled = !isVoidField(field) ? field.pattern === 'disabled' || field.pattern === 'readPretty' : undefined;
const readOnly = !isVoidField(field) ? field.pattern === 'readOnly' : undefined;
return React.createElement(
getComponent(field.componentType),
{
disabled,
readOnly,
...toJS(field.componentProps),
value,
onChange,
onFocus,
onBlur,
},
content,
);
};
return renderDecorator(renderComponent());
};
ReactiveInternal.displayName = 'ReactiveField';
export const ReactiveField = observer(ReactiveInternal, {
forwardRef: true,
});

View File

@ -0,0 +1,8 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/

View File

@ -10,44 +10,37 @@
import { DataSource, DataSourceManager, FlowModel } from '@nocobase/flow-engine'; import { DataSource, DataSourceManager, FlowModel } from '@nocobase/flow-engine';
import _ from 'lodash'; import _ from 'lodash';
import { Plugin } from '../application/Plugin'; import { Plugin } from '../application/Plugin';
import * as actions from './actions';
import { FlowEngineRunner } from './FlowEngineRunner'; import { FlowEngineRunner } from './FlowEngineRunner';
import { MockFlowModelRepository } from './FlowModelRepository'; import { FlowModelRepository, MockFlowModelRepository } from './FlowModelRepository';
import { FlowPage } from './FlowPage'; import { FlowRoute } from './FlowPage';
import { DateTimeFormat } from './flowSetting/DateTimeFormat';
import * as models from './models'; import * as models from './models';
export class PluginFlowEngine extends Plugin { export class PluginFlowEngine extends Plugin {
async load() { async load() {
this.app.addComponents({ FlowPage }); this.app.addComponents({ FlowRoute });
this.app.flowEngine.setModelRepository(new MockFlowModelRepository()); // this.app.flowEngine.setModelRepository(new MockFlowModelRepository());
this.app.flowEngine.setModelRepository(new FlowModelRepository(this.app));
const filteredModels = Object.fromEntries( const filteredModels = Object.fromEntries(
Object.entries(models).filter( Object.entries(models).filter(
([, ModelClass]) => typeof ModelClass === 'function' && ModelClass.prototype instanceof FlowModel, ([, ModelClass]) => typeof ModelClass === 'function' && ModelClass.prototype instanceof FlowModel,
), ),
); ) as Record<string, typeof FlowModel>;
console.log('Registering flow models:', Object.keys(filteredModels)); // console.log('Registering flow models:', Object.keys(filteredModels));
this.flowEngine.registerModels(filteredModels); this.flowEngine.registerModels(filteredModels);
this.flowEngine.registerActions(actions);
const dataSourceManager = new DataSourceManager(); const dataSourceManager = new DataSourceManager();
this.flowEngine.context['app'] = this.app; this.flowEngine.context['flowEngine'] = this.flowEngine;
this.flowEngine.context['api'] = this.app.apiClient;
this.flowEngine.context['dataSourceManager'] = dataSourceManager; this.flowEngine.context['dataSourceManager'] = dataSourceManager;
try { const mainDataSource = new DataSource({
const response = await this.app.apiClient.request<any>({ key: 'main',
url: '/collections:listMeta', displayName: 'Main',
}); });
const mainDataSource = new DataSource({ dataSourceManager.addDataSource(mainDataSource);
name: 'main',
displayName: 'Main',
});
dataSourceManager.addDataSource(mainDataSource);
const collections = response.data?.data || [];
collections.forEach((collection) => {
mainDataSource.addCollection(collection);
});
} catch (error) {
console.error('Failed to load collections:', error);
// Optionally, you can throw an error or handle it as needed
}
this.app.addProvider(FlowEngineRunner, {}); this.app.addProvider(FlowEngineRunner, {});
// 注册通用 flow
this.flowEngine.registerAction(DateTimeFormat);
} }
} }

View File

@ -1,58 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FlowModel } from '@nocobase/flow-engine';
import { Modal } from 'antd';
import React from 'react';
export class ActionModel extends FlowModel {
set onClick(fn) {
this.setProps('onClick', fn);
}
render() {
return <a {...this.props}>{this.props.title || 'Untitle'}</a>;
}
}
ActionModel.registerFlow({
key: 'default',
auto: true,
steps: {
step1: {
handler(ctx, params) {
ctx.model.setProps('title', params.title);
ctx.model.onClick = (e) => {
ctx.model.dispatchEvent('click', {
event: e,
record: ctx.extra.record,
});
};
},
},
},
});
ActionModel.registerFlow({
key: 'event1',
on: {
eventName: 'click',
},
steps: {
step1: {
handler(ctx, params) {
Modal.confirm({
title: `${ctx.extra.record?.id}`,
content: 'Are you sure you want to perform this action?',
onOk: async () => {},
});
},
},
},
});

View File

@ -1,127 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FormItem, Input } from '@formily/antd-v5';
import { Field, Form } from '@formily/core';
import { FieldContext } from '@formily/react';
import { CollectionField, FlowModel } from '@nocobase/flow-engine';
import React from 'react';
import { ReactiveField } from '../Formily/ReactiveField';
export class FormFieldModel extends FlowModel {
collectionField: CollectionField;
field: Field;
get form() {
return this.parent.form as Form;
}
setTitle(title: string) {
this.field.title = title || this.collectionField.title;
}
setRequired(required: boolean) {
this.field.required = required;
}
setInitialValue(initialValue: any) {
this.field.initialValue = initialValue;
}
createField() {
return this.form.createField({
name: this.collectionField.name,
...this.props,
decorator: [
FormItem,
{
title: this.props.title,
},
],
component: [Input, {}],
});
}
render() {
return (
<FieldContext.Provider value={this.field}>
<ReactiveField field={this.field}>{this.props.children}</ReactiveField>
</FieldContext.Provider>
);
}
}
FormFieldModel.registerFlow({
key: 'default',
auto: true,
title: 'Basic',
steps: {
step1: {
handler(ctx, params) {
const collectionField = ctx.globals.dataSourceManager.getCollectionField(params.fieldPath);
ctx.model.collectionField = collectionField;
ctx.model.field = ctx.model.createField();
},
},
editTitle: {
title: 'Edit Title',
uiSchema: {
title: {
'x-component': 'Input',
'x-decorator': 'FormItem',
'x-component-props': {
placeholder: 'Enter field title',
},
},
},
handler(ctx, params) {
ctx.model.setTitle(params.title);
},
},
initialValue: {
title: 'Default value',
uiSchema: {
defaultValue: {
'x-component': 'Input',
'x-decorator': 'FormItem',
'x-component-props': {},
},
},
handler(ctx, params) {
ctx.model.setInitialValue(params.defaultValue);
},
},
},
});
export class CommonFormItemFlowModel extends FormFieldModel {}
FormFieldModel.registerFlow({
key: 'key2',
auto: true,
title: 'Group2',
steps: {
required: {
title: 'Required',
uiSchema: {
required: {
'x-component': 'Switch',
'x-decorator': 'FormItem',
'x-component-props': {
checkedChildren: 'Yes',
unCheckedChildren: 'No',
},
},
},
handler(ctx, params) {
ctx.model.setRequired(params.required || false);
},
},
},
});

View File

@ -1,133 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FormButtonGroup, FormDialog, FormLayout, Submit } from '@formily/antd-v5';
import { createForm, Form } from '@formily/core';
import { FormProvider } from '@formily/react';
import {
AddActionButton,
AddFieldButton,
AddFieldButtonProps,
Collection,
FlowEngineProvider,
FlowModelRenderer,
SingleRecordResource,
} from '@nocobase/flow-engine';
import { Card } from 'antd';
import React from 'react';
import { ActionModel } from './ActionModel';
import { BlockFlowModel } from './BlockFlowModel';
import { FormFieldModel } from './FormFieldModel';
export class FormModel extends BlockFlowModel {
form: Form;
resource: SingleRecordResource;
collection: Collection;
render() {
const buildColumnSubModelParams: AddFieldButtonProps['buildSubModelParams'] = (item) => {
return {
use: 'FormFieldModel',
stepParams: {
default: {
step1: {
fieldPath: `${item.field.collection.dataSource.name}.${item.field.collection.name}.${item.field.name}`,
},
},
},
};
};
return (
<Card>
<FormProvider form={this.form}>
<FormLayout layout={'vertical'}>
{this.mapSubModels('fields', (field) => (
<FlowModelRenderer model={field} showFlowSettings />
))}
</FormLayout>
<AddFieldButton
buildSubModelParams={buildColumnSubModelParams}
onModelAdded={async (fieldModel: FormFieldModel, item) => {
fieldModel.collectionField = item.field;
}}
subModelKey="fields"
model={this}
collection={this.collection}
ParentModelClass={FormFieldModel}
/>
<FormButtonGroup>
{this.mapSubModels('actions', (action) => (
<FlowModelRenderer model={action} />
))}
<AddActionButton model={this} ParentModelClass={ActionModel} />
</FormButtonGroup>
</FormProvider>
</Card>
);
}
}
FormModel.registerFlow({
key: 'default',
auto: true,
steps: {
step1: {
paramsRequired: true,
hideInSettings: true,
uiSchema: {
dataSourceKey: {
type: 'string',
title: 'Data Source Key',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: 'Enter data source key',
},
},
collectionName: {
type: 'string',
title: 'Collection Name',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: 'Enter collection name',
},
},
},
defaultParams: {
dataSourceKey: 'main',
},
async handler(ctx, params) {
ctx.model.form = ctx.extra.form || createForm();
if (ctx.model.collection) {
return;
}
ctx.model.collection = ctx.globals.dataSourceManager.getCollection(params.dataSourceKey, params.collectionName);
const resource = new SingleRecordResource();
resource.setDataSourceKey(params.dataSourceKey);
resource.setResourceName(params.collectionName);
resource.setAPIClient(ctx.globals.api);
ctx.model.resource = resource;
if (ctx.extra.filterByTk) {
resource.setFilterByTk(ctx.extra.filterByTk);
await resource.refresh();
ctx.model.form.setInitialValues(resource.getData());
}
},
},
},
});
FormModel.define({
title: 'Form',
group: 'Content',
defaultOptions: {
use: 'FormModel',
},
});

View File

@ -1,116 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FormButtonGroup, FormDialog, FormLayout, Submit } from '@formily/antd-v5';
import { createForm, Form } from '@formily/core';
import { FormProvider } from '@formily/react';
import {
Collection,
CollectionField,
FlowEngine,
FlowEngineProvider,
FlowModelRenderer,
SingleRecordResource,
} from '@nocobase/flow-engine';
import dataSource from 'packages/core/client/docs/zh-CN/core/flow-models/demos/data-source';
import React from 'react';
import { BlockFlowModel } from './BlockFlowModel';
export class QuickEditForm extends BlockFlowModel {
form: Form;
resource: SingleRecordResource;
collection: Collection;
static async open(options: { flowEngine: FlowEngine; collectionField: CollectionField; filterByTk: string }) {
const model = options.flowEngine.createModel({
use: 'QuickEditForm',
}) as QuickEditForm;
await model.open(options);
options.flowEngine.removeModel(model.uid);
}
async open({ collectionField, filterByTk }: { filterByTk: string; collectionField: CollectionField }) {
await this.applyFlow('initial', {
dataSourceKey: collectionField.collection.dataSource.name,
collectionName: collectionField.collection.name,
filterByTk,
fieldPath: collectionField.fullpath,
});
return new Promise((resolve) => {
const dialog = FormDialog(
{
footer: null,
title: 'Quick edit',
},
(form) => {
return (
<FlowEngineProvider engine={this.flowEngine}>
<FormProvider form={this.form}>
<FormLayout layout={'vertical'}>
{this.mapSubModels('fields', (field) => (
<FlowModelRenderer model={field} />
))}
</FormLayout>
<FormButtonGroup>
<Submit
onClick={async () => {
await this.resource.save(this.form.values);
dialog.close();
resolve(this.form.values); // 在 close 之后 resolve
}}
>
Submit
</Submit>
</FormButtonGroup>
</FormProvider>
</FlowEngineProvider>
);
},
);
dialog.open();
});
}
}
QuickEditForm.registerFlow({
key: 'initial',
steps: {
step1: {
async handler(ctx) {
ctx.model.form = createForm();
ctx.model.collection = ctx.globals.dataSourceManager.getCollection(
ctx.extra.dataSourceKey,
ctx.extra.collectionName,
);
const resource = new SingleRecordResource();
resource.setDataSourceKey(ctx.extra.dataSourceKey);
resource.setResourceName(ctx.extra.collectionName);
resource.setAPIClient(ctx.globals.api);
ctx.model.resource = resource;
if (ctx.extra.filterByTk) {
resource.setFilterByTk(ctx.extra.filterByTk);
await resource.refresh();
ctx.model.form.setInitialValues(resource.getData());
}
if (ctx.extra.fieldPath) {
ctx.model.addSubModel('fields', {
use: 'FormItemModel',
stepParams: {
default: {
step1: {
fieldPath: ctx.extra.fieldPath,
},
},
},
});
}
},
},
},
});

View File

@ -1,37 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Submit } from '@formily/antd-v5';
import React from 'react';
import { ActionModel } from './ActionModel';
export class SubmitActionModel extends ActionModel {
render() {
return <Submit {...this.props}>{this.props.title || 'Submit'}</Submit>;
}
}
SubmitActionModel.registerFlow({
key: 'event1',
on: {
eventName: 'click',
},
steps: {
step1: {
async handler(ctx, params) {
await ctx.model.parent.form.submit();
const values = ctx.model.parent.form.values;
await ctx.model.parent.resource.save(values);
if (ctx.model.parent.dialog) {
ctx.model.parent.dialog.close();
}
},
},
},
});

View File

@ -1,133 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { EditOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { CollectionField, FlowModelRenderer, FlowsFloatContextMenu } from '@nocobase/flow-engine';
import { Space, TableColumnProps, Tooltip } from 'antd';
import React from 'react';
import { ActionModel } from './ActionModel';
import { FieldFlowModel } from './FieldFlowModel';
import { QuickEditForm } from './QuickEditForm';
export class TableColumnModel extends FieldFlowModel {
// field: Field;
// fieldPath: string;
getColumnProps(): TableColumnProps {
return {
...this.props,
title: (
<FlowsFloatContextMenu
model={this}
containerStyle={{ display: 'block', padding: '11px 8px', margin: '-11px -8px' }}
>
{this.props.title}
</FlowsFloatContextMenu>
),
ellipsis: true,
onCell: (record) => ({
className: css`
.edit-icon {
position: absolute;
display: none;
color: #1890ff;
margin-left: 8px;
cursor: pointer;
top: 50%;
right: 8px;
transform: translateY(-50%);
}
&:hover {
background: rgba(24, 144, 255, 0.1) !important;
}
&:hover .edit-icon {
display: inline-flex;
}
`,
}),
render: this.render(),
};
}
renderQuickEditButton(record) {
return (
<Tooltip title="快速编辑">
<EditOutlined
className="edit-icon"
onClick={async (e) => {
e.stopPropagation();
await QuickEditForm.open({
flowEngine: this.flowEngine,
collectionField: this.field as CollectionField,
filterByTk: record.id,
});
await this.parent.resource.refresh();
}}
/>
</Tooltip>
);
}
render() {
return (value, record, index) => (
<>
{value}
{this.renderQuickEditButton(record)}
</>
);
}
}
TableColumnModel.define({
title: 'Table Column',
icon: 'TableColumn',
defaultOptions: {
use: 'TableColumnModel',
},
sort: 0,
});
export class TableColumnActionsModel extends TableColumnModel {
getColumnProps() {
return { title: 'Actions', ...this.props, render: this.render() };
}
render() {
return (value, record, index) => (
<Space>
{this.mapSubModels('actions', (action: ActionModel) => (
<FlowModelRenderer key={action.uid} model={action} extraContext={{ record }} />
))}
</Space>
);
}
}
TableColumnModel.registerFlow({
key: 'default',
auto: true,
steps: {
step1: {
handler(ctx, params) {
if (!params.fieldPath) {
return;
}
if (ctx.model.field) {
return;
}
const field = ctx.globals.dataSourceManager.getCollectionField(params.fieldPath);
ctx.model.fieldPath = params.fieldPath;
ctx.model.setProps('title', field.title);
ctx.model.setProps('dataIndex', field.name);
ctx.model.field = field;
},
},
},
});

View File

@ -1,148 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import {
AddActionButton,
AddFieldButton,
AddFieldButtonProps,
AddFieldMenuItem,
AddSubModelMenuItem,
Collection,
FlowModel,
MultiRecordResource,
} from '@nocobase/flow-engine';
import { Button, Card, Dropdown, Table } from 'antd';
import React from 'react';
import { BlockFlowModel } from './BlockFlowModel';
import { FieldFlowModel } from './FieldFlowModel';
import { TableColumnModel } from './TableColumnModel';
type S = {
subModels: {
columns: TableColumnModel[];
};
};
export class TableModel extends BlockFlowModel<S> {
collection: Collection;
resource: MultiRecordResource;
getColumns() {
const buildColumnSubModelParams: AddFieldButtonProps['buildSubModelParams'] = (item) => {
return {
use: 'TableColumnModel',
props: {
dataIndex: item.field.name,
title: item.field.title,
},
};
};
const onModelAdded = async (column: TableColumnModel, item: AddFieldMenuItem) => {
const field = item.field;
column.field = field;
column.fieldPath = `${field.collection.dataSource.name}.${field.collection.name}.${field.name}`;
column.setStepParams('default', 'step1', {
fieldPath: column.fieldPath,
});
await column.applyAutoFlows();
};
return this.mapSubModels('columns', (column) => {
const ps = column.getColumnProps();
return ps;
}).concat({
key: 'addColumn',
fixed: 'right',
title: (
<AddFieldButton
onModelAdded={onModelAdded}
buildSubModelParams={buildColumnSubModelParams}
subModelKey="columns"
model={this}
collection={this.collection}
ParentModelClass={FieldFlowModel}
/>
),
} as any);
}
render() {
return (
<Card>
<Table
rowKey="id"
dataSource={this.resource.getData()}
columns={this.getColumns()}
pagination={{
current: this.resource.getMeta('page'),
pageSize: this.resource.getMeta('pageSize'),
total: this.resource.getMeta('count'),
}}
onChange={(pagination) => {
this.resource.setPage(pagination.current);
this.resource.setPageSize(pagination.pageSize);
this.resource.refresh();
}}
/>
</Card>
);
}
}
TableModel.registerFlow({
key: 'default',
auto: true,
steps: {
step1: {
paramsRequired: true,
hideInSettings: true,
uiSchema: {
dataSourceKey: {
type: 'string',
title: 'Data Source Key',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: 'Enter data source key',
},
},
collectionName: {
type: 'string',
title: 'Collection Name',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: 'Enter collection name',
},
},
},
defaultParams: {
dataSourceKey: 'main',
},
handler: async (ctx, params) => {
const collection = ctx.globals.dataSourceManager.getCollection(params.dataSourceKey, params.collectionName);
ctx.model.collection = collection;
const resource = new MultiRecordResource();
resource.setDataSourceKey(params.dataSourceKey);
resource.setResourceName(params.collectionName);
resource.setAPIClient(ctx.globals.api);
ctx.model.resource = resource;
await resource.refresh();
await ctx.model.applySubModelsAutoFlows('columns');
},
},
},
});
TableModel.define({
title: 'Table',
group: 'Content',
defaultOptions: {
use: 'TableModel',
},
});

View File

@ -0,0 +1,40 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ButtonProps } from 'antd';
import { GlobalActionModel } from '../base/ActionModel';
export class AddNewActionModel extends GlobalActionModel {
defaultProps: ButtonProps = {
type: 'primary',
title: 'Add new',
icon: 'PlusOutlined',
};
}
AddNewActionModel.registerFlow({
sort: 200,
title: '点击事件',
key: 'handleClick',
on: {
eventName: 'click',
},
steps: {
popup: {
use: 'popup',
defaultParams(ctx) {
return {
sharedContext: {
parentBlockModel: ctx.shared?.currentBlockModel,
},
};
},
},
},
});

View File

@ -0,0 +1,49 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { MultiRecordResource } from '@nocobase/flow-engine';
import { ButtonProps } from 'antd';
import { refreshOnCompleteAction } from '../../actions/refreshOnCompleteAction';
import { secondaryConfirmationAction } from '../../actions/secondaryConfirmationAction';
import { GlobalActionModel } from '../base/ActionModel';
export class BulkDeleteActionModel extends GlobalActionModel {
defaultProps: ButtonProps = {
title: 'Delete',
icon: 'DeleteOutlined',
};
}
BulkDeleteActionModel.registerFlow({
key: 'handleClick',
title: '点击事件',
on: {
eventName: 'click',
},
steps: {
confirm: {
use: 'confirm',
},
delete: {
async handler(ctx, params) {
if (!ctx.shared?.currentBlockModel?.resource) {
ctx.globals.message.error('No resource selected for deletion.');
return;
}
const resource = ctx.shared.currentBlockModel.resource as MultiRecordResource;
if (resource.getSelectedRows().length === 0) {
ctx.globals.message.warning('No records selected for deletion.');
return;
}
await resource.destroySelectedRows();
ctx.globals.message.success('Selected records deleted successfully.');
},
},
},
});

Some files were not shown because too many files have changed in this diff Show More