diff --git a/config/config.ts b/config/config.ts index ee8811032ba6accfbaa61bf5d91b59022abaf3c7..d5c90e6aeed832dafaa8c22c4394fc10b3873c42 100644 --- a/config/config.ts +++ b/config/config.ts @@ -1,14 +1,10 @@ import { IConfig, IPlugin } from 'umi-types'; +import defaultSettings from './defaultSettings'; // https://umijs.org/config/ -import defaultSettings from './defaultSettings'; -// https://umijs.org/config/ import os from 'os'; import slash from 'slash2'; import webpackPlugin from './plugin.config'; - -const { pwa, primaryColor } = defaultSettings; - -// preview.pro.ant.design only do not use in your production ; +const { pwa, primaryColor } = defaultSettings; // preview.pro.ant.design only do not use in your production ; // preview.pro.ant.design 专用环境变量,请不要在你的项目中使用它。 const { ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION, TEST, NODE_ENV } = process.env; @@ -104,15 +100,232 @@ export default { routes: [ { path: '/', - component: '../layouts/BasicLayout', - Routes: ['src/pages/Authorized'], - authority: ['admin', 'user'], + component: '../layouts/BlankLayout', routes: [ + { + path: '/user', + component: '../layouts/UserLayout', + routes: [ + { + path: '/user', + redirect: '/user/login', + }, + { + name: 'login', + path: '/user/login', + component: './user/login', + }, + { + name: 'register-result', + path: '/user/register-result', + component: './user/register-result', + }, + { + name: 'register', + path: '/user/register', + component: './user/register', + }, + ], + }, { path: '/', - name: 'welcome', - icon: 'smile', - component: './Welcome', + component: '../layouts/BasicLayout', + Routes: ['src/pages/Authorized'], + authority: ['admin', 'user'], + routes: [ + { + path: '/dashboard', + name: 'dashboard', + icon: 'dashboard', + routes: [ + { + name: 'analysis', + path: '/dashboard/analysis', + component: './dashboard/analysis', + }, + { + name: 'monitor', + path: '/dashboard/monitor', + component: './dashboard/monitor', + }, + { + name: 'workplace', + path: '/dashboard/workplace', + component: './dashboard/workplace', + }, + ], + }, + { + path: '/form', + icon: 'form', + name: 'form', + routes: [ + { + name: 'basic-form', + path: '/form/basic-form', + component: './form/basic-form', + }, + { + name: 'step-form', + path: '/form/step-form', + component: './form/step-form', + }, + { + name: 'advanced-form', + path: '/form/advanced-form', + component: './form/advanced-form', + }, + ], + }, + { + path: '/list', + icon: 'table', + name: 'list', + routes: [ + { + path: '/list/search', + name: 'search-list', + component: './list/search', + routes: [ + { + path: '/list/search', + redirect: '/list/search/articles', + }, + { + name: 'articles', + path: '/list/search/articles', + component: './list/search/articles', + }, + { + name: 'projects', + path: '/list/search/projects', + component: './list/search/projects', + }, + { + name: 'applications', + path: '/list/search/applications', + component: './list/search/applications', + }, + ], + }, + { + name: 'table-list', + path: '/list/table-list', + component: './list/table-list', + }, + { + name: 'basic-list', + path: '/list/basic-list', + component: './list/basic-list', + }, + { + name: 'card-list', + path: '/list/card-list', + component: './list/card-list', + }, + ], + }, + { + path: '/profile', + name: 'profile', + icon: 'profile', + routes: [ + { + name: 'basic', + path: '/profile/basic', + component: './profile/basic', + }, + { + name: 'advanced', + path: '/profile/advanced', + component: './profile/advanced', + }, + ], + }, + { + name: 'result', + icon: 'check-circle-o', + path: '/result', + routes: [ + { + name: 'success', + path: '/result/success', + component: './result/success', + }, + { + name: 'fail', + path: '/result/fail', + component: './result/fail', + }, + ], + }, + { + name: 'exception', + icon: 'warning', + path: '/exception', + routes: [ + { + name: '403', + path: '/exception/403', + component: './exception/403', + }, + { + name: '404', + path: '/exception/404', + component: './exception/404', + }, + { + name: '500', + path: '/exception/500', + component: './exception/500', + }, + ], + }, + { + name: 'account', + icon: 'user', + path: '/account', + routes: [ + { + name: 'center', + path: '/account/center', + component: './account/center', + }, + { + name: 'settings', + path: '/account/settings', + component: './account/settings', + }, + ], + }, + { + name: 'editor', + icon: 'highlight', + path: '/editor', + routes: [ + { + name: 'flow', + path: '/editor/flow', + component: './editor/flow', + }, + { + name: 'mind', + path: '/editor/mind', + component: './editor/mind', + }, + { + name: 'koni', + path: '/editor/koni', + component: './editor/koni', + }, + ], + }, + { + path: '/', + redirect: '/dashboard/analysis', + authority: ['admin', 'user'], + }, + ], }, ], }, @@ -141,7 +354,7 @@ export default { resourcePath: string; }, localIdentName: string, - localName: string, + localName: string ) => { if ( context.resourcePath.includes('node_modules') || diff --git a/package.json b/package.json index 7cd6eba6280251bcd3cb830fd0072ce5f6207e76..870182a30155907c12bb012f50f9b85281b2fd3a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ }, "husky": { "hooks": { - "pre-commit": "npm run lint-staged" } }, "lint-staged": { @@ -54,13 +53,22 @@ "dependencies": { "@ant-design/pro-layout": "^4.5.0", "@antv/data-set": "^0.10.2", + "@types/lodash.debounce": "^4.0.6", + "@types/react-router": "^5.0.2", "antd": "^3.19.1", + "bizcharts": "^3.5.3-beta.0", + "bizcharts-plugin-slider": "^2.1.1-beta.1", "classnames": "^2.2.6", "dva": "^2.4.1", + "gg-editor": "^2.0.2", + "hash.js": "^1.1.5", "lodash": "^4.17.11", "lodash-decorators": "^6.0.1", + "lodash.debounce": "^4.0.8", "memoize-one": "^5.0.4", "moment": "^2.24.0", + "numeral": "^2.0.6", + "nzh": "^1.0.3", "omit.js": "^1.0.2", "path-to-regexp": "^3.0.0", "prop-types": "^15.7.2", @@ -71,10 +79,13 @@ "react-copy-to-clipboard": "^5.0.1", "react-document-title": "^2.0.3", "react-dom": "^16.8.6", + "react-fittext": "^1.0.0", "react-media": "^1.9.2", "react-media-hook2": "^1.0.5", + "react-router": "^4.3.1", "redux": "^4.0.1", "umi": "^2.7.2", + "umi-plugin-block-dev": "^1.0.0", "umi-plugin-ga": "^1.1.3", "umi-plugin-pro-block": "^1.3.2", "umi-plugin-react": "^1.8.2", @@ -85,11 +96,12 @@ "@types/classnames": "^2.2.7", "@types/history": "^4.7.2", "@types/jest": "^24.0.13", - "@types/lodash": "^4.14.133", + "@types/lodash": "^4.14.134", "@types/qs": "^6.5.3", "@types/react": "^16.8.19", "@types/react-document-title": "^2.0.3", "@types/react-dom": "^16.8.4", + "@types/numeral":"^0.0.25", "@umijs/fabric": "^1.0.4", "babel-eslint": "^10.0.1", "chalk": "^2.4.2", @@ -158,4 +170,4 @@ "create-umi" ] } -} \ No newline at end of file +} diff --git a/src/components/GlobalHeader/RightContent.tsx b/src/components/GlobalHeader/RightContent.tsx index 9200785daf02123b233d95550d9e224b0a83b4c4..6fc42e819630e8976c747c122d56214474b7da0b 100644 --- a/src/components/GlobalHeader/RightContent.tsx +++ b/src/components/GlobalHeader/RightContent.tsx @@ -1,6 +1,5 @@ import { ConnectProps, ConnectState } from '@/models/connect'; import { Icon, Tooltip } from 'antd'; - import Avatar from './AvatarDropdown'; import HeaderSearch from '../HeaderSearch'; import React from 'react'; @@ -8,6 +7,7 @@ import SelectLang from '../SelectLang'; import { connect } from 'dva'; import { formatMessage } from 'umi-plugin-react/locale'; import styles from './index.less'; +import NoticeIconView from './NoticeIconView'; export type SiderTheme = 'light' | 'dark'; export interface GlobalHeaderRightProps extends ConnectProps { @@ -62,7 +62,8 @@ const GlobalHeaderRight: React.SFC = props => { - + + ); diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index 2ac74fcc4e5b67890884e13e0357ca2480fd2c37..c5e634f2e17e94252b05394eb9a91a94a65abd18 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -3,15 +3,14 @@ * You can view component api by: * https://github.com/ant-design/ant-design-pro-layout */ - import { ConnectProps, ConnectState } from '@/models/connect'; import ProLayout, { MenuDataItem, BasicLayoutProps as ProLayoutProps, Settings, + SettingDrawer, } from '@ant-design/pro-layout'; import React, { useState } from 'react'; - import Authorized from '@/utils/Authorized'; import Link from 'umi/link'; import RightContent from '@/components/GlobalHeader/RightContent'; @@ -31,16 +30,13 @@ export type BasicLayoutContext = { [K in 'location']: BasicLayoutProps[K] } & { [path: string]: MenuDataItem; }; }; - /** * use Authorized check all menu item */ + const menuDataRender = (menuList: MenuDataItem[]): MenuDataItem[] => menuList.map(item => { - const localItem = { - ...item, - children: item.children ? menuDataRender(item.children) : [], - }; + const localItem = { ...item, children: item.children ? menuDataRender(item.children) : [] }; return Authorized.check(item.authority, localItem, null) as MenuDataItem; }); @@ -48,6 +44,7 @@ const footerRender: BasicLayoutProps['footerRender'] = (_, defaultDom) => { if (!isAntDesignPro()) { return defaultDom; } + return ( <> {defaultDom} @@ -85,10 +82,10 @@ const BasicLayout: React.FC = props => { }); } }); - /** * init variables */ + const handleMenuCollapse = (payload: boolean): void => dispatch && dispatch({ @@ -97,31 +94,42 @@ const BasicLayout: React.FC = props => { }); return ( - ( - {defaultDom} - )} - breadcrumbRender={(routers = []) => [ - { - path: '/', - breadcrumbName: formatMessage({ - id: 'menu.home', - defaultMessage: 'Home', - }), - }, - ...routers, - ]} - footerRender={footerRender} - menuDataRender={menuDataRender} - formatMessage={formatMessage} - rightContentRender={rightProps => } - {...props} - {...settings} - > - {children} - + <> + ( + {defaultDom} + )} + breadcrumbRender={(routers = []) => [ + { + path: '/', + breadcrumbName: formatMessage({ + id: 'menu.home', + defaultMessage: 'Home', + }), + }, + ...routers, + ]} + footerRender={footerRender} + menuDataRender={menuDataRender} + formatMessage={formatMessage} + rightContentRender={rightProps => } + {...props} + {...settings} + > + {children} + + + dispatch({ + type: 'settings/changeSetting', + payload: config, + }) + } + /> + ); }; diff --git a/src/layouts/BlankLayout.tsx b/src/layouts/BlankLayout.tsx index a5ff8c4cfa1909981083292a26ae7966f23d825e..35f28e8dfd43a2000d7b28df14626205b76d72a6 100644 --- a/src/layouts/BlankLayout.tsx +++ b/src/layouts/BlankLayout.tsx @@ -1,5 +1,11 @@ import React from 'react'; +import CopyBlock from '@/components/CopyBlock'; -const Layout: React.FC = ({ children }) =>
{children}
; +const Layout: React.FC = ({ children }) => ( + <> +
{children}
+ + +); export default Layout; diff --git a/src/pages/account/center/Center.less b/src/pages/account/center/Center.less new file mode 100644 index 0000000000000000000000000000000000000000..7f65fb761164fc3a498c558c0c749878ebdec43c --- /dev/null +++ b/src/pages/account/center/Center.less @@ -0,0 +1,99 @@ +@import '~antd/es/style/themes/default.less'; + +.avatarHolder { + margin-bottom: 24px; + text-align: center; + + & > img { + width: 104px; + height: 104px; + margin-bottom: 20px; + } + + .name { + margin-bottom: 4px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } +} + +.detail { + p { + position: relative; + margin-bottom: 8px; + padding-left: 26px; + + &:last-child { + margin-bottom: 0; + } + } + + i { + position: absolute; + top: 4px; + left: 0; + width: 14px; + height: 14px; + background: url(https://gw.alipayobjects.com/zos/rmsportal/pBjWzVAHnOOtAUvZmZfy.svg); + + &.title { + background-position: 0 0; + } + + &.group { + background-position: 0 -22px; + } + + &.address { + background-position: 0 -44px; + } + } +} + +.tagsTitle, +.teamTitle { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; +} + +.tags { + :global { + .ant-tag { + margin-bottom: 8px; + } + } +} + +.team { + :global { + .ant-avatar { + margin-right: 12px; + } + } + + a { + display: block; + margin-bottom: 24px; + overflow: hidden; + color: @text-color; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + transition: color 0.3s; + + &:hover { + color: @primary-color; + } + } +} + +.tabsCard { + :global { + .ant-card-head { + padding: 0 16px; + } + } +} diff --git a/src/pages/account/center/_mock.ts b/src/pages/account/center/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea19d21f6c15b6468cf612e1c861197437ed4da6 --- /dev/null +++ b/src/pages/account/center/_mock.ts @@ -0,0 +1,227 @@ +import { ListItemDataType } from './data.d'; + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const covers = [ + 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iXjVmWVHbCJAyqvDxdtx.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png', +]; +const desc = [ + '那是一种内在的东西, 他们到达不了,也无法触及的', + '希望是一个好东西,也许是最好的,好东西是不会消亡的', + '生命就像一盒巧克力,结果往往出人意料', + '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + '那时候我只会想自己想要什么,从不想自己拥有什么', +]; + +const user = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +function fakeList(count: number): ListItemDataType[] { + const list = []; + for (let i = 0; i < count; i += 1) { + list.push({ + id: `fake-list-${i}`, + owner: user[i % 10], + title: titles[i % 8], + avatar: avatars[i % 8], + cover: parseInt(`${i / 4}`, 10) % 2 === 0 ? covers[i % 4] : covers[3 - (i % 4)], + status: ['active', 'exception', 'normal'][i % 3] as + | 'normal' + | 'exception' + | 'active' + | 'success', + percent: Math.ceil(Math.random() * 50) + 50, + logo: avatars[i % 8], + href: 'https://ant.design', + updatedAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + subDescription: desc[i % 5], + description: + '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。', + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + star: Math.ceil(Math.random() * 100) + 100, + like: Math.ceil(Math.random() * 100) + 100, + message: Math.ceil(Math.random() * 10) + 10, + content: + '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。', + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3', + }, + ], + }); + } + + return list; +} + +function getFakeList(req: { query: any }, res: { json: (arg0: ListItemDataType[]) => void }) { + const params = req.query; + + const count = params.count * 1 || 5; + + const result = fakeList(count); + return res.json(result); +} + +export default { + 'GET /api/fake_list': getFakeList, + // 支持值为 Object 和 Array + 'GET /api/currentUser': { + name: 'Serati Ma', + avatar: 'https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png', + userid: '00000001', + email: 'antdesign@alipay.com', + signature: '海纳百川,有容乃大', + title: '交互专家', + group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED', + tags: [ + { + key: '0', + label: '很有想法的', + }, + { + key: '1', + label: '专注设计', + }, + { + key: '2', + label: '辣~', + }, + { + key: '3', + label: '大长腿', + }, + { + key: '4', + label: '川妹子', + }, + { + key: '5', + label: '海纳百川', + }, + ], + notice: [ + { + id: 'xxx1', + title: titles[0], + logo: avatars[0], + description: '那是一种内在的东西,他们到达不了,也无法触及的', + updatedAt: new Date(), + member: '科学搬砖组', + href: '', + memberLink: '', + }, + { + id: 'xxx2', + title: titles[1], + logo: avatars[1], + description: '希望是一个好东西,也许是最好的,好东西是不会消亡的', + updatedAt: new Date('2017-07-24'), + member: '全组都是吴彦祖', + href: '', + memberLink: '', + }, + { + id: 'xxx3', + title: titles[2], + logo: avatars[2], + description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + updatedAt: new Date(), + member: '中二少女团', + href: '', + memberLink: '', + }, + { + id: 'xxx4', + title: titles[3], + logo: avatars[3], + description: '那时候我只会想自己想要什么,从不想自己拥有什么', + updatedAt: new Date('2017-07-23'), + member: '程序员日常', + href: '', + memberLink: '', + }, + { + id: 'xxx5', + title: titles[4], + logo: avatars[4], + description: '凛冬将至', + updatedAt: new Date('2017-07-23'), + member: '高逼格设计天团', + href: '', + memberLink: '', + }, + { + id: 'xxx6', + title: titles[5], + logo: avatars[5], + description: '生命就像一盒巧克力,结果往往出人意料', + updatedAt: new Date('2017-07-23'), + member: '骗你来学计算机', + href: '', + memberLink: '', + }, + ], + notifyCount: 12, + unreadCount: 11, + country: 'China', + geographic: { + province: { + label: '浙江省', + key: '330000', + }, + city: { + label: '杭州市', + key: '330100', + }, + }, + address: '西湖区工专路 77 号', + phone: '0752-268888888', + }, +}; diff --git a/src/pages/account/center/components/Applications/index.less b/src/pages/account/center/components/Applications/index.less new file mode 100644 index 0000000000000000000000000000000000000000..c550b333da1612abb92f9bb2527afce9dcc3f1c2 --- /dev/null +++ b/src/pages/account/center/components/Applications/index.less @@ -0,0 +1,51 @@ +@import '~antd/es/style/themes/default.less'; + +.filterCardList { + margin-bottom: -24px; + :global { + .ant-card-meta-content { + margin-top: 0; + } + // disabled white space + .ant-card-meta-avatar { + font-size: 0; + } + + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } + .cardInfo { + margin-top: 16px; + margin-left: 40px; + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + & > div { + position: relative; + float: left; + width: 50%; + text-align: left; + p { + margin: 0; + font-size: 24px; + line-height: 32px; + } + p:first-child { + margin-bottom: 4px; + color: @text-color-secondary; + font-size: 12px; + line-height: 20px; + } + } + } +} diff --git a/src/pages/account/center/components/Applications/index.tsx b/src/pages/account/center/components/Applications/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fda73be7801c54846e47f8c3fcd3561b47b1beb3 --- /dev/null +++ b/src/pages/account/center/components/Applications/index.tsx @@ -0,0 +1,116 @@ +import { Avatar, Card, Dropdown, Icon, List, Menu, Tooltip } from 'antd'; +import React, { Component } from 'react'; + +import { connect } from 'dva'; +import numeral from 'numeral'; +import { ModalState } from '../../model'; +import stylesApplications from './index.less'; + +export function formatWan(val: number) { + const v = val * 1; + if (!v || Number.isNaN(v)) return ''; + + let result: React.ReactNode = val; + if (val > 10000) { + result = ( + + {Math.floor(val / 10000)} + + 万 + + + ); + } + return result; +} + +@connect(({ accountCenter }: { accountCenter: ModalState }) => ({ + list: accountCenter.list, +})) +class Applications extends Component> { + render() { + const { list } = this.props; + const itemMenu = ( + + + + 1st menu item + + + + + 2nd menu item + + + + + 3d menu item + + + + ); + const CardInfo: React.SFC<{ + activeUser: React.ReactNode; + newUser: React.ReactNode; + }> = ({ activeUser, newUser }) => ( +
+
+

活跃用户

+

{activeUser}

+
+
+

新增用户

+

{newUser}

+
+
+ ); + return ( + ( + + + + , + + + , + + + , + + + , + ]} + > + } title={item.title} /> +
+ +
+
+
+ )} + /> + ); + } +} + +export default Applications; diff --git a/src/pages/account/center/components/ArticleListContent/index.less b/src/pages/account/center/components/ArticleListContent/index.less new file mode 100644 index 0000000000000000000000000000000000000000..eca0811cd021d24a8127cb0b1bb91fd748c2a5ab --- /dev/null +++ b/src/pages/account/center/components/ArticleListContent/index.less @@ -0,0 +1,38 @@ +@import '~antd/es/style/themes/default.less'; + +.listContent { + .description { + max-width: 720px; + line-height: 22px; + } + .extra { + margin-top: 16px; + color: @text-color-secondary; + line-height: 22px; + & > :global(.ant-avatar) { + position: relative; + top: 1px; + width: 20px; + height: 20px; + margin-right: 8px; + vertical-align: top; + } + & > em { + margin-left: 16px; + color: @disabled-color; + font-style: normal; + } + } +} + +@media screen and (max-width: @screen-xs) { + .listContent { + .extra { + & > em { + display: block; + margin-top: 8px; + margin-left: 0; + } + } + } +} diff --git a/src/pages/account/center/components/ArticleListContent/index.tsx b/src/pages/account/center/components/ArticleListContent/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..74c8b2f0d849c05900aedecbaeb451d0b2ff6a99 --- /dev/null +++ b/src/pages/account/center/components/ArticleListContent/index.tsx @@ -0,0 +1,28 @@ +import { Avatar } from 'antd'; +import React from 'react'; +import moment from 'moment'; +import styles from './index.less'; + +export interface ApplicationsProps { + data: { + content?: string; + updatedAt?: any; + avatar?: string; + owner?: string; + href?: string; + }; +} +const ArticleListContent: React.SFC = ({ + data: { content, updatedAt, avatar, owner, href }, +}) => ( +
+
{content}
+
+ + {owner} 发布在 {href} + {moment(updatedAt).format('YYYY-MM-DD HH:mm')} +
+
+); + +export default ArticleListContent; diff --git a/src/pages/account/center/components/Articles/index.less b/src/pages/account/center/components/Articles/index.less new file mode 100644 index 0000000000000000000000000000000000000000..e78c412cf00649b2679d8f67491f7992a021ddc7 --- /dev/null +++ b/src/pages/account/center/components/Articles/index.less @@ -0,0 +1,12 @@ +@import '~antd/es/style/themes/default.less'; + +.articleList { + :global { + .ant-list-item:first-child { + padding-top: 0; + } + } +} +a.listItemMetaTitle { + color: @heading-color; +} diff --git a/src/pages/account/center/components/Articles/index.tsx b/src/pages/account/center/components/Articles/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bdd53176d00689f5f8815c7ce24a6fc011d583e5 --- /dev/null +++ b/src/pages/account/center/components/Articles/index.tsx @@ -0,0 +1,63 @@ +import { Icon, List, Tag } from 'antd'; +import React, { Component } from 'react'; + +import { connect } from 'dva'; +import ArticleListContent from '../ArticleListContent'; +import { ListItemDataType } from '../../data.d'; +import { ModalState } from '../../model'; +import styles from './index.less'; + +@connect(({ accountCenter }: { accountCenter: ModalState }) => ({ + list: accountCenter.list, +})) +class Articles extends Component> { + render() { + const { list } = this.props; + const IconText: React.SFC<{ + type: string; + text: React.ReactNode; + }> = ({ type, text }) => ( + + + {text} + + ); + return ( + + size="large" + className={styles.articleList} + rowKey="id" + itemLayout="vertical" + dataSource={list} + renderItem={item => ( + , + , + , + ]} + > + + {item.title} + + } + description={ + + Ant Design + 设计语言 + 蚂蚁金服 + + } + /> + + + )} + /> + ); + } +} + +export default Articles; diff --git a/src/pages/account/center/components/AvatarList/index.less b/src/pages/account/center/components/AvatarList/index.less new file mode 100644 index 0000000000000000000000000000000000000000..8c342fd17d509e1128061e5cf3ebbbd162f843f3 --- /dev/null +++ b/src/pages/account/center/components/AvatarList/index.less @@ -0,0 +1,50 @@ +@import '~antd/es/style/themes/default.less'; + +.avatarList { + display: inline-block; + ul { + display: inline-block; + margin-left: 8px; + font-size: 0; + } +} + +.avatarItem { + display: inline-block; + width: @avatar-size-base; + height: @avatar-size-base; + margin-left: -8px; + font-size: @font-size-base; + :global { + .ant-avatar { + border: 1px solid #fff; + } + } +} + +.avatarItemLarge { + width: @avatar-size-lg; + height: @avatar-size-lg; +} + +.avatarItemSmall { + width: @avatar-size-sm; + height: @avatar-size-sm; +} + +.avatarItemMini { + width: 20px; + height: 20px; + :global { + .ant-avatar { + width: 20px; + height: 20px; + line-height: 20px; + + .ant-avatar-string { + font-size: 12px; + line-height: 18px; + } + } + } +} diff --git a/src/pages/account/center/components/AvatarList/index.tsx b/src/pages/account/center/components/AvatarList/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..650df830d9ca2720554e49639b5916febd5dbc5c --- /dev/null +++ b/src/pages/account/center/components/AvatarList/index.tsx @@ -0,0 +1,84 @@ +import { Avatar, Tooltip } from 'antd'; + +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export declare type SizeType = number | 'small' | 'default' | 'large'; + +export interface AvatarItemProps { + tips: React.ReactNode; + src: string; + size?: SizeType; + style?: React.CSSProperties; + onClick?: () => void; +} + +export interface AvatarListProps { + Item?: React.ReactElement; + size?: SizeType; + maxLength?: number; + excessItemsStyle?: React.CSSProperties; + style?: React.CSSProperties; + children: React.ReactElement | React.ReactElement[]; +} + +const avatarSizeToClassName = (size?: SizeType | 'mini') => + classNames(styles.avatarItem, { + [styles.avatarItemLarge]: size === 'large', + [styles.avatarItemSmall]: size === 'small', + [styles.avatarItemMini]: size === 'mini', + }); + +const Item: React.SFC = ({ src, size, tips, onClick = () => {} }) => { + const cls = avatarSizeToClassName(size); + + return ( +
  • + {tips ? ( + + + + ) : ( + + )} +
  • + ); +}; + +const AvatarList: React.SFC & { Item: typeof Item } = ({ + children, + size, + maxLength = 5, + excessItemsStyle, + ...other +}) => { + const numOfChildren = React.Children.count(children); + const numToShow = maxLength >= numOfChildren ? numOfChildren : maxLength; + const childrenArray = React.Children.toArray(children) as React.ReactElement[]; + const childrenWithProps = childrenArray.slice(0, numToShow).map(child => + React.cloneElement(child, { + size, + }), + ); + + if (numToShow < numOfChildren) { + const cls = avatarSizeToClassName(size); + + childrenWithProps.push( +
  • + {`+${numOfChildren - maxLength}`} +
  • , + ); + } + + return ( +
    +
      {childrenWithProps}
    +
    + ); +}; + +AvatarList.Item = Item; + +export default AvatarList; diff --git a/src/pages/account/center/components/Projects/index.less b/src/pages/account/center/components/Projects/index.less new file mode 100644 index 0000000000000000000000000000000000000000..ab2fcfc7aee820947f731e84716af459936a9dbb --- /dev/null +++ b/src/pages/account/center/components/Projects/index.less @@ -0,0 +1,56 @@ +@import '~antd/es/style/themes/default.less'; + +.coverCardList { + margin-bottom: -24px; + + .card { + :global { + .ant-card-meta-title { + margin-bottom: 4px; + & > a { + display: inline-block; + max-width: 100%; + color: @heading-color; + } + } + .ant-card-meta-description { + height: 44px; + overflow: hidden; + line-height: 22px; + } + } + + &:hover { + :global { + .ant-card-meta-title > a { + color: @primary-color; + } + } + } + } + + .cardItemContent { + display: flex; + height: 20px; + margin-top: 16px; + margin-bottom: -4px; + line-height: 20px; + & > span { + flex: 1; + color: @text-color-secondary; + font-size: 12px; + } + .avatarList { + flex: 0 1 auto; + } + } + .cardList { + margin-top: 24px; + } + + :global { + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } +} diff --git a/src/pages/account/center/components/Projects/index.tsx b/src/pages/account/center/components/Projects/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0c88b78276eedaca20252a35f99c57a2c09861e7 --- /dev/null +++ b/src/pages/account/center/components/Projects/index.tsx @@ -0,0 +1,53 @@ +import { Card, List } from 'antd'; +import React, { Component } from 'react'; + +import { connect } from 'dva'; +import moment from 'moment'; +import AvatarList from '../AvatarList'; +import { ListItemDataType } from '../../data.d'; +import { ModalState } from '../../model'; +import styles from './index.less'; + +@connect(({ accountCenter }: { accountCenter: ModalState }) => ({ + list: accountCenter.list, +})) +class Projects extends Component> { + render() { + const { list } = this.props; + return ( + + className={styles.coverCardList} + rowKey="id" + grid={{ gutter: 24, xxl: 3, xl: 2, lg: 2, md: 2, sm: 2, xs: 1 }} + dataSource={list} + renderItem={item => ( + + } + > + {item.title}} description={item.subDescription} /> +
    + {moment(item.updatedAt).fromNow()} +
    + + {item.members.map(member => ( + + ))} + +
    +
    +
    +
    + )} + /> + ); + } +} + +export default Projects; diff --git a/src/pages/account/center/data.d.ts b/src/pages/account/center/data.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b604c402cafcb224ebb6295b4b465468810ca01 --- /dev/null +++ b/src/pages/account/center/data.d.ts @@ -0,0 +1,78 @@ +export interface TagType { + key: string; + label: string; +} + +export interface ProvinceType { + label: string; + key: string; +} + +export interface CityType { + label: string; + key: string; +} + +export interface GeographicType { + province: ProvinceType; + city: CityType; +} + +export interface NoticeType { + id: string; + title: string; + logo: string; + description: string; + updatedAt: string; + member: string; + href: string; + memberLink: string; +} + +export interface CurrentUser { + name: string; + avatar: string; + userid: string; + notice: NoticeType[]; + email: string; + signature: string; + title: string; + group: string; + tags: TagType[]; + notifyCount: number; + unreadCount: number; + country: string; + geographic: GeographicType; + address: string; + phone: string; +} + +export interface Member { + avatar: string; + name: string; + id: string; +} + +export interface ListItemDataType { + id: string; + owner: string; + title: string; + avatar: string; + cover: string; + status: 'normal' | 'exception' | 'active' | 'success'; + percent: number; + logo: string; + href: string; + body?: any; + updatedAt: number; + createdAt: number; + subDescription: string; + description: string; + activeUser: number; + newUser: number; + star: number; + like: number; + message: number; + content: string; + members: Member[]; +} diff --git a/src/pages/account/center/index.tsx b/src/pages/account/center/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f457223c9ecc87661b003a43c522de41b4b38280 --- /dev/null +++ b/src/pages/account/center/index.tsx @@ -0,0 +1,250 @@ +import { Avatar, Card, Col, Divider, Icon, Input, Row, Tag } from 'antd'; +import React, { PureComponent } from 'react'; + +import { Dispatch } from 'redux'; +import { GridContent } from '@ant-design/pro-layout'; +import Link from 'umi/link'; +import { RouteChildrenProps } from 'react-router'; +import { connect } from 'dva'; +import { ModalState } from './model'; +import Projects from './components/Projects'; +import Articles from './components/Articles'; +import Applications from './components/Applications'; +import { CurrentUser, TagType } from './data.d'; +import styles from './Center.less'; + +const operationTabList = [ + { + key: 'articles', + tab: ( + + 文章 (8) + + ), + }, + { + key: 'applications', + tab: ( + + 应用 (8) + + ), + }, + { + key: 'projects', + tab: ( + + 项目 (8) + + ), + }, +]; + +interface accountCenterProps extends RouteChildrenProps { + dispatch: Dispatch; + currentUser: CurrentUser; + currentUserLoading: boolean; +} +interface accountCenterState { + newTags: TagType[]; + tabKey: 'articles' | 'applications' | 'projects'; + inputVisible: boolean; + inputValue: string; +} + +@connect( + ({ + loading, + accountCenter, + }: { + loading: { effects: { [key: string]: boolean } }; + accountCenter: ModalState; + }) => ({ + currentUser: accountCenter.currentUser, + currentUserLoading: loading.effects['accountCenter/fetchCurrent'], + }), +) +class Center extends PureComponent< + accountCenterProps, + accountCenterState +> { + // static getDerivedStateFromProps( + // props: accountCenterProps, + // state: accountCenterState, + // ) { + // const { match, location } = props; + // const { tabKey } = state; + // const path = match && match.path; + + // const urlTabKey = location.pathname.replace(`${path}/`, ''); + // if (urlTabKey && urlTabKey !== '/' && tabKey !== urlTabKey) { + // return { + // tabKey: urlTabKey, + // }; + // } + + // return null; + // } + + state: accountCenterState = { + newTags: [], + inputVisible: false, + inputValue: '', + tabKey: 'articles', + }; + + public input: Input | null | undefined = undefined; + + componentDidMount() { + const { dispatch } = this.props; + dispatch({ + type: 'accountCenter/fetchCurrent', + }); + dispatch({ + type: 'accountCenter/fetch', + }); + } + + onTabChange = (key: string) => { + // If you need to sync state to url + // const { match } = this.props; + // router.push(`${match.url}/${key}`); + this.setState({ + tabKey: key as accountCenterState['tabKey'], + }); + }; + + showInput = () => { + this.setState({ inputVisible: true }, () => this.input && this.input.focus()); + }; + + saveInputRef = (input: Input | null) => { + this.input = input; + }; + + handleInputChange = (e: React.ChangeEvent) => { + this.setState({ inputValue: e.target.value }); + }; + + handleInputConfirm = () => { + const { state } = this; + const { inputValue } = state; + let { newTags } = state; + if (inputValue && newTags.filter(tag => tag.label === inputValue).length === 0) { + newTags = [...newTags, { key: `new-${newTags.length}`, label: inputValue }]; + } + this.setState({ + newTags, + inputVisible: false, + inputValue: '', + }); + }; + + renderChildrenByTabKey = (tabKey: accountCenterState['tabKey']) => { + if (tabKey === 'projects') { + return ; + } + if (tabKey === 'applications') { + return ; + } + if (tabKey === 'articles') { + return ; + } + return null; + }; + + render() { + const { newTags, inputVisible, inputValue, tabKey } = this.state; + const { currentUser, currentUserLoading } = this.props; + const dataLoading = currentUserLoading || !(currentUser && Object.keys(currentUser).length); + return ( + + + + + {!dataLoading ? ( +
    +
    + +
    {currentUser.name}
    +
    {currentUser.signature}
    +
    +
    +

    + + {currentUser.title} +

    +

    + + {currentUser.group} +

    +

    + + {currentUser.geographic.province.label} + {currentUser.geographic.city.label} +

    +
    + +
    +
    标签
    + {currentUser.tags.concat(newTags).map(item => ( + {item.label} + ))} + {inputVisible && ( + this.saveInputRef(ref)} + type="text" + size="small" + style={{ width: 78 }} + value={inputValue} + onChange={this.handleInputChange} + onBlur={this.handleInputConfirm} + onPressEnter={this.handleInputConfirm} + /> + )} + {!inputVisible && ( + + + + )} +
    + +
    +
    团队
    + + {currentUser.notice && + currentUser.notice.map(item => ( + + + + {item.member} + + + ))} + +
    +
    + ) : null} +
    + + + + {this.renderChildrenByTabKey(tabKey)} + + +
    +
    + ); + } +} + +export default Center; diff --git a/src/pages/account/center/model.ts b/src/pages/account/center/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..d437337a971beddb5635267a9741850d2cb5528a --- /dev/null +++ b/src/pages/account/center/model.ts @@ -0,0 +1,70 @@ +import { AnyAction, Reducer } from 'redux'; +import { EffectsCommandMap } from 'dva'; +import { CurrentUser, ListItemDataType } from './data.d'; +import { queryCurrent, queryFakeList } from './service'; + +export interface ModalState { + currentUser: Partial; + list: ListItemDataType[]; +} + +export type Effect = ( + action: AnyAction, + effects: EffectsCommandMap & { select: (func: (state: ModalState) => T) => T }, +) => void; + +export interface ModelType { + namespace: string; + state: ModalState; + effects: { + fetchCurrent: Effect; + fetch: Effect; + }; + reducers: { + saveCurrentUser: Reducer; + queryList: Reducer; + }; +} + +const Model: ModelType = { + namespace: 'accountCenter', + + state: { + currentUser: {}, + list: [], + }, + + effects: { + *fetchCurrent(_, { call, put }) { + const response = yield call(queryCurrent); + yield put({ + type: 'saveCurrentUser', + payload: response, + }); + }, + *fetch({ payload }, { call, put }) { + const response = yield call(queryFakeList, payload); + yield put({ + type: 'queryList', + payload: Array.isArray(response) ? response : [], + }); + }, + }, + + reducers: { + saveCurrentUser(state, action) { + return { + ...(state as ModalState), + currentUser: action.payload || {}, + }; + }, + queryList(state, action) { + return { + ...(state as ModalState), + list: action.payload, + }; + }, + }, +}; + +export default Model; diff --git a/src/pages/account/center/service.ts b/src/pages/account/center/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..db8254d58677ca4cf5db3be9efeb75680cdf6c2f --- /dev/null +++ b/src/pages/account/center/service.ts @@ -0,0 +1,11 @@ +import request from 'umi-request'; + +export async function queryCurrent() { + return request('/api/currentUser'); +} + +export async function queryFakeList(params: { count: number }) { + return request('/api/fake_list', { + params, + }); +} diff --git a/src/pages/account/settings/_mock.ts b/src/pages/account/settings/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2f20b9c969431e01cd671b2f42431cc8eb251a9 --- /dev/null +++ b/src/pages/account/settings/_mock.ts @@ -0,0 +1,69 @@ +import city from './geographic/city.json'; +import province from './geographic/province.json'; + +function getProvince(req: any, res: { json: (arg0: { name: string; id: string }[]) => void }) { + return res.json(province); +} + +function getCity( + req: { params: { province: string | number } }, + res: { json: (arg: any) => void }, +) { + return res.json(city[req.params.province]); +} +// 代码中会兼容本地 service mock 以及部署站点的静态数据 +export default { + // 支持值为 Object 和 Array + 'GET /api/currentUser': { + name: 'Serati Ma', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png', + userid: '00000001', + email: 'antdesign@alipay.com', + signature: '海纳百川,有容乃大', + title: '交互专家', + group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED', + tags: [ + { + key: '0', + label: '很有想法的', + }, + { + key: '1', + label: '专注设计', + }, + { + key: '2', + label: '辣~', + }, + { + key: '3', + label: '大长腿', + }, + { + key: '4', + label: '川妹子', + }, + { + key: '5', + label: '海纳百川', + }, + ], + notifyCount: 12, + unreadCount: 11, + country: 'China', + geographic: { + province: { + label: '浙江省', + key: '330000', + }, + city: { + label: '杭州市', + key: '330100', + }, + }, + address: '西湖区工专路 77 号', + phone: '0752-268888888', + }, + 'GET /api/geographic/province': getProvince, + 'GET /api/geographic/city/:province': getCity, +}; diff --git a/src/pages/account/settings/components/BaseView.less b/src/pages/account/settings/components/BaseView.less new file mode 100644 index 0000000000000000000000000000000000000000..b731637e94e9301e4eb0e497f0eab3fce01dd033 --- /dev/null +++ b/src/pages/account/settings/components/BaseView.less @@ -0,0 +1,52 @@ +@import '~antd/es/style/themes/default.less'; + +.baseView { + display: flex; + padding-top: 12px; + + .left { + min-width: 224px; + max-width: 448px; + } + .right { + flex: 1; + padding-left: 104px; + .avatar_title { + height: 22px; + margin-bottom: 8px; + color: @heading-color; + font-size: @font-size-base; + line-height: 22px; + } + .avatar { + width: 144px; + height: 144px; + margin-bottom: 12px; + overflow: hidden; + img { + width: 100%; + } + } + .button_view { + width: 144px; + text-align: center; + } + } +} + +@media screen and (max-width: @screen-xl) { + .baseView { + flex-direction: column-reverse; + + .right { + display: flex; + flex-direction: column; + align-items: center; + max-width: 448px; + padding: 20px; + .avatar_title { + display: none; + } + } + } +} diff --git a/src/pages/account/settings/components/GeographicView.less b/src/pages/account/settings/components/GeographicView.less new file mode 100644 index 0000000000000000000000000000000000000000..9905aafe3468e8bab4323316e761c873aec77c56 --- /dev/null +++ b/src/pages/account/settings/components/GeographicView.less @@ -0,0 +1,19 @@ +@import '~antd/es/style/themes/default.less'; + +.row { + .item { + width: 50%; + max-width: 220px; + } + .item:first-child { + width: ~'calc(50% - 8px)'; + margin-right: 8px; + } +} + +@media screen and (max-width: @screen-sm) { + .item:first-child { + margin: 0; + margin-bottom: 8px; + } +} diff --git a/src/pages/account/settings/components/GeographicView.tsx b/src/pages/account/settings/components/GeographicView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32559a51d199fa72c0b18163cbf37f0c39c4a440 --- /dev/null +++ b/src/pages/account/settings/components/GeographicView.tsx @@ -0,0 +1,175 @@ +import React, { Component } from 'react'; +import { Select, Spin } from 'antd'; + +import { Dispatch } from 'redux'; +import { connect } from 'dva'; +import { CityType, ProvinceType } from '../data.d'; +import styles from './GeographicView.less'; + +const { Option } = Select; + +interface SelectItem { + label: string; + key: string; +} +const nullSelectItem: SelectItem = { + label: '', + key: '', +}; + +interface GeographicViewProps { + dispatch?: Dispatch; + province?: ProvinceType[]; + city?: CityType[]; + value?: { + province: SelectItem; + city: SelectItem; + }; + loading?: boolean; + onChange?: (value: { province: SelectItem; city: SelectItem }) => void; +} + +@connect( + ({ + accountSettings, + loading, + }: { + accountSettings: { + province: ProvinceType[]; + city: CityType[]; + }; + loading: any; + }) => { + const { province, city } = accountSettings; + return { + province, + city, + loading: loading.models.accountSettings, + }; + }, +) +class GeographicView extends Component { + componentDidMount = () => { + const { dispatch } = this.props; + if (dispatch) { + dispatch({ + type: 'accountSettings/fetchProvince', + }); + } + }; + + componentDidUpdate(props: GeographicViewProps) { + const { dispatch, value } = this.props; + + if (!props.value && !!value && !!value.province) { + if (dispatch) { + dispatch({ + type: 'accountSettings/fetchCity', + payload: value.province.key, + }); + } + } + } + + getProvinceOption() { + const { province } = this.props; + if (province) { + return this.getOption(province); + } + return []; + } + + getCityOption = () => { + const { city } = this.props; + if (city) { + return this.getOption(city); + } + return []; + }; + + getOption = (list: CityType[] | ProvinceType[]) => { + if (!list || list.length < 1) { + return ( + + ); + } + return (list as CityType[]).map(item => ( + + )); + }; + + selectProvinceItem = (item: SelectItem) => { + const { dispatch, onChange } = this.props; + + if (dispatch) { + dispatch({ + type: 'accountSettings/fetchCity', + payload: item.key, + }); + } + if (onChange) { + onChange({ + province: item, + city: nullSelectItem, + }); + } + }; + + selectCityItem = (item: SelectItem) => { + const { value, onChange } = this.props; + if (value && onChange) { + onChange({ + province: value.province, + city: item, + }); + } + }; + + conversionObject() { + const { value } = this.props; + if (!value) { + return { + province: nullSelectItem, + city: nullSelectItem, + }; + } + const { province, city } = value; + return { + province: province || nullSelectItem, + city: city || nullSelectItem, + }; + } + + render() { + const { province, city } = this.conversionObject(); + const { loading } = this.props; + return ( + + + + + ); + } +} + +export default GeographicView; diff --git a/src/pages/account/settings/components/PhoneView.less b/src/pages/account/settings/components/PhoneView.less new file mode 100644 index 0000000000000000000000000000000000000000..f66a32d6e318a09597d140bb721daa365ae15a2c --- /dev/null +++ b/src/pages/account/settings/components/PhoneView.less @@ -0,0 +1,11 @@ +@import '~antd/es/style/themes/default.less'; + +.area_code { + width: 30%; + max-width: 128px; + margin-right: 8px; +} +.phone_number { + width: ~'calc(70% - 8px)'; + max-width: 312px; +} diff --git a/src/pages/account/settings/components/PhoneView.tsx b/src/pages/account/settings/components/PhoneView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e97ec0c104c23d36e37b17e966322d5f3d46e6cc --- /dev/null +++ b/src/pages/account/settings/components/PhoneView.tsx @@ -0,0 +1,43 @@ +import React, { Fragment, PureComponent } from 'react'; + +import { Input } from 'antd'; +import styles from './PhoneView.less'; + +interface PhoneViewProps { + value?: string; + onChange?: (value: string) => void; +} + +class PhoneView extends PureComponent { + render() { + const { value, onChange } = this.props; + let values = ['', '']; + if (value) { + values = value.split('-'); + } + return ( + + { + if (onChange) { + onChange(`${e.target.value}-${values[1]}`); + } + }} + /> + { + if (onChange) { + onChange(`${values[0]}-${e.target.value}`); + } + }} + value={values[1]} + /> + + ); + } +} + +export default PhoneView; diff --git a/src/pages/account/settings/components/base.tsx b/src/pages/account/settings/components/base.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7968677bdcf8e0c6a8b85c6ee1952e7242b34e8 --- /dev/null +++ b/src/pages/account/settings/components/base.tsx @@ -0,0 +1,222 @@ +import { Button, Form, Input, Select, Upload, message } from 'antd'; +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; +import React, { Component, Fragment } from 'react'; + +import { FormComponentProps } from 'antd/es/form'; +import { connect } from 'dva'; +import { CurrentUser } from '../data.d'; +import GeographicView from './GeographicView'; +import PhoneView from './PhoneView'; +import styles from './BaseView.less'; + +const FormItem = Form.Item; +const { Option } = Select; + +// 头像组件 方便以后独立,增加裁剪之类的功能 +const AvatarView = ({ avatar }: { avatar: string }) => ( + +
    + +
    +
    + avatar +
    + +
    + +
    +
    +
    +); +interface SelectItem { + label: string; + key: string; +} + +const validatorGeographic = ( + _: any, + value: { + province: SelectItem; + city: SelectItem; + }, + callback: (message?: string) => void, +) => { + const { province, city } = value; + if (!province.key) { + callback('Please input your province!'); + } + if (!city.key) { + callback('Please input your city!'); + } + callback(); +}; + +const validatorPhone = (rule: any, value: string, callback: (message?: string) => void) => { + const values = value.split('-'); + if (!values[0]) { + callback('Please input your area code!'); + } + if (!values[1]) { + callback('Please input your phone number!'); + } + callback(); +}; + +interface BaseViewProps extends FormComponentProps { + currentUser?: CurrentUser; +} + +@connect(({ accountSettings }: { accountSettings: { currentUser: CurrentUser } }) => ({ + currentUser: accountSettings.currentUser, +})) +class BaseView extends Component { + view: HTMLDivElement | undefined = undefined; + + componentDidMount() { + this.setBaseInfo(); + } + + setBaseInfo = () => { + const { currentUser, form } = this.props; + if (currentUser) { + Object.keys(form.getFieldsValue()).forEach(key => { + const obj = {}; + obj[key] = currentUser[key] || null; + form.setFieldsValue(obj); + }); + } + }; + + getAvatarURL() { + const { currentUser } = this.props; + if (currentUser) { + if (currentUser.avatar) { + return currentUser.avatar; + } + const url = 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png'; + return url; + } + return ''; + } + + getViewDom = (ref: HTMLDivElement) => { + this.view = ref; + }; + + handlerSubmit = (event: React.MouseEvent) => { + event.preventDefault(); + const { form } = this.props; + form.validateFields(err => { + if (!err) { + message.success(formatMessage({ id: 'account-settings.basic.update.success' })); + } + }); + }; + + render() { + const { + form: { getFieldDecorator }, + } = this.props; + return ( +
    +
    +
    + + {getFieldDecorator('email', { + rules: [ + { + required: true, + message: formatMessage({ id: 'account-settings.basic.email-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('name', { + rules: [ + { + required: true, + message: formatMessage({ id: 'account-settings.basic.nickname-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('profile', { + rules: [ + { + required: true, + message: formatMessage({ id: 'account-settings.basic.profile-message' }, {}), + }, + ], + })( + , + )} + + + {getFieldDecorator('country', { + rules: [ + { + required: true, + message: formatMessage({ id: 'account-settings.basic.country-message' }, {}), + }, + ], + })( + , + )} + + + {getFieldDecorator('geographic', { + rules: [ + { + required: true, + message: formatMessage({ id: 'account-settings.basic.geographic-message' }, {}), + }, + { + validator: validatorGeographic, + }, + ], + })()} + + + {getFieldDecorator('address', { + rules: [ + { + required: true, + message: formatMessage({ id: 'account-settings.basic.address-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('phone', { + rules: [ + { + required: true, + message: formatMessage({ id: 'account-settings.basic.phone-message' }, {}), + }, + { validator: validatorPhone }, + ], + })()} + + +
    +
    +
    + +
    +
    + ); + } +} + +export default Form.create()(BaseView); diff --git a/src/pages/account/settings/components/binding.tsx b/src/pages/account/settings/components/binding.tsx new file mode 100644 index 0000000000000000000000000000000000000000..390943343677534e835a0d4546dab6424c460369 --- /dev/null +++ b/src/pages/account/settings/components/binding.tsx @@ -0,0 +1,60 @@ +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; +import { Icon, List } from 'antd'; +import React, { Component, Fragment } from 'react'; + +class BindingView extends Component { + getData = () => [ + { + title: formatMessage({ id: 'account-settings.binding.taobao' }, {}), + description: formatMessage({ id: 'account-settings.binding.taobao-description' }, {}), + actions: [ + + + , + ], + avatar: , + }, + { + title: formatMessage({ id: 'account-settings.binding.alipay' }, {}), + description: formatMessage({ id: 'account-settings.binding.alipay-description' }, {}), + actions: [ + + + , + ], + avatar: , + }, + { + title: formatMessage({ id: 'account-settings.binding.dingding' }, {}), + description: formatMessage({ id: 'account-settings.binding.dingding-description' }, {}), + actions: [ + + + , + ], + avatar: , + }, + ]; + + render() { + return ( + + ( + + + + )} + /> + + ); + } +} + +export default BindingView; diff --git a/src/pages/account/settings/components/notification.tsx b/src/pages/account/settings/components/notification.tsx new file mode 100644 index 0000000000000000000000000000000000000000..334686fb287467cc86826e30efed564ed36134b5 --- /dev/null +++ b/src/pages/account/settings/components/notification.tsx @@ -0,0 +1,54 @@ +import { List, Switch } from 'antd'; +import React, { Component, Fragment } from 'react'; + +import { formatMessage } from 'umi-plugin-react/locale'; + +type Unpacked = T extends (infer U)[] ? U : T; + +class NotificationView extends Component { + getData = () => { + const Action = ( + + ); + return [ + { + title: formatMessage({ id: 'account-settings.notification.password' }, {}), + description: formatMessage({ id: 'account-settings.notification.password-description' }, {}), + actions: [Action], + }, + { + title: formatMessage({ id: 'account-settings.notification.messages' }, {}), + description: formatMessage({ id: 'account-settings.notification.messages-description' }, {}), + actions: [Action], + }, + { + title: formatMessage({ id: 'account-settings.notification.todo' }, {}), + description: formatMessage({ id: 'account-settings.notification.todo-description' }, {}), + actions: [Action], + }, + ]; + }; + + render() { + const data = this.getData(); + return ( + + > + itemLayout="horizontal" + dataSource={data} + renderItem={item => ( + + + + )} + /> + + ); + } +} + +export default NotificationView; diff --git a/src/pages/account/settings/components/security.tsx b/src/pages/account/settings/components/security.tsx new file mode 100644 index 0000000000000000000000000000000000000000..408ad6443f0dbb25a62a1429a482aa7b565c5548 --- /dev/null +++ b/src/pages/account/settings/components/security.tsx @@ -0,0 +1,105 @@ +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; +import React, { Component, Fragment } from 'react'; + +import { List } from 'antd'; + +type Unpacked = T extends (infer U)[] ? U : T; + +const passwordStrength = { + strong: ( + + + + ), + medium: ( + + + + ), + weak: ( + + + Weak + + ), +}; + +class SecurityView extends Component { + getData = () => [ + { + title: formatMessage({ id: 'account-settings.security.password' }, {}), + description: ( + + {formatMessage({ id: 'account-settings.security.password-description' })}: + {passwordStrength.strong} + + ), + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'account-settings.security.phone' }, {}), + description: `${formatMessage( + { id: 'account-settings.security.phone-description' }, + {}, + )}:138****8293`, + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'account-settings.security.question' }, {}), + description: formatMessage({ id: 'account-settings.security.question-description' }, {}), + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'account-settings.security.email' }, {}), + description: `${formatMessage( + { id: 'account-settings.security.email-description' }, + {}, + )}:ant***sign.com`, + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'account-settings.security.mfa' }, {}), + description: formatMessage({ id: 'account-settings.security.mfa-description' }, {}), + actions: [ + + + , + ], + }, + ]; + + render() { + const data = this.getData(); + return ( + + > + itemLayout="horizontal" + dataSource={data} + renderItem={item => ( + + + + )} + /> + + ); + } +} + +export default SecurityView; diff --git a/src/pages/account/settings/data.d.ts b/src/pages/account/settings/data.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b0a824add2f3c1e21a3e95de4308c964357f492 --- /dev/null +++ b/src/pages/account/settings/data.d.ts @@ -0,0 +1,48 @@ +export interface TagType { + key: string; + label: string; +} + +export interface ProvinceType { + label: string; + key: string; +} + +export interface CityType { + label: string; + key: string; +} + +export interface GeographicType { + province: ProvinceType; + city: CityType; +} + +export interface NoticeType { + id: string; + title: string; + logo: string; + description: string; + updatedAt: string; + member: string; + href: string; + memberLink: string; +} + +export interface CurrentUser { + name: string; + avatar: string; + userid: string; + notice: NoticeType[]; + email: string; + signature: string; + title: string; + group: string; + tags: TagType[]; + notifyCount: number; + unreadCount: number; + country: string; + geographic: GeographicType; + address: string; + phone: string; +} diff --git a/src/pages/account/settings/geographic/city.json b/src/pages/account/settings/geographic/city.json new file mode 100644 index 0000000000000000000000000000000000000000..297837477ab80c14edca173521e1b5e6f97461af --- /dev/null +++ b/src/pages/account/settings/geographic/city.json @@ -0,0 +1,1784 @@ +{ + "110000": [ + { + "province": "北京市", + "name": "市辖区", + "id": "110100" + } + ], + "120000": [ + { + "province": "天津市", + "name": "市辖区", + "id": "120100" + } + ], + "130000": [ + { + "province": "河北省", + "name": "石家庄市", + "id": "130100" + }, + { + "province": "河北省", + "name": "唐山市", + "id": "130200" + }, + { + "province": "河北省", + "name": "秦皇岛市", + "id": "130300" + }, + { + "province": "河北省", + "name": "邯郸市", + "id": "130400" + }, + { + "province": "河北省", + "name": "邢台市", + "id": "130500" + }, + { + "province": "河北省", + "name": "保定市", + "id": "130600" + }, + { + "province": "河北省", + "name": "张家口市", + "id": "130700" + }, + { + "province": "河北省", + "name": "承德市", + "id": "130800" + }, + { + "province": "河北省", + "name": "沧州市", + "id": "130900" + }, + { + "province": "河北省", + "name": "廊坊市", + "id": "131000" + }, + { + "province": "河北省", + "name": "衡水市", + "id": "131100" + }, + { + "province": "河北省", + "name": "省直辖县级行政区划", + "id": "139000" + } + ], + "140000": [ + { + "province": "山西省", + "name": "太原市", + "id": "140100" + }, + { + "province": "山西省", + "name": "大同市", + "id": "140200" + }, + { + "province": "山西省", + "name": "阳泉市", + "id": "140300" + }, + { + "province": "山西省", + "name": "长治市", + "id": "140400" + }, + { + "province": "山西省", + "name": "晋城市", + "id": "140500" + }, + { + "province": "山西省", + "name": "朔州市", + "id": "140600" + }, + { + "province": "山西省", + "name": "晋中市", + "id": "140700" + }, + { + "province": "山西省", + "name": "运城市", + "id": "140800" + }, + { + "province": "山西省", + "name": "忻州市", + "id": "140900" + }, + { + "province": "山西省", + "name": "临汾市", + "id": "141000" + }, + { + "province": "山西省", + "name": "吕梁市", + "id": "141100" + } + ], + "150000": [ + { + "province": "内蒙古自治区", + "name": "呼和浩特市", + "id": "150100" + }, + { + "province": "内蒙古自治区", + "name": "包头市", + "id": "150200" + }, + { + "province": "内蒙古自治区", + "name": "乌海市", + "id": "150300" + }, + { + "province": "内蒙古自治区", + "name": "赤峰市", + "id": "150400" + }, + { + "province": "内蒙古自治区", + "name": "通辽市", + "id": "150500" + }, + { + "province": "内蒙古自治区", + "name": "鄂尔多斯市", + "id": "150600" + }, + { + "province": "内蒙古自治区", + "name": "呼伦贝尔市", + "id": "150700" + }, + { + "province": "内蒙古自治区", + "name": "巴彦淖尔市", + "id": "150800" + }, + { + "province": "内蒙古自治区", + "name": "乌兰察布市", + "id": "150900" + }, + { + "province": "内蒙古自治区", + "name": "兴安盟", + "id": "152200" + }, + { + "province": "内蒙古自治区", + "name": "锡林郭勒盟", + "id": "152500" + }, + { + "province": "内蒙古自治区", + "name": "阿拉善盟", + "id": "152900" + } + ], + "210000": [ + { + "province": "辽宁省", + "name": "沈阳市", + "id": "210100" + }, + { + "province": "辽宁省", + "name": "大连市", + "id": "210200" + }, + { + "province": "辽宁省", + "name": "鞍山市", + "id": "210300" + }, + { + "province": "辽宁省", + "name": "抚顺市", + "id": "210400" + }, + { + "province": "辽宁省", + "name": "本溪市", + "id": "210500" + }, + { + "province": "辽宁省", + "name": "丹东市", + "id": "210600" + }, + { + "province": "辽宁省", + "name": "锦州市", + "id": "210700" + }, + { + "province": "辽宁省", + "name": "营口市", + "id": "210800" + }, + { + "province": "辽宁省", + "name": "阜新市", + "id": "210900" + }, + { + "province": "辽宁省", + "name": "辽阳市", + "id": "211000" + }, + { + "province": "辽宁省", + "name": "盘锦市", + "id": "211100" + }, + { + "province": "辽宁省", + "name": "铁岭市", + "id": "211200" + }, + { + "province": "辽宁省", + "name": "朝阳市", + "id": "211300" + }, + { + "province": "辽宁省", + "name": "葫芦岛市", + "id": "211400" + } + ], + "220000": [ + { + "province": "吉林省", + "name": "长春市", + "id": "220100" + }, + { + "province": "吉林省", + "name": "吉林市", + "id": "220200" + }, + { + "province": "吉林省", + "name": "四平市", + "id": "220300" + }, + { + "province": "吉林省", + "name": "辽源市", + "id": "220400" + }, + { + "province": "吉林省", + "name": "通化市", + "id": "220500" + }, + { + "province": "吉林省", + "name": "白山市", + "id": "220600" + }, + { + "province": "吉林省", + "name": "松原市", + "id": "220700" + }, + { + "province": "吉林省", + "name": "白城市", + "id": "220800" + }, + { + "province": "吉林省", + "name": "延边朝鲜族自治州", + "id": "222400" + } + ], + "230000": [ + { + "province": "黑龙江省", + "name": "哈尔滨市", + "id": "230100" + }, + { + "province": "黑龙江省", + "name": "齐齐哈尔市", + "id": "230200" + }, + { + "province": "黑龙江省", + "name": "鸡西市", + "id": "230300" + }, + { + "province": "黑龙江省", + "name": "鹤岗市", + "id": "230400" + }, + { + "province": "黑龙江省", + "name": "双鸭山市", + "id": "230500" + }, + { + "province": "黑龙江省", + "name": "大庆市", + "id": "230600" + }, + { + "province": "黑龙江省", + "name": "伊春市", + "id": "230700" + }, + { + "province": "黑龙江省", + "name": "佳木斯市", + "id": "230800" + }, + { + "province": "黑龙江省", + "name": "七台河市", + "id": "230900" + }, + { + "province": "黑龙江省", + "name": "牡丹江市", + "id": "231000" + }, + { + "province": "黑龙江省", + "name": "黑河市", + "id": "231100" + }, + { + "province": "黑龙江省", + "name": "绥化市", + "id": "231200" + }, + { + "province": "黑龙江省", + "name": "大兴安岭地区", + "id": "232700" + } + ], + "310000": [ + { + "province": "上海市", + "name": "市辖区", + "id": "310100" + } + ], + "320000": [ + { + "province": "江苏省", + "name": "南京市", + "id": "320100" + }, + { + "province": "江苏省", + "name": "无锡市", + "id": "320200" + }, + { + "province": "江苏省", + "name": "徐州市", + "id": "320300" + }, + { + "province": "江苏省", + "name": "常州市", + "id": "320400" + }, + { + "province": "江苏省", + "name": "苏州市", + "id": "320500" + }, + { + "province": "江苏省", + "name": "南通市", + "id": "320600" + }, + { + "province": "江苏省", + "name": "连云港市", + "id": "320700" + }, + { + "province": "江苏省", + "name": "淮安市", + "id": "320800" + }, + { + "province": "江苏省", + "name": "盐城市", + "id": "320900" + }, + { + "province": "江苏省", + "name": "扬州市", + "id": "321000" + }, + { + "province": "江苏省", + "name": "镇江市", + "id": "321100" + }, + { + "province": "江苏省", + "name": "泰州市", + "id": "321200" + }, + { + "province": "江苏省", + "name": "宿迁市", + "id": "321300" + } + ], + "330000": [ + { + "province": "浙江省", + "name": "杭州市", + "id": "330100" + }, + { + "province": "浙江省", + "name": "宁波市", + "id": "330200" + }, + { + "province": "浙江省", + "name": "温州市", + "id": "330300" + }, + { + "province": "浙江省", + "name": "嘉兴市", + "id": "330400" + }, + { + "province": "浙江省", + "name": "湖州市", + "id": "330500" + }, + { + "province": "浙江省", + "name": "绍兴市", + "id": "330600" + }, + { + "province": "浙江省", + "name": "金华市", + "id": "330700" + }, + { + "province": "浙江省", + "name": "衢州市", + "id": "330800" + }, + { + "province": "浙江省", + "name": "舟山市", + "id": "330900" + }, + { + "province": "浙江省", + "name": "台州市", + "id": "331000" + }, + { + "province": "浙江省", + "name": "丽水市", + "id": "331100" + } + ], + "340000": [ + { + "province": "安徽省", + "name": "合肥市", + "id": "340100" + }, + { + "province": "安徽省", + "name": "芜湖市", + "id": "340200" + }, + { + "province": "安徽省", + "name": "蚌埠市", + "id": "340300" + }, + { + "province": "安徽省", + "name": "淮南市", + "id": "340400" + }, + { + "province": "安徽省", + "name": "马鞍山市", + "id": "340500" + }, + { + "province": "安徽省", + "name": "淮北市", + "id": "340600" + }, + { + "province": "安徽省", + "name": "铜陵市", + "id": "340700" + }, + { + "province": "安徽省", + "name": "安庆市", + "id": "340800" + }, + { + "province": "安徽省", + "name": "黄山市", + "id": "341000" + }, + { + "province": "安徽省", + "name": "滁州市", + "id": "341100" + }, + { + "province": "安徽省", + "name": "阜阳市", + "id": "341200" + }, + { + "province": "安徽省", + "name": "宿州市", + "id": "341300" + }, + { + "province": "安徽省", + "name": "六安市", + "id": "341500" + }, + { + "province": "安徽省", + "name": "亳州市", + "id": "341600" + }, + { + "province": "安徽省", + "name": "池州市", + "id": "341700" + }, + { + "province": "安徽省", + "name": "宣城市", + "id": "341800" + } + ], + "350000": [ + { + "province": "福建省", + "name": "福州市", + "id": "350100" + }, + { + "province": "福建省", + "name": "厦门市", + "id": "350200" + }, + { + "province": "福建省", + "name": "莆田市", + "id": "350300" + }, + { + "province": "福建省", + "name": "三明市", + "id": "350400" + }, + { + "province": "福建省", + "name": "泉州市", + "id": "350500" + }, + { + "province": "福建省", + "name": "漳州市", + "id": "350600" + }, + { + "province": "福建省", + "name": "南平市", + "id": "350700" + }, + { + "province": "福建省", + "name": "龙岩市", + "id": "350800" + }, + { + "province": "福建省", + "name": "宁德市", + "id": "350900" + } + ], + "360000": [ + { + "province": "江西省", + "name": "南昌市", + "id": "360100" + }, + { + "province": "江西省", + "name": "景德镇市", + "id": "360200" + }, + { + "province": "江西省", + "name": "萍乡市", + "id": "360300" + }, + { + "province": "江西省", + "name": "九江市", + "id": "360400" + }, + { + "province": "江西省", + "name": "新余市", + "id": "360500" + }, + { + "province": "江西省", + "name": "鹰潭市", + "id": "360600" + }, + { + "province": "江西省", + "name": "赣州市", + "id": "360700" + }, + { + "province": "江西省", + "name": "吉安市", + "id": "360800" + }, + { + "province": "江西省", + "name": "宜春市", + "id": "360900" + }, + { + "province": "江西省", + "name": "抚州市", + "id": "361000" + }, + { + "province": "江西省", + "name": "上饶市", + "id": "361100" + } + ], + "370000": [ + { + "province": "山东省", + "name": "济南市", + "id": "370100" + }, + { + "province": "山东省", + "name": "青岛市", + "id": "370200" + }, + { + "province": "山东省", + "name": "淄博市", + "id": "370300" + }, + { + "province": "山东省", + "name": "枣庄市", + "id": "370400" + }, + { + "province": "山东省", + "name": "东营市", + "id": "370500" + }, + { + "province": "山东省", + "name": "烟台市", + "id": "370600" + }, + { + "province": "山东省", + "name": "潍坊市", + "id": "370700" + }, + { + "province": "山东省", + "name": "济宁市", + "id": "370800" + }, + { + "province": "山东省", + "name": "泰安市", + "id": "370900" + }, + { + "province": "山东省", + "name": "威海市", + "id": "371000" + }, + { + "province": "山东省", + "name": "日照市", + "id": "371100" + }, + { + "province": "山东省", + "name": "莱芜市", + "id": "371200" + }, + { + "province": "山东省", + "name": "临沂市", + "id": "371300" + }, + { + "province": "山东省", + "name": "德州市", + "id": "371400" + }, + { + "province": "山东省", + "name": "聊城市", + "id": "371500" + }, + { + "province": "山东省", + "name": "滨州市", + "id": "371600" + }, + { + "province": "山东省", + "name": "菏泽市", + "id": "371700" + } + ], + "410000": [ + { + "province": "河南省", + "name": "郑州市", + "id": "410100" + }, + { + "province": "河南省", + "name": "开封市", + "id": "410200" + }, + { + "province": "河南省", + "name": "洛阳市", + "id": "410300" + }, + { + "province": "河南省", + "name": "平顶山市", + "id": "410400" + }, + { + "province": "河南省", + "name": "安阳市", + "id": "410500" + }, + { + "province": "河南省", + "name": "鹤壁市", + "id": "410600" + }, + { + "province": "河南省", + "name": "新乡市", + "id": "410700" + }, + { + "province": "河南省", + "name": "焦作市", + "id": "410800" + }, + { + "province": "河南省", + "name": "濮阳市", + "id": "410900" + }, + { + "province": "河南省", + "name": "许昌市", + "id": "411000" + }, + { + "province": "河南省", + "name": "漯河市", + "id": "411100" + }, + { + "province": "河南省", + "name": "三门峡市", + "id": "411200" + }, + { + "province": "河南省", + "name": "南阳市", + "id": "411300" + }, + { + "province": "河南省", + "name": "商丘市", + "id": "411400" + }, + { + "province": "河南省", + "name": "信阳市", + "id": "411500" + }, + { + "province": "河南省", + "name": "周口市", + "id": "411600" + }, + { + "province": "河南省", + "name": "驻马店市", + "id": "411700" + }, + { + "province": "河南省", + "name": "省直辖县级行政区划", + "id": "419000" + } + ], + "420000": [ + { + "province": "湖北省", + "name": "武汉市", + "id": "420100" + }, + { + "province": "湖北省", + "name": "黄石市", + "id": "420200" + }, + { + "province": "湖北省", + "name": "十堰市", + "id": "420300" + }, + { + "province": "湖北省", + "name": "宜昌市", + "id": "420500" + }, + { + "province": "湖北省", + "name": "襄阳市", + "id": "420600" + }, + { + "province": "湖北省", + "name": "鄂州市", + "id": "420700" + }, + { + "province": "湖北省", + "name": "荆门市", + "id": "420800" + }, + { + "province": "湖北省", + "name": "孝感市", + "id": "420900" + }, + { + "province": "湖北省", + "name": "荆州市", + "id": "421000" + }, + { + "province": "湖北省", + "name": "黄冈市", + "id": "421100" + }, + { + "province": "湖北省", + "name": "咸宁市", + "id": "421200" + }, + { + "province": "湖北省", + "name": "随州市", + "id": "421300" + }, + { + "province": "湖北省", + "name": "恩施土家族苗族自治州", + "id": "422800" + }, + { + "province": "湖北省", + "name": "省直辖县级行政区划", + "id": "429000" + } + ], + "430000": [ + { + "province": "湖南省", + "name": "长沙市", + "id": "430100" + }, + { + "province": "湖南省", + "name": "株洲市", + "id": "430200" + }, + { + "province": "湖南省", + "name": "湘潭市", + "id": "430300" + }, + { + "province": "湖南省", + "name": "衡阳市", + "id": "430400" + }, + { + "province": "湖南省", + "name": "邵阳市", + "id": "430500" + }, + { + "province": "湖南省", + "name": "岳阳市", + "id": "430600" + }, + { + "province": "湖南省", + "name": "常德市", + "id": "430700" + }, + { + "province": "湖南省", + "name": "张家界市", + "id": "430800" + }, + { + "province": "湖南省", + "name": "益阳市", + "id": "430900" + }, + { + "province": "湖南省", + "name": "郴州市", + "id": "431000" + }, + { + "province": "湖南省", + "name": "永州市", + "id": "431100" + }, + { + "province": "湖南省", + "name": "怀化市", + "id": "431200" + }, + { + "province": "湖南省", + "name": "娄底市", + "id": "431300" + }, + { + "province": "湖南省", + "name": "湘西土家族苗族自治州", + "id": "433100" + } + ], + "440000": [ + { + "province": "广东省", + "name": "广州市", + "id": "440100" + }, + { + "province": "广东省", + "name": "韶关市", + "id": "440200" + }, + { + "province": "广东省", + "name": "深圳市", + "id": "440300" + }, + { + "province": "广东省", + "name": "珠海市", + "id": "440400" + }, + { + "province": "广东省", + "name": "汕头市", + "id": "440500" + }, + { + "province": "广东省", + "name": "佛山市", + "id": "440600" + }, + { + "province": "广东省", + "name": "江门市", + "id": "440700" + }, + { + "province": "广东省", + "name": "湛江市", + "id": "440800" + }, + { + "province": "广东省", + "name": "茂名市", + "id": "440900" + }, + { + "province": "广东省", + "name": "肇庆市", + "id": "441200" + }, + { + "province": "广东省", + "name": "惠州市", + "id": "441300" + }, + { + "province": "广东省", + "name": "梅州市", + "id": "441400" + }, + { + "province": "广东省", + "name": "汕尾市", + "id": "441500" + }, + { + "province": "广东省", + "name": "河源市", + "id": "441600" + }, + { + "province": "广东省", + "name": "阳江市", + "id": "441700" + }, + { + "province": "广东省", + "name": "清远市", + "id": "441800" + }, + { + "province": "广东省", + "name": "东莞市", + "id": "441900" + }, + { + "province": "广东省", + "name": "中山市", + "id": "442000" + }, + { + "province": "广东省", + "name": "潮州市", + "id": "445100" + }, + { + "province": "广东省", + "name": "揭阳市", + "id": "445200" + }, + { + "province": "广东省", + "name": "云浮市", + "id": "445300" + } + ], + "450000": [ + { + "province": "广西壮族自治区", + "name": "南宁市", + "id": "450100" + }, + { + "province": "广西壮族自治区", + "name": "柳州市", + "id": "450200" + }, + { + "province": "广西壮族自治区", + "name": "桂林市", + "id": "450300" + }, + { + "province": "广西壮族自治区", + "name": "梧州市", + "id": "450400" + }, + { + "province": "广西壮族自治区", + "name": "北海市", + "id": "450500" + }, + { + "province": "广西壮族自治区", + "name": "防城港市", + "id": "450600" + }, + { + "province": "广西壮族自治区", + "name": "钦州市", + "id": "450700" + }, + { + "province": "广西壮族自治区", + "name": "贵港市", + "id": "450800" + }, + { + "province": "广西壮族自治区", + "name": "玉林市", + "id": "450900" + }, + { + "province": "广西壮族自治区", + "name": "百色市", + "id": "451000" + }, + { + "province": "广西壮族自治区", + "name": "贺州市", + "id": "451100" + }, + { + "province": "广西壮族自治区", + "name": "河池市", + "id": "451200" + }, + { + "province": "广西壮族自治区", + "name": "来宾市", + "id": "451300" + }, + { + "province": "广西壮族自治区", + "name": "崇左市", + "id": "451400" + } + ], + "460000": [ + { + "province": "海南省", + "name": "海口市", + "id": "460100" + }, + { + "province": "海南省", + "name": "三亚市", + "id": "460200" + }, + { + "province": "海南省", + "name": "三沙市", + "id": "460300" + }, + { + "province": "海南省", + "name": "儋州市", + "id": "460400" + }, + { + "province": "海南省", + "name": "省直辖县级行政区划", + "id": "469000" + } + ], + "500000": [ + { + "province": "重庆市", + "name": "市辖区", + "id": "500100" + }, + { + "province": "重庆市", + "name": "县", + "id": "500200" + } + ], + "510000": [ + { + "province": "四川省", + "name": "成都市", + "id": "510100" + }, + { + "province": "四川省", + "name": "自贡市", + "id": "510300" + }, + { + "province": "四川省", + "name": "攀枝花市", + "id": "510400" + }, + { + "province": "四川省", + "name": "泸州市", + "id": "510500" + }, + { + "province": "四川省", + "name": "德阳市", + "id": "510600" + }, + { + "province": "四川省", + "name": "绵阳市", + "id": "510700" + }, + { + "province": "四川省", + "name": "广元市", + "id": "510800" + }, + { + "province": "四川省", + "name": "遂宁市", + "id": "510900" + }, + { + "province": "四川省", + "name": "内江市", + "id": "511000" + }, + { + "province": "四川省", + "name": "乐山市", + "id": "511100" + }, + { + "province": "四川省", + "name": "南充市", + "id": "511300" + }, + { + "province": "四川省", + "name": "眉山市", + "id": "511400" + }, + { + "province": "四川省", + "name": "宜宾市", + "id": "511500" + }, + { + "province": "四川省", + "name": "广安市", + "id": "511600" + }, + { + "province": "四川省", + "name": "达州市", + "id": "511700" + }, + { + "province": "四川省", + "name": "雅安市", + "id": "511800" + }, + { + "province": "四川省", + "name": "巴中市", + "id": "511900" + }, + { + "province": "四川省", + "name": "资阳市", + "id": "512000" + }, + { + "province": "四川省", + "name": "阿坝藏族羌族自治州", + "id": "513200" + }, + { + "province": "四川省", + "name": "甘孜藏族自治州", + "id": "513300" + }, + { + "province": "四川省", + "name": "凉山彝族自治州", + "id": "513400" + } + ], + "520000": [ + { + "province": "贵州省", + "name": "贵阳市", + "id": "520100" + }, + { + "province": "贵州省", + "name": "六盘水市", + "id": "520200" + }, + { + "province": "贵州省", + "name": "遵义市", + "id": "520300" + }, + { + "province": "贵州省", + "name": "安顺市", + "id": "520400" + }, + { + "province": "贵州省", + "name": "毕节市", + "id": "520500" + }, + { + "province": "贵州省", + "name": "铜仁市", + "id": "520600" + }, + { + "province": "贵州省", + "name": "黔西南布依族苗族自治州", + "id": "522300" + }, + { + "province": "贵州省", + "name": "黔东南苗族侗族自治州", + "id": "522600" + }, + { + "province": "贵州省", + "name": "黔南布依族苗族自治州", + "id": "522700" + } + ], + "530000": [ + { + "province": "云南省", + "name": "昆明市", + "id": "530100" + }, + { + "province": "云南省", + "name": "曲靖市", + "id": "530300" + }, + { + "province": "云南省", + "name": "玉溪市", + "id": "530400" + }, + { + "province": "云南省", + "name": "保山市", + "id": "530500" + }, + { + "province": "云南省", + "name": "昭通市", + "id": "530600" + }, + { + "province": "云南省", + "name": "丽江市", + "id": "530700" + }, + { + "province": "云南省", + "name": "普洱市", + "id": "530800" + }, + { + "province": "云南省", + "name": "临沧市", + "id": "530900" + }, + { + "province": "云南省", + "name": "楚雄彝族自治州", + "id": "532300" + }, + { + "province": "云南省", + "name": "红河哈尼族彝族自治州", + "id": "532500" + }, + { + "province": "云南省", + "name": "文山壮族苗族自治州", + "id": "532600" + }, + { + "province": "云南省", + "name": "西双版纳傣族自治州", + "id": "532800" + }, + { + "province": "云南省", + "name": "大理白族自治州", + "id": "532900" + }, + { + "province": "云南省", + "name": "德宏傣族景颇族自治州", + "id": "533100" + }, + { + "province": "云南省", + "name": "怒江傈僳族自治州", + "id": "533300" + }, + { + "province": "云南省", + "name": "迪庆藏族自治州", + "id": "533400" + } + ], + "540000": [ + { + "province": "西藏自治区", + "name": "拉萨市", + "id": "540100" + }, + { + "province": "西藏自治区", + "name": "日喀则市", + "id": "540200" + }, + { + "province": "西藏自治区", + "name": "昌都市", + "id": "540300" + }, + { + "province": "西藏自治区", + "name": "林芝市", + "id": "540400" + }, + { + "province": "西藏自治区", + "name": "山南市", + "id": "540500" + }, + { + "province": "西藏自治区", + "name": "那曲地区", + "id": "542400" + }, + { + "province": "西藏自治区", + "name": "阿里地区", + "id": "542500" + } + ], + "610000": [ + { + "province": "陕西省", + "name": "西安市", + "id": "610100" + }, + { + "province": "陕西省", + "name": "铜川市", + "id": "610200" + }, + { + "province": "陕西省", + "name": "宝鸡市", + "id": "610300" + }, + { + "province": "陕西省", + "name": "咸阳市", + "id": "610400" + }, + { + "province": "陕西省", + "name": "渭南市", + "id": "610500" + }, + { + "province": "陕西省", + "name": "延安市", + "id": "610600" + }, + { + "province": "陕西省", + "name": "汉中市", + "id": "610700" + }, + { + "province": "陕西省", + "name": "榆林市", + "id": "610800" + }, + { + "province": "陕西省", + "name": "安康市", + "id": "610900" + }, + { + "province": "陕西省", + "name": "商洛市", + "id": "611000" + } + ], + "620000": [ + { + "province": "甘肃省", + "name": "兰州市", + "id": "620100" + }, + { + "province": "甘肃省", + "name": "嘉峪关市", + "id": "620200" + }, + { + "province": "甘肃省", + "name": "金昌市", + "id": "620300" + }, + { + "province": "甘肃省", + "name": "白银市", + "id": "620400" + }, + { + "province": "甘肃省", + "name": "天水市", + "id": "620500" + }, + { + "province": "甘肃省", + "name": "武威市", + "id": "620600" + }, + { + "province": "甘肃省", + "name": "张掖市", + "id": "620700" + }, + { + "province": "甘肃省", + "name": "平凉市", + "id": "620800" + }, + { + "province": "甘肃省", + "name": "酒泉市", + "id": "620900" + }, + { + "province": "甘肃省", + "name": "庆阳市", + "id": "621000" + }, + { + "province": "甘肃省", + "name": "定西市", + "id": "621100" + }, + { + "province": "甘肃省", + "name": "陇南市", + "id": "621200" + }, + { + "province": "甘肃省", + "name": "临夏回族自治州", + "id": "622900" + }, + { + "province": "甘肃省", + "name": "甘南藏族自治州", + "id": "623000" + } + ], + "630000": [ + { + "province": "青海省", + "name": "西宁市", + "id": "630100" + }, + { + "province": "青海省", + "name": "海东市", + "id": "630200" + }, + { + "province": "青海省", + "name": "海北藏族自治州", + "id": "632200" + }, + { + "province": "青海省", + "name": "黄南藏族自治州", + "id": "632300" + }, + { + "province": "青海省", + "name": "海南藏族自治州", + "id": "632500" + }, + { + "province": "青海省", + "name": "果洛藏族自治州", + "id": "632600" + }, + { + "province": "青海省", + "name": "玉树藏族自治州", + "id": "632700" + }, + { + "province": "青海省", + "name": "海西蒙古族藏族自治州", + "id": "632800" + } + ], + "640000": [ + { + "province": "宁夏回族自治区", + "name": "银川市", + "id": "640100" + }, + { + "province": "宁夏回族自治区", + "name": "石嘴山市", + "id": "640200" + }, + { + "province": "宁夏回族自治区", + "name": "吴忠市", + "id": "640300" + }, + { + "province": "宁夏回族自治区", + "name": "固原市", + "id": "640400" + }, + { + "province": "宁夏回族自治区", + "name": "中卫市", + "id": "640500" + } + ], + "650000": [ + { + "province": "新疆维吾尔自治区", + "name": "乌鲁木齐市", + "id": "650100" + }, + { + "province": "新疆维吾尔自治区", + "name": "克拉玛依市", + "id": "650200" + }, + { + "province": "新疆维吾尔自治区", + "name": "吐鲁番市", + "id": "650400" + }, + { + "province": "新疆维吾尔自治区", + "name": "哈密市", + "id": "650500" + }, + { + "province": "新疆维吾尔自治区", + "name": "昌吉回族自治州", + "id": "652300" + }, + { + "province": "新疆维吾尔自治区", + "name": "博尔塔拉蒙古自治州", + "id": "652700" + }, + { + "province": "新疆维吾尔自治区", + "name": "巴音郭楞蒙古自治州", + "id": "652800" + }, + { + "province": "新疆维吾尔自治区", + "name": "阿克苏地区", + "id": "652900" + }, + { + "province": "新疆维吾尔自治区", + "name": "克孜勒苏柯尔克孜自治州", + "id": "653000" + }, + { + "province": "新疆维吾尔自治区", + "name": "喀什地区", + "id": "653100" + }, + { + "province": "新疆维吾尔自治区", + "name": "和田地区", + "id": "653200" + }, + { + "province": "新疆维吾尔自治区", + "name": "伊犁哈萨克自治州", + "id": "654000" + }, + { + "province": "新疆维吾尔自治区", + "name": "塔城地区", + "id": "654200" + }, + { + "province": "新疆维吾尔自治区", + "name": "阿勒泰地区", + "id": "654300" + }, + { + "province": "新疆维吾尔自治区", + "name": "自治区直辖县级行政区划", + "id": "659000" + } + ] +} diff --git a/src/pages/account/settings/geographic/province.json b/src/pages/account/settings/geographic/province.json new file mode 100644 index 0000000000000000000000000000000000000000..910c83f08e3186bae3a6f13b19ae8b0e4b5c5a0d --- /dev/null +++ b/src/pages/account/settings/geographic/province.json @@ -0,0 +1,138 @@ +[ + { + "name": "北京市", + "id": "110000" + }, + { + "name": "天津市", + "id": "120000" + }, + { + "name": "河北省", + "id": "130000" + }, + { + "name": "山西省", + "id": "140000" + }, + { + "name": "内蒙古自治区", + "id": "150000" + }, + { + "name": "辽宁省", + "id": "210000" + }, + { + "name": "吉林省", + "id": "220000" + }, + { + "name": "黑龙江省", + "id": "230000" + }, + { + "name": "上海市", + "id": "310000" + }, + { + "name": "江苏省", + "id": "320000" + }, + { + "name": "浙江省", + "id": "330000" + }, + { + "name": "安徽省", + "id": "340000" + }, + { + "name": "福建省", + "id": "350000" + }, + { + "name": "江西省", + "id": "360000" + }, + { + "name": "山东省", + "id": "370000" + }, + { + "name": "河南省", + "id": "410000" + }, + { + "name": "湖北省", + "id": "420000" + }, + { + "name": "湖南省", + "id": "430000" + }, + { + "name": "广东省", + "id": "440000" + }, + { + "name": "广西壮族自治区", + "id": "450000" + }, + { + "name": "海南省", + "id": "460000" + }, + { + "name": "重庆市", + "id": "500000" + }, + { + "name": "四川省", + "id": "510000" + }, + { + "name": "贵州省", + "id": "520000" + }, + { + "name": "云南省", + "id": "530000" + }, + { + "name": "西藏自治区", + "id": "540000" + }, + { + "name": "陕西省", + "id": "610000" + }, + { + "name": "甘肃省", + "id": "620000" + }, + { + "name": "青海省", + "id": "630000" + }, + { + "name": "宁夏回族自治区", + "id": "640000" + }, + { + "name": "新疆维吾尔自治区", + "id": "650000" + }, + { + "name": "台湾省", + "id": "710000" + }, + { + "name": "香港特别行政区", + "id": "810000" + }, + { + "name": "澳门特别行政区", + "id": "820000" + } +] diff --git a/src/pages/account/settings/index.tsx b/src/pages/account/settings/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ebf0a71dedd7bf7e3727ce4fdadb955d6e4ee70 --- /dev/null +++ b/src/pages/account/settings/index.tsx @@ -0,0 +1,167 @@ +import React, { Component } from 'react'; + +import { Dispatch } from 'redux'; +import { FormattedMessage } from 'umi-plugin-react/locale'; +import { GridContent } from '@ant-design/pro-layout'; +import { Menu } from 'antd'; +import { connect } from 'dva'; +import BaseView from './components/base'; +import BindingView from './components/binding'; +import { CurrentUser } from './data.d'; +import NotificationView from './components/notification'; +import SecurityView from './components/security'; +import styles from './style.less'; + +const { Item } = Menu; + +interface SettingsProps { + dispatch: Dispatch; + currentUser: CurrentUser; +} + +type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification'; +interface SettingsState { + mode: 'inline' | 'horizontal'; + menuMap: { + [key: string]: React.ReactNode; + }; + selectKey: SettingsStateKeys; +} +@connect(({ accountSettings }: { accountSettings: { currentUser: CurrentUser } }) => ({ + currentUser: accountSettings.currentUser, +})) +class Settings extends Component< + SettingsProps, + SettingsState +> { + main: HTMLDivElement | undefined = undefined; + + constructor(props: SettingsProps) { + super(props); + const menuMap = { + base: , + security: ( + + ), + binding: ( + + ), + notification: ( + + ), + }; + this.state = { + mode: 'inline', + menuMap, + selectKey: 'base', + }; + } + + componentDidMount() { + const { dispatch } = this.props; + dispatch({ + type: 'accountSettings/fetchCurrent', + }); + window.addEventListener('resize', this.resize); + this.resize(); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resize); + } + + getMenu = () => { + const { menuMap } = this.state; + return Object.keys(menuMap).map(item => {menuMap[item]}); + }; + + getRightTitle = () => { + const { selectKey, menuMap } = this.state; + return menuMap[selectKey]; + }; + + selectKey = (key: SettingsStateKeys) => { + this.setState({ + selectKey: key, + }); + }; + + resize = () => { + if (!this.main) { + return; + } + requestAnimationFrame(() => { + if (!this.main) { + return; + } + let mode: 'inline' | 'horizontal' = 'inline'; + const { offsetWidth } = this.main; + if (this.main.offsetWidth < 641 && offsetWidth > 400) { + mode = 'horizontal'; + } + if (window.innerWidth < 768 && offsetWidth > 400) { + mode = 'horizontal'; + } + this.setState({ + mode, + }); + }); + }; + + renderChildren = () => { + const { selectKey } = this.state; + switch (selectKey) { + case 'base': + return ; + case 'security': + return ; + case 'binding': + return ; + case 'notification': + return ; + default: + break; + } + + return null; + }; + + render() { + const { currentUser } = this.props; + if (!currentUser.userid) { + return ''; + } + const { mode, selectKey } = this.state; + return ( + +
    { + if (ref) { + this.main = ref; + } + }} + > +
    + this.selectKey(key as SettingsStateKeys)} + > + {this.getMenu()} + +
    +
    +
    {this.getRightTitle()}
    + {this.renderChildren()} +
    +
    +
    + ); + } +} + +export default Settings; diff --git a/src/pages/account/settings/locales/en-US.ts b/src/pages/account/settings/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..49b58a926fd7c8c7dedbc2d1fe5ec9216648399f --- /dev/null +++ b/src/pages/account/settings/locales/en-US.ts @@ -0,0 +1,61 @@ +export default { + 'account-settings.menuMap.basic': 'Basic Settings', + 'account-settings.menuMap.security': 'Security Settings', + 'account-settings.menuMap.binding': 'Account Binding', + 'account-settings.menuMap.notification': 'New Message Notification', + 'account-settings.basic.avatar': 'Avatar', + 'account-settings.basic.change-avatar': 'Change avatar', + 'account-settings.basic.email': 'Email', + 'account-settings.basic.email-message': 'Please input your email!', + 'account-settings.basic.nickname': 'Nickname', + 'account-settings.basic.nickname-message': 'Please input your Nickname!', + 'account-settings.basic.profile': 'Personal profile', + 'account-settings.basic.profile-message': 'Please input your personal profile!', + 'account-settings.basic.profile-placeholder': 'Brief introduction to yourself', + 'account-settings.basic.country': 'Country/Region', + 'account-settings.basic.country-message': 'Please input your country!', + 'account-settings.basic.geographic': 'Province or city', + 'account-settings.basic.geographic-message': 'Please input your geographic info!', + 'account-settings.basic.address': 'Street Address', + 'account-settings.basic.address-message': 'Please input your address!', + 'account-settings.basic.phone': 'Phone Number', + 'account-settings.basic.phone-message': 'Please input your phone!', + 'account-settings.basic.update': 'Update Information', + 'account-settings.basic.update.success': 'Update basic information successfully', + 'account-settings.security.strong': 'Strong', + 'account-settings.security.medium': 'Medium', + 'account-settings.security.weak': 'Weak', + 'account-settings.security.password': 'Account Password', + 'account-settings.security.password-description': 'Current password strength:', + 'account-settings.security.phone': 'Security Phone', + 'account-settings.security.phone-description': 'Bound phone:', + 'account-settings.security.question': 'Security Question', + 'account-settings.security.question-description': + 'The security question is not set, and the security policy can effectively protect the account security', + 'account-settings.security.email': 'Backup Email', + 'account-settings.security.email-description': 'Bound Email:', + 'account-settings.security.mfa': 'MFA Device', + 'account-settings.security.mfa-description': + 'Unbound MFA device, after binding, can be confirmed twice', + 'account-settings.security.modify': 'Modify', + 'account-settings.security.set': 'Set', + 'account-settings.security.bind': 'Bind', + 'account-settings.binding.taobao': 'Binding Taobao', + 'account-settings.binding.taobao-description': 'Currently unbound Taobao account', + 'account-settings.binding.alipay': 'Binding Alipay', + 'account-settings.binding.alipay-description': 'Currently unbound Alipay account', + 'account-settings.binding.dingding': 'Binding DingTalk', + 'account-settings.binding.dingding-description': 'Currently unbound DingTalk account', + 'account-settings.binding.bind': 'Bind', + 'account-settings.notification.password': 'Account Password', + 'account-settings.notification.password-description': + 'Messages from other users will be notified in the form of a station letter', + 'account-settings.notification.messages': 'System Messages', + 'account-settings.notification.messages-description': + 'System messages will be notified in the form of a station letter', + 'account-settings.notification.todo': 'To-do Notification', + 'account-settings.notification.todo-description': + 'The to-do list will be notified in the form of a letter from the station', + 'account-settings.settings.open': 'Open', + 'account-settings.settings.close': 'Close', +}; diff --git a/src/pages/account/settings/locales/zh-CN.ts b/src/pages/account/settings/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..a05152d50f52b37806bdaa0524d5e99dbadfb6b8 --- /dev/null +++ b/src/pages/account/settings/locales/zh-CN.ts @@ -0,0 +1,56 @@ +export default { + 'account-settings.menuMap.basic': '基本设置', + 'account-settings.menuMap.security': '安全设置', + 'account-settings.menuMap.binding': '账号绑定', + 'account-settings.menuMap.notification': '新消息通知', + 'account-settings.basic.avatar': '头像', + 'account-settings.basic.change-avatar': '更换头像', + 'account-settings.basic.email': '邮箱', + 'account-settings.basic.email-message': '请输入您的邮箱!', + 'account-settings.basic.nickname': '昵称', + 'account-settings.basic.nickname-message': '请输入您的昵称!', + 'account-settings.basic.profile': '个人简介', + 'account-settings.basic.profile-message': '请输入个人简介!', + 'account-settings.basic.profile-placeholder': '个人简介', + 'account-settings.basic.country': '国家/地区', + 'account-settings.basic.country-message': '请输入您的国家或地区!', + 'account-settings.basic.geographic': '所在省市', + 'account-settings.basic.geographic-message': '请输入您的所在省市!', + 'account-settings.basic.address': '街道地址', + 'account-settings.basic.address-message': '请输入您的街道地址!', + 'account-settings.basic.phone': '联系电话', + 'account-settings.basic.phone-message': '请输入您的联系电话!', + 'account-settings.basic.update': '更新基本信息', + 'account-settings.basic.update.success': '更新基本信息成功', + 'account-settings.security.strong': '强', + 'account-settings.security.medium': '中', + 'account-settings.security.weak': '弱', + 'account-settings.security.password': '账户密码', + 'account-settings.security.password-description': '当前密码强度:', + 'account-settings.security.phone': '密保手机', + 'account-settings.security.phone-description': '已绑定手机:', + 'account-settings.security.question': '密保问题', + 'account-settings.security.question-description': '未设置密保问题,密保问题可有效保护账户安全', + 'account-settings.security.email': '备用邮箱', + 'account-settings.security.email-description': '已绑定邮箱:', + 'account-settings.security.mfa': 'MFA 设备', + 'account-settings.security.mfa-description': '未绑定 MFA 设备,绑定后,可以进行二次确认', + 'account-settings.security.modify': '修改', + 'account-settings.security.set': '设置', + 'account-settings.security.bind': '绑定', + 'account-settings.binding.taobao': '绑定淘宝', + 'account-settings.binding.taobao-description': '当前未绑定淘宝账号', + 'account-settings.binding.alipay': '绑定支付宝', + 'account-settings.binding.alipay-description': '当前未绑定支付宝账号', + 'account-settings.binding.dingding': '绑定钉钉', + 'account-settings.binding.dingding-description': '当前未绑定钉钉账号', + 'account-settings.binding.bind': '绑定', + 'account-settings.notification.password': '账户密码', + 'account-settings.notification.password-description': '其他用户的消息将以站内信的形式通知', + 'account-settings.notification.messages': '系统消息', + 'account-settings.notification.messages-description': '系统消息将以站内信的形式通知', + 'account-settings.notification.todo': '待办任务', + 'account-settings.notification.todo-description': '待办任务将以站内信的形式通知', + 'account-settings.settings.open': '开', + 'account-settings.settings.close': '关', +}; diff --git a/src/pages/account/settings/locales/zh-TW.ts b/src/pages/account/settings/locales/zh-TW.ts new file mode 100644 index 0000000000000000000000000000000000000000..a769edd58741f243ed8b4dbf0e94aeda9de344c0 --- /dev/null +++ b/src/pages/account/settings/locales/zh-TW.ts @@ -0,0 +1,56 @@ +export default { + 'account-settings.menuMap.basic': '基本設置', + 'account-settings.menuMap.security': '安全設置', + 'account-settings.menuMap.binding': '賬號綁定', + 'account-settings.menuMap.notification': '新消息通知', + 'account-settings.basic.avatar': '頭像', + 'account-settings.basic.change-avatar': '更換頭像', + 'account-settings.basic.email': '郵箱', + 'account-settings.basic.email-message': '請輸入您的郵箱!', + 'account-settings.basic.nickname': '昵稱', + 'account-settings.basic.nickname-message': '請輸入您的昵稱!', + 'account-settings.basic.profile': '個人簡介', + 'account-settings.basic.profile-message': '請輸入個人簡介!', + 'account-settings.basic.profile-placeholder': '個人簡介', + 'account-settings.basic.country': '國家/地區', + 'account-settings.basic.country-message': '請輸入您的國家或地區!', + 'account-settings.basic.geographic': '所在省市', + 'account-settings.basic.geographic-message': '請輸入您的所在省市!', + 'account-settings.basic.address': '街道地址', + 'account-settings.basic.address-message': '請輸入您的街道地址!', + 'account-settings.basic.phone': '聯系電話', + 'account-settings.basic.phone-message': '請輸入您的聯系電話!', + 'account-settings.basic.update': '更新基本信息', + 'account-settings.basic.update.success': '更新基本信息成功', + 'account-settings.security.strong': '強', + 'account-settings.security.medium': '中', + 'account-settings.security.weak': '弱', + 'account-settings.security.password': '賬戶密碼', + 'account-settings.security.password-description': '當前密碼強度:', + 'account-settings.security.phone': '密保手機', + 'account-settings.security.phone-description': '已綁定手機:', + 'account-settings.security.question': '密保問題', + 'account-settings.security.question-description': '未設置密保問題,密保問題可有效保護賬戶安全', + 'account-settings.security.email': '備用郵箱', + 'account-settings.security.email-description': '已綁定郵箱:', + 'account-settings.security.mfa': 'MFA 設備', + 'account-settings.security.mfa-description': '未綁定 MFA 設備,綁定後,可以進行二次確認', + 'account-settings.security.modify': '修改', + 'account-settings.security.set': '設置', + 'account-settings.security.bind': '綁定', + 'account-settings.binding.taobao': '綁定淘寶', + 'account-settings.binding.taobao-description': '當前未綁定淘寶賬號', + 'account-settings.binding.alipay': '綁定支付寶', + 'account-settings.binding.alipay-description': '當前未綁定支付寶賬號', + 'account-settings.binding.dingding': '綁定釘釘', + 'account-settings.binding.dingding-description': '當前未綁定釘釘賬號', + 'account-settings.binding.bind': '綁定', + 'account-settings.notification.password': '賬戶密碼', + 'account-settings.notification.password-description': '其他用戶的消息將以站內信的形式通知', + 'account-settings.notification.messages': '系統消息', + 'account-settings.notification.messages-description': '系統消息將以站內信的形式通知', + 'account-settings.notification.todo': '待辦任務', + 'account-settings.notification.todo-description': '待辦任務將以站內信的形式通知', + 'account-settings.settings.open': '開', + 'account-settings.settings.close': '關', +}; diff --git a/src/pages/account/settings/model.ts b/src/pages/account/settings/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1fe8ac97e867992ca77c7fd4eca8ae9fa9d9e68 --- /dev/null +++ b/src/pages/account/settings/model.ts @@ -0,0 +1,119 @@ +import { AnyAction, Reducer } from 'redux'; +import { EffectsCommandMap } from 'dva'; +import { CityType, CurrentUser, ProvinceType } from './data.d'; +import { queryCity, queryCurrent, queryProvince, query as queryUsers } from './service'; + +export interface ModalState { + currentUser?: Partial; + province?: ProvinceType[]; + city?: CityType[]; + isLoading?: boolean; +} + +export type Effect = ( + action: AnyAction, + effects: EffectsCommandMap & { select: (func: (state: ModalState) => T) => T }, +) => void; + +export interface ModelType { + namespace: string; + state: ModalState; + effects: { + fetchCurrent: Effect; + fetch: Effect; + fetchProvince: Effect; + fetchCity: Effect; + }; + reducers: { + saveCurrentUser: Reducer; + changeNotifyCount: Reducer; + setProvince: Reducer; + setCity: Reducer; + changeLoading: Reducer; + }; +} + +const Model: ModelType = { + namespace: 'accountSettings', + + state: { + currentUser: {}, + province: [], + city: [], + isLoading: false, + }, + + effects: { + *fetch(_, { call, put }) { + const response = yield call(queryUsers); + yield put({ + type: 'save', + payload: response, + }); + }, + *fetchCurrent(_, { call, put }) { + const response = yield call(queryCurrent); + yield put({ + type: 'saveCurrentUser', + payload: response, + }); + }, + *fetchProvince(_, { call, put }) { + yield put({ + type: 'changeLoading', + payload: true, + }); + const response = yield call(queryProvince); + yield put({ + type: 'setProvince', + payload: response, + }); + }, + *fetchCity({ payload }, { call, put }) { + const response = yield call(queryCity, payload); + yield put({ + type: 'setCity', + payload: response, + }); + }, + }, + + reducers: { + saveCurrentUser(state, action) { + return { + ...state, + currentUser: action.payload || {}, + }; + }, + changeNotifyCount(state = {}, action) { + return { + ...state, + currentUser: { + ...state.currentUser, + notifyCount: action.payload.totalCount, + unreadCount: action.payload.unreadCount, + }, + }; + }, + setProvince(state, action) { + return { + ...state, + province: action.payload, + }; + }, + setCity(state, action) { + return { + ...state, + city: action.payload, + }; + }, + changeLoading(state, action) { + return { + ...state, + isLoading: action.payload, + }; + }, + }, +}; + +export default Model; diff --git a/src/pages/account/settings/service.ts b/src/pages/account/settings/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..4da62fb1835236cfdf335f3a1a0a3d38994e81a1 --- /dev/null +++ b/src/pages/account/settings/service.ts @@ -0,0 +1,17 @@ +import request from 'umi-request'; + +export async function queryCurrent() { + return request('/api/currentUser'); +} + +export async function queryProvince() { + return request('/api/geographic/province'); +} + +export async function queryCity(province: string) { + return request(`/api/geographic/city/${province}`); +} + +export async function query() { + return request('/api/users'); +} diff --git a/src/pages/account/settings/style.less b/src/pages/account/settings/style.less new file mode 100644 index 0000000000000000000000000000000000000000..d5c3693c87fc728e257a02c65864c0b009b1473d --- /dev/null +++ b/src/pages/account/settings/style.less @@ -0,0 +1,97 @@ +@import '~antd/es/style/themes/default.less'; + +.main { + display: flex; + width: 100%; + height: 100%; + padding-top: 16px; + padding-bottom: 16px; + overflow: auto; + background-color: @menu-bg; + .leftMenu { + width: 224px; + border-right: @border-width-base @border-style-base @border-color-split; + :global { + .ant-menu-inline { + border: none; + } + .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected { + font-weight: bold; + } + } + } + .right { + flex: 1; + padding-top: 8px; + padding-right: 40px; + padding-bottom: 8px; + padding-left: 40px; + .title { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } + } + :global { + .ant-list-split .ant-list-item:last-child { + border-bottom: 1px solid @border-color-split; + } + .ant-list-item { + padding-top: 14px; + padding-bottom: 14px; + } + } +} +:global { + .ant-list-item-meta { + // 账号绑定图标 + .taobao { + display: block; + color: #ff4000; + font-size: 48px; + line-height: 48px; + border-radius: @border-radius-base; + } + .dingding { + margin: 2px; + padding: 6px; + color: #fff; + font-size: 32px; + line-height: 32px; + background-color: #2eabff; + border-radius: @border-radius-base; + } + .alipay { + color: #2eabff; + font-size: 48px; + line-height: 48px; + border-radius: @border-radius-base; + } + } + + // 密码强度 + font.strong { + color: @success-color; + } + font.medium { + color: @warning-color; + } + font.weak { + color: @error-color; + } +} + +@media screen and (max-width: @screen-md) { + .main { + flex-direction: column; + .leftMenu { + width: 100%; + border: none; + } + .right { + padding: 40px; + } + } +} diff --git a/src/pages/dashboard/analysis/_mock.ts b/src/pages/dashboard/analysis/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..03e49108da4aa2b024fd53d6e6b8211031dbf574 --- /dev/null +++ b/src/pages/dashboard/analysis/_mock.ts @@ -0,0 +1,197 @@ +import moment from 'moment'; +import { AnalysisData, RadarData, VisitDataType } from './data.d'; + +// mock data +const visitData: VisitDataType[] = []; +const beginDay = new Date().getTime(); + +const fakeY = [7, 5, 4, 2, 4, 7, 5, 6, 5, 9, 6, 3, 1, 5, 3, 6, 5]; +for (let i = 0; i < fakeY.length; i += 1) { + visitData.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY[i], + }); +} + +const visitData2 = []; +const fakeY2 = [1, 6, 4, 8, 3, 7, 2]; +for (let i = 0; i < fakeY2.length; i += 1) { + visitData2.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY2[i], + }); +} + +const salesData = []; +for (let i = 0; i < 12; i += 1) { + salesData.push({ + x: `${i + 1}月`, + y: Math.floor(Math.random() * 1000) + 200, + }); +} +const searchData = []; +for (let i = 0; i < 50; i += 1) { + searchData.push({ + index: i + 1, + keyword: `搜索关键词-${i}`, + count: Math.floor(Math.random() * 1000), + range: Math.floor(Math.random() * 100), + status: Math.floor((Math.random() * 10) % 2), + }); +} +const salesTypeData = [ + { + x: '家用电器', + y: 4544, + }, + { + x: '食用酒水', + y: 3321, + }, + { + x: '个护健康', + y: 3113, + }, + { + x: '服饰箱包', + y: 2341, + }, + { + x: '母婴产品', + y: 1231, + }, + { + x: '其他', + y: 1231, + }, +]; + +const salesTypeDataOnline = [ + { + x: '家用电器', + y: 244, + }, + { + x: '食用酒水', + y: 321, + }, + { + x: '个护健康', + y: 311, + }, + { + x: '服饰箱包', + y: 41, + }, + { + x: '母婴产品', + y: 121, + }, + { + x: '其他', + y: 111, + }, +]; + +const salesTypeDataOffline = [ + { + x: '家用电器', + y: 99, + }, + { + x: '食用酒水', + y: 188, + }, + { + x: '个护健康', + y: 344, + }, + { + x: '服饰箱包', + y: 255, + }, + { + x: '其他', + y: 65, + }, +]; + +const offlineData = []; +for (let i = 0; i < 10; i += 1) { + offlineData.push({ + name: `Stores ${i}`, + cvr: Math.ceil(Math.random() * 9) / 10, + }); +} +const offlineChartData = []; +for (let i = 0; i < 20; i += 1) { + offlineChartData.push({ + x: new Date().getTime() + 1000 * 60 * 30 * i, + y1: Math.floor(Math.random() * 100) + 10, + y2: Math.floor(Math.random() * 100) + 10, + }); +} + +const radarOriginData = [ + { + name: '个人', + ref: 10, + koubei: 8, + output: 4, + contribute: 5, + hot: 7, + }, + { + name: '团队', + ref: 3, + koubei: 9, + output: 6, + contribute: 3, + hot: 1, + }, + { + name: '部门', + ref: 4, + koubei: 1, + output: 6, + contribute: 5, + hot: 7, + }, +]; + +const radarData: RadarData[] = []; +const radarTitleMap = { + ref: '引用', + koubei: '口碑', + output: '产量', + contribute: '贡献', + hot: '热度', +}; +radarOriginData.forEach(item => { + Object.keys(item).forEach(key => { + if (key !== 'name') { + radarData.push({ + name: item.name, + label: radarTitleMap[key], + value: item[key], + }); + } + }); +}); + +const getFakeChartData: AnalysisData = { + visitData, + visitData2, + salesData, + searchData, + offlineData, + offlineChartData, + salesTypeData, + salesTypeDataOnline, + salesTypeDataOffline, + radarData, +}; + +export default { + 'GET /api/fake_chart_data': getFakeChartData, +}; diff --git a/src/pages/dashboard/analysis/components/Charts/Bar/index.tsx b/src/pages/dashboard/analysis/components/Charts/Bar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..86f9fbc99285d214f3422dcd34f8f325af908b58 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Bar/index.tsx @@ -0,0 +1,133 @@ +import { Axis, Chart, Geom, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import Debounce from 'lodash.debounce'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export interface BarProps { + title: React.ReactNode; + color?: string; + padding?: [number, number, number, number]; + height?: number; + data: { + x: string; + y: number; + }[]; + forceFit?: boolean; + autoLabel?: boolean; + style?: React.CSSProperties; +} + +class Bar extends Component< + BarProps, + { + autoHideXLabels: boolean; + } +> { + state = { + autoHideXLabels: false, + }; + + root: HTMLDivElement | undefined = undefined; + + node: HTMLDivElement | undefined = undefined; + + resize = Debounce(() => { + if (!this.node || !this.node.parentNode) { + return; + } + const canvasWidth = (this.node.parentNode as HTMLDivElement).clientWidth; + const { data = [], autoLabel = true } = this.props; + if (!autoLabel) { + return; + } + const minWidth = data.length * 30; + const { autoHideXLabels } = this.state; + + if (canvasWidth <= minWidth) { + if (!autoHideXLabels) { + this.setState({ + autoHideXLabels: true, + }); + } + } else if (autoHideXLabels) { + this.setState({ + autoHideXLabels: false, + }); + } + }, 500); + + componentDidMount() { + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resize); + } + + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + + handleRef = (n: HTMLDivElement) => { + this.node = n; + }; + + render() { + const { + height = 1, + title, + forceFit = true, + data, + color = 'rgba(24, 144, 255, 0.85)', + padding, + } = this.props; + + const { autoHideXLabels } = this.state; + + const scale = { + x: { + type: 'cat', + }, + y: { + min: 0, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + return ( +
    +
    + {title &&

    {title}

    } + + + + + + +
    +
    + ); + } +} + +export default autoHeight()(Bar); diff --git a/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less new file mode 100644 index 0000000000000000000000000000000000000000..d7bf6dda6aef5e4c34cb485f40367a4242ceae03 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less @@ -0,0 +1,75 @@ +@import '~antd/es/style/themes/default.less'; + +.chartCard { + position: relative; + .chartTop { + position: relative; + width: 100%; + overflow: hidden; + } + .chartTopMargin { + margin-bottom: 12px; + } + .chartTopHasMargin { + margin-bottom: 20px; + } + .metaWrap { + float: left; + } + .avatar { + position: relative; + top: 4px; + float: left; + margin-right: 20px; + img { + border-radius: 100%; + } + } + .meta { + height: 22px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + .action { + position: absolute; + top: 4px; + right: 0; + line-height: 1; + cursor: pointer; + } + .total { + height: 38px; + margin-top: 4px; + margin-bottom: 0; + overflow: hidden; + color: @heading-color; + font-size: 30px; + line-height: 38px; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + } + .content { + position: relative; + width: 100%; + margin-bottom: 12px; + } + .contentFixed { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + } + .footer { + margin-top: 8px; + padding-top: 9px; + border-top: 1px solid @border-color-split; + & > * { + position: relative; + } + } + .footerMargin { + margin-top: 20px; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d367b8026647133b64a98d247e25717d641fbb1 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx @@ -0,0 +1,97 @@ +import { Card } from 'antd'; +import { CardProps } from 'antd/es/card'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +type totalType = () => React.ReactNode; + +const renderTotal = (total?: number | totalType | React.ReactNode) => { + if (!total) { + return null; + } + let totalDom; + switch (typeof total) { + case 'undefined': + totalDom = null; + break; + case 'function': + totalDom =
    {total()}
    ; + break; + default: + totalDom =
    {total}
    ; + } + return totalDom; +}; + +export interface ChartCardProps extends CardProps { + title: React.ReactNode; + action?: React.ReactNode; + total?: React.ReactNode | number | (() => React.ReactNode | number); + footer?: React.ReactNode; + contentHeight?: number; + avatar?: React.ReactNode; + style?: React.CSSProperties; +} + +class ChartCard extends React.Component { + renderContent = () => { + const { contentHeight, title, avatar, action, total, footer, children, loading } = this.props; + if (loading) { + return false; + } + return ( +
    +
    +
    {avatar}
    +
    +
    + {title} + {action} +
    + {renderTotal(total)} +
    +
    + {children && ( +
    +
    {children}
    +
    + )} + {footer && ( +
    + {footer} +
    + )} +
    + ); + }; + + render() { + const { + loading = false, + contentHeight, + title, + avatar, + action, + total, + footer, + children, + ...rest + } = this.props; + return ( + + {this.renderContent()} + + ); + } +} + +export default ChartCard; diff --git a/src/pages/dashboard/analysis/components/Charts/Field/index.less b/src/pages/dashboard/analysis/components/Charts/Field/index.less new file mode 100644 index 0000000000000000000000000000000000000000..4fe0d1f643748d0b46519d3873bf119facc46e49 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Field/index.less @@ -0,0 +1,17 @@ +@import '~antd/es/style/themes/default.less'; + +.field { + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + .label, + .number { + font-size: @font-size-base; + line-height: 22px; + } + .number { + margin-left: 8px; + color: @heading-color; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/Field/index.tsx b/src/pages/dashboard/analysis/components/Charts/Field/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..876673faeeb7a079bdceb58faf27aec867541c95 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Field/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from './index.less'; + +export interface FieldProps { + label: React.ReactNode; + value: React.ReactNode; + style?: React.CSSProperties; +} + +const Field: React.SFC = ({ label, value, ...rest }) => ( +
    + {label} + {value} +
    +); + +export default Field; diff --git a/src/pages/dashboard/analysis/components/Charts/Gauge/index.tsx b/src/pages/dashboard/analysis/components/Charts/Gauge/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf9289662dfab097eead4ae8d892a18e4c92073f --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Gauge/index.tsx @@ -0,0 +1,179 @@ +import { Axis, Chart, Coord, Geom, Guide, Shape } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; + +const { Arc, Html, Line } = Guide; + +export interface GaugeProps { + title: React.ReactNode; + color?: string; + height?: number; + bgColor?: number; + percent: number; + forceFit?: boolean; + style?: React.CSSProperties; + formatter: (value: string) => string; +} + +const defaultFormatter = (val: string): string => { + switch (val) { + case '2': + return '差'; + case '4': + return '中'; + case '6': + return '良'; + case '8': + return '优'; + default: + return ''; + } +}; + +if (Shape.registerShape) { + Shape.registerShape('point', 'pointer', { + drawShape(cfg: any, group: any) { + let point = cfg.points[0]; + point = (this as any).parsePoint(point); + const center = (this as any).parsePoint({ + x: 0, + y: 0, + }); + group.addShape('line', { + attrs: { + x1: center.x, + y1: center.y, + x2: point.x, + y2: point.y, + stroke: cfg.color, + lineWidth: 2, + lineCap: 'round', + }, + }); + return group.addShape('circle', { + attrs: { + x: center.x, + y: center.y, + r: 6, + stroke: cfg.color, + lineWidth: 3, + fill: '#fff', + }, + }); + }, + }); +} + +const Gauge: React.FC = props => { + const { + title, + height = 1, + percent, + forceFit = true, + formatter = defaultFormatter, + color = '#2F9CFF', + bgColor = '#F0F2F5', + } = props; + const cols = { + value: { + type: 'linear', + min: 0, + max: 10, + tickCount: 6, + nice: true, + }, + }; + const data = [{ value: percent / 10 }]; + const renderHtml = () => ` +
    +

    ${title}

    +

    + ${(data[0].value * 10).toFixed(2)}% +

    +
    `; + const textStyle: { + fontSize: number; + fill: string; + textAlign: 'center'; + } = { + fontSize: 12, + fill: 'rgba(0, 0, 0, 0.65)', + textAlign: 'center', + }; + + return ( + + + + + + + + + + + + + + + ); +}; + +export default autoHeight()(Gauge); diff --git a/src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx b/src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2e12183b629fb4fbe5dc52806fc8b016b62392b5 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx @@ -0,0 +1,130 @@ +import { Axis, Chart, Geom, Tooltip, AxisProps } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export interface MiniAreaProps { + color?: string; + height?: number; + borderColor?: string; + line?: boolean; + animate?: boolean; + xAxis?: AxisProps; + forceFit?: boolean; + scale?: { + x?: { + tickCount: number; + }; + y?: { + tickCount: number; + }; + }; + yAxis?: Partial; + borderWidth?: number; + data: { + x: number | string; + y: number; + }[]; +} + +const MiniArea: React.FC = props => { + const { + height = 1, + data = [], + forceFit = true, + color = 'rgba(24, 144, 255, 0.2)', + borderColor = '#1089ff', + scale = { x: {}, y: {} }, + borderWidth = 2, + line, + xAxis, + yAxis, + animate = true, + } = props; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const scaleProps = { + x: { + type: 'cat', + range: [0, 1], + ...scale.x, + }, + y: { + min: 0, + ...scale.y, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + const chartHeight = height + 54; + + return ( +
    +
    + {height > 0 && ( + + + + + + {line ? ( + + ) : ( + + )} + + )} +
    +
    + ); +}; + +export default autoHeight()(MiniArea); diff --git a/src/pages/dashboard/analysis/components/Charts/MiniBar/index.tsx b/src/pages/dashboard/analysis/components/Charts/MiniBar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..044ba3c0672bb9ef579241c72169c7f6f6028342 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/MiniBar/index.tsx @@ -0,0 +1,54 @@ +import { Chart, Geom, Tooltip } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export interface MiniBarProps { + color?: string; + height?: number; + data: { + x: number | string; + y: number; + }[]; + forceFit?: boolean; + style?: React.CSSProperties; +} + +const MiniBar: React.FC = props => { + const { height = 0, forceFit = true, color = '#1890FF', data = [] } = props; + + const scale = { + x: { + type: 'cat', + }, + y: { + min: 0, + }, + }; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + // for tooltip not to be hide + const chartHeight = height + 54; + + return ( +
    +
    + + + + +
    +
    + ); +}; +export default autoHeight()(MiniBar); diff --git a/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.less b/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.less new file mode 100644 index 0000000000000000000000000000000000000000..918d6ac3d4c06778017686df02f4ef2a2e43dac8 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.less @@ -0,0 +1,37 @@ +@import '~antd/es/style/themes/default.less'; + +.miniProgress { + position: relative; + width: 100%; + padding: 5px 0; + .progressWrap { + position: relative; + background-color: @background-color-base; + } + .progress { + width: 0; + height: 100%; + background-color: @primary-color; + border-radius: 1px 0 0 1px; + transition: all 0.4s cubic-bezier(0.08, 0.82, 0.17, 1) 0s; + } + .target { + position: absolute; + top: 0; + bottom: 0; + z-index: 9; + width: 20px; + span { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 4px; + border-radius: 100px; + } + span:last-child { + top: auto; + bottom: 0; + } + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx b/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ea0b993709c417e7b654364b85795234beb134b --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import styles from './index.less'; + +export interface MiniProgressProps { + target: number; + targetLabel?: string; + color?: string; + strokeWidth?: number; + percent?: number; + style?: React.CSSProperties; +} + +const MiniProgress: React.SFC = ({ + targetLabel, + target, + color = 'rgb(19, 194, 194)', + strokeWidth, + percent, +}) => ( +
    + +
    + + +
    +
    +
    +
    +
    +
    +); + +export default MiniProgress; diff --git a/src/pages/dashboard/analysis/components/Charts/Pie/index.less b/src/pages/dashboard/analysis/components/Charts/Pie/index.less new file mode 100644 index 0000000000000000000000000000000000000000..8641d658058870152411c872c961d1ba351fef16 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Pie/index.less @@ -0,0 +1,94 @@ +@import '~antd/es/style/themes/default.less'; + +.pie { + position: relative; + .chart { + position: relative; + } + &.hasLegend .chart { + width: ~'calc(100% - 240px)'; + } + .legend { + position: absolute; + top: 50%; + right: 0; + min-width: 200px; + margin: 0 20px; + padding: 0; + list-style: none; + transform: translateY(-50%); + li { + height: 22px; + margin-bottom: 16px; + line-height: 22px; + cursor: pointer; + &:last-child { + margin-bottom: 0; + } + } + } + .dot { + position: relative; + top: -1px; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 8px; + border-radius: 8px; + } + .line { + display: inline-block; + width: 1px; + height: 16px; + margin-right: 8px; + background-color: @border-color-split; + } + .legendTitle { + color: @text-color; + } + .percent { + color: @text-color-secondary; + } + .value { + position: absolute; + right: 0; + } + .title { + margin-bottom: 8px; + } + .total { + position: absolute; + top: 50%; + left: 50%; + max-height: 62px; + text-align: center; + transform: translate(-50%, -50%); + & > h4 { + height: 22px; + margin-bottom: 8px; + color: @text-color-secondary; + font-weight: normal; + font-size: 14px; + line-height: 22px; + } + & > p { + display: block; + height: 32px; + color: @heading-color; + font-size: 1.2em; + line-height: 32px; + white-space: nowrap; + } + } +} + +.legendBlock { + &.hasLegend .chart { + width: 100%; + margin: 0 0 32px 0; + } + .legend { + position: relative; + transform: none; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/Pie/index.tsx b/src/pages/dashboard/analysis/components/Charts/Pie/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad27d90d69e7af2a7052fdaddd282c74fe37e26b --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Pie/index.tsx @@ -0,0 +1,309 @@ +import { Chart, Coord, Geom, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import { DataView } from '@antv/data-set'; +import Debounce from 'lodash.debounce'; +import { Divider } from 'antd'; +import ReactFitText from 'react-fittext'; +import classNames from 'classnames'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +export interface PieProps { + animate?: boolean; + color?: string; + colors?: string[]; + selected?: boolean; + height?: number; + margin?: [number, number, number, number]; + hasLegend?: boolean; + padding?: [number, number, number, number]; + percent?: number; + data?: { + x: string | string; + y: number; + }[]; + inner?: number; + lineWidth?: number; + forceFit?: boolean; + style?: React.CSSProperties; + className?: string; + total?: React.ReactNode | number | (() => React.ReactNode | number); + title?: React.ReactNode; + tooltip?: boolean; + valueFormat?: (value: string) => string | React.ReactNode; + subTitle?: React.ReactNode; +} +interface PieState { + legendData: { checked: boolean; x: string; color: string; percent: number; y: string }[]; + legendBlock: boolean; +} +class Pie extends Component { + state: PieState = { + legendData: [], + legendBlock: false, + }; + + requestRef: number | undefined = undefined; + + root: HTMLDivElement | undefined = undefined; + + chart: G2.Chart | undefined = undefined; + + // for window resize auto responsive legend + resize = Debounce(() => { + const { hasLegend } = this.props; + const { legendBlock } = this.state; + if (!hasLegend || !this.root) { + window.removeEventListener('resize', this.resize); + return; + } + if ( + this.root && + this.root.parentNode && + (this.root.parentNode as HTMLElement).clientWidth <= 380 + ) { + if (!legendBlock) { + this.setState({ + legendBlock: true, + }); + } + } else if (legendBlock) { + this.setState({ + legendBlock: false, + }); + } + }, 400); + + componentDidMount() { + window.addEventListener( + 'resize', + () => { + this.requestRef = requestAnimationFrame(() => this.resize()); + }, + { passive: true }, + ); + } + + componentDidUpdate(preProps: PieProps) { + const { data } = this.props; + if (data !== preProps.data) { + // because of charts data create when rendered + // so there is a trick for get rendered time + this.getLegendData(); + } + } + + componentWillUnmount() { + if (this.requestRef) { + window.cancelAnimationFrame(this.requestRef); + } + window.removeEventListener('resize', this.resize); + if (this.resize) { + (this.resize as any).cancel(); + } + } + + getG2Instance = (chart: G2.Chart) => { + this.chart = chart; + requestAnimationFrame(() => { + this.getLegendData(); + this.resize(); + }); + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + const items = (geom as any).get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map((item: { color: any; _origin: any }[]) => { + /* eslint no-underscore-dangle:0 */ + const origin = item[0]._origin; + origin.color = item[0].color; + origin.checked = true; + return origin; + }); + + this.setState({ + legendData, + }); + }; + + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + + handleLegendClick = (item: any, i: string | number) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter(l => l.checked).map(l => l.x); + + if (this.chart) { + this.chart.filter('x', val => filteredLegendData.indexOf(`${val}`) > -1); + } + + this.setState({ + legendData, + }); + }; + + render() { + const { + valueFormat, + subTitle, + total, + hasLegend = false, + className, + style, + height = 0, + forceFit = true, + percent, + color, + inner = 0.75, + animate = true, + colors, + lineWidth = 1, + } = this.props; + + const { legendData, legendBlock } = this.state; + const pieClassName = classNames(styles.pie, className, { + [styles.hasLegend]: !!hasLegend, + [styles.legendBlock]: legendBlock, + }); + + const { + data: propsData, + selected: propsSelected = true, + tooltip: propsTooltip = true, + } = this.props; + + let data = propsData || []; + let selected = propsSelected; + let tooltip = propsTooltip; + + const defaultColors = colors; + data = data || []; + selected = selected || true; + tooltip = tooltip || true; + let formatColor; + + const scale = { + x: { + type: 'cat', + range: [0, 1], + }, + y: { + min: 0, + }, + }; + + if (percent || percent === 0) { + selected = false; + tooltip = false; + formatColor = (value: string) => { + if (value === '占比') { + return color || 'rgba(24, 144, 255, 0.85)'; + } + return '#F0F2F5'; + }; + + data = [ + { + x: '占比', + y: parseFloat(`${percent}`), + }, + { + x: '反比', + y: 100 - parseFloat(`${percent}`), + }, + ]; + } + + const tooltipFormat: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*percent', + (x: string, p: number) => ({ + name: x, + value: `${(p * 100).toFixed(2)}%`, + }), + ]; + + const padding = [12, 0, 12, 0] as [number, number, number, number]; + + const dv = new DataView(); + dv.source(data).transform({ + type: 'percent', + field: 'y', + dimension: 'x', + as: 'percent', + }); + + return ( +
    + +
    + + {!!tooltip && } + + + + + {(subTitle || total) && ( +
    + {subTitle &&

    {subTitle}

    } + {/* eslint-disable-next-line */} + {total && ( +
    {typeof total === 'function' ? total() : total}
    + )} +
    + )} +
    +
    + + {hasLegend && ( +
      + {legendData.map((item, i) => ( +
    • this.handleLegendClick(item, i)}> + + {item.x} + + + {`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`} + + {valueFormat ? valueFormat(item.y) : item.y} +
    • + ))} +
    + )} +
    + ); + } +} + +export default autoHeight()(Pie); diff --git a/src/pages/dashboard/analysis/components/Charts/TagCloud/index.less b/src/pages/dashboard/analysis/components/Charts/TagCloud/index.less new file mode 100644 index 0000000000000000000000000000000000000000..db8e4dabfdd3f1fd4566ff22f55962648c369c49 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/TagCloud/index.less @@ -0,0 +1,6 @@ +.tagCloud { + overflow: hidden; + canvas { + transform-origin: 0 0; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/TagCloud/index.tsx b/src/pages/dashboard/analysis/components/Charts/TagCloud/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8515ad86f8a30ded8bdfc98ffc9f8abdfa8bf4b1 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/TagCloud/index.tsx @@ -0,0 +1,212 @@ +import { Chart, Coord, Geom, Shape, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import DataSet from '@antv/data-set'; +import Debounce from 'lodash.debounce'; +import classNames from 'classnames'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-underscore-dangle: 0 */ +/* eslint no-param-reassign: 0 */ + +const imgUrl = 'https://gw.alipayobjects.com/zos/rmsportal/gWyeGLCdFFRavBGIDzWk.png'; + +export interface TagCloudProps { + data: { + name: string; + value: number; + }[]; + height?: number; + className?: string; + style?: React.CSSProperties; +} + +interface TagCloudState { + dv: any; + height?: number; + width: number; +} + +class TagCloud extends Component { + state = { + dv: null, + height: 0, + width: 0, + }; + + isUnmount: boolean = false; + + requestRef: number = 0; + + root: HTMLDivElement | undefined = undefined; + + imageMask: HTMLImageElement | undefined = undefined; + + componentDidMount() { + requestAnimationFrame(() => { + this.initTagCloud(); + this.renderChart(this.props); + }); + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentDidUpdate(preProps?: TagCloudProps) { + const { data } = this.props; + if (preProps && JSON.stringify(preProps.data) !== JSON.stringify(data)) { + this.renderChart(this.props); + } + } + + componentWillUnmount() { + this.isUnmount = true; + window.cancelAnimationFrame(this.requestRef); + window.removeEventListener('resize', this.resize); + } + + resize = () => { + this.requestRef = requestAnimationFrame(() => { + this.renderChart(this.props); + }); + }; + + saveRootRef = (node: HTMLDivElement) => { + this.root = node; + }; + + initTagCloud = () => { + function getTextAttrs(cfg: { + x?: any; + y?: any; + style?: any; + opacity?: any; + origin?: any; + color?: any; + }) { + return { + ...cfg.style, + fillOpacity: cfg.opacity, + fontSize: cfg.origin._origin.size, + rotate: cfg.origin._origin.rotate, + text: cfg.origin._origin.text, + textAlign: 'center', + fontFamily: cfg.origin._origin.font, + fill: cfg.color, + textBaseline: 'Alphabetic', + }; + } + + (Shape as any).registerShape('point', 'cloud', { + drawShape( + cfg: { x: any; y: any }, + container: { addShape: (arg0: string, arg1: { attrs: any }) => void }, + ) { + const attrs = getTextAttrs(cfg); + return container.addShape('text', { + attrs: { + ...attrs, + x: cfg.x, + y: cfg.y, + }, + }); + }, + }); + }; + + renderChart = Debounce((nextProps: TagCloudProps) => { + // const colors = ['#1890FF', '#41D9C7', '#2FC25B', '#FACC14', '#9AE65C']; + const { data, height } = nextProps || this.props; + + if (data.length < 1 || !this.root) { + return; + } + + const h = height; + const w = this.root.offsetWidth; + + const onload = () => { + const dv = new DataSet.View().source(data); + const range = dv.range('value'); + const [min, max] = range; + dv.transform({ + type: 'tag-cloud', + fields: ['name', 'value'], + imageMask: this.imageMask, + font: 'Verdana', + size: [w, h], // 宽高设置最好根据 imageMask 做调整 + padding: 0, + timeInterval: 5000, // max execute time + rotate() { + return 0; + }, + fontSize(d: { value: number }) { + const size = ((d.value - min) / (max - min)) ** 2; + return size * (17.5 - 5) + 5; + }, + }); + + if (this.isUnmount) { + return; + } + + this.setState({ + dv, + width: w, + height: h, + }); + }; + + if (!this.imageMask) { + this.imageMask = new Image(); + this.imageMask.crossOrigin = ''; + this.imageMask.src = imgUrl; + + this.imageMask.onload = onload; + } else { + onload(); + } + }, 500); + + render() { + const { className, height } = this.props; + const { dv, width, height: stateHeight } = this.state; + + return ( +
    + {dv && ( + + + + + + )} +
    + ); + } +} + +export default autoHeight()(TagCloud); diff --git a/src/pages/dashboard/analysis/components/Charts/TimelineChart/index.less b/src/pages/dashboard/analysis/components/Charts/TimelineChart/index.less new file mode 100644 index 0000000000000000000000000000000000000000..eadb5bc026ebbb519e7e2dcc1b5ef526e2f07bde --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/TimelineChart/index.less @@ -0,0 +1,5 @@ +@import '~antd/es/style/themes/default.less'; + +.timelineChart { + background: @component-background; +} diff --git a/src/pages/dashboard/analysis/components/Charts/TimelineChart/index.tsx b/src/pages/dashboard/analysis/components/Charts/TimelineChart/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..57c1516f2be1f97ee8b3e8626a311259e6941b41 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/TimelineChart/index.tsx @@ -0,0 +1,132 @@ +import { Axis, Chart, Geom, Legend, Tooltip } from 'bizcharts'; + +import DataSet from '@antv/data-set'; +import React from 'react'; +import Slider from 'bizcharts-plugin-slider'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +export interface TimelineChartProps { + data: { + x: number; + y1: number; + y2: number; + }[]; + title?: string; + titleMap: { y1: string; y2: string }; + padding?: [number, number, number, number]; + height?: number; + style?: React.CSSProperties; + borderWidth?: number; +} + +const TimelineChart: React.FC = props => { + const { + title, + height = 400, + padding = [60, 20, 40, 40] as [number, number, number, number], + titleMap = { + y1: 'y1', + y2: 'y2', + }, + borderWidth = 2, + data: sourceData, + } = props; + + const data = Array.isArray(sourceData) ? sourceData : [{ x: 0, y1: 0, y2: 0 }]; + + data.sort((a, b) => a.x - b.x); + + let max; + if (data[0] && data[0].y1 && data[0].y2) { + max = Math.max( + [...data].sort((a, b) => b.y1 - a.y1)[0].y1, + [...data].sort((a, b) => b.y2 - a.y2)[0].y2, + ); + } + + const ds = new DataSet({ + state: { + start: data[0].x, + end: data[data.length - 1].x, + }, + }); + + const dv = ds.createView(); + dv.source(data) + .transform({ + type: 'filter', + callback: (obj: { x: string }) => { + const date = obj.x; + return date <= ds.state.end && date >= ds.state.start; + }, + }) + .transform({ + type: 'map', + callback(row: { y1: string; y2: string }) { + const newRow = { ...row }; + newRow[titleMap.y1] = row.y1; + newRow[titleMap.y2] = row.y2; + return newRow; + }, + }) + .transform({ + type: 'fold', + fields: [titleMap.y1, titleMap.y2], // 展开字段集 + key: 'key', // key字段 + value: 'value', // value字段 + }); + + const timeScale = { + type: 'time', + tickInterval: 60 * 60 * 1000, + mask: 'HH:mm', + range: [0, 1], + }; + + const cols = { + x: timeScale, + value: { + max, + min: 0, + }, + }; + + const SliderGen = () => ( + { + ds.setState('start', startValue); + ds.setState('end', endValue); + }} + /> + ); + + return ( +
    +
    + {title &&

    {title}

    } + + + + + + +
    + +
    +
    +
    + ); +}; + +export default autoHeight()(TimelineChart); diff --git a/src/pages/dashboard/analysis/components/Charts/WaterWave/index.less b/src/pages/dashboard/analysis/components/Charts/WaterWave/index.less new file mode 100644 index 0000000000000000000000000000000000000000..f52ac142e2c59c12c2aeeb4bde42f45bb893e4b3 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/WaterWave/index.less @@ -0,0 +1,28 @@ +@import '~antd/es/style/themes/default.less'; + +.waterWave { + position: relative; + display: inline-block; + transform-origin: left; + .text { + position: absolute; + top: 32px; + left: 0; + width: 100%; + text-align: center; + span { + color: @text-color-secondary; + font-size: 14px; + line-height: 22px; + } + h4 { + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + } + .waterWaveCanvasWrapper { + transform: scale(0.5); + transform-origin: 0 0; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx b/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..065cd0c607892a295f608a7d55adad484240c537 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx @@ -0,0 +1,236 @@ +import React, { Component } from 'react'; + +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-return-assign: 0 */ +/* eslint no-mixed-operators: 0 */ +// riddle: https://riddle.alibaba-inc.com/riddles/2d9a4b90 + +export interface WaterWaveProps { + title: React.ReactNode; + color?: string; + height?: number; + percent: number; + style?: React.CSSProperties; +} + +class WaterWave extends Component { + state = { + radio: 1, + }; + + timer: number = 0; + + root: HTMLDivElement | undefined | null = null; + + node: HTMLCanvasElement | undefined | null = null; + + componentDidMount() { + this.renderChart(); + this.resize(); + window.addEventListener( + 'resize', + () => { + requestAnimationFrame(() => this.resize()); + }, + { passive: true }, + ); + } + + componentDidUpdate(props: WaterWaveProps) { + const { percent } = this.props; + if (props.percent !== percent) { + // 不加这个会造成绘制缓慢 + this.renderChart('update'); + } + } + + componentWillUnmount() { + cancelAnimationFrame(this.timer); + if (this.node) { + this.node.innerHTML = ''; + } + window.removeEventListener('resize', this.resize); + } + + resize = () => { + if (this.root) { + const { height = 1 } = this.props; + const { offsetWidth } = this.root.parentNode as HTMLElement; + this.setState({ + radio: offsetWidth < height ? offsetWidth / height : 1, + }); + } + }; + + renderChart(type?: string) { + const { percent, color = '#1890FF' } = this.props; + const data = percent / 100; + const self = this; + cancelAnimationFrame(this.timer); + + if (!this.node || (data !== 0 && !data)) { + return; + } + + const canvas = this.node; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + const radius = canvasWidth / 2; + const lineWidth = 2; + const cR = radius - lineWidth; + + ctx.beginPath(); + ctx.lineWidth = lineWidth * 2; + + const axisLength = canvasWidth - lineWidth; + const unit = axisLength / 8; + const range = 0.2; // 振幅 + let currRange = range; + const xOffset = lineWidth; + let sp = 0; // 周期偏移量 + let currData = 0; + const waveupsp = 0.005; // 水波上涨速度 + + let arcStack: number[][] = []; + const bR = radius - lineWidth; + const circleOffset = -(Math.PI / 2); + let circleLock = true; + + for (let i = circleOffset; i < circleOffset + 2 * Math.PI; i += 1 / (8 * Math.PI)) { + arcStack.push([radius + bR * Math.cos(i), radius + bR * Math.sin(i)]); + } + + const cStartPoint = arcStack.shift() as number[]; + ctx.strokeStyle = color; + ctx.moveTo(cStartPoint[0], cStartPoint[1]); + + function drawSin() { + if (!ctx) { + return; + } + ctx.beginPath(); + ctx.save(); + + const sinStack = []; + for (let i = xOffset; i <= xOffset + axisLength; i += 20 / axisLength) { + const x = sp + (xOffset + i) / unit; + const y = Math.sin(x) * currRange; + const dx = i; + const dy = 2 * cR * (1 - currData) + (radius - cR) - unit * y; + + ctx.lineTo(dx, dy); + sinStack.push([dx, dy]); + } + + const startPoint = sinStack.shift() as number[]; + + ctx.lineTo(xOffset + axisLength, canvasHeight); + ctx.lineTo(xOffset, canvasHeight); + ctx.lineTo(startPoint[0], startPoint[1]); + + const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeight); + gradient.addColorStop(0, '#ffffff'); + gradient.addColorStop(1, color); + ctx.fillStyle = gradient; + ctx.fill(); + ctx.restore(); + } + + function render() { + if (!ctx) { + return; + } + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (circleLock && type !== 'update') { + if (arcStack.length) { + const temp = arcStack.shift() as number[]; + ctx.lineTo(temp[0], temp[1]); + ctx.stroke(); + } else { + circleLock = false; + ctx.lineTo(cStartPoint[0], cStartPoint[1]); + ctx.stroke(); + arcStack = []; + + ctx.globalCompositeOperation = 'destination-over'; + ctx.beginPath(); + ctx.lineWidth = lineWidth; + ctx.arc(radius, radius, bR, 0, 2 * Math.PI, true); + + ctx.beginPath(); + ctx.save(); + ctx.arc(radius, radius, radius - 3 * lineWidth, 0, 2 * Math.PI, true); + + ctx.restore(); + ctx.clip(); + ctx.fillStyle = color; + } + } else { + if (data >= 0.85) { + if (currRange > range / 4) { + const t = range * 0.01; + currRange -= t; + } + } else if (data <= 0.1) { + if (currRange < range * 1.5) { + const t = range * 0.01; + currRange += t; + } + } else { + if (currRange <= range) { + const t = range * 0.01; + currRange += t; + } + if (currRange >= range) { + const t = range * 0.01; + currRange -= t; + } + } + if (data - currData > 0) { + currData += waveupsp; + } + if (data - currData < 0) { + currData -= waveupsp; + } + + sp += 0.07; + drawSin(); + } + self.timer = requestAnimationFrame(render); + } + render(); + } + + render() { + const { radio } = this.state; + const { percent, title, height = 1 } = this.props; + return ( +
    (this.root = n)} + style={{ transform: `scale(${radio})` }} + > +
    + (this.node = n)} + width={height * 2} + height={height * 2} + /> +
    +
    + {title && {title}} +

    {percent}%

    +
    +
    + ); + } +} + +export default autoHeight()(WaterWave); diff --git a/src/pages/dashboard/analysis/components/Charts/autoHeight.tsx b/src/pages/dashboard/analysis/components/Charts/autoHeight.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba20956d1627dfeab788690945968b92d7e31774 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/autoHeight.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +export type IReactComponent

    = + | React.StatelessComponent

    + | React.ComponentClass

    + | React.ClassicComponentClass

    ; + +function computeHeight(node: HTMLDivElement) { + const { style } = node; + style.height = '100%'; + const totalHeight = parseInt(`${getComputedStyle(node).height}`, 10); + const padding = + parseInt(`${getComputedStyle(node).paddingTop}`, 10) + + parseInt(`${getComputedStyle(node).paddingBottom}`, 10); + return totalHeight - padding; +} + +function getAutoHeight(n: HTMLDivElement | undefined) { + if (!n) { + return 0; + } + + const node = n; + + let height = computeHeight(node); + const parentNode = node.parentNode as HTMLDivElement; + if (parentNode) { + height = computeHeight(parentNode); + } + + return height; +} + +interface AutoHeightProps { + height?: number; +} + +function autoHeight() { + return

    ( + WrappedComponent: React.ComponentClass

    | React.SFC

    , + ): React.ComponentClass

    => { + class AutoHeightComponent extends React.Component

    { + state = { + computedHeight: 0, + }; + + root: HTMLDivElement | undefined = undefined; + + componentDidMount() { + const { height } = this.props; + if (!height) { + let h = getAutoHeight(this.root); + this.setState({ computedHeight: h }); + if (h < 1) { + h = getAutoHeight(this.root); + this.setState({ computedHeight: h }); + } + } + } + + handleRoot = (node: HTMLDivElement) => { + this.root = node; + }; + + render() { + const { height } = this.props; + const { computedHeight } = this.state; + const h = height || computedHeight; + return ( +

    + {h > 0 && } +
    + ); + } + } + return AutoHeightComponent; + }; +} +export default autoHeight; diff --git a/src/pages/dashboard/analysis/components/Charts/bizcharts.d.ts b/src/pages/dashboard/analysis/components/Charts/bizcharts.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..0815ffeeffcacd0ac9710977ab3d4419d078491c --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/bizcharts.d.ts @@ -0,0 +1,3 @@ +import * as BizChart from 'bizcharts'; + +export = BizChart; diff --git a/src/pages/dashboard/analysis/components/Charts/bizcharts.tsx b/src/pages/dashboard/analysis/components/Charts/bizcharts.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e08db8d6d2dca240451bdf6ab8a30be077a3fd9d --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/bizcharts.tsx @@ -0,0 +1,3 @@ +import * as BizChart from 'bizcharts'; + +export default BizChart; diff --git a/src/pages/dashboard/analysis/components/Charts/index.less b/src/pages/dashboard/analysis/components/Charts/index.less new file mode 100644 index 0000000000000000000000000000000000000000..190428bc80d7cd7f6f22d51fd48fa37b2d44eb10 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/index.less @@ -0,0 +1,19 @@ +.miniChart { + position: relative; + width: 100%; + .chartContent { + position: absolute; + bottom: -28px; + width: 100%; + > div { + margin: 0 -5px; + overflow: hidden; + } + } + .chartLoading { + position: absolute; + top: 16px; + left: 50%; + margin-left: -7px; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/index.tsx b/src/pages/dashboard/analysis/components/Charts/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..50c27030db0d66434d085b1d3cd831b966c215da --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/index.tsx @@ -0,0 +1,45 @@ +import numeral from 'numeral'; +import Bar from './Bar'; +import ChartCard from './ChartCard'; +import Field from './Field'; +import Gauge from './Gauge'; +import MiniArea from './MiniArea'; +import MiniBar from './MiniBar'; +import MiniProgress from './MiniProgress'; +import Pie from './Pie'; +import TagCloud from './TagCloud'; +import TimelineChart from './TimelineChart'; +import WaterWave from './WaterWave'; + +const yuan = (val: number | string) => `¥ ${numeral(val).format('0,0')}`; + +const Charts = { + yuan, + Bar, + Pie, + Gauge, + MiniBar, + MiniArea, + MiniProgress, + ChartCard, + Field, + WaterWave, + TagCloud, + TimelineChart, +}; + +export { + Charts as default, + yuan, + Bar, + Pie, + Gauge, + MiniBar, + MiniArea, + MiniProgress, + ChartCard, + Field, + WaterWave, + TagCloud, + TimelineChart, +}; diff --git a/src/pages/dashboard/analysis/components/IntroduceRow.tsx b/src/pages/dashboard/analysis/components/IntroduceRow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e32004c6afd68306d02ce5b91efd34381f631abc --- /dev/null +++ b/src/pages/dashboard/analysis/components/IntroduceRow.tsx @@ -0,0 +1,160 @@ +import { Col, Icon, Row, Tooltip } from 'antd'; + +import { FormattedMessage } from 'umi-plugin-react/locale'; +import React from 'react'; +import numeral from 'numeral'; +import { ChartCard, MiniArea, MiniBar, MiniProgress, Field } from './Charts'; +import { VisitDataType } from '../data.d'; +import Trend from './Trend'; +import Yuan from '../utils/Yuan'; +import styles from '../style.less'; + +const topColResponsiveProps = { + xs: 24, + sm: 12, + md: 12, + lg: 12, + xl: 6, + style: { marginBottom: 24 }, +}; + +const IntroduceRow = ({ loading, visitData }: { loading: boolean; visitData: VisitDataType[] }) => ( + + + + } + action={ + + } + > + + + } + loading={loading} + total={() => 126560} + footer={ + + } + value={`¥${numeral(12423).format('0,0')}`} + /> + } + contentHeight={46} + > + + + 12% + + + + 11% + + + + + + } + action={ + + } + > + + + } + total={numeral(8846).format('0,0')} + footer={ + + } + value={numeral(1234).format('0,0')} + /> + } + contentHeight={46} + > + + + + + } + action={ + + } + > + + + } + total={numeral(6560).format('0,0')} + footer={ + + } + value="60%" + /> + } + contentHeight={46} + > + + + + + + } + action={ + + } + > + + + } + total="78%" + footer={ +
    + + + 12% + + + + 11% + +
    + } + contentHeight={46} + > + +
    + +
    +); + +export default IntroduceRow; diff --git a/src/pages/dashboard/analysis/components/NumberInfo/index.less b/src/pages/dashboard/analysis/components/NumberInfo/index.less new file mode 100644 index 0000000000000000000000000000000000000000..3e7a3d90a7e2dfb62bb09851a18d35f1487d5575 --- /dev/null +++ b/src/pages/dashboard/analysis/components/NumberInfo/index.less @@ -0,0 +1,68 @@ +@import '~antd/es/style/themes/default.less'; + +.numberInfo { + .suffix { + margin-left: 4px; + color: @text-color; + font-size: 16px; + font-style: normal; + } + .numberInfoTitle { + margin-bottom: 16px; + color: @text-color; + font-size: @font-size-lg; + transition: all 0.3s; + } + .numberInfoSubTitle { + height: 22px; + overflow: hidden; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + } + .numberInfoValue { + margin-top: 4px; + overflow: hidden; + font-size: 0; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + & > span { + display: inline-block; + height: 32px; + margin-right: 32px; + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + .subTotal { + margin-right: 0; + color: @text-color-secondary; + font-size: @font-size-lg; + vertical-align: top; + i { + margin-left: 4px; + font-size: 12px; + transform: scale(0.82); + } + :global { + .anticon-caret-up { + color: @red-6; + } + .anticon-caret-down { + color: @green-6; + } + } + } + } +} +.numberInfolight { + .numberInfoValue { + & > span { + color: @text-color; + } + } +} diff --git a/src/pages/dashboard/analysis/components/NumberInfo/index.tsx b/src/pages/dashboard/analysis/components/NumberInfo/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d573c0918adb091933c7d42d48570bbbf8711a37 --- /dev/null +++ b/src/pages/dashboard/analysis/components/NumberInfo/index.tsx @@ -0,0 +1,62 @@ +import { Icon } from 'antd'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export interface NumberInfoProps { + title?: React.ReactNode | string; + subTitle?: React.ReactNode | string; + total?: React.ReactNode | string; + status?: 'up' | 'down'; + theme?: string; + gap?: number; + subTotal?: number; + suffix?: string; + style?: React.CSSProperties; +} +const NumberInfo: React.SFC = ({ + theme, + title, + subTitle, + total, + subTotal, + status, + suffix, + gap, + ...rest +}) => ( +
    + {title && ( +
    + {title} +
    + )} + {subTitle && ( +
    + {subTitle} +
    + )} +
    + + {total} + {suffix && {suffix}} + + {(status || subTotal) && ( + + {subTotal} + {status && } + + )} +
    +
    +); + +export default NumberInfo; diff --git a/src/pages/dashboard/analysis/components/OfflineData.tsx b/src/pages/dashboard/analysis/components/OfflineData.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f64275d7eec43f36dcbc8ace0f8281a186b38520 --- /dev/null +++ b/src/pages/dashboard/analysis/components/OfflineData.tsx @@ -0,0 +1,80 @@ +import { Card, Col, Row, Tabs } from 'antd'; +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; +import React from 'react'; +import { OfflineChartData, OfflineDataType } from '../data.d'; + +import { TimelineChart, Pie } from './Charts'; +import NumberInfo from './NumberInfo'; +import styles from '../style.less'; + +const CustomTab = ({ + data, + currentTabKey: currentKey, +}: { + data: OfflineDataType; + currentTabKey: string; +}) => ( + + + + } + gap={2} + total={`${data.cvr * 100}%`} + theme={currentKey !== data.name ? 'light' : undefined} + /> + + + + + +); + +const { TabPane } = Tabs; + +const OfflineData = ({ + activeKey, + loading, + offlineData, + offlineChartData, + handleTabChange, +}: { + activeKey: string; + loading: boolean; + offlineData: OfflineDataType[]; + offlineChartData: OfflineChartData[]; + handleTabChange: (activeKey: string) => void; +}) => ( + + + {offlineData.map(shop => ( + } key={shop.name}> +
    + +
    +
    + ))} +
    +
    +); + +export default OfflineData; diff --git a/src/pages/dashboard/analysis/components/PageLoading/index.tsx b/src/pages/dashboard/analysis/components/PageLoading/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77c0f165f9d20b4f974e754efb9cf08606c41a49 --- /dev/null +++ b/src/pages/dashboard/analysis/components/PageLoading/index.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { Spin } from 'antd'; + +// loading components from code split +// https://umijs.org/plugin/umi-plugin-react.html#dynamicimport +export default () => ( +
    + +
    +); diff --git a/src/pages/dashboard/analysis/components/ProportionSales.tsx b/src/pages/dashboard/analysis/components/ProportionSales.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3a5b18802625dd17f145fc36e92dd3716809b044 --- /dev/null +++ b/src/pages/dashboard/analysis/components/ProportionSales.tsx @@ -0,0 +1,73 @@ +import { Card, Radio } from 'antd'; + +import { FormattedMessage } from 'umi-plugin-react/locale'; +import { RadioChangeEvent } from 'antd/es/radio'; +import React from 'react'; +import { VisitDataType } from '../data.d'; +import { Pie } from './Charts'; +import Yuan from '../utils/Yuan'; +import styles from '../style.less'; + +const ProportionSales = ({ + dropdownGroup, + salesType, + loading, + salesPieData, + handleChangeSalesType, +}: { + loading: boolean; + dropdownGroup: React.ReactNode; + salesType: 'all' | 'online' | 'stores'; + salesPieData: VisitDataType[]; + handleChangeSalesType?: (e: RadioChangeEvent) => void; +}) => ( + + } + style={{ + height: '100%', + }} + extra={ +
    + {dropdownGroup} +
    + + + + + + + + + + + +
    +
    + } + > +
    +

    + +

    + } + total={() => {salesPieData.reduce((pre, now) => now.y + pre, 0)}} + data={salesPieData} + valueFormat={value => {value}} + height={248} + lineWidth={4} + /> +
    +
    +); + +export default ProportionSales; diff --git a/src/pages/dashboard/analysis/components/SalesCard.tsx b/src/pages/dashboard/analysis/components/SalesCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c2a21fcf5093066366cd5d14381b7be7c140ed16 --- /dev/null +++ b/src/pages/dashboard/analysis/components/SalesCard.tsx @@ -0,0 +1,161 @@ +import { Card, Col, DatePicker, Row, Tabs } from 'antd'; +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; + +import { RangePickerValue } from 'antd/es/date-picker/interface'; +import React from 'react'; +import numeral from 'numeral'; +import { VisitDataType } from '../data.d'; +import { Bar } from './Charts'; +import styles from '../style.less'; + +const { RangePicker } = DatePicker; +const { TabPane } = Tabs; + +const rankingListData: { title: string; total: number }[] = []; +for (let i = 0; i < 7; i += 1) { + rankingListData.push({ + title: formatMessage({ id: 'dashboard-analysis.analysis.test' }, { no: i }), + total: 323234, + }); +} + +const SalesCard = ({ + rangePickerValue, + salesData, + isActive, + handleRangePickerChange, + loading, + selectDate, +}: { + rangePickerValue: RangePickerValue; + isActive: (key: 'today' | 'week' | 'month' | 'year') => string; + salesData: VisitDataType[]; + loading: boolean; + handleRangePickerChange: (dates: RangePickerValue, dateStrings: [string, string]) => void; + selectDate: (key: 'today' | 'week' | 'month' | 'year') => void; +}) => ( + + + } + size="large" + tabBarStyle={{ marginBottom: 24 }} + > + } + key="sales" + > + + +
    + + } + data={salesData} + /> +
    + + +
    +

    + +

    +
      + {rankingListData.map((item, i) => ( +
    • + + {i + 1} + + + {item.title} + + + {numeral(item.total).format('0,0')} + +
    • + ))} +
    +
    + +
    +
    + } + key="views" + > + + +
    + + } + data={salesData} + /> +
    + + +
    +

    + +

    +
      + {rankingListData.map((item, i) => ( +
    • + + {i + 1} + + + {item.title} + + {numeral(item.total).format('0,0')} +
    • + ))} +
    +
    + +
    +
    + +
    + +); + +export default SalesCard; diff --git a/src/pages/dashboard/analysis/components/TopSearch.tsx b/src/pages/dashboard/analysis/components/TopSearch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a05e0aea83072d72c86dfc46e1d11f0c8f11efac --- /dev/null +++ b/src/pages/dashboard/analysis/components/TopSearch.tsx @@ -0,0 +1,134 @@ +import { Card, Col, Icon, Row, Table, Tooltip } from 'antd'; +import { FormattedMessage } from 'umi-plugin-react/locale'; +import React from 'react'; +import numeral from 'numeral'; +import { SearchDataType, VisitDataType } from '../data.d'; + +import { MiniArea } from './Charts'; +import NumberInfo from './NumberInfo'; +import Trend from './Trend'; +import styles from '../style.less'; + +const columns = [ + { + title: , + dataIndex: 'index', + key: 'index', + }, + { + title: ( + + ), + dataIndex: 'keyword', + key: 'keyword', + render: (text: React.ReactNode) => {text}, + }, + { + title: , + dataIndex: 'count', + key: 'count', + sorter: (a: { count: number }, b: { count: number }) => a.count - b.count, + className: styles.alignRight, + }, + { + title: , + dataIndex: 'range', + key: 'range', + sorter: (a: { range: number }, b: { range: number }) => a.range - b.range, + render: (text: React.ReactNode, record: { status: number }) => ( + + {text}% + + ), + }, +]; + +const TopSearch = ({ + loading, + visitData2, + searchData, + dropdownGroup, +}: { + loading: boolean; + visitData2: VisitDataType[]; + dropdownGroup: React.ReactNode; + searchData: SearchDataType[]; +}) => ( + + } + extra={dropdownGroup} + style={{ + height: '100%', + }} + > + + + + + + } + > + + + + } + gap={8} + total={numeral(12321).format('0,0')} + status="up" + subTotal={17.1} + /> + + + + + + + } + > + + + + } + total={2.7} + status="down" + subTotal={26.2} + gap={8} + /> + + + + + rowKey={record => record.index} + size="small" + columns={columns} + dataSource={searchData} + pagination={{ + style: { marginBottom: 0 }, + pageSize: 5, + }} + /> + +); + +export default TopSearch; diff --git a/src/pages/dashboard/analysis/components/Trend/index.less b/src/pages/dashboard/analysis/components/Trend/index.less new file mode 100644 index 0000000000000000000000000000000000000000..28c315142185367e763e4779bc43bdb65b8cdf1d --- /dev/null +++ b/src/pages/dashboard/analysis/components/Trend/index.less @@ -0,0 +1,37 @@ +@import '~antd/es/style/themes/default.less'; + +.trendItem { + display: inline-block; + font-size: @font-size-base; + line-height: 22px; + + .up, + .down { + position: relative; + top: 1px; + margin-left: 4px; + i { + font-size: 12px; + transform: scale(0.83); + } + } + .up { + color: @red-6; + } + .down { + top: -1px; + color: @green-6; + } + + &.trendItemGrey .up, + &.trendItemGrey .down { + color: @text-color; + } + + &.reverseColor .up { + color: @green-6; + } + &.reverseColor .down { + color: @red-6; + } +} diff --git a/src/pages/dashboard/analysis/components/Trend/index.tsx b/src/pages/dashboard/analysis/components/Trend/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2bba484e8d7608837c0c979eee52cdfaf9d57c5a --- /dev/null +++ b/src/pages/dashboard/analysis/components/Trend/index.tsx @@ -0,0 +1,42 @@ +import { Icon } from 'antd'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export interface TrendProps { + colorful?: boolean; + flag: 'up' | 'down'; + style?: React.CSSProperties; + reverseColor?: boolean; + className?: string; +} + +const Trend: React.SFC = ({ + colorful = true, + reverseColor = false, + flag, + children, + className, + ...rest +}) => { + const classString = classNames( + styles.trendItem, + { + [styles.trendItemGrey]: !colorful, + [styles.reverseColor]: reverseColor && colorful, + }, + className, + ); + return ( +
    + {children} + {flag && ( + + + + )} +
    + ); +}; + +export default Trend; diff --git a/src/pages/dashboard/analysis/data.d.ts b/src/pages/dashboard/analysis/data.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..adc1c9936d5e898588a79914e54239fe050ab356 --- /dev/null +++ b/src/pages/dashboard/analysis/data.d.ts @@ -0,0 +1,42 @@ +export interface VisitDataType { + x: string; + y: number; +} + +export interface SearchDataType { + index: number; + keyword: string; + count: number; + range: number; + status: number; +} + +export interface OfflineDataType { + name: string; + cvr: number; +} + +export interface OfflineChartData { + x: any; + y1: number; + y2: number; +} + +export interface RadarData { + name: string; + label: string; + value: number; +} + +export interface AnalysisData { + visitData: VisitDataType[]; + visitData2: VisitDataType[]; + salesData: VisitDataType[]; + searchData: SearchDataType[]; + offlineData: OfflineDataType[]; + offlineChartData: OfflineChartData[]; + salesTypeData: VisitDataType[]; + salesTypeDataOnline: VisitDataType[]; + salesTypeDataOffline: VisitDataType[]; + radarData: RadarData[]; +} diff --git a/src/pages/dashboard/analysis/index.tsx b/src/pages/dashboard/analysis/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea04cce627c905099a0621abc2fcbeb649114e9b --- /dev/null +++ b/src/pages/dashboard/analysis/index.tsx @@ -0,0 +1,223 @@ +import { Col, Dropdown, Icon, Menu, Row } from 'antd'; +import React, { Component, Suspense } from 'react'; + +import { Dispatch } from 'redux'; +import { GridContent } from '@ant-design/pro-layout'; +import { RadioChangeEvent } from 'antd/es/radio'; +import { RangePickerValue } from 'antd/es/date-picker/interface'; +import { connect } from 'dva'; +import PageLoading from './components/PageLoading'; +import { getTimeDistance } from './utils/utils'; +import { AnalysisData } from './data.d'; +import styles from './style.less'; + +const IntroduceRow = React.lazy(() => import('./components/IntroduceRow')); +const SalesCard = React.lazy(() => import('./components/SalesCard')); +const TopSearch = React.lazy(() => import('./components/TopSearch')); +const ProportionSales = React.lazy(() => import('./components/ProportionSales')); +const OfflineData = React.lazy(() => import('./components/OfflineData')); + +interface dashboardAnalysisProps { + dashboardAnalysis: AnalysisData; + dispatch: Dispatch; + loading: boolean; +} + +interface dashboardAnalysisState { + salesType: 'all' | 'online' | 'stores'; + currentTabKey: string; + rangePickerValue: RangePickerValue; +} + +@connect( + ({ + dashboardAnalysis, + loading, + }: { + dashboardAnalysis: any; + loading: { + effects: { [key: string]: boolean }; + }; + }) => ({ + dashboardAnalysis, + loading: loading.effects['dashboardAnalysis/fetch'], + }), +) +class Analysis extends Component< + dashboardAnalysisProps, + dashboardAnalysisState +> { + state: dashboardAnalysisState = { + salesType: 'all', + currentTabKey: '', + rangePickerValue: getTimeDistance('year'), + }; + + reqRef: number = 0; + + timeoutId: number = 0; + + componentDidMount() { + const { dispatch } = this.props; + this.reqRef = requestAnimationFrame(() => { + dispatch({ + type: 'dashboardAnalysis/fetch', + }); + }); + } + + componentWillUnmount() { + const { dispatch } = this.props; + dispatch({ + type: 'dashboardAnalysis/clear', + }); + cancelAnimationFrame(this.reqRef); + clearTimeout(this.timeoutId); + } + + handleChangeSalesType = (e: RadioChangeEvent) => { + this.setState({ + salesType: e.target.value, + }); + }; + + handleTabChange = (key: string) => { + this.setState({ + currentTabKey: key, + }); + }; + + handleRangePickerChange = (rangePickerValue: RangePickerValue) => { + const { dispatch } = this.props; + this.setState({ + rangePickerValue, + }); + + dispatch({ + type: 'dashboardAnalysis/fetchSalesData', + }); + }; + + selectDate = (type: 'today' | 'week' | 'month' | 'year') => { + const { dispatch } = this.props; + this.setState({ + rangePickerValue: getTimeDistance(type), + }); + + dispatch({ + type: 'dashboardAnalysis/fetchSalesData', + }); + }; + + isActive = (type: 'today' | 'week' | 'month' | 'year') => { + const { rangePickerValue } = this.state; + const value = getTimeDistance(type); + if (!rangePickerValue[0] || !rangePickerValue[1]) { + return ''; + } + if ( + rangePickerValue[0].isSame(value[0], 'day') && + rangePickerValue[1].isSame(value[1], 'day') + ) { + return styles.currentDate; + } + return ''; + }; + + render() { + const { rangePickerValue, salesType, currentTabKey } = this.state; + const { dashboardAnalysis, loading } = this.props; + const { + visitData, + visitData2, + salesData, + searchData, + offlineData, + offlineChartData, + salesTypeData, + salesTypeDataOnline, + salesTypeDataOffline, + } = dashboardAnalysis; + let salesPieData; + if (salesType === 'all') { + salesPieData = salesTypeData; + } else { + salesPieData = salesType === 'online' ? salesTypeDataOnline : salesTypeDataOffline; + } + const menu = ( + + 操作一 + 操作二 + + ); + + const dropdownGroup = ( + + + + + + ); + + const activeKey = currentTabKey || (offlineData[0] && offlineData[0].name); + return ( + + + }> + + + + + + + + + + + + + + + + + + + + + + + ); + } +} + +export default Analysis; diff --git a/src/pages/dashboard/analysis/locales/en-US.ts b/src/pages/dashboard/analysis/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..78407f194deb34f5dd9297d889f8f1019602229e --- /dev/null +++ b/src/pages/dashboard/analysis/locales/en-US.ts @@ -0,0 +1,34 @@ +export default { + 'dashboard-analysis.analysis.test': 'Gongzhuan No.{no} shop', + 'dashboard-analysis.analysis.introduce': 'Introduce', + 'dashboard-analysis.analysis.total-sales': 'Total Sales', + 'dashboard-analysis.analysis.day-sales': 'Daily Sales', + 'dashboard-analysis.analysis.visits': 'Visits', + 'dashboard-analysis.analysis.visits-trend': 'Visits Trend', + 'dashboard-analysis.analysis.visits-ranking': 'Visits Ranking', + 'dashboard-analysis.analysis.day-visits': 'Daily Visits', + 'dashboard-analysis.analysis.week': 'WoW Change', + 'dashboard-analysis.analysis.day': 'DoD Change', + 'dashboard-analysis.analysis.payments': 'Payments', + 'dashboard-analysis.analysis.conversion-rate': 'Conversion Rate', + 'dashboard-analysis.analysis.operational-effect': 'Operational Effect', + 'dashboard-analysis.analysis.sales-trend': 'Stores Sales Trend', + 'dashboard-analysis.analysis.sales-ranking': 'Sales Ranking', + 'dashboard-analysis.analysis.all-year': 'All Year', + 'dashboard-analysis.analysis.all-month': 'All Month', + 'dashboard-analysis.analysis.all-week': 'All Week', + 'dashboard-analysis.analysis.all-day': 'All day', + 'dashboard-analysis.analysis.search-users': 'Search Users', + 'dashboard-analysis.analysis.per-capita-search': 'Per Capita Search', + 'dashboard-analysis.analysis.online-top-search': 'Online Top Search', + 'dashboard-analysis.analysis.the-proportion-of-sales': 'The Proportion Of Sales', + 'dashboard-analysis.channel.all': 'ALL', + 'dashboard-analysis.channel.online': 'Online', + 'dashboard-analysis.channel.stores': 'Stores', + 'dashboard-analysis.analysis.sales': 'Sales', + 'dashboard-analysis.analysis.traffic': 'Traffic', + 'dashboard-analysis.table.rank': 'Rank', + 'dashboard-analysis.table.search-keyword': 'Keyword', + 'dashboard-analysis.table.users': 'Users', + 'dashboard-analysis.table.weekly-range': 'Weekly Range', +}; diff --git a/src/pages/dashboard/analysis/locales/pt-BR.ts b/src/pages/dashboard/analysis/locales/pt-BR.ts new file mode 100644 index 0000000000000000000000000000000000000000..23355908da2634c2c108f3b1d0fa0bb8da36e14f --- /dev/null +++ b/src/pages/dashboard/analysis/locales/pt-BR.ts @@ -0,0 +1,34 @@ +export default { + 'dashboard-analysis.analysis.test': 'Gongzhuan No.{no} shop', + 'dashboard-analysis.analysis.introduce': 'Introduzir', + 'dashboard-analysis.analysis.total-sales': 'Vendas Totais', + 'dashboard-analysis.analysis.day-sales': 'Vendas do Dia', + 'dashboard-analysis.analysis.visits': 'Visitas', + 'dashboard-analysis.analysis.visits-trend': 'Tendência de Visitas', + 'dashboard-analysis.analysis.visits-ranking': 'Ranking de Visitas', + 'dashboard-analysis.analysis.day-visits': 'Visitas do Dia', + 'dashboard-analysis.analysis.week': 'Taxa Semanal', + 'dashboard-analysis.analysis.day': 'Taxa Diária', + 'dashboard-analysis.analysis.payments': 'Pagamentos', + 'dashboard-analysis.analysis.conversion-rate': 'Taxa de Conversão', + 'dashboard-analysis.analysis.operational-effect': 'Efeito Operacional', + 'dashboard-analysis.analysis.sales-trend': 'Tendência de Vendas das Lojas', + 'dashboard-analysis.analysis.sales-ranking': 'Ranking de Vendas', + 'dashboard-analysis.$2': 'Todo ano', + 'dashboard-analysis.analysis.all-month': 'Todo mês', + 'dashboard-analysis.analysis.all-week': 'Toda semana', + 'dashboard-analysis.analysis.all-day': 'Todo dia', + 'dashboard-analysis.analysis.search-users': 'Pesquisa de Usuários', + 'dashboard-analysis.analysis.per-capita-search': 'Busca Per Capta', + 'dashboard-analysis.analysis.online-top-search': 'Mais Buscadas Online', + 'dashboard-analysis.analysis.the-proportion-of-sales': 'The Proportion Of Sales', + 'dashboard-analysis.channel.all': 'Tudo', + 'dashboard-analysis.channel.online': 'Online', + 'dashboard-analysis.channel.stores': 'Lojas', + 'dashboard-analysis.analysis.sales': 'Vendas', + 'dashboard-analysis.analysis.traffic': 'Tráfego', + 'dashboard-analysis.table.rank': 'Rank', + 'dashboard-analysis.table.search-keyword': 'Palavra chave', + 'dashboard-analysis.table.users': 'Usuários', + 'dashboard-analysis.table.weekly-range': 'Faixa Semanal', +}; diff --git a/src/pages/dashboard/analysis/locales/zh-CN.ts b/src/pages/dashboard/analysis/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac9646c85db9cd21cadcff14294928b8bb1c6823 --- /dev/null +++ b/src/pages/dashboard/analysis/locales/zh-CN.ts @@ -0,0 +1,34 @@ +export default { + 'dashboard-analysis.analysis.test': '工专路 {no} 号店', + 'dashboard-analysis.analysis.introduce': '指标说明', + 'dashboard-analysis.analysis.total-sales': '总销售额', + 'dashboard-analysis.analysis.day-sales': '日销售额', + 'dashboard-analysis.analysis.visits': '访问量', + 'dashboard-analysis.analysis.visits-trend': '访问量趋势', + 'dashboard-analysis.analysis.visits-ranking': '门店访问量排名', + 'dashboard-analysis.analysis.day-visits': '日访问量', + 'dashboard-analysis.analysis.week': '周同比', + 'dashboard-analysis.analysis.day': '日同比', + 'dashboard-analysis.analysis.payments': '支付笔数', + 'dashboard-analysis.analysis.conversion-rate': '转化率', + 'dashboard-analysis.analysis.operational-effect': '运营活动效果', + 'dashboard-analysis.analysis.sales-trend': '销售趋势', + 'dashboard-analysis.analysis.sales-ranking': '门店销售额排名', + 'dashboard-analysis.analysis.all-year': '全年', + 'dashboard-analysis.analysis.all-month': '本月', + 'dashboard-analysis.analysis.all-week': '本周', + 'dashboard-analysis.analysis.all-day': '今日', + 'dashboard-analysis.analysis.search-users': '搜索用户数', + 'dashboard-analysis.analysis.per-capita-search': '人均搜索次数', + 'dashboard-analysis.analysis.online-top-search': '线上热门搜索', + 'dashboard-analysis.analysis.the-proportion-of-sales': '销售额类别占比', + 'dashboard-analysis.channel.all': '全部渠道', + 'dashboard-analysis.channel.online': '线上', + 'dashboard-analysis.channel.stores': '门店', + 'dashboard-analysis.analysis.sales': '销售额', + 'dashboard-analysis.analysis.traffic': '客流量', + 'dashboard-analysis.table.rank': '排名', + 'dashboard-analysis.table.search-keyword': '搜索关键词', + 'dashboard-analysis.table.users': '用户数', + 'dashboard-analysis.table.weekly-range': '周涨幅', +}; diff --git a/src/pages/dashboard/analysis/locales/zh-TW.ts b/src/pages/dashboard/analysis/locales/zh-TW.ts new file mode 100644 index 0000000000000000000000000000000000000000..8040fd228498ac3b2a9bec7a475d753624c07fcc --- /dev/null +++ b/src/pages/dashboard/analysis/locales/zh-TW.ts @@ -0,0 +1,34 @@ +export default { + 'dashboard-analysis.analysis.test': '工專路 {no} 號店', + 'dashboard-analysis.analysis.introduce': '指標說明', + 'dashboard-analysis.analysis.total-sales': '總銷售額', + 'dashboard-analysis.analysis.day-sales': '日銷售額', + 'dashboard-analysis.analysis.visits': '訪問量', + 'dashboard-analysis.analysis.visits-trend': '訪問量趨勢', + 'dashboard-analysis.analysis.visits-ranking': '門店訪問量排名', + 'dashboard-analysis.analysis.day-visits': '日訪問量', + 'dashboard-analysis.analysis.week': '周同比', + 'dashboard-analysis.analysis.day': '日同比', + 'dashboard-analysis.analysis.payments': '支付筆數', + 'dashboard-analysis.analysis.conversion-rate': '轉化率', + 'dashboard-analysis.analysis.operational-effect': '運營活動效果', + 'dashboard-analysis.analysis.sales-trend': '銷售趨勢', + 'dashboard-analysis.analysis.sales-ranking': '門店銷售額排名', + 'dashboard-analysis.analysis.all-year': '全年', + 'dashboard-analysis.analysis.all-month': '本月', + 'dashboard-analysis.analysis.all-week': '本周', + 'dashboard-analysis.analysis.all-day': '今日', + 'dashboard-analysis.analysis.search-users': '搜索用戶數', + 'dashboard-analysis.analysis.per-capita-search': '人均搜索次數', + 'dashboard-analysis.analysis.online-top-search': '線上熱門搜索', + 'dashboard-analysis.analysis.the-proportion-of-sales': '銷售額類別占比', + 'dashboard-analysis.channel.all': '全部渠道', + 'dashboard-analysis.channel.online': '線上', + 'dashboard-analysis.channel.stores': '門店', + 'dashboard-analysis.analysis.sales': '銷售額', + 'dashboard-analysis.analysis.traffic': '客流量', + 'dashboard-analysis.table.rank': '排名', + 'dashboard-analysis.table.search-keyword': '搜索關鍵詞', + 'dashboard-analysis.table.users': '用戶數', + 'dashboard-analysis.table.weekly-range': '周漲幅', +}; diff --git a/src/pages/dashboard/analysis/model.tsx b/src/pages/dashboard/analysis/model.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d2ffef01f36e61a20321fe675edb690f8f230fff --- /dev/null +++ b/src/pages/dashboard/analysis/model.tsx @@ -0,0 +1,84 @@ +import { AnyAction, Reducer } from 'redux'; + +import { EffectsCommandMap } from 'dva'; +import { AnalysisData } from './data.d'; +import { fakeChartData } from './service'; + +export type Effect = ( + action: AnyAction, + effects: EffectsCommandMap & { select: (func: (state: AnalysisData) => T) => T }, +) => void; + +export interface ModelType { + namespace: string; + state: AnalysisData; + effects: { + fetch: Effect; + fetchSalesData: Effect; + }; + reducers: { + save: Reducer; + clear: Reducer; + }; +} + +const Model: ModelType = { + namespace: 'dashboardAnalysis', + + state: { + visitData: [], + visitData2: [], + salesData: [], + searchData: [], + offlineData: [], + offlineChartData: [], + salesTypeData: [], + salesTypeDataOnline: [], + salesTypeDataOffline: [], + radarData: [], + }, + + effects: { + *fetch(_, { call, put }) { + const response = yield call(fakeChartData); + yield put({ + type: 'save', + payload: response, + }); + }, + *fetchSalesData(_, { call, put }) { + const response = yield call(fakeChartData); + yield put({ + type: 'save', + payload: { + salesData: response.salesData, + }, + }); + }, + }, + + reducers: { + save(state, { payload }) { + return { + ...state, + ...payload, + }; + }, + clear() { + return { + visitData: [], + visitData2: [], + salesData: [], + searchData: [], + offlineData: [], + offlineChartData: [], + salesTypeData: [], + salesTypeDataOnline: [], + salesTypeDataOffline: [], + radarData: [], + }; + }, + }, +}; + +export default Model; diff --git a/src/pages/dashboard/analysis/service.tsx b/src/pages/dashboard/analysis/service.tsx new file mode 100644 index 0000000000000000000000000000000000000000..26cd82f03661b7ad66b2da6174e665c34ac72436 --- /dev/null +++ b/src/pages/dashboard/analysis/service.tsx @@ -0,0 +1,5 @@ +import request from 'umi-request'; + +export async function fakeChartData() { + return request('/api/fake_chart_data'); +} diff --git a/src/pages/dashboard/analysis/style.less b/src/pages/dashboard/analysis/style.less new file mode 100644 index 0000000000000000000000000000000000000000..e3cab4cdb37354fc2d438a2ed6018e55e2add03f --- /dev/null +++ b/src/pages/dashboard/analysis/style.less @@ -0,0 +1,188 @@ +@import '~antd/es/style/themes/default.less'; + +.iconGroup { + i { + margin-left: 16px; + color: @text-color-secondary; + cursor: pointer; + transition: color 0.32s; + &:hover { + color: @text-color; + } + } +} + +.rankingList { + margin: 25px 0 0; + padding: 0; + list-style: none; + li { + display: flex; + align-items: center; + margin-top: 16px; + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + span { + color: @text-color; + font-size: 14px; + line-height: 22px; + } + .rankingItemNumber { + display: inline-block; + width: 20px; + height: 20px; + margin-top: 1.5px; + margin-right: 16px; + font-weight: 600; + font-size: 12px; + line-height: 20px; + text-align: center; + background-color: @tag-default-bg; + border-radius: 20px; + &.active { + color: #fff; + background-color: #314659; + } + } + .rankingItemTitle { + flex: 1; + margin-right: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} + +.salesExtra { + display: inline-block; + margin-right: 24px; + a { + margin-left: 24px; + color: @text-color; + &:hover { + color: @primary-color; + } + &.currentDate { + color: @primary-color; + } + } +} + +.salesCard { + .salesBar { + padding: 0 0 32px 32px; + } + .salesRank { + padding: 0 32px 32px 72px; + } + :global { + .ant-tabs-bar { + padding-left: 16px; + .ant-tabs-nav .ant-tabs-tab { + padding-top: 16px; + padding-bottom: 14px; + line-height: 24px; + } + } + .ant-tabs-extra-content { + padding-right: 24px; + line-height: 55px; + } + .ant-card-head { + position: relative; + } + .ant-card-head-title { + align-items: normal; + } + } +} + +.salesCardExtra { + height: inherit; +} + +.salesTypeRadio { + position: absolute; + right: 54px; + bottom: 12px; +} + +.offlineCard { + :global { + .ant-tabs-ink-bar { + bottom: auto; + } + .ant-tabs-bar { + border-bottom: none; + } + .ant-tabs-nav-container-scrolling { + padding-right: 40px; + padding-left: 40px; + } + .ant-tabs-tab-prev-icon::before { + position: relative; + left: 6px; + } + .ant-tabs-tab-next-icon::before { + position: relative; + right: 6px; + } + .ant-tabs-tab-active h4 { + color: @primary-color; + } + } +} + +.trendText { + margin-left: 8px; + color: @heading-color; +} + +@media screen and (max-width: @screen-lg) { + .salesExtra { + display: none; + } + + .rankingList { + li { + span:first-child { + margin-right: 8px; + } + } + } +} + +@media screen and (max-width: @screen-md) { + .rankingTitle { + margin-top: 16px; + } + + .salesCard .salesBar { + padding: 16px; + } +} + +@media screen and (max-width: @screen-sm) { + .salesExtraWrap { + display: none; + } + + .salesCard { + :global { + .ant-tabs-content { + padding-top: 30px; + } + } + } +} diff --git a/src/pages/dashboard/analysis/utils/Yuan.tsx b/src/pages/dashboard/analysis/utils/Yuan.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f71019cda858b6c9a9fe0faec116d7a3b50b906 --- /dev/null +++ b/src/pages/dashboard/analysis/utils/Yuan.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { yuan } from '../components/Charts'; +/** + * 减少使用 dangerouslySetInnerHTML + */ +export default class Yuan extends React.Component<{ + children: React.ReactText; +}> { + main: HTMLSpanElement | undefined | null = null; + + componentDidMount() { + this.renderToHtml(); + } + + componentDidUpdate() { + this.renderToHtml(); + } + + renderToHtml = () => { + const { children } = this.props; + if (this.main) { + this.main.innerHTML = yuan(children); + } + }; + + render() { + return ( + { + this.main = ref; + }} + /> + ); + } +} diff --git a/src/pages/dashboard/analysis/utils/utils.less b/src/pages/dashboard/analysis/utils/utils.less new file mode 100644 index 0000000000000000000000000000000000000000..de1aa64222b6f14328d3a9e3c262ac5a31cce5af --- /dev/null +++ b/src/pages/dashboard/analysis/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + margin-right: -1em; + padding-right: 1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: 0.2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} diff --git a/src/pages/dashboard/analysis/utils/utils.ts b/src/pages/dashboard/analysis/utils/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2ce1b66cdbff40dc269f80e4f193180be4d7176 --- /dev/null +++ b/src/pages/dashboard/analysis/utils/utils.ts @@ -0,0 +1,50 @@ +import { RangePickerValue } from 'antd/es/date-picker/interface'; +import moment from 'moment'; + +export function fixedZero(val: number) { + return val * 1 < 10 ? `0${val}` : val; +} + +export function getTimeDistance(type: 'today' | 'week' | 'month' | 'year'): RangePickerValue { + const now = new Date(); + const oneDay = 1000 * 60 * 60 * 24; + + if (type === 'today') { + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + return [moment(now), moment(now.getTime() + (oneDay - 1000))]; + } + + if (type === 'week') { + let day = now.getDay(); + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + + if (day === 0) { + day = 6; + } else { + day -= 1; + } + + const beginTime = now.getTime() - day * oneDay; + + return [moment(beginTime), moment(beginTime + (7 * oneDay - 1000))]; + } + const year = now.getFullYear(); + + if (type === 'month') { + const month = now.getMonth(); + const nextDate = moment(now).add(1, 'months'); + const nextYear = nextDate.year(); + const nextMonth = nextDate.month(); + + return [ + moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), + moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000), + ]; + } + + return [moment(`${year}-01-01 00:00:00`), moment(`${year}-12-31 23:59:59`)]; +} diff --git a/src/pages/dashboard/monitor/_mock.ts b/src/pages/dashboard/monitor/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..b523dfbded2d802a4818bb2c166b554f0395be3d --- /dev/null +++ b/src/pages/dashboard/monitor/_mock.ts @@ -0,0 +1,7 @@ +import mockjs from 'mockjs'; + +export default { + 'GET /api/tags': mockjs.mock({ + 'list|100': [{ name: '@city', 'value|1-100': 150, 'type|0-2': 1 }], + }), +}; diff --git a/src/pages/dashboard/monitor/components/ActiveChart/index.less b/src/pages/dashboard/monitor/components/ActiveChart/index.less new file mode 100644 index 0000000000000000000000000000000000000000..2f5d15f2ba07962b56ca83e2cb1776492d79e7dd --- /dev/null +++ b/src/pages/dashboard/monitor/components/ActiveChart/index.less @@ -0,0 +1,51 @@ +.activeChart { + position: relative; +} +.activeChartGrid { + p { + position: absolute; + top: 80px; + } + p:last-child { + top: 115px; + } +} +.activeChartLegend { + position: relative; + height: 20px; + margin-top: 8px; + font-size: 0; + line-height: 20px; + span { + display: inline-block; + width: 33.33%; + font-size: 12px; + text-align: center; + } + span:first-child { + text-align: left; + } + span:last-child { + text-align: right; + } +} +.dashedLine { + position: relative; + top: -70px; + left: -3px; + height: 1px; + + .line { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: linear-gradient(to right, transparent 50%, #e9e9e9 50%); + background-size: 6px; + } +} + +.dashedLine:last-child { + top: -36px; +} diff --git a/src/pages/dashboard/monitor/components/ActiveChart/index.tsx b/src/pages/dashboard/monitor/components/ActiveChart/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71d508f8cbcdd418c6abd266464ee71652ebc375 --- /dev/null +++ b/src/pages/dashboard/monitor/components/ActiveChart/index.tsx @@ -0,0 +1,107 @@ +import React, { Component } from 'react'; + +import { Statistic } from 'antd'; +import { MiniArea } from '../Charts'; +import styles from './index.less'; + +function fixedZero(val: number) { + return val * 1 < 10 ? `0${val}` : val; +} + +function getActiveData() { + const activeData = []; + for (let i = 0; i < 24; i += 1) { + activeData.push({ + x: `${fixedZero(i)}:00`, + y: Math.floor(Math.random() * 200) + i * 50, + }); + } + return activeData; +} + +export default class ActiveChart extends Component { + state = { + activeData: getActiveData(), + }; + + timer: number | undefined = undefined; + + requestRef: number | undefined = undefined; + + componentDidMount() { + this.loopData(); + } + + componentWillUnmount() { + clearTimeout(this.timer); + if (this.requestRef) { + cancelAnimationFrame(this.requestRef); + } + } + + loopData = () => { + this.requestRef = requestAnimationFrame(() => { + this.timer = window.setTimeout(() => { + this.setState( + { + activeData: getActiveData(), + }, + () => { + this.loopData(); + }, + ); + }, 1000); + }); + }; + + render() { + const { activeData = [] } = this.state; + + return ( +
    + +
    + +
    + {activeData && ( +
    +
    +

    {[...activeData].sort()[activeData.length - 1].y + 200} 亿元

    +

    {[...activeData].sort()[Math.floor(activeData.length / 2)].y} 亿元

    +
    +
    +
    +
    +
    +
    +
    +
    + )} + {activeData && ( +
    + 00:00 + {activeData[Math.floor(activeData.length / 2)].x} + {activeData[activeData.length - 1].x} +
    + )} +
    + ); + } +} diff --git a/src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx b/src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..78c979688ca5d81e1dbf3d4f51b6d2611507b0fc --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx @@ -0,0 +1,179 @@ +import { Axis, Chart, Coord, Geom, Guide, Shape } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; + +const { Arc, Html, Line } = Guide; + +export interface GaugeProps { + title: React.ReactNode; + color?: string; + height?: number; + bgColor?: number; + percent: number; + forceFit?: boolean; + style?: React.CSSProperties; + formatter?: (value: string) => string; +} + +const defaultFormatter = (val: string): string => { + switch (val) { + case '2': + return '差'; + case '4': + return '中'; + case '6': + return '良'; + case '8': + return '优'; + default: + return ''; + } +}; + +if (Shape.registerShape) { + Shape.registerShape('point', 'pointer', { + drawShape(cfg: any, group: any) { + let point = cfg.points[0]; + point = (this as any).parsePoint(point); + const center = (this as any).parsePoint({ + x: 0, + y: 0, + }); + group.addShape('line', { + attrs: { + x1: center.x, + y1: center.y, + x2: point.x, + y2: point.y, + stroke: cfg.color, + lineWidth: 2, + lineCap: 'round', + }, + }); + return group.addShape('circle', { + attrs: { + x: center.x, + y: center.y, + r: 6, + stroke: cfg.color, + lineWidth: 3, + fill: '#fff', + }, + }); + }, + }); +} + +const Gauge: React.FC = props => { + const { + title, + height = 1, + percent, + forceFit = true, + formatter = defaultFormatter, + color = '#2F9CFF', + bgColor = '#F0F2F5', + } = props; + const cols = { + value: { + type: 'linear', + min: 0, + max: 10, + tickCount: 6, + nice: true, + }, + }; + const data = [{ value: percent / 10 }]; + + const renderHtml = () => ` +
    +

    ${title}

    +

    + ${(data[0].value * 10).toFixed(2)}% +

    +
    `; + const textStyle: { + fontSize: number; + fill: string; + textAlign: 'center'; + } = { + fontSize: 12, + fill: 'rgba(0, 0, 0, 0.65)', + textAlign: 'center', + }; + return ( + + + + + + + + + + + + + + + ); +}; + +export default autoHeight()(Gauge); diff --git a/src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx b/src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22ea7f0b4ef56c83a3ecfa296d0e618a79c7c788 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx @@ -0,0 +1,130 @@ +import { Axis, AxisProps, Chart, Geom, Tooltip } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export interface MiniAreaProps { + color?: string; + height?: number; + borderColor?: string; + line?: boolean; + animate?: boolean; + xAxis?: AxisProps; + forceFit?: boolean; + scale?: { + x?: { + tickCount: number; + }; + y?: { + tickCount: number; + }; + }; + yAxis?: Partial; + borderWidth?: number; + data: { + x: number | string; + y: number; + }[]; +} + +const MiniArea: React.FC = props => { + const { + height = 1, + data = [], + forceFit = true, + color = 'rgba(24, 144, 255, 0.2)', + borderColor = '#1089ff', + scale = { x: {}, y: {} }, + borderWidth = 2, + line, + xAxis, + yAxis, + animate = true, + } = props; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const scaleProps = { + x: { + type: 'cat', + range: [0, 1], + ...scale.x, + }, + y: { + min: 0, + ...scale.y, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + const chartHeight = height + 54; + + return ( +
    +
    + {height > 0 && ( + + + + + + {line ? ( + + ) : ( + + )} + + )} +
    +
    + ); +}; + +export default autoHeight()(MiniArea); diff --git a/src/pages/dashboard/monitor/components/Charts/Pie/index.less b/src/pages/dashboard/monitor/components/Charts/Pie/index.less new file mode 100644 index 0000000000000000000000000000000000000000..8641d658058870152411c872c961d1ba351fef16 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/Pie/index.less @@ -0,0 +1,94 @@ +@import '~antd/es/style/themes/default.less'; + +.pie { + position: relative; + .chart { + position: relative; + } + &.hasLegend .chart { + width: ~'calc(100% - 240px)'; + } + .legend { + position: absolute; + top: 50%; + right: 0; + min-width: 200px; + margin: 0 20px; + padding: 0; + list-style: none; + transform: translateY(-50%); + li { + height: 22px; + margin-bottom: 16px; + line-height: 22px; + cursor: pointer; + &:last-child { + margin-bottom: 0; + } + } + } + .dot { + position: relative; + top: -1px; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 8px; + border-radius: 8px; + } + .line { + display: inline-block; + width: 1px; + height: 16px; + margin-right: 8px; + background-color: @border-color-split; + } + .legendTitle { + color: @text-color; + } + .percent { + color: @text-color-secondary; + } + .value { + position: absolute; + right: 0; + } + .title { + margin-bottom: 8px; + } + .total { + position: absolute; + top: 50%; + left: 50%; + max-height: 62px; + text-align: center; + transform: translate(-50%, -50%); + & > h4 { + height: 22px; + margin-bottom: 8px; + color: @text-color-secondary; + font-weight: normal; + font-size: 14px; + line-height: 22px; + } + & > p { + display: block; + height: 32px; + color: @heading-color; + font-size: 1.2em; + line-height: 32px; + white-space: nowrap; + } + } +} + +.legendBlock { + &.hasLegend .chart { + width: 100%; + margin: 0 0 32px 0; + } + .legend { + position: relative; + transform: none; + } +} diff --git a/src/pages/dashboard/monitor/components/Charts/Pie/index.tsx b/src/pages/dashboard/monitor/components/Charts/Pie/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b0071a5b1949979bd2bfcf0e2ef89e8742f2f975 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/Pie/index.tsx @@ -0,0 +1,310 @@ +import { Chart, Coord, Geom, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import { DataView } from '@antv/data-set'; +import Debounce from 'lodash.debounce'; +import { Divider } from 'antd'; +import ReactFitText from 'react-fittext'; +import classNames from 'classnames'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +export interface PieProps { + animate?: boolean; + color?: string; + colors?: string[]; + selected?: boolean; + height?: number; + margin?: [number, number, number, number]; + hasLegend?: boolean; + padding?: [number, number, number, number]; + percent?: number; + data?: { + x: string | string; + y: number; + }[]; + inner?: number; + lineWidth?: number; + forceFit?: boolean; + style?: React.CSSProperties; + className?: string; + total?: React.ReactNode | number | (() => React.ReactNode | number); + title?: React.ReactNode; + tooltip?: boolean; + valueFormat?: (value: string) => string | React.ReactNode; + subTitle?: React.ReactNode; +} +interface PieState { + legendData: { checked: boolean; x: string; color: string; percent: number; y: string }[]; + legendBlock: boolean; +} +class Pie extends Component { + state: PieState = { + legendData: [], + legendBlock: false, + }; + + chart: G2.Chart | undefined = undefined; + + root: HTMLDivElement | undefined = undefined; + + requestRef: number | undefined = 0; + + // for window resize auto responsive legend + resize = Debounce(() => { + const { hasLegend } = this.props; + const { legendBlock } = this.state; + if (!hasLegend || !this.root) { + window.removeEventListener('resize', this.resize); + return; + } + if ( + this.root && + this.root.parentNode && + (this.root.parentNode as HTMLElement).clientWidth <= 380 + ) { + if (!legendBlock) { + this.setState({ + legendBlock: true, + }); + } + } else if (legendBlock) { + this.setState({ + legendBlock: false, + }); + } + }, 300); + + componentDidMount() { + window.addEventListener( + 'resize', + () => { + this.requestRef = requestAnimationFrame(() => this.resize()); + }, + { passive: true }, + ); + } + + componentDidUpdate(preProps: PieProps) { + const { data } = this.props; + if (data !== preProps.data) { + // because of charts data create when rendered + // so there is a trick for get rendered time + this.getLegendData(); + } + } + + componentWillUnmount() { + if (this.requestRef) { + window.cancelAnimationFrame(this.requestRef); + } + window.removeEventListener('resize', this.resize); + if (this.resize) { + (this.resize as any).cancel(); + } + } + + getG2Instance = (chart: G2.Chart) => { + this.chart = chart; + requestAnimationFrame(() => { + this.getLegendData(); + this.resize(); + }); + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + // g2 的类型有问题 + const items = (geom as any).get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map((item: { color: any; _origin: any }[]) => { + /* eslint no-underscore-dangle:0 */ + const origin = item[0]._origin; + origin.color = item[0].color; + origin.checked = true; + return origin; + }); + + this.setState({ + legendData, + }); + }; + + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + + handleLegendClick = (item: { checked: boolean }, i: string | number) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter(l => l.checked).map(l => l.x); + + if (this.chart) { + this.chart.filter('x', val => filteredLegendData.indexOf(`${val}`) > -1); + } + + this.setState({ + legendData, + }); + }; + + render() { + const { + valueFormat, + subTitle, + total, + hasLegend = false, + className, + style, + height = 0, + forceFit = true, + percent, + color, + inner = 0.75, + animate = true, + colors, + lineWidth = 1, + } = this.props; + + const { legendData, legendBlock } = this.state; + const pieClassName = classNames(styles.pie, className, { + [styles.hasLegend]: !!hasLegend, + [styles.legendBlock]: legendBlock, + }); + + const { + data: propsData, + selected: propsSelected = true, + tooltip: propsTooltip = true, + } = this.props; + + let data = propsData || []; + let selected = propsSelected; + let tooltip = propsTooltip; + + const defaultColors = colors; + data = data || []; + selected = selected || true; + tooltip = tooltip || true; + let formatColor; + + const scale = { + x: { + type: 'cat', + range: [0, 1], + }, + y: { + min: 0, + }, + }; + + if (percent || percent === 0) { + selected = false; + tooltip = false; + formatColor = (value: string) => { + if (value === '占比') { + return color || 'rgba(24, 144, 255, 0.85)'; + } + return '#F0F2F5'; + }; + + data = [ + { + x: '占比', + y: parseFloat(`${percent}`), + }, + { + x: '反比', + y: 100 - parseFloat(`${percent}`), + }, + ]; + } + + const tooltipFormat: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*percent', + (x: string, p: number) => ({ + name: x, + value: `${(p * 100).toFixed(2)}%`, + }), + ]; + + const padding = [12, 0, 12, 0] as [number, number, number, number]; + + const dv = new DataView(); + dv.source(data).transform({ + type: 'percent', + field: 'y', + dimension: 'x', + as: 'percent', + }); + + return ( +
    + +
    + + {!!tooltip && } + + + + + {(subTitle || total) && ( +
    + {subTitle &&

    {subTitle}

    } + {/* eslint-disable-next-line */} + {total && ( +
    {typeof total === 'function' ? total() : total}
    + )} +
    + )} +
    +
    + + {hasLegend && ( +
      + {legendData.map((item, i) => ( +
    • this.handleLegendClick(item, i)}> + + {item.x} + + + {`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`} + + {valueFormat ? valueFormat(item.y) : item.y} +
    • + ))} +
    + )} +
    + ); + } +} + +export default autoHeight()(Pie); diff --git a/src/pages/dashboard/monitor/components/Charts/TagCloud/index.less b/src/pages/dashboard/monitor/components/Charts/TagCloud/index.less new file mode 100644 index 0000000000000000000000000000000000000000..db8e4dabfdd3f1fd4566ff22f55962648c369c49 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/TagCloud/index.less @@ -0,0 +1,6 @@ +.tagCloud { + overflow: hidden; + canvas { + transform-origin: 0 0; + } +} diff --git a/src/pages/dashboard/monitor/components/Charts/TagCloud/index.tsx b/src/pages/dashboard/monitor/components/Charts/TagCloud/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c6565427c981531247206ec1eda46049d79da335 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/TagCloud/index.tsx @@ -0,0 +1,211 @@ +import { Chart, Coord, Geom, Shape, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import DataSet from '@antv/data-set'; +import Debounce from 'lodash.debounce'; +import classNames from 'classnames'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-underscore-dangle: 0 */ +/* eslint no-param-reassign: 0 */ + +const imgUrl = 'https://gw.alipayobjects.com/zos/rmsportal/gWyeGLCdFFRavBGIDzWk.png'; + +export interface TagCloudProps { + data: { + name: string; + value: string; + }[]; + height?: number; + className?: string; + style?: React.CSSProperties; +} + +interface TagCloudState { + dv: any; + height?: number; + width: number; +} + +class TagCloud extends Component { + state = { + dv: null, + height: 0, + width: 0, + }; + + requestRef: number = 0; + + isUnmount: boolean = false; + + root: HTMLDivElement | undefined = undefined; + + imageMask: HTMLImageElement | undefined = undefined; + + componentDidMount() { + requestAnimationFrame(() => { + this.initTagCloud(); + this.renderChart(this.props); + }); + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentDidUpdate(preProps?: TagCloudProps) { + const { data } = this.props; + if (preProps && JSON.stringify(preProps.data) !== JSON.stringify(data)) { + this.renderChart(this.props); + } + } + + componentWillUnmount() { + this.isUnmount = true; + window.cancelAnimationFrame(this.requestRef); + window.removeEventListener('resize', this.resize); + } + + resize = () => { + this.requestRef = requestAnimationFrame(() => { + this.renderChart(this.props); + }); + }; + + saveRootRef = (node: HTMLDivElement) => { + this.root = node; + }; + + initTagCloud = () => { + function getTextAttrs(cfg: { + x?: any; + y?: any; + style?: any; + opacity?: any; + origin?: any; + color?: any; + }) { + return { + ...cfg.style, + fillOpacity: cfg.opacity, + fontSize: cfg.origin._origin.size, + rotate: cfg.origin._origin.rotate, + text: cfg.origin._origin.text, + textAlign: 'center', + fontFamily: cfg.origin._origin.font, + fill: cfg.color, + textBaseline: 'Alphabetic', + }; + } + + (Shape as any).registerShape('point', 'cloud', { + drawShape( + cfg: { x: any; y: any }, + container: { addShape: (arg0: string, arg1: { attrs: any }) => void }, + ) { + const attrs = getTextAttrs(cfg); + return container.addShape('text', { + attrs: { + ...attrs, + x: cfg.x, + y: cfg.y, + }, + }); + }, + }); + }; + + renderChart = Debounce((nextProps: TagCloudProps) => { + // const colors = ['#1890FF', '#41D9C7', '#2FC25B', '#FACC14', '#9AE65C']; + const { data, height } = nextProps || this.props; + if (data.length < 1 || !this.root) { + return; + } + + const h = height; + const w = this.root.offsetWidth; + + const onload = () => { + const dv = new DataSet.View().source(data); + const range = dv.range('value'); + const [min, max] = range; + dv.transform({ + type: 'tag-cloud', + fields: ['name', 'value'], + imageMask: this.imageMask, + font: 'Verdana', + size: [w, h], // 宽高设置最好根据 imageMask 做调整 + padding: 0, + timeInterval: 5000, // max execute time + rotate() { + return 0; + }, + fontSize(d: { value: number }) { + const size = ((d.value - min) / (max - min)) ** 2; + return size * (17.5 - 5) + 5; + }, + }); + + if (this.isUnmount) { + return; + } + + this.setState({ + dv, + width: w, + height: h, + }); + }; + + if (!this.imageMask) { + this.imageMask = new Image(); + this.imageMask.crossOrigin = ''; + this.imageMask.src = imgUrl; + + this.imageMask.onload = onload; + } else { + onload(); + } + }, 200); + + render() { + const { className, height } = this.props; + const { dv, width, height: stateHeight } = this.state; + + return ( +
    + {dv && ( + + + + + + )} +
    + ); + } +} + +export default autoHeight()(TagCloud); diff --git a/src/pages/dashboard/monitor/components/Charts/WaterWave/index.less b/src/pages/dashboard/monitor/components/Charts/WaterWave/index.less new file mode 100644 index 0000000000000000000000000000000000000000..f52ac142e2c59c12c2aeeb4bde42f45bb893e4b3 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/WaterWave/index.less @@ -0,0 +1,28 @@ +@import '~antd/es/style/themes/default.less'; + +.waterWave { + position: relative; + display: inline-block; + transform-origin: left; + .text { + position: absolute; + top: 32px; + left: 0; + width: 100%; + text-align: center; + span { + color: @text-color-secondary; + font-size: 14px; + line-height: 22px; + } + h4 { + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + } + .waterWaveCanvasWrapper { + transform: scale(0.5); + transform-origin: 0 0; + } +} diff --git a/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx b/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..065cd0c607892a295f608a7d55adad484240c537 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx @@ -0,0 +1,236 @@ +import React, { Component } from 'react'; + +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-return-assign: 0 */ +/* eslint no-mixed-operators: 0 */ +// riddle: https://riddle.alibaba-inc.com/riddles/2d9a4b90 + +export interface WaterWaveProps { + title: React.ReactNode; + color?: string; + height?: number; + percent: number; + style?: React.CSSProperties; +} + +class WaterWave extends Component { + state = { + radio: 1, + }; + + timer: number = 0; + + root: HTMLDivElement | undefined | null = null; + + node: HTMLCanvasElement | undefined | null = null; + + componentDidMount() { + this.renderChart(); + this.resize(); + window.addEventListener( + 'resize', + () => { + requestAnimationFrame(() => this.resize()); + }, + { passive: true }, + ); + } + + componentDidUpdate(props: WaterWaveProps) { + const { percent } = this.props; + if (props.percent !== percent) { + // 不加这个会造成绘制缓慢 + this.renderChart('update'); + } + } + + componentWillUnmount() { + cancelAnimationFrame(this.timer); + if (this.node) { + this.node.innerHTML = ''; + } + window.removeEventListener('resize', this.resize); + } + + resize = () => { + if (this.root) { + const { height = 1 } = this.props; + const { offsetWidth } = this.root.parentNode as HTMLElement; + this.setState({ + radio: offsetWidth < height ? offsetWidth / height : 1, + }); + } + }; + + renderChart(type?: string) { + const { percent, color = '#1890FF' } = this.props; + const data = percent / 100; + const self = this; + cancelAnimationFrame(this.timer); + + if (!this.node || (data !== 0 && !data)) { + return; + } + + const canvas = this.node; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + const radius = canvasWidth / 2; + const lineWidth = 2; + const cR = radius - lineWidth; + + ctx.beginPath(); + ctx.lineWidth = lineWidth * 2; + + const axisLength = canvasWidth - lineWidth; + const unit = axisLength / 8; + const range = 0.2; // 振幅 + let currRange = range; + const xOffset = lineWidth; + let sp = 0; // 周期偏移量 + let currData = 0; + const waveupsp = 0.005; // 水波上涨速度 + + let arcStack: number[][] = []; + const bR = radius - lineWidth; + const circleOffset = -(Math.PI / 2); + let circleLock = true; + + for (let i = circleOffset; i < circleOffset + 2 * Math.PI; i += 1 / (8 * Math.PI)) { + arcStack.push([radius + bR * Math.cos(i), radius + bR * Math.sin(i)]); + } + + const cStartPoint = arcStack.shift() as number[]; + ctx.strokeStyle = color; + ctx.moveTo(cStartPoint[0], cStartPoint[1]); + + function drawSin() { + if (!ctx) { + return; + } + ctx.beginPath(); + ctx.save(); + + const sinStack = []; + for (let i = xOffset; i <= xOffset + axisLength; i += 20 / axisLength) { + const x = sp + (xOffset + i) / unit; + const y = Math.sin(x) * currRange; + const dx = i; + const dy = 2 * cR * (1 - currData) + (radius - cR) - unit * y; + + ctx.lineTo(dx, dy); + sinStack.push([dx, dy]); + } + + const startPoint = sinStack.shift() as number[]; + + ctx.lineTo(xOffset + axisLength, canvasHeight); + ctx.lineTo(xOffset, canvasHeight); + ctx.lineTo(startPoint[0], startPoint[1]); + + const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeight); + gradient.addColorStop(0, '#ffffff'); + gradient.addColorStop(1, color); + ctx.fillStyle = gradient; + ctx.fill(); + ctx.restore(); + } + + function render() { + if (!ctx) { + return; + } + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (circleLock && type !== 'update') { + if (arcStack.length) { + const temp = arcStack.shift() as number[]; + ctx.lineTo(temp[0], temp[1]); + ctx.stroke(); + } else { + circleLock = false; + ctx.lineTo(cStartPoint[0], cStartPoint[1]); + ctx.stroke(); + arcStack = []; + + ctx.globalCompositeOperation = 'destination-over'; + ctx.beginPath(); + ctx.lineWidth = lineWidth; + ctx.arc(radius, radius, bR, 0, 2 * Math.PI, true); + + ctx.beginPath(); + ctx.save(); + ctx.arc(radius, radius, radius - 3 * lineWidth, 0, 2 * Math.PI, true); + + ctx.restore(); + ctx.clip(); + ctx.fillStyle = color; + } + } else { + if (data >= 0.85) { + if (currRange > range / 4) { + const t = range * 0.01; + currRange -= t; + } + } else if (data <= 0.1) { + if (currRange < range * 1.5) { + const t = range * 0.01; + currRange += t; + } + } else { + if (currRange <= range) { + const t = range * 0.01; + currRange += t; + } + if (currRange >= range) { + const t = range * 0.01; + currRange -= t; + } + } + if (data - currData > 0) { + currData += waveupsp; + } + if (data - currData < 0) { + currData -= waveupsp; + } + + sp += 0.07; + drawSin(); + } + self.timer = requestAnimationFrame(render); + } + render(); + } + + render() { + const { radio } = this.state; + const { percent, title, height = 1 } = this.props; + return ( +
    (this.root = n)} + style={{ transform: `scale(${radio})` }} + > +
    + (this.node = n)} + width={height * 2} + height={height * 2} + /> +
    +
    + {title && {title}} +

    {percent}%

    +
    +
    + ); + } +} + +export default autoHeight()(WaterWave); diff --git a/src/pages/dashboard/monitor/components/Charts/autoHeight.tsx b/src/pages/dashboard/monitor/components/Charts/autoHeight.tsx new file mode 100644 index 0000000000000000000000000000000000000000..66784f295eb53b29481d77f8723425e8d5068785 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/autoHeight.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +export type IReactComponent

    = + | React.StatelessComponent

    + | React.ComponentClass

    + | React.ClassicComponentClass

    ; + +function computeHeight(node: HTMLDivElement) { + const { style } = node; + style.height = '100%'; + const totalHeight = parseInt(`${getComputedStyle(node).height}`, 10); + const padding = + parseInt(`${getComputedStyle(node).paddingTop}`, 10) + + parseInt(`${getComputedStyle(node).paddingBottom}`, 10); + return totalHeight - padding; +} + +function getAutoHeight(n: HTMLDivElement) { + if (!n) { + return 0; + } + + const node = n; + + let height = computeHeight(node); + const parentNode = node.parentNode as HTMLDivElement; + if (parentNode) { + height = computeHeight(parentNode); + } + + return height; +} + +interface AutoHeightProps { + height?: number; +} + +function autoHeight() { + return

    ( + WrappedComponent: React.ComponentClass

    | React.SFC

    , + ): React.ComponentClass

    => { + class AutoHeightComponent extends React.Component

    { + state = { + computedHeight: 0, + }; + + root: HTMLDivElement | null = null; + + componentDidMount() { + const { height } = this.props; + if (!height && this.root) { + let h = getAutoHeight(this.root); + this.setState({ computedHeight: h }); + if (h < 1) { + h = getAutoHeight(this.root); + this.setState({ computedHeight: h }); + } + } + } + + handleRoot = (node: HTMLDivElement) => { + this.root = node; + }; + + render() { + const { height } = this.props; + const { computedHeight } = this.state; + const h = height || computedHeight; + return ( +

    + {h > 0 && } +
    + ); + } + } + return AutoHeightComponent; + }; +} +export default autoHeight; diff --git a/src/pages/dashboard/monitor/components/Charts/index.less b/src/pages/dashboard/monitor/components/Charts/index.less new file mode 100644 index 0000000000000000000000000000000000000000..190428bc80d7cd7f6f22d51fd48fa37b2d44eb10 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/index.less @@ -0,0 +1,19 @@ +.miniChart { + position: relative; + width: 100%; + .chartContent { + position: absolute; + bottom: -28px; + width: 100%; + > div { + margin: 0 -5px; + overflow: hidden; + } + } + .chartLoading { + position: absolute; + top: 16px; + left: 50%; + margin-left: -7px; + } +} diff --git a/src/pages/dashboard/monitor/components/Charts/index.tsx b/src/pages/dashboard/monitor/components/Charts/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d3d1dac4710a03e1c18b51c010a384ea6b7a5101 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/index.tsx @@ -0,0 +1,15 @@ +import Gauge from './Gauge'; +import MiniArea from './MiniArea'; +import Pie from './Pie'; +import TagCloud from './TagCloud'; +import WaterWave from './WaterWave'; + +const Charts = { + Pie, + WaterWave, + Gauge, + MiniArea, + TagCloud, +}; + +export { Charts as default, Pie, WaterWave, Gauge, TagCloud, MiniArea }; diff --git a/src/pages/dashboard/monitor/data.d.ts b/src/pages/dashboard/monitor/data.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ee2217a259b3ce57f6bb0147b4400490d98247e --- /dev/null +++ b/src/pages/dashboard/monitor/data.d.ts @@ -0,0 +1,5 @@ +export interface TagType { + name: string; + value: string; + type: string; +} diff --git a/src/pages/dashboard/monitor/index.tsx b/src/pages/dashboard/monitor/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..342987b1d7d5b26c3deb8d880355be60e994bf7d --- /dev/null +++ b/src/pages/dashboard/monitor/index.tsx @@ -0,0 +1,272 @@ +import { Card, Col, Row, Statistic, Tooltip } from 'antd'; +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; +import React, { Component } from 'react'; + +import { Dispatch } from 'redux'; +import { GridContent } from '@ant-design/pro-layout'; +import { connect } from 'dva'; +import numeral from 'numeral'; +import { StateType } from './model'; +import { Pie, WaterWave, Gauge, TagCloud } from './components/Charts'; +import ActiveChart from './components/ActiveChart'; +import styles from './style.less'; + +const { Countdown } = Statistic; + +const targetTime = new Date().getTime() + 3900000; + +interface MonitorProps { + dashboardMonitor: StateType; + dispatch: Dispatch; + loading: boolean; +} + +@connect( + ({ + dashboardMonitor, + loading, + }: { + dashboardMonitor: StateType; + loading: { + models: { [key: string]: boolean }; + }; + }) => ({ + dashboardMonitor, + loading: loading.models.monitor, + }), +) +class Monitor extends Component { + componentDidMount() { + const { dispatch } = this.props; + dispatch({ + type: 'dashboardMonitor/fetchTags', + }); + } + + render() { + const { dashboardMonitor, loading } = this.props; + const { tags } = dashboardMonitor; + return ( + + + + + + } + bordered={false} + > + + + + } + suffix="元" + value={numeral(124543233).format('0,0')} + /> + + + + } + value="92%" + /> + + + + } + > + + + + + + } + suffix="元" + value={numeral(234).format('0,0')} + /> + + +
    + + } + > + map + +
    +
    + + + + } + style={{ marginBottom: 24 }} + bordered={false} + > + + + + } + style={{ marginBottom: 24 }} + bodyStyle={{ textAlign: 'center' }} + bordered={false} + > + + + +
    + + + + } + bordered={false} + className={styles.pieCard} + > + + + + } + total="28%" + height={128} + lineWidth={2} + /> + + + + } + total="22%" + height={128} + lineWidth={2} + /> + + + + } + total="32%" + height={128} + lineWidth={2} + /> + + + + + + + } + loading={loading} + bordered={false} + bodyStyle={{ overflow: 'hidden' }} + > + + + + + + } + bodyStyle={{ textAlign: 'center', fontSize: 0 }} + bordered={false} + > + + } + percent={34} + /> + + + +
    +
    + ); + } +} + +export default Monitor; diff --git a/src/pages/dashboard/monitor/locales/en-US.ts b/src/pages/dashboard/monitor/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1e3de41af2eabe50542a608984717bd76c9cf96 --- /dev/null +++ b/src/pages/dashboard/monitor/locales/en-US.ts @@ -0,0 +1,18 @@ +export default { + 'dashboard-monitor.monitor.trading-activity': 'Real-Time Trading Activity', + 'dashboard-monitor.monitor.total-transactions': 'Total transactions today', + 'dashboard-monitor.monitor.sales-target': 'Sales target completion rate', + 'dashboard-monitor.monitor.remaining-time': 'Remaining time of activity', + 'dashboard-monitor.monitor.total-transactions-per-second': 'Total transactions per second', + 'dashboard-monitor.monitor.activity-forecast': 'Activity forecast', + 'dashboard-monitor.monitor.efficiency': 'Efficiency', + 'dashboard-monitor.monitor.ratio': 'Ratio', + 'dashboard-monitor.monitor.proportion-per-category': 'Proportion Per Category', + 'dashboard-monitor.monitor.fast-food': 'Fast food', + 'dashboard-monitor.monitor.western-food': 'Western food', + 'dashboard-monitor.monitor.hot-pot': 'Hot pot', + 'dashboard-monitor.monitor.waiting-for-implementation': 'Waiting for implementation', + 'dashboard-monitor.monitor.popular-searches': 'Popular Searches', + 'dashboard-monitor.monitor.resource-surplus': 'Resource Surplus', + 'dashboard-monitor.monitor.fund-surplus': 'Fund Surplus', +}; diff --git a/src/pages/dashboard/monitor/locales/pt-BR.ts b/src/pages/dashboard/monitor/locales/pt-BR.ts new file mode 100644 index 0000000000000000000000000000000000000000..b11425a8ca88a486ddb16beeb78291b8fc82ad21 --- /dev/null +++ b/src/pages/dashboard/monitor/locales/pt-BR.ts @@ -0,0 +1,19 @@ +export default { + 'dashboard-monitor.monitor.trading-activity': 'Real-Time Trading Activity', + 'dashboard-monitor.monitor.total-transactions': 'Total transactions today', + 'dashboard-monitor.monitor.sales-target': 'Sales target completion rate', + 'dashboard-monitor.monitor.remaining-time': 'Remaining time of activity', + 'dashboard-monitor.monitor.total-transactions-per-second': 'Total transactions per second', + 'dashboard-monitor.monitor.activity-forecast': 'Activity forecast', + 'dashboard-monitor.monitor.efficiency': 'Efficiency', + 'dashboard-monitor.monitor.ratio': 'Ratio', + 'dashboard-monitor.monitor.proportion-per-category': 'Proportion Per Category', + 'dashboard-monitor.monitor.fast-food': 'Fast food', + 'dashboard-monitor.monitor.western-food': 'Western food', + 'dashboard-monitor.monitor.hot-pot': 'Hot pot', + 'dashboard-monitor.monitor.waiting-for-implementation': 'Waiting for implementation', + 'dashboard-monitor.monitor.popular-searches': 'Popular Searches', + 'dashboard-monitor.monitor.resource-surplus': 'Resource Surplus', + 'dashboard-monitor.monitor.fund-surplus': 'Fund Surplus', + 'dashboard-monitor.exception.back': 'Back to home', +}; diff --git a/src/pages/dashboard/monitor/locales/zh-CN.ts b/src/pages/dashboard/monitor/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..f35f139bebeec667c93c29659b6967614eac91a5 --- /dev/null +++ b/src/pages/dashboard/monitor/locales/zh-CN.ts @@ -0,0 +1,18 @@ +export default { + 'dashboard-monitor.monitor.trading-activity': '活动实时交易情况', + 'dashboard-monitor.monitor.total-transactions': '今日交易总额', + 'dashboard-monitor.monitor.sales-target': '销售目标完成率', + 'dashboard-monitor.monitor.remaining-time': '活动剩余时间', + 'dashboard-monitor.monitor.total-transactions-per-second': '每秒交易总额', + 'dashboard-monitor.monitor.activity-forecast': '活动情况预测', + 'dashboard-monitor.monitor.efficiency': '券核效率', + 'dashboard-monitor.monitor.ratio': '跳出率', + 'dashboard-monitor.monitor.proportion-per-category': '各品类占比', + 'dashboard-monitor.monitor.fast-food': '中式快餐', + 'dashboard-monitor.monitor.western-food': '西餐', + 'dashboard-monitor.monitor.hot-pot': '火锅', + 'dashboard-monitor.monitor.waiting-for-implementation': 'Waiting for implementation', + 'dashboard-monitor.monitor.popular-searches': '热门搜索', + 'dashboard-monitor.monitor.resource-surplus': '资源剩余', + 'dashboard-monitor.monitor.fund-surplus': '补贴资金剩余', +}; diff --git a/src/pages/dashboard/monitor/locales/zh-TW.ts b/src/pages/dashboard/monitor/locales/zh-TW.ts new file mode 100644 index 0000000000000000000000000000000000000000..8487b32858d6758eb2dd5fd333efc47b977ec1e0 --- /dev/null +++ b/src/pages/dashboard/monitor/locales/zh-TW.ts @@ -0,0 +1,18 @@ +export default { + 'dashboard-monitor.monitor.trading-activity': '活動實時交易情況', + 'dashboard-monitor.monitor.total-transactions': '今日交易總額', + 'dashboard-monitor.monitor.sales-target': '銷售目標完成率', + 'dashboard-monitor.monitor.remaining-time': '活動剩余時間', + 'dashboard-monitor.monitor.total-transactions-per-second': '每秒交易總額', + 'dashboard-monitor.monitor.activity-forecast': '活動情況預測', + 'dashboard-monitor.monitor.efficiency': '券核效率', + 'dashboard-monitor.monitor.ratio': '跳出率', + 'dashboard-monitor.monitor.proportion-per-category': '各品類占比', + 'dashboard-monitor.monitor.fast-food': '中式快餐', + 'dashboard-monitor.monitor.western-food': '西餐', + 'dashboard-monitor.monitor.hot-pot': '火鍋', + 'dashboard-monitor.monitor.waiting-for-implementation': 'Waiting for implementation', + 'dashboard-monitor.monitor.popular-searches': '熱門搜索', + 'dashboard-monitor.monitor.resource-surplus': '資源剩余', + 'dashboard-monitor.monitor.fund-surplus': '補貼資金剩余', +}; diff --git a/src/pages/dashboard/monitor/model.ts b/src/pages/dashboard/monitor/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb5c35ce78f6dda46c03964f82f59295bc8f4c16 --- /dev/null +++ b/src/pages/dashboard/monitor/model.ts @@ -0,0 +1,54 @@ +import { AnyAction, Reducer } from 'redux'; + +import { EffectsCommandMap } from 'dva'; +import { TagType } from './data.d'; +import { queryTags } from './service'; + +export interface StateType { + tags: TagType[]; +} + +export type Effect = ( + action: AnyAction, + effects: EffectsCommandMap & { select: (func: (state: StateType) => T) => T }, +) => void; + +export interface ModelType { + namespace: string; + state: StateType; + effects: { + fetchTags: Effect; + }; + reducers: { + saveTags: Reducer; + }; +} + +const Model: ModelType = { + namespace: 'dashboardMonitor', + + state: { + tags: [], + }, + + effects: { + *fetchTags(_, { call, put }) { + const response = yield call(queryTags); + yield put({ + type: 'saveTags', + payload: response.list, + }); + }, + }, + + reducers: { + saveTags(state, action) { + return { + ...state, + tags: action.payload, + }; + }, + }, +}; + +export default Model; diff --git a/src/pages/dashboard/monitor/service.ts b/src/pages/dashboard/monitor/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0d7b872ab595f2ac1c9e71ed981c86dbee93e13 --- /dev/null +++ b/src/pages/dashboard/monitor/service.ts @@ -0,0 +1,5 @@ +import request from 'umi-request'; + +export async function queryTags() { + return request('/api/tags'); +} diff --git a/src/pages/dashboard/monitor/style.less b/src/pages/dashboard/monitor/style.less new file mode 100644 index 0000000000000000000000000000000000000000..a03fbf19d5c46beb6359704df9116d62a8922dec --- /dev/null +++ b/src/pages/dashboard/monitor/style.less @@ -0,0 +1,22 @@ +@import '~antd/es/style/themes/default.less'; + +.mapChart { + height: 452px; + padding-top: 24px; + text-align: center; + img { + display: inline-block; + max-width: 100%; + max-height: 437px; + } +} + +.pieCard :global(.pie-stat) { + font-size: 24px !important; +} + +@media screen and (max-width: @screen-lg) { + .mapChart { + height: auto; + } +} diff --git a/src/pages/dashboard/workplace/_mock.ts b/src/pages/dashboard/workplace/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..619565745fe129ccb28b09c26de4e08ce800b045 --- /dev/null +++ b/src/pages/dashboard/workplace/_mock.ts @@ -0,0 +1,303 @@ +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const avatars2 = [ + 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png', + 'https://gw.alipayobjects.com/zos/rmsportal/cnrhVkzwxjPwAaCfPbdc.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gaOngJwsRYRaVAuXXcmB.png', + 'https://gw.alipayobjects.com/zos/rmsportal/ubnKSIfAJTxIgXOKlciN.png', + 'https://gw.alipayobjects.com/zos/rmsportal/WhxKECPNujWoWEFNdnJE.png', + 'https://gw.alipayobjects.com/zos/rmsportal/jZUIxmJycoymBprLOUbT.png', + 'https://gw.alipayobjects.com/zos/rmsportal/psOgztMplJMGpVEqfcgF.png', + 'https://gw.alipayobjects.com/zos/rmsportal/ZpBqSxLxVEXfcUNoPKrz.png', + 'https://gw.alipayobjects.com/zos/rmsportal/laiEnJdGHVOhJrUShBaJ.png', + 'https://gw.alipayobjects.com/zos/rmsportal/UrQsqscbKEpNuJcvBZBu.png', +]; + +const getNotice = [ + { + id: 'xxx1', + title: titles[0], + logo: avatars[0], + description: '那是一种内在的东西,他们到达不了,也无法触及的', + updatedAt: new Date(), + member: '科学搬砖组', + href: '', + memberLink: '', + }, + { + id: 'xxx2', + title: titles[1], + logo: avatars[1], + description: '希望是一个好东西,也许是最好的,好东西是不会消亡的', + updatedAt: new Date('2017-07-24'), + member: '全组都是吴彦祖', + href: '', + memberLink: '', + }, + { + id: 'xxx3', + title: titles[2], + logo: avatars[2], + description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + updatedAt: new Date(), + member: '中二少女团', + href: '', + memberLink: '', + }, + { + id: 'xxx4', + title: titles[3], + logo: avatars[3], + description: '那时候我只会想自己想要什么,从不想自己拥有什么', + updatedAt: new Date('2017-07-23'), + member: '程序员日常', + href: '', + memberLink: '', + }, + { + id: 'xxx5', + title: titles[4], + logo: avatars[4], + description: '凛冬将至', + updatedAt: new Date('2017-07-23'), + member: '高逼格设计天团', + href: '', + memberLink: '', + }, + { + id: 'xxx6', + title: titles[5], + logo: avatars[5], + description: '生命就像一盒巧克力,结果往往出人意料', + updatedAt: new Date('2017-07-23'), + member: '骗你来学计算机', + href: '', + memberLink: '', + }, +]; + +const getActivities = [ + { + id: 'trend-1', + updatedAt: new Date(), + user: { + name: '曲丽丽', + avatar: avatars2[0], + }, + group: { + name: '高逼格设计天团', + link: 'http://github.com/', + }, + project: { + name: '六月迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, + { + id: 'trend-2', + updatedAt: new Date(), + user: { + name: '付小小', + avatar: avatars2[1], + }, + group: { + name: '高逼格设计天团', + link: 'http://github.com/', + }, + project: { + name: '六月迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, + { + id: 'trend-3', + updatedAt: new Date(), + user: { + name: '林东东', + avatar: avatars2[2], + }, + group: { + name: '中二少女团', + link: 'http://github.com/', + }, + project: { + name: '六月迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, + { + id: 'trend-4', + updatedAt: new Date(), + user: { + name: '周星星', + avatar: avatars2[4], + }, + project: { + name: '5 月日常迭代', + link: 'http://github.com/', + }, + template: '将 @{project} 更新至已发布状态', + }, + { + id: 'trend-5', + updatedAt: new Date(), + user: { + name: '朱偏右', + avatar: avatars2[3], + }, + project: { + name: '工程效能', + link: 'http://github.com/', + }, + comment: { + name: '留言', + link: 'http://github.com/', + }, + template: '在 @{project} 发布了 @{comment}', + }, + { + id: 'trend-6', + updatedAt: new Date(), + user: { + name: '乐哥', + avatar: avatars2[5], + }, + group: { + name: '程序员日常', + link: 'http://github.com/', + }, + project: { + name: '品牌迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, +]; + +const radarOriginData = [ + { + name: '个人', + ref: 10, + koubei: 8, + output: 4, + contribute: 5, + hot: 7, + }, + { + name: '团队', + ref: 3, + koubei: 9, + output: 6, + contribute: 3, + hot: 1, + }, + { + name: '部门', + ref: 4, + koubei: 1, + output: 6, + contribute: 5, + hot: 7, + }, +]; + +const radarData: any[] = []; +const radarTitleMap = { + ref: '引用', + koubei: '口碑', + output: '产量', + contribute: '贡献', + hot: '热度', +}; +radarOriginData.forEach(item => { + Object.keys(item).forEach(key => { + if (key !== 'name') { + radarData.push({ + name: item.name, + label: radarTitleMap[key], + value: item[key], + }); + } + }); +}); + +export default { + 'GET /api/project/notice': getNotice, + 'GET /api/activities': getActivities, + 'GET /api/fake_chart_data': { + radarData, + }, + + 'GET /api/currentUser': { + name: 'Serati Ma', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png', + userid: '00000001', + email: 'antdesign@alipay.com', + signature: '海纳百川,有容乃大', + title: '交互专家', + group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED', + tags: [ + { + key: '0', + label: '很有想法的', + }, + { + key: '1', + label: '专注设计', + }, + { + key: '2', + label: '辣~', + }, + { + key: '3', + label: '大长腿', + }, + { + key: '4', + label: '川妹子', + }, + { + key: '5', + label: '海纳百川', + }, + ], + notifyCount: 12, + unreadCount: 11, + country: 'China', + geographic: { + province: { + label: '浙江省', + key: '330000', + }, + city: { + label: '杭州市', + key: '330100', + }, + }, + address: '西湖区工专路 77 号', + phone: '0752-268888888', + }, +}; diff --git a/src/pages/dashboard/workplace/components/EditableLinkGroup/index.less b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.less new file mode 100644 index 0000000000000000000000000000000000000000..5add1b0b423752fb7d567e4df5b74959e88b10d2 --- /dev/null +++ b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.less @@ -0,0 +1,16 @@ +@import '~antd/es/style/themes/default.less'; + +.linkGroup { + padding: 20px 0 8px 24px; + font-size: 0; + & > a { + display: inline-block; + width: 25%; + margin-bottom: 13px; + color: @text-color; + font-size: @font-size-base; + &:hover { + color: @primary-color; + } + } +} diff --git a/src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..607846385a747bf3581fc724a4995790381e2d6c --- /dev/null +++ b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx @@ -0,0 +1,50 @@ +import React, { PureComponent, createElement } from 'react'; + +import { Button } from 'antd'; +import styles from './index.less'; + +export interface EditableLink { + title: string; + href: string; + id?: string; +} + +interface EditableLinkGroupProps { + onAdd: () => void; + links: EditableLink[]; + linkElement: React.ComponentClass; +} + +class EditableLinkGroup extends PureComponent { + static defaultProps = { + links: [], + onAdd: () => {}, + linkElement: 'a', + }; + + render() { + const { links, linkElement, onAdd } = this.props; + return ( +
    + {links.map(link => + createElement( + linkElement, + { + key: `linkGroup-item-${link.id || link.title}`, + to: link.href, + href: link.href, + }, + link.title, + ), + )} + { + + } +
    + ); + } +} + +export default EditableLinkGroup; diff --git a/src/pages/dashboard/workplace/components/Radar/autoHeight.tsx b/src/pages/dashboard/workplace/components/Radar/autoHeight.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba20956d1627dfeab788690945968b92d7e31774 --- /dev/null +++ b/src/pages/dashboard/workplace/components/Radar/autoHeight.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +export type IReactComponent

    = + | React.StatelessComponent

    + | React.ComponentClass

    + | React.ClassicComponentClass

    ; + +function computeHeight(node: HTMLDivElement) { + const { style } = node; + style.height = '100%'; + const totalHeight = parseInt(`${getComputedStyle(node).height}`, 10); + const padding = + parseInt(`${getComputedStyle(node).paddingTop}`, 10) + + parseInt(`${getComputedStyle(node).paddingBottom}`, 10); + return totalHeight - padding; +} + +function getAutoHeight(n: HTMLDivElement | undefined) { + if (!n) { + return 0; + } + + const node = n; + + let height = computeHeight(node); + const parentNode = node.parentNode as HTMLDivElement; + if (parentNode) { + height = computeHeight(parentNode); + } + + return height; +} + +interface AutoHeightProps { + height?: number; +} + +function autoHeight() { + return

    ( + WrappedComponent: React.ComponentClass

    | React.SFC

    , + ): React.ComponentClass

    => { + class AutoHeightComponent extends React.Component

    { + state = { + computedHeight: 0, + }; + + root: HTMLDivElement | undefined = undefined; + + componentDidMount() { + const { height } = this.props; + if (!height) { + let h = getAutoHeight(this.root); + this.setState({ computedHeight: h }); + if (h < 1) { + h = getAutoHeight(this.root); + this.setState({ computedHeight: h }); + } + } + } + + handleRoot = (node: HTMLDivElement) => { + this.root = node; + }; + + render() { + const { height } = this.props; + const { computedHeight } = this.state; + const h = height || computedHeight; + return ( +

    + {h > 0 && } +
    + ); + } + } + return AutoHeightComponent; + }; +} +export default autoHeight; diff --git a/src/pages/dashboard/workplace/components/Radar/index.less b/src/pages/dashboard/workplace/components/Radar/index.less new file mode 100644 index 0000000000000000000000000000000000000000..f3c1af3ac7a3c060bc5e0358ab0148380100ca24 --- /dev/null +++ b/src/pages/dashboard/workplace/components/Radar/index.less @@ -0,0 +1,46 @@ +@import '~antd/es/style/themes/default.less'; + +.radar { + .legend { + margin-top: 16px; + .legendItem { + position: relative; + color: @text-color-secondary; + line-height: 22px; + text-align: center; + cursor: pointer; + p { + margin: 0; + } + h6 { + margin-top: 4px; + margin-bottom: 0; + padding-left: 16px; + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + &::after { + position: absolute; + top: 8px; + right: 0; + width: 1px; + height: 40px; + background-color: @border-color-split; + content: ''; + } + } + > :last-child .legendItem::after { + display: none; + } + .dot { + position: relative; + top: -1px; + display: inline-block; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 6px; + } + } +} diff --git a/src/pages/dashboard/workplace/components/Radar/index.tsx b/src/pages/dashboard/workplace/components/Radar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..176345695d78f3f9c2626556c98164b3324486ac --- /dev/null +++ b/src/pages/dashboard/workplace/components/Radar/index.tsx @@ -0,0 +1,219 @@ +import { Axis, Chart, Coord, Geom, Tooltip } from 'bizcharts'; +import { Col, Row } from 'antd'; +import React, { Component } from 'react'; + +import autoHeight from './autoHeight'; +import styles from './index.less'; + +export interface RadarProps { + title?: React.ReactNode; + height?: number; + padding?: [number, number, number, number]; + hasLegend?: boolean; + data: { + name: string; + label: string; + value: string | number; + }[]; + colors?: string[]; + animate?: boolean; + forceFit?: boolean; + tickCount?: number; + style?: React.CSSProperties; +} +interface RadarState { + legendData: { + checked: boolean; + name: string; + color: string; + percent: number; + value: string; + }[]; +} +/* eslint react/no-danger:0 */ +class Radar extends Component { + state: RadarState = { + legendData: [], + }; + + chart: G2.Chart | undefined = undefined; + + node: HTMLDivElement | undefined = undefined; + + componentDidMount() { + this.getLegendData(); + } + + componentDidUpdate(preProps: RadarProps) { + const { data } = this.props; + if (data !== preProps.data) { + this.getLegendData(); + } + } + + getG2Instance = (chart: G2.Chart) => { + this.chart = chart; + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + const items = (geom as any).get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map((item: { color: any; _origin: any }[]) => { + // eslint-disable-next-line no-underscore-dangle + const origins = item.map(t => t._origin); + const result = { + name: origins[0].name, + color: item[0].color, + checked: true, + value: origins.reduce((p, n) => p + n.value, 0), + }; + + return result; + }); + + this.setState({ + legendData, + }); + }; + + handleRef = (n: HTMLDivElement) => { + this.node = n; + }; + + handleLegendClick = ( + item: { + checked: boolean; + name: string; + }, + i: string | number, + ) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter(l => l.checked).map(l => l.name); + + if (this.chart) { + this.chart.filter('name', val => filteredLegendData.indexOf(`${val}`) > -1); + this.chart.repaint(); + } + + this.setState({ + legendData, + }); + }; + + render() { + const defaultColors = [ + '#1890FF', + '#FACC14', + '#2FC25B', + '#8543E0', + '#F04864', + '#13C2C2', + '#fa8c16', + '#a0d911', + ]; + + const { + data = [], + height = 0, + title, + hasLegend = false, + forceFit = true, + tickCount = 5, + padding = [35, 30, 16, 30] as [number, number, number, number], + animate = true, + colors = defaultColors, + } = this.props; + + const { legendData } = this.state; + + const scale = { + value: { + min: 0, + tickCount, + }, + }; + + const chartHeight = height - (hasLegend ? 80 : 22); + + return ( +
    + {title &&

    {title}

    } + + + + + + + + + {hasLegend && ( + + {legendData.map((item, i) => ( + this.handleLegendClick(item, i)} + > +
    +

    + + {item.name} +

    +
    {item.value}
    +
    + + ))} +
    + )} +
    + ); + } +} + +export default autoHeight()(Radar); diff --git a/src/pages/dashboard/workplace/data.d.ts b/src/pages/dashboard/workplace/data.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8ea5b83347c75d5efb01827df54030ba701de0e --- /dev/null +++ b/src/pages/dashboard/workplace/data.d.ts @@ -0,0 +1,78 @@ +export interface TagType { + key: string; + label: string; +} + +export interface ProvinceType { + label: string; + key: string; +} + +export interface CityType { + label: string; + key: string; +} + +export interface GeographicType { + province: ProvinceType; + city: CityType; +} + +export interface NoticeType { + id: string; + title: string; + logo: string; + description: string; + updatedAt: string; + member: string; + href: string; + memberLink: string; +} + +export interface CurrentUser { + name: string; + avatar: string; + userid: string; + notice: NoticeType[]; + email: string; + signature: string; + title: string; + group: string; + tags: TagType[]; + notifyCount: number; + unreadCount: number; + country: string; + geographic: GeographicType; + address: string; + phone: string; +} +export interface Member { + avatar: string; + name: string; + id: string; +} + +export interface ActivitiesType { + id: string; + updatedAt: string; + user: { + name: string; + avatar: string; + }; + group: { + name: string; + link: string; + }; + project: { + name: string; + link: string; + }; + + template: string; +} + +export interface RadarDataType { + label: string; + name: string; + value: number; +} diff --git a/src/pages/dashboard/workplace/index.tsx b/src/pages/dashboard/workplace/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fd87398e83091d228d314c64bbb3b3bfc024595e --- /dev/null +++ b/src/pages/dashboard/workplace/index.tsx @@ -0,0 +1,263 @@ +import { Avatar, Card, Col, List, Row } from 'antd'; +import React, { PureComponent } from 'react'; + +import { Dispatch } from 'redux'; +import Link from 'umi/link'; +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import { connect } from 'dva'; +import moment from 'moment'; +import Radar from './components/Radar'; +import { ModalState } from './model'; +import EditableLinkGroup from './components/EditableLinkGroup'; +import styles from './style.less'; +import { ActivitiesType, CurrentUser, NoticeType, RadarDataType } from './data.d'; + +const links = [ + { + title: '操作一', + href: '', + }, + { + title: '操作二', + href: '', + }, + { + title: '操作三', + href: '', + }, + { + title: '操作四', + href: '', + }, + { + title: '操作五', + href: '', + }, + { + title: '操作六', + href: '', + }, +]; + +interface dashboardWorkplaceProps { + currentUser: CurrentUser; + projectNotice: NoticeType[]; + activities: ActivitiesType[]; + radarData: RadarDataType[]; + dispatch: Dispatch; + currentUserLoading: boolean; + projectLoading: boolean; + activitiesLoading: boolean; +} + +@connect( + ({ + dashboardWorkplace: { currentUser, projectNotice, activities, radarData }, + loading, + }: { + dashboardWorkplace: ModalState; + loading: { effects: any }; + }) => ({ + currentUser, + projectNotice, + activities, + radarData, + currentUserLoading: loading.effects['dashboardWorkplace/fetchUserCurrent'], + projectLoading: loading.effects['dashboardWorkplace/fetchProjectNotice'], + activitiesLoading: loading.effects['dashboardWorkplace/fetchActivitiesList'], + }), +) +class Workplace extends PureComponent { + componentDidMount() { + const { dispatch } = this.props; + dispatch({ + type: 'dashboardWorkplace/init', + }); + } + + componentWillUnmount() { + const { dispatch } = this.props; + dispatch({ + type: 'dashboardWorkplace/clear', + }); + } + + renderActivities = (item: ActivitiesType) => { + const events = item.template.split(/@\{([^{}]*)\}/gi).map(key => { + if (item[key]) { + return ( + + {item[key].name} + + ); + } + return key; + }); + return ( + + } + title={ + + {item.user.name} +   + {events} + + } + description={ + + {moment(item.updatedAt).fromNow()} + + } + /> + + ); + }; + + render() { + const { + currentUser, + activities, + projectNotice, + projectLoading, + activitiesLoading, + radarData, + } = this.props; + + const pageHeaderContent = + currentUser && Object.keys(currentUser).length ? ( +
    +
    + +
    +
    +
    + 早安, + {currentUser.name} + ,祝你开心每一天! +
    +
    + {currentUser.title} |{currentUser.group} +
    +
    +
    + ) : null; + + const extraContent = ( +
    +
    +

    项目数

    +

    56

    +
    +
    +

    团队内排名

    +

    + 8 / 24 +

    +
    +
    +

    项目访问

    +

    2,223

    +
    +
    + ); + + return ( + + + + 全部项目} + loading={projectLoading} + bodyStyle={{ padding: 0 }} + > + {projectNotice.map(item => ( + + + + + {item.title} +
    + } + description={item.description} + /> +
    + {item.member || ''} + {item.updatedAt && ( + + {moment(item.updatedAt).fromNow()} + + )} +
    + + + ))} + + + + loading={activitiesLoading} + renderItem={item => this.renderActivities(item)} + dataSource={activities} + className={styles.activitiesList} + size="large" + /> + + + + + {}} links={links} linkElement={Link} /> + + +
    + +
    +
    + +
    + + {projectNotice.map(item => ( + + + + {item.member} + + + ))} + +
    +
    + + + + ); + } +} + +export default Workplace; diff --git a/src/pages/dashboard/workplace/model.ts b/src/pages/dashboard/workplace/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..362049ed8a27f70fc3a30d4f28cb97d1c55bce57 --- /dev/null +++ b/src/pages/dashboard/workplace/model.ts @@ -0,0 +1,104 @@ +import { AnyAction, Reducer } from 'redux'; +import { EffectsCommandMap } from 'dva'; +import { ActivitiesType, CurrentUser, NoticeType, RadarDataType } from './data.d'; +import { fakeChartData, queryActivities, queryCurrent, queryProjectNotice } from './service'; + +export interface ModalState { + currentUser: Partial; + projectNotice: NoticeType[]; + activities: ActivitiesType[]; + radarData: RadarDataType[]; +} + +export type Effect = ( + action: AnyAction, + effects: EffectsCommandMap & { select: (func: (state: ModalState) => T) => T }, +) => void; + +export interface ModelType { + namespace: string; + state: ModalState; + reducers: { + save: Reducer; + clear: Reducer; + }; + effects: { + init: Effect; + fetchUserCurrent: Effect; + fetchProjectNotice: Effect; + fetchActivitiesList: Effect; + fetchChart: Effect; + }; +} + +const Model: ModelType = { + namespace: 'dashboardWorkplace', + state: { + currentUser: {}, + projectNotice: [], + activities: [], + radarData: [], + }, + effects: { + *init(_, { put }) { + yield put({ type: 'fetchUserCurrent' }); + yield put({ type: 'fetchProjectNotice' }); + yield put({ type: 'fetchActivitiesList' }); + yield put({ type: 'fetchChart' }); + }, + *fetchUserCurrent(_, { call, put }) { + const response = yield call(queryCurrent); + yield put({ + type: 'save', + payload: { + currentUser: response, + }, + }); + }, + *fetchProjectNotice(_, { call, put }) { + const response = yield call(queryProjectNotice); + yield put({ + type: 'save', + payload: { + projectNotice: Array.isArray(response) ? response : [], + }, + }); + }, + *fetchActivitiesList(_, { call, put }) { + const response = yield call(queryActivities); + yield put({ + type: 'save', + payload: { + activities: Array.isArray(response) ? response : [], + }, + }); + }, + *fetchChart(_, { call, put }) { + const { radarData } = yield call(fakeChartData); + yield put({ + type: 'save', + payload: { + radarData, + }, + }); + }, + }, + reducers: { + save(state, { payload }) { + return { + ...state, + ...payload, + }; + }, + clear() { + return { + currentUser: {}, + projectNotice: [], + activities: [], + radarData: [], + }; + }, + }, +}; + +export default Model; diff --git a/src/pages/dashboard/workplace/service.ts b/src/pages/dashboard/workplace/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcd96ebefc7522b86256920f9f975e8e2115d5b4 --- /dev/null +++ b/src/pages/dashboard/workplace/service.ts @@ -0,0 +1,17 @@ +import request from 'umi-request'; + +export async function queryProjectNotice() { + return request('/api/project/notice'); +} + +export async function queryActivities() { + return request('/api/activities'); +} + +export async function fakeChartData() { + return request('/api/fake_chart_data'); +} + +export async function queryCurrent() { + return request('/api/currentUser'); +} diff --git a/src/pages/dashboard/workplace/style.less b/src/pages/dashboard/workplace/style.less new file mode 100644 index 0000000000000000000000000000000000000000..584017ff746cf78679b1f44ded5a11bd9bfac1ad --- /dev/null +++ b/src/pages/dashboard/workplace/style.less @@ -0,0 +1,251 @@ +@import '~antd/es/style/themes/default.less'; + +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} + +.activitiesList { + padding: 0 24px 8px 24px; + .username { + color: @text-color; + } + .event { + font-weight: normal; + } +} + +.pageHeaderContent { + display: flex; + .avatar { + flex: 0 1 72px; + margin-bottom: 8px; + & > span { + display: block; + width: 72px; + height: 72px; + border-radius: 72px; + } + } + .content { + position: relative; + top: 4px; + flex: 1 1 auto; + margin-left: 24px; + color: @text-color-secondary; + line-height: 22px; + .contentTitle { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } + } +} + +.extraContent { + .clearfix(); + + float: right; + white-space: nowrap; + .statItem { + position: relative; + display: inline-block; + padding: 0 32px; + > p:first-child { + margin-bottom: 4px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + > p { + margin: 0; + color: @heading-color; + font-size: 30px; + line-height: 38px; + > span { + color: @text-color-secondary; + font-size: 20px; + } + } + &::after { + position: absolute; + top: 8px; + right: 0; + width: 1px; + height: 40px; + background-color: @border-color-split; + content: ''; + } + &:last-child { + padding-right: 0; + &::after { + display: none; + } + } + } +} + +.members { + a { + display: block; + height: 24px; + margin: 12px 0; + color: @text-color; + transition: all 0.3s; + .textOverflow(); + .member { + margin-left: 12px; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + } + &:hover { + color: @primary-color; + } + } +} + +.projectList { + :global { + .ant-card-meta-description { + height: 44px; + overflow: hidden; + color: @text-color-secondary; + line-height: 22px; + } + } + .cardTitle { + font-size: 0; + a { + display: inline-block; + height: 24px; + margin-left: 12px; + color: @heading-color; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + &:hover { + color: @primary-color; + } + } + } + .projectGrid { + width: 33.33%; + } + .projectItemContent { + display: flex; + height: 20px; + margin-top: 8px; + overflow: hidden; + font-size: 12px; + line-height: 20px; + .textOverflow(); + a { + display: inline-block; + flex: 1 1 0; + color: @text-color-secondary; + .textOverflow(); + &:hover { + color: @primary-color; + } + } + .datetime { + flex: 0 0 auto; + float: right; + color: @disabled-color; + } + } +} + +.datetime { + color: @disabled-color; +} + +@media screen and (max-width: @screen-xl) and (min-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + margin-left: -44px; + .statItem { + padding: 0 16px; + } + } +} + +@media screen and (max-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + float: none; + margin-right: 0; + .statItem { + padding: 0 16px; + text-align: left; + &::after { + display: none; + } + } + } +} + +@media screen and (max-width: @screen-md) { + .extraContent { + margin-left: -16px; + } + .projectList { + .projectGrid { + width: 50%; + } + } +} + +@media screen and (max-width: @screen-sm) { + .pageHeaderContent { + display: block; + .content { + margin-left: 0; + } + } + .extraContent { + .statItem { + float: none; + } + } +} + +@media screen and (max-width: @screen-xs) { + .projectList { + .projectGrid { + width: 100%; + } + } +} diff --git a/src/pages/editor/flow/common/IconFont/index.ts b/src/pages/editor/flow/common/IconFont/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bba8d2ed7043dd0b4f542ad5ef64bd2e9939f66 --- /dev/null +++ b/src/pages/editor/flow/common/IconFont/index.ts @@ -0,0 +1,7 @@ +import { Icon } from 'antd'; + +const IconFont = Icon.createFromIconfontCN({ + scriptUrl: 'https://at.alicdn.com/t/font_1101588_01zniftxm9yp.js', +}); + +export default IconFont; diff --git a/src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx b/src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32f17aa15e0ec59870af8cf437cff068bde4117a --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx @@ -0,0 +1,35 @@ +import { CanvasMenu, ContextMenu, EdgeMenu, GroupMenu, MultiMenu, NodeMenu } from 'gg-editor'; + +import React from 'react'; +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const FlowContextMenu = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default FlowContextMenu; diff --git a/src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx b/src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b049a5ee7d0d0dae9216d6f01f1f5e40b4a9904 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx @@ -0,0 +1,3 @@ +import FlowContextMenu from './FlowContextMenu'; + +export default FlowContextMenu; diff --git a/src/pages/editor/flow/components/EditorContextMenu/MenuItem.tsx b/src/pages/editor/flow/components/EditorContextMenu/MenuItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..63b584ca9aa2dedcd91c81e93d020e1e6863ed48 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/MenuItem.tsx @@ -0,0 +1,27 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +interface MenuItemProps { + command: string; + icon?: string; + text?: string; +} +const MenuItem: React.SFC = props => { + const { command, icon, text } = props; + + return ( + +
    + + {text || upperFirst(command)} +
    +
    + ); +}; + +export default MenuItem; diff --git a/src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx b/src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2dc5247dabe4e70c4ab219f93eeec46787d78ad1 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx @@ -0,0 +1,23 @@ +import { CanvasMenu, ContextMenu, NodeMenu } from 'gg-editor'; + +import React from 'react'; +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const MindContextMenu = () => ( + + + + + + + + + + + + + +); + +export default MindContextMenu; diff --git a/src/pages/editor/flow/components/EditorContextMenu/index.less b/src/pages/editor/flow/components/EditorContextMenu/index.less new file mode 100644 index 0000000000000000000000000000000000000000..8a2cdae3bfbe5ffaaef812f8ab22677f5340dc91 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/index.less @@ -0,0 +1,39 @@ +.contextMenu { + display: none; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + .item { + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + transition: all 0.3s; + user-select: none; + + &:hover { + background: #e6f7ff; + } + + i { + margin-right: 8px; + } + } + + :global { + .disable { + :local { + .item { + color: rgba(0, 0, 0, 0.25); + cursor: auto; + + &:hover { + background: #fff; + } + } + } + } + } +} diff --git a/src/pages/editor/flow/components/EditorContextMenu/index.tsx b/src/pages/editor/flow/components/EditorContextMenu/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ee93423b8ec66db49a081d6e92239c553ec0418 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/index.tsx @@ -0,0 +1,5 @@ +import FlowContextMenu from './FlowContextMenu'; +import KoniContextMenu from './KoniContextMenu'; +import MindContextMenu from './MindContextMenu'; + +export { FlowContextMenu, MindContextMenu, KoniContextMenu }; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx b/src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..713d6dbbb2f03ab8c6c16546cb23de487d2f63d4 --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx @@ -0,0 +1,136 @@ +import { Card, Form, Input, Select } from 'antd'; +import React, { Fragment } from 'react'; + +import { FormComponentProps } from 'antd/es/form'; +import { withPropsAPI } from 'gg-editor'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +const { Item } = Form; +const { Option } = Select; + +const inlineFormItemLayout = { + labelCol: { + sm: { span: 8 }, + }, + wrapperCol: { + sm: { span: 16 }, + }, +}; + +interface DetailFormProps extends FormComponentProps { + type: string; + propsAPI?: any; +} + +class DetailForm extends React.Component { + get item() { + const { propsAPI } = this.props; + + return propsAPI.getSelected()[0]; + } + + handleSubmit = (e: React.FormEvent) => { + if (e && e.preventDefault) { + e.preventDefault(); + } + + const { form, propsAPI } = this.props; + const { getSelected, executeCommand, update } = propsAPI; + + setTimeout(() => { + form.validateFieldsAndScroll((err, values) => { + if (err) { + return; + } + + const item = getSelected()[0]; + + if (!item) { + return; + } + + executeCommand(() => { + update(item, { + ...values, + }); + }); + }); + }, 0); + }; + + renderEdgeShapeSelect = () => ( + + ); + + renderNodeDetail = () => { + const { form } = this.props; + const { label } = this.item.getModel(); + + return ( + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + ); + }; + + renderEdgeDetail = () => { + const { form } = this.props; + const { label = '', shape = 'flow-smooth' } = this.item.getModel(); + + return ( + + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + + {form.getFieldDecorator('shape', { + initialValue: shape, + })(this.renderEdgeShapeSelect())} + + + ); + }; + + renderGroupDetail = () => { + const { form } = this.props; + const { label = '新建分组' } = this.item.getModel(); + + return ( + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + ); + }; + + render() { + const { type } = this.props; + + if (!this.item) { + return null; + } + + return ( + +
    + {type === 'node' && this.renderNodeDetail()} + {type === 'edge' && this.renderEdgeDetail()} + {type === 'group' && this.renderGroupDetail()} +
    +
    + ); + } +} + +export default Form.create()(withPropsAPI(DetailForm as any)); diff --git a/src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx b/src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce38ea16add0aa97c3af0360302a2bdcab7bfe04 --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx @@ -0,0 +1,28 @@ +import { CanvasPanel, DetailPanel, EdgePanel, GroupPanel, MultiPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const FlowDetailPanel = () => ( + + + + + + + + + + + + + + + + + +); + +export default FlowDetailPanel; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx b/src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..18aea9a44f5d96009d696e6bf145b3715c0d824c --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx @@ -0,0 +1,3 @@ +import FlowDetailPanel from './FlowDetailPanel'; + +export default FlowDetailPanel; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx b/src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf8f4834315f2640f3ae51900227a5b7762f8c0a --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx @@ -0,0 +1,19 @@ +import { CanvasPanel, DetailPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const MindDetailPanel = () => ( + + + + + + + + +); + +export default MindDetailPanel; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/index.less b/src/pages/editor/flow/components/EditorDetailPanel/index.less new file mode 100644 index 0000000000000000000000000000000000000000..081945be43c093076bab1744984f5377a34f0fe2 --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/index.less @@ -0,0 +1,10 @@ +.detailPanel { + flex: 1; + background: #fafafa; + + :global { + .ant-card { + background: #fafafa; + } + } +} diff --git a/src/pages/editor/flow/components/EditorDetailPanel/index.tsx b/src/pages/editor/flow/components/EditorDetailPanel/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..50aa37a8a3cfcc168cb687966767aa7ffbb095cc --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/index.tsx @@ -0,0 +1,5 @@ +import FlowDetailPanel from './FlowDetailPanel'; +import KoniDetailPanel from './KoniDetailPanel'; +import MindDetailPanel from './MindDetailPanel'; + +export { FlowDetailPanel, MindDetailPanel, KoniDetailPanel }; diff --git a/src/pages/editor/flow/components/EditorItemPanel/FlowItemPanel.tsx b/src/pages/editor/flow/components/EditorItemPanel/FlowItemPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dce9855d26704f7ef1292a4a1a90165682eb01bc --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/FlowItemPanel.tsx @@ -0,0 +1,54 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import styles from './index.less'; + +const FlowItemPanel = () => ( + + + + + + + + +); + +export default FlowItemPanel; diff --git a/src/pages/editor/flow/components/EditorItemPanel/KoniItemPanel.tsx b/src/pages/editor/flow/components/EditorItemPanel/KoniItemPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad446f802201ed5a51779b920e4594e009ca7325 --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/KoniItemPanel.tsx @@ -0,0 +1,53 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import styles from './index.less'; + +const KoniItemPanel = () => ( + + + + + + + +); + +export default KoniItemPanel; diff --git a/src/pages/editor/flow/components/EditorItemPanel/index.less b/src/pages/editor/flow/components/EditorItemPanel/index.less new file mode 100644 index 0000000000000000000000000000000000000000..a7acc366d1715dc50003f7e7c1335e2844112a41 --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/index.less @@ -0,0 +1,20 @@ +.itemPanel { + flex: 1; + background: #fafafa; + + :global { + .ant-card { + background: #fafafa; + } + + .ant-card-body { + display: flex; + flex-direction: column; + align-items: center; + + > div { + margin-bottom: 16px; + } + } + } +} diff --git a/src/pages/editor/flow/components/EditorItemPanel/index.tsx b/src/pages/editor/flow/components/EditorItemPanel/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ba03fbb700d78600d6ea668a57ffaad4e0586a6 --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/index.tsx @@ -0,0 +1,4 @@ +import FlowItemPanel from './FlowItemPanel'; +import KoniItemPanel from './KoniItemPanel'; + +export { FlowItemPanel, KoniItemPanel }; diff --git a/src/pages/editor/flow/components/EditorMinimap/index.tsx b/src/pages/editor/flow/components/EditorMinimap/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca27e2f9dc62c4ddbee09cafb0633521086c5d5b --- /dev/null +++ b/src/pages/editor/flow/components/EditorMinimap/index.tsx @@ -0,0 +1,11 @@ +import { Card } from 'antd'; +import { Minimap } from 'gg-editor'; +import React from 'react'; + +const EditorMinimap = () => ( + + + +); + +export default EditorMinimap; diff --git a/src/pages/editor/flow/components/EditorToolbar/FlowToolbar.tsx b/src/pages/editor/flow/components/EditorToolbar/FlowToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe888be82cf31589b034236ad3771750e1275ff8 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/FlowToolbar.tsx @@ -0,0 +1,30 @@ +import { Divider } from 'antd'; +import React from 'react'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/flow/components/EditorToolbar/KoniToolbar.tsx b/src/pages/editor/flow/components/EditorToolbar/KoniToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f222007aff66a7d41abfd1f6bd459d7d7138c116 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/KoniToolbar.tsx @@ -0,0 +1,3 @@ +import FlowToolbar from './FlowToolbar'; + +export default FlowToolbar; diff --git a/src/pages/editor/flow/components/EditorToolbar/MindToolbar.tsx b/src/pages/editor/flow/components/EditorToolbar/MindToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..536c06d84df8a1c25e3e7c2be8a6e8b7b3264cd7 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/MindToolbar.tsx @@ -0,0 +1,25 @@ +import { Divider } from 'antd'; +import React from 'react'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/flow/components/EditorToolbar/ToolbarButton.tsx b/src/pages/editor/flow/components/EditorToolbar/ToolbarButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..15a96b7a05433c5d50c82347753487e1e33f51bd --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/ToolbarButton.tsx @@ -0,0 +1,31 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import { Tooltip } from 'antd'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +interface ToolbarButtonProps { + command: string; + icon?: string; + text?: string; +} +const ToolbarButton: React.SFC = props => { + const { command, icon, text } = props; + + return ( + + + + + + ); +}; + +export default ToolbarButton; diff --git a/src/pages/editor/flow/components/EditorToolbar/index.less b/src/pages/editor/flow/components/EditorToolbar/index.less new file mode 100644 index 0000000000000000000000000000000000000000..a5cca37c2931b149d39c94410e0d105922173e29 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/index.less @@ -0,0 +1,39 @@ +.toolbar { + display: flex; + align-items: center; + + :global { + .command i { + display: inline-block; + width: 27px; + height: 27px; + margin: 0 6px; + padding-top: 6px; + text-align: center; + border: 1px solid #fff; + cursor: pointer; + + &:hover { + border: 1px solid #e6e9ed; + } + } + + .disable i { + color: rgba(0, 0, 0, 0.25); + cursor: auto; + + &:hover { + border: 1px solid #fff; + } + } + } +} + +.tooltip { + :global { + .ant-tooltip-inner { + font-size: 12px; + border-radius: 0; + } + } +} diff --git a/src/pages/editor/flow/components/EditorToolbar/index.tsx b/src/pages/editor/flow/components/EditorToolbar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..58f1d277256d82572f3f593ca727f991650c6699 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/index.tsx @@ -0,0 +1,5 @@ +import FlowToolbar from './FlowToolbar'; +import KoniToolbar from './KoniToolbar'; +import MindToolbar from './MindToolbar'; + +export { FlowToolbar, MindToolbar, KoniToolbar }; diff --git a/src/pages/editor/flow/index.less b/src/pages/editor/flow/index.less new file mode 100644 index 0000000000000000000000000000000000000000..aeffa8293bba01745b47148456f4df752bddbdf0 --- /dev/null +++ b/src/pages/editor/flow/index.less @@ -0,0 +1,41 @@ +.editor { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: calc(100vh - 250px); + background: #fff; +} + +.editorHd { + padding: 8px; + border: 1px solid #e6e9ed; +} + +.editorBd { + flex: 1; +} + +.editorSidebar, +.editorContent { + display: flex; + flex-direction: column; +} + +.editorSidebar { + background: #fafafa; + + &:first-child { + border-right: 1px solid #e6e9ed; + } + + &:last-child { + border-left: 1px solid #e6e9ed; + } +} + +.flow, +.mind, +.koni { + flex: 1; +} diff --git a/src/pages/editor/flow/index.tsx b/src/pages/editor/flow/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..267e9d3cd9de849ad11056c7055fe079b0ecf4b2 --- /dev/null +++ b/src/pages/editor/flow/index.tsx @@ -0,0 +1,44 @@ +import { Col, Row } from 'antd'; +import GGEditor, { Flow } from 'gg-editor'; + +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import React from 'react'; +import { formatMessage } from 'umi-plugin-react/locale'; +import EditorMinimap from './components/EditorMinimap'; +import { FlowContextMenu } from './components/EditorContextMenu'; +import { FlowDetailPanel } from './components/EditorDetailPanel'; +import { FlowItemPanel } from './components/EditorItemPanel'; +import { FlowToolbar } from './components/EditorToolbar'; +import styles from './index.less'; + +GGEditor.setTrackable(false); + +export default () => ( + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/src/pages/editor/flow/locales/en-US.ts b/src/pages/editor/flow/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..a24b98a3a8271466fa04416f550f4d05559e421c --- /dev/null +++ b/src/pages/editor/flow/locales/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'editor-flow.description': + 'The flow chart is an excellent way to represent the idea of the algorithm', +}; diff --git a/src/pages/editor/flow/locales/zh-CN.ts b/src/pages/editor/flow/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..4571a32afc4fcb817aff0d71398f4ee481e080c4 --- /dev/null +++ b/src/pages/editor/flow/locales/zh-CN.ts @@ -0,0 +1,3 @@ +export default { + 'editor-flow.description': '千言万语不如一张图,流程图是表示算法思路的好方法', +}; diff --git a/src/pages/editor/koni/common/IconFont/index.ts b/src/pages/editor/koni/common/IconFont/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bba8d2ed7043dd0b4f542ad5ef64bd2e9939f66 --- /dev/null +++ b/src/pages/editor/koni/common/IconFont/index.ts @@ -0,0 +1,7 @@ +import { Icon } from 'antd'; + +const IconFont = Icon.createFromIconfontCN({ + scriptUrl: 'https://at.alicdn.com/t/font_1101588_01zniftxm9yp.js', +}); + +export default IconFont; diff --git a/src/pages/editor/koni/components/EditorContextMenu/FlowContextMenu.tsx b/src/pages/editor/koni/components/EditorContextMenu/FlowContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32f17aa15e0ec59870af8cf437cff068bde4117a --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/FlowContextMenu.tsx @@ -0,0 +1,35 @@ +import { CanvasMenu, ContextMenu, EdgeMenu, GroupMenu, MultiMenu, NodeMenu } from 'gg-editor'; + +import React from 'react'; +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const FlowContextMenu = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default FlowContextMenu; diff --git a/src/pages/editor/koni/components/EditorContextMenu/KoniContextMenu.tsx b/src/pages/editor/koni/components/EditorContextMenu/KoniContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b049a5ee7d0d0dae9216d6f01f1f5e40b4a9904 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/KoniContextMenu.tsx @@ -0,0 +1,3 @@ +import FlowContextMenu from './FlowContextMenu'; + +export default FlowContextMenu; diff --git a/src/pages/editor/koni/components/EditorContextMenu/MenuItem.tsx b/src/pages/editor/koni/components/EditorContextMenu/MenuItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..63b584ca9aa2dedcd91c81e93d020e1e6863ed48 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/MenuItem.tsx @@ -0,0 +1,27 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +interface MenuItemProps { + command: string; + icon?: string; + text?: string; +} +const MenuItem: React.SFC = props => { + const { command, icon, text } = props; + + return ( + +
    + + {text || upperFirst(command)} +
    +
    + ); +}; + +export default MenuItem; diff --git a/src/pages/editor/koni/components/EditorContextMenu/MindContextMenu.tsx b/src/pages/editor/koni/components/EditorContextMenu/MindContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2dc5247dabe4e70c4ab219f93eeec46787d78ad1 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/MindContextMenu.tsx @@ -0,0 +1,23 @@ +import { CanvasMenu, ContextMenu, NodeMenu } from 'gg-editor'; + +import React from 'react'; +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const MindContextMenu = () => ( + + + + + + + + + + + + + +); + +export default MindContextMenu; diff --git a/src/pages/editor/koni/components/EditorContextMenu/index.less b/src/pages/editor/koni/components/EditorContextMenu/index.less new file mode 100644 index 0000000000000000000000000000000000000000..8a2cdae3bfbe5ffaaef812f8ab22677f5340dc91 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/index.less @@ -0,0 +1,39 @@ +.contextMenu { + display: none; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + .item { + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + transition: all 0.3s; + user-select: none; + + &:hover { + background: #e6f7ff; + } + + i { + margin-right: 8px; + } + } + + :global { + .disable { + :local { + .item { + color: rgba(0, 0, 0, 0.25); + cursor: auto; + + &:hover { + background: #fff; + } + } + } + } + } +} diff --git a/src/pages/editor/koni/components/EditorContextMenu/index.tsx b/src/pages/editor/koni/components/EditorContextMenu/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ee93423b8ec66db49a081d6e92239c553ec0418 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/index.tsx @@ -0,0 +1,5 @@ +import FlowContextMenu from './FlowContextMenu'; +import KoniContextMenu from './KoniContextMenu'; +import MindContextMenu from './MindContextMenu'; + +export { FlowContextMenu, MindContextMenu, KoniContextMenu }; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/DetailForm.tsx b/src/pages/editor/koni/components/EditorDetailPanel/DetailForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..713d6dbbb2f03ab8c6c16546cb23de487d2f63d4 --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/DetailForm.tsx @@ -0,0 +1,136 @@ +import { Card, Form, Input, Select } from 'antd'; +import React, { Fragment } from 'react'; + +import { FormComponentProps } from 'antd/es/form'; +import { withPropsAPI } from 'gg-editor'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +const { Item } = Form; +const { Option } = Select; + +const inlineFormItemLayout = { + labelCol: { + sm: { span: 8 }, + }, + wrapperCol: { + sm: { span: 16 }, + }, +}; + +interface DetailFormProps extends FormComponentProps { + type: string; + propsAPI?: any; +} + +class DetailForm extends React.Component { + get item() { + const { propsAPI } = this.props; + + return propsAPI.getSelected()[0]; + } + + handleSubmit = (e: React.FormEvent) => { + if (e && e.preventDefault) { + e.preventDefault(); + } + + const { form, propsAPI } = this.props; + const { getSelected, executeCommand, update } = propsAPI; + + setTimeout(() => { + form.validateFieldsAndScroll((err, values) => { + if (err) { + return; + } + + const item = getSelected()[0]; + + if (!item) { + return; + } + + executeCommand(() => { + update(item, { + ...values, + }); + }); + }); + }, 0); + }; + + renderEdgeShapeSelect = () => ( + + ); + + renderNodeDetail = () => { + const { form } = this.props; + const { label } = this.item.getModel(); + + return ( + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + ); + }; + + renderEdgeDetail = () => { + const { form } = this.props; + const { label = '', shape = 'flow-smooth' } = this.item.getModel(); + + return ( + + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + + {form.getFieldDecorator('shape', { + initialValue: shape, + })(this.renderEdgeShapeSelect())} + + + ); + }; + + renderGroupDetail = () => { + const { form } = this.props; + const { label = '新建分组' } = this.item.getModel(); + + return ( + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + ); + }; + + render() { + const { type } = this.props; + + if (!this.item) { + return null; + } + + return ( + +
    + {type === 'node' && this.renderNodeDetail()} + {type === 'edge' && this.renderEdgeDetail()} + {type === 'group' && this.renderGroupDetail()} +
    +
    + ); + } +} + +export default Form.create()(withPropsAPI(DetailForm as any)); diff --git a/src/pages/editor/koni/components/EditorDetailPanel/FlowDetailPanel.tsx b/src/pages/editor/koni/components/EditorDetailPanel/FlowDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce38ea16add0aa97c3af0360302a2bdcab7bfe04 --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/FlowDetailPanel.tsx @@ -0,0 +1,28 @@ +import { CanvasPanel, DetailPanel, EdgePanel, GroupPanel, MultiPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const FlowDetailPanel = () => ( + + + + + + + + + + + + + + + + + +); + +export default FlowDetailPanel; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/KoniDetailPanel.tsx b/src/pages/editor/koni/components/EditorDetailPanel/KoniDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..18aea9a44f5d96009d696e6bf145b3715c0d824c --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/KoniDetailPanel.tsx @@ -0,0 +1,3 @@ +import FlowDetailPanel from './FlowDetailPanel'; + +export default FlowDetailPanel; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/MindDetailPanel.tsx b/src/pages/editor/koni/components/EditorDetailPanel/MindDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf8f4834315f2640f3ae51900227a5b7762f8c0a --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/MindDetailPanel.tsx @@ -0,0 +1,19 @@ +import { CanvasPanel, DetailPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const MindDetailPanel = () => ( + + + + + + + + +); + +export default MindDetailPanel; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/index.less b/src/pages/editor/koni/components/EditorDetailPanel/index.less new file mode 100644 index 0000000000000000000000000000000000000000..081945be43c093076bab1744984f5377a34f0fe2 --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/index.less @@ -0,0 +1,10 @@ +.detailPanel { + flex: 1; + background: #fafafa; + + :global { + .ant-card { + background: #fafafa; + } + } +} diff --git a/src/pages/editor/koni/components/EditorDetailPanel/index.tsx b/src/pages/editor/koni/components/EditorDetailPanel/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..50aa37a8a3cfcc168cb687966767aa7ffbb095cc --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/index.tsx @@ -0,0 +1,5 @@ +import FlowDetailPanel from './FlowDetailPanel'; +import KoniDetailPanel from './KoniDetailPanel'; +import MindDetailPanel from './MindDetailPanel'; + +export { FlowDetailPanel, MindDetailPanel, KoniDetailPanel }; diff --git a/src/pages/editor/koni/components/EditorItemPanel/FlowItemPanel.tsx b/src/pages/editor/koni/components/EditorItemPanel/FlowItemPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dce9855d26704f7ef1292a4a1a90165682eb01bc --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/FlowItemPanel.tsx @@ -0,0 +1,54 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import styles from './index.less'; + +const FlowItemPanel = () => ( + + + + + + + + +); + +export default FlowItemPanel; diff --git a/src/pages/editor/koni/components/EditorItemPanel/KoniItemPanel.tsx b/src/pages/editor/koni/components/EditorItemPanel/KoniItemPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad446f802201ed5a51779b920e4594e009ca7325 --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/KoniItemPanel.tsx @@ -0,0 +1,53 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import styles from './index.less'; + +const KoniItemPanel = () => ( + + + + + + + +); + +export default KoniItemPanel; diff --git a/src/pages/editor/koni/components/EditorItemPanel/index.less b/src/pages/editor/koni/components/EditorItemPanel/index.less new file mode 100644 index 0000000000000000000000000000000000000000..a7acc366d1715dc50003f7e7c1335e2844112a41 --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/index.less @@ -0,0 +1,20 @@ +.itemPanel { + flex: 1; + background: #fafafa; + + :global { + .ant-card { + background: #fafafa; + } + + .ant-card-body { + display: flex; + flex-direction: column; + align-items: center; + + > div { + margin-bottom: 16px; + } + } + } +} diff --git a/src/pages/editor/koni/components/EditorItemPanel/index.tsx b/src/pages/editor/koni/components/EditorItemPanel/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ba03fbb700d78600d6ea668a57ffaad4e0586a6 --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/index.tsx @@ -0,0 +1,4 @@ +import FlowItemPanel from './FlowItemPanel'; +import KoniItemPanel from './KoniItemPanel'; + +export { FlowItemPanel, KoniItemPanel }; diff --git a/src/pages/editor/koni/components/EditorMinimap/index.tsx b/src/pages/editor/koni/components/EditorMinimap/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca27e2f9dc62c4ddbee09cafb0633521086c5d5b --- /dev/null +++ b/src/pages/editor/koni/components/EditorMinimap/index.tsx @@ -0,0 +1,11 @@ +import { Card } from 'antd'; +import { Minimap } from 'gg-editor'; +import React from 'react'; + +const EditorMinimap = () => ( + + + +); + +export default EditorMinimap; diff --git a/src/pages/editor/koni/components/EditorToolbar/FlowToolbar.tsx b/src/pages/editor/koni/components/EditorToolbar/FlowToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe888be82cf31589b034236ad3771750e1275ff8 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/FlowToolbar.tsx @@ -0,0 +1,30 @@ +import { Divider } from 'antd'; +import React from 'react'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/koni/components/EditorToolbar/KoniToolbar.tsx b/src/pages/editor/koni/components/EditorToolbar/KoniToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f222007aff66a7d41abfd1f6bd459d7d7138c116 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/KoniToolbar.tsx @@ -0,0 +1,3 @@ +import FlowToolbar from './FlowToolbar'; + +export default FlowToolbar; diff --git a/src/pages/editor/koni/components/EditorToolbar/MindToolbar.tsx b/src/pages/editor/koni/components/EditorToolbar/MindToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..536c06d84df8a1c25e3e7c2be8a6e8b7b3264cd7 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/MindToolbar.tsx @@ -0,0 +1,25 @@ +import { Divider } from 'antd'; +import React from 'react'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/koni/components/EditorToolbar/ToolbarButton.tsx b/src/pages/editor/koni/components/EditorToolbar/ToolbarButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..15a96b7a05433c5d50c82347753487e1e33f51bd --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/ToolbarButton.tsx @@ -0,0 +1,31 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import { Tooltip } from 'antd'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +interface ToolbarButtonProps { + command: string; + icon?: string; + text?: string; +} +const ToolbarButton: React.SFC = props => { + const { command, icon, text } = props; + + return ( + + + + + + ); +}; + +export default ToolbarButton; diff --git a/src/pages/editor/koni/components/EditorToolbar/index.less b/src/pages/editor/koni/components/EditorToolbar/index.less new file mode 100644 index 0000000000000000000000000000000000000000..a5cca37c2931b149d39c94410e0d105922173e29 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/index.less @@ -0,0 +1,39 @@ +.toolbar { + display: flex; + align-items: center; + + :global { + .command i { + display: inline-block; + width: 27px; + height: 27px; + margin: 0 6px; + padding-top: 6px; + text-align: center; + border: 1px solid #fff; + cursor: pointer; + + &:hover { + border: 1px solid #e6e9ed; + } + } + + .disable i { + color: rgba(0, 0, 0, 0.25); + cursor: auto; + + &:hover { + border: 1px solid #fff; + } + } + } +} + +.tooltip { + :global { + .ant-tooltip-inner { + font-size: 12px; + border-radius: 0; + } + } +} diff --git a/src/pages/editor/koni/components/EditorToolbar/index.tsx b/src/pages/editor/koni/components/EditorToolbar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..58f1d277256d82572f3f593ca727f991650c6699 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/index.tsx @@ -0,0 +1,5 @@ +import FlowToolbar from './FlowToolbar'; +import KoniToolbar from './KoniToolbar'; +import MindToolbar from './MindToolbar'; + +export { FlowToolbar, MindToolbar, KoniToolbar }; diff --git a/src/pages/editor/koni/index.less b/src/pages/editor/koni/index.less new file mode 100644 index 0000000000000000000000000000000000000000..aeffa8293bba01745b47148456f4df752bddbdf0 --- /dev/null +++ b/src/pages/editor/koni/index.less @@ -0,0 +1,41 @@ +.editor { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: calc(100vh - 250px); + background: #fff; +} + +.editorHd { + padding: 8px; + border: 1px solid #e6e9ed; +} + +.editorBd { + flex: 1; +} + +.editorSidebar, +.editorContent { + display: flex; + flex-direction: column; +} + +.editorSidebar { + background: #fafafa; + + &:first-child { + border-right: 1px solid #e6e9ed; + } + + &:last-child { + border-left: 1px solid #e6e9ed; + } +} + +.flow, +.mind, +.koni { + flex: 1; +} diff --git a/src/pages/editor/koni/index.tsx b/src/pages/editor/koni/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..104c7484290a606844c2fe1bfda2f6a85717dfad --- /dev/null +++ b/src/pages/editor/koni/index.tsx @@ -0,0 +1,44 @@ +import { Col, Row } from 'antd'; +import GGEditor, { Koni } from 'gg-editor'; + +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import React from 'react'; +import { formatMessage } from 'umi-plugin-react/locale'; +import EditorMinimap from './components/EditorMinimap'; +import { KoniContextMenu } from './components/EditorContextMenu'; +import { KoniDetailPanel } from './components/EditorDetailPanel'; +import { KoniItemPanel } from './components/EditorItemPanel'; +import { KoniToolbar } from './components/EditorToolbar'; +import styles from './index.less'; + +GGEditor.setTrackable(false); + +export default () => ( + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/src/pages/editor/koni/locales/en-US.ts b/src/pages/editor/koni/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..0040230f3ab73262bc92de838adcf295786859b5 --- /dev/null +++ b/src/pages/editor/koni/locales/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'editor-koni.description': + 'The topology diagram refers to the network structure diagram composed of network node devices and communication media', +}; diff --git a/src/pages/editor/koni/locales/zh-CN.ts b/src/pages/editor/koni/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcc244c3d2ccf100830f0e4bf027c03fa9eda44b --- /dev/null +++ b/src/pages/editor/koni/locales/zh-CN.ts @@ -0,0 +1,3 @@ +export default { + 'editor-koni.description': '拓扑结构图是指由网络节点设备和通信介质构成的网络结构图', +}; diff --git a/src/pages/editor/mind/common/IconFont/index.ts b/src/pages/editor/mind/common/IconFont/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bba8d2ed7043dd0b4f542ad5ef64bd2e9939f66 --- /dev/null +++ b/src/pages/editor/mind/common/IconFont/index.ts @@ -0,0 +1,7 @@ +import { Icon } from 'antd'; + +const IconFont = Icon.createFromIconfontCN({ + scriptUrl: 'https://at.alicdn.com/t/font_1101588_01zniftxm9yp.js', +}); + +export default IconFont; diff --git a/src/pages/editor/mind/components/EditorContextMenu/FlowContextMenu.tsx b/src/pages/editor/mind/components/EditorContextMenu/FlowContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32f17aa15e0ec59870af8cf437cff068bde4117a --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/FlowContextMenu.tsx @@ -0,0 +1,35 @@ +import { CanvasMenu, ContextMenu, EdgeMenu, GroupMenu, MultiMenu, NodeMenu } from 'gg-editor'; + +import React from 'react'; +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const FlowContextMenu = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default FlowContextMenu; diff --git a/src/pages/editor/mind/components/EditorContextMenu/KoniContextMenu.tsx b/src/pages/editor/mind/components/EditorContextMenu/KoniContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b049a5ee7d0d0dae9216d6f01f1f5e40b4a9904 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/KoniContextMenu.tsx @@ -0,0 +1,3 @@ +import FlowContextMenu from './FlowContextMenu'; + +export default FlowContextMenu; diff --git a/src/pages/editor/mind/components/EditorContextMenu/MenuItem.tsx b/src/pages/editor/mind/components/EditorContextMenu/MenuItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..63b584ca9aa2dedcd91c81e93d020e1e6863ed48 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/MenuItem.tsx @@ -0,0 +1,27 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +interface MenuItemProps { + command: string; + icon?: string; + text?: string; +} +const MenuItem: React.SFC = props => { + const { command, icon, text } = props; + + return ( + +
    + + {text || upperFirst(command)} +
    +
    + ); +}; + +export default MenuItem; diff --git a/src/pages/editor/mind/components/EditorContextMenu/MindContextMenu.tsx b/src/pages/editor/mind/components/EditorContextMenu/MindContextMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2dc5247dabe4e70c4ab219f93eeec46787d78ad1 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/MindContextMenu.tsx @@ -0,0 +1,23 @@ +import { CanvasMenu, ContextMenu, NodeMenu } from 'gg-editor'; + +import React from 'react'; +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const MindContextMenu = () => ( + + + + + + + + + + + + + +); + +export default MindContextMenu; diff --git a/src/pages/editor/mind/components/EditorContextMenu/index.less b/src/pages/editor/mind/components/EditorContextMenu/index.less new file mode 100644 index 0000000000000000000000000000000000000000..8a2cdae3bfbe5ffaaef812f8ab22677f5340dc91 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/index.less @@ -0,0 +1,39 @@ +.contextMenu { + display: none; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + .item { + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + transition: all 0.3s; + user-select: none; + + &:hover { + background: #e6f7ff; + } + + i { + margin-right: 8px; + } + } + + :global { + .disable { + :local { + .item { + color: rgba(0, 0, 0, 0.25); + cursor: auto; + + &:hover { + background: #fff; + } + } + } + } + } +} diff --git a/src/pages/editor/mind/components/EditorContextMenu/index.tsx b/src/pages/editor/mind/components/EditorContextMenu/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ee93423b8ec66db49a081d6e92239c553ec0418 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/index.tsx @@ -0,0 +1,5 @@ +import FlowContextMenu from './FlowContextMenu'; +import KoniContextMenu from './KoniContextMenu'; +import MindContextMenu from './MindContextMenu'; + +export { FlowContextMenu, MindContextMenu, KoniContextMenu }; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/DetailForm.tsx b/src/pages/editor/mind/components/EditorDetailPanel/DetailForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..713d6dbbb2f03ab8c6c16546cb23de487d2f63d4 --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/DetailForm.tsx @@ -0,0 +1,136 @@ +import { Card, Form, Input, Select } from 'antd'; +import React, { Fragment } from 'react'; + +import { FormComponentProps } from 'antd/es/form'; +import { withPropsAPI } from 'gg-editor'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +const { Item } = Form; +const { Option } = Select; + +const inlineFormItemLayout = { + labelCol: { + sm: { span: 8 }, + }, + wrapperCol: { + sm: { span: 16 }, + }, +}; + +interface DetailFormProps extends FormComponentProps { + type: string; + propsAPI?: any; +} + +class DetailForm extends React.Component { + get item() { + const { propsAPI } = this.props; + + return propsAPI.getSelected()[0]; + } + + handleSubmit = (e: React.FormEvent) => { + if (e && e.preventDefault) { + e.preventDefault(); + } + + const { form, propsAPI } = this.props; + const { getSelected, executeCommand, update } = propsAPI; + + setTimeout(() => { + form.validateFieldsAndScroll((err, values) => { + if (err) { + return; + } + + const item = getSelected()[0]; + + if (!item) { + return; + } + + executeCommand(() => { + update(item, { + ...values, + }); + }); + }); + }, 0); + }; + + renderEdgeShapeSelect = () => ( + + ); + + renderNodeDetail = () => { + const { form } = this.props; + const { label } = this.item.getModel(); + + return ( + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + ); + }; + + renderEdgeDetail = () => { + const { form } = this.props; + const { label = '', shape = 'flow-smooth' } = this.item.getModel(); + + return ( + + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + + {form.getFieldDecorator('shape', { + initialValue: shape, + })(this.renderEdgeShapeSelect())} + + + ); + }; + + renderGroupDetail = () => { + const { form } = this.props; + const { label = '新建分组' } = this.item.getModel(); + + return ( + + {form.getFieldDecorator('label', { + initialValue: label, + })()} + + ); + }; + + render() { + const { type } = this.props; + + if (!this.item) { + return null; + } + + return ( + +
    + {type === 'node' && this.renderNodeDetail()} + {type === 'edge' && this.renderEdgeDetail()} + {type === 'group' && this.renderGroupDetail()} +
    +
    + ); + } +} + +export default Form.create()(withPropsAPI(DetailForm as any)); diff --git a/src/pages/editor/mind/components/EditorDetailPanel/FlowDetailPanel.tsx b/src/pages/editor/mind/components/EditorDetailPanel/FlowDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce38ea16add0aa97c3af0360302a2bdcab7bfe04 --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/FlowDetailPanel.tsx @@ -0,0 +1,28 @@ +import { CanvasPanel, DetailPanel, EdgePanel, GroupPanel, MultiPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const FlowDetailPanel = () => ( + + + + + + + + + + + + + + + + + +); + +export default FlowDetailPanel; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/KoniDetailPanel.tsx b/src/pages/editor/mind/components/EditorDetailPanel/KoniDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..18aea9a44f5d96009d696e6bf145b3715c0d824c --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/KoniDetailPanel.tsx @@ -0,0 +1,3 @@ +import FlowDetailPanel from './FlowDetailPanel'; + +export default FlowDetailPanel; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/MindDetailPanel.tsx b/src/pages/editor/mind/components/EditorDetailPanel/MindDetailPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf8f4834315f2640f3ae51900227a5b7762f8c0a --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/MindDetailPanel.tsx @@ -0,0 +1,19 @@ +import { CanvasPanel, DetailPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const MindDetailPanel = () => ( + + + + + + + + +); + +export default MindDetailPanel; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/index.less b/src/pages/editor/mind/components/EditorDetailPanel/index.less new file mode 100644 index 0000000000000000000000000000000000000000..081945be43c093076bab1744984f5377a34f0fe2 --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/index.less @@ -0,0 +1,10 @@ +.detailPanel { + flex: 1; + background: #fafafa; + + :global { + .ant-card { + background: #fafafa; + } + } +} diff --git a/src/pages/editor/mind/components/EditorDetailPanel/index.tsx b/src/pages/editor/mind/components/EditorDetailPanel/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..50aa37a8a3cfcc168cb687966767aa7ffbb095cc --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/index.tsx @@ -0,0 +1,5 @@ +import FlowDetailPanel from './FlowDetailPanel'; +import KoniDetailPanel from './KoniDetailPanel'; +import MindDetailPanel from './MindDetailPanel'; + +export { FlowDetailPanel, MindDetailPanel, KoniDetailPanel }; diff --git a/src/pages/editor/mind/components/EditorItemPanel/FlowItemPanel.tsx b/src/pages/editor/mind/components/EditorItemPanel/FlowItemPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dce9855d26704f7ef1292a4a1a90165682eb01bc --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/FlowItemPanel.tsx @@ -0,0 +1,54 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import styles from './index.less'; + +const FlowItemPanel = () => ( + + + + + + + + +); + +export default FlowItemPanel; diff --git a/src/pages/editor/mind/components/EditorItemPanel/KoniItemPanel.tsx b/src/pages/editor/mind/components/EditorItemPanel/KoniItemPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad446f802201ed5a51779b920e4594e009ca7325 --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/KoniItemPanel.tsx @@ -0,0 +1,53 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import React from 'react'; +import styles from './index.less'; + +const KoniItemPanel = () => ( + + + + + + + +); + +export default KoniItemPanel; diff --git a/src/pages/editor/mind/components/EditorItemPanel/index.less b/src/pages/editor/mind/components/EditorItemPanel/index.less new file mode 100644 index 0000000000000000000000000000000000000000..a7acc366d1715dc50003f7e7c1335e2844112a41 --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/index.less @@ -0,0 +1,20 @@ +.itemPanel { + flex: 1; + background: #fafafa; + + :global { + .ant-card { + background: #fafafa; + } + + .ant-card-body { + display: flex; + flex-direction: column; + align-items: center; + + > div { + margin-bottom: 16px; + } + } + } +} diff --git a/src/pages/editor/mind/components/EditorItemPanel/index.tsx b/src/pages/editor/mind/components/EditorItemPanel/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ba03fbb700d78600d6ea668a57ffaad4e0586a6 --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/index.tsx @@ -0,0 +1,4 @@ +import FlowItemPanel from './FlowItemPanel'; +import KoniItemPanel from './KoniItemPanel'; + +export { FlowItemPanel, KoniItemPanel }; diff --git a/src/pages/editor/mind/components/EditorMinimap/index.tsx b/src/pages/editor/mind/components/EditorMinimap/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca27e2f9dc62c4ddbee09cafb0633521086c5d5b --- /dev/null +++ b/src/pages/editor/mind/components/EditorMinimap/index.tsx @@ -0,0 +1,11 @@ +import { Card } from 'antd'; +import { Minimap } from 'gg-editor'; +import React from 'react'; + +const EditorMinimap = () => ( + + + +); + +export default EditorMinimap; diff --git a/src/pages/editor/mind/components/EditorToolbar/FlowToolbar.tsx b/src/pages/editor/mind/components/EditorToolbar/FlowToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe888be82cf31589b034236ad3771750e1275ff8 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/FlowToolbar.tsx @@ -0,0 +1,30 @@ +import { Divider } from 'antd'; +import React from 'react'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/mind/components/EditorToolbar/KoniToolbar.tsx b/src/pages/editor/mind/components/EditorToolbar/KoniToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f222007aff66a7d41abfd1f6bd459d7d7138c116 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/KoniToolbar.tsx @@ -0,0 +1,3 @@ +import FlowToolbar from './FlowToolbar'; + +export default FlowToolbar; diff --git a/src/pages/editor/mind/components/EditorToolbar/MindToolbar.tsx b/src/pages/editor/mind/components/EditorToolbar/MindToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..536c06d84df8a1c25e3e7c2be8a6e8b7b3264cd7 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/MindToolbar.tsx @@ -0,0 +1,25 @@ +import { Divider } from 'antd'; +import React from 'react'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/mind/components/EditorToolbar/ToolbarButton.tsx b/src/pages/editor/mind/components/EditorToolbar/ToolbarButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..15a96b7a05433c5d50c82347753487e1e33f51bd --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/ToolbarButton.tsx @@ -0,0 +1,31 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import { Tooltip } from 'antd'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +interface ToolbarButtonProps { + command: string; + icon?: string; + text?: string; +} +const ToolbarButton: React.SFC = props => { + const { command, icon, text } = props; + + return ( + + + + + + ); +}; + +export default ToolbarButton; diff --git a/src/pages/editor/mind/components/EditorToolbar/index.less b/src/pages/editor/mind/components/EditorToolbar/index.less new file mode 100644 index 0000000000000000000000000000000000000000..a5cca37c2931b149d39c94410e0d105922173e29 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/index.less @@ -0,0 +1,39 @@ +.toolbar { + display: flex; + align-items: center; + + :global { + .command i { + display: inline-block; + width: 27px; + height: 27px; + margin: 0 6px; + padding-top: 6px; + text-align: center; + border: 1px solid #fff; + cursor: pointer; + + &:hover { + border: 1px solid #e6e9ed; + } + } + + .disable i { + color: rgba(0, 0, 0, 0.25); + cursor: auto; + + &:hover { + border: 1px solid #fff; + } + } + } +} + +.tooltip { + :global { + .ant-tooltip-inner { + font-size: 12px; + border-radius: 0; + } + } +} diff --git a/src/pages/editor/mind/components/EditorToolbar/index.tsx b/src/pages/editor/mind/components/EditorToolbar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..58f1d277256d82572f3f593ca727f991650c6699 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/index.tsx @@ -0,0 +1,5 @@ +import FlowToolbar from './FlowToolbar'; +import KoniToolbar from './KoniToolbar'; +import MindToolbar from './MindToolbar'; + +export { FlowToolbar, MindToolbar, KoniToolbar }; diff --git a/src/pages/editor/mind/index.less b/src/pages/editor/mind/index.less new file mode 100644 index 0000000000000000000000000000000000000000..aeffa8293bba01745b47148456f4df752bddbdf0 --- /dev/null +++ b/src/pages/editor/mind/index.less @@ -0,0 +1,41 @@ +.editor { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: calc(100vh - 250px); + background: #fff; +} + +.editorHd { + padding: 8px; + border: 1px solid #e6e9ed; +} + +.editorBd { + flex: 1; +} + +.editorSidebar, +.editorContent { + display: flex; + flex-direction: column; +} + +.editorSidebar { + background: #fafafa; + + &:first-child { + border-right: 1px solid #e6e9ed; + } + + &:last-child { + border-left: 1px solid #e6e9ed; + } +} + +.flow, +.mind, +.koni { + flex: 1; +} diff --git a/src/pages/editor/mind/index.tsx b/src/pages/editor/mind/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6fa0a05d84893ecc17f89c429035e7f20b72b56b --- /dev/null +++ b/src/pages/editor/mind/index.tsx @@ -0,0 +1,41 @@ +import { Col, Row } from 'antd'; +import GGEditor, { Mind } from 'gg-editor'; + +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import React from 'react'; +import { formatMessage } from 'umi-plugin-react/locale'; +import EditorMinimap from './components/EditorMinimap'; +import { MindContextMenu } from './components/EditorContextMenu'; +import { MindDetailPanel } from './components/EditorDetailPanel'; +import { MindToolbar } from './components/EditorToolbar'; +import data from './worldCup2018.json'; +import styles from './index.less'; + +GGEditor.setTrackable(false); + +export default () => ( + + + + + + + + + + + + + + + + + + + +); diff --git a/src/pages/editor/mind/locales/en-US.ts b/src/pages/editor/mind/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0e527428d4c0c8af7ccf5d88155719e56cf997f --- /dev/null +++ b/src/pages/editor/mind/locales/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'editor-mind.description': + 'The brain map is an effective graphical thinking tool for expressing divergent thinking. It is simple but effective and is a practical thinking tool', +}; diff --git a/src/pages/editor/mind/locales/zh-CN.ts b/src/pages/editor/mind/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..36231a9890b66c7080dd12c6670498d0ae5e7086 --- /dev/null +++ b/src/pages/editor/mind/locales/zh-CN.ts @@ -0,0 +1,4 @@ +export default { + 'editor-mind.description': + '脑图是表达发散性思维的有效图形思维工具 ,它简单却又很有效,是一种实用性的思维工具', +}; diff --git a/src/pages/editor/mind/worldCup2018.json b/src/pages/editor/mind/worldCup2018.json new file mode 100644 index 0000000000000000000000000000000000000000..44f3e63f56ae005d569335026a79f8f0012d4611 --- /dev/null +++ b/src/pages/editor/mind/worldCup2018.json @@ -0,0 +1,129 @@ +{ + "roots": [ + { + "label": "法国", + "children": [ + { + "label": "克罗地亚", + "side": "left", + "children": [ + { + "label": "克罗地亚", + "children": [ + { + "label": "克罗地亚", + "children": [ + { + "label": "克罗地亚" + }, + { + "label": "丹麦" + } + ] + }, + { + "label": "俄罗斯", + "children": [ + { + "label": "俄罗斯" + }, + { + "label": "西班牙" + } + ] + } + ] + }, + { + "label": "英格兰", + "children": [ + { + "label": "英格兰", + "children": [ + { + "label": "英格兰" + }, + { + "label": "哥伦比亚" + } + ] + }, + { + "label": "瑞典", + "children": [ + { + "label": "瑞士" + }, + { + "label": "瑞典" + } + ] + } + ] + } + ] + }, + { + "label": "法国", + "side": "right", + "children": [ + { + "label": "法国", + "children": [ + { + "label": "法国", + "children": [ + { + "label": "法国" + }, + { + "label": "阿根廷" + } + ] + }, + { + "label": "乌拉圭", + "children": [ + { + "label": "乌拉圭" + }, + { + "label": "葡萄牙" + } + ] + } + ] + }, + { + "label": "比利时", + "children": [ + { + "label": "比利时", + "children": [ + { + "label": "比利时" + }, + { + "label": "日本" + } + ] + }, + { + "label": "巴西", + "children": [ + { + "label": "巴西" + }, + { + "label": "墨西哥" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/pages/exception/403/components/Exception/index.less b/src/pages/exception/403/components/Exception/index.less new file mode 100644 index 0000000000000000000000000000000000000000..dd0dd575085cf2cb87d217655b1c607fee0a71b4 --- /dev/null +++ b/src/pages/exception/403/components/Exception/index.less @@ -0,0 +1,89 @@ +@import '~antd/es/style/themes/default.less'; + +.exception { + display: flex; + align-items: center; + height: 80%; + min-height: 500px; + + .imgBlock { + flex: 0 0 62.5%; + width: 62.5%; + padding-right: 152px; + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + } + + .imgEle { + float: right; + width: 100%; + max-width: 430px; + height: 360px; + background-repeat: no-repeat; + background-position: 50% 50%; + background-size: contain; + } + + .content { + flex: auto; + + h1 { + margin-bottom: 24px; + color: #434e59; + font-weight: 600; + font-size: 72px; + line-height: 72px; + } + + .desc { + margin-bottom: 16px; + color: @text-color-secondary; + font-size: 20px; + line-height: 28px; + } + + .actions { + button:not(:last-child) { + margin-right: 8px; + } + } + } +} + +@media screen and (max-width: @screen-xl) { + .exception { + .imgBlock { + padding-right: 88px; + } + } +} + +@media screen and (max-width: @screen-sm) { + .exception { + display: block; + text-align: center; + .imgBlock { + margin: 0 auto 24px; + padding-right: 0; + } + } +} + +@media screen and (max-width: @screen-xs) { + .exception { + .imgBlock { + margin-bottom: -24px; + overflow: hidden; + } + } +} diff --git a/src/pages/exception/403/components/Exception/index.tsx b/src/pages/exception/403/components/Exception/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5111bf2a546eb9d576ce593dc71eb05fbc63ab2c --- /dev/null +++ b/src/pages/exception/403/components/Exception/index.tsx @@ -0,0 +1,83 @@ +import React, { createElement } from 'react'; + +import { Button } from 'antd'; +import Link from 'umi/link'; +import classNames from 'classnames'; +import config from './typeConfig'; +import styles from './index.less'; + +export interface ExceptionProps< + L = { + to: string; + href?: string; + replace?: boolean; + innerRef?: (node: HTMLAnchorElement | null) => void; + } +> { + type?: '403' | '404' | '500'; + title?: React.ReactNode; + desc?: React.ReactNode; + img?: string; + actions?: React.ReactNode; + linkElement?: string | React.ComponentType | typeof Link; + style?: React.CSSProperties; + className?: string; + backText?: React.ReactNode; + redirect?: string; +} + +class Exception extends React.Component { + static defaultProps = { + backText: 'back to home', + redirect: '/', + }; + + constructor(props: ExceptionProps) { + super(props); + this.state = {}; + } + + render() { + const { + className, + backText, + linkElement = 'a', + type = '404', + title, + desc, + img, + actions, + redirect, + ...rest + } = this.props; + const pageType = type in config ? type : '404'; + const clsString = classNames(styles.exception, className); + return ( +
    +
    +
    +
    +
    +

    {title || config[pageType].title}

    +
    {desc || config[pageType].desc}
    +
    + {actions || + createElement( + linkElement as any, + { + to: redirect, + href: redirect, + }, + , + )} +
    +
    +
    + ); + } +} + +export default Exception; diff --git a/src/pages/exception/403/components/Exception/typeConfig.ts b/src/pages/exception/403/components/Exception/typeConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..453a5edcecb0b01f7f2cb720be3b0dfcc2302da4 --- /dev/null +++ b/src/pages/exception/403/components/Exception/typeConfig.ts @@ -0,0 +1,36 @@ +interface Config { + 403: { + img: string; + title: string; + desc: string; + }; + 404: { + img: string; + title: string; + desc: string; + }; + 500: { + img: string; + title: string; + desc: string; + }; +} +const config: Config = { + 403: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/wZcnGqRDyhPOEYFcZDnb.svg', + title: '403', + desc: '抱歉,你无权访问该页面', + }, + 404: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/KpnpchXsobRgLElEozzI.svg', + title: '404', + desc: '抱歉,你访问的页面不存在', + }, + 500: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/RVRUAYdCGeYNBWoKiIwB.svg', + title: '500', + desc: '抱歉,服务器出错了', + }, +}; + +export default config; diff --git a/src/pages/exception/403/index.tsx b/src/pages/exception/403/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..59406a4623dab8ced973e2954c3b3418581d5086 --- /dev/null +++ b/src/pages/exception/403/index.tsx @@ -0,0 +1,13 @@ +import Link from 'umi/link'; +import React from 'react'; +import { formatMessage } from 'umi-plugin-react/locale'; +import Exception from './components/Exception'; + +export default () => ( + +); diff --git a/src/pages/exception/403/locales/en-US.ts b/src/pages/exception/403/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f5f46c500e4dde7947f94ba6dd790224a054ef6 --- /dev/null +++ b/src/pages/exception/403/locales/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'exception-403.exception.back': 'Back to home', + 'exception-403.description.403': "Sorry, you don't have access to this page", +}; diff --git a/src/pages/exception/403/locales/pt-BR.ts b/src/pages/exception/403/locales/pt-BR.ts new file mode 100644 index 0000000000000000000000000000000000000000..6261bddc6300effd655ca5a7b5c70044d45b7794 --- /dev/null +++ b/src/pages/exception/403/locales/pt-BR.ts @@ -0,0 +1,4 @@ +export default { + 'exception-403.exception.back': 'Voltar para Início', + 'exception-403.description.403': 'Desculpe, você não tem acesso a esta página', +}; diff --git a/src/pages/exception/403/locales/zh-CN.ts b/src/pages/exception/403/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..35855599bbb7ffc88ff6ce4d91bde953e1c9de81 --- /dev/null +++ b/src/pages/exception/403/locales/zh-CN.ts @@ -0,0 +1,4 @@ +export default { + 'exception-403.exception.back': '返回首页', + 'exception-403.description.403': '抱歉,你无权访问该页面', +}; diff --git a/src/pages/exception/403/locales/zh-TW.ts b/src/pages/exception/403/locales/zh-TW.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c5e41e3c21d2a5002da23b5708516d87eb72b42 --- /dev/null +++ b/src/pages/exception/403/locales/zh-TW.ts @@ -0,0 +1,4 @@ +export default { + 'exception-403.exception.back': '返回首頁', + 'exception-403.description.403': '抱歉,妳無權訪問該頁面', +}; diff --git a/src/pages/exception/404/components/Exception/index.less b/src/pages/exception/404/components/Exception/index.less new file mode 100644 index 0000000000000000000000000000000000000000..dd0dd575085cf2cb87d217655b1c607fee0a71b4 --- /dev/null +++ b/src/pages/exception/404/components/Exception/index.less @@ -0,0 +1,89 @@ +@import '~antd/es/style/themes/default.less'; + +.exception { + display: flex; + align-items: center; + height: 80%; + min-height: 500px; + + .imgBlock { + flex: 0 0 62.5%; + width: 62.5%; + padding-right: 152px; + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + } + + .imgEle { + float: right; + width: 100%; + max-width: 430px; + height: 360px; + background-repeat: no-repeat; + background-position: 50% 50%; + background-size: contain; + } + + .content { + flex: auto; + + h1 { + margin-bottom: 24px; + color: #434e59; + font-weight: 600; + font-size: 72px; + line-height: 72px; + } + + .desc { + margin-bottom: 16px; + color: @text-color-secondary; + font-size: 20px; + line-height: 28px; + } + + .actions { + button:not(:last-child) { + margin-right: 8px; + } + } + } +} + +@media screen and (max-width: @screen-xl) { + .exception { + .imgBlock { + padding-right: 88px; + } + } +} + +@media screen and (max-width: @screen-sm) { + .exception { + display: block; + text-align: center; + .imgBlock { + margin: 0 auto 24px; + padding-right: 0; + } + } +} + +@media screen and (max-width: @screen-xs) { + .exception { + .imgBlock { + margin-bottom: -24px; + overflow: hidden; + } + } +} diff --git a/src/pages/exception/404/components/Exception/index.tsx b/src/pages/exception/404/components/Exception/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5111bf2a546eb9d576ce593dc71eb05fbc63ab2c --- /dev/null +++ b/src/pages/exception/404/components/Exception/index.tsx @@ -0,0 +1,83 @@ +import React, { createElement } from 'react'; + +import { Button } from 'antd'; +import Link from 'umi/link'; +import classNames from 'classnames'; +import config from './typeConfig'; +import styles from './index.less'; + +export interface ExceptionProps< + L = { + to: string; + href?: string; + replace?: boolean; + innerRef?: (node: HTMLAnchorElement | null) => void; + } +> { + type?: '403' | '404' | '500'; + title?: React.ReactNode; + desc?: React.ReactNode; + img?: string; + actions?: React.ReactNode; + linkElement?: string | React.ComponentType | typeof Link; + style?: React.CSSProperties; + className?: string; + backText?: React.ReactNode; + redirect?: string; +} + +class Exception extends React.Component { + static defaultProps = { + backText: 'back to home', + redirect: '/', + }; + + constructor(props: ExceptionProps) { + super(props); + this.state = {}; + } + + render() { + const { + className, + backText, + linkElement = 'a', + type = '404', + title, + desc, + img, + actions, + redirect, + ...rest + } = this.props; + const pageType = type in config ? type : '404'; + const clsString = classNames(styles.exception, className); + return ( +
    +
    +
    +
    +
    +

    {title || config[pageType].title}

    +
    {desc || config[pageType].desc}
    +
    + {actions || + createElement( + linkElement as any, + { + to: redirect, + href: redirect, + }, + , + )} +
    +
    +
    + ); + } +} + +export default Exception; diff --git a/src/pages/exception/404/components/Exception/typeConfig.ts b/src/pages/exception/404/components/Exception/typeConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..453a5edcecb0b01f7f2cb720be3b0dfcc2302da4 --- /dev/null +++ b/src/pages/exception/404/components/Exception/typeConfig.ts @@ -0,0 +1,36 @@ +interface Config { + 403: { + img: string; + title: string; + desc: string; + }; + 404: { + img: string; + title: string; + desc: string; + }; + 500: { + img: string; + title: string; + desc: string; + }; +} +const config: Config = { + 403: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/wZcnGqRDyhPOEYFcZDnb.svg', + title: '403', + desc: '抱歉,你无权访问该页面', + }, + 404: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/KpnpchXsobRgLElEozzI.svg', + title: '404', + desc: '抱歉,你访问的页面不存在', + }, + 500: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/RVRUAYdCGeYNBWoKiIwB.svg', + title: '500', + desc: '抱歉,服务器出错了', + }, +}; + +export default config; diff --git a/src/pages/exception/404/index.tsx b/src/pages/exception/404/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5eacbfe48384a43a7166d8d30833726310a33f7d --- /dev/null +++ b/src/pages/exception/404/index.tsx @@ -0,0 +1,13 @@ +import Link from 'umi/link'; +import React from 'react'; +import { formatMessage } from 'umi-plugin-react/locale'; +import Exception from './components/Exception'; + +export default () => ( + +); diff --git a/src/pages/exception/404/locales/en-US.ts b/src/pages/exception/404/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..d524292a747892e09f0f5b8e8d756b31f9683ffb --- /dev/null +++ b/src/pages/exception/404/locales/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'exception-404.exception.back': 'Back to home', + 'exception-404.description.404': 'Sorry, the page you visited does not exist', +}; diff --git a/src/pages/exception/404/locales/pt-BR.ts b/src/pages/exception/404/locales/pt-BR.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c0421b4164f3910ddb6e92c4b46fc6bb77ccf1e --- /dev/null +++ b/src/pages/exception/404/locales/pt-BR.ts @@ -0,0 +1,4 @@ +export default { + 'exception-404.exception.back': 'Voltar para Início', + 'exception-404.description.404': 'Desculpe, a página que você visitou não existe', +}; diff --git a/src/pages/exception/404/locales/zh-CN.ts b/src/pages/exception/404/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..a26978631c98a16ea7b4d22ecb8ade2bc3abeea8 --- /dev/null +++ b/src/pages/exception/404/locales/zh-CN.ts @@ -0,0 +1,4 @@ +export default { + 'exception-404.exception.back': '返回首页', + 'exception-404.description.404': '抱歉,你访问的页面不存在', +}; diff --git a/src/pages/exception/404/locales/zh-TW.ts b/src/pages/exception/404/locales/zh-TW.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ab307f4f89e629712d2f729b80e140f6f4923a5 --- /dev/null +++ b/src/pages/exception/404/locales/zh-TW.ts @@ -0,0 +1,4 @@ +export default { + 'exception-404.exception.back': '返回首頁', + 'exception-404.description.404': '抱歉,妳訪問的頁面不存在', +}; diff --git a/src/pages/exception/500/components/Exception/index.less b/src/pages/exception/500/components/Exception/index.less new file mode 100644 index 0000000000000000000000000000000000000000..dd0dd575085cf2cb87d217655b1c607fee0a71b4 --- /dev/null +++ b/src/pages/exception/500/components/Exception/index.less @@ -0,0 +1,89 @@ +@import '~antd/es/style/themes/default.less'; + +.exception { + display: flex; + align-items: center; + height: 80%; + min-height: 500px; + + .imgBlock { + flex: 0 0 62.5%; + width: 62.5%; + padding-right: 152px; + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + } + + .imgEle { + float: right; + width: 100%; + max-width: 430px; + height: 360px; + background-repeat: no-repeat; + background-position: 50% 50%; + background-size: contain; + } + + .content { + flex: auto; + + h1 { + margin-bottom: 24px; + color: #434e59; + font-weight: 600; + font-size: 72px; + line-height: 72px; + } + + .desc { + margin-bottom: 16px; + color: @text-color-secondary; + font-size: 20px; + line-height: 28px; + } + + .actions { + button:not(:last-child) { + margin-right: 8px; + } + } + } +} + +@media screen and (max-width: @screen-xl) { + .exception { + .imgBlock { + padding-right: 88px; + } + } +} + +@media screen and (max-width: @screen-sm) { + .exception { + display: block; + text-align: center; + .imgBlock { + margin: 0 auto 24px; + padding-right: 0; + } + } +} + +@media screen and (max-width: @screen-xs) { + .exception { + .imgBlock { + margin-bottom: -24px; + overflow: hidden; + } + } +} diff --git a/src/pages/exception/500/components/Exception/index.tsx b/src/pages/exception/500/components/Exception/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5111bf2a546eb9d576ce593dc71eb05fbc63ab2c --- /dev/null +++ b/src/pages/exception/500/components/Exception/index.tsx @@ -0,0 +1,83 @@ +import React, { createElement } from 'react'; + +import { Button } from 'antd'; +import Link from 'umi/link'; +import classNames from 'classnames'; +import config from './typeConfig'; +import styles from './index.less'; + +export interface ExceptionProps< + L = { + to: string; + href?: string; + replace?: boolean; + innerRef?: (node: HTMLAnchorElement | null) => void; + } +> { + type?: '403' | '404' | '500'; + title?: React.ReactNode; + desc?: React.ReactNode; + img?: string; + actions?: React.ReactNode; + linkElement?: string | React.ComponentType | typeof Link; + style?: React.CSSProperties; + className?: string; + backText?: React.ReactNode; + redirect?: string; +} + +class Exception extends React.Component { + static defaultProps = { + backText: 'back to home', + redirect: '/', + }; + + constructor(props: ExceptionProps) { + super(props); + this.state = {}; + } + + render() { + const { + className, + backText, + linkElement = 'a', + type = '404', + title, + desc, + img, + actions, + redirect, + ...rest + } = this.props; + const pageType = type in config ? type : '404'; + const clsString = classNames(styles.exception, className); + return ( +
    +
    +
    +
    +
    +

    {title || config[pageType].title}

    +
    {desc || config[pageType].desc}
    +
    + {actions || + createElement( + linkElement as any, + { + to: redirect, + href: redirect, + }, + , + )} +
    +
    +
    + ); + } +} + +export default Exception; diff --git a/src/pages/exception/500/components/Exception/typeConfig.ts b/src/pages/exception/500/components/Exception/typeConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..453a5edcecb0b01f7f2cb720be3b0dfcc2302da4 --- /dev/null +++ b/src/pages/exception/500/components/Exception/typeConfig.ts @@ -0,0 +1,36 @@ +interface Config { + 403: { + img: string; + title: string; + desc: string; + }; + 404: { + img: string; + title: string; + desc: string; + }; + 500: { + img: string; + title: string; + desc: string; + }; +} +const config: Config = { + 403: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/wZcnGqRDyhPOEYFcZDnb.svg', + title: '403', + desc: '抱歉,你无权访问该页面', + }, + 404: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/KpnpchXsobRgLElEozzI.svg', + title: '404', + desc: '抱歉,你访问的页面不存在', + }, + 500: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/RVRUAYdCGeYNBWoKiIwB.svg', + title: '500', + desc: '抱歉,服务器出错了', + }, +}; + +export default config; diff --git a/src/pages/exception/500/index.tsx b/src/pages/exception/500/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d6b3a80ae7066ac85ae0206c23f0c264dd23fd5 --- /dev/null +++ b/src/pages/exception/500/index.tsx @@ -0,0 +1,13 @@ +import Link from 'umi/link'; +import React from 'react'; +import { formatMessage } from 'umi-plugin-react/locale'; +import Exception from './components/Exception'; + +export default () => ( + +); diff --git a/src/pages/exception/500/locales/en-US.ts b/src/pages/exception/500/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..673b9fce415467329e271ee79c149beecabb0a3b --- /dev/null +++ b/src/pages/exception/500/locales/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'exception-500.exception.back': 'Back to home', + 'exception-500.description.500': 'Sorry, the server is reporting an error', +}; diff --git a/src/pages/exception/500/locales/pt-BR.ts b/src/pages/exception/500/locales/pt-BR.ts new file mode 100644 index 0000000000000000000000000000000000000000..5342a7858ba1b3a1517536fc91a983bd40847415 --- /dev/null +++ b/src/pages/exception/500/locales/pt-BR.ts @@ -0,0 +1,4 @@ +export default { + 'exception-500.exception.back': 'Voltar para Início', + 'exception-500.description.500': 'Desculpe, o servidor está reportando um erro', +}; diff --git a/src/pages/exception/500/locales/zh-CN.ts b/src/pages/exception/500/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5fe370cc6f127c8bc79baafd60372c9bda111a7 --- /dev/null +++ b/src/pages/exception/500/locales/zh-CN.ts @@ -0,0 +1,4 @@ +export default { + 'exception-500.exception.back': '返回首页', + 'exception-500.description.500': '抱歉,服务器出错了', +}; diff --git a/src/pages/exception/500/locales/zh-TW.ts b/src/pages/exception/500/locales/zh-TW.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfbb9d1e8fbca57caff9c8d277baa1d8cead760c --- /dev/null +++ b/src/pages/exception/500/locales/zh-TW.ts @@ -0,0 +1,4 @@ +export default { + 'exception-500.exception.back': '返回首頁', + 'exception-500.description.500': '抱歉,服務器出錯了', +}; diff --git a/src/pages/form/advanced-form/_mock.ts b/src/pages/form/advanced-form/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..e94c8af95a2632a83c5e64eae5da8194e8c39ab5 --- /dev/null +++ b/src/pages/form/advanced-form/_mock.ts @@ -0,0 +1,5 @@ +export default { + 'POST /api/forms': (req: any, res: any) => { + res.send({ message: 'Ok' }); + }, +}; diff --git a/src/pages/form/advanced-form/components/FooterToolbar/index.less b/src/pages/form/advanced-form/components/FooterToolbar/index.less new file mode 100644 index 0000000000000000000000000000000000000000..2e6d8303462b2bc04df28ab7663d5a6a06d9da99 --- /dev/null +++ b/src/pages/form/advanced-form/components/FooterToolbar/index.less @@ -0,0 +1,33 @@ +@import '~antd/es/style/themes/default.less'; + +.toolbar { + position: fixed; + right: 0; + bottom: 0; + z-index: 99; + width: 100%; + height: 56px; + padding: 0 24px; + line-height: 56px; + background: @component-background; + border-top: 1px solid @border-color-split; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.03); + + &::after { + display: block; + clear: both; + content: ''; + } + + .left { + float: left; + } + + .right { + float: right; + } + + button + button { + margin-left: 8px; + } +} diff --git a/src/pages/form/advanced-form/components/FooterToolbar/index.tsx b/src/pages/form/advanced-form/components/FooterToolbar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ec11d2b86009a9c1dac736a787966cb553b0cd14 --- /dev/null +++ b/src/pages/form/advanced-form/components/FooterToolbar/index.tsx @@ -0,0 +1,49 @@ +import React, { Component } from 'react'; + +import classNames from 'classnames'; +import styles from './index.less'; + +export interface FooterToolbarProps { + extra?: React.ReactNode; + style?: React.CSSProperties; + className?: string; +} + +export default class FooterToolbar extends Component { + state = { + width: undefined, + }; + + componentDidMount() { + window.addEventListener('resize', this.resizeFooterToolbar); + this.resizeFooterToolbar(); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resizeFooterToolbar); + } + + resizeFooterToolbar = () => { + const sider = document.querySelector('.ant-layout-sider') as HTMLDivElement; + if (sider == null) { + return; + } + const { isMobile } = this.context; + const width = isMobile ? null : `calc(100% - ${sider.style.width})`; + const { width: stateWidth } = this.state; + if (stateWidth !== width) { + this.setState({ width }); + } + }; + + render() { + const { children, className, extra, ...restProps } = this.props; + const { width } = this.state; + return ( +
    +
    {extra}
    +
    {children}
    +
    + ); + } +} diff --git a/src/pages/form/advanced-form/components/TableForm.tsx b/src/pages/form/advanced-form/components/TableForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92d6347bb0c113875345aed6324b4c5f7d012bc7 --- /dev/null +++ b/src/pages/form/advanced-form/components/TableForm.tsx @@ -0,0 +1,293 @@ +import { Button, Divider, Input, Popconfirm, Table, message } from 'antd'; +import React, { Fragment, PureComponent } from 'react'; + +import { isEqual } from 'lodash'; +import styles from '../style.less'; + +interface TableFormDateType { + key: string; + workId?: string; + name?: string; + department?: string; + isNew?: boolean; + editable?: boolean; +} +interface TableFormProps { + loading?: boolean; + value?: TableFormDateType[]; + onChange?: (value: TableFormDateType[]) => void; +} + +interface TableFormState { + loading?: boolean; + value?: TableFormDateType[]; + data?: TableFormDateType[]; +} +class TableForm extends PureComponent { + static getDerivedStateFromProps(nextProps: TableFormProps, preState: TableFormState) { + if (isEqual(nextProps.value, preState.value)) { + return null; + } + return { + data: nextProps.value, + value: nextProps.value, + }; + } + + clickedCancel: boolean = false; + + index = 0; + + cacheOriginData = {}; + + columns = [ + { + title: '成员姓名', + dataIndex: 'name', + key: 'name', + width: '20%', + render: (text: string, record: TableFormDateType) => { + if (record.editable) { + return ( + this.handleFieldChange(e, 'name', record.key)} + onKeyPress={e => this.handleKeyPress(e, record.key)} + placeholder="成员姓名" + /> + ); + } + return text; + }, + }, + { + title: '工号', + dataIndex: 'workId', + key: 'workId', + width: '20%', + render: (text: string, record: TableFormDateType) => { + if (record.editable) { + return ( + this.handleFieldChange(e, 'workId', record.key)} + onKeyPress={e => this.handleKeyPress(e, record.key)} + placeholder="工号" + /> + ); + } + return text; + }, + }, + { + title: '所属部门', + dataIndex: 'department', + key: 'department', + width: '40%', + render: (text: string, record: TableFormDateType) => { + if (record.editable) { + return ( + this.handleFieldChange(e, 'department', record.key)} + onKeyPress={e => this.handleKeyPress(e, record.key)} + placeholder="所属部门" + /> + ); + } + return text; + }, + }, + { + title: '操作', + key: 'action', + render: (text: string, record: TableFormDateType) => { + const { loading } = this.state; + if (!!record.editable && loading) { + return null; + } + if (record.editable) { + if (record.isNew) { + return ( + + this.saveRow(e, record.key)}>添加 + + this.remove(record.key)}> + 删除 + + + ); + } + return ( + + this.saveRow(e, record.key)}>保存 + + this.cancel(e, record.key)}>取消 + + ); + } + return ( + + this.toggleEditable(e, record.key)}>编辑 + + this.remove(record.key)}> + 删除 + + + ); + }, + }, + ]; + + constructor(props: TableFormProps) { + super(props); + this.state = { + data: props.value, + loading: false, + value: props.value, + }; + } + + getRowByKey(key: string, newData?: TableFormDateType[]) { + const { data = [] } = this.state; + return (newData || data).filter(item => item.key === key)[0]; + } + + toggleEditable = (e: React.MouseEvent | React.KeyboardEvent, key: string) => { + e.preventDefault(); + const { data = [] } = this.state; + const newData = data.map(item => ({ ...item })); + const target = this.getRowByKey(key, newData); + if (target) { + // 进入编辑状态时保存原始数据 + if (!target.editable) { + this.cacheOriginData[key] = { ...target }; + } + target.editable = !target.editable; + this.setState({ data: newData }); + } + }; + + newMember = () => { + const { data = [] } = this.state; + const newData = data.map(item => ({ ...item })); + newData.push({ + key: `NEW_TEMP_ID_${this.index}`, + workId: '', + name: '', + department: '', + editable: true, + isNew: true, + }); + this.index += 1; + this.setState({ data: newData }); + }; + + remove(key: string) { + const { data = [] } = this.state; + const { onChange } = this.props; + const newData = data.filter(item => item.key !== key); + this.setState({ data: newData }); + if (onChange) { + onChange(newData); + } + } + + handleKeyPress(e: React.KeyboardEvent, key: string) { + if (e.key === 'Enter') { + this.saveRow(e, key); + } + } + + handleFieldChange(e: React.ChangeEvent, fieldName: string, key: string) { + const { data = [] } = this.state; + const newData = [...data]; + const target = this.getRowByKey(key, newData); + if (target) { + target[fieldName] = e.target.value; + this.setState({ data: newData }); + } + } + + saveRow(e: React.MouseEvent | React.KeyboardEvent, key: string) { + e.persist(); + this.setState({ + loading: true, + }); + setTimeout(() => { + if (this.clickedCancel) { + this.clickedCancel = false; + return; + } + const target = this.getRowByKey(key) || {}; + if (!target.workId || !target.name || !target.department) { + message.error('请填写完整成员信息。'); + (e.target as HTMLInputElement).focus(); + this.setState({ + loading: false, + }); + return; + } + delete target.isNew; + this.toggleEditable(e, key); + const { data = [] } = this.state; + const { onChange } = this.props; + if (onChange) { + onChange(data); + } + this.setState({ + loading: false, + }); + }, 500); + } + + cancel(e: React.MouseEvent, key: string) { + this.clickedCancel = true; + e.preventDefault(); + const { data = [] } = this.state; + const newData = [...data]; + newData.map(item => { + if (item.key === key) { + if (this.cacheOriginData[key]) { + delete this.cacheOriginData[key]; + return { + ...item, + ...this.cacheOriginData[key], + editable: false, + }; + } + } + return item; + }); + + this.setState({ data: newData }); + this.clickedCancel = false; + } + + render() { + const { loading, data } = this.state; + + return ( + + + loading={loading} + columns={this.columns} + dataSource={data} + pagination={false} + rowClassName={record => (record.editable ? styles.editable : '')} + /> + + + ); + } +} + +export default TableForm; diff --git a/src/pages/form/advanced-form/index.tsx b/src/pages/form/advanced-form/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5291d390336aff1bc2331c318ecc8cdb754f399a --- /dev/null +++ b/src/pages/form/advanced-form/index.tsx @@ -0,0 +1,341 @@ +import { + Button, + Card, + Col, + DatePicker, + Form, + Icon, + Input, + Popover, + Row, + Select, + TimePicker, +} from 'antd'; +import React, { Component } from 'react'; + +import { Dispatch } from 'redux'; +import { FormComponentProps } from 'antd/es/form'; +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import { connect } from 'dva'; +import TableForm from './components/TableForm'; +import FooterToolbar from './components/FooterToolbar'; +import styles from './style.less'; + +const { Option } = Select; +const { RangePicker } = DatePicker; + +const fieldLabels = { + name: '仓库名', + url: '仓库域名', + owner: '仓库管理员', + approver: '审批人', + dateRange: '生效日期', + type: '仓库类型', + name2: '任务名', + url2: '任务描述', + owner2: '执行人', + approver2: '责任人', + dateRange2: '生效日期', + type2: '任务类型', +}; + +const tableData = [ + { + key: '1', + workId: '00001', + name: 'John Brown', + department: 'New York No. 1 Lake Park', + }, + { + key: '2', + workId: '00002', + name: 'Jim Green', + department: 'London No. 1 Lake Park', + }, + { + key: '3', + workId: '00003', + name: 'Joe Black', + department: 'Sidney No. 1 Lake Park', + }, +]; + +interface AdvancedFormProps extends FormComponentProps { + dispatch: Dispatch; + submitting: boolean; +} + +@connect(({ loading }: { loading: { effects: { [key: string]: boolean } } }) => ({ + submitting: loading.effects['formAdvancedForm/submitAdvancedForm'], +})) +class AdvancedForm extends Component { + state = { + width: '100%', + }; + + componentDidMount() { + window.addEventListener('resize', this.resizeFooterToolbar, { passive: true }); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resizeFooterToolbar); + } + + getErrorInfo = () => { + const { + form: { getFieldsError }, + } = this.props; + const errors = getFieldsError(); + const errorCount = Object.keys(errors).filter(key => errors[key]).length; + if (!errors || errorCount === 0) { + return null; + } + const scrollToField = (fieldKey: string) => { + const labelNode = document.querySelector(`label[for="${fieldKey}"]`); + if (labelNode) { + labelNode.scrollIntoView(true); + } + }; + const errorList = Object.keys(errors).map(key => { + if (!errors[key]) { + return null; + } + const errorMessage = errors[key] || []; + return ( +
  • scrollToField(key)}> + +
    {errorMessage[0]}
    +
    {fieldLabels[key]}
    +
  • + ); + }); + return ( + + { + if (trigger && trigger.parentNode) { + return trigger.parentNode as HTMLElement; + } + return trigger; + }} + > + + + {errorCount} + + ); + }; + + resizeFooterToolbar = () => { + requestAnimationFrame(() => { + const sider = document.querySelectorAll('.ant-layout-sider')[0] as HTMLDivElement; + if (sider) { + const width = `calc(100% - ${sider.style.width})`; + const { width: stateWidth } = this.state; + if (stateWidth !== width) { + this.setState({ width }); + } + } + }); + }; + + validate = () => { + const { + form: { validateFieldsAndScroll }, + dispatch, + } = this.props; + validateFieldsAndScroll((error, values) => { + if (!error) { + // submit the values + dispatch({ + type: 'formAdvancedForm/submitAdvancedForm', + payload: values, + }); + } + }); + }; + + render() { + const { + form: { getFieldDecorator }, + submitting, + } = this.props; + const { width } = this.state; + return ( + <> + + +
    + + + + {getFieldDecorator('name', { + rules: [{ required: true, message: '请输入仓库名称' }], + })()} + + + + + {getFieldDecorator('url', { + rules: [{ required: true, message: '请选择' }], + })( + , + )} + + + + + {getFieldDecorator('owner', { + rules: [{ required: true, message: '请选择管理员' }], + })( + , + )} + + + + + + + {getFieldDecorator('approver', { + rules: [{ required: true, message: '请选择审批员' }], + })( + , + )} + + + + + {getFieldDecorator('dateRange', { + rules: [{ required: true, message: '请选择生效日期' }], + })( + , + )} + + + + + {getFieldDecorator('type', { + rules: [{ required: true, message: '请选择仓库类型' }], + })( + , + )} + + + +
    +
    + +
    + + + + {getFieldDecorator('name2', { + rules: [{ required: true, message: '请输入' }], + })()} + + + + + {getFieldDecorator('url2', { + rules: [{ required: true, message: '请选择' }], + })()} + + + + + {getFieldDecorator('owner2', { + rules: [{ required: true, message: '请选择管理员' }], + })( + , + )} + + + + + + + {getFieldDecorator('approver2', { + rules: [{ required: true, message: '请选择审批员' }], + })( + , + )} + + + + + {getFieldDecorator('dateRange2', { + rules: [{ required: true, message: '请输入' }], + })( + { + if (trigger && trigger.parentNode) { + return trigger.parentNode as HTMLElement; + } + return trigger; + }} + />, + )} + + + + + {getFieldDecorator('type2', { + rules: [{ required: true, message: '请选择仓库类型' }], + })( + , + )} + + + +
    +
    + + {getFieldDecorator('members', { + initialValue: tableData, + })()} + +
    + + {this.getErrorInfo()} + + + + ); + } +} + +export default Form.create()(AdvancedForm); diff --git a/src/pages/form/advanced-form/model.ts b/src/pages/form/advanced-form/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f23b4463236eb1e7f43327ce748019d9ebcabbd --- /dev/null +++ b/src/pages/form/advanced-form/model.ts @@ -0,0 +1,32 @@ +import { AnyAction } from 'redux'; +import { EffectsCommandMap } from 'dva'; +import { message } from 'antd'; +import { fakeSubmitForm } from './service'; + +export type Effect = ( + action: AnyAction, + effects: EffectsCommandMap & { select: (func: (state: {}) => T) => T }, +) => void; + +export interface ModelType { + namespace: string; + state: {}; + effects: { + submitAdvancedForm: Effect; + }; +} + +const Model: ModelType = { + namespace: 'formAdvancedForm', + + state: {}, + + effects: { + *submitAdvancedForm({ payload }, { call }) { + yield call(fakeSubmitForm, payload); + message.success('提交成功'); + }, + }, +}; + +export default Model; diff --git a/src/pages/form/advanced-form/service.ts b/src/pages/form/advanced-form/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..36024f2bb6b24b5ca7757eda0c6c6d15c98d0d90 --- /dev/null +++ b/src/pages/form/advanced-form/service.ts @@ -0,0 +1,8 @@ +import request from 'umi-request'; + +export async function fakeSubmitForm(params: any) { + return request('/api/forms', { + method: 'POST', + data: params, + }); +} diff --git a/src/pages/form/advanced-form/style.less b/src/pages/form/advanced-form/style.less new file mode 100644 index 0000000000000000000000000000000000000000..83061335f4e4c5f51a814a831d4cfdde6433ebfa --- /dev/null +++ b/src/pages/form/advanced-form/style.less @@ -0,0 +1,90 @@ +@import '~antd/es/style/themes/default.less'; + +.card { + margin-bottom: 24px; +} + +.heading { + margin: 0 0 16px 0; + font-size: 14px; + line-height: 22px; +} + +.steps:global(.ant-steps) { + max-width: 750px; + margin: 16px auto; +} + +.errorIcon { + margin-right: 24px; + color: @error-color; + cursor: pointer; + i { + margin-right: 4px; + } +} + +.errorPopover { + :global { + .ant-popover-inner-content { + min-width: 256px; + max-height: 290px; + padding: 0; + overflow: auto; + } + } +} + +.errorListItem { + padding: 8px 16px; + list-style: none; + border-bottom: 1px solid @border-color-split; + cursor: pointer; + transition: all 0.3s; + &:hover { + background: @primary-1; + } + &:last-child { + border: 0; + } + .errorIcon { + float: left; + margin-top: 4px; + margin-right: 12px; + padding-bottom: 22px; + color: @error-color; + } + .errorField { + margin-top: 2px; + color: @text-color-secondary; + font-size: 12px; + } +} + +.editable { + td { + padding-top: 13px !important; + padding-bottom: 12.5px !important; + } +} + +// custom footer for fixed footer toolbar +.advancedForm + div { + padding-bottom: 64px; +} + +.advancedForm { + :global { + .ant-form .ant-row:last-child .ant-form-item { + margin-bottom: 24px; + } + .ant-table td { + transition: none !important; + } + } +} + +.optional { + color: @text-color-secondary; + font-style: normal; +} diff --git a/src/pages/form/basic-form/_mock.ts b/src/pages/form/basic-form/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..e94c8af95a2632a83c5e64eae5da8194e8c39ab5 --- /dev/null +++ b/src/pages/form/basic-form/_mock.ts @@ -0,0 +1,5 @@ +export default { + 'POST /api/forms': (req: any, res: any) => { + res.send({ message: 'Ok' }); + }, +}; diff --git a/src/pages/form/basic-form/index.tsx b/src/pages/form/basic-form/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3a65b2bb0c61ec8e6e4d64d957d05608438d735c --- /dev/null +++ b/src/pages/form/basic-form/index.tsx @@ -0,0 +1,254 @@ +import { + Button, + Card, + DatePicker, + Form, + Icon, + Input, + InputNumber, + Radio, + Select, + Tooltip, +} from 'antd'; +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; +import React, { Component } from 'react'; + +import { Dispatch } from 'redux'; +import { FormComponentProps } from 'antd/es/form'; +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import { connect } from 'dva'; +import styles from './style.less'; + +const FormItem = Form.Item; +const { Option } = Select; +const { RangePicker } = DatePicker; +const { TextArea } = Input; + +interface BasicFormProps extends FormComponentProps { + submitting: boolean; + dispatch: Dispatch; +} + +class BasicForm extends Component { + handleSubmit = (e: React.FormEvent) => { + const { dispatch, form } = this.props; + e.preventDefault(); + form.validateFieldsAndScroll((err, values) => { + if (!err) { + dispatch({ + type: 'formBasicForm/submitRegularForm', + payload: values, + }); + } + }); + }; + + render() { + const { submitting } = this.props; + const { + form: { getFieldDecorator, getFieldValue }, + } = this.props; + + const formItemLayout = { + labelCol: { + xs: { span: 24 }, + sm: { span: 7 }, + }, + wrapperCol: { + xs: { span: 24 }, + sm: { span: 12 }, + md: { span: 10 }, + }, + }; + + const submitFormLayout = { + wrapperCol: { + xs: { span: 24, offset: 0 }, + sm: { span: 10, offset: 7 }, + }, + }; + return ( + }> + +
    + }> + {getFieldDecorator('title', { + rules: [ + { + required: true, + message: formatMessage({ id: 'form-basic-form.title.required' }), + }, + ], + })()} + + }> + {getFieldDecorator('date', { + rules: [ + { + required: true, + message: formatMessage({ id: 'form-basic-form.date.required' }), + }, + ], + })( + , + )} + + }> + {getFieldDecorator('goal', { + rules: [ + { + required: true, + message: formatMessage({ id: 'form-basic-form.goal.required' }), + }, + ], + })( +