Browse Source

增加后台操作微信公众号自定义菜单组件

wechat_public_accounts
linyaostalker 5 years ago
parent
commit
9ad275687c
  1. 30
      backend/web/src/custom-menu/AddItem.js
  2. 124
      backend/web/src/custom-menu/Editor.js
  3. 176
      backend/web/src/custom-menu/MenuItem.js
  4. 247
      backend/web/src/custom-menu/index.js
  5. 7
      backend/web/src/custom-menu/index.scss
  6. 25
      backend/web/src/custom-menu/utils.js

30
backend/web/src/custom-menu/AddItem.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;
}
}
`;

124
backend/web/src/custom-menu/Editor.js

@ -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`
`;

176
backend/web/src/custom-menu/MenuItem.js

@ -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;
}
`;

247
backend/web/src/custom-menu/index.js

@ -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'));

7
backend/web/src/custom-menu/index.scss

@ -0,0 +1,7 @@
.ant-popover-inner-content {
padding: 0!important;
}
.ant-popover-arrow {
display: none!important;
}

25
backend/web/src/custom-menu/utils.js

@ -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);
}
Loading…
Cancel
Save