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:
copilot-swe-agent[bot] 2025-11-23 05:32:56 +00:00
parent a4712f2b96
commit af88062219
13 changed files with 1532 additions and 3 deletions

View File

@ -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
View 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
View 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
View 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 } });
}
}

View File

@ -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('环境变量'),

View File

@ -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"
}

View File

@ -552,5 +552,53 @@
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容",
"场景模式": "场景模式",
"场景管理": "场景管理",
"新建场景": "新建场景",
"编辑场景": "编辑场景",
"场景名称": "场景名称",
"场景描述": "场景描述",
"工作流编辑": "工作流编辑",
"编辑工作流": "编辑工作流",
"请输入场景名称": "请输入场景名称",
"请输入场景描述": "请输入场景描述",
"确认删除场景": "确认删除场景",
"确认删除选中的场景吗": "确认删除选中的场景吗",
"场景": "场景",
"工作流": "工作流",
"节点类型": "节点类型",
"节点标签": "节点标签",
"节点配置": "节点配置",
"添加节点": "添加节点",
"HTTP请求": "HTTP请求",
"脚本执行": "脚本执行",
"条件判断": "条件判断",
"延迟": "延迟",
"循环": "循环",
"请求URL": "请求URL",
"请求方法": "请求方法",
"请求头": "请求头",
"请求体": "请求体",
"脚本ID": "脚本ID",
"脚本路径": "脚本路径",
"脚本内容": "脚本内容",
"条件表达式": "条件表达式",
"延迟时间": "延迟时间",
"迭代次数": "迭代次数",
"选择节点类型": "选择节点类型",
"请输入节点标签": "请输入节点标签",
"验证工作流": "验证工作流",
"保存工作流": "保存工作流",
"请选择节点": "请选择节点",
"删除节点": "删除节点",
"确认删除节点": "确认删除节点",
"工作流编辑器": "工作流编辑器",
"画布": "画布",
"编辑面板": "编辑面板",
"工具栏": "工具栏",
"启用场景": "启用场景",
"禁用场景": "禁用场景",
"确认启用场景": "确认启用场景",
"确认禁用场景": "确认禁用场景"
}

View 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%;
}
}

View 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;

View 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;

View 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;
}

View 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;
}
}

View 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;