diff --git a/back/api/index.ts b/back/api/index.ts index 6dcab62f..871153da 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -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; }; diff --git a/back/api/scenario.ts b/back/api/scenario.ts new file mode 100644 index 00000000..7dd04865 --- /dev/null +++ b/back/api/scenario.ts @@ -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); + } + }, + ); +}; diff --git a/back/data/scenario.ts b/back/data/scenario.ts new file mode 100644 index 00000000..2013ced5 --- /dev/null +++ b/back/data/scenario.ts @@ -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; + 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 {} + +export const ScenarioModel = sequelize.define( + '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, + }, +); diff --git a/back/services/scenario.ts b/back/services/scenario.ts new file mode 100644 index 00000000..a83756f2 --- /dev/null +++ b/back/services/scenario.ts @@ -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 { + const scenario = new Scenario(payload); + const doc = await this.insert(scenario); + return doc; + } + + public async insert(payload: Scenario): Promise { + const result = await ScenarioModel.create(payload, { returning: true }); + return result.get({ plain: true }); + } + + public async update(payload: Scenario): Promise { + 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 { + 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['where'], + ): Promise { + 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 } }); + } +} diff --git a/src/layouts/defaultProps.tsx b/src/layouts/defaultProps.tsx index c747b11e..9928a560 100644 --- a/src/layouts/defaultProps.tsx +++ b/src/layouts/defaultProps.tsx @@ -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: , component: '@/pages/subscription/index', }, + { + path: '/scenario', + name: intl.get('场景管理'), + icon: , + component: '@/pages/scenario/index', + }, { path: '/env', name: intl.get('环境变量'), diff --git a/src/locales/en-US.json b/src/locales/en-US.json index bf4ec2dc..4e806d59 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -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" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index f69f3a29..bb316186 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -552,5 +552,53 @@ "批量": "批量", "全局SSH私钥": "全局SSH私钥", "用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥", - "请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容" + "请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容", + "场景模式": "场景模式", + "场景管理": "场景管理", + "新建场景": "新建场景", + "编辑场景": "编辑场景", + "场景名称": "场景名称", + "场景描述": "场景描述", + "工作流编辑": "工作流编辑", + "编辑工作流": "编辑工作流", + "请输入场景名称": "请输入场景名称", + "请输入场景描述": "请输入场景描述", + "确认删除场景": "确认删除场景", + "确认删除选中的场景吗": "确认删除选中的场景吗", + "场景": "场景", + "工作流": "工作流", + "节点类型": "节点类型", + "节点标签": "节点标签", + "节点配置": "节点配置", + "添加节点": "添加节点", + "HTTP请求": "HTTP请求", + "脚本执行": "脚本执行", + "条件判断": "条件判断", + "延迟": "延迟", + "循环": "循环", + "请求URL": "请求URL", + "请求方法": "请求方法", + "请求头": "请求头", + "请求体": "请求体", + "脚本ID": "脚本ID", + "脚本路径": "脚本路径", + "脚本内容": "脚本内容", + "条件表达式": "条件表达式", + "延迟时间": "延迟时间", + "迭代次数": "迭代次数", + "选择节点类型": "选择节点类型", + "请输入节点标签": "请输入节点标签", + "验证工作流": "验证工作流", + "保存工作流": "保存工作流", + "请选择节点": "请选择节点", + "删除节点": "删除节点", + "确认删除节点": "确认删除节点", + "工作流编辑器": "工作流编辑器", + "画布": "画布", + "编辑面板": "编辑面板", + "工具栏": "工具栏", + "启用场景": "启用场景", + "禁用场景": "禁用场景", + "确认启用场景": "确认启用场景", + "确认禁用场景": "确认禁用场景" } diff --git a/src/pages/scenario/index.less b/src/pages/scenario/index.less new file mode 100644 index 00000000..d61f192e --- /dev/null +++ b/src/pages/scenario/index.less @@ -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%; + } +} diff --git a/src/pages/scenario/index.tsx b/src/pages/scenario/index.tsx new file mode 100644 index 00000000..71302fb2 --- /dev/null +++ b/src/pages/scenario/index.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [isModalVisible, setIsModalVisible] = useState(false); + const [isWorkflowModalVisible, setIsWorkflowModalVisible] = useState(false); + const [editingScenario, setEditingScenario] = useState( + undefined, + ); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + 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: , + label: intl.get('编辑'), + onClick: () => handleEdit(record), + }, + { + key: 'workflow', + icon: , + label: intl.get('编辑工作流'), + onClick: () => handleEditWorkflow(record), + }, + { + key: 'status', + icon: + record.status === 1 ? : , + label: record.status === 1 ? intl.get('禁用场景') : intl.get('启用场景'), + onClick: () => + handleStatusChange([record.id!], record.status === 1 ? 0 : 1), + }, + { + type: 'divider', + }, + { + key: 'delete', + icon: , + label: intl.get('删除'), + danger: true, + onClick: () => handleDelete([record.id!]), + }, + ]; + + const columns = [ + { + title: intl.get('场景名称'), + dataIndex: 'name', + key: 'name', + width: 200, + render: (text: string) => {text}, + }, + { + title: intl.get('场景描述'), + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + { + title: intl.get('状态'), + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: number) => ( + + {status === 1 ? intl.get('已启用') : intl.get('已禁用')} + + ), + }, + { + title: intl.get('工作流'), + dataIndex: 'workflowGraph', + key: 'workflowGraph', + width: 120, + render: (graph: WorkflowGraph) => ( + + {graph?.nodes?.length || 0} {intl.get('节点')} + + ), + }, + { + 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) => ( + + + + + {selectedRowKeys.length > 0 && ( + <> + + + + + )} + + { + setSearchValue(value); + fetchScenarios(value); + }} + allowClear + /> + + + `${intl.get('共')} ${total} ${intl.get('条')}`, + }} + /> + + setIsModalVisible(false)} + /> + + setIsWorkflowModalVisible(false)} + /> + + + ); +}; + +export default ScenarioPage; diff --git a/src/pages/scenario/modal.tsx b/src/pages/scenario/modal.tsx new file mode 100644 index 00000000..27a9d32d --- /dev/null +++ b/src/pages/scenario/modal.tsx @@ -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 = ({ + 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 ( + +
+ + + + + +