init pipeline
|
@ -0,0 +1,132 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
|
* Released under the AGPL-3.0 License.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import NodeConfig from './NodeConfig';
|
||||||
|
import Node from './Node';
|
||||||
|
import { transfer } from './utils';
|
||||||
|
import S from './store';
|
||||||
|
import lds from 'lodash';
|
||||||
|
import css from './editor.module.less';
|
||||||
|
|
||||||
|
function Editor(props) {
|
||||||
|
const [record, setRecord] = useState({})
|
||||||
|
const [nodes, setNodes] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = transfer(record.pipeline || [])
|
||||||
|
setNodes(data)
|
||||||
|
}, [record])
|
||||||
|
|
||||||
|
function handleAction({key, domEvent}) {
|
||||||
|
domEvent.stopPropagation()
|
||||||
|
switch (key) {
|
||||||
|
case 'upstream':
|
||||||
|
return handleAddUpstream()
|
||||||
|
case 'downstream':
|
||||||
|
return handleAddDownstream()
|
||||||
|
case 'delete':
|
||||||
|
return handleDelNode()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findIndexAndUpNode() {
|
||||||
|
let index
|
||||||
|
let [upNode, streamIdx] = [null, null]
|
||||||
|
const id = S.actionNode.id
|
||||||
|
for (let idx in record.pipeline) {
|
||||||
|
const node = record.pipeline[idx]
|
||||||
|
if (node.id === id) {
|
||||||
|
index = Number(idx)
|
||||||
|
}
|
||||||
|
idx = lds.findIndex(node.downstream, {id})
|
||||||
|
if (idx >= 0) {
|
||||||
|
upNode = node
|
||||||
|
streamIdx = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [index, upNode, streamIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddUpstream() {
|
||||||
|
const oldID = S.actionNode.id
|
||||||
|
const newID = new Date().getTime()
|
||||||
|
const [index, upNode, streamIdx] = _findIndexAndUpNode()
|
||||||
|
if (upNode) upNode.downstream.splice(streamIdx, 1, {id: newID})
|
||||||
|
record.pipeline.splice(index, 0, {id: newID, downstream: [{id: oldID}]})
|
||||||
|
setRecord(Object.assign({}, record))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddDownstream(e) {
|
||||||
|
if (e) e.stopPropagation()
|
||||||
|
const oldID = S.actionNode.id
|
||||||
|
const newNode = {id: new Date().getTime()}
|
||||||
|
if (record.pipeline) {
|
||||||
|
const idx = lds.findIndex(record.pipeline, {id: oldID})
|
||||||
|
if (record.pipeline[idx].downstream) {
|
||||||
|
record.pipeline[idx].downstream.push(newNode)
|
||||||
|
} else {
|
||||||
|
record.pipeline[idx].downstream = [newNode]
|
||||||
|
}
|
||||||
|
record.pipeline.splice(idx + 1, 0, newNode)
|
||||||
|
} else {
|
||||||
|
record.pipeline = [newNode]
|
||||||
|
}
|
||||||
|
setRecord(Object.assign({}, record))
|
||||||
|
S.node = newNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelNode() {
|
||||||
|
const {downstream} = S.actionNode
|
||||||
|
const [index, upNode, streamIdx] = _findIndexAndUpNode()
|
||||||
|
if (index === 0 && downstream && downstream.length > 1) {
|
||||||
|
return message.error('该节点为起始节点且有多个下游节点无法删除')
|
||||||
|
}
|
||||||
|
if (upNode) {
|
||||||
|
upNode.downstream.splice(streamIdx, 1)
|
||||||
|
if (downstream) {
|
||||||
|
for (let item of downstream.slice().reverse()) {
|
||||||
|
upNode.downstream.splice(streamIdx, 0, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.pipeline.splice(index, 1)
|
||||||
|
setRecord(Object.assign({}, record))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh(node) {
|
||||||
|
const index = lds.findIndex(record.pipeline, {id: node.id})
|
||||||
|
record.pipeline.splice(index, 1, node)
|
||||||
|
setRecord(Object.assign({}, record))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.container} onClick={() => S.node = {}}>
|
||||||
|
{nodes.map((row, idx) => (
|
||||||
|
<div key={idx} className={css.row}>
|
||||||
|
{row.map((item, idx) => (
|
||||||
|
<Node key={idx} node={item} onAction={handleAction}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<div className={css.item} onClick={handleAddDownstream}>
|
||||||
|
<div className={css.add}>
|
||||||
|
<PlusOutlined className={css.icon}/>
|
||||||
|
</div>
|
||||||
|
<div className={css.title} style={{color: '#999999'}}>点击添加节点</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NodeConfig doRefresh={handleRefresh}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Editor)
|
|
@ -0,0 +1,35 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Avatar } from 'antd';
|
||||||
|
import iconRemoteExec from './assets/icon_remote_exec.png';
|
||||||
|
import iconBuild from './assets/icon_build.png';
|
||||||
|
import iconParameter from './assets/icon_parameter.png';
|
||||||
|
import iconDataTransfer from './assets/icon_data_transfer.png';
|
||||||
|
import iconDataUpload from './assets/icon_data_upload.png';
|
||||||
|
import iconPushSpug from './assets/icon_push_spug.png';
|
||||||
|
import iconPushDD from './assets/icon_push_dd.png';
|
||||||
|
import iconSelect from './assets/icon_select.png';
|
||||||
|
|
||||||
|
function Icon(props) {
|
||||||
|
switch (props.module) {
|
||||||
|
case 'remote_exec':
|
||||||
|
return <Avatar size={props.size || 42} src={iconRemoteExec}/>
|
||||||
|
case 'build':
|
||||||
|
return <Avatar size={props.size || 42} src={iconBuild}/>
|
||||||
|
case 'parameter':
|
||||||
|
return <Avatar size={props.size || 42} src={iconParameter}/>
|
||||||
|
case 'data_transfer':
|
||||||
|
return <Avatar size={props.size || 42} src={iconDataTransfer}/>
|
||||||
|
case 'data_upload':
|
||||||
|
return <Avatar size={props.size || 42} src={iconDataUpload}/>
|
||||||
|
case 'push_spug':
|
||||||
|
return <Avatar size={props.size || 42} src={iconPushSpug}/>
|
||||||
|
case 'push_dd':
|
||||||
|
return <Avatar size={props.size || 42} src={iconPushDD}/>
|
||||||
|
case undefined:
|
||||||
|
return <Avatar size={props.size || 42} src={iconSelect}/>
|
||||||
|
default:
|
||||||
|
return <Avatar size={props.size || 42}/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Icon
|
|
@ -0,0 +1,76 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { Dropdown } from 'antd';
|
||||||
|
import { MoreOutlined } from '@ant-design/icons';
|
||||||
|
import Icon from './Icon';
|
||||||
|
import { clsNames } from 'libs';
|
||||||
|
import css from './node.module.less';
|
||||||
|
import S from './store';
|
||||||
|
|
||||||
|
function Node(props) {
|
||||||
|
function handleNodeClick(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
S.node = props.node
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionClick(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
S.actionNode = props.node
|
||||||
|
}
|
||||||
|
|
||||||
|
const menus = [
|
||||||
|
{
|
||||||
|
key: 'upstream',
|
||||||
|
label: '添加上游节点',
|
||||||
|
onClick: props.onAction
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'downstream',
|
||||||
|
label: '添加下游节点',
|
||||||
|
onClick: props.onAction
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
danger: true,
|
||||||
|
label: '删除此节点',
|
||||||
|
onClick: props.onAction
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const node = props.node
|
||||||
|
switch (node) {
|
||||||
|
case ' ':
|
||||||
|
return <div className={css.box}/>
|
||||||
|
case ' -':
|
||||||
|
return <div className={clsNames(css.box, css.line)}/>
|
||||||
|
case '--':
|
||||||
|
return <div className={clsNames(css.box, css.line, css.line2)}/>
|
||||||
|
case ' 7':
|
||||||
|
return <div className={clsNames(css.box, css.angle)}/>
|
||||||
|
case '-7':
|
||||||
|
return <div className={clsNames(css.box, css.angle, css.angle2)}/>
|
||||||
|
case ' |':
|
||||||
|
return (
|
||||||
|
<div className={clsNames(css.box, css.arrow)}>
|
||||||
|
<div className={css.triangle}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className={clsNames(css.box, css.node, S.node.id === node.id && css.active)}
|
||||||
|
onClick={handleNodeClick}>
|
||||||
|
<Icon module={node.module}/>
|
||||||
|
{node.name ? (
|
||||||
|
<div className={css.title}>{node.name}</div>
|
||||||
|
) : (
|
||||||
|
<div className={css.title} style={{color: '#595959'}}>请选择节点</div>
|
||||||
|
)}
|
||||||
|
<Dropdown className={css.action} trigger="click" menu={{items: menus}} onClick={handleActionClick}>
|
||||||
|
<MoreOutlined/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Node)
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
|
* Released under the AGPL-3.0 License.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { Drawer, Form, Radio, Button, Input, message } from 'antd';
|
||||||
|
import { AppstoreOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
import Icon from './Icon';
|
||||||
|
import { ACEditor } from 'components';
|
||||||
|
import HostSelector from 'pages/host/Selector';
|
||||||
|
import { clsNames } from 'libs';
|
||||||
|
import S from './store';
|
||||||
|
import css from './nodeConfig.module.less';
|
||||||
|
import { NODES } from './data'
|
||||||
|
|
||||||
|
function NodeConfig(props) {
|
||||||
|
const [tab, setTab] = useState('node')
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTab(S.node.module ? 'conf' : 'node')
|
||||||
|
form.setFieldsValue(S.node)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [S.node])
|
||||||
|
|
||||||
|
function handleNode({module, name}) {
|
||||||
|
S.node.module = module
|
||||||
|
if (!S.node.name) S.node.name = name
|
||||||
|
setTab('conf')
|
||||||
|
S.node = {...S.node}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
message.success('保存成功')
|
||||||
|
const data = form.getFieldsValue()
|
||||||
|
Object.assign(S.node, data)
|
||||||
|
props.doRefresh(S.node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = !!S.node.id
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={visible}
|
||||||
|
width={500}
|
||||||
|
mask={false}
|
||||||
|
closable={false}
|
||||||
|
getContainer={false}
|
||||||
|
bodyStyle={{padding: 0, position: 'relative'}}>
|
||||||
|
<div className={css.container} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={css.header}>
|
||||||
|
<div className={clsNames(css.item, tab === 'node' && css.active)} onClick={() => setTab('node')}>
|
||||||
|
<AppstoreOutlined/>
|
||||||
|
<span>选择节点</span>
|
||||||
|
</div>
|
||||||
|
<div className={clsNames(css.item, tab === 'conf' && css.active)} onClick={() => setTab('conf')}>
|
||||||
|
<SettingOutlined/>
|
||||||
|
<span>节点配置</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{marginTop: 72, display: tab === 'node' ? 'block' : 'none'}}>
|
||||||
|
<div className={css.category}>内置节点</div>
|
||||||
|
<div className={css.items}>
|
||||||
|
{NODES.map(item => (
|
||||||
|
<div key={item.module} className={clsNames(css.item, S.node?.module === item.module && css.active)}
|
||||||
|
onClick={() => handleNode(item)}>
|
||||||
|
<Icon size={36} module={item.module}/>
|
||||||
|
<div className={css.title}>{item.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.body} style={{display: tab === 'conf' ? 'block' : 'none'}}>
|
||||||
|
<Form layout="vertical" form={form}>
|
||||||
|
<Form.Item required name="name" label="节点名称">
|
||||||
|
<Input placeholder="请输入节点名称"/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item required name="targets" label="选择主机">
|
||||||
|
<HostSelector type="button"/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item required name="interpreter" label="执行解释器">
|
||||||
|
<Radio.Group buttonStyle="solid">
|
||||||
|
<Radio.Button value="sh">Shell</Radio.Button>
|
||||||
|
<Radio.Button value="python">Python</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item required label="执行内容" shouldUpdate={(p, c) => p.interpreter !== c.interpreter}>
|
||||||
|
{({getFieldValue}) => (
|
||||||
|
<Form.Item name="command" noStyle>
|
||||||
|
<ACEditor
|
||||||
|
mode={getFieldValue('interpreter')}
|
||||||
|
onChange={val => console.log(val)}
|
||||||
|
width="464px"
|
||||||
|
height="220px"/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<div className={css.footer} style={{display: tab === 'conf' ? 'block' : 'none'}}>
|
||||||
|
<Button type="primary" onClick={handleSave}>保存</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(NodeConfig)
|
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 1.4 KiB |
|
@ -0,0 +1,51 @@
|
||||||
|
export const NODES = [
|
||||||
|
{module: 'remote_exec', name: '执行命令'},
|
||||||
|
{module: 'build', name: '构建'},
|
||||||
|
{module: 'parameter', name: '参数化'},
|
||||||
|
{module: 'data_transfer', name: '数据传输'},
|
||||||
|
{module: 'data_upload', name: '数据上传'},
|
||||||
|
{module: 'push_dd', name: '钉钉推送'},
|
||||||
|
{module: 'push_spug', name: '推送助手'},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DATAS = {
|
||||||
|
'name': 'test',
|
||||||
|
'pipeline': [
|
||||||
|
{
|
||||||
|
'module': 'build',
|
||||||
|
'name': '构建',
|
||||||
|
'id': 0,
|
||||||
|
'repository': 1,
|
||||||
|
'target': 2,
|
||||||
|
'workspace': '/data/spug',
|
||||||
|
'command': 'mvn build',
|
||||||
|
'downstream': [
|
||||||
|
{'id': 1, 'state': 'success'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'module': 'remote_exec',
|
||||||
|
'name': '执行命令',
|
||||||
|
'id': 1,
|
||||||
|
'targets': [2, 3],
|
||||||
|
'interpreter': 'sh',
|
||||||
|
'command': 'date && sleep 3',
|
||||||
|
'downstream': [
|
||||||
|
{'id': 2, 'state': 'success'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'module': 'data_transfer',
|
||||||
|
'name': '数据传输',
|
||||||
|
'id': 2,
|
||||||
|
'source': {
|
||||||
|
'target': 1,
|
||||||
|
'path': '/data/spug'
|
||||||
|
},
|
||||||
|
'dest': {
|
||||||
|
'targets': [2, 3],
|
||||||
|
'path': '/data/dist'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: calc(100vh - 127px);
|
||||||
|
margin: -12px -12px 0 -12px;
|
||||||
|
padding: 12px 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: 100px solid transparent;
|
||||||
|
border-top-color: #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
width: 240px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 0 15px #9999994c;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
width: 164px;
|
||||||
|
margin-left: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 2px dashed #dddddd;
|
||||||
|
background: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
|
* Released under the AGPL-3.0 License.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { } from 'antd';
|
||||||
|
import { AuthDiv, Breadcrumb } from 'components';
|
||||||
|
import Editor from './Editor';
|
||||||
|
|
||||||
|
export default observer(function () {
|
||||||
|
return (
|
||||||
|
<AuthDiv auth="system.account.view">
|
||||||
|
<Breadcrumb>
|
||||||
|
<Breadcrumb.Item>首页</Breadcrumb.Item>
|
||||||
|
<Breadcrumb.Item>流水线</Breadcrumb.Item>
|
||||||
|
</Breadcrumb>
|
||||||
|
<Editor/>
|
||||||
|
</AuthDiv>
|
||||||
|
)
|
||||||
|
})
|
|
@ -0,0 +1,115 @@
|
||||||
|
.box {
|
||||||
|
position: relative;
|
||||||
|
width: 240px;
|
||||||
|
height: 80px;
|
||||||
|
margin-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8px;
|
||||||
|
left: 112px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-top-color: #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
box-shadow: 0 0 15px #9999994c;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 6px #2563fcbb;
|
||||||
|
.action {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-left: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: none;
|
||||||
|
padding: 12px;
|
||||||
|
margin-right: -12px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border-color: #4d8ffd;
|
||||||
|
box-shadow: 0 0 6px #2563fcbb;
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
height: 2px;
|
||||||
|
background: #999999;
|
||||||
|
position: absolute;
|
||||||
|
top: 39px;
|
||||||
|
left: -24px;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line2 {
|
||||||
|
&:after {
|
||||||
|
left: -144px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.angle {
|
||||||
|
&:before {
|
||||||
|
content: ' ';
|
||||||
|
position: absolute;
|
||||||
|
background: #999999;
|
||||||
|
top: 39px;
|
||||||
|
bottom: 39px;
|
||||||
|
left: -24px;
|
||||||
|
right: 119px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
position: absolute;
|
||||||
|
background: #999999;
|
||||||
|
top: 40px;
|
||||||
|
left: 119px;
|
||||||
|
right: 119px;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.angle2 {
|
||||||
|
&:before {
|
||||||
|
left: -144px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
background: #999999;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 119px;
|
||||||
|
right: 119px;
|
||||||
|
bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
.container {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 16px 0 16px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
width: 100px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #2563fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2563fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 24px 18px;
|
||||||
|
overflow: auto;
|
||||||
|
position: absolute;
|
||||||
|
top: 48px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 48px;
|
||||||
|
border-top: 1px solid #dfdfdf;
|
||||||
|
padding: 8px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 6px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
width: 150px;
|
||||||
|
height: 50px;
|
||||||
|
box-shadow: 0 0 3px #9999994c;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
margin: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border-color: #4d8ffd;
|
||||||
|
background: #f5faff;
|
||||||
|
color: #2563fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
|
* Released under the AGPL-3.0 License.
|
||||||
|
*/
|
||||||
|
import { observable } from 'mobx';
|
||||||
|
|
||||||
|
class Store {
|
||||||
|
@observable nodes = [];
|
||||||
|
@observable node = {};
|
||||||
|
@observable actionNode = {};
|
||||||
|
@observable isFetching = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Store()
|
|
@ -0,0 +1,92 @@
|
||||||
|
let response = []
|
||||||
|
let nodes = {}
|
||||||
|
let layer = 0
|
||||||
|
|
||||||
|
function loop(keys) {
|
||||||
|
const tmp = []
|
||||||
|
let downKeys = []
|
||||||
|
for (let key of keys) {
|
||||||
|
const node = nodes[key]
|
||||||
|
tmp.push(node.id)
|
||||||
|
for (let item of node.downstream || []) {
|
||||||
|
downKeys.push(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response[layer] = tmp
|
||||||
|
layer += 1
|
||||||
|
if (downKeys.length) {
|
||||||
|
loop(downKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transfer(data) {
|
||||||
|
if (data.length === 0) return []
|
||||||
|
response = []
|
||||||
|
nodes = {}
|
||||||
|
layer = 0
|
||||||
|
for (let item of data) {
|
||||||
|
nodes[item.id] = item
|
||||||
|
}
|
||||||
|
loop([data[0].id])
|
||||||
|
|
||||||
|
let idx = response.length - 2
|
||||||
|
while (idx >= 0) {
|
||||||
|
let cIdx = 0
|
||||||
|
const currentRow = response[idx]
|
||||||
|
while (cIdx < currentRow.length) {
|
||||||
|
const node = nodes[currentRow[cIdx]]
|
||||||
|
if (node.downstream) {
|
||||||
|
const downRow = response[idx + 1]
|
||||||
|
for (let item of node.downstream) {
|
||||||
|
const sKey = item.id
|
||||||
|
let dIdx = downRow.indexOf(sKey)
|
||||||
|
while (dIdx < cIdx) { // 下级在左侧,则在下级前补空
|
||||||
|
let tIdx = idx + 1
|
||||||
|
while (tIdx < response.length) { // 下下级对应位置也要补空
|
||||||
|
response[tIdx].splice(dIdx, 0, ' ')
|
||||||
|
tIdx += 1
|
||||||
|
}
|
||||||
|
dIdx += 1
|
||||||
|
}
|
||||||
|
if (dIdx === cIdx) continue;
|
||||||
|
while (dIdx > cIdx + 1) { // 下级在右侧跨列,则当前级补-
|
||||||
|
const flag = [' 7', '-7'].includes(currentRow[cIdx]) ? '--' : ' -'
|
||||||
|
cIdx += 1
|
||||||
|
currentRow.splice(cIdx, 0, flag)
|
||||||
|
}
|
||||||
|
if ([' 7', '-7'].includes(currentRow[cIdx])) {
|
||||||
|
currentRow.splice(cIdx + 1, 0, '-7')
|
||||||
|
} else {
|
||||||
|
currentRow.splice(cIdx + 1, 0, ' 7')
|
||||||
|
}
|
||||||
|
cIdx += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cIdx += 1
|
||||||
|
}
|
||||||
|
idx -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let row of response) {
|
||||||
|
for (let idx in row) {
|
||||||
|
const key = row[idx]
|
||||||
|
row[idx] = nodes[key] || key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = 1
|
||||||
|
while (idx < response.length) {
|
||||||
|
const nRow = []
|
||||||
|
for (let item of response[idx]) {
|
||||||
|
if (item.id) {
|
||||||
|
nRow.push(' |')
|
||||||
|
} else {
|
||||||
|
nRow.push(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.splice(idx, 0, nRow)
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|