linyaostalker
5 years ago
6 changed files with 609 additions and 0 deletions
-
30backend/web/src/custom-menu/AddItem.js
-
124backend/web/src/custom-menu/Editor.js
-
176backend/web/src/custom-menu/MenuItem.js
-
247backend/web/src/custom-menu/index.js
-
7backend/web/src/custom-menu/index.scss
-
25backend/web/src/custom-menu/utils.js
@ -0,0 +1,30 @@ |
|||
import React from 'react' |
|||
import {Icon} from "antd"; |
|||
import styled from "styled-components"; |
|||
|
|||
export default function AddItem({ subMenu = false, onClick }) { |
|||
return ( |
|||
<AddItemRoot className={subMenu ? 'sub-menu' : ''} onClick={onClick}> |
|||
<Icon type='plus' /> |
|||
</AddItemRoot> |
|||
) |
|||
} |
|||
|
|||
const AddItemRoot = styled.div`
|
|||
flex: 1; |
|||
display: inline-flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
width: 100%; |
|||
cursor: default; |
|||
&:active { |
|||
background: #eee; |
|||
} |
|||
&.sub-menu { |
|||
height: 40px; |
|||
border: none; |
|||
&:not(:last-child) { |
|||
border-bottom: 1px solid #eee; |
|||
} |
|||
} |
|||
`;
|
@ -0,0 +1,124 @@ |
|||
import React, { createContext, useContext } from 'react' |
|||
import { Form, Input, Radio, Select } from "antd"; |
|||
import styled from "styled-components"; |
|||
import { produce } from "immer"; |
|||
|
|||
const FormItem = Form.Item; |
|||
const RadioGroup = Radio.Group; |
|||
const Option = Select.Option; |
|||
|
|||
const { customPageList } = window; |
|||
|
|||
const MenuContext = createContext(); |
|||
const MenuContextProvider = MenuContext.Provider; |
|||
|
|||
function InputItem({ label, name }) { |
|||
const { activeMenu, formItemLayout, onChange } = useContext(MenuContext); |
|||
const rawValue = activeMenu.content.value; |
|||
const value = name ? (rawValue ? rawValue[name] : '') : rawValue; |
|||
|
|||
function handleChange(e) { |
|||
const newValue = e.target.value; |
|||
onChange(name ? { |
|||
...rawValue, |
|||
[name]: newValue |
|||
} : newValue); |
|||
} |
|||
|
|||
return ( |
|||
<FormItem label={label} {...formItemLayout}> |
|||
<Input |
|||
value={value} |
|||
placeholder={'请输入' + label} |
|||
onChange={handleChange} |
|||
/> |
|||
</FormItem> |
|||
) |
|||
} |
|||
|
|||
export default function Editor({ activeMenu, onChange }) { |
|||
function changeType(e) { |
|||
onChange('content', produce(activeMenu.content, draft => { |
|||
draft.type = e.target.value; |
|||
draft.value = null; |
|||
})) |
|||
} |
|||
|
|||
function changeValue(newValue) { |
|||
onChange('content', { |
|||
type: activeMenu.content.type, |
|||
value: newValue |
|||
}) |
|||
} |
|||
|
|||
const formItemLayout = { |
|||
labelCol: {span: 10}, |
|||
wrapperCol: {span: 14}, |
|||
}; |
|||
|
|||
const typeTable = { |
|||
view: { |
|||
label: '跳转网页', |
|||
content: <InputItem label='网页链接' /> |
|||
}, |
|||
customPage: { |
|||
label: '自定义页面', |
|||
content: ( |
|||
<FormItem label='自定义页面' {...formItemLayout}> |
|||
<Select defaultValue={activeMenu.content.value} onChange={changeValue}> |
|||
{customPageList.map(({id, title}) => <Option key={id} value={id.toString()}>{title}</Option>)} |
|||
</Select> |
|||
</FormItem> |
|||
) |
|||
}, |
|||
miniprogram: { |
|||
label: '跳转小程序', |
|||
content: ( |
|||
<> |
|||
<InputItem label='小程序 AppId' name='appId' /> |
|||
<InputItem label='小程序页面链接' name='url' /> |
|||
<InputItem label='备用网页链接' name='spareWebUrl' /> |
|||
</> |
|||
) |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<Root> |
|||
<Form> |
|||
<FormItem label='菜单名称' {...formItemLayout}> |
|||
<Input |
|||
value={activeMenu.title} |
|||
onInput={e => onChange('title', e.target.value)} |
|||
/> |
|||
</FormItem> |
|||
{(!activeMenu.children || activeMenu.children.length === 0) && ( |
|||
<ContentValue> |
|||
<FormItem label='菜单内容' {...formItemLayout}> |
|||
<RadioGroup value={activeMenu.content.type} onChange={changeType}> |
|||
{Object.keys(typeTable).map(name => ( |
|||
<Radio key={name} value={name}>{typeTable[name].label}</Radio> |
|||
))} |
|||
</RadioGroup> |
|||
</FormItem> |
|||
{activeMenu.content.type && ( |
|||
<MenuContextProvider value={{ activeMenu, onChange: changeValue, formItemLayout }}> |
|||
{typeTable[activeMenu.content.type].content} |
|||
</MenuContextProvider> |
|||
)} |
|||
</ContentValue> |
|||
)} |
|||
</Form> |
|||
</Root> |
|||
) |
|||
} |
|||
|
|||
const Root = styled.div`
|
|||
margin-left: 20px; |
|||
padding: 20px; |
|||
background: #fff; |
|||
`;
|
|||
|
|||
const ContentValue = styled.div`
|
|||
|
|||
`;
|
@ -0,0 +1,176 @@ |
|||
import React, { useState } from 'react' |
|||
import { Button, Popover } from "antd"; |
|||
import styled from "styled-components"; |
|||
import ClassNames from 'classnames' |
|||
import AddItem from './AddItem' |
|||
import { DragDropContext, Draggable, Droppable} from "react-beautiful-dnd"; |
|||
|
|||
function BaseMenuItem({ title, active, onClick, onDelete, children, provided, snapshot }) { |
|||
const providedProps = provided ? { |
|||
ref: provided.innerRef, |
|||
...provided.draggableProps, |
|||
...provided.dragHandleProps |
|||
} : {}; |
|||
|
|||
return ( |
|||
<MenuItemRoot |
|||
{...providedProps} |
|||
className={ClassNames({ |
|||
active, |
|||
dragging: snapshot.isDragging |
|||
})} |
|||
onClick={onClick} |
|||
> |
|||
<MenuItemWrapper> |
|||
<MenuItemText>{title}</MenuItemText> |
|||
<DeleteButton |
|||
type='danger' |
|||
shape='circle' |
|||
size='small' |
|||
icon='close' |
|||
onClick={e => { |
|||
e.stopPropagation(); |
|||
onDelete(e); |
|||
}} |
|||
/> |
|||
{children} |
|||
</MenuItemWrapper> |
|||
</MenuItemRoot> |
|||
) |
|||
} |
|||
|
|||
export default function MenuItem( |
|||
{ |
|||
title, children, active, activeSubMenuId, showSubMenu, provided, snapshot, onActivate, |
|||
onAddSubMenu, onDeleteSubMenu, onReorderSubMenu, onDelete |
|||
}) { |
|||
const content = ( |
|||
<> |
|||
<DragDropContext |
|||
onDragStart={({ draggableId }) => onActivate(draggableId)} |
|||
onDragEnd={result => onReorderSubMenu(result)} |
|||
> |
|||
<Droppable droppableId="droppable"> |
|||
{provided => ( |
|||
<div |
|||
{...provided.droppableProps} |
|||
ref={provided.innerRef} |
|||
> |
|||
{children && children.map(({id, title}, subIndex) => ( |
|||
<Draggable key={id} draggableId={id} index={subIndex}> |
|||
{(provided, snapshot) => ( |
|||
<SubMenuItem |
|||
ref={provided.innerRef} |
|||
{...provided.draggableProps} |
|||
{...provided.dragHandleProps} |
|||
> |
|||
<BaseMenuItem |
|||
title={title} |
|||
active={activeSubMenuId === id} |
|||
snapshot={snapshot} |
|||
onClick={() => onActivate(id)} |
|||
onDelete={() => onDeleteSubMenu(subIndex)} |
|||
/> |
|||
</SubMenuItem> |
|||
)} |
|||
</Draggable> |
|||
))} |
|||
{provided.placeholder} |
|||
</div> |
|||
)} |
|||
</Droppable> |
|||
</DragDropContext> |
|||
{(!children || children.length < 5) && <AddItem subMenu onClick={onAddSubMenu}/>} |
|||
</> |
|||
); |
|||
|
|||
return ( |
|||
<Popover |
|||
trigger='click' |
|||
content={content} |
|||
overlayStyle={{width: '100px', padding: '0'}} |
|||
visible={showSubMenu} |
|||
> |
|||
<BaseMenuItem |
|||
provided={provided} |
|||
snapshot={snapshot} |
|||
title={title} |
|||
active={active} |
|||
onClick={() => onActivate()} |
|||
onDelete={onDelete} |
|||
> |
|||
{(children && children.length > 0) && <SpreadSymbol/>} |
|||
</BaseMenuItem> |
|||
</Popover> |
|||
) |
|||
} |
|||
|
|||
const DeleteButton = styled(Button)`
|
|||
position: absolute; |
|||
right: 0; |
|||
top: 0; |
|||
transform: translate(50%, -50%); |
|||
box-shadow: 0 0 10px 0 rgba(0 0 0 .1); |
|||
visibility: hidden; |
|||
z-index: 2000; |
|||
`;
|
|||
|
|||
const fontSizeRatio = 0.03; |
|||
const MenuItemText = styled.div`
|
|||
padding: 0 5px; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
font-size: ${70 * fontSizeRatio}vh; |
|||
`;
|
|||
|
|||
const SubMenuItem = styled.div`
|
|||
display: flex; |
|||
width: 100%; |
|||
height: 40px; |
|||
border-bottom: 1px solid #eee; |
|||
`;
|
|||
|
|||
const MenuItemWrapper = styled.div`
|
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
width: 100%; |
|||
height: 100%; |
|||
`;
|
|||
|
|||
const triangleSize = 8; |
|||
const spreadOffset = 3; |
|||
const SpreadSymbol = styled.div`
|
|||
position: absolute; |
|||
right: ${spreadOffset}px; |
|||
bottom: ${spreadOffset}px; |
|||
width: 0; |
|||
height: 0; |
|||
border-top: ${triangleSize}px solid transparent; |
|||
border-right: ${triangleSize}px solid #ddd; |
|||
`;
|
|||
|
|||
const MenuItemRoot = styled.div`
|
|||
align-items: stretch; |
|||
position: relative; |
|||
flex: 1; |
|||
padding: 5px 0; |
|||
cursor: default; |
|||
background: #fff; |
|||
border: 2px solid transparent; |
|||
width: 0; |
|||
transition: box-shadow .1s; |
|||
&.active { |
|||
border-color: #09bb07; |
|||
} |
|||
&.dragging { |
|||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, .1); |
|||
} |
|||
&:not(:last-child) ${MenuItemWrapper} { |
|||
border-right: 1px solid #eee; |
|||
} |
|||
&:hover ${DeleteButton} { |
|||
visibility: visible; |
|||
} |
|||
`;
|
@ -0,0 +1,247 @@ |
|||
import React, { useState } from 'react' |
|||
import ReactDOM from 'react-dom' |
|||
import styled from 'styled-components' |
|||
import { Button, Icon, message } from 'antd' |
|||
import { produce } from 'immer' |
|||
import axios from 'axios' |
|||
import { DragDropContext, Draggable, Droppable} from "react-beautiful-dnd"; |
|||
import './index.scss' |
|||
import Editor from './Editor' |
|||
import AddItem from './AddItem' |
|||
import MenuItem from './MenuItem' |
|||
import '../utils/ajax' |
|||
import { handleDragEnd } from './utils' |
|||
import uuid from 'uuid' |
|||
|
|||
const IconFont = Icon.createFromIconfontCN({ |
|||
scriptUrl: '//at.alicdn.com/t/font_827976_5ojdd8bmdsw.js', |
|||
}); |
|||
|
|||
const { data: initMenuList = [] } = window; |
|||
|
|||
function getActiveMenu(menuList, activeMenuInfo) { |
|||
const activeParent = menuList.find(menu => menu.id === activeMenuInfo.id); |
|||
return (activeParent && activeMenuInfo.subId !== null && activeParent.children.length > 0) ? |
|||
activeParent.children.find(child => child.id === activeMenuInfo.subId) : activeParent; |
|||
} |
|||
|
|||
function App() { |
|||
const [menuList, setMenuList] = useState(initMenuList); |
|||
const [activeMenuInfo, setActiveMenuInfo] = useState({ id: null, subId: null }); |
|||
|
|||
const activeMenu = getActiveMenu(menuList, activeMenuInfo); |
|||
|
|||
function addMenu() { |
|||
const id = uuid(); |
|||
|
|||
setMenuList([ |
|||
...menuList, |
|||
{ |
|||
id, |
|||
title: '菜单名称', |
|||
content: { |
|||
type: null, |
|||
value: null |
|||
}, |
|||
children: [] |
|||
} |
|||
]); |
|||
|
|||
setActiveMenuInfo({ |
|||
id, |
|||
subId: null |
|||
}) |
|||
} |
|||
|
|||
function deleteMenu(index) { |
|||
setMenuList(produce(menuList, (draft => { |
|||
draft.splice(index, 1); |
|||
}))); |
|||
} |
|||
|
|||
function deleteSubMenu(index, subIndex) { |
|||
setMenuList(produce(menuList, (draft => { |
|||
draft[index].children.splice(subIndex, 1); |
|||
}))); |
|||
} |
|||
|
|||
function addSubMenu(index) { |
|||
const id = uuid(); |
|||
|
|||
setMenuList(produce(menuList, (draft => { |
|||
draft[index].children.push({ |
|||
id, |
|||
title: '菜单名称', |
|||
content: { |
|||
type: null, |
|||
value: null |
|||
} |
|||
}) |
|||
}))); |
|||
setActiveMenuInfo({ |
|||
id: menuList[index].id, |
|||
subId: id |
|||
}) |
|||
} |
|||
|
|||
function changeForm(name, value) { |
|||
setMenuList(produce(menuList, (draft => { |
|||
const activeMenu = getActiveMenu(draft, activeMenuInfo); |
|||
activeMenu[name] = value; |
|||
}))); |
|||
} |
|||
|
|||
function checkData() { |
|||
return menuList.every(menu => { |
|||
if (menu.children && menu.children.length > 0) { |
|||
return menu.title && menu.children.every(item => { |
|||
if (item.content.type === 'weapp') { |
|||
const { appId, url, spareWebUrl } = item.content.value; |
|||
return item.title && appId && url && spareWebUrl |
|||
} else { |
|||
return item.title && item.content.type && item.content.value |
|||
} |
|||
}) |
|||
} else { |
|||
return menu.title && menu.content.type && menu.content.value |
|||
} |
|||
}) |
|||
} |
|||
|
|||
async function submit() { |
|||
if (checkData()) { |
|||
await axios.post('', { |
|||
data: menuList |
|||
}); |
|||
message.success('保存成功'); |
|||
} else { |
|||
message.error('数据填写不完整,请检查是否有数据漏填') |
|||
} |
|||
} |
|||
|
|||
function reorderSubMenu(index, result) { |
|||
handleDragEnd(menuList[index].children, result, newSubMenu => { |
|||
setMenuList(produce(menuList, draft => { |
|||
draft[index].children = newSubMenu; |
|||
})) |
|||
}) |
|||
} |
|||
|
|||
return ( |
|||
<Root> |
|||
<Previewer> |
|||
<TopBar src='/img/wechat-top.png' /> |
|||
<BottomBar> |
|||
<KeyBoardIcon> |
|||
<IconFont |
|||
type='fa-keyboard' |
|||
style={{ |
|||
fontSize: '1.3em', |
|||
padding: '0 10px', |
|||
alignSelf: 'center', |
|||
borderRight: '1px solid #eee' |
|||
}} |
|||
/> |
|||
</KeyBoardIcon> |
|||
<MenuWrapper> |
|||
<DragDropContext |
|||
onDragStart={({ draggableId }) => setActiveMenuInfo({ id: draggableId, subId: null })} |
|||
onDragEnd={result => handleDragEnd(menuList, result, setMenuList)} |
|||
> |
|||
<Droppable droppableId='droppable' direction='horizontal'> |
|||
{provided => ( |
|||
<MenuGroup |
|||
{...provided.droppableProps} |
|||
ref={provided.innerRef} |
|||
length={menuList.length} |
|||
> |
|||
{menuList.map((menu, index) => ( |
|||
<Draggable key={menu.id} draggableId={menu.id} index={index}> |
|||
{(provided, snapshot) => ( |
|||
<MenuItem |
|||
key={menu.id} |
|||
{...menu} |
|||
provided={provided} |
|||
snapshot={snapshot} |
|||
showSubMenu={activeMenuInfo.id === menu.id} |
|||
active={activeMenuInfo.id === menu.id && activeMenuInfo.subId === null} |
|||
activeSubMenuId={activeMenuInfo.id === menu.id && activeMenuInfo.subId} |
|||
onAddSubMenu={() => addSubMenu(index)} |
|||
onActivate={(subId = null) => setActiveMenuInfo({ id: menu.id, subId })} |
|||
onDelete={() => deleteMenu(index)} |
|||
onDeleteSubMenu={(subIndex) => deleteSubMenu(index, subIndex)} |
|||
onReorderSubMenu={result => reorderSubMenu(index, result)} |
|||
/> |
|||
)} |
|||
</Draggable> |
|||
))} |
|||
{provided.placeholder} |
|||
</MenuGroup> |
|||
)} |
|||
</Droppable> |
|||
</DragDropContext> |
|||
{menuList.length < 3 && <AddItem onClick={addMenu} />} |
|||
</MenuWrapper> |
|||
</BottomBar> |
|||
</Previewer> |
|||
{activeMenu && <Editor activeMenu={activeMenu} onChange={changeForm} />} |
|||
<SubmitButton type='primary' shape='circle' icon='check' size='large' onClick={submit} /> |
|||
</Root> |
|||
) |
|||
} |
|||
|
|||
const Root = styled.div`
|
|||
display: flex; |
|||
`;
|
|||
|
|||
const TopBar = styled.img`
|
|||
width: 100%; |
|||
height: auto; |
|||
`;
|
|||
|
|||
const BottomBar = styled.div`
|
|||
display: flex; |
|||
width: 100%; |
|||
height: 35px; |
|||
background: #fff; |
|||
`;
|
|||
|
|||
const KeyBoardIcon = styled.div`
|
|||
padding: 10px 0; |
|||
`;
|
|||
|
|||
const height = 70; |
|||
const ratio = 16 / 9; |
|||
|
|||
const Previewer = styled.div`
|
|||
flex-shrink: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: space-between; |
|||
width: ${height / ratio}vh; |
|||
height: ${height}vh; |
|||
background: #eee; |
|||
border: 1px solid #eee; |
|||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, .1); |
|||
`;
|
|||
|
|||
const SubmitButton = styled(Button)`
|
|||
position: fixed; |
|||
width: 60px!important; |
|||
height: 60px!important; |
|||
font-size: 1.8em!important; |
|||
bottom: 40px; |
|||
right: 40px; |
|||
`;
|
|||
|
|||
const MenuWrapper = styled.div`
|
|||
flex-grow: 1; |
|||
display: flex; |
|||
width: 0; |
|||
`;
|
|||
|
|||
const MenuGroup = styled(MenuWrapper)`
|
|||
flex-grow: ${({ length }) => length}; |
|||
`;
|
|||
|
|||
ReactDOM.render(<App />, document.getElementById('app')); |
@ -0,0 +1,7 @@ |
|||
.ant-popover-inner-content { |
|||
padding: 0!important; |
|||
} |
|||
|
|||
.ant-popover-arrow { |
|||
display: none!important; |
|||
} |
@ -0,0 +1,25 @@ |
|||
export const reorder = (list, startIndex, endIndex) => { |
|||
const result = Array.from(list); |
|||
const [removed] = result.splice(startIndex, 1); |
|||
result.splice(endIndex, 0, removed); |
|||
|
|||
return result; |
|||
}; |
|||
|
|||
export function handleDragEnd(list, result, setList) { |
|||
if (!result.destination) { |
|||
return; |
|||
} |
|||
|
|||
if (result.destination.index === result.source.index) { |
|||
return; |
|||
} |
|||
|
|||
const newList = reorder( |
|||
list, |
|||
result.source.index, |
|||
result.destination.index |
|||
); |
|||
|
|||
setList(newList); |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue