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 { |
|||
|
|||
margin-top: 20px; |
|||
|
|||
.titleText { |
|||
font-size: 14px; |
|||
margin-bottom: 10px; |
|||
} |
|||
|
|||
.btn { |
|||
margin-top: 5px; |
|||
.header{ |
|||
display:flex; |
|||
flex-direction: row; |
|||
justify-content:space-between; |
|||
|
|||
.titleText { |
|||
font-size: 14px; |
|||
margin-bottom: 10px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.editModal { |
|||
.tabPane{ |
|||
.form{ |
|||
height:600px; |
|||
overflow-y: auto; |
|||
margin:0 20px; |
|||
} |
|||
.configInput{ |
|||
height: 600px; |
|||
} |
|||
} |
|||
|
|||
:global { |
|||
.ant-modal-body { |
|||
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 { |
|||
padding: 0 20px; |
|||
height: 100%; |
|||
overflow: scroll; |
|||
height: 900px; |
|||
overflow-y: auto; |
|||
|
|||
&::-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