Browse Source

update

pull/9136/head
chenshuai2144 5 years ago
parent
commit
926267e514
  1. 290
      config/config.ts
  2. 16
      package.json
  3. 14
      src/components/RightContent/index.tsx
  4. 86
      src/pages/account/center/Center.less
  5. 243
      src/pages/account/center/_mock.ts
  6. 51
      src/pages/account/center/components/Applications/index.less
  7. 123
      src/pages/account/center/components/Applications/index.tsx
  8. 38
      src/pages/account/center/components/ArticleListContent/index.less
  9. 28
      src/pages/account/center/components/ArticleListContent/index.tsx
  10. 12
      src/pages/account/center/components/Articles/index.less
  11. 63
      src/pages/account/center/components/Articles/index.tsx
  12. 50
      src/pages/account/center/components/AvatarList/index.less
  13. 84
      src/pages/account/center/components/AvatarList/index.tsx
  14. 54
      src/pages/account/center/components/Projects/index.less
  15. 49
      src/pages/account/center/components/Projects/index.tsx
  16. 75
      src/pages/account/center/data.d.ts
  17. 210
      src/pages/account/center/index.tsx
  18. 14
      src/pages/account/center/service.ts
  19. 79
      src/pages/account/settings/_mock.ts
  20. 65
      src/pages/account/settings/components/BaseView.less
  21. 42
      src/pages/account/settings/components/PhoneView.tsx
  22. 235
      src/pages/account/settings/components/base.tsx
  23. 46
      src/pages/account/settings/components/binding.tsx
  24. 44
      src/pages/account/settings/components/notification.tsx
  25. 62
      src/pages/account/settings/components/security.tsx
  26. 43
      src/pages/account/settings/data.d.ts
  27. 1784
      src/pages/account/settings/geographic/city.json
  28. 138
      src/pages/account/settings/geographic/province.json
  29. 111
      src/pages/account/settings/index.tsx
  30. 18
      src/pages/account/settings/service.ts
  31. 93
      src/pages/account/settings/style.less
  32. 210
      src/pages/dashboard/analysis/_mock.ts
  33. 133
      src/pages/dashboard/analysis/components/Charts/Bar/index.tsx
  34. 75
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.less
  35. 97
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx
  36. 17
      src/pages/dashboard/analysis/components/Charts/Field/index.less
  37. 17
      src/pages/dashboard/analysis/components/Charts/Field/index.tsx
  38. 179
      src/pages/dashboard/analysis/components/Charts/Gauge/index.tsx
  39. 131
      src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx
  40. 54
      src/pages/dashboard/analysis/components/Charts/MiniBar/index.tsx
  41. 41
      src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx
  42. 309
      src/pages/dashboard/analysis/components/Charts/Pie/index.tsx
  43. 212
      src/pages/dashboard/analysis/components/Charts/TagCloud/index.tsx
  44. 132
      src/pages/dashboard/analysis/components/Charts/TimelineChart/index.tsx
  45. 235
      src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx
  46. 79
      src/pages/dashboard/analysis/components/Charts/autoHeight.tsx
  47. 19
      src/pages/dashboard/analysis/components/Charts/index.less
  48. 13
      src/pages/dashboard/analysis/components/Charts/index.tsx
  49. 135
      src/pages/dashboard/analysis/components/IntroduceRow.tsx
  50. 68
      src/pages/dashboard/analysis/components/NumberInfo/index.less
  51. 62
      src/pages/dashboard/analysis/components/NumberInfo/index.tsx
  52. 76
      src/pages/dashboard/analysis/components/OfflineData.tsx
  53. 9
      src/pages/dashboard/analysis/components/PageLoading/index.tsx
  54. 76
      src/pages/dashboard/analysis/components/ProportionSales.tsx
  55. 189
      src/pages/dashboard/analysis/components/SalesCard.tsx
  56. 113
      src/pages/dashboard/analysis/components/TopSearch.tsx
  57. 37
      src/pages/dashboard/analysis/components/Trend/index.less
  58. 42
      src/pages/dashboard/analysis/components/Trend/index.tsx
  59. 46
      src/pages/dashboard/analysis/data.d.ts
  60. 162
      src/pages/dashboard/analysis/index.tsx
  61. 6
      src/pages/dashboard/analysis/service.ts
  62. 189
      src/pages/dashboard/analysis/style.less
  63. 33
      src/pages/dashboard/analysis/utils/Yuan.tsx
  64. 50
      src/pages/dashboard/analysis/utils/utils.less
  65. 52
      src/pages/dashboard/analysis/utils/utils.ts
  66. 14
      src/pages/dashboard/monitor/_mock.ts
  67. 51
      src/pages/dashboard/monitor/components/ActiveChart/index.less
  68. 90
      src/pages/dashboard/monitor/components/ActiveChart/index.tsx
  69. 180
      src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx
  70. 131
      src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx
  71. 310
      src/pages/dashboard/monitor/components/Charts/Pie/index.tsx
  72. 211
      src/pages/dashboard/monitor/components/Charts/TagCloud/index.tsx
  73. 235
      src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx
  74. 79
      src/pages/dashboard/monitor/components/Charts/autoHeight.tsx
  75. 145
      src/pages/dashboard/monitor/components/Map/index.tsx
  76. 5
      src/pages/dashboard/monitor/data.d.ts
  77. 152
      src/pages/dashboard/monitor/index.tsx
  78. 6
      src/pages/dashboard/monitor/service.ts
  79. 21
      src/pages/dashboard/monitor/style.less
  80. 410
      src/pages/dashboard/workplace/_mock.ts
  81. 16
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.less
  82. 47
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx
  83. 79
      src/pages/dashboard/workplace/components/Radar/autoHeight.tsx
  84. 219
      src/pages/dashboard/workplace/components/Radar/index.tsx
  85. 111
      src/pages/dashboard/workplace/data.d.ts
  86. 242
      src/pages/dashboard/workplace/index.tsx
  87. 14
      src/pages/dashboard/workplace/service.ts
  88. 250
      src/pages/dashboard/workplace/style.less
  89. 7
      src/pages/editor/flow/common/IconFont/index.ts
  90. 34
      src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx
  91. 3
      src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx
  92. 27
      src/pages/editor/flow/components/EditorContextMenu/MenuItem.tsx
  93. 22
      src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx
  94. 41
      src/pages/editor/flow/components/EditorContextMenu/index.less
  95. 5
      src/pages/editor/flow/components/EditorContextMenu/index.tsx
  96. 114
      src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx
  97. 27
      src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx
  98. 3
      src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx
  99. 18
      src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx
  100. 6
      src/pages/editor/flow/components/EditorDetailPanel/index.less

290
config/config.ts

@ -1,10 +1,8 @@
// https://umijs.org/config/
import { defineConfig } from 'umi';
import { join } from 'path';
import defaultSettings from './defaultSettings';
import proxy from './proxy';
import routes from './routes';
const { REACT_APP_ENV } = process.env;
@ -35,7 +33,289 @@ export default defineConfig({
ie: 11,
},
// umi routes: https://umijs.org/docs/routing
routes,
routes: [
{
path: '/user',
layout: false,
routes: [
{
path: '/user/login',
layout: false,
name: 'login',
component: './user/Login',
},
{
path: '/user',
redirect: '/user/login',
},
{
name: 'register-result',
icon: 'smile',
path: '/user/register-result',
component: './user/register-result',
},
{
name: 'register',
icon: 'smile',
path: '/user/register',
component: './user/register',
},
{
component: '404',
},
],
},
{
path: '/dashboard',
name: 'dashboard',
icon: 'dashboard',
routes: [
{
path: '/dashboard',
redirect: '/dashboard/analysis',
},
{
name: 'analysis',
icon: 'smile',
path: '/dashboard/analysis',
component: './dashboard/analysis',
},
{
name: 'monitor',
icon: 'smile',
path: '/dashboard/monitor',
component: './dashboard/monitor',
},
{
name: 'workplace',
icon: 'smile',
path: '/dashboard/workplace',
component: './dashboard/workplace',
},
],
},
{
path: '/form',
icon: 'form',
name: 'form',
routes: [
{
path: '/form',
redirect: '/form/basic-form',
},
{
name: 'basic-form',
icon: 'smile',
path: '/form/basic-form',
component: './form/basic-form',
},
{
name: 'step-form',
icon: 'smile',
path: '/form/step-form',
component: './form/step-form',
},
{
name: 'advanced-form',
icon: 'smile',
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',
icon: 'smile',
path: '/list/search/articles',
component: './list/search/articles',
},
{
name: 'projects',
icon: 'smile',
path: '/list/search/projects',
component: './list/search/projects',
},
{
name: 'applications',
icon: 'smile',
path: '/list/search/applications',
component: './list/search/applications',
},
],
},
{
path: '/list',
redirect: '/list/table-list',
},
{
name: 'table-list',
icon: 'smile',
path: '/list/table-list',
component: './list/table-list',
},
{
name: 'basic-list',
icon: 'smile',
path: '/list/basic-list',
component: './list/basic-list',
},
{
name: 'card-list',
icon: 'smile',
path: '/list/card-list',
component: './list/card-list',
},
],
},
{
path: '/profile',
name: 'profile',
icon: 'profile',
routes: [
{
path: '/profile',
redirect: '/profile/basic',
},
{
name: 'basic',
icon: 'smile',
path: '/profile/basic',
component: './profile/basic',
},
{
name: 'advanced',
icon: 'smile',
path: '/profile/advanced',
component: './profile/advanced',
},
],
},
{
name: 'result',
icon: 'CheckCircleOutlined',
path: '/result',
routes: [
{
path: '/result',
redirect: '/result/success',
},
{
name: 'success',
icon: 'smile',
path: '/result/success',
component: './result/success',
},
{
name: 'fail',
icon: 'smile',
path: '/result/fail',
component: './result/fail',
},
],
},
{
name: 'exception',
icon: 'warning',
path: '/exception',
routes: [
{
path: '/exception',
redirect: '/exception/403',
},
{
name: '403',
icon: 'smile',
path: '/exception/403',
component: './exception/403',
},
{
name: '404',
icon: 'smile',
path: '/exception/404',
component: './exception/404',
},
{
name: '500',
icon: 'smile',
path: '/exception/500',
component: './exception/500',
},
],
},
{
name: 'account',
icon: 'user',
path: '/account',
routes: [
{
path: '/account',
redirect: '/account/center',
},
{
name: 'center',
icon: 'smile',
path: '/account/center',
component: './account/center',
},
{
name: 'settings',
icon: 'smile',
path: '/account/settings',
component: './account/settings',
},
],
},
{
name: 'editor',
icon: 'highlight',
path: '/editor',
routes: [
{
path: '/editor',
redirect: '/editor/flow',
},
{
name: 'flow',
icon: 'smile',
path: '/editor/flow',
component: './editor/flow',
},
{
name: 'mind',
icon: 'smile',
path: '/editor/mind',
component: './editor/mind',
},
{
name: 'koni',
icon: 'smile',
path: '/editor/koni',
component: './editor/koni',
},
],
},
{
path: '/',
redirect: '/dashboard/analysis',
},
{
component: '404',
},
],
// Theme for antd: https://ant.design/docs/react/customize-theme-cn
theme: {
'primary-color': defaultSettings.primaryColor,
@ -65,7 +345,9 @@ export default defineConfig({
projectName: 'swagger',
},
],
nodeModulesTransform: { type: 'none' },
nodeModulesTransform: {
type: 'none',
},
mfsu: {},
webpack5: {},
exportStatic: {},

16
package.json

@ -54,21 +54,35 @@
"not ie <= 10"
],
"dependencies": {
"@ant-design/charts": "^0.9.4",
"@ant-design/icons": "^4.5.0",
"@ant-design/pro-descriptions": "^1.6.8",
"@ant-design/pro-form": "^1.18.3",
"@ant-design/pro-layout": "^6.15.3",
"@ant-design/pro-table": "^2.30.8",
"@antv/data-set": "^0.11.0",
"@antv/l7": "^2.3.7",
"@antv/l7-maps": "^2.3.7",
"@antv/l7-react": "^2.1.9",
"@umijs/route-utils": "^1.0.36",
"ahooks": "^2.0.0",
"antd": "^4.14.0",
"bizcharts": "^3.5.3-beta.0",
"bizcharts-plugin-slider": "^2.1.1-beta.1",
"classnames": "^2.2.6",
"gg-editor": "^2.0.2",
"lodash": "^4.17.11",
"lodash-decorators": "^6.0.0",
"moment": "^2.25.3",
"numeral": "^2.0.6",
"nzh": "^1.0.3",
"omit.js": "^2.0.2",
"react": "^17.0.0",
"react-dev-inspector": "^1.1.1",
"react-dom": "^17.0.0",
"react-fittext": "^1.0.0",
"react-helmet-async": "^1.0.4",
"react-router": "^4.3.1",
"umi": "^3.5.0",
"umi-serve": "^1.9.10"
},
@ -141,4 +155,4 @@
"gitHooks": {
"commit-msg": "fabric verify-commit"
}
}
}

14
src/components/RightContent/index.tsx

@ -5,6 +5,7 @@ import { useModel, SelectLang } from 'umi';
import Avatar from './AvatarDropdown';
import HeaderSearch from '../HeaderSearch';
import styles from './index.less';
import NoticeIconView from '../NoticeIcon';
export type SiderTheme = 'light' | 'dark';
@ -21,6 +22,7 @@ const GlobalHeaderRight: React.FC = () => {
if ((navTheme === 'dark' && layout === 'top') || layout === 'mix') {
className = `${styles.right} ${styles.dark}`;
}
return (
<Space className={className}>
<HeaderSearch
@ -28,7 +30,10 @@ const GlobalHeaderRight: React.FC = () => {
placeholder="站内搜索"
defaultValue="umi ui"
options={[
{ label: <a href="https://umijs.org/zh/guide/umi-ui.html">umi ui</a>, value: 'umi ui' },
{
label: <a href="https://umijs.org/zh/guide/umi-ui.html">umi ui</a>,
value: 'umi ui',
},
{
label: <a href="next.ant.design">Ant Design</a>,
value: 'Ant Design',
@ -41,8 +46,7 @@ const GlobalHeaderRight: React.FC = () => {
label: <a href="https://prolayout.ant.design/">Pro Layout</a>,
value: 'Pro Layout',
},
]}
// onSearch={value => {
]} // onSearch={value => {
// console.log('input', value);
// }}
/>
@ -54,9 +58,11 @@ const GlobalHeaderRight: React.FC = () => {
>
<QuestionCircleOutlined />
</span>
<Avatar />
<NoticeIconView />
<Avatar menu />
<SelectLang className={styles.action} />
</Space>
);
};
export default GlobalHeaderRight;

86
src/pages/account/center/Center.less

@ -0,0 +1,86 @@
@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;
}
}
.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;
}
}
}

243
src/pages/account/center/_mock.ts

@ -0,0 +1,243 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import type { Request, Response } from 'express';
import type { 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 = [
'付小小',
'曲丽丽',
'林东东',
'周星星',
'吴加好',
'朱偏右',
'鱼酱',
'乐哥',
'谭小仪',
'仲尼',
];
// 当前用户信息
const currentUseDetail = {
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',
};
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: Request, res: Response) {
const params = req.query as any;
const count = Number(params.count) * 1 || 5;
const result = fakeList(count);
return res.json({
data: {
list: result,
},
});
}
// 获取用户信息
function getCurrentUser(req: Request, res: Response) {
return res.json({
data: currentUseDetail,
});
}
export default {
'GET /api/fake_list_Detail': getFakeList,
// 支持值为 Object 和 Array
'GET /api/currentUserDetail': getCurrentUser,
};

51
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;
}
}
}
}

123
src/pages/account/center/components/Applications/index.tsx

@ -0,0 +1,123 @@
import {
DownloadOutlined,
EditOutlined,
EllipsisOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import { useRequest } from 'umi';
import { Avatar, Card, Dropdown, List, Menu, Tooltip } from 'antd';
import React from 'react';
import numeral from 'numeral';
import type { ListItemDataType } from '../../data.d';
import { queryFakeList } from '../../service';
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 = (
<span>
{Math.floor(val / 10000)}
<span
style={{
position: 'relative',
top: -2,
fontSize: 14,
fontStyle: 'normal',
marginLeft: 2,
}}
>
</span>
</span>
);
}
return result;
}
const Applications: React.FC = () => {
// 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
});
});
const itemMenu = (
<Menu>
<Menu.Item>
<a target="_blank" rel="noopener noreferrer" href="https://www.alipay.com/">
1st menu item
</a>
</Menu.Item>
<Menu.Item>
<a target="_blank" rel="noopener noreferrer" href="https://www.taobao.com/">
2nd menu item
</a>
</Menu.Item>
<Menu.Item>
<a target="_blank" rel="noopener noreferrer" href="https://www.tmall.com/">
3d menu item
</a>
</Menu.Item>
</Menu>
);
const CardInfo: React.FC<{
activeUser: React.ReactNode;
newUser: React.ReactNode;
}> = ({ activeUser, newUser }) => (
<div className={stylesApplications.cardInfo}>
<div>
<p></p>
<p>{activeUser}</p>
</div>
<div>
<p></p>
<p>{newUser}</p>
</div>
</div>
);
return (
<List<ListItemDataType>
rowKey="id"
className={stylesApplications.filterCardList}
grid={{ gutter: 24, xxl: 3, xl: 2, lg: 2, md: 2, sm: 2, xs: 1 }}
dataSource={listData?.list || []}
renderItem={(item) => (
<List.Item key={item.id}>
<Card
hoverable
bodyStyle={{ paddingBottom: 20 }}
actions={[
<Tooltip key="download" title="下载">
<DownloadOutlined />
</Tooltip>,
<Tooltip title="编辑" key="edit">
<EditOutlined />
</Tooltip>,
<Tooltip title="分享" key="share">
<ShareAltOutlined />
</Tooltip>,
<Dropdown overlay={itemMenu} key="ellipsis">
<EllipsisOutlined />
</Dropdown>,
]}
>
<Card.Meta avatar={<Avatar size="small" src={item.avatar} />} title={item.title} />
<div className={stylesApplications.cardItemContent}>
<CardInfo
activeUser={formatWan(item.activeUser)}
newUser={numeral(item.newUser).format('0,0')}
/>
</div>
</Card>
</List.Item>
)}
/>
);
};
export default Applications;

38
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;
}
}
}
}

28
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 type ApplicationsProps = {
data: {
content?: string;
updatedAt?: any;
avatar?: string;
owner?: string;
href?: string;
};
};
const ArticleListContent: React.FC<ApplicationsProps> = ({
data: { content, updatedAt, avatar, owner, href },
}) => (
<div className={styles.listContent}>
<div className={styles.description}>{content}</div>
<div className={styles.extra}>
<Avatar src={avatar} size="small" />
<a href={href}>{owner}</a> <a href={href}>{href}</a>
<em>{moment(updatedAt).format('YYYY-MM-DD HH:mm')}</em>
</div>
</div>
);
export default ArticleListContent;

12
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;
}

63
src/pages/account/center/components/Articles/index.tsx

@ -0,0 +1,63 @@
import React from 'react';
import { StarTwoTone, LikeOutlined, MessageFilled } from '@ant-design/icons';
import { useRequest } from 'umi';
import { List, Tag } from 'antd';
import ArticleListContent from '../ArticleListContent';
import type { ListItemDataType } from '../../data.d';
import { queryFakeList } from '../../service';
import styles from './index.less';
const Articles: React.FC = () => {
const IconText: React.FC<{
icon: React.ReactNode;
text: React.ReactNode;
}> = ({ icon, text }) => (
<span>
{icon} {text}
</span>
);
// 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
});
});
return (
<List<ListItemDataType>
size="large"
className={styles.articleList}
rowKey="id"
itemLayout="vertical"
dataSource={listData?.list || []}
renderItem={(item) => (
<List.Item
key={item.id}
actions={[
<IconText key="star" icon={<StarTwoTone />} text={item.star} />,
<IconText key="like" icon={<LikeOutlined />} text={item.like} />,
<IconText key="message" icon={<MessageFilled />} text={item.message} />,
]}
>
<List.Item.Meta
title={
<a className={styles.listItemMetaTitle} href={item.href}>
{item.title}
</a>
}
description={
<span>
<Tag>Ant Design</Tag>
<Tag></Tag>
<Tag></Tag>
</span>
}
/>
<ArticleListContent data={item} />
</List.Item>
)}
/>
);
};
export default Articles;

50
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 @border-color-base;
}
}
}
.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;
}
}
}
}

84
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 type AvatarItemProps = {
tips: React.ReactNode;
src: string;
size?: SizeType;
style?: React.CSSProperties;
onClick?: () => void;
};
export type AvatarListProps = {
Item?: React.ReactElement<AvatarItemProps>;
size?: SizeType;
maxLength?: number;
excessItemsStyle?: React.CSSProperties;
style?: React.CSSProperties;
children: React.ReactElement<AvatarItemProps> | React.ReactElement<AvatarItemProps>[];
};
const avatarSizeToClassName = (size?: SizeType | 'mini') =>
classNames(styles.avatarItem, {
[styles.avatarItemLarge]: size === 'large',
[styles.avatarItemSmall]: size === 'small',
[styles.avatarItemMini]: size === 'mini',
});
const Item: React.FC<AvatarItemProps> = ({ src, size, tips, onClick = () => {} }) => {
const cls = avatarSizeToClassName(size);
return (
<li className={cls} onClick={onClick}>
{tips ? (
<Tooltip title={tips}>
<Avatar src={src} size={size} style={{ cursor: 'pointer' }} />
</Tooltip>
) : (
<Avatar src={src} size={size} />
)}
</li>
);
};
const AvatarList: React.FC<AvatarListProps> & { 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<AvatarItemProps>[];
const childrenWithProps = childrenArray.slice(0, numToShow).map((child) =>
React.cloneElement(child, {
size,
}),
);
if (numToShow < numOfChildren) {
const cls = avatarSizeToClassName(size);
childrenWithProps.push(
<li key="exceed" className={cls}>
<Avatar size={size} style={excessItemsStyle}>{`+${numOfChildren - maxLength}`}</Avatar>
</li>,
);
}
return (
<div {...other} className={styles.avatarList}>
<ul> {childrenWithProps} </ul>
</div>
);
};
AvatarList.Item = Item;
export default AvatarList;

54
src/pages/account/center/components/Projects/index.less

@ -0,0 +1,54 @@
@import '~antd/es/style/themes/default.less';
.coverCardList {
.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%;
}
}
}

49
src/pages/account/center/components/Projects/index.tsx

@ -0,0 +1,49 @@
import { Card, List } from 'antd';
import { useRequest } from 'umi';
import React from 'react';
import moment from 'moment';
import { queryFakeList } from '../../service';
import AvatarList from '../AvatarList';
import type { ListItemDataType } from '../../data.d';
import styles from './index.less';
const Projects: React.FC = () => {
// 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
});
});
return (
<List<ListItemDataType>
className={styles.coverCardList}
rowKey="id"
grid={{ gutter: 24, xxl: 3, xl: 2, lg: 2, md: 2, sm: 2, xs: 1 }}
dataSource={listData?.list || []}
renderItem={(item) => (
<List.Item>
<Card className={styles.card} hoverable cover={<img alt={item.title} src={item.cover} />}>
<Card.Meta title={<a>{item.title}</a>} description={item.subDescription} />
<div className={styles.cardItemContent}>
<span>{moment(item.updatedAt).fromNow()}</span>
<div className={styles.avatarList}>
<AvatarList size="small">
{item.members.map((member) => (
<AvatarList.Item
key={`${item.id}-avatar-${member.id}`}
src={member.avatar}
tips={member.name}
/>
))}
</AvatarList>
</div>
</div>
</Card>
</List.Item>
)}
/>
);
};
export default Projects;

75
src/pages/account/center/data.d.ts

@ -0,0 +1,75 @@
export type tabKeyType = 'articles' | 'applications' | 'projects';
export interface TagType {
key: string;
label: string;
}
export type GeographicType = {
province: {
label: string;
key: string;
};
city: {
label: string;
key: string;
};
};
export type NoticeType = {
id: string;
title: string;
logo: string;
description: string;
updatedAt: string;
member: string;
href: string;
memberLink: string;
};
export type 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 type Member = {
avatar: string;
name: string;
id: string;
};
export type 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[];
};

210
src/pages/account/center/index.tsx

@ -0,0 +1,210 @@
import { PlusOutlined, HomeOutlined, ContactsOutlined, ClusterOutlined } from '@ant-design/icons';
import { Avatar, Card, Col, Divider, Input, Row, Tag } from 'antd';
import React, { useState, useRef } from 'react';
import { GridContent } from '@ant-design/pro-layout';
import { Link, useRequest } from 'umi';
import type { RouteChildrenProps } from 'react-router';
import Projects from './components/Projects';
import Articles from './components/Articles';
import Applications from './components/Applications';
import type { CurrentUser, TagType, tabKeyType } from './data.d';
import { queryCurrent } from './service';
import styles from './Center.less';
const operationTabList = [
{
key: 'articles',
tab: (
<span>
<span style={{ fontSize: 14 }}>(8)</span>
</span>
),
},
{
key: 'applications',
tab: (
<span>
<span style={{ fontSize: 14 }}>(8)</span>
</span>
),
},
{
key: 'projects',
tab: (
<span>
<span style={{ fontSize: 14 }}>(8)</span>
</span>
),
},
];
const TagList: React.FC<{ tags: CurrentUser['tags'] }> = ({ tags }) => {
const ref = useRef<Input | null>(null);
const [newTags, setNewTags] = useState<TagType[]>([]);
const [inputVisible, setInputVisible] = useState<boolean>(false);
const [inputValue, setInputValue] = useState<string>('');
const showInput = () => {
setInputVisible(true);
if (ref.current) {
// eslint-disable-next-line no-unused-expressions
ref.current?.focus();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputConfirm = () => {
let tempsTags = [...newTags];
if (inputValue && tempsTags.filter((tag) => tag.label === inputValue).length === 0) {
tempsTags = [...tempsTags, { key: `new-${tempsTags.length}`, label: inputValue }];
}
setNewTags(tempsTags);
setInputVisible(false);
setInputValue('');
};
return (
<div className={styles.tags}>
<div className={styles.tagsTitle}></div>
{(tags || []).concat(newTags).map((item) => (
<Tag key={item.key}>{item.label}</Tag>
))}
{inputVisible && (
<Input
ref={ref}
type="text"
size="small"
style={{ width: 78 }}
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onPressEnter={handleInputConfirm}
/>
)}
{!inputVisible && (
<Tag onClick={showInput} style={{ borderStyle: 'dashed' }}>
<PlusOutlined />
</Tag>
)}
</div>
);
};
const Center: React.FC<RouteChildrenProps> = () => {
const [tabKey, setTabKey] = useState<tabKeyType>('articles');
// 获取用户信息
const { data: currentUser, loading } = useRequest(() => {
return queryCurrent();
});
// 渲染用户信息
const renderUserInfo = ({ title, group, geographic }: Partial<CurrentUser>) => {
return (
<div className={styles.detail}>
<p>
<ContactsOutlined
style={{
marginRight: 8,
}}
/>
{title}
</p>
<p>
<ClusterOutlined
style={{
marginRight: 8,
}}
/>
{group}
</p>
<p>
<HomeOutlined
style={{
marginRight: 8,
}}
/>
{(geographic || { province: { label: '' } }).province.label}
{
(
geographic || {
city: {
label: '',
},
}
).city.label
}
</p>
</div>
);
};
// 渲染tab切换
const renderChildrenByTabKey = (tabValue: tabKeyType) => {
if (tabValue === 'projects') {
return <Projects />;
}
if (tabValue === 'applications') {
return <Applications />;
}
if (tabValue === 'articles') {
return <Articles />;
}
return null;
};
return (
<GridContent>
<Row gutter={24}>
<Col lg={7} md={24}>
<Card bordered={false} style={{ marginBottom: 24 }} loading={loading}>
{!loading && currentUser && (
<div>
<div className={styles.avatarHolder}>
<img alt="" src={currentUser.avatar} />
<div className={styles.name}>{currentUser.name}</div>
<div>{currentUser?.signature}</div>
</div>
{renderUserInfo(currentUser)}
<Divider dashed />
<TagList tags={currentUser.tags || []} />
<Divider style={{ marginTop: 16 }} dashed />
<div className={styles.team}>
<div className={styles.teamTitle}></div>
<Row gutter={36}>
{currentUser.notice &&
currentUser.notice.map((item) => (
<Col key={item.id} lg={24} xl={12}>
<Link to={item.href}>
<Avatar size="small" src={item.logo} />
{item.member}
</Link>
</Col>
))}
</Row>
</div>
</div>
)}
</Card>
</Col>
<Col lg={17} md={24}>
<Card
className={styles.tabsCard}
bordered={false}
tabList={operationTabList}
activeTabKey={tabKey}
onTabChange={(_tabKey: string) => {
setTabKey(_tabKey as tabKeyType);
}}
>
{renderChildrenByTabKey(tabKey)}
</Card>
</Col>
</Row>
</GridContent>
);
};
export default Center;

14
src/pages/account/center/service.ts

@ -0,0 +1,14 @@
import { request } from 'umi';
import type { CurrentUser, ListItemDataType } from './data.d';
export async function queryCurrent(): Promise<{ data: CurrentUser }> {
return request('/api/currentUserDetail');
}
export async function queryFakeList(params: {
count: number;
}): Promise<{ data: { list: ListItemDataType[] } }> {
return request('/api/fake_list_Detail', {
params,
});
}

79
src/pages/account/settings/_mock.ts

@ -0,0 +1,79 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import type { Request, Response } from 'express';
const city = require('./geographic/city.json');
const province = require('./geographic/province.json');
function getProvince(_: Request, res: Response) {
return res.json({
data: province,
});
}
function getCity(req: Request, res: Response) {
return res.json({
data: city[req.params.province],
});
}
function getCurrentUse(req: Request, res: Response) {
return res.json({
data: {
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',
},
});
}
// 代码中会兼容本地 service mock 以及部署站点的静态数据
export default {
// 支持值为 Object 和 Array
'GET /api/accountSettingCurrentUser': getCurrentUse,
'GET /api/geographic/province': getProvince,
'GET /api/geographic/city/:province': getCity,
};

65
src/pages/account/settings/components/BaseView.less

@ -0,0 +1,65 @@
@import '~antd/es/style/themes/default.less';
.baseView {
display: flex;
padding-top: 12px;
:global {
.ant-legacy-form-item .ant-legacy-form-item-control-wrapper {
width: 100%;
}
}
.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;
}
}
}
.area_code {
width: 72px;
}
.phone_number {
width: 214px;
}
@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;
}
}
}
}

42
src/pages/account/settings/components/PhoneView.tsx

@ -0,0 +1,42 @@
import React from 'react';
import { Input } from 'antd';
import styles from './PhoneView.less';
type PhoneViewProps = {
value?: string;
onChange?: (value: string) => void;
};
const PhoneView: React.FC<PhoneViewProps> = (props) => {
const { value, onChange } = props;
let values = ['', ''];
if (value) {
values = value.split('-');
}
return (
<>
<Input
className={styles.area_code}
value={values[0]}
onChange={(e) => {
if (onChange) {
onChange(`${e.target.value}-${values[1]}`);
}
}}
/>
<Input
className={styles.phone_number}
onChange={(e) => {
if (onChange) {
onChange(`${values[0]}-${e.target.value}`);
}
}}
value={values[1]}
/>
</>
);
};
export default PhoneView;

235
src/pages/account/settings/components/base.tsx

@ -0,0 +1,235 @@
import React from 'react';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Input, Upload, message } from 'antd';
import ProForm, {
ProFormDependency,
ProFormFieldSet,
ProFormSelect,
ProFormText,
ProFormTextArea,
} from '@ant-design/pro-form';
import { useRequest } from 'umi';
import { queryCurrent } from '../service';
import { queryProvince, queryCity } from '../service';
import styles from './BaseView.less';
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();
};
// 头像组件 方便以后独立,增加裁剪之类的功能
const AvatarView = ({ avatar }: { avatar: string }) => (
<>
<div className={styles.avatar_title}></div>
<div className={styles.avatar}>
<img src={avatar} alt="avatar" />
</div>
<Upload showUploadList={false}>
<div className={styles.button_view}>
<Button>
<UploadOutlined />
</Button>
</div>
</Upload>
</>
);
const BaseView: React.FC = () => {
const { data: currentUser, loading } = useRequest(() => {
return queryCurrent();
});
const getAvatarURL = () => {
if (currentUser) {
if (currentUser.avatar) {
return currentUser.avatar;
}
const url = 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png';
return url;
}
return '';
};
const handleFinish = async () => {
message.success('更新基本信息成功');
};
return (
<div className={styles.baseView}>
{loading ? null : (
<>
<div className={styles.left}>
<ProForm
layout="vertical"
onFinish={handleFinish}
submitter={{
resetButtonProps: {
style: {
display: 'none',
},
},
submitButtonProps: {
children: '更新基本信息',
},
}}
initialValues={{
...currentUser,
phone: currentUser?.phone.split('-'),
}}
hideRequiredMark
>
<ProFormText
width="md"
name="email"
label="邮箱"
rules={[
{
required: true,
message: '请输入您的邮箱!',
},
]}
/>
<ProFormText
width="md"
name="name"
label="昵称"
rules={[
{
required: true,
message: '请输入您的昵称!',
},
]}
/>
<ProFormTextArea
name="profile"
label="个人简介"
rules={[
{
required: true,
message: '请输入个人简介!',
},
]}
placeholder="个人简介"
/>
<ProFormSelect
width="sm"
name="country"
label="国家/地区"
rules={[
{
required: true,
message: '请输入您的国家或地区!',
},
]}
options={[
{
label: '中国',
value: 'China',
},
]}
/>
<ProForm.Group title="所在省市" size={8}>
<ProFormSelect
rules={[
{
required: true,
message: '请输入您的所在省!',
},
]}
width="sm"
fieldProps={{
labelInValue: true,
}}
name="province"
className={styles.item}
request={async () => {
return queryProvince().then(({ data }) => {
return data.map((item) => {
return {
label: item.name,
value: item.id,
};
});
});
}}
/>
<ProFormDependency name={['province']}>
{({ province }) => {
return (
<ProFormSelect
params={{
key: province?.value,
}}
name="city"
width="sm"
rules={[
{
required: true,
message: '请输入您的所在城市!',
},
]}
disabled={!province}
className={styles.item}
request={async () => {
if (!province?.key) {
return [];
}
return queryCity(province.key || '').then(({ data }) => {
return data.map((item) => {
return {
label: item.name,
value: item.id,
};
});
});
}}
/>
);
}}
</ProFormDependency>
</ProForm.Group>
<ProFormText
width="md"
name="address"
label="街道地址"
rules={[
{
required: true,
message: '请输入您的街道地址!',
},
]}
/>
<ProFormFieldSet
name="phone"
label="联系电话"
rules={[
{
required: true,
message: '请输入您的联系电话!',
},
{ validator: validatorPhone },
]}
>
<Input className={styles.area_code} />
<Input className={styles.phone_number} />
</ProFormFieldSet>
</ProForm>
</div>
<div className={styles.right}>
<AvatarView avatar={getAvatarURL()} />
</div>
</>
)}
</div>
);
};
export default BaseView;

46
src/pages/account/settings/components/binding.tsx

@ -0,0 +1,46 @@
import { AlipayOutlined, DingdingOutlined, TaobaoOutlined } from '@ant-design/icons';
import { List } from 'antd';
import React, { Fragment } from 'react';
const BindingView: React.FC = () => {
const getData = () => [
{
title: '绑定淘宝',
description: '当前未绑定淘宝账号',
actions: [<a key="Bind"></a>],
avatar: <TaobaoOutlined className="taobao" />,
},
{
title: '绑定支付宝',
description: '当前未绑定支付宝账号',
actions: [<a key="Bind"></a>],
avatar: <AlipayOutlined className="alipay" />,
},
{
title: '绑定钉钉',
description: '当前未绑定钉钉账号',
actions: [<a key="Bind"></a>],
avatar: <DingdingOutlined className="dingding" />,
},
];
return (
<Fragment>
<List
itemLayout="horizontal"
dataSource={getData()}
renderItem={(item) => (
<List.Item actions={item.actions}>
<List.Item.Meta
avatar={item.avatar}
title={item.title}
description={item.description}
/>
</List.Item>
)}
/>
</Fragment>
);
};
export default BindingView;

44
src/pages/account/settings/components/notification.tsx

@ -0,0 +1,44 @@
import { List, Switch } from 'antd';
import React, { Fragment } from 'react';
type Unpacked<T> = T extends (infer U)[] ? U : T;
const NotificationView: React.FC = () => {
const getData = () => {
const Action = <Switch checkedChildren="开" unCheckedChildren="关" defaultChecked />;
return [
{
title: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
actions: [Action],
},
{
title: '系统消息',
description: '系统消息将以站内信的形式通知',
actions: [Action],
},
{
title: '待办任务',
description: '待办任务将以站内信的形式通知',
actions: [Action],
},
];
};
const data = getData();
return (
<Fragment>
<List<Unpacked<typeof data>>
itemLayout="horizontal"
dataSource={data}
renderItem={(item) => (
<List.Item actions={item.actions}>
<List.Item.Meta title={item.title} description={item.description} />
</List.Item>
)}
/>
</Fragment>
);
};
export default NotificationView;

62
src/pages/account/settings/components/security.tsx

@ -0,0 +1,62 @@
import React from 'react';
import { List } from 'antd';
type Unpacked<T> = T extends (infer U)[] ? U : T;
const passwordStrength = {
strong: <span className="strong"></span>,
medium: <span className="medium"></span>,
weak: <span className="weak"> Weak</span>,
};
const SecurityView: React.FC = () => {
const getData = () => [
{
title: '账户密码',
description: (
<>
{passwordStrength.strong}
</>
),
actions: [<a key="Modify"></a>],
},
{
title: '密保手机',
description: `已绑定手机:138****8293`,
actions: [<a key="Modify"></a>],
},
{
title: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
actions: [<a key="Set"></a>],
},
{
title: '备用邮箱',
description: `已绑定邮箱:ant***sign.com`,
actions: [<a key="Modify"></a>],
},
{
title: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
actions: [<a key="bind"></a>],
},
];
const data = getData();
return (
<>
<List<Unpacked<typeof data>>
itemLayout="horizontal"
dataSource={data}
renderItem={(item) => (
<List.Item actions={item.actions}>
<List.Item.Meta title={item.title} description={item.description} />
</List.Item>
)}
/>
</>
);
};
export default SecurityView;

43
src/pages/account/settings/data.d.ts

@ -0,0 +1,43 @@
export type TagType = {
key: string;
label: string;
};
export type GeographicItemType = {
name: string;
id: string;
};
export type GeographicType = {
province: GeographicItemType;
city: GeographicItemType;
};
export type NoticeType = {
id: string;
title: string;
logo: string;
description: string;
updatedAt: string;
member: string;
href: string;
memberLink: string;
};
export type 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;
};

1784
src/pages/account/settings/geographic/city.json

File diff suppressed because it is too large

138
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"
}
]

111
src/pages/account/settings/index.tsx

@ -0,0 +1,111 @@
import React, { useState, useRef, useLayoutEffect } from 'react';
import { GridContent } from '@ant-design/pro-layout';
import { Menu } from 'antd';
import BaseView from './components/base';
import BindingView from './components/binding';
import NotificationView from './components/notification';
import SecurityView from './components/security';
import styles from './style.less';
const { Item } = Menu;
type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification';
type SettingsState = {
mode: 'inline' | 'horizontal';
selectKey: SettingsStateKeys;
};
const Settings: React.FC = () => {
const menuMap: Record<string, React.ReactNode> = {
base: '基本设置',
security: '安全设置',
binding: '账号绑定',
notification: '新消息通知',
};
const [initConfig, setInitConfig] = useState<SettingsState>({
mode: 'inline',
selectKey: 'base',
});
const dom = useRef<HTMLDivElement>();
const resize = () => {
requestAnimationFrame(() => {
if (!dom.current) {
return;
}
let mode: 'inline' | 'horizontal' = 'inline';
const { offsetWidth } = dom.current;
if (dom.current.offsetWidth < 641 && offsetWidth > 400) {
mode = 'horizontal';
}
if (window.innerWidth < 768 && offsetWidth > 400) {
mode = 'horizontal';
}
setInitConfig({ ...initConfig, mode: mode as SettingsState['mode'] });
});
};
useLayoutEffect(() => {
if (dom.current) {
window.addEventListener('resize', resize);
resize();
}
return () => {
window.removeEventListener('resize', resize);
};
}, [dom.current]);
const getMenu = () => {
return Object.keys(menuMap).map((item) => <Item key={item}>{menuMap[item]}</Item>);
};
const renderChildren = () => {
const { selectKey } = initConfig;
switch (selectKey) {
case 'base':
return <BaseView />;
case 'security':
return <SecurityView />;
case 'binding':
return <BindingView />;
case 'notification':
return <NotificationView />;
default:
return null;
}
};
return (
<GridContent>
<div
className={styles.main}
ref={(ref) => {
if (ref) {
dom.current = ref;
}
}}
>
<div className={styles.leftMenu}>
<Menu
mode={initConfig.mode}
selectedKeys={[initConfig.selectKey]}
onClick={({ key }) => {
setInitConfig({
...initConfig,
selectKey: key as SettingsStateKeys,
});
}}
>
{getMenu()}
</Menu>
</div>
<div className={styles.right}>
<div className={styles.title}>{menuMap[initConfig.selectKey]}</div>
{renderChildren()}
</div>
</div>
</GridContent>
);
};
export default Settings;

18
src/pages/account/settings/service.ts

@ -0,0 +1,18 @@
import { request } from 'umi';
import type { CurrentUser, GeographicItemType } from './data';
export async function queryCurrent(): Promise<{ data: CurrentUser }> {
return request('/api/accountSettingCurrentUser');
}
export async function queryProvince(): Promise<{ data: GeographicItemType[] }> {
return request('/api/geographic/province');
}
export async function queryCity(province: string): Promise<{ data: GeographicItemType[] }> {
return request(`/api/geographic/city/${province}`);
}
export async function query() {
return request('/api/users');
}

93
src/pages/account/settings/style.less

@ -0,0 +1,93 @@
@import '~antd/es/style/themes/default.less';
.main {
display: flex;
width: 100%;
height: 100%;
padding-top: 16px;
padding-bottom: 16px;
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: 8px 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;
}
}
}

210
src/pages/dashboard/analysis/_mock.ts

@ -0,0 +1,210 @@
import moment from 'moment';
import type { Request, Response } from 'express';
import type { AnalysisData, RadarData, DataItem } from './data.d';
// mock data
const visitData: DataItem[] = [];
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) {
const date = moment(new Date().getTime() + 1000 * 60 * 30 * i).format('HH:mm');
offlineChartData.push({
date,
type: '客流量',
value: Math.floor(Math.random() * 100) + 10,
});
offlineChartData.push({
date,
type: '支付笔数',
value: 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,
};
const fakeChartData = (_: Request, res: Response) => {
return res.json({
data: getFakeChartData,
});
};
export default {
'GET /api/fake_analysis_chart_data': fakeChartData,
};

133
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 type 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 (
<div className={styles.chart} style={{ height }} ref={this.handleRoot}>
<div ref={this.handleRef}>
{title && <h4 style={{ marginBottom: 20 }}>{title}</h4>}
<Chart
scale={scale}
height={title ? height - 41 : height}
forceFit={forceFit}
data={data}
padding={padding || 'auto'}
>
<Axis
name="x"
title={false}
label={autoHideXLabels ? undefined : {}}
tickLine={autoHideXLabels ? undefined : {}}
/>
<Axis name="y" min={0} />
<Tooltip showTitle={false} crosshairs={false} />
<Geom type="interval" position="x*y" color={color} tooltip={tooltip} />
</Chart>
</div>
</div>
);
}
}
export default autoHeight()(Bar);

75
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;
}
}

97
src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx

@ -0,0 +1,97 @@
import { Card } from 'antd';
import type { 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 && total !== 0) {
return null;
}
let totalDom;
switch (typeof total) {
case 'undefined':
totalDom = null;
break;
case 'function':
totalDom = <div className={styles.total}>{total()}</div>;
break;
default:
totalDom = <div className={styles.total}>{total}</div>;
}
return totalDom;
};
export type ChartCardProps = {
title: React.ReactNode;
action?: React.ReactNode;
total?: React.ReactNode | number | (() => React.ReactNode | number);
footer?: React.ReactNode;
contentHeight?: number;
avatar?: React.ReactNode;
style?: React.CSSProperties;
} & CardProps;
class ChartCard extends React.Component<ChartCardProps> {
renderContent = () => {
const { contentHeight, title, avatar, action, total, footer, children, loading } = this.props;
if (loading) {
return false;
}
return (
<div className={styles.chartCard}>
<div
className={classNames(styles.chartTop, {
[styles.chartTopMargin]: !children && !footer,
})}
>
<div className={styles.avatar}>{avatar}</div>
<div className={styles.metaWrap}>
<div className={styles.meta}>
<span className={styles.title}>{title}</span>
<span className={styles.action}>{action}</span>
</div>
{renderTotal(total)}
</div>
</div>
{children && (
<div className={styles.content} style={{ height: contentHeight || 'auto' }}>
<div className={contentHeight && styles.contentFixed}>{children}</div>
</div>
)}
{footer && (
<div
className={classNames(styles.footer, {
[styles.footerMargin]: !children,
})}
>
{footer}
</div>
)}
</div>
);
};
render() {
const {
loading = false,
contentHeight,
title,
avatar,
action,
total,
footer,
children,
...rest
} = this.props;
return (
<Card loading={loading} bodyStyle={{ padding: '20px 24px 8px 24px' }} {...rest}>
{this.renderContent()}
</Card>
);
}
}
export default ChartCard;

17
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;
}
}

17
src/pages/dashboard/analysis/components/Charts/Field/index.tsx

@ -0,0 +1,17 @@
import React from 'react';
import styles from './index.less';
export type FieldProps = {
label: React.ReactNode;
value: React.ReactNode;
style?: React.CSSProperties;
};
const Field: React.FC<FieldProps> = ({ label, value, ...rest }) => (
<div className={styles.field} {...rest}>
<span className={styles.label}>{label}</span>
<span className={styles.number}>{value}</span>
</div>
);
export default Field;

179
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 type 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<GaugeProps> = (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 = () => `
<div style="width: 300px;text-align: center;font-size: 12px!important;">
<div style="font-size: 14px; color: rgba(0,0,0,0.43);margin: 0;">${title}</div>
<div style="font-size: 24px;color: rgba(0,0,0,0.85);margin: 0;">
${(data[0].value * 10).toFixed(2)}%
</div>
</div>`;
const textStyle: {
fontSize: number;
fill: string;
textAlign: 'center';
} = {
fontSize: 12,
fill: 'rgba(0, 0, 0, 0.65)',
textAlign: 'center',
};
return (
<Chart height={height} data={data} scale={cols} padding={[-16, 0, 16, 0]} forceFit={forceFit}>
<Coord type="polar" startAngle={-1.25 * Math.PI} endAngle={0.25 * Math.PI} radius={0.8} />
<Axis name="1" line={undefined} />
<Axis
line={undefined}
tickLine={undefined}
subTickLine={undefined}
name="value"
zIndex={2}
label={{
offset: -12,
formatter,
textStyle,
}}
/>
<Guide>
<Line
start={[3, 0.905]}
end={[3, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 2,
}}
/>
<Line
start={[5, 0.905]}
end={[5, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 3,
}}
/>
<Line
start={[7, 0.905]}
end={[7, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 3,
}}
/>
<Arc
start={[0, 0.965]}
end={[10, 0.965]}
style={{
stroke: bgColor,
lineWidth: 10,
}}
/>
<Arc
start={[0, 0.965]}
end={[data[0].value, 0.965]}
style={{
stroke: color,
lineWidth: 10,
}}
/>
<Html position={['50%', '95%']} html={renderHtml()} />
</Guide>
<Geom
line={false}
type="point"
position="value*1"
shape="pointer"
color={color}
active={false}
/>
</Chart>
);
};
export default autoHeight()(Gauge);

131
src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx

@ -0,0 +1,131 @@
import type { AxisProps } from 'bizcharts';
import { Axis, Chart, Geom, Tooltip } from 'bizcharts';
import React from 'react';
import autoHeight from '../autoHeight';
import styles from '../index.less';
export type MiniAreaProps = {
color?: string;
height?: number;
borderColor?: string;
line?: boolean;
animate?: boolean;
xAxis?: AxisProps;
forceFit?: boolean;
scale?: {
x?: {
tickCount: number;
};
y?: {
tickCount: number;
};
};
yAxis?: Partial<AxisProps>;
borderWidth?: number;
data: {
x: number | string;
y: number;
}[];
};
const MiniArea: React.FC<MiniAreaProps> = (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 (
<div className={styles.miniChart} style={{ height }}>
<div className={styles.chartContent}>
{height > 0 && (
<Chart
animate={animate}
scale={scaleProps}
height={chartHeight}
forceFit={forceFit}
data={data}
padding={padding}
>
<Axis
key="axis-x"
name="x"
label={null}
line={null}
tickLine={null}
grid={null}
{...xAxis}
/>
<Axis
key="axis-y"
name="y"
label={null}
line={null}
tickLine={null}
grid={null}
{...yAxis}
/>
<Tooltip showTitle={false} crosshairs={false} />
<Geom
type="area"
position="x*y"
color={color}
tooltip={tooltip}
shape="smooth"
style={{
fillOpacity: 1,
}}
/>
{line ? (
<Geom
type="line"
position="x*y"
shape="smooth"
color={borderColor}
size={borderWidth}
tooltip={false}
/>
) : (
<span style={{ display: 'none' }} />
)}
</Chart>
)}
</div>
</div>
);
};
export default autoHeight()(MiniArea);

54
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 type MiniBarProps = {
color?: string;
height?: number;
data: {
x: number | string;
y: number;
}[];
forceFit?: boolean;
style?: React.CSSProperties;
};
const MiniBar: React.FC<MiniBarProps> = (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 (
<div className={styles.miniChart} style={{ height }}>
<div className={styles.chartContent}>
<Chart scale={scale} height={chartHeight} forceFit={forceFit} data={data} padding={padding}>
<Tooltip showTitle={false} crosshairs={false} />
<Geom type="interval" position="x*y" color={color} tooltip={tooltip} />
</Chart>
</div>
</div>
);
};
export default autoHeight()(MiniBar);

41
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 type MiniProgressProps = {
target: number;
targetLabel?: string;
color?: string;
strokeWidth?: number;
percent?: number;
style?: React.CSSProperties;
};
const MiniProgress: React.FC<MiniProgressProps> = ({
targetLabel,
target,
color = 'rgb(19, 194, 194)',
strokeWidth,
percent,
}) => (
<div className={styles.miniProgress}>
<Tooltip title={targetLabel}>
<div className={styles.target} style={{ left: target ? `${target}%` : undefined }}>
<span style={{ backgroundColor: color || undefined }} />
<span style={{ backgroundColor: color || undefined }} />
</div>
</Tooltip>
<div className={styles.progressWrap}>
<div
className={styles.progress}
style={{
backgroundColor: color || undefined,
width: percent ? `${percent}%` : undefined,
height: strokeWidth || undefined,
}}
/>
</div>
</div>
);
export default MiniProgress;

309
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 type 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;
};
type PieState = {
legendData: { checked: boolean; x: string; color: string; percent: number; y: string }[];
legendBlock: boolean;
};
class Pie extends Component<PieProps, PieState> {
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 (
<div ref={this.handleRoot} className={pieClassName} style={style}>
<ReactFitText maxFontSize={25}>
<div className={styles.chart}>
<Chart
scale={scale}
height={height}
forceFit={forceFit}
data={dv}
padding={padding}
animate={animate}
onGetG2Instance={this.getG2Instance}
>
{!!tooltip && <Tooltip showTitle={false} />}
<Coord type="theta" innerRadius={inner} />
<Geom
style={{ lineWidth, stroke: '#fff' }}
tooltip={tooltip ? tooltipFormat : undefined}
type="intervalStack"
position="percent"
color={['x', percent || percent === 0 ? formatColor : defaultColors] as any}
selected={selected}
/>
</Chart>
{(subTitle || total) && (
<div className={styles.total}>
{subTitle && <h4 className="pie-sub-title">{subTitle}</h4>}
{/* eslint-disable-next-line */}
{total && (
<div className="pie-stat">{typeof total === 'function' ? total() : total}</div>
)}
</div>
)}
</div>
</ReactFitText>
{hasLegend && (
<ul className={styles.legend}>
{legendData.map((item, i) => (
<li key={item.x} onClick={() => this.handleLegendClick(item, i)}>
<span
className={styles.dot}
style={{
backgroundColor: !item.checked ? '#aaa' : item.color,
}}
/>
<span className={styles.legendTitle}>{item.x}</span>
<Divider type="vertical" />
<span className={styles.percent}>
{`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`}
</span>
<span className={styles.value}>{valueFormat ? valueFormat(item.y) : item.y}</span>
</li>
))}
</ul>
)}
</div>
);
}
}
export default autoHeight()(Pie);

212
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 type TagCloudProps = {
data: {
name: string;
value: number;
}[];
height?: number;
className?: string;
style?: React.CSSProperties;
};
type TagCloudState = {
dv: any;
height?: number;
width: number;
};
class TagCloud extends Component<TagCloudProps, TagCloudState> {
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 (
<div
className={classNames(styles.tagCloud, className)}
style={{ width: '100%', height }}
ref={this.saveRootRef}
>
{dv && (
<Chart
width={width}
height={stateHeight}
data={dv}
padding={0}
scale={{
x: { nice: false },
y: { nice: false },
}}
>
<Tooltip showTitle={false} />
<Coord reflect="y" />
<Geom
type="point"
position="x*y"
color="text"
shape="cloud"
tooltip={[
'text*value',
function trans(text, value) {
return { name: text, value };
},
]}
/>
</Chart>
)}
</div>
);
}
}
export default autoHeight()(TagCloud);

132
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 type 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<TimelineChartProps> = (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 = () => (
<Slider
padding={[0, padding[1] + 20, 0, padding[3]]}
width="auto"
height={26}
xAxis="x"
yAxis="y1"
scales={{ x: timeScale }}
data={data}
start={ds.state.start}
end={ds.state.end}
backgroundChart={{ type: 'line' }}
onChange={({ startValue, endValue }: { startValue: string; endValue: string }) => {
ds.setState('start', startValue);
ds.setState('end', endValue);
}}
/>
);
return (
<div className={styles.timelineChart} style={{ height: height + 30 }}>
<div>
{title && <h4>{title}</h4>}
<Chart height={height} padding={padding} data={dv} scale={cols} forceFit>
<Axis name="x" />
<Tooltip />
<Legend name="key" position="top" />
<Geom type="line" position="x*value" size={borderWidth} color="key" />
</Chart>
<div style={{ marginRight: -20 }}>
<SliderGen />
</div>
</div>
</div>
);
};
export default autoHeight()(TimelineChart);

235
src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx

@ -0,0 +1,235 @@
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 type WaterWaveProps = {
title: React.ReactNode;
color?: string;
height?: number;
percent: number;
style?: React.CSSProperties;
};
class WaterWave extends Component<WaterWaveProps> {
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;
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]);
const 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();
};
const 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();
}
this.timer = requestAnimationFrame(render);
};
render();
}
render() {
const { radio } = this.state;
const { percent, title, height = 1 } = this.props;
return (
<div
className={styles.waterWave}
ref={(n) => (this.root = n)}
style={{ transform: `scale(${radio})` }}
>
<div style={{ width: height, height, overflow: 'hidden' }}>
<canvas
className={styles.waterWaveCanvasWrapper}
ref={(n) => (this.node = n)}
width={height * 2}
height={height * 2}
/>
</div>
<div className={styles.text} style={{ width: height }}>
{title && <span>{title}</span>}
<h4>{percent}%</h4>
</div>
</div>
);
}
}
export default autoHeight()(WaterWave);

79
src/pages/dashboard/analysis/components/Charts/autoHeight.tsx

@ -0,0 +1,79 @@
import React from 'react';
export type IReactComponent<P = any> =
| React.StatelessComponent<P>
| React.ComponentClass<P>
| React.ClassicComponentClass<P>;
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;
}
type AutoHeightProps = {
height?: number;
};
function autoHeight() {
return <P extends AutoHeightProps>(
WrappedComponent: React.ComponentClass<P> | React.FC<P>,
): React.ComponentClass<P> => {
class AutoHeightComponent extends React.Component<P & AutoHeightProps> {
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 (
<div ref={this.handleRoot}>
{h > 0 && <WrappedComponent {...this.props} height={h} />}
</div>
);
}
}
return AutoHeightComponent;
};
}
export default autoHeight;

19
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;
}
}

13
src/pages/dashboard/analysis/components/Charts/index.tsx

@ -0,0 +1,13 @@
import numeral from 'numeral';
import ChartCard from './ChartCard';
import Field from './Field';
const yuan = (val: number | string) => `¥ ${numeral(val).format('0,0')}`;
const Charts = {
yuan,
ChartCard,
Field,
};
export { Charts as default, yuan, ChartCard, Field };

135
src/pages/dashboard/analysis/components/IntroduceRow.tsx

@ -0,0 +1,135 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { TinyArea, TinyColumn, Progress } from '@ant-design/charts';
import { Col, Row, Tooltip } from 'antd';
import numeral from 'numeral';
import { ChartCard, Field } from './Charts';
import type { DataItem } 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: DataItem[] }) => (
<Row gutter={24}>
<Col {...topColResponsiveProps}>
<ChartCard
bordered={false}
title="总销售额"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
loading={loading}
total={() => <Yuan>126560</Yuan>}
footer={<Field label="日销售额" value={`${numeral(12423).format('0,0')}`} />}
contentHeight={46}
>
<Trend flag="up" style={{ marginRight: 16 }}>
<span className={styles.trendText}>12%</span>
</Trend>
<Trend flag="down">
<span className={styles.trendText}>11%</span>
</Trend>
</ChartCard>
</Col>
<Col {...topColResponsiveProps}>
<ChartCard
bordered={false}
loading={loading}
title="访问量"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
total={numeral(8846).format('0,0')}
footer={<Field label="日访问量" value={numeral(1234).format('0,0')} />}
contentHeight={46}
>
<TinyArea
color="#975FE4"
xField="x"
height={46}
forceFit
yField="y"
smooth
data={visitData}
/>
</ChartCard>
</Col>
<Col {...topColResponsiveProps}>
<ChartCard
bordered={false}
loading={loading}
title="支付笔数"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
total={numeral(6560).format('0,0')}
footer={<Field label="转化率" value="60%" />}
contentHeight={46}
>
<TinyColumn xField="x" height={46} forceFit yField="y" data={visitData} />
</ChartCard>
</Col>
<Col {...topColResponsiveProps}>
<ChartCard
loading={loading}
bordered={false}
title="运营活动效果"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
total="78%"
footer={
<div style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Trend flag="up" style={{ marginRight: 16 }}>
<span className={styles.trendText}>12%</span>
</Trend>
<Trend flag="down">
<span className={styles.trendText}>11%</span>
</Trend>
</div>
}
contentHeight={46}
>
<Progress
height={46}
percent={0.78}
color="#13C2C2"
forceFit
size={8}
marker={[
{
value: 0.8,
style: {
stroke: '#13C2C2',
},
},
]}
/>
</ChartCard>
</Col>
</Row>
);
export default IntroduceRow;

68
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;
.anticon {
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;
}
}
}

62
src/pages/dashboard/analysis/components/NumberInfo/index.tsx

@ -0,0 +1,62 @@
import { CaretUpOutlined, CaretDownOutlined } from '@ant-design/icons';
import React from 'react';
import classNames from 'classnames';
import styles from './index.less';
export type 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.FC<NumberInfoProps> = ({
theme,
title,
subTitle,
total,
subTotal,
status,
suffix,
gap,
...rest
}) => (
<div
className={classNames(styles.numberInfo, {
[styles[`numberInfo${theme}`]]: theme,
})}
{...rest}
>
{title && (
<div className={styles.numberInfoTitle} title={typeof title === 'string' ? title : ''}>
{title}
</div>
)}
{subTitle && (
<div
className={styles.numberInfoSubTitle}
title={typeof subTitle === 'string' ? subTitle : ''}
>
{subTitle}
</div>
)}
<div className={styles.numberInfoValue} style={gap ? { marginTop: gap } : {}}>
<span>
{total}
{suffix && <em className={styles.suffix}>{suffix}</em>}
</span>
{(status || subTotal) && (
<span className={styles.subTotal}>
{subTotal}
{status && status === 'up' ? <CaretUpOutlined /> : <CaretDownOutlined />}
</span>
)}
</div>
</div>
);
export default NumberInfo;

76
src/pages/dashboard/analysis/components/OfflineData.tsx

@ -0,0 +1,76 @@
import { Card, Col, Row, Tabs } from 'antd';
import { RingProgress, Line } from '@ant-design/charts';
import type { OfflineDataType, DataItem } from '../data.d';
import NumberInfo from './NumberInfo';
import styles from '../style.less';
const CustomTab = ({
data,
currentTabKey: currentKey,
}: {
data: OfflineDataType;
currentTabKey: string;
}) => (
<Row gutter={8} style={{ width: 138, margin: '8px 0' }}>
<Col span={12}>
<NumberInfo
title={data.name}
subTitle="转化率"
gap={2}
total={`${data.cvr * 100}%`}
theme={currentKey !== data.name ? 'light' : undefined}
/>
</Col>
<Col span={12} style={{ paddingTop: 36 }}>
<RingProgress forceFit height={60} width={60} percent={data.cvr} />
</Col>
</Row>
);
const { TabPane } = Tabs;
const OfflineData = ({
activeKey,
loading,
offlineData,
offlineChartData,
handleTabChange,
}: {
activeKey: string;
loading: boolean;
offlineData: OfflineDataType[];
offlineChartData: DataItem[];
handleTabChange: (activeKey: string) => void;
}) => (
<Card loading={loading} className={styles.offlineCard} bordered={false} style={{ marginTop: 32 }}>
<Tabs activeKey={activeKey} onChange={handleTabChange}>
{offlineData.map((shop) => (
<TabPane tab={<CustomTab data={shop} currentTabKey={activeKey} />} key={shop.name}>
<div style={{ padding: '0 24px' }}>
<Line
forceFit
height={400}
data={offlineChartData}
responsive
xField="date"
yField="value"
seriesField="type"
interactions={[
{
type: 'slider',
cfg: {},
},
]}
legend={{
position: 'top-center',
}}
/>
</div>
</TabPane>
))}
</Tabs>
</Card>
);
export default OfflineData;

9
src/pages/dashboard/analysis/components/PageLoading/index.tsx

@ -0,0 +1,9 @@
import { Spin } from 'antd';
// loading components from code split
// https://umijs.org/plugin/umi-plugin-react.html#dynamicimport
export default () => (
<div style={{ paddingTop: 100, textAlign: 'center' }}>
<Spin size="large" />
</div>
);

76
src/pages/dashboard/analysis/components/ProportionSales.tsx

@ -0,0 +1,76 @@
import { Card, Radio, Typography } from 'antd';
import numeral from 'numeral';
import type { RadioChangeEvent } from 'antd/es/radio';
import { Donut } from '@ant-design/charts';
import type { DonutConfig } from '@ant-design/charts/es/donut';
import React from 'react';
import type { DataItem } from '../data.d';
import styles from '../style.less';
const { Text } = Typography;
const ProportionSales = ({
dropdownGroup,
salesType,
loading,
salesPieData,
handleChangeSalesType,
}: {
loading: boolean;
dropdownGroup: React.ReactNode;
salesType: 'all' | 'online' | 'stores';
salesPieData: DataItem[];
handleChangeSalesType?: (e: RadioChangeEvent) => void;
}) => (
<Card
loading={loading}
className={styles.salesCard}
bordered={false}
title="销售额类别占比"
style={{
height: '100%',
}}
extra={
<div className={styles.salesCardExtra}>
{dropdownGroup}
<div className={styles.salesTypeRadio}>
<Radio.Group value={salesType} onChange={handleChangeSalesType}>
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="online">线</Radio.Button>
<Radio.Button value="stores"></Radio.Button>
</Radio.Group>
</div>
</div>
}
>
<div>
<Text></Text>
<Donut
forceFit
height={340}
radius={0.8}
angleField="y"
colorField="x"
data={salesPieData as any}
legend={{
visible: false,
}}
label={{
visible: true,
type: 'spider',
formatter: (text, item) => {
// eslint-disable-next-line no-underscore-dangle
return `${item._origin.x}: ${numeral(item._origin.y).format('0,0')}`;
},
}}
statistic={
{
totalLabel: '销售额',
} as DonutConfig['statistic']
}
/>
</div>
</Card>
);
export default ProportionSales;

189
src/pages/dashboard/analysis/components/SalesCard.tsx

@ -0,0 +1,189 @@
import { Card, Col, DatePicker, Row, Tabs } from 'antd';
import type { RangePickerProps } from 'antd/es/date-picker/generatePicker';
import type moment from 'moment';
import { Column } from '@ant-design/charts';
import numeral from 'numeral';
import type { DataItem } from '../data.d';
import styles from '../style.less';
type RangePickerValue = RangePickerProps<moment.Moment>['value'];
export type TimeType = 'today' | 'week' | 'month' | 'year';
const { RangePicker } = DatePicker;
const { TabPane } = Tabs;
const rankingListData: { title: string; total: number }[] = [];
for (let i = 0; i < 7; i += 1) {
rankingListData.push({
title: `工专路 ${i} 号店`,
total: 323234,
});
}
const SalesCard = ({
rangePickerValue,
salesData,
isActive,
handleRangePickerChange,
loading,
selectDate,
}: {
rangePickerValue: RangePickerValue;
isActive: (key: TimeType) => string;
salesData: DataItem[];
loading: boolean;
handleRangePickerChange: (dates: RangePickerValue, dateStrings: [string, string]) => void;
selectDate: (key: TimeType) => void;
}) => (
<Card loading={loading} bordered={false} bodyStyle={{ padding: 0 }}>
<div className={styles.salesCard}>
<Tabs
tabBarExtraContent={
<div className={styles.salesExtraWrap}>
<div className={styles.salesExtra}>
<a className={isActive('today')} onClick={() => selectDate('today')}>
</a>
<a className={isActive('week')} onClick={() => selectDate('week')}>
</a>
<a className={isActive('month')} onClick={() => selectDate('month')}>
</a>
<a className={isActive('year')} onClick={() => selectDate('year')}>
</a>
</div>
<RangePicker
value={rangePickerValue}
onChange={handleRangePickerChange}
style={{ width: 256 }}
/>
</div>
}
size="large"
tabBarStyle={{ marginBottom: 24 }}
>
<TabPane tab="销售额" key="sales">
<Row>
<Col xl={16} lg={12} md={12} sm={24} xs={24}>
<div className={styles.salesBar}>
<Column
height={300}
forceFit
data={salesData as any}
xField="x"
yField="y"
xAxis={{
visible: true,
title: {
visible: false,
},
}}
yAxis={{
visible: true,
title: {
visible: false,
},
}}
title={{
visible: true,
text: '销售趋势',
style: {
fontSize: 14,
},
}}
meta={{
y: {
alias: '销售量',
},
}}
/>
</div>
</Col>
<Col xl={8} lg={12} md={12} sm={24} xs={24}>
<div className={styles.salesRank}>
<h4 className={styles.rankingTitle}></h4>
<ul className={styles.rankingList}>
{rankingListData.map((item, i) => (
<li key={item.title}>
<span className={`${styles.rankingItemNumber} ${i < 3 ? styles.active : ''}`}>
{i + 1}
</span>
<span className={styles.rankingItemTitle} title={item.title}>
{item.title}
</span>
<span className={styles.rankingItemValue}>
{numeral(item.total).format('0,0')}
</span>
</li>
))}
</ul>
</div>
</Col>
</Row>
</TabPane>
<TabPane tab="访问量" key="views">
<Row>
<Col xl={16} lg={12} md={12} sm={24} xs={24}>
<div className={styles.salesBar}>
<Column
height={300}
forceFit
data={salesData as any}
xField="x"
yField="y"
xAxis={{
visible: true,
title: {
visible: false,
},
}}
yAxis={{
visible: true,
title: {
visible: false,
},
}}
title={{
visible: true,
text: '访问量趋势',
style: {
fontSize: 14,
},
}}
meta={{
y: {
alias: '访问量',
},
}}
/>
</div>
</Col>
<Col xl={8} lg={12} md={12} sm={24} xs={24}>
<div className={styles.salesRank}>
<h4 className={styles.rankingTitle}>访</h4>
<ul className={styles.rankingList}>
{rankingListData.map((item, i) => (
<li key={item.title}>
<span className={`${styles.rankingItemNumber} ${i < 3 ? styles.active : ''}`}>
{i + 1}
</span>
<span className={styles.rankingItemTitle} title={item.title}>
{item.title}
</span>
<span>{numeral(item.total).format('0,0')}</span>
</li>
))}
</ul>
</div>
</Col>
</Row>
</TabPane>
</Tabs>
</div>
</Card>
);
export default SalesCard;

113
src/pages/dashboard/analysis/components/TopSearch.tsx

@ -0,0 +1,113 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Card, Col, Row, Table, Tooltip } from 'antd';
import { TinyArea } from '@ant-design/charts';
import React from 'react';
import numeral from 'numeral';
import type { DataItem } from '../data.d';
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) => <a href="/">{text}</a>,
},
{
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 }) => (
<Trend flag={record.status === 1 ? 'down' : 'up'}>
<span style={{ marginRight: 4 }}>{text}%</span>
</Trend>
),
},
];
const TopSearch = ({
loading,
visitData2,
searchData,
dropdownGroup,
}: {
loading: boolean;
visitData2: DataItem[];
dropdownGroup: React.ReactNode;
searchData: DataItem[];
}) => (
<Card
loading={loading}
bordered={false}
title="线上热门搜索"
extra={dropdownGroup}
style={{
height: '100%',
}}
>
<Row gutter={68}>
<Col sm={12} xs={24} style={{ marginBottom: 24 }}>
<NumberInfo
subTitle={
<span>
<Tooltip title="指标说明">
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</span>
}
gap={8}
total={numeral(12321).format('0,0')}
status="up"
subTotal={17.1}
/>
<TinyArea xField="x" height={45} forceFit yField="y" smooth data={visitData2} />
</Col>
<Col sm={12} xs={24} style={{ marginBottom: 24 }}>
<NumberInfo
subTitle={
<span>
<Tooltip title="指标说明">
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</span>
}
total={2.7}
status="down"
subTotal={26.2}
gap={8}
/>
<TinyArea xField="x" height={45} forceFit yField="y" smooth data={visitData2} />
</Col>
</Row>
<Table<any>
rowKey={(record) => record.index}
size="small"
columns={columns}
dataSource={searchData}
pagination={{
style: { marginBottom: 0 },
pageSize: 5,
}}
/>
</Card>
);
export default TopSearch;

37
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;
span {
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;
}
}

42
src/pages/dashboard/analysis/components/Trend/index.tsx

@ -0,0 +1,42 @@
import { CaretUpOutlined, CaretDownOutlined } from '@ant-design/icons';
import React from 'react';
import classNames from 'classnames';
import styles from './index.less';
export type TrendProps = {
colorful?: boolean;
flag: 'up' | 'down';
style?: React.CSSProperties;
reverseColor?: boolean;
className?: string;
};
const Trend: React.FC<TrendProps> = ({
colorful = true,
reverseColor = false,
flag,
children,
className,
...rest
}) => {
const classString = classNames(
styles.trendItem,
{
[styles.trendItemGrey]: !colorful,
[styles.reverseColor]: reverseColor && colorful,
},
className,
);
return (
<div {...rest} className={classString} title={typeof children === 'string' ? children : ''}>
<span>{children}</span>
{flag && (
<span className={styles[flag]}>
{flag === 'up' ? <CaretUpOutlined /> : <CaretDownOutlined />}
</span>
)}
</div>
);
};
export default Trend;

46
src/pages/dashboard/analysis/data.d.ts

@ -0,0 +1,46 @@
import { DataItem } from '@antv/g2plot/esm/interface/config';
export { DataItem };
export interface VisitDataType {
x: string;
y: number;
}
export type SearchDataType = {
index: number;
keyword: string;
count: number;
range: number;
status: number;
};
export type OfflineDataType = {
name: string;
cvr: number;
};
export interface OfflineChartData {
date: number;
type: number;
value: number;
}
export type RadarData = {
name: string;
label: string;
value: number;
};
export interface AnalysisData {
visitData: DataItem[];
visitData2: DataItem[];
salesData: DataItem[];
searchData: DataItem[];
offlineData: OfflineDataType[];
offlineChartData: DataItem[];
salesTypeData: DataItem[];
salesTypeDataOnline: DataItem[];
salesTypeDataOffline: DataItem[];
radarData: RadarData[];
}

162
src/pages/dashboard/analysis/index.tsx

@ -0,0 +1,162 @@
import type { FC } from 'react';
import { Suspense, useState } from 'react';
import { EllipsisOutlined } from '@ant-design/icons';
import { Col, Dropdown, Menu, Row } from 'antd';
import { GridContent } from '@ant-design/pro-layout';
import type { RadioChangeEvent } from 'antd/es/radio';
import type { RangePickerProps } from 'antd/es/date-picker/generatePicker';
import type moment from 'moment';
import IntroduceRow from './components/IntroduceRow';
import SalesCard from './components/SalesCard';
import TopSearch from './components/TopSearch';
import ProportionSales from './components/ProportionSales';
import OfflineData from './components/OfflineData';
import { useRequest } from 'umi';
import { fakeChartData } from './service';
import PageLoading from './components/PageLoading';
import type { TimeType } from './components/SalesCard';
import { getTimeDistance } from './utils/utils';
import type { AnalysisData } from './data.d';
import styles from './style.less';
type RangePickerValue = RangePickerProps<moment.Moment>['value'];
type AnalysisProps = {
dashboardAndanalysis: AnalysisData;
loading: boolean;
};
type SalesType = 'all' | 'online' | 'stores';
const Analysis: FC<AnalysisProps> = () => {
const [salesType, setSalesType] = useState<SalesType>('all');
const [currentTabKey, setCurrentTabKey] = useState<string>('');
const [rangePickerValue, setRangePickerValue] = useState<RangePickerValue>(
getTimeDistance('year'),
);
const { loading, data } = useRequest(fakeChartData);
const selectDate = (type: TimeType) => {
setRangePickerValue(getTimeDistance(type));
};
const handleRangePickerChange = (value: RangePickerValue) => {
setRangePickerValue(value);
};
const isActive = (type: TimeType) => {
if (!rangePickerValue) {
return '';
}
const value = getTimeDistance(type);
if (!value) {
return '';
}
if (!rangePickerValue[0] || !rangePickerValue[1]) {
return '';
}
if (
rangePickerValue[0].isSame(value[0] as moment.Moment, 'day') &&
rangePickerValue[1].isSame(value[1] as moment.Moment, 'day')
) {
return styles.currentDate;
}
return '';
};
let salesPieData;
if (salesType === 'all') {
salesPieData = data?.salesTypeData;
} else {
salesPieData = salesType === 'online' ? data?.salesTypeDataOnline : data?.salesTypeDataOffline;
}
const menu = (
<Menu>
<Menu.Item></Menu.Item>
<Menu.Item></Menu.Item>
</Menu>
);
const dropdownGroup = (
<span className={styles.iconGroup}>
<Dropdown overlay={menu} placement="bottomRight">
<EllipsisOutlined />
</Dropdown>
</span>
);
const handleChangeSalesType = (e: RadioChangeEvent) => {
setSalesType(e.target.value);
};
const handleTabChange = (key: string) => {
setCurrentTabKey(key);
};
const activeKey = currentTabKey || (data?.offlineData[0] && data?.offlineData[0].name) || '';
return (
<GridContent>
<>
<Suspense fallback={<PageLoading />}>
<IntroduceRow loading={loading} visitData={data?.visitData || []} />
</Suspense>
<Suspense fallback={null}>
<SalesCard
rangePickerValue={rangePickerValue}
salesData={data?.salesData || []}
isActive={isActive}
handleRangePickerChange={handleRangePickerChange}
loading={loading}
selectDate={selectDate}
/>
</Suspense>
<Row
gutter={24}
style={{
marginTop: 24,
}}
>
<Col xl={12} lg={24} md={24} sm={24} xs={24}>
<Suspense fallback={null}>
<TopSearch
loading={loading}
visitData2={data?.visitData2 || []}
searchData={data?.searchData || []}
dropdownGroup={dropdownGroup}
/>
</Suspense>
</Col>
<Col xl={12} lg={24} md={24} sm={24} xs={24}>
<Suspense fallback={null}>
<ProportionSales
dropdownGroup={dropdownGroup}
salesType={salesType}
loading={loading}
salesPieData={salesPieData || []}
handleChangeSalesType={handleChangeSalesType}
/>
</Suspense>
</Col>
</Row>
<Suspense fallback={null}>
<OfflineData
activeKey={activeKey}
loading={loading}
offlineData={data?.offlineData || []}
offlineChartData={data?.offlineChartData || []}
handleTabChange={handleTabChange}
/>
</Suspense>
</>
</GridContent>
);
};
export default Analysis;

6
src/pages/dashboard/analysis/service.ts

@ -0,0 +1,6 @@
import { request } from 'umi';
import type { AnalysisData } from './data';
export async function fakeChartData(): Promise<{ data: AnalysisData }> {
return request('/api/fake_analysis_chart_data');
}

189
src/pages/dashboard/analysis/style.less

@ -0,0 +1,189 @@
@import '~antd/es/style/themes/default.less';
.iconGroup {
span.anticon {
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,
.ant-tabs-nav-wrap {
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;
}
}
}
}

33
src/pages/dashboard/analysis/utils/Yuan.tsx

@ -0,0 +1,33 @@
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 (
<span
ref={(ref) => {
this.main = ref;
}}
/>
);
}
}

50
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;
}
}

52
src/pages/dashboard/analysis/utils/utils.ts

@ -0,0 +1,52 @@
import moment from 'moment';
import type { RangePickerProps } from 'antd/es/date-picker/generatePicker';
type RangePickerValue = RangePickerProps<moment.Moment>['value'];
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`)];
}

14
src/pages/dashboard/monitor/_mock.ts

@ -0,0 +1,14 @@
import mockjs from 'mockjs';
import type { Request, Response } from 'express';
const getTags = (_: Request, res: Response) => {
return res.json({
data: mockjs.mock({
'list|100': [{ name: '@city', 'value|1-100': 150, 'type|0-2': 1 }],
}),
});
};
export default {
'GET /api/tags': getTags,
};

51
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;
}

90
src/pages/dashboard/monitor/components/ActiveChart/index.tsx

@ -0,0 +1,90 @@
import { Component } from 'react';
import { TinyArea } from '@ant-design/charts';
import { Statistic } from 'antd';
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 (
<div className={styles.activeChart}>
<Statistic title="目标评估" value="有望达到预期" />
<div style={{ marginTop: 32 }}>
<TinyArea data={activeData} xField="x" forceFit yField="y" height={84} />
</div>
{activeData && (
<div>
<div className={styles.activeChartGrid}>
<p>{[...activeData].sort()[activeData.length - 1].y + 200} 亿</p>
<p>{[...activeData].sort()[Math.floor(activeData.length / 2)].y} 亿</p>
</div>
<div className={styles.dashedLine}>
<div className={styles.line} />
</div>
<div className={styles.dashedLine}>
<div className={styles.line} />
</div>
</div>
)}
{activeData && (
<div className={styles.activeChartLegend}>
<span>00:00</span>
<span>{activeData[Math.floor(activeData.length / 2)].x}</span>
<span>{activeData[activeData.length - 1].x}</span>
</div>
)}
</div>
);
}
}

180
src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx

@ -0,0 +1,180 @@
import { Axis, Chart, Coord, Geom, Guide, Shape } from 'bizcharts';
import React from 'react';
import autoHeight from '../autoHeight';
const { Arc, Html, Line } = Guide;
export type 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<GaugeProps> = (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 = () => `
<div style="width: 300px;text-align: center;font-size: 12px!important;">
<div style="font-size: 14px; color: rgba(0,0,0,0.43);margin: 0;">${title}</div>
<div style="font-size: 24px;color: rgba(0,0,0,0.85);margin: 0;">
${(data[0].value * 10).toFixed(2)}%
</div>
</div>`;
const textStyle: {
fontSize: number;
fill: string;
textAlign: 'center';
} = {
fontSize: 12,
fill: 'rgba(0, 0, 0, 0.65)',
textAlign: 'center',
};
return (
<Chart height={height} data={data} scale={cols} padding={[-16, 0, 16, 0]} forceFit={forceFit}>
<Coord type="polar" startAngle={-1.25 * Math.PI} endAngle={0.25 * Math.PI} radius={0.8} />
<Axis name="1" line={undefined} />
<Axis
line={undefined}
tickLine={undefined}
subTickLine={undefined}
name="value"
zIndex={2}
label={{
offset: -12,
formatter,
textStyle,
}}
/>
<Guide>
<Line
start={[3, 0.905]}
end={[3, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 2,
}}
/>
<Line
start={[5, 0.905]}
end={[5, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 3,
}}
/>
<Line
start={[7, 0.905]}
end={[7, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 3,
}}
/>
<Arc
start={[0, 0.965]}
end={[10, 0.965]}
style={{
stroke: bgColor,
lineWidth: 10,
}}
/>
<Arc
start={[0, 0.965]}
end={[data[0].value, 0.965]}
style={{
stroke: color,
lineWidth: 10,
}}
/>
<Html position={['50%', '95%']} html={renderHtml()} />
</Guide>
<Geom
line={false}
type="point"
position="value*1"
shape="pointer"
color={color}
active={false}
/>
</Chart>
);
};
export default autoHeight()(Gauge);

131
src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx

@ -0,0 +1,131 @@
import type { AxisProps } from 'bizcharts';
import { Axis, Chart, Geom, Tooltip } from 'bizcharts';
import React from 'react';
import autoHeight from '../autoHeight';
import styles from '../index.less';
export type MiniAreaProps = {
color?: string;
height?: number;
borderColor?: string;
line?: boolean;
animate?: boolean;
xAxis?: AxisProps;
forceFit?: boolean;
scale?: {
x?: {
tickCount: number;
};
y?: {
tickCount: number;
};
};
yAxis?: Partial<AxisProps>;
borderWidth?: number;
data: {
x: number | string;
y: number;
}[];
};
const MiniArea: React.FC<MiniAreaProps> = (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 (
<div className={styles.miniChart} style={{ height }}>
<div className={styles.chartContent}>
{height > 0 && (
<Chart
animate={animate}
scale={scaleProps}
height={chartHeight}
forceFit={forceFit}
data={data}
padding={padding}
>
<Axis
key="axis-x"
name="x"
label={null}
line={null}
tickLine={null}
grid={null}
{...xAxis}
/>
<Axis
key="axis-y"
name="y"
label={null}
line={null}
tickLine={null}
grid={null}
{...yAxis}
/>
<Tooltip showTitle={false} crosshairs={false} />
<Geom
type="area"
position="x*y"
color={color}
tooltip={tooltip}
shape="smooth"
style={{
fillOpacity: 1,
}}
/>
{line ? (
<Geom
type="line"
position="x*y"
shape="smooth"
color={borderColor}
size={borderWidth}
tooltip={false}
/>
) : (
<span style={{ display: 'none' }} />
)}
</Chart>
)}
</div>
</div>
);
};
export default autoHeight()(MiniArea);

310
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 type 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;
};
type PieState = {
legendData: { checked: boolean; x: string; color: string; percent: number; y: string }[];
legendBlock: boolean;
};
class Pie extends Component<PieProps, PieState> {
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 (
<div ref={this.handleRoot} className={pieClassName} style={style}>
<ReactFitText maxFontSize={25}>
<div className={styles.chart}>
<Chart
scale={scale}
height={height}
forceFit={forceFit}
data={dv}
padding={padding}
animate={animate}
onGetG2Instance={this.getG2Instance}
>
{!!tooltip && <Tooltip showTitle={false} />}
<Coord type="theta" innerRadius={inner} />
<Geom
style={{ lineWidth, stroke: '#fff' }}
tooltip={tooltip ? tooltipFormat : undefined}
type="intervalStack"
position="percent"
color={['x', percent || percent === 0 ? formatColor : defaultColors] as any}
selected={selected}
/>
</Chart>
{(subTitle || total) && (
<div className={styles.total}>
{subTitle && <h4 className="pie-sub-title">{subTitle}</h4>}
{/* eslint-disable-next-line */}
{total && (
<div className="pie-stat">{typeof total === 'function' ? total() : total}</div>
)}
</div>
)}
</div>
</ReactFitText>
{hasLegend && (
<ul className={styles.legend}>
{legendData.map((item, i) => (
<li key={item.x} onClick={() => this.handleLegendClick(item, i)}>
<span
className={styles.dot}
style={{
backgroundColor: !item.checked ? '#aaa' : item.color,
}}
/>
<span className={styles.legendTitle}>{item.x}</span>
<Divider type="vertical" />
<span className={styles.percent}>
{`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`}
</span>
<span className={styles.value}>{valueFormat ? valueFormat(item.y) : item.y}</span>
</li>
))}
</ul>
)}
</div>
);
}
}
export default autoHeight()(Pie);

211
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 type TagCloudProps = {
data: {
name: string;
value: string;
}[];
height?: number;
className?: string;
style?: React.CSSProperties;
};
type TagCloudState = {
dv: any;
height?: number;
width: number;
};
class TagCloud extends Component<TagCloudProps, TagCloudState> {
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 (
<div
className={classNames(styles.tagCloud, className)}
style={{ width: '100%', height }}
ref={this.saveRootRef}
>
{dv && (
<Chart
width={width}
height={stateHeight}
data={dv}
padding={0}
scale={{
x: { nice: false },
y: { nice: false },
}}
>
<Tooltip showTitle={false} />
<Coord reflect="y" />
<Geom
type="point"
position="x*y"
color="text"
shape="cloud"
tooltip={[
'text*value',
function trans(text, value) {
return { name: text, value };
},
]}
/>
</Chart>
)}
</div>
);
}
}
export default autoHeight()(TagCloud);

235
src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx

@ -0,0 +1,235 @@
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 type WaterWaveProps = {
title: React.ReactNode;
color?: string;
height?: number;
percent: number;
style?: React.CSSProperties;
};
class WaterWave extends Component<WaterWaveProps> {
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;
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]);
const 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();
};
const 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();
}
this.timer = requestAnimationFrame(render);
};
render();
}
render() {
const { radio } = this.state;
const { percent, title, height = 1 } = this.props;
return (
<div
className={styles.waterWave}
ref={(n) => (this.root = n)}
style={{ transform: `scale(${radio})` }}
>
<div style={{ width: height, height, overflow: 'hidden' }}>
<canvas
className={styles.waterWaveCanvasWrapper}
ref={(n) => (this.node = n)}
width={height * 2}
height={height * 2}
/>
</div>
<div className={styles.text} style={{ width: height }}>
{title && <span>{title}</span>}
<h4>{percent}%</h4>
</div>
</div>
);
}
}
export default autoHeight()(WaterWave);

79
src/pages/dashboard/monitor/components/Charts/autoHeight.tsx

@ -0,0 +1,79 @@
import React from 'react';
export type IReactComponent<P = any> =
| React.StatelessComponent<P>
| React.ComponentClass<P>
| React.ClassicComponentClass<P>;
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;
}
type AutoHeightProps = {
height?: number;
};
function autoHeight() {
return <P extends AutoHeightProps>(
WrappedComponent: React.ComponentClass<P> | React.FC<P>,
): React.ComponentClass<P> => {
class AutoHeightComponent extends React.Component<P & AutoHeightProps> {
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 (
<div ref={this.handleRoot}>
{h > 0 && <WrappedComponent {...this.props} height={h} />}
</div>
);
}
}
return AutoHeightComponent;
};
}
export default autoHeight;

145
src/pages/dashboard/monitor/components/Map/index.tsx

@ -0,0 +1,145 @@
import * as React from 'react';
import { HeatmapLayer, MapboxScene, PointLayer } from '@antv/l7-react';
import { PageLoading } from '@ant-design/pro-layout';
const colors = ['#eff3ff', '#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#084594'];
export default class Map extends React.Component {
state = {
data: null,
grid: null,
loading: false,
};
public async componentDidMount() {
const [geoData, gridData] = await Promise.all([
fetch(
'https://gw.alipayobjects.com/os/bmw-prod/c5dba875-b6ea-4e88-b778-66a862906c93.json',
).then((d) => d.json()),
fetch(
'https://gw.alipayobjects.com/os/bmw-prod/8990e8b4-c58e-419b-afb9-8ea3daff2dd1.json',
).then((d) => d.json()),
]);
this.setState({
data: geoData,
grid: gridData,
loading: true,
});
}
public render() {
const { data, grid, loading } = this.state;
return loading === false ? (
<PageLoading />
) : (
<MapboxScene
map={{
center: [110.19382669582967, 50.258134],
pitch: 0,
style: 'blank',
zoom: 1,
}}
style={{
position: 'relative',
width: '100%',
height: '452px',
}}
>
{grid && (
<HeatmapLayer
key="1"
source={{
data: grid,
transforms: [
{
type: 'hexagon',
size: 800000,
field: 'capacity',
method: 'sum',
},
],
}}
color={{
values: '#ddd',
}}
shape={{
values: 'hexagon',
}}
style={{
coverage: 0.7,
opacity: 0.8,
}}
/>
)}
{data && [
<PointLayer
key="2"
options={{
autoFit: true,
}}
source={{
data,
}}
scale={{
values: {
color: {
field: 'cum_conf',
type: 'quantile',
},
size: {
field: 'cum_conf',
type: 'log',
},
},
}}
color={{
field: 'cum_conf',
values: colors,
}}
shape={{
values: 'circle',
}}
active={{
option: {
color: '#0c2c84',
},
}}
size={{
field: 'cum_conf',
values: [0, 30],
}}
style={{
opacity: 0.8,
}}
/>,
<PointLayer
key="5"
source={{
data,
}}
color={{
values: '#fff',
}}
shape={{
field: 'Short_Name_ZH',
values: 'text',
}}
filter={{
field: 'cum_conf',
values: (v) => {
return v > 2000;
},
}}
size={{
values: 12,
}}
style={{
opacity: 1,
strokeOpacity: 1,
strokeWidth: 0,
}}
/>,
]}
</MapboxScene>
);
}
}

5
src/pages/dashboard/monitor/data.d.ts

@ -0,0 +1,5 @@
export type TagType = {
name: string;
value: number;
type: string;
};

152
src/pages/dashboard/monitor/index.tsx

@ -0,0 +1,152 @@
import { Card, Col, Row, Statistic } from 'antd';
import { useRequest } from 'umi';
import type { FC } from 'react';
import { Gauge, WordCloud, Liquid, RingProgress } from '@ant-design/charts';
import type { WordCloudData } from '@antv/g2plot/esm/plots/word-cloud/layer';
import { GridContent } from '@ant-design/pro-layout';
import numeral from 'numeral';
import Map from './components/Map';
import ActiveChart from './components/ActiveChart';
import { queryTags } from './service';
import styles from './style.less';
const { Countdown } = Statistic;
const deadline = Date.now() + 1000 * 60 * 60 * 24 * 2 + 1000 * 30; // Moment is also OK
const Monitor: FC = () => {
const { loading, data } = useRequest(queryTags);
const wordCloudData: WordCloudData[] = (data?.list || []).map((item) => {
return {
id: +Date.now(),
word: item.name,
weight: item.value,
};
});
return (
<GridContent>
<>
<Row gutter={24}>
<Col xl={18} lg={24} md={24} sm={24} xs={24} style={{ marginBottom: 24 }}>
<Card title="活动实时交易情况" bordered={false}>
<Row>
<Col md={6} sm={12} xs={24}>
<Statistic
title="今日交易总额"
suffix="元"
value={numeral(124543233).format('0,0')}
/>
</Col>
<Col md={6} sm={12} xs={24}>
<Statistic title="销售目标完成率" value="92%" />
</Col>
<Col md={6} sm={12} xs={24}>
<Countdown title="活动剩余时间" value={deadline} format="HH:mm:ss:SSS" />
</Col>
<Col md={6} sm={12} xs={24}>
<Statistic title="每秒交易总额" suffix="元" value={numeral(234).format('0,0')} />
</Col>
</Row>
<div className={styles.mapChart}>
<Map />
</div>
</Card>
</Col>
<Col xl={6} lg={24} md={24} sm={24} xs={24}>
<Card title="活动情况预测" style={{ marginBottom: 24 }} bordered={false}>
<ActiveChart />
</Card>
<Card
title="券核效率"
style={{ marginBottom: 24 }}
bodyStyle={{ textAlign: 'center' }}
bordered={false}
>
<Gauge
height={180}
min={0}
max={100}
forceFit
value={87}
range={[0, 25, 50, 75, 100]}
statistic={{
visible: true,
text: '优',
color: '#30bf78',
}}
/>
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col xl={12} lg={24} sm={24} xs={24} style={{ marginBottom: 24 }}>
<Card title="各品类占比" bordered={false} className={styles.pieCard}>
<Row style={{ padding: '16px 0' }}>
<Col span={8}>
<RingProgress forceFit height={128} percent={0.28} />
{/* <Pie
animate={false}
percent={28}
title="中式快餐"
total="28%"
height={128}
lineWidth={2}
/> */}
</Col>
<Col span={8}>
<RingProgress color="#5DDECF" forceFit height={128} percent={0.22} />
</Col>
<Col span={8}>
<RingProgress color="#2FC25B" forceFit height={128} percent={0.32} />
</Col>
</Row>
</Card>
</Col>
<Col xl={6} lg={12} sm={24} xs={24} style={{ marginBottom: 24 }}>
<Card
title="热门搜索"
loading={loading}
bordered={false}
bodyStyle={{ overflow: 'hidden' }}
>
<WordCloud
data={wordCloudData}
forceFit
height={162}
wordStyle={{
fontSize: [10, 20],
}}
shape="triangle"
/>
{/* <TagCloud data={data?.list || []} height={161} /> */}
</Card>
</Col>
<Col xl={6} lg={12} sm={24} xs={24} style={{ marginBottom: 24 }}>
<Card
title="资源剩余"
bodyStyle={{ textAlign: 'center', fontSize: 0 }}
bordered={false}
>
<Liquid
height={161}
min={0}
max={10000}
value={5639}
forceFit
padding={[0, 0, 0, 0]}
statistic={{
formatter: (value) => `${((100 * value) / 10000).toFixed(1)}%`,
}}
/>
</Card>
</Col>
</Row>
</>
</GridContent>
);
};
export default Monitor;

6
src/pages/dashboard/monitor/service.ts

@ -0,0 +1,6 @@
import { request } from 'umi';
import type { TagType } from './data';
export async function queryTags(): Promise<{ data: { list: TagType[] } }> {
return request('/api/tags');
}

21
src/pages/dashboard/monitor/style.less

@ -0,0 +1,21 @@
@import '~antd/es/style/themes/default.less';
.mapChart {
height: 452px;
padding-top: 24px;
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;
}
}

410
src/pages/dashboard/workplace/_mock.ts

@ -0,0 +1,410 @@
import moment from 'moment';
import type { Request, Response } from 'express';
import type { SearchDataType, OfflineDataType, DataItem } from './data.d';
// mock data
const visitData: DataItem[] = [];
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: DataItem[] = [];
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: DataItem[] = [];
for (let i = 0; i < 12; i += 1) {
salesData.push({
x: `${i + 1}`,
y: Math.floor(Math.random() * 1000) + 200,
});
}
const searchData: SearchDataType[] = [];
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: OfflineDataType[] = [];
for (let i = 0; i < 10; i += 1) {
offlineData.push({
name: `Stores ${i}`,
cvr: Math.ceil(Math.random() * 9) / 10,
});
}
const offlineChartData: DataItem[] = [];
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 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 = (_: Request, res: Response) => {
res.json({
data: [
{
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 = (_: Request, res: Response) => {
res.json({
data: [
{
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],
});
}
});
});
const getChartData = (_: Request, res: Response) => {
res.json({
data: {
visitData,
visitData2,
salesData,
searchData,
offlineData,
offlineChartData,
salesTypeData,
salesTypeDataOnline,
salesTypeDataOffline,
radarData,
},
});
};
export default {
'GET /api/project/notice': getNotice,
'GET /api/activities': getActivities,
'GET /api/fake_workplace_chart_data': getChartData,
};

16
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;
}
}
}

47
src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx

@ -0,0 +1,47 @@
import React, { createElement } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import styles from './index.less';
export type EditableLink = {
title: string;
href: string;
id?: string;
};
type EditableLinkGroupProps = {
onAdd: () => void;
links: EditableLink[];
linkElement: any;
};
const EditableLinkGroup: React.FC<EditableLinkGroupProps> = (props) => {
const { links, linkElement, onAdd } = props;
return (
<div className={styles.linkGroup}>
{links.map((link) =>
createElement(
linkElement,
{
key: `linkGroup-item-${link.id || link.title}`,
to: link.href,
href: link.href,
},
link.title,
),
)}
<Button size="small" type="primary" ghost onClick={onAdd}>
<PlusOutlined />
</Button>
</div>
);
};
EditableLinkGroup.defaultProps = {
links: [],
onAdd: () => {},
linkElement: 'a',
};
export default EditableLinkGroup;

79
src/pages/dashboard/workplace/components/Radar/autoHeight.tsx

@ -0,0 +1,79 @@
import React from 'react';
export type IReactComponent<P = any> =
| React.StatelessComponent<P>
| React.ComponentClass<P>
| React.ClassicComponentClass<P>;
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;
}
type AutoHeightProps = {
height?: number;
};
function autoHeight() {
return <P extends AutoHeightProps>(
WrappedComponent: React.ComponentClass<P> | React.FC<P>,
): React.ComponentClass<P> => {
class AutoHeightComponent extends React.Component<P & AutoHeightProps> {
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 (
<div ref={this.handleRoot}>
{h > 0 && <WrappedComponent {...this.props} height={h} />}
</div>
);
}
}
return AutoHeightComponent;
};
}
export default autoHeight;

219
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 type 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;
};
type RadarState = {
legendData: {
checked: boolean;
name: string;
color: string;
percent: number;
value: string;
}[];
};
/* eslint react/no-danger:0 */
class Radar extends Component<RadarProps, RadarState> {
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 (
<div className={styles.radar} style={{ height }}>
{title && <h4>{title}</h4>}
<Chart
scale={scale}
height={chartHeight}
forceFit={forceFit}
data={data}
padding={padding}
animate={animate}
onGetG2Instance={this.getG2Instance}
>
<Tooltip />
<Coord type="polar" />
<Axis
name="label"
line={undefined}
tickLine={undefined}
grid={{
lineStyle: {
lineDash: undefined,
},
hideFirstLine: false,
}}
/>
<Axis
name="value"
grid={{
type: 'polygon',
lineStyle: {
lineDash: undefined,
},
}}
/>
<Geom type="line" position="label*value" color={['name', colors]} size={1} />
<Geom
type="point"
position="label*value"
color={['name', colors]}
shape="circle"
size={3}
/>
</Chart>
{hasLegend && (
<Row className={styles.legend}>
{legendData.map((item, i) => (
<Col
span={24 / legendData.length}
key={item.name}
onClick={() => this.handleLegendClick(item, i)}
>
<div className={styles.legendItem}>
<p>
<span
className={styles.dot}
style={{
backgroundColor: !item.checked ? '#aaa' : item.color,
}}
/>
<span>{item.name}</span>
</p>
<h6>{item.value}</h6>
</div>
</Col>
))}
</Row>
)}
</div>
);
}
}
export default autoHeight()(Radar);

111
src/pages/dashboard/workplace/data.d.ts

@ -0,0 +1,111 @@
import { DataItem } from '@antv/g2plot/esm/interface/config';
export { DataItem };
export interface TagType {
key: string;
label: string;
}
export type SearchDataType = {
index: number;
keyword: string;
count: number;
range: number;
status: number;
};
export type OfflineDataType = {
name: string;
cvr: number;
};
export interface RadarData {
name: string;
label: string;
value: number;
}
export type AnalysisData = {
visitData: VisitDataType[];
visitData2: VisitDataType[];
salesData: VisitDataType[];
searchData: SearchDataType[];
offlineData: OfflineDataType[];
offlineChartData: OfflineChartData[];
salesTypeData: VisitDataType[];
salesTypeDataOnline: VisitDataType[];
salesTypeDataOffline: VisitDataType[];
radarData: DataItem[];
};
export type GeographicType = {
province: {
label: string;
key: string;
};
city: {
label: string;
key: string;
};
};
export type NoticeType = {
id: string;
title: string;
logo: string;
description: string;
updatedAt: string;
member: string;
href: string;
memberLink: string;
};
export type 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 type Member = {
avatar: string;
name: string;
id: string;
};
export type ActivitiesType = {
id: string;
updatedAt: string;
user: {
name: string;
avatar: string;
};
group: {
name: string;
link: string;
};
project: {
name: string;
link: string;
};
template: string;
};
export type RadarDataType = {
label: string;
name: string;
value: number;
};

242
src/pages/dashboard/workplace/index.tsx

@ -0,0 +1,242 @@
import type { FC } from 'react';
import { Avatar, Card, Col, List, Skeleton, Row, Statistic } from 'antd';
import { Radar } from '@ant-design/charts';
import { Link, useRequest } from 'umi';
import { PageContainer } from '@ant-design/pro-layout';
import moment from 'moment';
import EditableLinkGroup from './components/EditableLinkGroup';
import styles from './style.less';
import type { ActivitiesType, CurrentUser } from './data.d';
import { queryProjectNotice, queryActivities, fakeChartData } from './service';
const links = [
{
title: '操作一',
href: '',
},
{
title: '操作二',
href: '',
},
{
title: '操作三',
href: '',
},
{
title: '操作四',
href: '',
},
{
title: '操作五',
href: '',
},
{
title: '操作六',
href: '',
},
];
const PageHeaderContent: FC<{ currentUser: Partial<CurrentUser> }> = ({ currentUser }) => {
const loading = currentUser && Object.keys(currentUser).length;
if (!loading) {
return <Skeleton avatar paragraph={{ rows: 1 }} active />;
}
return (
<div className={styles.pageHeaderContent}>
<div className={styles.avatar}>
<Avatar size="large" src={currentUser.avatar} />
</div>
<div className={styles.content}>
<div className={styles.contentTitle}>
{currentUser.name}
</div>
<div>
{currentUser.title} |{currentUser.group}
</div>
</div>
</div>
);
};
const ExtraContent: FC<Record<string, any>> = () => (
<div className={styles.extraContent}>
<div className={styles.statItem}>
<Statistic title="项目数" value={56} />
</div>
<div className={styles.statItem}>
<Statistic title="团队内排名" value={8} suffix="/ 24" />
</div>
<div className={styles.statItem}>
<Statistic title="项目访问" value={2223} />
</div>
</div>
);
const Workplace: FC = () => {
const { loading: projectLoading, data: projectNotice = [] } = useRequest(queryProjectNotice);
const { loading: activitiesLoading, data: activities = [] } = useRequest(queryActivities);
const { data } = useRequest(fakeChartData);
const renderActivities = (item: ActivitiesType) => {
const events = item.template.split(/@\{([^{}]*)\}/gi).map((key) => {
if (item[key]) {
return (
<a href={item[key].link} key={item[key].name}>
{item[key].name}
</a>
);
}
return key;
});
return (
<List.Item key={item.id}>
<List.Item.Meta
avatar={<Avatar src={item.user.avatar} />}
title={
<span>
<a className={styles.username}>{item.user.name}</a>
&nbsp;
<span className={styles.event}>{events}</span>
</span>
}
description={
<span className={styles.datetime} title={item.updatedAt}>
{moment(item.updatedAt).fromNow()}
</span>
}
/>
</List.Item>
);
};
return (
<PageContainer
content={
<PageHeaderContent
currentUser={{
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png',
name: '吴彦祖',
userid: '00000001',
email: 'antdesign@alipay.com',
signature: '海纳百川,有容乃大',
title: '交互专家',
group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED',
}}
/>
}
extraContent={<ExtraContent />}
>
<Row gutter={24}>
<Col xl={16} lg={24} md={24} sm={24} xs={24}>
<Card
className={styles.projectList}
style={{ marginBottom: 24 }}
title="进行中的项目"
bordered={false}
extra={<Link to="/"></Link>}
loading={projectLoading}
bodyStyle={{ padding: 0 }}
>
{projectNotice.map((item) => (
<Card.Grid className={styles.projectGrid} key={item.id}>
<Card bodyStyle={{ padding: 0 }} bordered={false}>
<Card.Meta
title={
<div className={styles.cardTitle}>
<Avatar size="small" src={item.logo} />
<Link to={item.href}>{item.title}</Link>
</div>
}
description={item.description}
/>
<div className={styles.projectItemContent}>
<Link to={item.memberLink}>{item.member || ''}</Link>
{item.updatedAt && (
<span className={styles.datetime} title={item.updatedAt}>
{moment(item.updatedAt).fromNow()}
</span>
)}
</div>
</Card>
</Card.Grid>
))}
</Card>
<Card
bodyStyle={{ padding: 0 }}
bordered={false}
className={styles.activeCard}
title="动态"
loading={activitiesLoading}
>
<List<ActivitiesType>
loading={activitiesLoading}
renderItem={(item) => renderActivities(item)}
dataSource={activities}
className={styles.activitiesList}
size="large"
/>
</Card>
</Col>
<Col xl={8} lg={24} md={24} sm={24} xs={24}>
<Card
style={{ marginBottom: 24 }}
title="快速开始 / 便捷导航"
bordered={false}
bodyStyle={{ padding: 0 }}
>
<EditableLinkGroup onAdd={() => {}} links={links} linkElement={Link} />
</Card>
<Card
style={{ marginBottom: 24 }}
bordered={false}
title="XX 指数"
loading={data?.radarData?.length === 0}
>
<div className={styles.chart}>
<Radar
height={343}
data={data?.radarData || []}
angleField="label"
seriesField="name"
radiusField="value"
area={{
visible: false,
}}
point={{
visible: true,
}}
legend={{
position: 'bottom-center',
}}
/>
</div>
</Card>
<Card
bodyStyle={{ paddingTop: 12, paddingBottom: 12 }}
bordered={false}
title="团队"
loading={projectLoading}
>
<div className={styles.members}>
<Row gutter={48}>
{projectNotice.map((item) => (
<Col span={12} key={`members-item-${item.id}`}>
<Link to={item.href}>
<Avatar src={item.logo} size="small" />
<span className={styles.member}>{item.member}</span>
</Link>
</Col>
))}
</Row>
</div>
</Card>
</Col>
</Row>
</PageContainer>
);
};
export default Workplace;

14
src/pages/dashboard/workplace/service.ts

@ -0,0 +1,14 @@
import { request } from 'umi';
import type { NoticeType, ActivitiesType, AnalysisData } from './data';
export async function queryProjectNotice(): Promise<{ data: NoticeType[] }> {
return request('/api/project/notice');
}
export async function queryActivities(): Promise<{ data: ActivitiesType[] }> {
return request('/api/activities');
}
export async function fakeChartData(): Promise<{ data: AnalysisData }> {
return request('/api/fake_workplace_chart_data');
}

250
src/pages/dashboard/workplace/style.less

@ -0,0 +1,250 @@
@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;
& > 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%;
}
}
}

7
src/pages/editor/flow/common/IconFont/index.ts

@ -0,0 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({
scriptUrl: 'https://at.alicdn.com/t/font_1101588_01zniftxm9yp.js',
});
export default IconFont;

34
src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx

@ -0,0 +1,34 @@
import { CanvasMenu, ContextMenu, EdgeMenu, GroupMenu, MultiMenu, NodeMenu } from 'gg-editor';
import MenuItem from './MenuItem';
import styles from './index.less';
const FlowContextMenu = () => (
<ContextMenu className={styles.contextMenu}>
<NodeMenu>
<MenuItem command="copy" />
<MenuItem command="delete" />
</NodeMenu>
<EdgeMenu>
<MenuItem command="delete" />
</EdgeMenu>
<GroupMenu>
<MenuItem command="copy" />
<MenuItem command="delete" />
<MenuItem command="unGroup" icon="ungroup" text="Ungroup" />
</GroupMenu>
<MultiMenu>
<MenuItem command="copy" />
<MenuItem command="paste" />
<MenuItem command="addGroup" icon="group" text="Add Group" />
<MenuItem command="delete" />
</MultiMenu>
<CanvasMenu>
<MenuItem command="undo" />
<MenuItem command="redo" />
<MenuItem command="pasteHere" icon="paste" text="Paste Here" />
</CanvasMenu>
</ContextMenu>
);
export default FlowContextMenu;

3
src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx

@ -0,0 +1,3 @@
import FlowContextMenu from './FlowContextMenu';
export default FlowContextMenu;

27
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());
type MenuItemProps = {
command: string;
icon?: string;
text?: string;
};
const MenuItem: React.FC<MenuItemProps> = (props) => {
const { command, icon, text } = props;
return (
<Command name={command}>
<div className={styles.item}>
<IconFont type={`icon-${icon || command}`} />
<span>{text || upperFirst(command)}</span>
</div>
</Command>
);
};
export default MenuItem;

22
src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx

@ -0,0 +1,22 @@
import { CanvasMenu, ContextMenu, NodeMenu } from 'gg-editor';
import MenuItem from './MenuItem';
import styles from './index.less';
const MindContextMenu = () => (
<ContextMenu className={styles.contextMenu}>
<NodeMenu>
<MenuItem command="append" text="Topic" />
<MenuItem command="appendChild" icon="append-child" text="Subtopic" />
<MenuItem command="collapse" text="Fold" />
<MenuItem command="expand" text="Unfold" />
<MenuItem command="delete" />
</NodeMenu>
<CanvasMenu>
<MenuItem command="undo" />
<MenuItem command="redo" />
</CanvasMenu>
</ContextMenu>
);
export default MindContextMenu;

41
src/pages/editor/flow/components/EditorContextMenu/index.less

@ -0,0 +1,41 @@
@import '~antd/es/style/themes/default.less';
.contextMenu {
display: none;
overflow: hidden;
background: @component-background;
border-radius: 4px;
box-shadow: @box-shadow-base;
.item {
display: flex;
align-items: center;
padding: 5px 12px;
cursor: pointer;
transition: all 0.3s;
user-select: none;
&:hover {
background: @select-item-selected-bg;
}
span.anticon {
margin-right: 8px;
}
}
:global {
.disable {
:local {
.item {
color: @disabled-color;
cursor: auto;
&:hover {
background: @item-hover-bg;
}
}
}
}
}
}

5
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 };

114
src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx

@ -0,0 +1,114 @@
import React from 'react';
import { Card, Input, Select, Form } from 'antd';
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 },
},
};
type DetailFormProps = {
type: string;
propsAPI?: any;
};
class DetailForm extends React.Component<DetailFormProps> {
get item() {
const { propsAPI } = this.props;
return propsAPI.getSelected()[0];
}
handleFieldChange = (values: any) => {
const { propsAPI } = this.props;
const { getSelected, executeCommand, update } = propsAPI;
setTimeout(() => {
const item = getSelected()[0];
if (!item) {
return;
}
executeCommand(() => {
update(item, {
...values,
});
});
}, 0);
};
handleInputBlur = (type: string) => (e: React.FormEvent<HTMLInputElement>) => {
e.preventDefault();
this.handleFieldChange({
[type]: e.currentTarget.value,
});
};
renderNodeDetail = () => {
const { label } = this.item.getModel();
return (
<Form initialValues={{ label }}>
<Item label="Label" name="label" {...inlineFormItemLayout}>
<Input onBlur={this.handleInputBlur('label')} />
</Item>
</Form>
);
};
renderEdgeDetail = () => {
const { label = '', shape = 'flow-smooth' } = this.item.getModel();
return (
<Form initialValues={{ label, shape }}>
<Item label="Label" name="label" {...inlineFormItemLayout}>
<Input onBlur={this.handleInputBlur('label')} />
</Item>
<Item label="Shape" name="shape" {...inlineFormItemLayout}>
<Select onChange={(value) => this.handleFieldChange({ shape: value })}>
<Option value="flow-smooth">Smooth</Option>
<Option value="flow-polyline">Polyline</Option>
<Option value="flow-polyline-round">Polyline Round</Option>
</Select>
</Item>
</Form>
);
};
renderGroupDetail = () => {
const { label = '新建分组' } = this.item.getModel();
return (
<Form initialValues={{ label }}>
<Item label="Label" name="label" {...inlineFormItemLayout}>
<Input onBlur={this.handleInputBlur('label')} />
</Item>
</Form>
);
};
render() {
const { type } = this.props;
if (!this.item) {
return null;
}
return (
<Card type="inner" size="small" title={upperFirst(type)} bordered={false}>
{type === 'node' && this.renderNodeDetail()}
{type === 'edge' && this.renderEdgeDetail()}
{type === 'group' && this.renderGroupDetail()}
</Card>
);
}
}
export default withPropsAPI(DetailForm as any);

27
src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx

@ -0,0 +1,27 @@
import { CanvasPanel, DetailPanel, EdgePanel, GroupPanel, MultiPanel, NodePanel } from 'gg-editor';
import { Card } from 'antd';
import DetailForm from './DetailForm';
import styles from './index.less';
const FlowDetailPanel = () => (
<DetailPanel className={styles.detailPanel}>
<NodePanel>
<DetailForm type="node" />
</NodePanel>
<EdgePanel>
<DetailForm type="edge" />
</EdgePanel>
<GroupPanel>
<DetailForm type="group" />
</GroupPanel>
<MultiPanel>
<Card type="inner" size="small" title="Multi Select" bordered={false} />
</MultiPanel>
<CanvasPanel>
<Card type="inner" size="small" title="Canvas" bordered={false} />
</CanvasPanel>
</DetailPanel>
);
export default FlowDetailPanel;

3
src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx

@ -0,0 +1,3 @@
import FlowDetailPanel from './FlowDetailPanel';
export default FlowDetailPanel;

18
src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx

@ -0,0 +1,18 @@
import { CanvasPanel, DetailPanel, NodePanel } from 'gg-editor';
import { Card } from 'antd';
import DetailForm from './DetailForm';
import styles from './index.less';
const MindDetailPanel = () => (
<DetailPanel className={styles.detailPanel}>
<NodePanel>
<DetailForm type="node" />
</NodePanel>
<CanvasPanel>
<Card type="inner" size="small" title="Canvas" bordered={false} />
</CanvasPanel>
</DetailPanel>
);
export default MindDetailPanel;

6
src/pages/editor/flow/components/EditorDetailPanel/index.less

@ -0,0 +1,6 @@
@import '~antd/es/style/themes/default.less';
.detailPanel {
flex: 1;
background-color: @component-background;
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save