smallstonesk
4 years ago
67 changed files with 2631 additions and 481 deletions
@ -0,0 +1,119 @@ |
|||||
|
import {Cell} from '@antv/x6'; |
||||
|
import makeCode from './template/runOnline'; |
||||
|
import simplifyDSL from './simplifyDSL'; |
||||
|
|
||||
|
interface DSL { |
||||
|
cells: Cell.Properties[]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Solution |
||||
|
* |
||||
|
* 1. find the source node form dsl, and if it is not imove-start, |
||||
|
* then insert a vitural imove-start at first. |
||||
|
* |
||||
|
* 2. transform node funciton, follows should be noted: |
||||
|
* - import statement should be replaced with import('packge/from/network') |
||||
|
* - export statement should be replace with return function |
||||
|
* - each node function should be wrapped within a new function to avoid duplicate declaration global variable |
||||
|
* |
||||
|
* 3. assemble Logic, Context, simplyfied dsl and nodeFns map into one file |
||||
|
* |
||||
|
*/ |
||||
|
|
||||
|
const INSERT_DSL_COMMENT = '// define dsl here'; |
||||
|
const INSERT_NODE_FNS_COMMENT = '// define nodeFns here'; |
||||
|
const importRegex = /import\s+([\s\S]*?)\s+from\s+(?:('[@\.\/\-\w]+')|("[@\.\/\-\w]+"))\s*;?/mg; |
||||
|
const virtualSourceNode = { |
||||
|
id: 'virtual-imove-start', |
||||
|
shape: 'imove-start', |
||||
|
data: { |
||||
|
trigger: 'virtual-imove-start', |
||||
|
configData: {}, |
||||
|
code: 'export default async function(ctx) {\n \n}' |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const findStartNode = (dsl: DSL): Cell.Properties => { |
||||
|
|
||||
|
const nodes = dsl.cells.filter(cell => cell.shape !== 'edge'); |
||||
|
const edges = dsl.cells.filter(cell => cell.shape === 'edge'); |
||||
|
|
||||
|
if(nodes.length === 0) { |
||||
|
throw new Error('Compile failed, no node is selected'); |
||||
|
} |
||||
|
|
||||
|
let foundEdge = null; |
||||
|
let startNode = nodes[0]; |
||||
|
while(foundEdge = edges.find(edge => edge.target.cell === startNode.id)) { |
||||
|
const newSourceId = foundEdge.source.cell; |
||||
|
startNode = nodes.find(node => node.id === newSourceId) as Cell.Properties; |
||||
|
} |
||||
|
|
||||
|
if(startNode.shape !== 'imove-start') { |
||||
|
dsl.cells.push( |
||||
|
virtualSourceNode, |
||||
|
{ |
||||
|
shape: "edge", |
||||
|
source: { |
||||
|
cell: 'virtual-imove-start', |
||||
|
}, |
||||
|
target: { |
||||
|
cell: startNode.id |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
startNode = virtualSourceNode; |
||||
|
} |
||||
|
|
||||
|
return startNode; |
||||
|
}; |
||||
|
|
||||
|
const getNextNode = (curNode: Cell.Properties, dsl: DSL) => { |
||||
|
|
||||
|
const nodes = dsl.cells.filter(cell => cell.shape !== 'edge'); |
||||
|
const edges = dsl.cells.filter(cell => cell.shape === 'edge'); |
||||
|
|
||||
|
const foundEdge = edges.find(edge => edge.source.cell === curNode.id); |
||||
|
if(foundEdge) { |
||||
|
return nodes.find(node => node.id === foundEdge.target.cell); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const compileSimplifiedDSL = (dsl: DSL): string => { |
||||
|
const simplyfiedDSL = JSON.stringify(simplifyDSL(dsl), null, 2); |
||||
|
return `const dsl = ${simplyfiedDSL};`; |
||||
|
}; |
||||
|
|
||||
|
const compileNodeFn = (node: Cell.Properties): string => { |
||||
|
const {data: {label, code}} = node; |
||||
|
const newCode = code.replace(importRegex, (match: string, p1: string, p2: string, p3: string) => { |
||||
|
const pkgName = (p2 || p3).replace(/('|")/g, ''); |
||||
|
return `const ${p1} = (await import('https://jspm.dev/${pkgName}')).default;`; |
||||
|
}).replace(/export\s+default/, 'return'); |
||||
|
|
||||
|
return `await (async function() {
|
||||
|
${newCode} |
||||
|
}())`;
|
||||
|
}; |
||||
|
|
||||
|
const compileNodeFnsMap = (dsl: DSL): string => { |
||||
|
const nodes = dsl.cells.filter(cell => cell.shape !== 'edge'); |
||||
|
const kvs = nodes.map(node => { |
||||
|
const {id} = node; |
||||
|
return `'${id}': ${compileNodeFn(node)}`; |
||||
|
}); |
||||
|
|
||||
|
return `const nodeFns = {\n ${kvs.join(',\n ')}\n}`; |
||||
|
}; |
||||
|
|
||||
|
const compile = (dsl: DSL, mockInput: any): string => { |
||||
|
const startNode = findStartNode(dsl); |
||||
|
const mockNode = getNextNode(startNode, dsl); |
||||
|
return makeCode(mockNode, mockInput) |
||||
|
.replace(INSERT_DSL_COMMENT, compileSimplifiedDSL(dsl)) |
||||
|
.replace(INSERT_NODE_FNS_COMMENT, compileNodeFnsMap(dsl)) |
||||
|
.replace('$TRIGGER$', startNode.data.trigger); |
||||
|
}; |
||||
|
|
||||
|
export default compile; |
@ -0,0 +1,34 @@ |
|||||
|
import {Cell} from '@antv/x6'; |
||||
|
import addPlugins from './addPlugins'; |
||||
|
import simplifyDSL from './simplifyDSL'; |
||||
|
import extractNodeFns from './extractNodeFns'; |
||||
|
import logicTpl from './template/logic'; |
||||
|
import indexTpl from './template/index'; |
||||
|
import contextTpl from './template/context'; |
||||
|
|
||||
|
interface DSL { |
||||
|
cells: Cell.Properties[]; |
||||
|
} |
||||
|
|
||||
|
interface IOutput { |
||||
|
'nodeFns': { |
||||
|
[fileName: string]: string |
||||
|
}; |
||||
|
'context.js': string; |
||||
|
'dsl.json': string; |
||||
|
'index.js': string; |
||||
|
'logic.js': string; |
||||
|
} |
||||
|
|
||||
|
const compile = (dsl: DSL, plugins = []): IOutput => { |
||||
|
const output: IOutput = { |
||||
|
'nodeFns': extractNodeFns(dsl), |
||||
|
"context.js": contextTpl, |
||||
|
'dsl.json': JSON.stringify(simplifyDSL(dsl), null, 2), |
||||
|
'index.js': addPlugins(indexTpl, plugins), |
||||
|
'logic.js': logicTpl |
||||
|
}; |
||||
|
return output; |
||||
|
}; |
||||
|
|
||||
|
export default compile; |
@ -0,0 +1,74 @@ |
|||||
|
import logic from './logic'; |
||||
|
import context from './context'; |
||||
|
|
||||
|
const makeCode = (mockNode: any, mockInput: any) => ` |
||||
|
(async function run() { |
||||
|
// Context
|
||||
|
${context.replace(/export\s+default/, '')} |
||||
|
|
||||
|
// Logic
|
||||
|
${logic |
||||
|
.split('\n') |
||||
|
.filter(line => !line.match(/import nodeFns/) && !line.match(/import Context/)) |
||||
|
.join('\n') |
||||
|
.replace(/export\s+default/, '') |
||||
|
.replace( |
||||
|
`import EventEmitter from 'eventemitter3';`, |
||||
|
`const EventEmitter = (await import('https://jspm.dev/eventemitter3')).default;` |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// DSL
|
||||
|
// define dsl here
|
||||
|
|
||||
|
// nodeFns map
|
||||
|
// define nodeFns here
|
||||
|
|
||||
|
// imove plugin
|
||||
|
const mockPlugin = () => { |
||||
|
const mockNode = ${JSON.stringify(mockNode)}; |
||||
|
const mockInput = ${JSON.stringify(mockInput)}; |
||||
|
const toMockTargets = [ |
||||
|
['pipe', 'getPipe'], |
||||
|
['config', 'getConfig'], |
||||
|
['payload', 'getPayload'], |
||||
|
['context', 'getContext'], |
||||
|
]; |
||||
|
return { |
||||
|
enterNode(ctx) { |
||||
|
// hijack
|
||||
|
if(ctx.curNode.id === mockNode.id) { |
||||
|
toMockTargets.forEach(item => { |
||||
|
const [type, method] = item; |
||||
|
item[2] = ctx[method]; |
||||
|
ctx[method] = () => mockInput[type]; |
||||
|
}); |
||||
|
} |
||||
|
}, |
||||
|
leaveNode(ctx) { |
||||
|
// restore
|
||||
|
if(ctx.curNode.id === mockNode.id) { |
||||
|
toMockTargets.forEach(item => { |
||||
|
const [type, method, originMethod] = item; |
||||
|
ctx[method] = originMethod; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
// instantiation and invoke
|
||||
|
const logic = new Logic({ dsl }); |
||||
|
logic.use(mockPlugin); |
||||
|
logic.invoke('$TRIGGER$', {}, (pipe) => { |
||||
|
const ctx = logic._getUnsafeCtx(); |
||||
|
const context = ctx.getContext(); |
||||
|
window.dispatchEvent(new CustomEvent('iMoveOnlineExecEnds', {detail: {pipe, context}})); |
||||
|
}); |
||||
|
})().catch(err => { |
||||
|
console.error(err.message); |
||||
|
window.dispatchEvent(new CustomEvent('iMoveOnlineExecEnds', {detail: {error: {message: err.message}}})); |
||||
|
}); |
||||
|
`;
|
||||
|
|
||||
|
export default makeCode; |
@ -0,0 +1,62 @@ |
|||||
|
.container { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
flex: 1; |
||||
|
height: 100%; |
||||
|
|
||||
|
.pane { |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
flex: 1; |
||||
|
|
||||
|
.runWrapper { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
right: 0; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.card { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
flex: 1; |
||||
|
background-color: #ffffff; |
||||
|
|
||||
|
.cardTitleText { |
||||
|
margin: 10px 24px 0; |
||||
|
font-size: 16px; |
||||
|
font-weight: 500; |
||||
|
color: #000000; |
||||
|
} |
||||
|
|
||||
|
.cardBody { |
||||
|
padding: 10px 24px; |
||||
|
height: 100%; |
||||
|
overflow: scroll; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:global { |
||||
|
.ant-tabs { |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.ant-tabs-content-holder { |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.ant-tabs-content { |
||||
|
height: 100%; |
||||
|
overflow: scroll; |
||||
|
} |
||||
|
|
||||
|
.sc-bdVaJa.cjjWdp { |
||||
|
opacity: 1; |
||||
|
background-color: #efefef; |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: transparent; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,129 @@ |
|||||
|
import React, { |
||||
|
useState, |
||||
|
useEffect, |
||||
|
useCallback, |
||||
|
} from 'react'; |
||||
|
|
||||
|
import styles from './index.module.less'; |
||||
|
|
||||
|
import { Button } from 'antd'; |
||||
|
import { Graph } from '@antv/x6'; |
||||
|
import Console from '../console'; |
||||
|
import InputPanel from './inputPanel'; |
||||
|
import JsonView from 'react-json-view'; |
||||
|
import { executeScript } from '../../utils'; |
||||
|
import { PlayCircleFilled, LoadingOutlined } from '@ant-design/icons'; |
||||
|
import { compileForOnline } from '@imove/compile-code'; |
||||
|
import { toSelectedCellsJSON } from '../../utils/flowChartUtils'; |
||||
|
|
||||
|
// FIXME: https://github.com/tomkp/react-split-pane/issues/541
|
||||
|
// @ts-ignore
|
||||
|
import SplitPane from 'react-split-pane/lib/SplitPane'; |
||||
|
// @ts-ignore
|
||||
|
import Pane from 'react-split-pane/lib/Pane'; |
||||
|
|
||||
|
const defaultInput = { |
||||
|
pipe: {}, |
||||
|
context: {}, |
||||
|
payload: {}, |
||||
|
config: {} |
||||
|
}; |
||||
|
|
||||
|
interface ICardProps { |
||||
|
title: string; |
||||
|
} |
||||
|
|
||||
|
const Card: React.FC<ICardProps> = (props) => { |
||||
|
const { title } = props; |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.card}> |
||||
|
<div className={styles.cardTitleText}>{title}</div> |
||||
|
<div className={styles.cardBody}> |
||||
|
{props.children} |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
interface ICodeRunProps { |
||||
|
flowChart: Graph; |
||||
|
} |
||||
|
|
||||
|
const CodeRun: React.FC<ICodeRunProps> = (props) => { |
||||
|
|
||||
|
const {flowChart} = props; |
||||
|
const [isRunning, setIsRunning] = useState(false); |
||||
|
const [input, setInput] = useState(defaultInput); |
||||
|
const [output, setOutput] = useState({}); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
// NOTE: listen the event that iMove online exec ends
|
||||
|
const handler = (data: any) => { |
||||
|
setIsRunning(false); |
||||
|
setOutput(data.detail || {}); |
||||
|
}; |
||||
|
window.addEventListener('iMoveOnlineExecEnds', handler); |
||||
|
return () => { |
||||
|
window.removeEventListener('iMoveOnlineExecEnds', handler); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
const onClickRun = useCallback(() => { |
||||
|
setIsRunning(true); |
||||
|
const selectedCelssJson = toSelectedCellsJSON(flowChart); |
||||
|
const compiledCode = compileForOnline(selectedCelssJson, input); |
||||
|
executeScript(compiledCode); |
||||
|
}, [flowChart, input]); |
||||
|
|
||||
|
const onChangeInput = useCallback((val: any) => { |
||||
|
setInput(val); |
||||
|
}, []); |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.container}> |
||||
|
<SplitPane split={'horizontal'}> |
||||
|
<Pane initialSize={'380px'} minSize={'43px'}> |
||||
|
<SplitPane split={'vertical'}> |
||||
|
<Pane className={styles.pane} minSize={'360px'} maxSize={'660px'}> |
||||
|
<div className={styles.runWrapper}> |
||||
|
{isRunning ? ( |
||||
|
<Button size={'large'} type={'link'}> |
||||
|
<LoadingOutlined /> 运行中... |
||||
|
</Button> |
||||
|
) : ( |
||||
|
<Button size={'large'} type={'link'} onClick={onClickRun}> |
||||
|
<PlayCircleFilled /> 运行代码 |
||||
|
</Button> |
||||
|
)} |
||||
|
</div> |
||||
|
<Card title={'输入'}> |
||||
|
<InputPanel |
||||
|
data={input} |
||||
|
onChange={onChangeInput} |
||||
|
/> |
||||
|
</Card> |
||||
|
</Pane> |
||||
|
<Pane className={styles.pane}> |
||||
|
<Card title={'输出'}> |
||||
|
<JsonView |
||||
|
name={null} |
||||
|
collapsed={false} |
||||
|
enableClipboard={false} |
||||
|
displayDataTypes={false} |
||||
|
displayObjectSize={false} |
||||
|
src={output} |
||||
|
/> |
||||
|
</Card> |
||||
|
</Pane> |
||||
|
</SplitPane> |
||||
|
</Pane> |
||||
|
<Pane className={styles.pane} minSize={'40px'}> |
||||
|
<Console /> |
||||
|
</Pane> |
||||
|
</SplitPane> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export default CodeRun; |
@ -0,0 +1,24 @@ |
|||||
|
.itemHeader { |
||||
|
margin-bottom: 8px; |
||||
|
|
||||
|
.itemTitleText { |
||||
|
font-size: 15px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.itemDescText { |
||||
|
margin-left: 5px; |
||||
|
font-size: 12px; |
||||
|
color: #999; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:global { |
||||
|
.ant-tabs-nav { |
||||
|
margin-bottom: 5px; |
||||
|
} |
||||
|
|
||||
|
.ant-form-item { |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
} |
@ -0,0 +1,166 @@ |
|||||
|
import React, { |
||||
|
useRef, |
||||
|
useEffect, |
||||
|
} from 'react'; |
||||
|
|
||||
|
import styles from './index.module.less'; |
||||
|
|
||||
|
import CodeEditor from '../../codeEditor'; |
||||
|
import { Tabs, Form, Input, Button, Space } from 'antd'; |
||||
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; |
||||
|
|
||||
|
const inputItems = [ |
||||
|
{ type: 'pipe', desc: '上一节点返回值' }, |
||||
|
{ type: 'context', desc: '当前逻辑流程公用数据' }, |
||||
|
{ type: 'payload', desc: '当前逻辑流程通用数据' }, |
||||
|
{ type: 'config', desc: '当前节点投放配置' }, |
||||
|
]; |
||||
|
|
||||
|
interface IVisualFormItemProps { |
||||
|
type: string; |
||||
|
desc: string; |
||||
|
} |
||||
|
|
||||
|
const VisualFormItem: React.FC<IVisualFormItemProps> = (props) => { |
||||
|
const { type, desc } = props; |
||||
|
|
||||
|
return ( |
||||
|
<React.Fragment> |
||||
|
<div className={styles.itemHeader}> |
||||
|
<span className={styles.itemTitleText}>{type}</span> |
||||
|
<span className={styles.itemDescText}>{desc}</span> |
||||
|
</div> |
||||
|
<Form.List name={type}> |
||||
|
{(fields, { add, remove }) => ( |
||||
|
<React.Fragment> |
||||
|
{fields.map(field => ( |
||||
|
<Space key={field.key} align={'baseline'}> |
||||
|
<Form.Item |
||||
|
{...field} |
||||
|
label={'key'} |
||||
|
name={[field.name, 'key']} |
||||
|
fieldKey={[field.fieldKey, 'key']} |
||||
|
> |
||||
|
<Input /> |
||||
|
</Form.Item> |
||||
|
<Form.Item |
||||
|
{...field} |
||||
|
label={'value'} |
||||
|
name={[field.name, 'value']} |
||||
|
fieldKey={[field.fieldKey, 'value']} |
||||
|
> |
||||
|
<Input /> |
||||
|
</Form.Item> |
||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} /> |
||||
|
</Space> |
||||
|
))} |
||||
|
<Form.Item> |
||||
|
<Button |
||||
|
block={true} |
||||
|
type={'dashed'} |
||||
|
icon={<PlusOutlined />} |
||||
|
onClick={() => add()} |
||||
|
> |
||||
|
新增 |
||||
|
</Button> |
||||
|
</Form.Item> |
||||
|
</React.Fragment> |
||||
|
)} |
||||
|
</Form.List> |
||||
|
</React.Fragment> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
interface IVisualPanelProps { |
||||
|
data: any; |
||||
|
onChange: (value: object) => void |
||||
|
} |
||||
|
|
||||
|
const VisualPanel: React.FC<IVisualPanelProps> = (props) => { |
||||
|
const { data, onChange } = props; |
||||
|
const [form] = Form.useForm(); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const filedsValue: { [key: string]: any } = {}; |
||||
|
for (const { type } of inputItems) { |
||||
|
const mockValue = data[type] || {}; |
||||
|
filedsValue[type] = Object.keys(mockValue).map(key => ({ key, value: mockValue[key] })); |
||||
|
} |
||||
|
form.setFieldsValue(filedsValue); |
||||
|
}, [data]); |
||||
|
|
||||
|
const onFormChange = () => { |
||||
|
const input: { [key: string]: any } = {}; |
||||
|
const filedsValue = form.getFieldsValue(); |
||||
|
for (const { type } of inputItems) { |
||||
|
const mockValue = filedsValue[type] || []; |
||||
|
input[type] = mockValue.reduce((prev: any, cur: { key: string, value: any }) => { |
||||
|
const { key, value } = cur; |
||||
|
prev[key] = value; |
||||
|
return prev; |
||||
|
}, {}); |
||||
|
} |
||||
|
onChange(input); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.visualInput}> |
||||
|
<Form |
||||
|
form={form} |
||||
|
autoComplete={'on'} |
||||
|
onChange={onFormChange} |
||||
|
> |
||||
|
{inputItems.map(({ type, desc }, index) => ( |
||||
|
<VisualFormItem |
||||
|
key={index} |
||||
|
type={type} |
||||
|
desc={desc} |
||||
|
/> |
||||
|
))} |
||||
|
</Form> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
interface IInputPanelProps { |
||||
|
data: any; |
||||
|
onChange: (data: any) => void; |
||||
|
} |
||||
|
|
||||
|
const InputPanel: React.FC<IInputPanelProps> = (props) => { |
||||
|
const cache: any = useRef({}); |
||||
|
const { data, onChange } = props; |
||||
|
|
||||
|
const onVisualChange = (newForm: any) => { |
||||
|
clearTimeout(cache.current.timer); |
||||
|
cache.current.timer = setTimeout(() => onChange(newForm), 1000); |
||||
|
}; |
||||
|
const onEditorChange = (ev: any, newCode: string | undefined = '') => { |
||||
|
clearTimeout(cache.current.timer); |
||||
|
cache.current.timer = setTimeout(() => { |
||||
|
try { |
||||
|
onChange(JSON.parse(newCode)); |
||||
|
} catch (err) { |
||||
|
console.log(err); |
||||
|
} |
||||
|
}, 1000); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Tabs type={'card'} defaultActiveKey={'visualTab'}> |
||||
|
<Tabs.TabPane className={styles.tabPane} tab={'可视化模式'} key={'visualTab'}> |
||||
|
<VisualPanel data={data} onChange={onVisualChange} /> |
||||
|
</Tabs.TabPane> |
||||
|
<Tabs.TabPane className={styles.tabPane} tab={'源码模式'} key={'jsonTab'}> |
||||
|
<CodeEditor |
||||
|
height={'100%'} |
||||
|
language={'json'} |
||||
|
value={JSON.stringify(data, null, 2)} |
||||
|
onChange={onEditorChange} |
||||
|
/> |
||||
|
</Tabs.TabPane> |
||||
|
</Tabs> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default InputPanel; |
@ -0,0 +1,52 @@ |
|||||
|
.container { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
overflow: scroll; |
||||
|
|
||||
|
:global { |
||||
|
.ant-tabs-nav { |
||||
|
margin-bottom: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.logPanel { |
||||
|
height: 100%; |
||||
|
background: #272823; |
||||
|
|
||||
|
.logLine { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
position: relative; |
||||
|
margin-top: -1px; |
||||
|
padding-left: 10px; |
||||
|
font-size: 13px; |
||||
|
border: 1px solid transparent; |
||||
|
|
||||
|
&:first-child { |
||||
|
margin-top: 0; |
||||
|
} |
||||
|
|
||||
|
.logIcon { |
||||
|
padding-top: 0.6rem; |
||||
|
width: 1rem; |
||||
|
height: 1rem; |
||||
|
} |
||||
|
|
||||
|
.logText { |
||||
|
margin-left: 8px; |
||||
|
min-height: 18px; |
||||
|
padding: 0.4rem 1.5rem 0.4rem 0px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.toolBar { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
|
||||
|
.levels { |
||||
|
margin-left: 4px; |
||||
|
width: 150px; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,202 @@ |
|||||
|
import React, { |
||||
|
useRef, |
||||
|
useState, |
||||
|
useEffect, |
||||
|
useCallback, |
||||
|
ChangeEvent |
||||
|
} from 'react'; |
||||
|
|
||||
|
import styles from './index.module.less'; |
||||
|
|
||||
|
import { |
||||
|
InfoCircleOutlined, |
||||
|
WarningOutlined, |
||||
|
BugOutlined, |
||||
|
CloseCircleOutlined, |
||||
|
ClearOutlined, |
||||
|
FilterOutlined |
||||
|
} from '@ant-design/icons'; |
||||
|
import { Button, Input, Select, Tabs } from 'antd'; |
||||
|
|
||||
|
interface ILog { |
||||
|
type: string; |
||||
|
data: any[]; |
||||
|
strVal: string; |
||||
|
} |
||||
|
|
||||
|
const Helper = { |
||||
|
isPlainObject(value: any): boolean { |
||||
|
return typeof value === 'object' && value !== null; |
||||
|
}, |
||||
|
getArgsToString(args: any[]): string { |
||||
|
return args.map(o => { |
||||
|
if (Helper.isPlainObject(o)) { |
||||
|
return JSON.stringify(o); |
||||
|
} else { |
||||
|
return o; |
||||
|
} |
||||
|
}).join(' '); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const hijackMap: { [key: string]: any } = { |
||||
|
log: { |
||||
|
bgColor: '#272823', |
||||
|
textColor: '#ffffff', |
||||
|
borderColor: 'rgba(128, 128, 128, 0.35)', |
||||
|
icon: <div className={styles.logIcon} />, |
||||
|
originMethod: console.log |
||||
|
}, |
||||
|
info: { |
||||
|
bgColor: '#272823', |
||||
|
textColor: '#ffffff', |
||||
|
borderColor: 'rgba(128, 128, 128, 0.35)', |
||||
|
icon: <InfoCircleOutlined className={styles.logIcon} />, |
||||
|
originMethod: console.info |
||||
|
}, |
||||
|
warn: { |
||||
|
bgColor: 'rgb(51, 42, 0)', |
||||
|
textColor: 'rgb(245, 211, 150)', |
||||
|
borderColor: 'rgb(102, 85, 0)', |
||||
|
icon: <WarningOutlined className={styles.logIcon} />, |
||||
|
originMethod: console.warn |
||||
|
}, |
||||
|
debug: { |
||||
|
bgColor: '#272823', |
||||
|
textColor: 'rgb(77, 136, 255)', |
||||
|
borderColor: 'rgba(128, 128, 128, 0.35)', |
||||
|
icon: <BugOutlined className={styles.logIcon} />, |
||||
|
originMethod: console.debug |
||||
|
}, |
||||
|
error: { |
||||
|
bgColor: 'rgb(40, 0, 0)', |
||||
|
textColor: 'rgb(254, 127, 127)', |
||||
|
borderColor: 'rgb(91, 0, 0)', |
||||
|
icon: <CloseCircleOutlined className={styles.logIcon} />, |
||||
|
originMethod: console.error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const MyConsole: React.FC = () => { |
||||
|
const [filter, setFilter] = useState(''); |
||||
|
const [level, setLevel] = useState('all'); |
||||
|
const [logList, setLogList] = useState<ILog[]>([]); |
||||
|
const cache = useRef<{ allLogs: ILog[] }>({ allLogs: [] }); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
hijackConsole(); |
||||
|
return () => { |
||||
|
resetConsole(); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const filteredLogs = cache.current.allLogs |
||||
|
.filter(log => { |
||||
|
if (level === 'all') { |
||||
|
return true; |
||||
|
} else { |
||||
|
return log.type === level; |
||||
|
} |
||||
|
}) |
||||
|
.filter(log => { |
||||
|
return log.strVal.indexOf(filter) > -1; |
||||
|
}); |
||||
|
setLogList(filteredLogs); |
||||
|
}, [filter, level]); |
||||
|
|
||||
|
const hijackConsole = () => { |
||||
|
Object.keys(hijackMap).forEach(method => { |
||||
|
// @ts-ignore
|
||||
|
window.console[method] = (...args: any[]) => { |
||||
|
hijackMap[method].originMethod(...args); |
||||
|
cache.current.allLogs = cache.current.allLogs.concat({ |
||||
|
type: method, |
||||
|
data: args, |
||||
|
strVal: Helper.getArgsToString(args) |
||||
|
}); |
||||
|
setLogList(cache.current.allLogs); |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const resetConsole = () => { |
||||
|
Object.keys(hijackMap).forEach(method => { |
||||
|
// @ts-ignore
|
||||
|
window.console[method] = hijackMap[method].originMethod; |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const onChangeFilter = useCallback((evt: ChangeEvent<HTMLInputElement>) => { |
||||
|
setFilter(evt.target.value); |
||||
|
}, []); |
||||
|
|
||||
|
const onChangeLevel = useCallback((level: string) => { |
||||
|
setLevel(level); |
||||
|
}, []); |
||||
|
|
||||
|
const onClickClear = useCallback(() => { |
||||
|
setLogList([]); |
||||
|
cache.current.allLogs = []; |
||||
|
}, []); |
||||
|
|
||||
|
const renderToolBar = () => { |
||||
|
return ( |
||||
|
<div className={styles.toolBar}> |
||||
|
<Input |
||||
|
allowClear={true} |
||||
|
placeholder={'过滤'} |
||||
|
prefix={<FilterOutlined />} |
||||
|
onChange={onChangeFilter} |
||||
|
/> |
||||
|
<Select className={styles.levels} defaultValue={'all'} onChange={onChangeLevel}> |
||||
|
{['all', 'info', 'warn', 'error', 'debug'].map(method => ( |
||||
|
<Select.Option key={method} value={method}> |
||||
|
{method} |
||||
|
</Select.Option> |
||||
|
))} |
||||
|
</Select> |
||||
|
<Button type={'link'} onClick={onClickClear}> |
||||
|
<ClearOutlined /> 清空 |
||||
|
</Button> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const renderLogPanel = (logList: ILog[]) => { |
||||
|
return ( |
||||
|
<div className={styles.logPanel}> |
||||
|
{logList.map((log: ILog, index: number) => { |
||||
|
const { icon, textColor, bgColor, borderColor } = hijackMap[log.type]; |
||||
|
const logLineStyle = { |
||||
|
color: textColor, |
||||
|
backgroundColor: bgColor, |
||||
|
borderTopColor: borderColor, |
||||
|
borderBottomColor: borderColor |
||||
|
}; |
||||
|
// TODO: use react-json-view to render object
|
||||
|
return ( |
||||
|
<div key={index} className={styles.logLine} style={logLineStyle}> |
||||
|
{icon} |
||||
|
<div className={styles.logText}> |
||||
|
{log.strVal} |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
})} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.container}> |
||||
|
<Tabs type={'card'} tabBarExtraContent={renderToolBar()}> |
||||
|
<Tabs.TabPane tab={'控制台'} key={'log'}> |
||||
|
{renderLogPanel(logList)} |
||||
|
</Tabs.TabPane> |
||||
|
</Tabs> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default MyConsole; |
@ -0,0 +1,9 @@ |
|||||
|
.btnWrap{ |
||||
|
width:100%; |
||||
|
display:flex; |
||||
|
flex-direction: row; |
||||
|
justify-content:flex-end; |
||||
|
.btn{ |
||||
|
margin-top: 20px; |
||||
|
} |
||||
|
} |
@ -0,0 +1,109 @@ |
|||||
|
import React from 'react'; |
||||
|
import { Form, Input, Switch, Select, Checkbox, DatePicker, TimePicker, Empty, Button, message } from 'antd'; |
||||
|
import moment from 'moment'; |
||||
|
import styles from './index.module.less' |
||||
|
const FormItem = Form.Item |
||||
|
const { RangePicker } = DatePicker; |
||||
|
const { Option } = Select; |
||||
|
interface INum { |
||||
|
label: string, |
||||
|
value: string |
||||
|
} |
||||
|
|
||||
|
interface IConfigData { |
||||
|
[key: string]: any |
||||
|
} |
||||
|
interface IProps { |
||||
|
schema: { |
||||
|
type: string, |
||||
|
properties: { [key: string]: any } |
||||
|
}, |
||||
|
configData: IConfigData, |
||||
|
changeConfigData: (data: IConfigData) => void |
||||
|
} |
||||
|
|
||||
|
const formLayout = { |
||||
|
labelCol: { span: 8 }, |
||||
|
wrapperCol: { span: 16 } |
||||
|
}; |
||||
|
|
||||
|
const SchemaForm: React.FC<IProps> = (props) => { |
||||
|
const { schema, configData, changeConfigData } = props |
||||
|
const [form] = Form.useForm() |
||||
|
const onClickSave = (): void => { |
||||
|
changeConfigData(form.getFieldsValue()) |
||||
|
message.success('保存成功!') |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
schema && Object.keys(schema).length > 0 ? |
||||
|
<Form |
||||
|
form={form} |
||||
|
initialValues={configData} |
||||
|
{...formLayout} |
||||
|
> |
||||
|
{schema?.properties && Object.keys(schema.properties).map((key: string) => { |
||||
|
const obj = schema.properties[key] |
||||
|
const options: INum[] = obj.enum ? obj.enum.map((item: string, idx: number) => { |
||||
|
return { label: item, value: obj.enumNames[idx] } |
||||
|
}) : [] |
||||
|
let ele = null |
||||
|
// 输入框
|
||||
|
if (obj.type === 'string' && !obj.hasOwnProperty('format') && !obj.hasOwnProperty('enum')) { |
||||
|
ele = <Input /> |
||||
|
} |
||||
|
// 编辑框
|
||||
|
if (obj.type === 'string' && obj.format === 'textarea') { |
||||
|
ele = <Input.TextArea /> |
||||
|
} |
||||
|
// switch
|
||||
|
if (obj.type === 'boolean' && obj['ui:widget'] === 'switch') { |
||||
|
ele = <Switch /> |
||||
|
} |
||||
|
// 下拉单选
|
||||
|
if (obj.type === 'string' && obj.hasOwnProperty('enum') && !obj.hasOwnProperty('ui:widget')) { |
||||
|
ele = <Select allowClear>{options.map(item => |
||||
|
<Option key={item.value} value={item.value}>{item.label}</Option> |
||||
|
)}</Select> |
||||
|
} |
||||
|
// 下拉多选
|
||||
|
if (obj.type === 'array' && obj.hasOwnProperty('enum') && obj['ui:widget'] === 'multiSelect') { |
||||
|
ele = <Select allowClear mode="multiple"> |
||||
|
{options.map(item => <Option key={item.value} value={item.value}>{item.label}</Option>)} |
||||
|
</Select> |
||||
|
} |
||||
|
// 点击多选
|
||||
|
if (obj.type === 'array' && obj.hasOwnProperty('enum') && !obj.hasOwnProperty('ui:widget')) { |
||||
|
ele = <Checkbox.Group options={options} /> |
||||
|
} |
||||
|
// 时间选择
|
||||
|
if (obj.type === 'string' && obj.format === 'time') { |
||||
|
ele = <TimePicker defaultValue={moment('00:00:00', 'HH:mm:ss')} /> |
||||
|
} |
||||
|
// 日期范围
|
||||
|
if (obj.type === 'range' && obj.format === 'date') { |
||||
|
ele = <DatePicker /> |
||||
|
} |
||||
|
// 日期选择
|
||||
|
if (obj.type === 'string' && obj.format === 'date') { |
||||
|
ele = <RangePicker /> |
||||
|
} |
||||
|
return <FormItem |
||||
|
label={obj.title} |
||||
|
name={key} |
||||
|
> |
||||
|
{ele} |
||||
|
</FormItem> |
||||
|
})} |
||||
|
<div className={styles.btnWrap}> |
||||
|
<Button size='small' className={styles.btn} type={'primary'} onClick={onClickSave}>保存</Button> |
||||
|
</div> |
||||
|
</Form> : |
||||
|
<Empty |
||||
|
description={'请编辑投放配置schema'} |
||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default SchemaForm |
@ -0,0 +1,65 @@ |
|||||
|
import { |
||||
|
useRef, |
||||
|
useEffect, |
||||
|
MutableRefObject |
||||
|
} from 'react'; |
||||
|
|
||||
|
type EventType = MouseEvent | TouchEvent; |
||||
|
type TargetElement = HTMLElement | Element | Document | Window; |
||||
|
type BasicTarget<T = HTMLElement> = |
||||
|
| (() => T | null) |
||||
|
| T |
||||
|
| null |
||||
|
| MutableRefObject<T | null | undefined>; |
||||
|
|
||||
|
const defaultEvent = 'click'; |
||||
|
|
||||
|
const getTargetElement = ( |
||||
|
target: BasicTarget<TargetElement>, |
||||
|
defaultElement?: TargetElement, |
||||
|
): TargetElement | undefined | null => { |
||||
|
if (!target) { |
||||
|
return defaultElement; |
||||
|
} |
||||
|
|
||||
|
let targetElement: TargetElement | undefined | null; |
||||
|
if (typeof target === 'function') { |
||||
|
targetElement = target(); |
||||
|
} else if ('current' in target) { |
||||
|
targetElement = target.current; |
||||
|
} else { |
||||
|
targetElement = target; |
||||
|
} |
||||
|
|
||||
|
return targetElement; |
||||
|
}; |
||||
|
|
||||
|
const useClickAway = ( |
||||
|
onClickAway: (event: EventType) => void, |
||||
|
target: BasicTarget | BasicTarget[], |
||||
|
eventName: string = defaultEvent, |
||||
|
): void => { |
||||
|
const onClickAwayRef = useRef(onClickAway); |
||||
|
onClickAwayRef.current = onClickAway; |
||||
|
useEffect(() => { |
||||
|
const handler = (event: any) => { |
||||
|
const targets = Array.isArray(target) ? target : [target]; |
||||
|
if ( |
||||
|
targets.some((targetItem) => { |
||||
|
const targetElement = getTargetElement(targetItem) as HTMLElement; |
||||
|
return !targetElement || targetElement?.contains(event.target); |
||||
|
}) |
||||
|
) { |
||||
|
return; |
||||
|
} |
||||
|
onClickAwayRef.current(event); |
||||
|
}; |
||||
|
document.addEventListener(eventName, handler); |
||||
|
|
||||
|
return () => { |
||||
|
document.removeEventListener(eventName, handler); |
||||
|
}; |
||||
|
}, [target, eventName]); |
||||
|
}; |
||||
|
|
||||
|
export default useClickAway; |
@ -0,0 +1,11 @@ |
|||||
|
.depsInfoModalContent { |
||||
|
padding-top: 20px; |
||||
|
} |
||||
|
|
||||
|
.modal { |
||||
|
:global { |
||||
|
.ant-modal-body { |
||||
|
padding: 0; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,129 @@ |
|||||
|
import React, { |
||||
|
useState, |
||||
|
useEffect |
||||
|
} from 'react'; |
||||
|
|
||||
|
import 'antd/es/modal/style'; |
||||
|
import styles from './index.module.less'; |
||||
|
|
||||
|
import { Graph } from '@antv/x6'; |
||||
|
import { Button, Modal, message } from 'antd'; |
||||
|
import JsonView from 'react-json-view'; |
||||
|
import { safeParse } from '../../../utils'; |
||||
|
import analyzeDeps from '../../../utils/analyzeDeps'; |
||||
|
import CodeEditor from '../../../components/codeEditor'; |
||||
|
|
||||
|
interface IProps { |
||||
|
title?: string; |
||||
|
flowChart: Graph; |
||||
|
} |
||||
|
|
||||
|
const CodeEditModal: React.FC<IProps> = (props) => { |
||||
|
|
||||
|
const { title = '编辑代码', flowChart } = props; |
||||
|
const [code, setCode] = useState<string>(''); |
||||
|
const [visible, setVisible] = useState<boolean>(false); |
||||
|
|
||||
|
const updateNodeCode = (code: string): void => { |
||||
|
|
||||
|
const cell = flowChart.getSelectedCells()[0]; |
||||
|
const { code: oldCode, dependencies } = cell.getData(); |
||||
|
if(code === oldCode) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
cell.setData({ code }); |
||||
|
message.success('代码保存成功', 1); |
||||
|
const excludeDeps = safeParse(dependencies); |
||||
|
analyzeDeps(code, Object.keys(excludeDeps)).then((deps): void => { |
||||
|
if (Object.keys(deps).length > 0) { |
||||
|
Modal.info({ |
||||
|
title: '检测到您的代码有新依赖,已为您自动更新', |
||||
|
content: ( |
||||
|
<div className={styles.depsInfoModalContent}> |
||||
|
<JsonView |
||||
|
src={deps} |
||||
|
name={'dependencies'} |
||||
|
collapsed={false} |
||||
|
enableClipboard={false} |
||||
|
displayDataTypes={false} |
||||
|
displayObjectSize={false} |
||||
|
/> |
||||
|
</div> |
||||
|
), |
||||
|
onOk() { |
||||
|
const newDeps = { ...excludeDeps, ...deps }; |
||||
|
cell.setData({ |
||||
|
code, |
||||
|
dependencies: JSON.stringify(newDeps, null, 2) |
||||
|
}); |
||||
|
// NOTE: notify basic panel to update dependency
|
||||
|
flowChart.trigger('settingBar.basicPanel:forceUpdate'); |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
// life
|
||||
|
useEffect(() => { |
||||
|
const handler = () => setVisible(true); |
||||
|
flowChart.on('graph:editCode', handler); |
||||
|
return () => { |
||||
|
flowChart.off('graph:editCode', handler); |
||||
|
}; |
||||
|
}, []); |
||||
|
useEffect(() => { |
||||
|
if (visible) { |
||||
|
const cell = flowChart.getSelectedCells()[0]; |
||||
|
const { code } = cell.getData() || {}; |
||||
|
setCode(code); |
||||
|
} else { |
||||
|
setCode(''); |
||||
|
} |
||||
|
}, [visible]); |
||||
|
|
||||
|
// events
|
||||
|
const onOk = (): void => { |
||||
|
setVisible(false); |
||||
|
updateNodeCode(code); |
||||
|
}; |
||||
|
const onCancel = (): void => { |
||||
|
setVisible(false); |
||||
|
}; |
||||
|
const onRunCode = (): void => { |
||||
|
updateNodeCode(code); |
||||
|
flowChart.trigger('graph:runCode'); |
||||
|
}; |
||||
|
const onChangeCode = (ev: any, newCode: string | undefined = ''): void => { |
||||
|
setCode(newCode); |
||||
|
}; |
||||
|
const onSaveCode = (newCode: string) => { |
||||
|
updateNodeCode(newCode); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Modal |
||||
|
className={styles.modal} |
||||
|
width={1000} |
||||
|
title={title} |
||||
|
visible={visible} |
||||
|
onCancel={onCancel} |
||||
|
footer={[ |
||||
|
<Button key={'cancel'} onClick={onCancel}>取消</Button>, |
||||
|
<Button key={'runCode'} type={'primary'} ghost onClick={onRunCode}>运行代码</Button>, |
||||
|
<Button key={'saveCode'} type={'primary'} onClick={onOk}>保存</Button>, |
||||
|
]} |
||||
|
> |
||||
|
<CodeEditor |
||||
|
value={code} |
||||
|
width={'100%'} |
||||
|
height={'600px'} |
||||
|
onChange={onChangeCode} |
||||
|
onSave={onSaveCode} |
||||
|
/> |
||||
|
</Modal> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default CodeEditModal; |
@ -0,0 +1,8 @@ |
|||||
|
.modal { |
||||
|
:global { |
||||
|
.ant-modal-body { |
||||
|
padding: 0; |
||||
|
height: 653px; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,50 @@ |
|||||
|
import React, { |
||||
|
useState, |
||||
|
useEffect, |
||||
|
useCallback |
||||
|
} from 'react'; |
||||
|
|
||||
|
import styles from './index.module.less'; |
||||
|
|
||||
|
import { Modal } from 'antd'; |
||||
|
import { Graph } from '@antv/x6'; |
||||
|
import CodeRun from '../../../components/codeRun'; |
||||
|
|
||||
|
interface IEditModalProps { |
||||
|
title?: string; |
||||
|
flowChart: Graph; |
||||
|
} |
||||
|
|
||||
|
const CodeRunModal: React.FC<IEditModalProps> = (props): JSX.Element => { |
||||
|
|
||||
|
const { title = '执行代码', flowChart } = props; |
||||
|
const [visible, setVisible] = useState(false); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const handler = () => setVisible(true); |
||||
|
flowChart.on('graph:runCode', handler); |
||||
|
return () => { |
||||
|
flowChart.off('graph:runCode', handler); |
||||
|
}; |
||||
|
}, [flowChart]); |
||||
|
|
||||
|
// events
|
||||
|
const onClose = useCallback((): void => { |
||||
|
setVisible(false); |
||||
|
}, []); |
||||
|
|
||||
|
return ( |
||||
|
<Modal |
||||
|
className={styles.modal} |
||||
|
width={1000} |
||||
|
title={title} |
||||
|
visible={visible} |
||||
|
footer={null} |
||||
|
onCancel={onClose} |
||||
|
> |
||||
|
<CodeRun flowChart={flowChart}/> |
||||
|
</Modal> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default CodeRunModal; |
@ -0,0 +1,115 @@ |
|||||
|
import React, { |
||||
|
useRef, |
||||
|
useCallback |
||||
|
} from 'react'; |
||||
|
|
||||
|
import styles from '../index.module.less'; |
||||
|
|
||||
|
import { Menu } from 'antd'; |
||||
|
import { Graph } from '@antv/x6'; |
||||
|
import useClickAway from '../../../hooks/useClickAway'; |
||||
|
import { nodeMenuConfig, blankMenuConfig } from './menuConfig'; |
||||
|
|
||||
|
interface IProps { |
||||
|
x: number; |
||||
|
y: number; |
||||
|
scene: string; |
||||
|
visible: boolean; |
||||
|
flowChart: Graph; |
||||
|
} |
||||
|
|
||||
|
interface IMenuConfig { |
||||
|
key: string; |
||||
|
title: string; |
||||
|
icon?: React.ReactElement; |
||||
|
children?: IMenuConfig[]; |
||||
|
showDividerBehind?: boolean; |
||||
|
disabled?: boolean | ((flowChart: Graph) => boolean); |
||||
|
handler: (flowChart: Graph) => void; |
||||
|
} |
||||
|
|
||||
|
const menuConfigMap: { [scene: string]: IMenuConfig[] } = { |
||||
|
node: nodeMenuConfig, |
||||
|
blank: blankMenuConfig |
||||
|
}; |
||||
|
|
||||
|
const FlowChartContextMenu: React.FC<IProps> = props => { |
||||
|
const menuRef = useRef(null); |
||||
|
const { x, y, scene, visible, flowChart } = props; |
||||
|
const menuConfig = menuConfigMap[scene]; |
||||
|
|
||||
|
useClickAway(() => onClickAway(), menuRef); |
||||
|
|
||||
|
const onClickAway = useCallback(() => flowChart.trigger('graph:hideContextMenu'), [flowChart]); |
||||
|
const onClickMenu = useCallback(({ key }) => { |
||||
|
const handlerMap = Helper.makeMenuHandlerMap(menuConfig); |
||||
|
const handler = handlerMap[key]; |
||||
|
if (handler) { |
||||
|
onClickAway(); |
||||
|
handler(flowChart); |
||||
|
} |
||||
|
}, [flowChart, menuConfig]); |
||||
|
|
||||
|
return !visible ? null : ( |
||||
|
<div |
||||
|
ref={menuRef} |
||||
|
className={styles.contextMenu} |
||||
|
style={{ left: x, top: y }} |
||||
|
> |
||||
|
<Menu |
||||
|
mode={'vertical'} |
||||
|
selectable={false} |
||||
|
onClick={onClickMenu} |
||||
|
> |
||||
|
{Helper.makeMenuContent(flowChart, menuConfig)} |
||||
|
</Menu> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const Helper = { |
||||
|
makeMenuHandlerMap(config: IMenuConfig[]) { |
||||
|
const queue = config.slice(0); |
||||
|
const handlerMap: { [key: string]: (flowChart: Graph) => void } = {}; |
||||
|
while (queue.length > 0) { |
||||
|
const { key, handler, children } = queue.pop() as IMenuConfig; |
||||
|
if (children && children.length > 0) { |
||||
|
queue.push(...children); |
||||
|
} else { |
||||
|
handlerMap[key] = handler; |
||||
|
} |
||||
|
} |
||||
|
return handlerMap; |
||||
|
}, |
||||
|
makeMenuContent(flowChart: Graph, menuConfig: IMenuConfig[]) { |
||||
|
const loop = (config: IMenuConfig[]) => { |
||||
|
return config.map(item => { |
||||
|
let content = null; |
||||
|
let { key, title, icon, children, disabled = false, showDividerBehind } = item; |
||||
|
if (typeof disabled === 'function') { |
||||
|
disabled = disabled(flowChart); |
||||
|
} |
||||
|
if (children && children.length > 0) { |
||||
|
content = ( |
||||
|
<Menu.SubMenu key={key} icon={icon} title={title} disabled={disabled}> |
||||
|
{loop(children)} |
||||
|
</Menu.SubMenu> |
||||
|
); |
||||
|
} else { |
||||
|
content = ( |
||||
|
<Menu.Item key={key} icon={icon} disabled={disabled}> |
||||
|
{title} |
||||
|
</Menu.Item> |
||||
|
); |
||||
|
} |
||||
|
return [ |
||||
|
content, |
||||
|
showDividerBehind && <Menu.Divider /> |
||||
|
]; |
||||
|
}); |
||||
|
}; |
||||
|
return loop(menuConfig); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default FlowChartContextMenu; |
@ -0,0 +1,24 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { Graph } from '@antv/x6'; |
||||
|
import XIcon from '../../../../components/xIcon'; |
||||
|
import shortcuts from '../../../../common/shortcuts'; |
||||
|
import { SnippetsOutlined } from '@ant-design/icons'; |
||||
|
|
||||
|
const blankMenuConfig = [ |
||||
|
{ |
||||
|
key: 'selectAll', |
||||
|
title: '全选', |
||||
|
icon: <XIcon type={'icon-select-all'} />, |
||||
|
handler: shortcuts.selectAll.handler |
||||
|
}, |
||||
|
{ |
||||
|
key: 'paste', |
||||
|
title: '粘贴', |
||||
|
icon: <SnippetsOutlined />, |
||||
|
disabled: (flowChart: Graph) => flowChart.isClipboardEmpty(), |
||||
|
handler: shortcuts.paste.handler |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
export default blankMenuConfig; |
@ -0,0 +1,7 @@ |
|||||
|
import nodeMenuConfig from './node'; |
||||
|
import blankMenuConfig from './blank'; |
||||
|
|
||||
|
export { |
||||
|
nodeMenuConfig, |
||||
|
blankMenuConfig |
||||
|
}; |
@ -0,0 +1,75 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { |
||||
|
CopyOutlined, |
||||
|
EditOutlined, |
||||
|
CodeOutlined, |
||||
|
FormOutlined, |
||||
|
DeleteOutlined, |
||||
|
} from '@ant-design/icons'; |
||||
|
|
||||
|
import { Graph } from '@antv/x6'; |
||||
|
import XIcon from '../../../../components/xIcon'; |
||||
|
import shortcuts from '../../../../common/shortcuts'; |
||||
|
import { getSelectedNodes } from '../../../../utils/flowChartUtils'; |
||||
|
|
||||
|
const nodeMenuConfig = [ |
||||
|
{ |
||||
|
key: 'copy', |
||||
|
title: '复制', |
||||
|
icon: <CopyOutlined />, |
||||
|
handler: shortcuts.copy.handler |
||||
|
}, |
||||
|
{ |
||||
|
key: 'delete', |
||||
|
title: '删除', |
||||
|
icon: <DeleteOutlined />, |
||||
|
handler: shortcuts.delete.handler |
||||
|
}, |
||||
|
{ |
||||
|
key: 'rename', |
||||
|
title: '编辑文本', |
||||
|
icon: <EditOutlined />, |
||||
|
showDividerBehind: true, |
||||
|
handler() { |
||||
|
// TODO
|
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
key: 'bringToTop', |
||||
|
title: '置于顶层', |
||||
|
icon: <XIcon type={'icon-bring-to-top'} />, |
||||
|
handler: shortcuts.bringToTop.handler |
||||
|
}, |
||||
|
{ |
||||
|
key: 'bringToBack', |
||||
|
title: '置于底层', |
||||
|
icon: <XIcon type={'icon-bring-to-bottom'} />, |
||||
|
showDividerBehind: true, |
||||
|
handler: shortcuts.bringToBack.handler |
||||
|
}, |
||||
|
{ |
||||
|
key: 'editCode', |
||||
|
title: '编辑代码', |
||||
|
icon: <FormOutlined />, |
||||
|
disabled(flowChart: Graph) { |
||||
|
return getSelectedNodes(flowChart).length !== 1; |
||||
|
}, |
||||
|
handler(flowChart: Graph) { |
||||
|
flowChart.trigger('graph:editCode'); |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
key: 'executeCode', |
||||
|
title: '执行代码', |
||||
|
icon: <CodeOutlined />, |
||||
|
disabled(flowChart: Graph) { |
||||
|
return getSelectedNodes(flowChart).length !== 1; |
||||
|
}, |
||||
|
handler(flowChart: Graph) { |
||||
|
flowChart.trigger('graph:runCode'); |
||||
|
} |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
export default nodeMenuConfig; |
@ -0,0 +1,279 @@ |
|||||
|
import React from 'react'; |
||||
|
import 'antd/es/modal/style'; |
||||
|
import './index.less' |
||||
|
import { Modal, Tabs } from 'antd'; |
||||
|
const { TabPane } = Tabs; |
||||
|
|
||||
|
interface IExportModalProps { |
||||
|
visible: boolean; |
||||
|
onClose: () => void; |
||||
|
} |
||||
|
|
||||
|
const renderCode = (code: any) => { |
||||
|
return <pre>{code}</pre> |
||||
|
} |
||||
|
|
||||
|
const QuickStart: React.FC = () => { |
||||
|
return <div> |
||||
|
<h2>启动项目</h2> |
||||
|
{renderCode(`$ git clone git@github.com:imgcook/imove.git\n
|
||||
|
$ cd imove\n |
||||
|
$ npm install\n |
||||
|
$ npm run example`)}
|
||||
|
|
||||
|
打开 <a href="http://localhost:8000/">http://localhost:8000/</a>
|
||||
|
<img width="100%" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610523659304-84fc5548-3203-48b6-9ec8-81f0b0228245.png" /> |
||||
|
|
||||
|
<h2>绘制流程图、编写代码</h2> |
||||
|
<p>根据你的业务逻辑绘制流程图。双击各节点,完成每个节点的函数编写。</p> |
||||
|
<img width="100%" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610529557793-63ef6842-079c-498a-8ad3-dfbdad7549bb.png" /> |
||||
|
<img width="100%" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610523745104-24e94b10-f8d7-4eb2-a282-ca32a19f7ccd.png" /> |
||||
|
|
||||
|
<h2>在项目中使用</h2> |
||||
|
<p>这时有两种方式:</p> |
||||
|
<ol> |
||||
|
<li>在线打包出码</li> |
||||
|
<li>本地启动开发模式</li> |
||||
|
</ol> |
||||
|
|
||||
|
<h3>1. 在线打包出码</h3> |
||||
|
<img width="100%" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610523805396-4211e9c2-b501-4b23-aeae-f55147d67e8d.png" /> |
||||
|
<p>点击页面右上方的导出按钮后,可以在弹窗内选择导出 代码,此时流程图编译后的代码将以 zip 包的形式下载到本地,你可以解压后再引入项目中使用。</p> |
||||
|
|
||||
|
<h3>2. 本地命令行出码</h3> |
||||
|
<p>安装 CLI</p> |
||||
|
<pre> |
||||
|
$ npm install -g @imove/cli |
||||
|
</pre> |
||||
|
<p>进入项目根目录,imove 初始化</p> |
||||
|
<pre> |
||||
|
$ cd yourProject |
||||
|
$ imove --init # 或 imove -i |
||||
|
</pre> |
||||
|
<p>本地启动开发模式</p> |
||||
|
<pre> |
||||
|
$ imove --dev # 或 imove -d |
||||
|
</pre> |
||||
|
|
||||
|
<p>本地启动成功之后,可以看到原来的页面右上角会显示连接成功。</p> |
||||
|
<img width="100%" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610529177065-5000bb28-10f2-4053-a4bb-af98139f488e.png" /> |
||||
|
|
||||
|
<p>此时页面上触发 <b>保存快捷键 Ctrl + S</b> 时,可以看到当前项目的 src 目录下会多出一个 <strong>logic</strong> 目录,这就是 imove 编译生成的代码,此时你只要在你的组件中调用它即可。</p> |
||||
|
|
||||
|
<b>如上代码所示,需要注意两点:</b> |
||||
|
<ol> |
||||
|
<li>通过 logic.on 方法监听事件,事件名和参数与流程图中节点代码的 ctx.emit 相对应</li> |
||||
|
<li>通过 logic.invoke 方法调用逻辑,事件名与流程图中的开始节点的 逻辑触发名称 相对应,不然会调用失败</li> |
||||
|
</ol> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
const CreateNode: React.FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h2>创建节点</h2> |
||||
|
<p>项目启动成功后,可以从左侧面板中依次拖动节点至中间的画布,从而完成流程图的绘制工作</p> |
||||
|
<img width="100%" alt="创建节点" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610528033956-df4c7cb8-2b35-4065-8f51-a8d98c02f532.png"></img> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const NodeType: React.FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h2>节点类型</h2> |
||||
|
<p>在 iMove 中,我们将流程图的节点一共分为以下 3 种类型:</p> |
||||
|
<ol> |
||||
|
<li>开始节点:逻辑起始,是所有流程的开始,可以是一次生命周期初始化/一次点击</li> |
||||
|
<li>行为节点:逻辑执行,可以是一次网络请求/一次改变状态/发送埋点等</li> |
||||
|
<li>分支节点:逻辑路由,根据不同的逻辑执行结果跳转到不同的节点</li> |
||||
|
</ol> |
||||
|
<b>(注:一条逻辑流程必须以 开始节点 为起始)</b> |
||||
|
<p>根据上述的规范描述,我们可以绘制出各种各样的逻辑流程图,例如<b>获取个人数据</b>的逻辑流程图如下所示:</p> |
||||
|
<img width="100%" alt="节点类型" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610528367101-6ff34826-8658-41e8-bb8b-66b254f14cde.png"></img> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const ConfigNode: React.FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h2>配置节点</h2> |
||||
|
<p>依次选中图中的节点,完成右边面板中的基础配置信息填写</p> |
||||
|
<img width="100%" alt="配置节点" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610528399738-19c54ce9-25f4-4fc0-93a8-580f9eb9f20f.png"></img> |
||||
|
<p>通常来说,基础信息配置使用频率最高的有:</p> |
||||
|
<ul> |
||||
|
<li>显示名称:更改节点名称</li> |
||||
|
<li>代码:编辑节点代码</li> |
||||
|
<li>逻辑触发名称:开始节点类型专属配置,项目代码使用时根据这个值触发逻辑调用</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const CodeStyle: React.FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h2>节点代码规范</h2> |
||||
|
<p>点击编辑按钮,可打开代码编辑器</p> |
||||
|
<img width="100%" alt="节点代码规范" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610528493678-c08c4d08-380e-44ca-8dfd-33adb5c1604f.png"></img> |
||||
|
<p>每个节点的代码等价于一个 js 文件,因此你不用担心全局变量的命名污染问题,甚至可以 import 现有的 npm 包,但最后必须 export 出一个函数。需要注意的是,由于 iMove 天生支持节点代码的异步调用,因此 export 出的函数默认是一个 promise。</p> |
||||
|
<p>就拿 <b>是否登录</b> 这个分支节点为例,我们来看下节点代码该如何编写:</p> |
||||
|
{renderCode(`export default async function() {\n
|
||||
|
return fetch('/api/isLogin')\n |
||||
|
.then(res => res.json())\n |
||||
|
.then(res => {\n |
||||
|
const {success, data: {isLogin} = {}} = res;\n |
||||
|
return success && isLogin;\n |
||||
|
}).catch(err => {\n |
||||
|
console.log('fetch /api/isLogin failed, the err is:', err);\n |
||||
|
return false;\n |
||||
|
});\n |
||||
|
}\n |
||||
|
`)}
|
||||
|
<p>注:由于该节点是<b>分支节点</b>,因此其 boolean 返回值决定了整个流程的走向</p> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const NodeConnect: React.FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h2>节点间数据通信</h2> |
||||
|
<p>在 iMove 中,数据是以流(pipe)的形式从前往后进行流动的,也就是说前一个节点的返回值会是下一个节点的输入。不过也有一个例外,由于<b>分支节点</b>的返回值会是 boolean 类型,因此它的下游节点拿到的输入必将是一个 boolean 类型值,从而造成数据流的中断。为此,我们进行了一定的改造,分支节点的作用只负责数据流的转发,就像一个开关一样,只决定数据流的走向,但不改变流向下游的数据流值。</p> |
||||
|
<p>因此,前文例子中的<b>请求profile接口</b>和<b>返回数据</b>两个节点会成为数据流的上下游关系。我们再来看下他们之间是如何进行数据通信的:</p> |
||||
|
{renderCode(`// 节点: 请求profile接口\n
|
||||
|
export default async function() {\n |
||||
|
return fetch('/api/profile')\n |
||||
|
.then(res => res.json())\n |
||||
|
.then(res => {\n |
||||
|
const {success, data} = res;\n |
||||
|
return {success, data};\n |
||||
|
}).catch(err => {\n |
||||
|
console.log('fetch /api/isLogin failed, the err is:', err);\n |
||||
|
return {success: false};\n |
||||
|
});\n |
||||
|
}\n |
||||
|
`)}
|
||||
|
{renderCode(`// 节点: 接口成功\n
|
||||
|
export default async function(ctx) {\n |
||||
|
// 获取上游数据\n
|
||||
|
const pipe = ctx.getPipe() || {};\n |
||||
|
return pipe.success;\n |
||||
|
}\n |
||||
|
`)}
|
||||
|
{renderCode(`// 节点: 返回数据\n
|
||||
|
const doSomething = (data) => {\n |
||||
|
// TODO: 数据加工处理\n
|
||||
|
return data;\n |
||||
|
};\n |
||||
|
export default async function(ctx) {\n |
||||
|
// 这里获取到的上游数据,不是"接口成功"这个分支节点的返回值,而是"请求profile接口"这个节点的返回值\n
|
||||
|
const pipe = ctx.getPipe() || {};\n |
||||
|
ctx.emit('updateUI', {profileData: doSomething(pipe.data)});\n |
||||
|
}\n |
||||
|
`)}
|
||||
|
如上代码所述,每个下游节点可以调用 <b>ctx.getPipe</b> 方法获取上游节点返回的数据流。另外,需要注意的是 <b>返回数据</b> 节点的最后一行代码 <b>ctx.emit('eventName', data)</b> 需要和你项目中的代码配合使用。 |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const HowToUse: React.FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h2>如何在项目中使用</h2> |
||||
|
<p>这时有两种方式:</p> |
||||
|
<ol> |
||||
|
<li>在线打包出码</li> |
||||
|
<li>本地启动开发模式</li> |
||||
|
</ol> |
||||
|
|
||||
|
<h3>1. 在线打包出码</h3> |
||||
|
<img width="100%" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610523805396-4211e9c2-b501-4b23-aeae-f55147d67e8d.png" /> |
||||
|
<p>点击页面右上方的导出按钮后,可以在弹窗内选择导出 代码,此时流程图编译后的代码将以 zip 包的形式下载到本地,你可以解压后再引入项目中使用。</p> |
||||
|
|
||||
|
<h3>2. 本地命令行出码</h3> |
||||
|
<p>安装 CLI</p> |
||||
|
<pre> |
||||
|
$ npm install -g @imove/cli |
||||
|
</pre> |
||||
|
<p>进入项目根目录,imove 初始化</p> |
||||
|
<pre> |
||||
|
$ cd yourProject |
||||
|
$ imove --init # 或 imove -i |
||||
|
</pre> |
||||
|
<p>本地启动开发模式</p> |
||||
|
<pre> |
||||
|
$ imove --dev # 或 imove -d |
||||
|
</pre> |
||||
|
|
||||
|
<p>本地启动成功之后,可以看到原来的页面右上角会显示连接成功。</p> |
||||
|
<img width="100%" src="https://intranetproxy.alipay.com/skylark/lark/0/2021/png/317407/1610529177065-5000bb28-10f2-4053-a4bb-af98139f488e.png" /> |
||||
|
|
||||
|
<p>此时页面上触发 <b>保存快捷键 Ctrl + S</b> 时,可以看到当前项目的 src 目录下会多出一个 <strong>logic</strong> 目录,这就是 imove 编译生成的代码,此时你只要在你的组件中调用它即可。</p> |
||||
|
|
||||
|
<b>如上代码所示,需要注意两点:</b> |
||||
|
<ol> |
||||
|
<li>通过 logic.on 方法监听事件,事件名和参数与流程图中节点代码的 ctx.emit 相对应</li> |
||||
|
<li>通过 logic.invoke 方法调用逻辑,事件名与流程图中的开始节点的 逻辑触发名称 相对应,不然会调用失败</li> |
||||
|
</ol> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const Document: React.FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h2>使用文档</h2> |
||||
|
<div><a target="blank" href="https://www.yuque.com/imove/docs">详细使用文档</a></div> |
||||
|
<div><a target="blank" href="https://github.com/imgcook/imove/blob/master/README.md">github README</a></div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const GuideModal: React.FC<IExportModalProps> = (props) => { |
||||
|
const { visible, onClose } = props; |
||||
|
|
||||
|
return ( |
||||
|
<Modal |
||||
|
width={1000} |
||||
|
visible={visible} |
||||
|
footer={null} |
||||
|
title={'帮助文档'} |
||||
|
onOk={onClose} |
||||
|
onCancel={onClose} |
||||
|
> |
||||
|
<div className="guide-container"> |
||||
|
<Tabs tabPosition='left'> |
||||
|
<TabPane className="tabPane" tab="简单上手" key="1"> |
||||
|
<QuickStart /> |
||||
|
</TabPane> |
||||
|
<TabPane className="tabPane" tab="创建节点" key="2"> |
||||
|
<CreateNode /> |
||||
|
</TabPane> |
||||
|
<TabPane className="tabPane" tab="节点类型" key="3"> |
||||
|
<NodeType /> |
||||
|
</TabPane> |
||||
|
<TabPane className="tabPane" tab="配置节点" key="4"> |
||||
|
<ConfigNode /> |
||||
|
</TabPane> |
||||
|
<TabPane className="tabPane" tab="节点代码规范" key="5"> |
||||
|
<CodeStyle /> |
||||
|
</TabPane> |
||||
|
<TabPane className="tabPane" tab="节点间数据通信" key="6"> |
||||
|
<NodeConnect /> |
||||
|
</TabPane> |
||||
|
<TabPane className="tabPane" tab="如何在项目中使用" key="7"> |
||||
|
<HowToUse /> |
||||
|
</TabPane> |
||||
|
<TabPane className="tabPane" tab="使用文档" key="8"> |
||||
|
<Document /> |
||||
|
</TabPane> |
||||
|
</Tabs> |
||||
|
|
||||
|
</div> |
||||
|
</Modal> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default GuideModal; |
@ -0,0 +1,22 @@ |
|||||
|
.guide-container{ |
||||
|
height:600px; |
||||
|
|
||||
|
.tabPane{ |
||||
|
height:600px; |
||||
|
overflow-y:auto; |
||||
|
|
||||
|
&::-webkit-scrollbar { |
||||
|
width: 0; |
||||
|
} |
||||
|
|
||||
|
img{ |
||||
|
margin:10px 0; |
||||
|
} |
||||
|
|
||||
|
pre{ |
||||
|
color:#c1811d; |
||||
|
font-size:12px; |
||||
|
line-height:13px; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
import React, { useState } from 'react'; |
||||
|
|
||||
|
import { Button } from 'antd'; |
||||
|
import GuideModal from './guideModal'; |
||||
|
|
||||
|
const Export: React.FC = props => { |
||||
|
|
||||
|
const [modalVisible, setModalVisible] = useState<boolean>(false); |
||||
|
|
||||
|
const onOpenModal = () => setModalVisible(true); |
||||
|
const onCloseModal = () => setModalVisible(false); |
||||
|
|
||||
|
return ( |
||||
|
<div> |
||||
|
<Button type={'primary'} size={'small'} onClick={onOpenModal}>帮助指引</Button> |
||||
|
<GuideModal |
||||
|
visible={modalVisible} |
||||
|
onClose={onCloseModal} |
||||
|
/> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default Export; |
@ -1,27 +0,0 @@ |
|||||
.container { |
|
||||
|
|
||||
margin-top: 20px; |
|
||||
|
|
||||
.titleText { |
|
||||
font-size: 14px; |
|
||||
margin-bottom: 10px; |
|
||||
} |
|
||||
|
|
||||
.btn { |
|
||||
margin-top: 5px; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
.editModal { |
|
||||
|
|
||||
:global { |
|
||||
.ant-modal-body { |
|
||||
padding: 0; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
.editor { |
|
||||
width: 100%; |
|
||||
height: 400; |
|
||||
} |
|
||||
} |
|
@ -1,112 +0,0 @@ |
|||||
import React, { useState, useEffect } from 'react'; |
|
||||
|
|
||||
import 'antd/es/modal/style'; |
|
||||
import 'antd/es/button/style'; |
|
||||
import styles from './index.module.less'; |
|
||||
|
|
||||
import { Graph } from '@antv/x6'; |
|
||||
import { Button, Modal } from 'antd'; |
|
||||
import CodeEditor from '../../../../components/codeEditor'; |
|
||||
|
|
||||
interface IProps { |
|
||||
value: any; |
|
||||
name: string; |
|
||||
title: string; |
|
||||
flowChart: Graph; |
|
||||
onValueChange: (value: string) => void; |
|
||||
} |
|
||||
|
|
||||
const Code: React.FC<IProps> = (props) => { |
|
||||
const { title, value, onValueChange, flowChart } = props; |
|
||||
const [visible, setVisible] = useState<boolean>(false); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
flowChart.on('node:dblclick', () => { |
|
||||
onClickEdit(); |
|
||||
}); |
|
||||
}, []); |
|
||||
|
|
||||
// events
|
|
||||
const onClickEdit = (): void => { |
|
||||
setVisible(true); |
|
||||
}; |
|
||||
const onClickCancel = (): void => { |
|
||||
setVisible(false); |
|
||||
}; |
|
||||
const onClickOk = (newJson: string): void => { |
|
||||
setVisible(false); |
|
||||
onValueChange(newJson); |
|
||||
}; |
|
||||
|
|
||||
return ( |
|
||||
<div className={styles.container}> |
|
||||
<p className={styles.titleText}>{title}</p> |
|
||||
<Button block={true} className={styles.btn} onClick={onClickEdit}> |
|
||||
编辑 |
|
||||
</Button> |
|
||||
<EditModal |
|
||||
title={title} |
|
||||
value={value} |
|
||||
visible={visible} |
|
||||
onOk={onClickOk} |
|
||||
onCancel={onClickCancel} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
interface IEditorModalProps { |
|
||||
visible: boolean; |
|
||||
title: string; |
|
||||
value: string; |
|
||||
onOk: (val: string) => void; |
|
||||
onCancel: () => void; |
|
||||
} |
|
||||
|
|
||||
const EditModal: React.FC<IEditorModalProps> = (props) => { |
|
||||
const { visible, title, value, onOk, onCancel } = props; |
|
||||
const [code, setCode] = useState<string>(value); |
|
||||
|
|
||||
// life
|
|
||||
useEffect(() => { |
|
||||
// set value when opening modal
|
|
||||
// clear content when closing modal
|
|
||||
if (visible) { |
|
||||
setCode(value); |
|
||||
} else { |
|
||||
setCode(''); |
|
||||
} |
|
||||
}, [visible]); |
|
||||
|
|
||||
// events
|
|
||||
const onClickOk = (): void => { |
|
||||
onOk(code); |
|
||||
}; |
|
||||
const onChangeCode = (ev: any, newCode: string | undefined = ''): void => { |
|
||||
if (newCode !== code) { |
|
||||
setCode(newCode); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
return ( |
|
||||
<Modal |
|
||||
className={styles.editModal} |
|
||||
width={800} |
|
||||
title={title} |
|
||||
okText={'保存'} |
|
||||
visible={visible} |
|
||||
cancelText={'取消'} |
|
||||
onOk={onClickOk} |
|
||||
onCancel={onCancel} |
|
||||
> |
|
||||
<CodeEditor |
|
||||
value={code} |
|
||||
width={'100%'} |
|
||||
height={'600px'} |
|
||||
onChange={onChangeCode} |
|
||||
/> |
|
||||
</Modal> |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
export default Code; |
|
@ -0,0 +1,7 @@ |
|||||
|
.ant-card-bordered{ |
||||
|
border:none; |
||||
|
} |
||||
|
.ant-card-head{ |
||||
|
border:none; |
||||
|
height:35px; |
||||
|
} |
@ -1,27 +1,33 @@ |
|||||
.container { |
.container { |
||||
|
|
||||
margin-top: 20px; |
margin-top: 20px; |
||||
|
|
||||
|
.header{ |
||||
|
display:flex; |
||||
|
flex-direction: row; |
||||
|
justify-content:space-between; |
||||
|
|
||||
.titleText { |
.titleText { |
||||
font-size: 14px; |
font-size: 14px; |
||||
margin-bottom: 10px; |
margin-bottom: 10px; |
||||
} |
} |
||||
|
|
||||
.btn { |
|
||||
margin-top: 5px; |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
.editModal { |
.editModal { |
||||
|
.tabPane{ |
||||
|
.form{ |
||||
|
height:600px; |
||||
|
overflow-y: auto; |
||||
|
margin:0 20px; |
||||
|
} |
||||
|
.configInput{ |
||||
|
height: 600px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
:global { |
:global { |
||||
.ant-modal-body { |
.ant-modal-body { |
||||
padding: 0; |
padding: 0; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
.editor { |
|
||||
width: 100%; |
|
||||
height: 400; |
|
||||
} |
|
||||
} |
} |
||||
|
@ -0,0 +1,133 @@ |
|||||
|
const compData = [ |
||||
|
{ |
||||
|
"text": 'Input', |
||||
|
"name": 'input', |
||||
|
"schema": { |
||||
|
"title": "Input", |
||||
|
"type": "string", |
||||
|
"description": "输入框" |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'Textarea', |
||||
|
"name": 'textarea', |
||||
|
"schema": { |
||||
|
"title": "Textarea", |
||||
|
"type": "string", |
||||
|
"format": "textarea", |
||||
|
"description": "文本编辑框" |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'Switch', |
||||
|
"name": 'allBoolean', |
||||
|
"schema": { |
||||
|
"title": "Switch", |
||||
|
"type": "boolean", |
||||
|
"ui:widget": "switch", |
||||
|
"description": "开关控制" |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'Select', |
||||
|
"name": 'select', |
||||
|
"schema": { |
||||
|
"title": "Select", |
||||
|
"type": "string", |
||||
|
"enum": [ |
||||
|
"a", |
||||
|
"b", |
||||
|
"c" |
||||
|
], |
||||
|
"enumNames": [ |
||||
|
"早", |
||||
|
"中", |
||||
|
"晚" |
||||
|
], |
||||
|
"description": "下拉单选" |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'MultiSelect', |
||||
|
"name": 'multiSelect', |
||||
|
"schema": { |
||||
|
"title": "MultiSelect", |
||||
|
"description": "下拉多选", |
||||
|
"type": "array", |
||||
|
"items": { |
||||
|
"type": "string" |
||||
|
}, |
||||
|
"enum": [ |
||||
|
"A", |
||||
|
"B", |
||||
|
"C", |
||||
|
"D" |
||||
|
], |
||||
|
"enumNames": [ |
||||
|
"1", |
||||
|
"2", |
||||
|
"3", |
||||
|
"4" |
||||
|
], |
||||
|
"ui:widget": "multiSelect" |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'Checkbox', |
||||
|
"name": 'checkbox', |
||||
|
"schema": { |
||||
|
"title": "Checkbox", |
||||
|
"description": "点击多选", |
||||
|
"type": "array", |
||||
|
"items": { |
||||
|
"type": "string" |
||||
|
}, |
||||
|
"enum": [ |
||||
|
"A", |
||||
|
"B", |
||||
|
"C", |
||||
|
"D" |
||||
|
], |
||||
|
"enumNames": [ |
||||
|
"1", |
||||
|
"2", |
||||
|
"3", |
||||
|
"4" |
||||
|
] |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'TimePicker', |
||||
|
"name": 'timeSelect', |
||||
|
"schema": { |
||||
|
"title": "TimePicker", |
||||
|
"type": "string", |
||||
|
"format": "time", |
||||
|
"description": "时间选择" |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'DatePicker', |
||||
|
"name": 'dateSelect', |
||||
|
"schema": { |
||||
|
"title": "DatePicker", |
||||
|
"type": "string", |
||||
|
"format": "date", |
||||
|
"description": "日期选择" |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
"text": 'DateRange', |
||||
|
"name": 'dateRangeSelect', |
||||
|
"schema": { |
||||
|
"title": "DateRange", |
||||
|
"type": "range", |
||||
|
"format": "date", |
||||
|
"description": "日期范围选择" |
||||
|
} |
||||
|
} |
||||
|
] |
||||
|
|
||||
|
export { |
||||
|
compData |
||||
|
} |
@ -1,5 +1,12 @@ |
|||||
.container { |
.container { |
||||
padding: 0 20px; |
height: 900px; |
||||
height: 100%; |
overflow-y: auto; |
||||
overflow: scroll; |
|
||||
|
&::-webkit-scrollbar { |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
.input{ |
||||
|
margin-top:20px; |
||||
|
} |
||||
} |
} |
||||
|
@ -1,23 +0,0 @@ |
|||||
.container { |
|
||||
|
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
padding: 0 20px; |
|
||||
height: 100%; |
|
||||
|
|
||||
.empty { |
|
||||
margin-top: 100px; |
|
||||
} |
|
||||
|
|
||||
.footContainer { |
|
||||
|
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: flex-end; |
|
||||
margin-top: 20px; |
|
||||
|
|
||||
.saveBtn { |
|
||||
margin-left: 20px; |
|
||||
} |
|
||||
} |
|
||||
} |
|
@ -1,123 +0,0 @@ |
|||||
import React, { useState, useEffect } from 'react'; |
|
||||
|
|
||||
import styles from './index.module.less'; |
|
||||
|
|
||||
import { Cell } from '@antv/x6'; |
|
||||
import { Button, Empty } from 'antd'; |
|
||||
import Input from '../../components/input'; |
|
||||
import Checkbox from '../../components/checkbox'; |
|
||||
import { safeParse } from '../../../../utils'; |
|
||||
|
|
||||
// FIXME
|
|
||||
// save original config in tempConfig when change to edit mode
|
|
||||
// and recover original config when click cancel button
|
|
||||
let tempConfig: any = null; |
|
||||
|
|
||||
enum Mode { |
|
||||
edit = 'edit', |
|
||||
readOnly = 'readOnly', |
|
||||
} |
|
||||
|
|
||||
interface IConfig { |
|
||||
[key: string]: any; |
|
||||
} |
|
||||
|
|
||||
interface IProps { |
|
||||
selectedCell: Cell; |
|
||||
} |
|
||||
|
|
||||
const Config: React.FC<IProps> = (props) => { |
|
||||
const { selectedCell } = props; |
|
||||
const [mode, setMode] = useState<Mode>(Mode.readOnly); |
|
||||
const [configData, setConfigData] = useState<IConfig>({}); |
|
||||
const [configSchema, setConfigSchema] = useState<IConfig>({}); |
|
||||
|
|
||||
// life
|
|
||||
useEffect(() => { |
|
||||
const { configSchema, configData = {} } = selectedCell.getData() || {}; |
|
||||
setConfigData(configData); |
|
||||
setConfigSchema(safeParse(configSchema)); |
|
||||
selectedCell.on('change:configSchema', (data: { configSchema: string }) => { |
|
||||
setConfigSchema(safeParse(data.configSchema)); |
|
||||
}); |
|
||||
return () => { |
|
||||
selectedCell.off('change:configSchema'); |
|
||||
}; |
|
||||
}, [selectedCell]); |
|
||||
|
|
||||
// events
|
|
||||
const onFieldValueChange = (key: string, value: any) => { |
|
||||
setConfigData(Object.assign({}, configData, { [key]: value })); |
|
||||
}; |
|
||||
const onClickEdit = (): void => { |
|
||||
setMode(Mode.edit); |
|
||||
tempConfig = configData; |
|
||||
}; |
|
||||
const onClickCancel = (): void => { |
|
||||
setMode(Mode.readOnly); |
|
||||
setConfigData(tempConfig); |
|
||||
}; |
|
||||
const onClickSave = (): void => { |
|
||||
setMode(Mode.readOnly); |
|
||||
selectedCell.setData({ configData }); |
|
||||
}; |
|
||||
|
|
||||
// no config schema
|
|
||||
if (!configSchema || Object.keys(configSchema).length === 0) { |
|
||||
return ( |
|
||||
<div className={styles.container}> |
|
||||
<Empty |
|
||||
className={styles.empty} |
|
||||
description={'请编辑投放配置schema'} |
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
} else { |
|
||||
return ( |
|
||||
<div className={styles.container}> |
|
||||
{Object.keys(configSchema).map((key, idx) => { |
|
||||
const { title, description, type } = configSchema[key]; |
|
||||
const FieldComponent = Helper.getFieldComponent(type); |
|
||||
return ( |
|
||||
<FieldComponent |
|
||||
key={idx} |
|
||||
name={key} |
|
||||
title={title} |
|
||||
value={configData[key]} |
|
||||
description={description} |
|
||||
disabled={mode === Mode.readOnly} |
|
||||
onValueChange={(value: any) => onFieldValueChange(key, value)} |
|
||||
/> |
|
||||
); |
|
||||
})} |
|
||||
{mode === Mode.readOnly ? ( |
|
||||
<div className={styles.footContainer}> |
|
||||
<Button block onClick={onClickEdit}> |
|
||||
编辑 |
|
||||
</Button> |
|
||||
</div> |
|
||||
) : ( |
|
||||
<div className={styles.footContainer}> |
|
||||
<Button onClick={onClickCancel}>取消</Button> |
|
||||
<Button className={styles.saveBtn} type={'primary'} onClick={onClickSave}> |
|
||||
保存 |
|
||||
</Button> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
const Helper = { |
|
||||
getFieldComponent(type: string) { |
|
||||
if (type === 'boolean') { |
|
||||
return Checkbox; |
|
||||
} else { |
|
||||
return Input; |
|
||||
} |
|
||||
}, |
|
||||
}; |
|
||||
|
|
||||
export default Config; |
|
@ -0,0 +1,63 @@ |
|||||
|
import React, { |
||||
|
useState, |
||||
|
useEffect, |
||||
|
useCallback |
||||
|
} from 'react'; |
||||
|
|
||||
|
import { Modal } from 'antd'; |
||||
|
import { Graph } from '@antv/x6'; |
||||
|
import CodeRun from '../../../../components/codeRun'; |
||||
|
|
||||
|
interface IProps { |
||||
|
flowChart: Graph; |
||||
|
selectedCell: any; |
||||
|
} |
||||
|
|
||||
|
const TestCase: React.FC<IProps> = (props) => { |
||||
|
const { flowChart } = props; |
||||
|
const [visible, setVisible] = useState(false); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
flowChart.on('settingBar:runCode', showModal); |
||||
|
return () => { |
||||
|
flowChart.off('settingBar:runCode', showModal); |
||||
|
}; |
||||
|
}, [flowChart]); |
||||
|
|
||||
|
const showModal = useCallback(() => setVisible(true), []); |
||||
|
const closeModal = useCallback(() => setVisible(false), []); |
||||
|
|
||||
|
return ( |
||||
|
<EditModal |
||||
|
visible={visible} |
||||
|
flowChart={flowChart} |
||||
|
onClose={closeModal} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
interface IEditModalProps { |
||||
|
visible: boolean; |
||||
|
flowChart: Graph; |
||||
|
onClose: () => void; |
||||
|
} |
||||
|
|
||||
|
const EditModal: React.FC<IEditModalProps> = (props): JSX.Element => { |
||||
|
const { visible, flowChart, onClose } = props; |
||||
|
|
||||
|
return ( |
||||
|
<Modal |
||||
|
width={1000} |
||||
|
centered={true} |
||||
|
bodyStyle={{ padding: 0, height: 650 }} |
||||
|
title={'执行代码'} |
||||
|
visible={visible} |
||||
|
footer={null} |
||||
|
onCancel={onClose} |
||||
|
> |
||||
|
<CodeRun flowChart={flowChart}/> |
||||
|
</Modal> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default TestCase; |
@ -0,0 +1,49 @@ |
|||||
|
import axios from 'axios'; |
||||
|
import { safeGet } from './index'; |
||||
|
|
||||
|
const regex = /import\s([\s\S]*?)\sfrom\s(?:('[@\.\/\-\w]+')|("[@\.\/\-\w]+"))/mg; |
||||
|
|
||||
|
const extractPkgs = (code: string, excludePkgs?: string[]): string[] => { |
||||
|
let matchRet = null; |
||||
|
const pkgNames: string[] = []; |
||||
|
while ((matchRet = regex.exec(code)) != null) { |
||||
|
let pkgName = (matchRet[2] || matchRet[3]); |
||||
|
pkgName = pkgName.slice(1, pkgName.length - 1); |
||||
|
// NOTE: ignore relative path (./ and ../) and excludePkgs
|
||||
|
if ( |
||||
|
pkgName.indexOf('./') === -1 && |
||||
|
pkgName.indexOf('../') === -1 && |
||||
|
excludePkgs?.indexOf(pkgName) === -1 |
||||
|
) { |
||||
|
pkgNames.push(pkgName); |
||||
|
} |
||||
|
} |
||||
|
return pkgNames; |
||||
|
}; |
||||
|
|
||||
|
const getPkgLatestVersion = (pkg: string): Promise<string[]> => { |
||||
|
return axios.get(`https://registry.npm.taobao.org/${pkg}`) |
||||
|
.then(res => { |
||||
|
return [pkg, safeGet(res, 'data.dist-tags.latest', '*')]; |
||||
|
}).catch(err => { |
||||
|
console.log(`get package ${pkg} info failed, the error is:`, err); |
||||
|
return [pkg, '*']; |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const analyzeDeps = (code: string, excludePkgs?: string[]): Promise<{ [pkgName: string]: string }> => { |
||||
|
const pkgs = extractPkgs(code, excludePkgs); |
||||
|
return Promise |
||||
|
.all(pkgs.map(pkg => getPkgLatestVersion(pkg))) |
||||
|
.then(pkgResults => { |
||||
|
const map: any = {}; |
||||
|
pkgResults.forEach(([pkg, version]) => { |
||||
|
map[pkg] = version; |
||||
|
}); |
||||
|
return map; |
||||
|
}).catch(err => { |
||||
|
console.log('analyze deps failed, the error is:', err); |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
export default analyzeDeps; |
Loading…
Reference in new issue