feat(flows): 新增流程编辑器基础功能与相关组件

feat(backend): 添加流程模型与服务支持
feat(frontend): 实现流程编辑器UI与交互
feat(assets): 添加流程节点图标资源
feat(plugins): 实现上下文菜单和运行时插件
feat(components): 新增基础节点和侧边栏组件
feat(routes): 添加流程相关路由配置
feat(models): 创建流程和运行日志数据模型
feat(services): 实现流程服务层逻辑
feat(migration): 添加流程相关数据库迁移
feat(config): 更新前端配置支持流程编辑器
feat(utils): 增强axios错误处理和工具函数
This commit is contained in:
2025-09-15 00:27:13 +08:00
parent 9da3978f91
commit b0963e5e37
291 changed files with 17947 additions and 86 deletions

View File

@ -0,0 +1,84 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
import {
Layer,
injectable,
inject,
FreeLayoutPluginContext,
WorkflowHoverService,
WorkflowNodeEntity,
WorkflowNodeJSON,
WorkflowSelectService,
WorkflowDocument,
PositionSchema,
WorkflowDragService,
} from '@flowgram.ai/free-layout-editor';
import { ContainerUtils } from '@flowgram.ai/free-container-plugin';
@injectable()
export class ContextMenuLayer extends Layer {
@inject(FreeLayoutPluginContext) ctx: FreeLayoutPluginContext;
@inject(WorkflowNodePanelService) nodePanelService: WorkflowNodePanelService;
@inject(WorkflowHoverService) hoverService: WorkflowHoverService;
@inject(WorkflowSelectService) selectService: WorkflowSelectService;
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowDragService) dragService: WorkflowDragService;
onReady() {
this.listenPlaygroundEvent('contextmenu', (e) => {
if (this.config.readonlyOrDisabled) return;
this.openNodePanel(e);
e.preventDefault();
e.stopPropagation();
});
}
openNodePanel(e: MouseEvent) {
const mousePos = this.getPosFromMouseEvent(e);
const containerNode = this.getContainerNode(mousePos);
this.nodePanelService.callNodePanel({
position: mousePos,
containerNode,
panelProps: {},
// handle node selection from panel - 处理从面板中选择节点
onSelect: async (panelParams?: NodePanelResult) => {
if (!panelParams) {
return;
}
const { nodeType, nodeJSON } = panelParams;
const position = this.dragService.adjustSubNodePosition(nodeType, containerNode, mousePos);
// create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
const node: WorkflowNodeEntity = this.ctx.document.createWorkflowNodeByType(
nodeType,
position,
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
// select the newly created node - 选择新创建的节点
this.selectService.select(node);
},
// handle panel close - 处理面板关闭
onClose: () => {},
});
}
private getContainerNode(mousePos: PositionSchema): WorkflowNodeEntity | undefined {
const allNodes = this.document.getAllNodes();
const containerTransforms = ContainerUtils.getContainerTransforms(allNodes);
const collisionTransform = ContainerUtils.getCollisionTransform({
targetPoint: mousePos,
transforms: containerTransforms,
document: this.document,
});
return collisionTransform?.entity;
}
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
definePluginCreator,
PluginCreator,
FreeLayoutPluginContext,
} from '@flowgram.ai/free-layout-editor';
import { ContextMenuLayer } from './context-menu-layer';
export interface ContextMenuPluginOptions {}
/**
* Creates a plugin of contextmenu
* @param ctx - The plugin context, containing the document and other relevant information.
* @param options - Plugin options, currently an empty object.
*/
export const createContextMenuPlugin: PluginCreator<ContextMenuPluginOptions> = definePluginCreator<
ContextMenuPluginOptions,
FreeLayoutPluginContext
>({
onInit(ctx, options) {
ctx.playground.registerLayer(ContextMenuLayer);
},
});

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createContextMenuPlugin } from './context-menu-plugin';

View File

@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createContextMenuPlugin } from './context-menu-plugin';
export { createRuntimePlugin } from './runtime-plugin';
export { createVariablePanelPlugin } from './variable-panel-plugin';

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowGramAPIName, IRuntimeClient } from '@flowgram.ai/runtime-interface';
import { injectable } from '@flowgram.ai/free-layout-editor';
@injectable()
export class WorkflowRuntimeClient implements IRuntimeClient {
constructor() {}
public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun];
public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport];
public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult];
public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel];
public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate];
}

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable no-console */
import { FlowGramAPIName, IRuntimeClient } from '@flowgram.ai/runtime-interface';
import { injectable } from '@flowgram.ai/free-layout-editor';
@injectable()
export class WorkflowRuntimeBrowserClient implements IRuntimeClient {
constructor() {}
public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun] = async (input) => {
const { TaskRunAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskRunAPI(input);
};
public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport] = async (
input
) => {
const { TaskReportAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskReportAPI(input);
};
public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult] = async (
input
) => {
const { TaskResultAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskResultAPI(input);
};
public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel] = async (
input
) => {
const { TaskCancelAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskCancelAPI(input);
};
public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate] = async (
input
) => {
const { TaskValidateAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskValidateAPI(input);
};
}

View File

@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { WorkflowRuntimeClient } from './base-client';
export { WorkflowRuntimeBrowserClient } from './browser-client';
export { WorkflowRuntimeServerClient } from './server-client';

View File

@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ServerConfig } from '../../type';
export const DEFAULT_SERVER_CONFIG: ServerConfig = {
domain: 'localhost',
port: 4000,
protocol: 'http',
};

View File

@ -0,0 +1,151 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
FlowGramAPIName,
IRuntimeClient,
TaskCancelDefine,
TaskCancelInput,
TaskCancelOutput,
TaskReportDefine,
TaskReportInput,
TaskReportOutput,
TaskResultDefine,
TaskResultInput,
TaskResultOutput,
TaskRunDefine,
TaskRunInput,
TaskRunOutput,
TaskValidateDefine,
TaskValidateInput,
TaskValidateOutput,
} from '@flowgram.ai/runtime-interface';
import { injectable } from '@flowgram.ai/free-layout-editor';
import { ServerConfig } from '../../type';
import type { ServerError } from './type';
import { DEFAULT_SERVER_CONFIG } from './constant';
@injectable()
export class WorkflowRuntimeServerClient implements IRuntimeClient {
private config: ServerConfig = DEFAULT_SERVER_CONFIG;
constructor() {}
public init(config: ServerConfig) {
this.config = config;
}
public async [FlowGramAPIName.TaskRun](input: TaskRunInput): Promise<TaskRunOutput | undefined> {
return this.request<TaskRunOutput>(TaskRunDefine.path, TaskRunDefine.method, {
body: input,
errorMessage: 'TaskRun failed',
});
}
public async [FlowGramAPIName.TaskReport](
input: TaskReportInput
): Promise<TaskReportOutput | undefined> {
return this.request<TaskReportOutput>(TaskReportDefine.path, TaskReportDefine.method, {
queryParams: { taskID: input.taskID },
errorMessage: 'TaskReport failed',
});
}
public async [FlowGramAPIName.TaskResult](
input: TaskResultInput
): Promise<TaskResultOutput | undefined> {
return this.request<TaskResultOutput>(TaskResultDefine.path, TaskResultDefine.method, {
queryParams: { taskID: input.taskID },
errorMessage: 'TaskResult failed',
fallbackValue: { success: false },
});
}
public async [FlowGramAPIName.TaskCancel](input: TaskCancelInput): Promise<TaskCancelOutput> {
const result = await this.request<TaskCancelOutput>(
TaskCancelDefine.path,
TaskCancelDefine.method,
{
body: input,
errorMessage: 'TaskCancel failed',
fallbackValue: { success: false },
}
);
return result ?? { success: false };
}
public async [FlowGramAPIName.TaskValidate](
input: TaskValidateInput
): Promise<TaskValidateOutput | undefined> {
return this.request<TaskValidateOutput>(TaskValidateDefine.path, TaskValidateDefine.method, {
body: input,
errorMessage: 'TaskValidate failed',
});
}
// Generic request method to reduce code duplication
private async request<T>(
path: string,
method: string,
options: {
body?: unknown;
queryParams?: Record<string, string>;
errorMessage: string;
fallbackValue?: T;
}
): Promise<T | undefined> {
try {
const url = this.url(path, options.queryParams);
const requestOptions: RequestInit = {
method,
redirect: 'follow',
};
if (options.body) {
requestOptions.headers = {
'Content-Type': 'application/json',
};
requestOptions.body = JSON.stringify(options.body);
}
const response = await fetch(url, requestOptions);
const output: T | ServerError = await response.json();
if (this.isError(output)) {
console.error(options.errorMessage, output);
return options.fallbackValue;
}
return output;
} catch (error) {
console.error(error);
return options.fallbackValue;
}
}
// Build URL with query parameters
private url(path: string, queryParams?: Record<string, string>): string {
const baseURL = this.getURL(`/api${path}`);
if (!queryParams) {
return baseURL;
}
const searchParams = new URLSearchParams(queryParams);
return `${baseURL}?${searchParams.toString()}`;
}
private isError(output: unknown | undefined): output is ServerError {
return !!output && (output as ServerError).code !== undefined;
}
private getURL(path: string): string {
const protocol = this.config.protocol ?? window.location.protocol;
const host = this.config.port
? `${this.config.domain}:${this.config.port}`
: this.config.domain;
return `${protocol}://${host}${path}`;
}
}

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export interface ServerError {
code: string;
message: string;
}

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { definePluginCreator, PluginContext } from '@flowgram.ai/free-layout-editor';
import { RuntimePluginOptions } from './type';
import { WorkflowRuntimeService } from './runtime-service';
import {
WorkflowRuntimeBrowserClient,
WorkflowRuntimeClient,
WorkflowRuntimeServerClient,
} from './client';
export const createRuntimePlugin = definePluginCreator<RuntimePluginOptions, PluginContext>({
onBind({ bind, rebind }, options) {
bind(WorkflowRuntimeClient).toSelf().inSingletonScope();
bind(WorkflowRuntimeServerClient).toSelf().inSingletonScope();
bind(WorkflowRuntimeBrowserClient).toSelf().inSingletonScope();
if (options.mode === 'server') {
rebind(WorkflowRuntimeClient).to(WorkflowRuntimeServerClient);
} else {
rebind(WorkflowRuntimeClient).to(WorkflowRuntimeBrowserClient);
}
bind(WorkflowRuntimeService).toSelf().inSingletonScope();
},
onInit(ctx, options) {
if (options.mode === 'server') {
const serverClient = ctx.get<WorkflowRuntimeServerClient>(WorkflowRuntimeClient);
serverClient.init(options.serverConfig);
}
},
});

View File

@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createRuntimePlugin } from './create-runtime-plugin';
export { WorkflowRuntimeClient } from './client';

View File

@ -0,0 +1,218 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
IReport,
NodeReport,
WorkflowInputs,
WorkflowOutputs,
WorkflowStatus,
} from '@flowgram.ai/runtime-interface';
import {
injectable,
inject,
WorkflowDocument,
Playground,
WorkflowLineEntity,
WorkflowNodeEntity,
WorkflowNodeLinesData,
Emitter,
getNodeForm,
} from '@flowgram.ai/free-layout-editor';
import { WorkflowRuntimeClient } from '../client';
import { WorkflowNodeType } from '../../../nodes';
const SYNC_TASK_REPORT_INTERVAL = 500;
interface NodeRunningStatus {
nodeID: string;
status: WorkflowStatus;
nodeResultLength: number;
}
@injectable()
export class WorkflowRuntimeService {
@inject(Playground) playground: Playground;
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowRuntimeClient) runtimeClient: WorkflowRuntimeClient;
private runningNodes: WorkflowNodeEntity[] = [];
private taskID?: string;
private syncTaskReportIntervalID?: ReturnType<typeof setInterval>;
private reportEmitter = new Emitter<NodeReport>();
private resetEmitter = new Emitter<{}>();
private resultEmitter = new Emitter<{
errors?: string[];
result?: {
inputs: WorkflowInputs;
outputs: WorkflowOutputs;
};
}>();
private nodeRunningStatus: Map<string, NodeRunningStatus>;
public onNodeReportChange = this.reportEmitter.event;
public onReset = this.resetEmitter.event;
public onResultChanged = this.resultEmitter.event;
public isFlowingLine(line: WorkflowLineEntity) {
return this.runningNodes.some((node) =>
node.getData(WorkflowNodeLinesData).inputLines.includes(line)
);
}
public async taskRun(inputs: WorkflowInputs): Promise<string | undefined> {
if (this.taskID) {
await this.taskCancel();
}
const isFormValid = await this.validateForm();
if (!isFormValid) {
this.resultEmitter.fire({
errors: ['Form validation failed'],
});
return;
}
const schema = this.document.toJSON();
const validateResult = await this.runtimeClient.TaskValidate({
schema: JSON.stringify(schema),
inputs,
});
if (!validateResult?.valid) {
this.resultEmitter.fire({
errors: validateResult?.errors ?? ['Internal Server Error'],
});
return;
}
this.reset();
let taskID: string | undefined;
try {
const output = await this.runtimeClient.TaskRun({
schema: JSON.stringify(schema),
inputs,
});
taskID = output?.taskID;
} catch (e) {
this.resultEmitter.fire({
errors: [(e as Error)?.message],
});
return;
}
if (!taskID) {
this.resultEmitter.fire({
errors: ['Task run failed'],
});
return;
}
this.taskID = taskID;
this.syncTaskReportIntervalID = setInterval(() => {
this.syncTaskReport();
}, SYNC_TASK_REPORT_INTERVAL);
return this.taskID;
}
public async taskCancel(): Promise<void> {
if (!this.taskID) {
return;
}
await this.runtimeClient.TaskCancel({
taskID: this.taskID,
});
}
private async validateForm(): Promise<boolean> {
const allForms = this.document.getAllNodes().map((node) => getNodeForm(node));
const formValidations = await Promise.all(allForms.map(async (form) => form?.validate()));
const validations = formValidations.filter((validation) => validation !== undefined);
const isValid = validations.every((validation) => validation);
return isValid;
}
private reset(): void {
this.taskID = undefined;
this.nodeRunningStatus = new Map();
this.runningNodes = [];
if (this.syncTaskReportIntervalID) {
clearInterval(this.syncTaskReportIntervalID);
}
this.resetEmitter.fire({});
}
private async syncTaskReport(): Promise<void> {
if (!this.taskID) {
return;
}
const report = await this.runtimeClient.TaskReport({
taskID: this.taskID,
});
if (!report) {
clearInterval(this.syncTaskReportIntervalID);
console.error('Sync task report failed');
return;
}
const { workflowStatus, inputs, outputs, messages } = report;
if (workflowStatus.terminated) {
clearInterval(this.syncTaskReportIntervalID);
if (Object.keys(outputs).length > 0) {
this.resultEmitter.fire({ result: { inputs, outputs } });
} else {
this.resultEmitter.fire({
errors: messages?.error?.map((message) =>
message.nodeID ? `${message.nodeID}: ${message.message}` : message.message
),
});
}
}
this.updateReport(report);
}
private updateReport(report: IReport): void {
const { reports } = report;
this.runningNodes = [];
this.document
.getAllNodes()
.filter(
(node) =>
![WorkflowNodeType.BlockStart, WorkflowNodeType.BlockEnd].includes(
node.flowNodeType as WorkflowNodeType
)
)
.forEach((node) => {
const nodeID = node.id;
const nodeReport = reports[nodeID];
if (!nodeReport) {
return;
}
if (nodeReport.status === WorkflowStatus.Processing) {
this.runningNodes.push(node);
}
const runningStatus = this.nodeRunningStatus.get(nodeID);
if (
!runningStatus ||
nodeReport.status !== runningStatus.status ||
nodeReport.snapshots.length !== runningStatus.nodeResultLength
) {
this.nodeRunningStatus.set(nodeID, {
nodeID,
status: nodeReport.status,
nodeResultLength: nodeReport.snapshots.length,
});
this.reportEmitter.fire(nodeReport);
this.document.linesManager.forceUpdate();
} else if (nodeReport.status === WorkflowStatus.Processing) {
this.reportEmitter.fire(nodeReport);
}
});
}
}

View File

@ -0,0 +1,21 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export interface RuntimeBrowserOptions {
mode?: 'browser';
}
export interface RuntimeServerOptions {
mode: 'server';
serverConfig: ServerConfig;
}
export type RuntimePluginOptions = RuntimeBrowserOptions | RuntimeServerOptions;
export interface ServerConfig {
domain: string;
port?: number;
protocol?: string;
}

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useVariableTree } from '@flowgram.ai/form-materials';
import { Tree } from '@douyinfe/semi-ui';
export function FullVariableList() {
const treeData = useVariableTree({});
return <Tree treeData={treeData} />;
}

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import {
BaseVariableField,
GlobalScope,
useRefresh,
useService,
} from '@flowgram.ai/free-layout-editor';
import { JsonSchemaEditor, JsonSchemaUtils } from '@flowgram.ai/form-materials';
export function GlobalVariableEditor() {
const globalScope = useService(GlobalScope);
const refresh = useRefresh();
const globalVar = globalScope.getVar() as BaseVariableField;
useEffect(() => {
const disposable = globalScope.output.onVariableListChange(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, []);
if (!globalVar) {
return;
}
const value = globalVar.type ? JsonSchemaUtils.astToSchema(globalVar.type) : { type: 'object' };
return (
<JsonSchemaEditor
value={value}
onChange={(_schema) => globalVar.updateType(JsonSchemaUtils.schemaToAST(_schema))}
/>
);
}

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.panel-wrapper {
position: relative;
}
.variable-panel-button {
position: absolute;
top: 0;
right: 0;
border-radius: 50%;
width: 50px;
height: 50px;
z-index: 1;
&.close {
width: 30px;
height: 30px;
top: 10px;
right: 10px;
}
}
.panel-container {
width: 500px;
border-radius: 5px;
background-color: #fff;
overflow: hidden;
box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.1);
z-index: 30;
:global(.semi-tabs-bar) {
padding-left: 20px;
}
:global(.semi-tabs-content) {
padding: 20px;
height: 500px;
overflow: auto;
}
}

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState } from 'react';
import { Button, Collapsible, Tabs, Tooltip } from '@douyinfe/semi-ui';
import { IconMinus } from '@douyinfe/semi-icons';
import { I18n } from '@flowgram.ai/free-layout-editor';
import iconVariable from '../../../assets/icon-variable.png';
import { GlobalVariableEditor } from './global-variable-editor';
import { FullVariableList } from './full-variable-list';
import styles from './index.module.less';
export function VariablePanel() {
const [isOpen, setOpen] = useState<boolean>(false);
return (
<div className={styles['panel-wrapper']}>
<Tooltip content={I18n.t('Toggle Variable Panel')}>
<Button
className={`${styles['variable-panel-button']} ${isOpen ? styles.close : ''}`}
theme={isOpen ? 'borderless' : 'light'}
onClick={() => setOpen((_open) => !_open)}
>
{isOpen ? <IconMinus /> : <img src={iconVariable} width={20} height={20} />}
</Button>
</Tooltip>
<Collapsible isOpen={isOpen}>
<div className={styles['panel-container']}>
<Tabs>
<Tabs.TabPane itemKey="variables" tab={I18n.t('Variable List')}>
<FullVariableList />
</Tabs.TabPane>
<Tabs.TabPane itemKey="global" tab={I18n.t('Global Editor')}>
<GlobalVariableEditor />
</Tabs.TabPane>
</Tabs>
</div>
</Collapsible>
</div>
);
}

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createVariablePanelPlugin } from './variable-panel-plugin';

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { domUtils, injectable, Layer } from '@flowgram.ai/free-layout-editor';
import { VariablePanel } from './components/variable-panel';
@injectable()
export class VariablePanelLayer extends Layer {
onReady(): void {
// Fix variable panel in the right of canvas
this.config.onDataChange(() => {
const { scrollX, scrollY } = this.config.config;
domUtils.setStyle(this.node, {
position: 'absolute',
right: 25 - scrollX,
top: scrollY + 25,
});
});
}
render(): JSX.Element {
return <VariablePanel />;
}
}

View File

@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTFactory, definePluginCreator, GlobalScope, I18n } from '@flowgram.ai/free-layout-editor';
import { JsonSchemaUtils } from '@flowgram.ai/form-materials';
import iconVariable from '../../assets/icon-variable.png';
import { VariablePanelLayer } from './variable-panel-layer';
const fetchMockVariableFromRemote = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
type: 'object',
properties: {
userId: { type: 'string' },
},
};
};
export const createVariablePanelPlugin = definePluginCreator({
onInit(ctx) {
ctx.playground.registerLayer(VariablePanelLayer);
// Fetch Global Variable
fetchMockVariableFromRemote().then((v) => {
ctx.get(GlobalScope).setVar(
ASTFactory.createVariableDeclaration({
key: 'global',
meta: {
title: I18n.t('Global'),
icon: iconVariable,
},
type: JsonSchemaUtils.schemaToAST(v),
})
);
});
},
});