mirror of
https://github.com/whyour/qinglong.git
synced 2025-12-13 07:25:05 +08:00
Add Scenario Mode with workflow editor - backend and frontend implementation
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
parent
a4712f2b96
commit
af88062219
|
|
@ -11,6 +11,7 @@ import system from './system';
|
|||
import subscription from './subscription';
|
||||
import update from './update';
|
||||
import health from './health';
|
||||
import scenario from './scenario';
|
||||
|
||||
export default () => {
|
||||
const app = Router();
|
||||
|
|
@ -26,6 +27,7 @@ export default () => {
|
|||
subscription(app);
|
||||
update(app);
|
||||
health(app);
|
||||
scenario(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
|
|
|||
144
back/api/scenario.ts
Normal file
144
back/api/scenario.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import ScenarioService from '../services/scenario';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/scenarios', route);
|
||||
|
||||
route.get(
|
||||
'/',
|
||||
celebrate({
|
||||
query: Joi.object({
|
||||
searchValue: Joi.string().optional().allow(''),
|
||||
page: Joi.number().optional(),
|
||||
size: Joi.number().optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scenarioService = Container.get(ScenarioService);
|
||||
const { searchValue, page, size } = req.query as any;
|
||||
const result = await scenarioService.list(
|
||||
searchValue,
|
||||
page ? parseInt(page) : undefined,
|
||||
size ? parseInt(size) : undefined,
|
||||
);
|
||||
return res.send({ code: 200, data: result });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
name: Joi.string().required(),
|
||||
description: Joi.string().optional().allow(''),
|
||||
workflowGraph: Joi.object().optional(),
|
||||
status: Joi.number().optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scenarioService = Container.get(ScenarioService);
|
||||
const data = await scenarioService.create(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
name: Joi.string().required(),
|
||||
description: Joi.string().optional().allow(''),
|
||||
workflowGraph: Joi.object().optional(),
|
||||
status: Joi.number().optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scenarioService = Container.get(ScenarioService);
|
||||
const data = await scenarioService.update(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.delete(
|
||||
'/',
|
||||
celebrate({
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scenarioService = Container.get(ScenarioService);
|
||||
await scenarioService.remove(req.body);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/disable',
|
||||
celebrate({
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scenarioService = Container.get(ScenarioService);
|
||||
await scenarioService.disabled(req.body);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/enable',
|
||||
celebrate({
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scenarioService = Container.get(ScenarioService);
|
||||
await scenarioService.enabled(req.body);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/:id',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scenarioService = Container.get(ScenarioService);
|
||||
const data = await scenarioService.getDb({ id: parseInt(req.params.id) });
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
89
back/data/scenario.ts
Normal file
89
back/data/scenario.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { sequelize } from '.';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
type: 'http' | 'script' | 'condition' | 'delay' | 'loop';
|
||||
label: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
config: {
|
||||
// HTTP Request node
|
||||
url?: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
|
||||
// Script node
|
||||
scriptId?: number;
|
||||
scriptPath?: string;
|
||||
scriptContent?: string;
|
||||
|
||||
// Condition node
|
||||
condition?: string;
|
||||
trueNext?: string;
|
||||
falseNext?: string;
|
||||
|
||||
// Delay node
|
||||
delayMs?: number;
|
||||
|
||||
// Loop node
|
||||
iterations?: number;
|
||||
loopBody?: string[];
|
||||
};
|
||||
next?: string | string[]; // ID(s) of next node(s)
|
||||
}
|
||||
|
||||
interface WorkflowGraph {
|
||||
nodes: WorkflowNode[];
|
||||
startNode?: string;
|
||||
}
|
||||
|
||||
export class Scenario {
|
||||
name?: string;
|
||||
description?: string;
|
||||
id?: number;
|
||||
status?: 0 | 1; // 0: disabled, 1: enabled
|
||||
workflowGraph?: WorkflowGraph;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
|
||||
constructor(options: Scenario) {
|
||||
this.name = options.name;
|
||||
this.description = options.description;
|
||||
this.id = options.id;
|
||||
this.status = options.status || 0;
|
||||
this.workflowGraph = options.workflowGraph;
|
||||
this.createdAt = options.createdAt;
|
||||
this.updatedAt = options.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScenarioInstance
|
||||
extends Model<Scenario, Scenario>,
|
||||
Scenario {}
|
||||
|
||||
export const ScenarioModel = sequelize.define<ScenarioInstance>(
|
||||
'Scenario',
|
||||
{
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
workflowGraph: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
81
back/services/scenario.ts
Normal file
81
back/services/scenario.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { Scenario, ScenarioModel } from '../data/scenario';
|
||||
import { FindOptions, Op } from 'sequelize';
|
||||
|
||||
@Service()
|
||||
export default class ScenarioService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
public async create(payload: Scenario): Promise<Scenario> {
|
||||
const scenario = new Scenario(payload);
|
||||
const doc = await this.insert(scenario);
|
||||
return doc;
|
||||
}
|
||||
|
||||
public async insert(payload: Scenario): Promise<Scenario> {
|
||||
const result = await ScenarioModel.create(payload, { returning: true });
|
||||
return result.get({ plain: true });
|
||||
}
|
||||
|
||||
public async update(payload: Scenario): Promise<Scenario> {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const scenario = new Scenario({ ...doc, ...payload });
|
||||
const newDoc = await this.updateDb(scenario);
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
public async updateDb(payload: Scenario): Promise<Scenario> {
|
||||
await ScenarioModel.update(payload, { where: { id: payload.id } });
|
||||
return await this.getDb({ id: payload.id });
|
||||
}
|
||||
|
||||
public async remove(ids: number[]) {
|
||||
await ScenarioModel.destroy({ where: { id: ids } });
|
||||
}
|
||||
|
||||
public async list(
|
||||
searchText?: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<{ data: Scenario[]; total: number }> {
|
||||
const where: any = {};
|
||||
if (searchText) {
|
||||
where[Op.or] = [
|
||||
{ name: { [Op.like]: `%${searchText}%` } },
|
||||
{ description: { [Op.like]: `%${searchText}%` } },
|
||||
];
|
||||
}
|
||||
|
||||
const count = await ScenarioModel.count({ where });
|
||||
const data = await ScenarioModel.findAll({
|
||||
where,
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: size,
|
||||
offset: page && size ? (page - 1) * size : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((item) => item.get({ plain: true })),
|
||||
total: count,
|
||||
};
|
||||
}
|
||||
|
||||
public async getDb(
|
||||
query: FindOptions<Scenario>['where'],
|
||||
): Promise<Scenario> {
|
||||
const doc: any = await ScenarioModel.findOne({ where: { ...query } });
|
||||
if (!doc) {
|
||||
throw new Error(`Scenario ${JSON.stringify(query)} not found`);
|
||||
}
|
||||
return doc.get({ plain: true });
|
||||
}
|
||||
|
||||
public async disabled(ids: number[]) {
|
||||
await ScenarioModel.update({ status: 0 }, { where: { id: ids } });
|
||||
}
|
||||
|
||||
public async enabled(ids: number[]) {
|
||||
await ScenarioModel.update({ status: 1 }, { where: { id: ids } });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import intl from 'react-intl-universal';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { SettingOutlined, ApartmentOutlined } from '@ant-design/icons';
|
||||
import IconFont from '@/components/iconfont';
|
||||
import { BasicLayoutProps } from '@ant-design/pro-layout';
|
||||
|
||||
|
|
@ -36,6 +36,12 @@ export default {
|
|||
icon: <IconFont type="ql-icon-subs" />,
|
||||
component: '@/pages/subscription/index',
|
||||
},
|
||||
{
|
||||
path: '/scenario',
|
||||
name: intl.get('场景管理'),
|
||||
icon: <ApartmentOutlined />,
|
||||
component: '@/pages/scenario/index',
|
||||
},
|
||||
{
|
||||
path: '/env',
|
||||
name: intl.get('环境变量'),
|
||||
|
|
|
|||
|
|
@ -552,5 +552,53 @@
|
|||
"批量": "Batch",
|
||||
"全局SSH私钥": "Global SSH Private Key",
|
||||
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
|
||||
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content"
|
||||
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content",
|
||||
"场景模式": "Scenario Mode",
|
||||
"场景管理": "Scenario Management",
|
||||
"新建场景": "New Scenario",
|
||||
"编辑场景": "Edit Scenario",
|
||||
"场景名称": "Scenario Name",
|
||||
"场景描述": "Scenario Description",
|
||||
"工作流编辑": "Workflow Editor",
|
||||
"编辑工作流": "Edit Workflow",
|
||||
"请输入场景名称": "Please enter scenario name",
|
||||
"请输入场景描述": "Please enter scenario description",
|
||||
"确认删除场景": "Confirm to delete scenario",
|
||||
"确认删除选中的场景吗": "Confirm to delete selected scenarios?",
|
||||
"场景": "Scenario",
|
||||
"工作流": "Workflow",
|
||||
"节点类型": "Node Type",
|
||||
"节点标签": "Node Label",
|
||||
"节点配置": "Node Config",
|
||||
"添加节点": "Add Node",
|
||||
"HTTP请求": "HTTP Request",
|
||||
"脚本执行": "Script Execution",
|
||||
"条件判断": "Condition",
|
||||
"延迟": "Delay",
|
||||
"循环": "Loop",
|
||||
"请求URL": "Request URL",
|
||||
"请求方法": "Request Method",
|
||||
"请求头": "Request Headers",
|
||||
"请求体": "Request Body",
|
||||
"脚本ID": "Script ID",
|
||||
"脚本路径": "Script Path",
|
||||
"脚本内容": "Script Content",
|
||||
"条件表达式": "Condition Expression",
|
||||
"延迟时间": "Delay Time",
|
||||
"迭代次数": "Iterations",
|
||||
"选择节点类型": "Select Node Type",
|
||||
"请输入节点标签": "Please enter node label",
|
||||
"验证工作流": "Validate Workflow",
|
||||
"保存工作流": "Save Workflow",
|
||||
"请选择节点": "Please select a node",
|
||||
"删除节点": "Delete Node",
|
||||
"确认删除节点": "Confirm to delete node",
|
||||
"工作流编辑器": "Workflow Editor",
|
||||
"画布": "Canvas",
|
||||
"编辑面板": "Edit Panel",
|
||||
"工具栏": "Toolbar",
|
||||
"启用场景": "Enable Scenario",
|
||||
"禁用场景": "Disable Scenario",
|
||||
"确认启用场景": "Confirm to enable scenario",
|
||||
"确认禁用场景": "Confirm to disable scenario"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -552,5 +552,53 @@
|
|||
"批量": "批量",
|
||||
"全局SSH私钥": "全局SSH私钥",
|
||||
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
|
||||
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
|
||||
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容",
|
||||
"场景模式": "场景模式",
|
||||
"场景管理": "场景管理",
|
||||
"新建场景": "新建场景",
|
||||
"编辑场景": "编辑场景",
|
||||
"场景名称": "场景名称",
|
||||
"场景描述": "场景描述",
|
||||
"工作流编辑": "工作流编辑",
|
||||
"编辑工作流": "编辑工作流",
|
||||
"请输入场景名称": "请输入场景名称",
|
||||
"请输入场景描述": "请输入场景描述",
|
||||
"确认删除场景": "确认删除场景",
|
||||
"确认删除选中的场景吗": "确认删除选中的场景吗",
|
||||
"场景": "场景",
|
||||
"工作流": "工作流",
|
||||
"节点类型": "节点类型",
|
||||
"节点标签": "节点标签",
|
||||
"节点配置": "节点配置",
|
||||
"添加节点": "添加节点",
|
||||
"HTTP请求": "HTTP请求",
|
||||
"脚本执行": "脚本执行",
|
||||
"条件判断": "条件判断",
|
||||
"延迟": "延迟",
|
||||
"循环": "循环",
|
||||
"请求URL": "请求URL",
|
||||
"请求方法": "请求方法",
|
||||
"请求头": "请求头",
|
||||
"请求体": "请求体",
|
||||
"脚本ID": "脚本ID",
|
||||
"脚本路径": "脚本路径",
|
||||
"脚本内容": "脚本内容",
|
||||
"条件表达式": "条件表达式",
|
||||
"延迟时间": "延迟时间",
|
||||
"迭代次数": "迭代次数",
|
||||
"选择节点类型": "选择节点类型",
|
||||
"请输入节点标签": "请输入节点标签",
|
||||
"验证工作流": "验证工作流",
|
||||
"保存工作流": "保存工作流",
|
||||
"请选择节点": "请选择节点",
|
||||
"删除节点": "删除节点",
|
||||
"确认删除节点": "确认删除节点",
|
||||
"工作流编辑器": "工作流编辑器",
|
||||
"画布": "画布",
|
||||
"编辑面板": "编辑面板",
|
||||
"工具栏": "工具栏",
|
||||
"启用场景": "启用场景",
|
||||
"禁用场景": "禁用场景",
|
||||
"确认启用场景": "确认启用场景",
|
||||
"确认禁用场景": "确认禁用场景"
|
||||
}
|
||||
|
|
|
|||
30
src/pages/scenario/index.less
Normal file
30
src/pages/scenario/index.less
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.scenario-page {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.scenario-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .scenario-page {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scenario-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.scenario-toolbar .ant-space,
|
||||
.scenario-toolbar .ant-input-search {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
359
src/pages/scenario/index.tsx
Normal file
359
src/pages/scenario/index.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EllipsisOutlined,
|
||||
ApartmentOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-layout';
|
||||
import intl from 'react-intl-universal';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
import { Scenario, WorkflowGraph } from './type';
|
||||
import ScenarioModal from './modal';
|
||||
import WorkflowEditorModal from './workflowEditorModal';
|
||||
import './index.less';
|
||||
|
||||
const { Search } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
const ScenarioPage: React.FC = () => {
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isWorkflowModalVisible, setIsWorkflowModalVisible] = useState(false);
|
||||
const [editingScenario, setEditingScenario] = useState<Scenario | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchScenarios();
|
||||
}, []);
|
||||
|
||||
const fetchScenarios = async (search?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { code, data } = await request.get(
|
||||
`${config.apiPrefix}scenarios`,
|
||||
{
|
||||
params: { searchValue: search || searchValue },
|
||||
},
|
||||
);
|
||||
if (code === 200) {
|
||||
setScenarios(data?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取场景列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingScenario(undefined);
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (scenario: Scenario) => {
|
||||
setEditingScenario(scenario);
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditWorkflow = (scenario: Scenario) => {
|
||||
setEditingScenario(scenario);
|
||||
setIsWorkflowModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (ids: number[]) => {
|
||||
Modal.confirm({
|
||||
title: intl.get('确认删除场景'),
|
||||
content: intl.get('确认删除选中的场景吗'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const { code } = await request.delete(
|
||||
`${config.apiPrefix}scenarios`,
|
||||
{ data: ids },
|
||||
);
|
||||
if (code === 200) {
|
||||
message.success(`${intl.get('删除')}成功`);
|
||||
fetchScenarios();
|
||||
setSelectedRowKeys([]);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(`${intl.get('删除')}失败`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = async (ids: number[], status: 0 | 1) => {
|
||||
try {
|
||||
const endpoint =
|
||||
status === 1
|
||||
? `${config.apiPrefix}scenarios/enable`
|
||||
: `${config.apiPrefix}scenarios/disable`;
|
||||
const { code } = await request.put(endpoint, ids);
|
||||
if (code === 200) {
|
||||
message.success(
|
||||
`${status === 1 ? intl.get('启用场景') : intl.get('禁用场景')}成功`,
|
||||
);
|
||||
fetchScenarios();
|
||||
setSelectedRowKeys([]);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
`${status === 1 ? intl.get('启用场景') : intl.get('禁用场景')}失败`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async (values: Scenario) => {
|
||||
try {
|
||||
const isEdit = !!editingScenario?.id;
|
||||
const endpoint = `${config.apiPrefix}scenarios`;
|
||||
const method = isEdit ? 'put' : 'post';
|
||||
const payload = isEdit ? { ...values, id: editingScenario.id } : values;
|
||||
|
||||
const { code } = await request[method](endpoint, payload);
|
||||
if (code === 200) {
|
||||
message.success(
|
||||
`${isEdit ? intl.get('编辑') : intl.get('新建')}${intl.get('场景')}成功`,
|
||||
);
|
||||
setIsModalVisible(false);
|
||||
fetchScenarios();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
`${editingScenario ? intl.get('编辑') : intl.get('新建')}${intl.get('场景')}失败`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkflowModalOk = async (graph: WorkflowGraph) => {
|
||||
if (!editingScenario) return;
|
||||
|
||||
try {
|
||||
const { code } = await request.put(`${config.apiPrefix}scenarios`, {
|
||||
id: editingScenario.id,
|
||||
name: editingScenario.name,
|
||||
description: editingScenario.description,
|
||||
workflowGraph: graph,
|
||||
});
|
||||
|
||||
if (code === 200) {
|
||||
message.success(`${intl.get('保存工作流')}成功`);
|
||||
setIsWorkflowModalVisible(false);
|
||||
fetchScenarios();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(`${intl.get('保存工作流')}失败`);
|
||||
}
|
||||
};
|
||||
|
||||
const getRowMenuItems = (record: Scenario): MenuProps['items'] => [
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: intl.get('编辑'),
|
||||
onClick: () => handleEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'workflow',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: intl.get('编辑工作流'),
|
||||
onClick: () => handleEditWorkflow(record),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
icon:
|
||||
record.status === 1 ? <CloseCircleOutlined /> : <CheckCircleOutlined />,
|
||||
label: record.status === 1 ? intl.get('禁用场景') : intl.get('启用场景'),
|
||||
onClick: () =>
|
||||
handleStatusChange([record.id!], record.status === 1 ? 0 : 1),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: intl.get('删除'),
|
||||
danger: true,
|
||||
onClick: () => handleDelete([record.id!]),
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('场景名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
render: (text: string) => <Text strong>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: intl.get('场景描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: intl.get('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 1 ? 'success' : 'default'}>
|
||||
{status === 1 ? intl.get('已启用') : intl.get('已禁用')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: intl.get('工作流'),
|
||||
dataIndex: 'workflowGraph',
|
||||
key: 'workflowGraph',
|
||||
width: 120,
|
||||
render: (graph: WorkflowGraph) => (
|
||||
<Text type="secondary">
|
||||
{graph?.nodes?.length || 0} {intl.get('节点')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: intl.get('创建时间'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (date: string) =>
|
||||
date ? new Date(date).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: intl.get('操作'),
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: (_: any, record: Scenario) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ApartmentOutlined />}
|
||||
onClick={() => handleEditWorkflow(record)}
|
||||
>
|
||||
{intl.get('编辑工作流')}
|
||||
</Button>
|
||||
<Dropdown menu={{ items: getRowMenuItems(record) }}>
|
||||
<Button type="link" size="small" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys),
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
header={{
|
||||
title: intl.get('场景管理'),
|
||||
}}
|
||||
>
|
||||
<div className="scenario-page">
|
||||
<div className="scenario-toolbar">
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{intl.get('新建场景')}
|
||||
</Button>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() =>
|
||||
handleStatusChange(selectedRowKeys as number[], 1)
|
||||
}
|
||||
>
|
||||
{intl.get('启用场景')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() =>
|
||||
handleStatusChange(selectedRowKeys as number[], 0)
|
||||
}
|
||||
>
|
||||
{intl.get('禁用场景')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(selectedRowKeys as number[])}
|
||||
>
|
||||
{intl.get('删除')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
<Search
|
||||
placeholder={intl.get('搜索场景')}
|
||||
style={{ width: 300 }}
|
||||
onSearch={(value) => {
|
||||
setSearchValue(value);
|
||||
fetchScenarios(value);
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={scenarios}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${intl.get('共')} ${total} ${intl.get('条')}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScenarioModal
|
||||
visible={isModalVisible}
|
||||
scenario={editingScenario}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
/>
|
||||
|
||||
<WorkflowEditorModal
|
||||
visible={isWorkflowModalVisible}
|
||||
workflowGraph={editingScenario?.workflowGraph}
|
||||
onOk={handleWorkflowModalOk}
|
||||
onCancel={() => setIsWorkflowModalVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioPage;
|
||||
87
src/pages/scenario/modal.tsx
Normal file
87
src/pages/scenario/modal.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Modal, Form, Input, message } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import { Scenario } from './type';
|
||||
|
||||
interface ScenarioModalProps {
|
||||
visible: boolean;
|
||||
scenario?: Scenario;
|
||||
onOk: (values: Scenario) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const ScenarioModal: React.FC<ScenarioModalProps> = ({
|
||||
visible,
|
||||
scenario,
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && scenario) {
|
||||
form.setFieldsValue({
|
||||
name: scenario.name,
|
||||
description: scenario.description,
|
||||
});
|
||||
} else if (visible) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [visible, scenario, form]);
|
||||
|
||||
const handleOk = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
onOk({
|
||||
...scenario,
|
||||
...values,
|
||||
});
|
||||
form.resetFields();
|
||||
})
|
||||
.catch((info) => {
|
||||
console.log('Validate Failed:', info);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={scenario ? intl.get('编辑场景') : intl.get('新建场景')}
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText={intl.get('确认')}
|
||||
cancelText={intl.get('取消')}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={intl.get('场景名称')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.get('请输入场景名称'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={intl.get('请输入场景名称')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={intl.get('场景描述')}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder={intl.get('请输入场景描述')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioModal;
|
||||
49
src/pages/scenario/type.ts
Normal file
49
src/pages/scenario/type.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export type NodeType = 'http' | 'script' | 'condition' | 'delay' | 'loop';
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
label: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
config: {
|
||||
// HTTP Request node
|
||||
url?: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
|
||||
// Script node
|
||||
scriptId?: number;
|
||||
scriptPath?: string;
|
||||
scriptContent?: string;
|
||||
|
||||
// Condition node
|
||||
condition?: string;
|
||||
trueNext?: string;
|
||||
falseNext?: string;
|
||||
|
||||
// Delay node
|
||||
delayMs?: number;
|
||||
|
||||
// Loop node
|
||||
iterations?: number;
|
||||
loopBody?: string[];
|
||||
};
|
||||
next?: string | string[]; // ID(s) of next node(s)
|
||||
}
|
||||
|
||||
export interface WorkflowGraph {
|
||||
nodes: WorkflowNode[];
|
||||
startNode?: string;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: 0 | 1; // 0: disabled, 1: enabled
|
||||
workflowGraph?: WorkflowGraph;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
170
src/pages/scenario/workflowEditor.less
Normal file
170
src/pages/scenario/workflowEditor.less
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
.workflow-editor-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-canvas {
|
||||
flex: 1;
|
||||
min-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.workflow-toolbar {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.workflow-nodes-area {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.empty-canvas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nodes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
border: 2px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.node-card:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.node-card-selected {
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.node-card-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.node-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.node-card-body {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.workflow-edit-panel {
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.edit-panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.edit-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-panel-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
[data-theme='dark'] .workflow-toolbar,
|
||||
[data-theme='dark'] .edit-panel-header {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .workflow-canvas {
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .workflow-nodes-area {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .node-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #434343;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .node-card-selected {
|
||||
background: #111b26;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .workflow-edit-panel {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.workflow-canvas {
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
.workflow-edit-panel {
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
.nodes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workflow-editor-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workflow-canvas {
|
||||
min-width: 100%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.workflow-edit-panel {
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
border-left: none;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
}
|
||||
}
|
||||
416
src/pages/scenario/workflowEditorModal.tsx
Normal file
416
src/pages/scenario/workflowEditorModal.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import intl from 'react-intl-universal';
|
||||
import { WorkflowGraph, WorkflowNode, NodeType } from './type';
|
||||
import './workflowEditor.less';
|
||||
|
||||
interface WorkflowEditorModalProps {
|
||||
visible: boolean;
|
||||
workflowGraph?: WorkflowGraph;
|
||||
onOk: (graph: WorkflowGraph) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
const WorkflowEditorModal: React.FC<WorkflowEditorModalProps> = ({
|
||||
visible,
|
||||
workflowGraph,
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [localGraph, setLocalGraph] = useState<WorkflowGraph>({
|
||||
nodes: [],
|
||||
startNode: undefined,
|
||||
});
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && workflowGraph) {
|
||||
setLocalGraph(workflowGraph);
|
||||
setSelectedNodeId(null);
|
||||
} else if (visible) {
|
||||
setLocalGraph({ nodes: [], startNode: undefined });
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
}, [visible, workflowGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeId) {
|
||||
const node = localGraph.nodes.find((n) => n.id === selectedNodeId);
|
||||
if (node) {
|
||||
form.setFieldsValue({
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
...node.config,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [selectedNodeId, localGraph, form]);
|
||||
|
||||
const addNode = (type: NodeType) => {
|
||||
const newNode: WorkflowNode = {
|
||||
id: `node_${Date.now()}`,
|
||||
type,
|
||||
label: `${intl.get(getNodeTypeName(type))} ${localGraph.nodes.length + 1}`,
|
||||
config: {},
|
||||
x: 100 + (localGraph.nodes.length % 5) * 150,
|
||||
y: 100 + Math.floor(localGraph.nodes.length / 5) * 100,
|
||||
};
|
||||
|
||||
setLocalGraph({
|
||||
...localGraph,
|
||||
nodes: [...localGraph.nodes, newNode],
|
||||
});
|
||||
setSelectedNodeId(newNode.id);
|
||||
message.success(`${intl.get('添加节点')}成功`);
|
||||
};
|
||||
|
||||
const getNodeTypeName = (type: NodeType): string => {
|
||||
const typeMap: Record<NodeType, string> = {
|
||||
http: 'HTTP请求',
|
||||
script: '脚本执行',
|
||||
condition: '条件判断',
|
||||
delay: '延迟',
|
||||
loop: '循环',
|
||||
};
|
||||
return typeMap[type];
|
||||
};
|
||||
|
||||
const deleteNode = () => {
|
||||
if (!selectedNodeId) {
|
||||
message.warning(intl.get('请选择节点'));
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: intl.get('确认删除节点'),
|
||||
content: `${intl.get('确认')}${intl.get('删除节点')}${intl.get('吗')}?`,
|
||||
onOk: () => {
|
||||
setLocalGraph({
|
||||
...localGraph,
|
||||
nodes: localGraph.nodes.filter((n) => n.id !== selectedNodeId),
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
message.success(`${intl.get('删除')}成功`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateNode = () => {
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.validateFields().then((values) => {
|
||||
const updatedNodes = localGraph.nodes.map((node) => {
|
||||
if (node.id === selectedNodeId) {
|
||||
return {
|
||||
...node,
|
||||
label: values.label,
|
||||
type: values.type,
|
||||
config: {
|
||||
...values,
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
setLocalGraph({
|
||||
...localGraph,
|
||||
nodes: updatedNodes,
|
||||
});
|
||||
message.success(`${intl.get('保存')}成功`);
|
||||
});
|
||||
};
|
||||
|
||||
const validateWorkflow = () => {
|
||||
if (localGraph.nodes.length === 0) {
|
||||
message.warning('工作流至少需要一个节点');
|
||||
return false;
|
||||
}
|
||||
message.success('工作流验证通过');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
if (validateWorkflow()) {
|
||||
onOk(localGraph);
|
||||
}
|
||||
};
|
||||
|
||||
const renderNodeConfig = () => {
|
||||
if (!selectedNodeId) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
{intl.get('请选择节点')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedNode = localGraph.nodes.find((n) => n.id === selectedNodeId);
|
||||
if (!selectedNode) return null;
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical" onValuesChange={updateNode}>
|
||||
<Form.Item
|
||||
name="label"
|
||||
label={intl.get('节点标签')}
|
||||
rules={[{ required: true, message: intl.get('请输入节点标签') }]}
|
||||
>
|
||||
<Input placeholder={intl.get('请输入节点标签')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label={intl.get('节点类型')}
|
||||
rules={[{ required: true, message: intl.get('选择节点类型') }]}
|
||||
>
|
||||
<Select placeholder={intl.get('选择节点类型')} disabled>
|
||||
<Option value="http">{intl.get('HTTP请求')}</Option>
|
||||
<Option value="script">{intl.get('脚本执行')}</Option>
|
||||
<Option value="condition">{intl.get('条件判断')}</Option>
|
||||
<Option value="delay">{intl.get('延迟')}</Option>
|
||||
<Option value="loop">{intl.get('循环')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{selectedNode.type === 'http' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="url"
|
||||
label={intl.get('请求URL')}
|
||||
rules={[{ required: true, message: '请输入URL' }]}
|
||||
>
|
||||
<Input placeholder="https://api.example.com/endpoint" />
|
||||
</Form.Item>
|
||||
<Form.Item name="method" label={intl.get('请求方法')}>
|
||||
<Select defaultValue="GET">
|
||||
<Option value="GET">GET</Option>
|
||||
<Option value="POST">POST</Option>
|
||||
<Option value="PUT">PUT</Option>
|
||||
<Option value="DELETE">DELETE</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="headers" label={intl.get('请求头')}>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder='{"Content-Type": "application/json"}'
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="body" label={intl.get('请求体')}>
|
||||
<TextArea rows={4} placeholder='{"key": "value"}' />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'script' && (
|
||||
<>
|
||||
<Form.Item name="scriptPath" label={intl.get('脚本路径')}>
|
||||
<Input placeholder="/path/to/script.js" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scriptContent" label={intl.get('脚本内容')}>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="console.log('Hello World');"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'condition' && (
|
||||
<Form.Item
|
||||
name="condition"
|
||||
label={intl.get('条件表达式')}
|
||||
rules={[{ required: true, message: '请输入条件表达式' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="response.status === 200"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'delay' && (
|
||||
<Form.Item
|
||||
name="delayMs"
|
||||
label={`${intl.get('延迟时间')} (毫秒)`}
|
||||
rules={[{ required: true, message: '请输入延迟时间' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="1000"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'loop' && (
|
||||
<Form.Item
|
||||
name="iterations"
|
||||
label={intl.get('迭代次数')}
|
||||
rules={[{ required: true, message: '请输入迭代次数' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="5"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={updateNode}
|
||||
>
|
||||
{intl.get('保存')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={deleteNode}
|
||||
>
|
||||
{intl.get('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={intl.get('工作流编辑器')}
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width="95vw"
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ height: '85vh', padding: 0 }}
|
||||
okText={intl.get('保存工作流')}
|
||||
cancelText={intl.get('取消')}
|
||||
>
|
||||
<div className="workflow-editor-container">
|
||||
{/* Left Canvas Area */}
|
||||
<div className="workflow-canvas">
|
||||
<div className="workflow-toolbar">
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
onClick={() => addNode('http')}
|
||||
>
|
||||
{intl.get('HTTP请求')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
onClick={() => addNode('script')}
|
||||
>
|
||||
{intl.get('脚本执行')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
onClick={() => addNode('condition')}
|
||||
>
|
||||
{intl.get('条件判断')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
onClick={() => addNode('delay')}
|
||||
>
|
||||
{intl.get('延迟')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
onClick={() => addNode('loop')}
|
||||
>
|
||||
{intl.get('循环')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CheckOutlined />}
|
||||
size="small"
|
||||
onClick={validateWorkflow}
|
||||
>
|
||||
{intl.get('验证工作流')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="workflow-nodes-area">
|
||||
{localGraph.nodes.length === 0 ? (
|
||||
<div className="empty-canvas">
|
||||
<p>{intl.get('暂无节点,请点击上方按钮添加节点')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="nodes-grid">
|
||||
{localGraph.nodes.map((node) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`node-card ${
|
||||
selectedNodeId === node.id ? 'node-card-selected' : ''
|
||||
}`}
|
||||
hoverable
|
||||
onClick={() => setSelectedNodeId(node.id)}
|
||||
size="small"
|
||||
>
|
||||
<div className="node-card-header">
|
||||
<span className="node-type-badge">
|
||||
{intl.get(getNodeTypeName(node.type))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="node-card-body">
|
||||
<div className="node-label">{node.label}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Edit Panel */}
|
||||
<div className="workflow-edit-panel">
|
||||
<div className="edit-panel-header">
|
||||
<h3>{intl.get('节点配置')}</h3>
|
||||
</div>
|
||||
<div className="edit-panel-body">{renderNodeConfig()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowEditorModal;
|
||||
Loading…
Reference in New Issue
Block a user