Merge branch '2.0' into feat/actions-2.0

This commit is contained in:
Zeke Zhang 2025-06-19 06:12:17 +08:00
commit 0306ba6423
26 changed files with 802 additions and 173 deletions

View File

@ -0,0 +1,87 @@
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() {
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);
class PluginDemo extends Plugin {
async load() {
this.flowEngine.registerModels({ MyModel });
this.flowEngine.registerAction({
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();
}
},
});
const model = this.flowEngine.createModel({
use: 'MyModel',
});
this.router.add('root', {
path: '/',
element: <FlowModelRenderer model={model} showFlowSettings />,
});
}
}
export default createApp({ plugins: [PluginDemo] });

View File

@ -7,3 +7,7 @@
## 弹窗
<code src="./demos/popup.tsx"></code>
## Confirm
<code src="./demos/confirm.tsx"></code>

View File

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

View File

@ -1,6 +1,6 @@
# FlowAction
`FlowAction` 是 NocoBase 流引擎中用于定义和注册流步骤可复用操作Action的核心对象。每个操作Action封装一段可执行的业务逻辑,可在多个流步骤中复用支持参数配置、UI 配置和类型推断。
`FlowAction` 是 NocoBase 流引擎中用于定义和注册流步骤可复用操作Action的核心对象。每个操作封装一段可执行的业务逻辑可在多个流步骤中复用支持参数配置、UI 配置和类型推断。
---
@ -10,48 +10,50 @@
interface ActionDefinition {
name: string; // 操作唯一标识,必须唯一
title?: string; // 操作显示名称(可选)
uiSchema?: Record<string, ISchema>; // (可选)用于参数配置界面渲染
uiSchema?: Record<string, ISchema>; // (可选)参数配置界面渲染
defaultParams?: Record<string, any>; // (可选)默认参数
paramsRequired?: boolean; // (可选)是否需要参数配置为true时添加模型前会打开配置对话框
hideInSettings?: boolean; // (可选)是否在设置菜单中隐藏该步骤
paramsRequired?: boolean; // (可选)是否需要参数配置
hideInSettings?: boolean; // (可选)是否在设置菜单中隐藏
handler: (ctx: FlowContext, params: any) => Promise<any> | any; // 操作执行逻辑
}
```
---
## 定义操作的方式
## 使用说明
### 1. 使用 defineAction 工具函数
### 1. 定义 Action
推荐方式,结构清晰、类型推断友好:
#### 方式一:使用 defineAction 工具函数(推荐)
结构清晰,类型推断友好:
```ts
const myAction = defineAction({
name: 'actionName',
name: 'myAction',
title: '操作显示名称',
uiSchema: {},
defaultParams: {},
paramsRequired: true, // 添加模型前强制打开配置对话框
hideInSettings: false, // 在设置菜单中显示
paramsRequired: true,
hideInSettings: false,
async handler(ctx, params) {
// 操作逻辑
},
});
```
### 2. 实现 ActionDefinition 接口
#### 方式二:实现 ActionDefinition 接口
适合需要扩展属性或方法的场景:
复杂场景时可以通过定义 Action 类来处理更复杂的操作
```ts
class MyAction implements ActionDefinition {
name = 'actionName';
name = 'myAction';
title = '操作显示名称';
uiSchema = {};
defaultParams = {};
paramsRequired = true; // 添加模型前强制打开配置对话框
hideInSettings = false; // 在设置菜单中显示
paramsRequired = true;
hideInSettings = false;
async handler(ctx, params) {
// 操作逻辑
}
@ -60,59 +62,115 @@ class MyAction implements ActionDefinition {
---
## 注册操作
注册后可在流步骤中通过 `use` 字段复用:
### 2. 注册到 FlowEngine 里
```ts
flowEngine.registerAction({
name: 'actionName',
title: '操作显示名称',
uiSchema: {},
defaultParams: {},
paramsRequired: true, // 添加模型前强制打开配置对话框
hideInSettings: false, // 在设置菜单中显示
handler(ctx, params) {
// 操作逻辑
},
});
flowEngine.registerAction(myAction); // 注册 defineAction 返回的对象
flowEngine.registerAction(new MyAction()); // 注册类实例
```
---
## 在流中复用操作
### 3. 在流中使用
在流步骤定义中通过 `use` 字段引用已注册的操作:
```ts
steps: {
step1: {
use: 'actionName', // 复用已注册的操作
use: 'myAction', // 复用已注册的操作
defaultParams: {},
paramsRequired: true, // 可以在步骤级别覆盖操作的paramsRequired设置
hideInSettings: false, // 可以在步骤级别覆盖操作的hideInSettings设置
paramsRequired: true, // 可覆盖操作的 paramsRequired
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
- **类型**: `boolean`
- **默认值**: `false`
- **说明**: 当设置为 `true` 时,在添加该步骤模型前会强制打开参数配置对话框,确保用户配置必要的参数。适用于需要用户必须配置参数才能正常工作的操作。
- **说明**: 为 `true` 时,添加步骤前会强制打开参数配置对话框,确保用户配置必要参数。适用于参数必填的场景
### hideInSettings
- **类型**: `boolean`
- **默认值**: `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,34 @@ steps: {
- **FlowAction** 让流步骤逻辑高度复用,便于维护和扩展。
- 支持多种定义方式,适应不同复杂度的业务场景。
- 可通过 `uiSchema``defaultParams` 配置参数界面和默认值,提升易用性。
- 合理使用 `paramsRequired``hideInSettings`,提升操作安全性和灵活性。
---
## 示例:在 Drawer 中使用
以下示例演示如何在 Drawer 中使用 FlowAction并传递 `currentDrawer``sharedContext`
```tsx
// 1. 先声明 currentDrawer
let currentDrawer: any;
// 2. 定义内容组件,确保 currentDrawer 已赋值
function DrawerContent() {
return (
<div>
<FlowPageComponent
uid={`${ctx.model.uid}-drawer`}
sharedContext={{ ...ctx.extra, currentDrawer }}
/>
</div>
);
}
// 3. 打开 Drawer并赋值 currentDrawer
currentDrawer = ctx.globals.drawer.open({
title: '命令式 Drawer',
width: 800,
content: <DrawerContent />,
});
```

View File

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

View File

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

View File

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

View File

@ -26,6 +26,26 @@ export class MockFlowModelRepository implements IFlowModelRepository<FlowModel>
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) {
const data = localStorage.getItem(`flow-model:${uid}`);
@ -43,10 +63,12 @@ export class MockFlowModelRepository implements IFlowModelRepository<FlowModel>
json.subModels[model.subKey].push(subModel);
} else if (model.subType === 'object') {
const subModel = await this.load(model.uid);
if (subModel) {
json.subModels[model.subKey] = subModel;
}
}
}
}
console.log('Loading model:', uid, JSON.stringify(json, null, 2));
return json;
}

View File

@ -10,12 +10,12 @@
import { FlowModelRenderer, useFlowEngine, useFlowModel } from '@nocobase/flow-engine';
import { useRequest } from 'ahooks';
import { Spin } from 'antd';
import React from 'react';
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
function InternalFlowPage({ uid }) {
function InternalFlowPage({ uid, sharedContext }) {
const model = useFlowModel(uid);
return <FlowModelRenderer model={model} showFlowSettings hideRemoveInSettings />;
return <FlowModelRenderer model={model} sharedContext={sharedContext} showFlowSettings hideRemoveInSettings />;
}
export const FlowPage = () => {
@ -23,12 +23,13 @@ export const FlowPage = () => {
return <FlowPageComponent uid={params.name} />;
};
export const FlowPageComponent = ({ uid }) => {
export const FlowPageComponent = (props) => {
const { uid, parentId, sharedContext } = props;
const flowEngine = useFlowEngine();
const { loading } = useRequest(
() => {
return flowEngine.loadOrCreateModel({
uid: uid,
const { loading, data } = useRequest(
async () => {
const options = {
uid,
use: 'PageFlowModel',
subModels: {
tabs: [
@ -42,14 +43,22 @@ export const FlowPageComponent = ({ uid }) => {
},
],
},
});
};
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 <InternalFlowPage uid={uid} />;
return <InternalFlowPage uid={data.uid} sharedContext={sharedContext} />;
};

View File

@ -0,0 +1,46 @@
/**
* 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 type { ButtonType } from 'antd/es/button';
import React from 'react';
import { FlowPageComponent } from '../FlowPage';
import { ActionModel } from './ActionModel';
export class AddNewActionModel extends ActionModel {
title = 'Add new';
}
AddNewActionModel.registerFlow({
key: 'event1',
on: {
eventName: 'click',
},
steps: {
step1: {
handler(ctx, params) {
// eslint-disable-next-line prefer-const
let currentDrawer: any;
function DrawerContent() {
return (
<div>
<FlowPageComponent parentId={ctx.model.uid} sharedContext={{ ...ctx.extra, currentDrawer }} />
</div>
);
}
currentDrawer = ctx.globals.drawer.open({
title: '命令式 Drawer',
width: 800,
content: <DrawerContent />,
});
},
},
},
});

View File

@ -65,7 +65,7 @@ export class FormModel extends BlockFlowModel {
/>
<FormButtonGroup>
{this.mapSubModels('actions', (action) => (
<FlowModelRenderer model={action} showFlowSettings />
<FlowModelRenderer model={action} showFlowSettings extraContext={{ currentModel: this }} />
))}
<AddActionButton model={this} subModelBaseClass="ActionModel" />
</FormButtonGroup>
@ -107,19 +107,22 @@ FormModel.registerFlow({
},
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);
if (!ctx.model.collection) {
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());
}
console.log('FormModel flow context', ctx.shared, ctx.model.getSharedContext());
if (ctx.shared.currentRecord) {
ctx.model.resource.setFilterByTk(ctx.shared.currentRecord.id);
await ctx.model.resource.refresh();
ctx.model.form.setInitialValues(ctx.model.resource.getData());
}
},
},

View File

@ -25,11 +25,16 @@ SubmitActionModel.registerFlow({
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();
if (ctx.extra.currentModel) {
await ctx.extra.currentModel.form.submit();
const values = ctx.extra.currentModel.form.values;
await ctx.extra.currentModel.resource.save(values);
}
if (ctx.shared.currentDrawer) {
ctx.shared.currentDrawer.destroy();
}
if (ctx.shared.currentResource) {
ctx.shared.currentResource.refresh();
}
},
},

View File

@ -94,11 +94,11 @@ TableColumnModel.define({
sort: 0,
});
const Columns = observer<any>(({ record, model }) => {
const Columns = observer<any>(({ record, model, index }) => {
return (
<Space>
{model.mapSubModels('actions', (action: ActionModel) => {
const fork = action.createFork({}, `${record.id}`);
const fork = action.createFork({}, `${index}`);
return (
<FlowModelRenderer
showFlowSettings
@ -130,6 +130,13 @@ export class TableActionsColumnModel extends TableColumnModel {
model={this}
subModelKey={'actions'}
items={() => [
{
key: 'view',
label: 'View',
createModelOptions: {
use: 'ViewActionModel',
},
},
{
key: 'link',
label: 'Link',
@ -156,7 +163,7 @@ export class TableActionsColumnModel extends TableColumnModel {
}
render() {
return (value, record, index) => <Columns record={record} model={this} />;
return (value, record, index) => <Columns record={record} model={this} index={index} />;
}
}

View File

@ -87,6 +87,13 @@ export class TableModel extends BlockFlowModel<S> {
model={this}
subModelKey={'actions'}
items={() => [
{
key: 'addnew',
label: 'Add new',
createModelOptions: {
use: 'AddNewActionModel',
},
},
{
key: 'delete',
label: 'Delete',
@ -96,9 +103,7 @@ export class TableModel extends BlockFlowModel<S> {
},
]}
>
<Button type="primary" icon={<SettingOutlined />}>
Configure actions
</Button>
<Button icon={<SettingOutlined />}>Configure actions</Button>
</AddActionModel>
</Space>
<Table

View File

@ -27,14 +27,21 @@ ViewActionModel.registerFlow({
steps: {
step1: {
handler(ctx, params) {
ctx.globals.drawer.open({
// eslint-disable-next-line prefer-const
let currentDrawer: any;
function DrawerContent() {
return (
<div>
<FlowPageComponent parentId={ctx.model.uid} sharedContext={{ ...ctx.extra, currentDrawer }} />
</div>
);
}
currentDrawer = ctx.globals.drawer.open({
title: '命令式 Drawer',
width: 800,
content: (
<div>
<FlowPageComponent uid={`${ctx.model.uid}-drawer`} />
</div>
),
content: <DrawerContent />,
});
},
},

View File

@ -8,9 +8,10 @@
*/
export * from './ActionModel';
export * from './BulkDeleteActionModel';
export * from './AddNewActionModel';
export * from './BlockFlowModel';
export * from './BlockGridFlowModel';
export * from './BulkDeleteActionModel';
export * from './CalendarBlockFlowModel';
export * from './DeleteActionModel';
export * from './FormFieldModel';

View File

@ -1,3 +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.
*/
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
@ -27,7 +36,7 @@
*/
import { observer } from '@formily/reactive-react';
import React, { Suspense } from 'react';
import React, { Suspense, useEffect } from 'react';
import { useApplyAutoFlows, useFlowExtraContext } from '../hooks';
import { FlowModel } from '../models';
import { FlowsContextMenu } from './settings/wrappers/contextual/FlowsContextMenu';
@ -51,7 +60,10 @@ interface FlowModelRendererProps {
skipApplyAutoFlows?: boolean; // 默认 false
/** 当 skipApplyAutoFlows !== false 时,传递给 useApplyAutoFlows 的额外上下文 */
extraContext?: Record<string, any>
extraContext?: Record<string, any>;
/** Model 共享运行上下文,会沿着 model 树向下传递 */
sharedContext?: Record<string, any>;
/** 是否为每个组件独立执行 auto flow默认 false */
independentAutoFlowExecution?: boolean; // 默认 false
@ -66,8 +78,18 @@ const FlowModelRendererWithAutoFlows: React.FC<{
flowSettingsVariant: string;
hideRemoveInSettings: boolean;
extraContext?: Record<string, any>;
sharedContext?: Record<string, any>;
independentAutoFlowExecution?: boolean;
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, extraContext, independentAutoFlowExecution }) => {
}> = observer(
({
model,
showFlowSettings,
flowSettingsVariant,
hideRemoveInSettings,
extraContext,
sharedContext,
independentAutoFlowExecution,
}) => {
const defaultExtraContext = useFlowExtraContext();
useApplyAutoFlows(model, extraContext || defaultExtraContext, !independentAutoFlowExecution);
@ -79,7 +101,8 @@ const FlowModelRendererWithAutoFlows: React.FC<{
hideRemoveInSettings={hideRemoveInSettings}
/>
);
});
},
);
/**
* useApplyAutoFlows
@ -89,7 +112,8 @@ const FlowModelRendererWithoutAutoFlows: React.FC<{
showFlowSettings: boolean;
flowSettingsVariant: string;
hideRemoveInSettings: boolean;
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings }) => {
sharedContext?: Record<string, any>;
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, sharedContext }) => {
return (
<FlowModelRendererCore
model={model}
@ -120,10 +144,18 @@ const FlowModelRendererCore: React.FC<{
// 根据 flowSettingsVariant 包装相应的设置组件
switch (flowSettingsVariant) {
case 'dropdown':
return <FlowsFloatContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>{modelContent}</FlowsFloatContextMenu>;
return (
<FlowsFloatContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>
{modelContent}
</FlowsFloatContextMenu>
);
case 'contextMenu':
return <FlowsContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>{modelContent}</FlowsContextMenu>;
return (
<FlowsContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>
{modelContent}
</FlowsContextMenu>
);
case 'modal':
// TODO: 实现 modal 模式的流程设置
@ -136,10 +168,12 @@ const FlowModelRendererCore: React.FC<{
return modelContent;
default:
console.warn(
`FlowModelRenderer: Unknown flowSettingsVariant '${flowSettingsVariant}', falling back to dropdown`,
console.warn(`FlowModelRenderer: Unknown flowSettingsVariant '${flowSettingsVariant}', falling back to dropdown`);
return (
<FlowsFloatContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>
{modelContent}
</FlowsFloatContextMenu>
);
return <FlowsFloatContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>{modelContent}</FlowsFloatContextMenu>;
}
});
@ -155,6 +189,7 @@ const FlowModelRendererCore: React.FC<{
* @param {boolean} props.hideRemoveInSettings - Whether to hide remove button in settings.
* @param {boolean} props.skipApplyAutoFlows - Whether to skip applying auto flows.
* @param {any} props.extraContext - Extra context to pass to useApplyAutoFlows when skipApplyAutoFlows is false.
* @param {any} props.sharedContext - Shared context to pass to the model.
* @param {boolean} props.independentAutoFlowExecution - Whether each component has independent auto flow execution.
* @returns {React.ReactNode | null} The rendered output of the model, or null if the model or its render method is invalid.
*/
@ -166,6 +201,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
hideRemoveInSettings = false,
skipApplyAutoFlows = false,
extraContext,
sharedContext,
independentAutoFlowExecution = false,
}) => {
if (!model || typeof model.render !== 'function') {
@ -174,6 +210,10 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
return null;
}
useEffect(() => {
model.setSharedContext(sharedContext);
}, [model, sharedContext]);
// 根据 skipApplyAutoFlows 选择不同的内部组件
if (skipApplyAutoFlows) {
return (
@ -183,6 +223,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
showFlowSettings={showFlowSettings}
flowSettingsVariant={flowSettingsVariant}
hideRemoveInSettings={hideRemoveInSettings}
sharedContext={sharedContext}
/>
</Suspense>
);
@ -195,6 +236,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
flowSettingsVariant={flowSettingsVariant}
hideRemoveInSettings={hideRemoveInSettings}
extraContext={extraContext}
sharedContext={sharedContext}
independentAutoFlowExecution={independentAutoFlowExecution}
/>
</Suspense>

View File

@ -13,6 +13,26 @@ import React from 'react';
import { ActionStepDefinition } from '../../../../types';
import { resolveDefaultParams } from '../../../../utils';
/**
*
* @param uiSchema UI Schema
* @param currentParams
* @returns
*/
function hasRequiredParams(uiSchema: Record<string, any>, currentParams: Record<string, any>): boolean {
// 检查 uiSchema 中所有 required 为 true 的字段
for (const [fieldKey, fieldSchema] of Object.entries(uiSchema)) {
if (fieldSchema.required === true) {
// 如果字段是必需的,但当前参数中没有值或值为空
const value = currentParams[fieldKey];
if (value === undefined || value === null || value === '') {
return false;
}
}
}
return true;
}
const SchemaField = createSchemaField();
/**
@ -87,8 +107,16 @@ const openRequiredParamsStepFormDialog = async ({
}
});
// 如果有可配置的UI Schema添加到列表中
// 如果有可配置的UI Schema检查是否已经有了所需的配置值
if (Object.keys(mergedUiSchema).length > 0) {
// 获取当前步骤的参数
const currentStepParams = model.getStepParams(flowKey, stepKey) || {};
// 检查是否已经有了所需的配置值
const hasAllRequiredParams = hasRequiredParams(mergedUiSchema, currentStepParams);
// 只有当缺少必需参数时才添加到列表中
if (!hasAllRequiredParams) {
requiredSteps.push({
flowKey,
stepKey,
@ -101,6 +129,7 @@ const openRequiredParamsStepFormDialog = async ({
}
}
}
}
// 如果没有需要配置的步骤,显示提示
if (requiredSteps.length === 0) {
@ -121,10 +150,16 @@ const openRequiredParamsStepFormDialog = async ({
for (const { flowKey, stepKey, step } of requiredSteps) {
const stepParams = model.getStepParams(flowKey, stepKey) || {};
// 如果step使用了action也获取action的defaultParams
let actionDefaultParams = {};
if (step.use) {
const action = model.flowEngine?.getAction?.(step.use);
actionDefaultParams = action.defaultParams || {};
}
// 解析 defaultParams
const resolvedActionDefaultParams = await resolveDefaultParams(actionDefaultParams, paramsContext);
const resolvedDefaultParams = await resolveDefaultParams(step.defaultParams, paramsContext);
const mergedParams = { ...resolvedDefaultParams, ...stepParams };
const mergedParams = { ...resolvedActionDefaultParams, ...resolvedDefaultParams, ...stepParams };
if (Object.keys(mergedParams).length > 0) {
if (!initialValues[flowKey]) {
@ -137,7 +172,7 @@ const openRequiredParamsStepFormDialog = async ({
// 构建分步表单的 Schema
const stepPanes: Record<string, any> = {};
requiredSteps.forEach(({ flowKey, stepKey, uiSchema, title, flowTitle }, index) => {
requiredSteps.forEach(({ flowKey, stepKey, uiSchema, title, flowTitle }) => {
const stepId = `${flowKey}_${stepKey}`;
stepPanes[stepId] = {
@ -261,7 +296,7 @@ const openRequiredParamsStepFormDialog = async ({
formStep.next();
}
})
.catch((errors) => {
.catch((errors: any) => {
console.log('表单验证失败:', errors);
// 可以在这里添加更详细的错误处理
});
@ -310,7 +345,7 @@ const openRequiredParamsStepFormDialog = async ({
formStep.next();
}
})
.catch((errors) => {
.catch((errors: any) => {
console.log('表单验证失败:', errors);
// 可以在这里添加更详细的错误处理
});

View File

@ -55,6 +55,7 @@ const openStepSettingsDialog = async ({
// 获取可配置的步骤信息
const stepDefinition = step as ActionStepDefinition;
const stepUiSchema = stepDefinition.uiSchema || {};
let actionDefaultParams = {};
// 如果step使用了action也获取action的uiSchema
let actionUiSchema = {};
@ -63,6 +64,7 @@ const openStepSettingsDialog = async ({
if (action && action.uiSchema) {
actionUiSchema = action.uiSchema;
}
actionDefaultParams = action.defaultParams || {};
}
// 合并uiSchema确保step的uiSchema优先级更高
@ -93,7 +95,8 @@ const openStepSettingsDialog = async ({
// 解析 defaultParams
const resolvedDefaultParams = await resolveDefaultParams(stepDefinition.defaultParams, paramsContext);
const initialValues = { ...resolvedDefaultParams, ...stepParams };
const resolveActionDefaultParams = await resolveDefaultParams(actionDefaultParams, paramsContext);
const initialValues = { ...resolveActionDefaultParams, ...resolvedDefaultParams, ...stepParams };
// 构建表单Schema
const formSchema: ISchema = {

View File

@ -8,10 +8,11 @@
*/
import React, { useMemo } from 'react';
import { AddSubModelButton } from './AddSubModelButton';
import { AddSubModelButton, SubModelItemsType, mergeSubModelItems } from './AddSubModelButton';
import { FlowModel } from '../../models/flowModel';
import { ModelConstructor } from '../../types';
import { Button } from 'antd';
import { createBlockItems } from './blockItems';
interface AddBlockButtonProps {
/**
@ -32,16 +33,43 @@ interface AddBlockButtonProps {
*
*/
children?: React.ReactNode;
/**
* items
*/
items?: SubModelItemsType;
/**
*
*/
filterBlocks?: (blockClass: ModelConstructor, className: string) => boolean;
/**
*
*/
appendItems?: SubModelItemsType;
}
/**
*
*
* page:addBlock -> ->
*
* @example
* ```tsx
* // 基本用法
* <AddBlockButton
* model={parentModel}
* subModelBaseClass={'FlowModel'}
* subModelBaseClass={'BlockFlowModel'}
* />
*
* // 追加自定义菜单项
* <AddBlockButton
* model={parentModel}
* appendItems={[
* {
* key: 'customBlock',
* label: 'Custom Block',
* createModelOptions: { use: 'CustomBlock' }
* }
* ]}
* />
* ```
*/
@ -51,31 +79,33 @@ export const AddBlockButton: React.FC<AddBlockButtonProps> = ({
subModelKey = 'blocks',
children = <Button>Add block</Button>,
subModelType = 'array',
items,
filterBlocks,
appendItems,
onModelAdded,
}) => {
const items = useMemo(() => {
const blockClasses = model.flowEngine.filterModelClassByParent(subModelBaseClass);
const registeredBlocks = [];
for (const [className, ModelClass] of blockClasses) {
registeredBlocks.push({
key: className,
label: ModelClass.meta?.title || className,
icon: ModelClass.meta?.icon,
createModelOptions: {
...ModelClass.meta?.defaultOptions,
use: className,
},
});
// 确定最终使用的 items
const finalItems = useMemo(() => {
if (items) {
// 如果明确提供了 items直接使用
return items;
}
return registeredBlocks;
}, [model, subModelBaseClass]);
// 创建区块菜单项,并合并追加的 items
const blockItems = createBlockItems(model, {
subModelBaseClass,
filterBlocks,
});
return mergeSubModelItems([blockItems, appendItems]);
}, [items, model, subModelBaseClass, filterBlocks, appendItems]);
return (
<AddSubModelButton
model={model}
subModelKey={subModelKey}
subModelType={subModelType}
items={items}
items={finalItems}
onModelAdded={onModelAdded}
>
{children}

View File

@ -0,0 +1,189 @@
/**
* 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 '../../models/flowModel';
import { ModelConstructor } from '../../types';
import { SubModelItem, SubModelItemsType } from './AddSubModelButton';
import { DataSource, DataSourceManager, Collection } from '../../data-source';
export interface BlockItemsOptions {
/**
*
*/
subModelBaseClass?: string | ModelConstructor;
/**
*
*/
filterBlocks?: (blockClass: ModelConstructor, className: string) => boolean;
/**
*
*/
customBlocks?: SubModelItem[];
}
/**
*
* flowEngine
*/
async function getDataSourcesWithCollections(model: FlowModel) {
try {
// 从 flowEngine 的全局上下文获取数据源管理器
const globalContext = model.flowEngine.getContext();
const dataSourceManager: DataSourceManager = globalContext?.dataSourceManager;
if (!dataSourceManager) {
// 如果没有数据源管理器,返回空数组
return [];
}
// 获取所有数据源
const allDataSources: DataSource[] = dataSourceManager.getDataSources();
// 转换为我们需要的格式
return allDataSources.map((dataSource: DataSource) => {
const key = dataSource.name;
const displayName = dataSource.options.displayName || dataSource.name;
// 从 collectionManager 获取 collections
const collections: Collection[] = dataSource.getCollections();
return {
key,
displayName,
collections: collections.map((collection: Collection) => ({
name: collection.name,
title: collection.title,
dataSource: key,
})),
};
});
} catch (error) {
console.warn('Failed to get data sources:', error);
// 返回空数组,不提供假数据
return [];
}
}
/**
*
*
*
* -
* -
*
* @param model FlowModel
* @param options
* @returns SubModelItemsType
*/
export function createBlockItems(model: FlowModel, options: BlockItemsOptions = {}): SubModelItemsType {
const { subModelBaseClass = 'BlockFlowModel', filterBlocks, customBlocks = [] } = options;
// 获取所有注册的区块类
const blockClasses = model.flowEngine.filterModelClassByParent(subModelBaseClass);
// 分类区块:数据区块 vs 其他区块
const dataBlocks: Array<{ className: string; ModelClass: ModelConstructor }> = [];
const otherBlocks: Array<{ className: string; ModelClass: ModelConstructor }> = [];
for (const [className, ModelClass] of blockClasses) {
// 应用过滤器
if (filterBlocks && !filterBlocks(ModelClass, className)) {
continue;
}
// 判断是否为数据区块
const meta = (ModelClass as any).meta;
const isDataBlock =
meta?.category === 'data' ||
meta?.requiresDataSource === true ||
className.toLowerCase().includes('table') ||
className.toLowerCase().includes('form') ||
className.toLowerCase().includes('details') ||
className.toLowerCase().includes('list') ||
className.toLowerCase().includes('grid');
if (isDataBlock) {
dataBlocks.push({ className, ModelClass });
} else {
otherBlocks.push({ className, ModelClass });
}
}
const result: SubModelItem[] = [];
// 数据区块分组
if (dataBlocks.length > 0) {
result.push({
key: 'dataBlocks',
label: 'Data blocks',
type: 'group',
children: async () => {
const dataSources = await getDataSourcesWithCollections(model);
// 按区块类型组织菜单:区块 → 数据源 → 数据表
return dataBlocks.map(({ className, ModelClass }) => {
const meta = (ModelClass as any).meta;
return {
key: className,
label: meta?.title || className,
icon: meta?.icon,
children: dataSources.map((dataSource) => ({
key: `${className}.${dataSource.key}`,
label: dataSource.displayName,
children: dataSource.collections.map((collection) => ({
key: `${className}.${dataSource.key}.${collection.name}`,
label: collection.title || collection.name,
createModelOptions: {
...meta?.defaultOptions,
use: className,
stepParams: {
default: {
step1: {
dataSourceKey: dataSource.key,
collectionName: collection.name,
},
},
},
},
})),
})),
};
});
},
});
}
// 其他区块分组
if (otherBlocks.length > 0 || customBlocks.length > 0) {
const otherBlockItems = [
...otherBlocks.map(({ className, ModelClass }) => {
const meta = (ModelClass as any).meta;
return {
key: className,
label: meta?.title || className,
icon: meta?.icon,
createModelOptions: {
...meta?.defaultOptions,
use: className,
},
};
}),
...customBlocks,
];
result.push({
key: 'otherBlocks',
label: 'Other blocks',
type: 'group',
children: otherBlockItems,
});
}
return result;
}

View File

@ -12,4 +12,5 @@ export * from './AddBlockButton';
export * from './AddFieldButton';
export * from './AddSubModel';
export * from './AddSubModelButton';
export * from './blockItems';
//

View File

@ -227,13 +227,13 @@ export class FlowEngine {
async loadModel<T extends FlowModel = FlowModel>(uid: string): Promise<T | null> {
if (!this.ensureModelRepository()) return;
const data = await this.modelRepository.load(uid);
const data = await this.modelRepository.findOne({ uid });
return data?.uid ? this.createModel<T>(data as any) : null;
}
async loadOrCreateModel<T extends FlowModel = FlowModel>(options): Promise<T | null> {
if (!this.ensureModelRepository()) return;
const data = await this.modelRepository.load(options.uid);
const data = await this.modelRepository.findOne(options);
if (data?.uid) {
return this.createModel<T>(data as any);
} else {

View File

@ -14,7 +14,6 @@ import { uid } from 'uid/secure';
import { openRequiredParamsStepFormDialog as openRequiredParamsStepFormDialogFn } from '../components/settings/wrappers/contextual/StepRequiredSettingsDialog';
import { openStepSettingsDialog as openStepSettingsDialogFn } from '../components/settings/wrappers/contextual/StepSettingsDialog';
import { FlowEngine } from '../flowEngine';
import { resolveDefaultParams } from '../utils';
import type {
ActionStepDefinition,
ArrayElementType,
@ -30,7 +29,7 @@ import type {
StepParams,
} from '../types';
import { ExtendedFlowDefinition, FlowExtraContext, IModelComponentProps, ReadonlyModelProps } from '../types';
import { generateUid, mergeFlowDefinitions } from '../utils';
import { FlowExitException, generateUid, mergeFlowDefinitions, resolveDefaultParams } from '../utils';
import { ForkFlowModel } from './forkFlowModel';
// 使用WeakMap存储每个类的meta
@ -47,6 +46,8 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
public flowEngine: FlowEngine;
public parent: Structure['parent'];
public subModels: Structure['subModels'];
private _options: FlowModelOptions<Structure>;
/**
* fork
* 使 Set 便 dispose
@ -57,20 +58,26 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
* key fork fork
*/
private forkCache: Map<string, ForkFlowModel<any>> = new Map();
// public static meta: FlowModelMeta;
// model 树的共享运行上下文
private _sharedContext: Record<string, any> = {};
constructor(protected options: FlowModelOptions<Structure>) {
constructor(options: FlowModelOptions<Structure>) {
if (options?.flowEngine?.getModel(options.uid)) {
// 此时 new FlowModel 并不创建新实例而是返回已存在的实例避免重复创建同一个model实例
return options.flowEngine.getModel(options.uid);
}
this.uid = options.uid || uid();
if (!options.uid) {
options.uid = uid();
}
this.uid = options.uid;
this.props = options.props || {};
this.stepParams = options.stepParams || {};
this.subModels = {};
this.flowEngine = options.flowEngine;
this.sortIndex = options.sortIndex || 0;
this._options = options;
define(this, {
props: observable,
@ -343,9 +350,7 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
}
let lastResult: any;
let exited = false;
const stepResults: Record<string, any> = {};
const shared: Record<string, any> = {};
// Create a new FlowContext instance for this flow execution
const createLogger = (level: string) => (message: string, meta?: any) => {
@ -357,8 +362,7 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
const globalContexts = currentFlowEngine.getContext() || {};
const flowContext: FlowContext<this> = {
exit: () => {
exited = true;
console.log(`Flow '${flowKey}' on model '${this.uid}' exited via ctx.exit().`);
throw new FlowExitException(flowKey, this.uid);
},
logger: {
info: createLogger('INFO'),
@ -367,7 +371,7 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
debug: createLogger('DEBUG'),
},
stepResults,
shared,
shared: this.getSharedContext(),
globals: globalContexts,
extra: extra || {},
model: this,
@ -377,8 +381,6 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
for (const stepKey in flow.steps) {
if (Object.prototype.hasOwnProperty.call(flow.steps, stepKey)) {
const step: StepDefinition = flow.steps[stepKey];
if (exited) break;
let handler: ((ctx: FlowContext<this>, params: any) => Promise<any> | any) | undefined;
let combinedParams: Record<string, any> = {};
let actionDefinition;
@ -424,12 +426,18 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
// Store step result
stepResults[stepKey] = lastResult;
} catch (error) {
// 检查是否是通过 ctx.exit() 正常退出
if (error instanceof FlowExitException) {
console.log(`[FlowEngine] ${error.message}`);
return Promise.resolve(stepResults);
}
console.error(`BaseModel.applyFlow: Error executing step '${stepKey}' in flow '${flowKey}':`, error);
return Promise.reject(error);
}
}
}
return Promise.resolve(lastResult);
return Promise.resolve(stepResults);
}
dispatchEvent(eventName: string, extra?: FlowExtraContext): void {
@ -542,7 +550,7 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
throw new Error('Parent must be an instance of FlowModel.');
}
this.parent = parent;
this.options.parentId = parent.uid;
this._options.parentId = parent.uid;
}
addSubModel(subKey: string, options: CreateModelOptions | FlowModel) {
@ -718,11 +726,22 @@ export class FlowModel<Structure extends { parent?: any; subModels?: any } = Def
});
}
public setSharedContext(ctx: Record<string, any>) {
this._sharedContext = ctx;
}
public getSharedContext() {
return {
...this.parent?.getSharedContext(),
...this._sharedContext, // 当前实例的 context 优先级最高
};
}
// TODO: 不完整,需要考虑 sub-model 的情况
serialize(): Record<string, any> {
const data = {
uid: this.uid,
..._.omit(this.options, ['flowEngine']),
..._.omit(this._options, ['flowEngine']),
props: this.props,
stepParams: this.stepParams,
sortIndex: this.sortIndex,

View File

@ -248,7 +248,7 @@ export interface CreateModelOptions {
[key: string]: any; // 允许额外的自定义选项
}
export interface IFlowModelRepository<T extends FlowModel = FlowModel> {
load(uid: string): Promise<Record<string, any> | null>;
findOne(query: Record<string, any>): Promise<Record<string, any> | null>;
save(model: T): Promise<Record<string, any>>;
destroy(uid: string): Promise<boolean>;
}

View File

@ -102,3 +102,19 @@ export async function resolveDefaultParams<TModel extends FlowModel = FlowModel>
return defaultParams;
}
/**
* 退
* ctx.exit() 退
*/
export class FlowExitException extends Error {
public readonly flowKey: string;
public readonly modelUid: string;
constructor(flowKey: string, modelUid: string, message?: string) {
super(message || `Flow '${flowKey}' on model '${modelUid}' exited via ctx.exit().`);
this.name = 'FlowExitException';
this.flowKey = flowKey;
this.modelUid = modelUid;
}
}