Browse Source

Merge branch 'all-blocks' into master

- Add complete version with all blocks (dashboard, form, list, profile, result, exception, account)
- Add npm run simple script to simplify to minimal version
- Add routes.simple.ts for simple version route config
- Add extra dependencies: @ant-design/plots, @antv/l7, @antv/l7-react, numeral
- Update README.md with usage instructions

Default startup shows full version. Run `npm run simple` to convert to simple version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pull/11631/head
afc163 3 weeks ago
parent
commit
d6061104a2
  1. 29
      README.md
  2. 3
      biome.json
  3. 3
      config/config.ts
  4. 55
      config/routes.simple.ts
  5. 242
      config/routes.ts
  6. 210
      mock/analysis.mock.ts
  7. 38
      mock/listTableList.ts
  8. 6
      mock/notices.ts
  9. 63
      mock/requestRecord.mock.js
  10. 6
      mock/user.ts
  11. 418
      mock/workplace.mock.ts
  12. 14
      package.json
  13. 2
      public/scripts/loading.js
  14. 165
      scripts/simple.js
  15. 69
      src/pages/account/center/Center.style.ts
  16. 249
      src/pages/account/center/_mock.ts
  17. 43
      src/pages/account/center/components/Applications/index.style.ts
  18. 136
      src/pages/account/center/components/Applications/index.tsx
  19. 31
      src/pages/account/center/components/ArticleListContent/index.style.ts
  20. 29
      src/pages/account/center/components/ArticleListContent/index.tsx
  21. 14
      src/pages/account/center/components/Articles/index.style.ts
  22. 71
      src/pages/account/center/components/Articles/index.tsx
  23. 41
      src/pages/account/center/components/AvatarList/index.style.ts
  24. 89
      src/pages/account/center/components/AvatarList/index.tsx
  25. 49
      src/pages/account/center/components/Projects/index.style.ts
  26. 65
      src/pages/account/center/components/Projects/index.tsx
  27. 75
      src/pages/account/center/data.d.ts
  28. 278
      src/pages/account/center/index.tsx
  29. 14
      src/pages/account/center/service.ts
  30. 79
      src/pages/account/settings/_mock.ts
  31. 39
      src/pages/account/settings/components/PhoneView.tsx
  32. 239
      src/pages/account/settings/components/base.tsx
  33. 48
      src/pages/account/settings/components/binding.tsx
  34. 60
      src/pages/account/settings/components/index.style.ts
  35. 44
      src/pages/account/settings/components/notification.tsx
  36. 60
      src/pages/account/settings/components/security.tsx
  37. 43
      src/pages/account/settings/data.d.ts
  38. 1784
      src/pages/account/settings/geographic/city.json
  39. 138
      src/pages/account/settings/geographic/province.json
  40. 108
      src/pages/account/settings/index.tsx
  41. 20
      src/pages/account/settings/service.ts
  42. 74
      src/pages/account/settings/style.style.ts
  43. 210
      src/pages/dashboard/analysis/_mock.ts
  44. 75
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.less
  45. 77
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.style.ts
  46. 110
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx
  47. 17
      src/pages/dashboard/analysis/components/Charts/Field/index.less
  48. 22
      src/pages/dashboard/analysis/components/Charts/Field/index.style.ts
  49. 17
      src/pages/dashboard/analysis/components/Charts/Field/index.tsx
  50. 48
      src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx
  51. 225
      src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx
  52. 19
      src/pages/dashboard/analysis/components/Charts/index.less
  53. 23
      src/pages/dashboard/analysis/components/Charts/index.style.ts
  54. 13
      src/pages/dashboard/analysis/components/Charts/index.tsx
  55. 168
      src/pages/dashboard/analysis/components/IntroduceRow.tsx
  56. 68
      src/pages/dashboard/analysis/components/NumberInfo/index.less
  57. 56
      src/pages/dashboard/analysis/components/NumberInfo/index.style.ts
  58. 79
      src/pages/dashboard/analysis/components/NumberInfo/index.tsx
  59. 110
      src/pages/dashboard/analysis/components/OfflineData.tsx
  60. 9
      src/pages/dashboard/analysis/components/PageLoading/index.tsx
  61. 67
      src/pages/dashboard/analysis/components/ProportionSales.tsx
  62. 225
      src/pages/dashboard/analysis/components/SalesCard.tsx
  63. 181
      src/pages/dashboard/analysis/components/TopSearch.tsx
  64. 37
      src/pages/dashboard/analysis/components/Trend/index.less
  65. 32
      src/pages/dashboard/analysis/components/Trend/index.style.ts
  66. 47
      src/pages/dashboard/analysis/components/Trend/index.tsx
  67. 45
      src/pages/dashboard/analysis/data.d.ts
  68. 157
      src/pages/dashboard/analysis/index.tsx
  69. 6
      src/pages/dashboard/analysis/service.ts
  70. 189
      src/pages/dashboard/analysis/style.less
  71. 160
      src/pages/dashboard/analysis/style.style.ts
  72. 33
      src/pages/dashboard/analysis/utils/Yuan.tsx
  73. 57
      src/pages/dashboard/analysis/utils/utils.ts
  74. 14
      src/pages/dashboard/monitor/_mock.ts
  75. 51
      src/pages/dashboard/monitor/components/ActiveChart/index.less
  76. 48
      src/pages/dashboard/monitor/components/ActiveChart/index.style.ts
  77. 93
      src/pages/dashboard/monitor/components/ActiveChart/index.tsx
  78. 225
      src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx
  79. 78
      src/pages/dashboard/monitor/components/Charts/autoHeight.tsx
  80. 153
      src/pages/dashboard/monitor/components/Map/index.tsx
  81. 5
      src/pages/dashboard/monitor/data.d.ts
  82. 203
      src/pages/dashboard/monitor/index.tsx
  83. 6
      src/pages/dashboard/monitor/service.ts
  84. 21
      src/pages/dashboard/monitor/style.less
  85. 16
      src/pages/dashboard/monitor/style.style.ts
  86. 410
      src/pages/dashboard/workplace/_mock.ts
  87. 16
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.less
  88. 21
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.style.ts
  89. 38
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx
  90. 111
      src/pages/dashboard/workplace/data.d.ts
  91. 286
      src/pages/dashboard/workplace/index.tsx
  92. 14
      src/pages/dashboard/workplace/service.ts
  93. 251
      src/pages/dashboard/workplace/style.less
  94. 215
      src/pages/dashboard/workplace/style.style.ts
  95. 17
      src/pages/exception/403/index.tsx
  96. 17
      src/pages/exception/404/index.tsx
  97. 17
      src/pages/exception/500/index.tsx
  98. 7
      src/pages/form/advanced-form/_mock.ts
  99. 268
      src/pages/form/advanced-form/components/TableForm.tsx
  100. 549
      src/pages/form/advanced-form/index.tsx

29
README.md

@ -92,6 +92,35 @@ Choose the pro template. Simple is the basic template, which only provides the b
complete complete
``` ```
### Development
```bash
# Install dependencies
npm install
# Start development server (full version by default)
npm start
# Build for production
npm run build
```
### Simplify to Simple Version
If you want to simplify the project to a minimal version (removing extra blocks), run:
```bash
npm run simple
```
This will:
- Remove extra page directories (dashboard, form, list/*, profile, result, exception, account, etc.)
- Remove extra mock files
- Replace routes with simple version
- Remove extra dependencies from package.json
**Note**: This operation is irreversible and will permanently delete files.
Initialized Git repository: Initialized Git repository:
```shell ```shell

3
biome.json

@ -27,7 +27,8 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"suspicious": { "suspicious": {
"noExplicitAny": "off" "noExplicitAny": "off",
"noUnknownAtRules": "off"
}, },
"correctness": { "correctness": {
"useUniqueElementIds": "off", "useUniqueElementIds": "off",

3
config/config.ts

@ -143,6 +143,7 @@ export default defineConfig({
// 解决首次加载时白屏的问题 // 解决首次加载时白屏的问题
{ src: join(PUBLIC_PATH, 'scripts/loading.js'), async: true }, { src: join(PUBLIC_PATH, 'scripts/loading.js'), async: true },
], ],
//================ pro 插件配置 ================= //================ pro 插件配置 =================
presets: ['umi-presets-pro'], presets: ['umi-presets-pro'],
/** /**
@ -165,6 +166,7 @@ export default defineConfig({
projectName: 'swagger', projectName: 'swagger',
}, },
], ],
mock: { mock: {
include: ['mock/**/*', 'src/pages/**/_mock.ts'], include: ['mock/**/*', 'src/pages/**/_mock.ts'],
}, },
@ -174,4 +176,5 @@ export default defineConfig({
define: { define: {
'process.env.CI': process.env.CI, 'process.env.CI': process.env.CI,
}, },
tailwindcss: {},
}); });

55
config/routes.simple.ts

@ -0,0 +1,55 @@
/**
* @name
* @description npm run simple 使
*/
export default [
{
path: '/user',
layout: false,
routes: [
{
name: 'login',
path: '/user/login',
component: './user/login',
},
],
},
{
path: '/welcome',
name: 'welcome',
icon: 'smile',
component: './Welcome',
},
{
path: '/admin',
name: 'admin',
icon: 'crown',
access: 'canAdmin',
routes: [
{
path: '/admin',
redirect: '/admin/sub-page',
},
{
path: '/admin/sub-page',
name: 'sub-page',
component: './Admin',
},
],
},
{
name: 'list.table-list',
icon: 'table',
path: '/list',
component: './table-list',
},
{
path: '/',
redirect: '/welcome',
},
{
component: '404',
layout: false,
path: './*',
},
];

242
config/routes.ts

@ -1,4 +1,4 @@
/** /**
* @name umi * @name umi
* @description path,component,routes,redirect,wrappers,name,icon * @description path,component,routes,redirect,wrappers,name,icon
* @param path path 第一种是动态参数 :id * * @param path path 第一种是动态参数 :id *
@ -16,10 +16,31 @@ export default [
layout: false, layout: false,
routes: [ routes: [
{ {
name: 'login',
path: '/user/login', path: '/user/login',
layout: false,
name: 'login',
component: './user/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: '/user/*',
},
], ],
}, },
{ {
@ -46,18 +67,225 @@ export default [
], ],
}, },
{ {
name: 'list.table-list', path: '/dashboard',
icon: 'table', 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', path: '/list',
component: './table-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: './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',
},
],
}, },
{ {
path: '/', path: '/',
redirect: '/welcome', redirect: '/dashboard/analysis',
}, },
{ {
component: '404', component: '404',
layout: false,
path: './*', path: './*',
}, },
]; ];

210
mock/analysis.mock.ts

@ -0,0 +1,210 @@
import dayjs from 'dayjs';
import type { Request, Response } from 'express';
import type { AnalysisData, DataItem, RadarData } from '../src/pages/dashboard/analysis/data';
// 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: dayjs(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: dayjs(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 = dayjs(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 as 'ref'],
value: item[key as 'ref'],
});
}
});
});
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,
};

38
mock/listTableList.ts

@ -34,10 +34,7 @@ let tableListDataSource = genList(1, 100);
function getRule(req: Request, res: Response, u: string) { function getRule(req: Request, res: Response, u: string) {
let realUrl = u; let realUrl = u;
if ( if (!realUrl || Object.prototype.toString.call(realUrl) !== '[object String]') {
!realUrl ||
Object.prototype.toString.call(realUrl) !== '[object String]'
) {
realUrl = req.url; realUrl = req.url;
} }
const { current = 1, pageSize = 10 } = req.query; const { current = 1, pageSize = 10 } = req.query;
@ -81,25 +78,21 @@ function getRule(req: Request, res: Response, u: string) {
}; };
if (Object.keys(filter).length > 0) { if (Object.keys(filter).length > 0) {
dataSource = dataSource.filter((item) => { dataSource = dataSource.filter((item) => {
return (Object.keys(filter) as Array<keyof API.RuleListItem>).some( return (Object.keys(filter) as Array<keyof API.RuleListItem>).some((key) => {
(key) => { if (!filter[key]) {
if (!filter[key]) { return true;
return true; }
} if (filter[key].includes(`${item[key]}`)) {
if (filter[key].includes(`${item[key]}`)) { return true;
return true; }
} return false;
return false; });
},
);
}); });
} }
} }
if (params.name) { if (params.name) {
dataSource = dataSource.filter((data) => dataSource = dataSource.filter((data) => data?.name?.includes(params.name || ''));
data?.name?.includes(params.name || ''),
);
} }
const result = { const result = {
data: dataSource, data: dataSource,
@ -114,10 +107,7 @@ function getRule(req: Request, res: Response, u: string) {
function postRule(req: Request, res: Response, u: string, b: Request) { function postRule(req: Request, res: Response, u: string, b: Request) {
let realUrl = u; let realUrl = u;
if ( if (!realUrl || Object.prototype.toString.call(realUrl) !== '[object String]') {
!realUrl ||
Object.prototype.toString.call(realUrl) !== '[object String]'
) {
realUrl = req.url; realUrl = req.url;
} }
@ -126,9 +116,7 @@ function postRule(req: Request, res: Response, u: string, b: Request) {
switch (method) { switch (method) {
case 'delete': case 'delete':
tableListDataSource = tableListDataSource.filter( tableListDataSource = tableListDataSource.filter((item) => key.indexOf(item.key) === -1);
(item) => key.indexOf(item.key) === -1,
);
break; break;
case 'post': case 'post':
(() => { (() => {

6
mock/notices.ts

@ -85,8 +85,7 @@ const getNotices = (_req: Request, res: Response) => {
{ {
id: '000000010', id: '000000010',
title: '第三方紧急代码变更', title: '第三方紧急代码变更',
description: description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
'冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
extra: '马上到期', extra: '马上到期',
status: 'urgent', status: 'urgent',
type: 'event', type: 'event',
@ -102,8 +101,7 @@ const getNotices = (_req: Request, res: Response) => {
{ {
id: '000000012', id: '000000012',
title: 'ABCD 版本发布', title: 'ABCD 版本发布',
description: description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
'冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
extra: '进行中', extra: '进行中',
status: 'processing', status: 'processing',
type: 'event', type: 'event',

63
mock/requestRecord.mock.js

@ -2,8 +2,7 @@ module.exports = {
'GET /api/currentUser': { 'GET /api/currentUser': {
data: { data: {
name: 'Serati Ma', name: 'Serati Ma',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png',
'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png',
userid: '00000001', userid: '00000001',
email: 'antdesign@alipay.com', email: 'antdesign@alipay.com',
signature: '海纳百川,有容乃大', signature: '海纳百川,有容乃大',
@ -34,8 +33,7 @@ module.exports = {
key: 99, key: 99,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 99', name: 'TradeCode 99',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -49,8 +47,7 @@ module.exports = {
key: 98, key: 98,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 98', name: 'TradeCode 98',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -64,8 +61,7 @@ module.exports = {
key: 97, key: 97,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 97', name: 'TradeCode 97',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -79,8 +75,7 @@ module.exports = {
key: 96, key: 96,
disabled: true, disabled: true,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 96', name: 'TradeCode 96',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -94,8 +89,7 @@ module.exports = {
key: 95, key: 95,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 95', name: 'TradeCode 95',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -109,8 +103,7 @@ module.exports = {
key: 94, key: 94,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 94', name: 'TradeCode 94',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -124,8 +117,7 @@ module.exports = {
key: 93, key: 93,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 93', name: 'TradeCode 93',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -139,8 +131,7 @@ module.exports = {
key: 92, key: 92,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 92', name: 'TradeCode 92',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -154,8 +145,7 @@ module.exports = {
key: 91, key: 91,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 91', name: 'TradeCode 91',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -169,8 +159,7 @@ module.exports = {
key: 90, key: 90,
disabled: true, disabled: true,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 90', name: 'TradeCode 90',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -184,8 +173,7 @@ module.exports = {
key: 89, key: 89,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 89', name: 'TradeCode 89',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -199,8 +187,7 @@ module.exports = {
key: 88, key: 88,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 88', name: 'TradeCode 88',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -214,8 +201,7 @@ module.exports = {
key: 87, key: 87,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 87', name: 'TradeCode 87',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -229,8 +215,7 @@ module.exports = {
key: 86, key: 86,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 86', name: 'TradeCode 86',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -244,8 +229,7 @@ module.exports = {
key: 85, key: 85,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 85', name: 'TradeCode 85',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -259,8 +243,7 @@ module.exports = {
key: 84, key: 84,
disabled: true, disabled: true,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 84', name: 'TradeCode 84',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -274,8 +257,7 @@ module.exports = {
key: 83, key: 83,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 83', name: 'TradeCode 83',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -289,8 +271,7 @@ module.exports = {
key: 82, key: 82,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 82', name: 'TradeCode 82',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -304,8 +285,7 @@ module.exports = {
key: 81, key: 81,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
name: 'TradeCode 81', name: 'TradeCode 81',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',
@ -319,8 +299,7 @@ module.exports = {
key: 80, key: 80,
disabled: false, disabled: false,
href: 'https://ant.design', href: 'https://ant.design',
avatar: avatar: 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
name: 'TradeCode 80', name: 'TradeCode 80',
owner: '曲丽丽', owner: '曲丽丽',
desc: '这是一段描述', desc: '这是一段描述',

6
mock/user.ts

@ -20,8 +20,7 @@ const { ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION } = process.env;
* current user access if is '', user need login * current user access if is '', user need login
* pro * pro
*/ */
let access = let access = ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION === 'site' ? 'admin' : '';
ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION === 'site' ? 'admin' : '';
const getAccess = () => { const getAccess = () => {
return access; return access;
@ -46,8 +45,7 @@ export default {
success: true, success: true,
data: { data: {
name: 'Serati Ma', name: 'Serati Ma',
avatar: avatar: 'https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png',
'https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png',
userid: '00000001', userid: '00000001',
email: 'antdesign@alipay.com', email: 'antdesign@alipay.com',
signature: '海纳百川,有容乃大', signature: '海纳百川,有容乃大',

418
mock/workplace.mock.ts

@ -0,0 +1,418 @@
import dayjs from 'dayjs';
import type { Request, Response } from 'express';
import type { DataItem, OfflineDataType } from '../src/pages/dashboard/workplace/data.d';
export type SearchDataType = {
index: number;
keyword: string;
count: number;
range: number;
status: number;
};
// 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: dayjs(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: dayjs(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 as 'ref'],
value: item[key as 'ref'],
});
}
});
});
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,
};

14
package.json

@ -16,11 +16,13 @@
"lint": "npm run biome:lint && npm run tsc", "lint": "npm run biome:lint && npm run tsc",
"lint-staged": "lint-staged", "lint-staged": "lint-staged",
"biome:lint": "npx @biomejs/biome lint", "biome:lint": "npx @biomejs/biome lint",
"biome": "biome check --write",
"openapi": "max openapi", "openapi": "max openapi",
"prepare": "husky", "prepare": "husky",
"preview": "npm run build && max preview --port 8000", "preview": "npm run build && max preview --port 8000",
"record": "cross-env NODE_ENV=development UMI_ENV=test max record --scene=login", "record": "cross-env NODE_ENV=development UMI_ENV=test max record --scene=login",
"serve": "umi-serve", "serve": "umi-serve",
"simple": "node scripts/simple.js",
"start": "cross-env UMI_ENV=dev max dev", "start": "cross-env UMI_ENV=dev max dev",
"start:dev": "cross-env UMI_ENV=dev MOCK=none max dev", "start:dev": "cross-env UMI_ENV=dev MOCK=none max dev",
"start:no-mock": "cross-env MOCK=none max dev", "start:no-mock": "cross-env MOCK=none max dev",
@ -36,11 +38,15 @@
], ],
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@ant-design/plots": "^2.6.0",
"@ant-design/pro-components": "3.1.2-0", "@ant-design/pro-components": "3.1.2-0",
"@antv/l7": "^2.22.7",
"@antv/l7-react": "^2.4.3",
"antd": "^6.2.2", "antd": "^6.2.2",
"antd-style": "^4.1.0", "antd-style": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"numeral": "^2.0.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
@ -52,11 +58,14 @@
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^25.0.3",
"@types/numeral": "^2.0.5",
"@types/react": "^19.2.10", "@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-helmet": "^6.1.11", "@types/react-helmet": "^6.1.11",
"@umijs/lint": "^4.6.13", "@umijs/lint": "^4.6.13",
"@umijs/max": "^4.6.25", "@umijs/max": "^4.6.25",
"@biomejs/biome": "^2.1.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"express": "^5.2.0", "express": "^5.2.0",
"gh-pages": "^6.1.1", "gh-pages": "^6.1.1",
@ -65,12 +74,11 @@
"jest-environment-jsdom": "^30.0.5", "jest-environment-jsdom": "^30.0.5",
"lint-staged": "^16.1.2", "lint-staged": "^16.1.2",
"mockjs": "^1.1.0", "mockjs": "^1.1.0",
"tailwindcss": "^3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"umi-presets-pro": "^2.0.3", "umi-presets-pro": "^2.0.3",
"umi-serve": "^1.9.11", "umi-serve": "^1.9.11"
"@biomejs/biome": "^2.1.1",
"@types/node": "^25.0.3"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

2
public/scripts/loading.js

@ -2,7 +2,7 @@
* loading 占位 * loading 占位
* 解决首次加载时白屏的问题 * 解决首次加载时白屏的问题
*/ */
(function () { (() => {
const _root = document.querySelector('#root'); const _root = document.querySelector('#root');
if (_root && _root.innerHTML === '') { if (_root && _root.innerHTML === '') {
_root.innerHTML = ` _root.innerHTML = `

165
scripts/simple.js

@ -0,0 +1,165 @@
/**
* 精简脚本 - 将完整版精简为简单版
* 执行 npm run simple 运行此脚本
*
* 此操作不可逆会删除以下内容
* - 页面目录dashboard, form, list/basic-list, list/card-list, list/search, profile, result, exception, account, user/register, user/register-result
* - Mock 文件analysis.mock.ts, workplace.mock.ts
* - 替换路由配置为简单版
*/
const fs = require('fs');
const path = require('path');
// 需要删除的页面目录
const pageDirsToDelete = [
'src/pages/dashboard',
'src/pages/form',
'src/pages/list/basic-list',
'src/pages/list/card-list',
'src/pages/list/search',
'src/pages/profile',
'src/pages/result',
'src/pages/exception',
'src/pages/account',
'src/pages/user/register',
'src/pages/user/register-result',
];
// 需要删除的 mock 文件
const mockFilesToDelete = ['mock/analysis.mock.ts', 'mock/workplace.mock.ts'];
// 需要从 package.json 移除的依赖
const depsToRemove = [
'@ant-design/plots',
'@antv/l7-react',
'@antv/l7',
'numeral',
];
const devDepsToRemove = ['@types/numeral'];
// 递归删除目录
function deleteDir(dirPath) {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
console.log(`✓ 已删除目录: ${dirPath}`);
} else {
console.log(`- 目录不存在,跳过: ${dirPath}`);
}
}
// 删除文件
function deleteFile(filePath) {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
console.log(`✓ 已删除文件: ${filePath}`);
} else {
console.log(`- 文件不存在,跳过: ${filePath}`);
}
}
// 替换路由配置
function replaceRoutes() {
const simpleRoutesPath = 'config/routes.simple.ts';
const routesPath = 'config/routes.ts';
if (fs.existsSync(simpleRoutesPath)) {
// 读取简单版路由
const simpleRoutes = fs.readFileSync(simpleRoutesPath, 'utf-8');
// 写入到 routes.ts
fs.writeFileSync(routesPath, simpleRoutes);
console.log(`✓ 已替换路由配置: ${routesPath}`);
// 删除简单版路由备份文件
fs.unlinkSync(simpleRoutesPath);
console.log(`✓ 已删除备份文件: ${simpleRoutesPath}`);
} else {
console.log(`- 简单版路由配置不存在,跳过: ${simpleRoutesPath}`);
}
}
// 更新 package.json
function updatePackageJson() {
const pkgPath = 'package.json';
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
let modified = false;
// 移除 dependencies
if (pkg.dependencies) {
for (const dep of depsToRemove) {
if (pkg.dependencies[dep]) {
delete pkg.dependencies[dep];
console.log(`✓ 已移除依赖: ${dep}`);
modified = true;
}
}
}
// 移除 devDependencies
if (pkg.devDependencies) {
for (const dep of devDepsToRemove) {
if (pkg.devDependencies[dep]) {
delete pkg.devDependencies[dep];
console.log(`✓ 已移除开发依赖: ${dep}`);
modified = true;
}
}
}
// 移除 simple 脚本
if (pkg.scripts && pkg.scripts.simple) {
delete pkg.scripts.simple;
console.log('✓ 已移除 simple 脚本');
modified = true;
}
if (modified) {
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log('✓ 已更新 package.json');
} else {
console.log('- package.json 无需更新');
}
}
// 主函数
function main() {
console.log('========================================');
console.log(' 开始执行精简脚本');
console.log('========================================\n');
console.log('>>> 删除页面目录...');
for (const dir of pageDirsToDelete) {
deleteDir(dir);
}
console.log('\n>>> 删除 mock 文件...');
for (const file of mockFilesToDelete) {
deleteFile(file);
}
console.log('\n>>> 替换路由配置...');
replaceRoutes();
console.log('\n>>> 更新 package.json...');
updatePackageJson();
// 删除自身
console.log('\n>>> 清理精简脚本...');
fs.unlinkSync(__filename);
console.log('✓ 已删除 scripts/simple.js');
// 尝试删除 scripts 目录(如果为空)
const scriptsDir = path.dirname(__filename);
if (fs.readdirSync(scriptsDir).length === 0) {
fs.rmdirSync(scriptsDir);
console.log('✓ 已删除空的 scripts 目录');
}
console.log('\n========================================');
console.log(' 精简完成!');
console.log(' 请运行 npm install 更新依赖');
console.log('========================================');
}
main();

69
src/pages/account/center/Center.style.ts

@ -0,0 +1,69 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
avatarHolder: {
marginBottom: '24px',
textAlign: 'center',
'& > img': { width: '104px', height: '104px', marginBottom: '20px' },
},
name: {
marginBottom: '4px',
color: token.colorTextHeading,
fontWeight: '500',
fontSize: '20px',
lineHeight: '28px',
},
detail: {
p: {
position: 'relative',
marginBottom: '8px',
paddingLeft: '26px',
'&:last-child': {
marginBottom: '0',
},
},
i: {
position: 'absolute',
top: '4px',
left: '0',
width: '14px',
height: '14px',
},
},
tagsTitle: {
marginBottom: '12px',
color: token.colorTextHeading,
fontWeight: '500',
},
teamTitle: {
marginBottom: '12px',
color: token.colorTextHeading,
fontWeight: '500',
},
tags: {
'.ant-tag': { marginBottom: '8px' },
},
team: {
'.ant-avatar': { marginRight: '12px' },
a: {
display: 'block',
marginBottom: '24px',
overflow: 'hidden',
color: token.colorText,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
transition: 'color 0.3s',
'&:hover': {
color: token.colorPrimary,
},
},
},
tabsCard: {
'.ant-card-head': { padding: '0 16px' },
},
};
});
export default useStyles;

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

@ -0,0 +1,249 @@
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(Date.now() - 1000 * 60 * 60 * 2 * i).getTime(),
createdAt: new Date(Date.now() - 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,
};

43
src/pages/account/center/components/Applications/index.style.ts

@ -0,0 +1,43 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
filterCardList: {
marginBottom: '-24px',
'.ant-card-meta-content': { marginTop: '0' },
'.ant-card-meta-avatar': { fontSize: '0' },
'.ant-list .ant-list-item-content-single': { maxWidth: '100%' },
},
cardInfo: {
marginTop: '16px',
marginLeft: '40px',
zoom: '1',
'&::before, &::after': { display: 'table', content: "' '" },
'&::after': {
clear: 'both',
height: '0',
fontSize: '0',
visibility: 'hidden',
},
'& > div': {
position: 'relative',
float: 'left',
width: '50%',
textAlign: 'left',
p: {
margin: '0',
fontSize: '24px',
lineHeight: '32px',
},
'p:first-child': {
marginBottom: '4px',
color: token.colorTextSecondary,
fontSize: '12px',
lineHeight: '20px',
},
},
},
};
});
export default useStyles;

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

@ -0,0 +1,136 @@
import {
DownloadOutlined,
EditOutlined,
EllipsisOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import { useRequest } from '@umijs/max';
import { Avatar, Card, Dropdown, List, Tooltip } from 'antd';
import numeral from 'numeral';
import React from 'react';
import type { ListItemDataType } from '../../data.d';
import { queryFakeList } from '../../service';
import useStyles from './index.style';
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 CardInfo: React.FC<{
activeUser: React.ReactNode;
newUser: React.ReactNode;
}> = ({ activeUser, newUser }) => {
const { styles: stylesApplications } = useStyles();
return (
<div className={stylesApplications.cardInfo}>
<div>
<p></p>
<p>{activeUser}</p>
</div>
<div>
<p></p>
<p>{newUser}</p>
</div>
</div>
);
};
const Applications: React.FC = () => {
const { styles: stylesApplications } = useStyles();
// 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
});
});
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
styles={{
body: {
paddingBottom: 20,
},
}}
actions={[
<Tooltip key="download" title="下载">
<DownloadOutlined />
</Tooltip>,
<Tooltip title="编辑" key="edit">
<EditOutlined />
</Tooltip>,
<Tooltip title="分享" key="share">
<ShareAltOutlined />
</Tooltip>,
<Dropdown
menu={{
items: [
{
key: '1',
title: '1st menu item',
},
{
key: '2',
title: '2nd menu item',
},
],
}}
key="ellipsis"
>
<EllipsisOutlined />
</Dropdown>,
]}
>
<Card.Meta
avatar={<Avatar size="small" src={item.avatar} />}
title={item.title}
/>
<div>
<CardInfo
activeUser={formatWan(item.activeUser)}
newUser={numeral(item.newUser).format('0,0')}
/>
</div>
</Card>
</List.Item>
)}
/>
);
};
export default Applications;

31
src/pages/account/center/components/ArticleListContent/index.style.ts

@ -0,0 +1,31 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
description: {
maxWidth: '720px',
lineHeight: '22px',
},
extra: {
marginTop: '16px',
color: token.colorTextSecondary,
lineHeight: '22px',
display: 'flex',
gap: '8px',
alignItems: 'center',
'& > em': {
color: token.colorTextDisabled,
fontStyle: 'normal',
},
[`@media screen and (max-width: ${token.screenXS}px)`]: {
'& > em': {
display: 'block',
marginTop: '8px',
marginLeft: '0',
},
},
},
};
});
export default useStyles;

29
src/pages/account/center/components/ArticleListContent/index.tsx

@ -0,0 +1,29 @@
import { Avatar } from 'antd';
import dayjs from 'dayjs';
import React from 'react';
import useStyles from './index.style';
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 },
}) => {
const { styles } = useStyles();
return (
<div>
<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>{dayjs(updatedAt).format('YYYY-MM-DD HH:mm')}</em>
</div>
</div>
);
};
export default ArticleListContent;

14
src/pages/account/center/components/Articles/index.style.ts

@ -0,0 +1,14 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
articleList: {
'.ant-list-item:first-child': { paddingTop: '0' },
},
listItemMetaTitle: {
color: token.colorTextHeading,
},
};
});
export default useStyles;

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

@ -0,0 +1,71 @@
import { LikeOutlined, MessageFilled, StarTwoTone } from '@ant-design/icons';
import { useRequest } from '@umijs/max';
import { List, Tag } from 'antd';
import React from 'react';
import type { ListItemDataType } from '../../data.d';
import { queryFakeList } from '../../service';
import ArticleListContent from '../ArticleListContent';
import useStyles from './index.style';
const IconText: React.FC<{
icon: React.ReactNode;
text: React.ReactNode;
}> = ({ icon, text }) => (
<span>
{icon} {text}
</span>
);
const Articles: React.FC = () => {
const { styles } = useStyles();
// 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
});
});
return (
<List<ListItemDataType>
size="large"
className={styles.articleList}
rowKey="id"
itemLayout="vertical"
dataSource={listData?.list || []}
style={{
margin: '0 -24px',
}}
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;

41
src/pages/account/center/components/AvatarList/index.style.ts

@ -0,0 +1,41 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
avatarList: {
display: 'inline-block',
ul: { display: 'inline-block', marginLeft: '8px', fontSize: '0' },
},
avatarItem: {
display: 'inline-block',
width: token.controlHeight,
height: token.controlHeight,
marginLeft: '-8px',
fontSize: token.fontSize,
'.ant-avatar': { border: `1px solid ${token.colorBorder}` },
},
avatarItemLarge: {
width: token.controlHeightLG,
height: token.controlHeightLG,
},
avatarItemSmall: {
width: token.controlHeightSM,
height: token.controlHeightSM,
},
avatarItemMini: {
width: '20px',
height: '20px',
'.ant-avatar': {
width: '20px',
height: '20px',
lineHeight: '20px',
'.ant-avatar-string': {
fontSize: '12px',
lineHeight: '18px',
},
},
},
};
});
export default useStyles;

89
src/pages/account/center/components/AvatarList/index.tsx

@ -0,0 +1,89 @@
import { Avatar, Tooltip } from 'antd';
import classNames from 'classnames';
import React from 'react';
import useStyles from './index.style';
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 = (styles: any, 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 { styles } = useStyles();
const cls = avatarSizeToClassName(styles, 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 { styles } = useStyles();
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(styles, 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;

49
src/pages/account/center/components/Projects/index.style.ts

@ -0,0 +1,49 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
card: {
'.ant-card-meta-title': {
marginBottom: '4px',
'& > a': {
display: 'inline-block',
maxWidth: '100%',
color: token.colorTextHeading,
},
},
'.ant-card-meta-description': {
height: '44px',
overflow: 'hidden',
lineHeight: '22px',
},
'&:hover': {
'.ant-card-meta-title > a': {
color: token.colorPrimary,
},
},
},
cardItemContent: {
display: 'flex',
height: '20px',
marginTop: '16px',
marginBottom: '-4px',
lineHeight: '20px',
'& > span': {
flex: '1',
color: token.colorTextSecondary,
fontSize: '12px',
},
},
avatarList: {
flex: '0 1 auto',
},
cardList: {
marginTop: '24px',
},
coverCardList: {
'.ant-list .ant-list-item-content-single': { maxWidth: '100%' },
},
};
});
export default useStyles;

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

@ -0,0 +1,65 @@
import { useRequest } from '@umijs/max';
import { Card, List } from 'antd';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import React from 'react';
import type { ListItemDataType } from '../../data.d';
import { queryFakeList } from '../../service';
import AvatarList from '../AvatarList';
import useStyles from './index.style';
dayjs.extend(relativeTime);
const Projects: React.FC = () => {
const { styles } = useStyles();
// 获取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>{dayjs(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[];
};

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

@ -0,0 +1,278 @@
import {
ClusterOutlined,
ContactsOutlined,
HomeOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { GridContent } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import {
Avatar,
Card,
Col,
Divider,
Input,
type InputRef,
Row,
Tag,
} from 'antd';
import React, { useRef, useState } from 'react';
import useStyles from './Center.style';
import Applications from './components/Applications';
import Articles from './components/Articles';
import Projects from './components/Projects';
import type { CurrentUser, TagType, tabKeyType } from './data.d';
import { queryCurrent } from './service';
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 { styles } = useStyles();
const ref = useRef<InputRef | 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}
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 = () => {
const { styles } = useStyles();
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
variant="borderless"
style={{
marginBottom: 24,
}}
loading={loading}
>
{!loading && currentUser && (
<>
<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?.map((item) => (
<Col key={item.id} lg={24} xl={12}>
<a href={item.href}>
<Avatar size="small" src={item.logo} />
{item.member}
</a>
</Col>
))}
</Row>
</div>
</>
)}
</Card>
</Col>
<Col lg={17} md={24}>
<Card
className={styles.tabsCard}
variant="borderless"
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 '@umijs/max';
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 @@
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,
};

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

@ -0,0 +1,39 @@
import { Input } from 'antd';
import React from 'react';
import useStyles from './index.style';
type PhoneViewProps = {
value?: string;
onChange?: (value: string) => void;
};
const PhoneView: React.FC<PhoneViewProps> = (props) => {
const { styles } = useStyles();
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;

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

@ -0,0 +1,239 @@
import { UploadOutlined } from '@ant-design/icons';
import {
ProForm,
ProFormDependency,
ProFormFieldSet,
ProFormSelect,
ProFormText,
ProFormTextArea,
} from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import { Button, Input, message, Upload } from 'antd';
import React from 'react';
import { queryCity, queryCurrent, queryProvince } from '../service';
import useStyles from './index.style';
const validatorPhone = (
_rule: any,
value: string[],
callback: (message?: string) => void,
) => {
if (!value[0]) {
callback('Please input your area code!');
}
if (!value[1]) {
callback('Please input your phone number!');
}
callback();
};
const BaseView: React.FC = () => {
const { styles } = useStyles();
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={{
searchConfig: {
submitText: '更新基本信息',
},
render: (_, dom) => dom[1],
}}
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"
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}
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;
const AvatarView = ({ avatar }: { avatar: string }) => {
const { styles } = useStyles();
return (
<>
<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>
</>
);
};

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

@ -0,0 +1,48 @@
import {
AlipayOutlined,
DingdingOutlined,
TaobaoOutlined,
} from '@ant-design/icons';
import { List } from 'antd';
import React 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 (
<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>
)}
/>
);
};
export default BindingView;

60
src/pages/account/settings/components/index.style.ts

@ -0,0 +1,60 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
baseView: {
display: 'flex',
paddingTop: '12px',
'.ant-legacy-form-item .ant-legacy-form-item-control-wrapper': {
width: '100%',
},
[`@media screen and (max-width: ${token.screenXL}px)`]: {
flexDirection: 'column-reverse',
},
},
left: {
minWidth: '224px',
maxWidth: '448px',
},
right: {
flex: '1',
paddingLeft: '104px',
[`@media screen and (max-width: ${token.screenXL}px)`]: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: '448px',
padding: '20px',
},
},
avatar_title: {
height: '22px',
marginBottom: '8px',
color: token.colorTextHeading,
fontSize: token.fontSize,
lineHeight: '22px',
[`@media screen and (max-width: ${token.screenXL}px)`]: {
display: 'none',
},
},
avatar: {
width: '144px',
height: '144px',
marginBottom: '12px',
overflow: 'hidden',
img: { width: '100%' },
},
button_view: {
width: '144px',
textAlign: 'center',
},
area_code: {
width: '72px',
},
phone_number: {
width: '214px',
},
};
});
export default useStyles;

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

@ -0,0 +1,44 @@
import { List, Switch } from 'antd';
import React 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 (
<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 NotificationView;

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

@ -0,0 +1,60 @@
import { List } from 'antd';
import React from 'react';
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"
}
]

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

@ -0,0 +1,108 @@
import { GridContent } from '@ant-design/pro-components';
import { Menu } from 'antd';
import React, { useLayoutEffect, useRef, useState } from 'react';
import BaseView from './components/base';
import BindingView from './components/binding';
import NotificationView from './components/notification';
import SecurityView from './components/security';
import useStyles from './style.style';
type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification';
type SettingsState = {
mode: 'inline' | 'horizontal';
selectKey: SettingsStateKeys;
};
const Settings: React.FC = () => {
const { styles } = useStyles();
const menuMap: Record<string, React.ReactNode> = {
base: '基本设置',
security: '安全设置',
binding: '账号绑定',
notification: '新消息通知',
};
const [initConfig, setInitConfig] = useState<SettingsState>({
mode: 'inline',
selectKey: 'base',
});
const dom = useRef<HTMLDivElement>(null);
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);
};
}, []);
const getMenu = () => {
return Object.keys(menuMap).map((item) => ({
key: item,
label: menuMap[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,
});
}}
items={getMenu()}
/>
</div>
<div className={styles.right}>
<div className={styles.title}>{menuMap[initConfig.selectKey]}</div>
{renderChildren()}
</div>
</div>
</GridContent>
);
};
export default Settings;

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

@ -0,0 +1,20 @@
import { request } from '@umijs/max';
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');
}

74
src/pages/account/settings/style.style.ts

@ -0,0 +1,74 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
main: {
display: 'flex',
width: '100%',
height: '100%',
paddingTop: '16px',
paddingBottom: '16px',
backgroundColor: token.colorBgContainer,
'.ant-list-split .ant-list-item:last-child': {
borderBottom: `1px solid ${token.colorSplit}`,
},
'.ant-list-item': { paddingTop: '14px', paddingBottom: '14px' },
[`@media screen and (max-width: ${token.screenMD}px)`]: {
flexDirection: 'column',
},
},
leftMenu: {
width: '224px',
borderRight: `${token.lineWidth}px solid ${token.colorSplit}`,
'.ant-menu-inline': { border: 'none' },
'.ant-menu-horizontal': { fontWeight: 'bold' },
[`@media screen and (max-width: ${token.screenMD}px)`]: {
width: '100%',
border: 'none',
},
},
right: {
flex: '1',
padding: '8px 40px',
[`@media screen and (max-width: ${token.screenMD}px)`]: {
padding: '40px',
},
},
title: {
marginBottom: '12px',
color: token.colorTextHeading,
fontWeight: '500',
fontSize: '20px',
lineHeight: '28px',
},
taobao: {
display: 'block',
color: '#ff4000',
fontSize: '48px',
lineHeight: '48px',
borderRadius: token.borderRadius,
},
dingding: {
margin: '2px',
padding: '6px',
color: '#fff',
fontSize: '32px',
lineHeight: '32px',
backgroundColor: '#2eabff',
borderRadius: token.borderRadius,
},
alipay: {
color: '#2eabff',
fontSize: '48px',
lineHeight: '48px',
borderRadius: token.borderRadius,
},
':global': {
'font.strong': { color: token.colorSuccess },
'font.medium': { color: token.colorWarning },
'font.weak': { color: token.colorError },
},
};
});
export default useStyles;

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

@ -0,0 +1,210 @@
import dayjs from 'dayjs';
import type { Request, Response } from 'express';
import type { AnalysisData, DataItem, RadarData } from './data.d';
// mock data
const visitData: DataItem[] = [];
const beginDay = Date.now();
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: dayjs(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: dayjs(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 = dayjs(Date.now() + 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 as 'ref'],
value: item[key as 'ref'],
});
}
});
});
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,
};

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

77
src/pages/dashboard/analysis/components/Charts/ChartCard/index.style.ts

@ -0,0 +1,77 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
chartCard: {
position: 'relative',
},
chartTop: {
position: 'relative',
width: '100%',
overflow: 'hidden',
},
chartTopMargin: {
marginBottom: '12px',
},
chartTopHasMargin: {
marginBottom: '20px',
},
metaWrap: {
float: 'left',
},
avatar: {
position: 'relative',
top: '4px',
float: 'left',
marginRight: '20px',
img: { borderRadius: '100%' },
},
meta: {
height: '22px',
color: token.colorTextSecondary,
fontSize: token.fontSize,
lineHeight: '22px',
},
action: {
position: 'absolute',
top: '4px',
right: '0',
lineHeight: '1',
cursor: 'pointer',
},
total: {
height: '38px',
marginTop: '4px',
marginBottom: '0',
overflow: 'hidden',
color: token.colorTextHeading,
fontSize: '30px',
lineHeight: '38px',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
},
content: {
position: 'relative',
width: '100%',
marginBottom: '12px',
},
contentFixed: {
position: 'absolute',
bottom: '0',
left: '0',
width: '100%',
},
footer: {
marginTop: '8px',
paddingTop: '9px',
borderTop: `1px solid ${token.colorSplit}`,
'& > *': { position: 'relative' },
},
footerMargin: {
marginTop: '20px',
},
};
});
export default useStyles;

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

@ -0,0 +1,110 @@
import { Card } from 'antd';
import type { CardProps } from 'antd/es/card';
import classNames from 'classnames';
import omit from 'rc-util/lib/omit';
import React from 'react';
import useStyles from './index.style';
type totalType = () => React.ReactNode;
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;
const ChartCard: React.FC<ChartCardProps> = (props) => {
const { styles } = useStyles();
const renderTotal = (total?: number | totalType | React.ReactNode) => {
if (!total && total !== 0) {
return null;
}
let totalDom: React.ReactNode | null = null;
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;
};
const renderContent = () => {
const {
contentHeight,
title,
avatar,
action,
total,
footer,
children,
loading,
} = 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>{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 : undefined}>
{children}
</div>
</div>
)}
{footer && (
<div
className={classNames(styles.footer, {
[styles.footerMargin]: !children,
})}
>
{footer}
</div>
)}
</div>
);
};
const { loading = false, ...rest } = props;
const cardProps = omit(rest, ['total', 'contentHeight', 'action']);
return (
<Card
loading={loading}
styles={{
body: {
padding: '20px 24px 8px 24px',
},
}}
{...cardProps}
>
{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;
}
}

22
src/pages/dashboard/analysis/components/Charts/Field/index.style.ts

@ -0,0 +1,22 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
field: {
margin: '0',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
label: {
fontSize: token.fontSize,
lineHeight: '22px',
},
number: {
marginLeft: '8px',
color: token.colorTextHeading,
},
};
});
export default useStyles;

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

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

48
src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx

@ -0,0 +1,48 @@
import { Tooltip } from 'antd';
import React from 'react';
export type MiniProgressProps = {
target: number;
targetLabel?: string;
color?: string;
size?: number;
percent?: number;
style?: React.CSSProperties;
};
const MiniProgress: React.FC<MiniProgressProps> = ({
targetLabel,
target,
color = 'rgb(19, 194, 194)',
size,
percent,
}) => {
return (
<div>
<Tooltip title={targetLabel}>
<div
style={{
left: target ? `${target}%` : undefined,
}}
>
<span
style={{
backgroundColor: color || undefined,
}}
/>
<span
style={{
backgroundColor: color || undefined,
}}
/>
</div>
</Tooltip>
<div
style={{
backgroundColor: color || undefined,
width: percent ? `${percent}%` : undefined,
height: size || undefined,
}}
/>
</div>
);
};
export default MiniProgress;

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

@ -0,0 +1,225 @@
import React, { Component } from 'react';
import autoHeight from '../../../../monitor/components/Charts/autoHeight';
/* 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
ref={(n) => {
this.root = n;
}}
style={{
transform: `scale(${radio})`,
}}
>
<div
style={{
width: height,
height,
overflow: 'hidden',
}}
>
<canvas
ref={(n) => {
this.node = n;
}}
width={height * 2}
height={height * 2}
/>
</div>
<div
style={{
width: height,
}}
>
{title && <span>{title}</span>}
<h4>{percent}%</h4>
</div>
</div>
);
}
}
export default autoHeight()(WaterWave);

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

23
src/pages/dashboard/analysis/components/Charts/index.style.ts

@ -0,0 +1,23 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(() => {
return {
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%',
marginLeft: '-7px',
},
};
});
export default useStyles;

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

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

@ -0,0 +1,168 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Area, Column } from '@ant-design/plots';
import { Col, Progress, Row, Tooltip } from 'antd';
import numeral from 'numeral';
import type { DataItem } from '../data.d';
import useStyles from '../style.style';
import Yuan from '../utils/Yuan';
import { ChartCard, Field } from './Charts';
import Trend from './Trend';
const topColResponsiveProps = {
xs: 24,
sm: 12,
md: 12,
lg: 12,
xl: 6,
style: {
marginBottom: 24,
},
};
const IntroduceRow = ({
loading,
visitData,
}: {
loading: boolean;
visitData: DataItem[];
}) => {
const { styles } = useStyles();
return (
<Row gutter={24}>
<Col {...topColResponsiveProps}>
<ChartCard
variant="borderless"
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
variant="borderless"
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}
>
<Area
xField="x"
yField="y"
shapeField="smooth"
height={46}
axis={false}
style={{
fill: 'linear-gradient(-90deg, white 0%, #975FE4 100%)',
fillOpacity: 0.6,
width: '100%',
}}
padding={-20}
data={visitData}
/>
</ChartCard>
</Col>
<Col {...topColResponsiveProps}>
<ChartCard
variant="borderless"
loading={loading}
title="支付笔数"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
total={numeral(6560).format('0,0')}
footer={<Field label="转化率" value="60%" />}
contentHeight={46}
>
<Column
xField="x"
yField="y"
padding={-20}
axis={false}
height={46}
data={visitData}
scale={{ x: { paddingInner: 0.4 } }}
/>
</ChartCard>
</Col>
<Col {...topColResponsiveProps}>
<ChartCard
loading={loading}
variant="borderless"
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
percent={78}
strokeColor={{ from: '#108ee9', to: '#87d068' }}
status="active"
/>
</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;
}
}
}

56
src/pages/dashboard/analysis/components/NumberInfo/index.style.ts

@ -0,0 +1,56 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
suffix: {
marginLeft: '4px',
color: token.colorText,
fontSize: '16px',
fontStyle: 'normal',
},
numberInfoTitle: {
marginBottom: '16px',
color: token.colorText,
fontSize: token.fontSizeLG,
transition: 'all 0.3s',
},
numberInfoSubTitle: {
height: '22px',
overflow: 'hidden',
color: token.colorTextSecondary,
fontSize: token.fontSize,
lineHeight: '22px',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
},
numberInfoValue: {
marginTop: '4px',
overflow: 'hidden',
fontSize: '0',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
'& > span': { color: token.colorText },
},
subTotal: {
marginRight: '0',
color: token.colorTextSecondary,
fontSize: token.fontSizeLG,
verticalAlign: 'top',
},
anticon: {
marginLeft: '4px',
fontSize: '12px',
transform: 'scale(0.82)',
},
'anticon-caret-up': {
color: token['red-6'],
},
'anticon-caret-down': {
color: token['green-6'],
},
};
});
export default useStyles;

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

@ -0,0 +1,79 @@
import { CaretDownOutlined, CaretUpOutlined } from '@ant-design/icons';
import classNames from 'classnames';
import React from 'react';
import useStyles from './index.style';
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
}) => {
const { styles } = useStyles();
return (
<div
className={classNames({
[styles[`numberInfo${theme}` as keyof typeof styles]]: !!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;

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

@ -0,0 +1,110 @@
import { Line, Tiny } from '@ant-design/plots';
import { Card, Col, Row, Tabs } from 'antd';
import type { DataItem, OfflineDataType } from '../data.d';
import useStyles from '../style.style';
import NumberInfo from './NumberInfo';
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,
}}
>
<Tiny.Ring
height={60}
width={60}
percent={data.cvr}
color={['#E8EEF4', '#5FABF4']}
/>
</Col>
</Row>
);
const OfflineData = ({
activeKey,
loading,
offlineData,
offlineChartData,
handleTabChange,
}: {
activeKey: string;
loading: boolean;
offlineData: OfflineDataType[];
offlineChartData: DataItem[];
handleTabChange: (activeKey: string) => void;
}) => {
const { styles } = useStyles();
return (
<Card
loading={loading}
className={styles.offlineCard}
variant="borderless"
style={{
marginTop: 32,
}}
>
<Tabs
activeKey={activeKey}
onChange={handleTabChange}
items={offlineData.map((shop) => ({
key: shop.name,
label: <CustomTab data={shop} currentTabKey={activeKey} />,
children: (
<div
style={{
padding: '0 24px',
}}
>
<Line
height={400}
data={offlineChartData}
xField="date"
yField="value"
colorField="type"
slider={{ x: true }}
axis={{
x: { title: false },
y: {
title: false,
gridLineDash: null,
gridStroke: '#ccc',
gridStrokeOpacity: 1,
},
}}
legend={{
color: {
layout: { justifyContent: 'center' },
},
}}
/>
</div>
),
}))}
/>
</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>
);

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

@ -0,0 +1,67 @@
import { Pie } from '@ant-design/plots';
import { Card, Segmented, Typography } from 'antd';
import numeral from 'numeral';
import React from 'react';
import type { DataItem } from '../data.d';
import useStyles from '../style.style';
const { Text } = Typography;
const ProportionSales = ({
dropdownGroup,
salesType,
loading,
salesPieData,
handleChangeSalesType,
}: {
loading: boolean;
dropdownGroup: React.ReactNode;
salesType: 'all' | 'online' | 'stores';
salesPieData: DataItem[];
handleChangeSalesType?: (value: 'all' | 'online' | 'stores') => void;
}) => {
const { styles } = useStyles();
return (
<Card
loading={loading}
className={styles.salesCard}
variant="borderless"
title="销售额类别占比"
style={{
height: '100%',
}}
extra={
<div className={styles.salesCardExtra}>
{dropdownGroup}
<Segmented
className={styles.salesTypeRadio}
value={salesType}
onChange={handleChangeSalesType}
options={[
{ label: '全部渠道', value: 'all' },
{ label: '线上', value: 'online' },
{ label: '门店', value: 'stores' },
]}
size="middle"
/>
</div>
}
>
<Text></Text>
<Pie
height={340}
radius={0.8}
innerRadius={0.5}
angleField="y"
colorField="x"
data={salesPieData as any}
legend={false}
label={{
position: 'spider',
text: (item: { x: number; y: number }) =>
`${item.x}: ${numeral(item.y).format('0,0')}`,
}}
/>
</Card>
);
};
export default ProportionSales;

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

@ -0,0 +1,225 @@
import { Column } from '@ant-design/plots';
import { Button, Card, Col, DatePicker, Row, Tabs } from 'antd';
import type { RangePickerProps } from 'antd/es/date-picker';
import numeral from 'numeral';
import type { DataItem } from '../data.d';
import useStyles from '../style.style';
export type TimeType = 'today' | 'week' | 'month' | 'year';
const { RangePicker } = DatePicker;
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: RangePickerProps['value'];
isActive: (key: TimeType) => string;
salesData: DataItem[];
loading: boolean;
handleRangePickerChange: RangePickerProps['onChange'];
selectDate: (key: TimeType) => void;
}) => {
const { styles } = useStyles();
return (
<Card
loading={loading}
variant="borderless"
styles={{
body: {
padding: loading ? 24 : 0,
},
}}
>
<Tabs
className={styles.salesCard}
tabBarExtraContent={
<div className={styles.salesExtraWrap}>
<div className={styles.salesExtra}>
<Button
type="text"
className={isActive('today')}
onClick={() => selectDate('today')}
>
</Button>
<Button
type="text"
className={isActive('week')}
onClick={() => selectDate('week')}
>
</Button>
<Button
type="text"
className={isActive('month')}
onClick={() => selectDate('month')}
>
</Button>
<Button
type="text"
className={isActive('year')}
onClick={() => selectDate('year')}
>
</Button>
</div>
<RangePicker
value={rangePickerValue}
onChange={handleRangePickerChange}
variant="filled"
style={{
width: 256,
}}
/>
</div>
}
size="large"
tabBarStyle={{
marginBottom: 24,
}}
items={[
{
key: 'sales',
label: '销售额',
children: (
<Row>
<Col xl={16} lg={12} md={12} sm={24} xs={24}>
<div className={styles.salesBar}>
<Column
height={300}
data={salesData}
xField="x"
yField="y"
paddingBottom={12}
axis={{
x: {
title: false,
},
y: {
title: false,
gridLineDash: null,
gridStroke: '#ccc',
},
}}
scale={{
x: { paddingInner: 0.4 },
}}
tooltip={{
name: '销售量',
channel: 'y',
}}
/>
</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.rankingItemNumberActive : ''
}`}
>
{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>
),
},
{
key: 'views',
label: '访问量',
children: (
<Row>
<Col xl={16} lg={12} md={12} sm={24} xs={24}>
<div className={styles.salesBar}>
<Column
height={300}
data={salesData}
xField="x"
yField="y"
paddingBottom={12}
axis={{
x: {
title: false,
},
y: {
title: false,
},
}}
scale={{
x: { paddingInner: 0.4 },
}}
tooltip={{
name: '访问量',
channel: 'y',
}}
/>
</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={`${
i < 3
? styles.rankingItemNumberActive
: styles.rankingItemNumber
}`}
>
{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>
),
},
]}
/>
</Card>
);
};
export default SalesCard;

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

@ -0,0 +1,181 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Area } from '@ant-design/plots';
import { Card, Col, Row, Table, Tooltip } from 'antd';
import numeral from 'numeral';
import React from 'react';
import type { DataItem } from '../data.d';
import NumberInfo from './NumberInfo';
import Trend from './Trend';
const TopSearch = ({
loading,
visitData2,
searchData,
dropdownGroup,
}: {
loading: boolean;
visitData2: DataItem[];
dropdownGroup: React.ReactNode;
searchData: DataItem[];
}) => {
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,
},
{
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>
),
},
];
return (
<Card
loading={loading}
variant="borderless"
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}
/>
<Area
xField="x"
yField="y"
shapeField="smooth"
height={45}
axis={false}
padding={-12}
style={{
fill: 'linear-gradient(-90deg, white 0%, #6294FA 100%)',
fillOpacity: 0.4,
}}
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}
/>
<Area
xField="x"
yField="y"
shapeField="smooth"
height={45}
padding={-12}
style={{
fill: 'linear-gradient(-90deg, white 0%, #6294FA 100%)',
fillOpacity: 0.4,
}}
data={visitData2}
axis={false}
/>
</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;
}
}

32
src/pages/dashboard/analysis/components/Trend/index.style.ts

@ -0,0 +1,32 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
trendItem: {
display: 'inline-block',
fontSize: token.fontSize,
lineHeight: '22px',
},
up: {
color: token['red-6'],
},
down: {
top: '-1px',
color: token['green-6'],
},
trendItemGrey: {
up: {
color: token.colorText,
},
down: {
color: token.colorText,
},
},
reverseColor: {
up: { color: token['green-6'] },
down: { color: token['red-6'] },
},
};
});
export default useStyles;

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

@ -0,0 +1,47 @@
import { CaretDownOutlined, CaretUpOutlined } from '@ant-design/icons';
import classNames from 'classnames';
import React from 'react';
import useStyles from './index.style';
export type TrendProps = {
colorful?: boolean;
flag: 'up' | 'down';
style?: React.CSSProperties;
reverseColor?: boolean;
className?: string;
children?: React.ReactNode;
};
const Trend: React.FC<TrendProps> = ({
colorful = true,
reverseColor = false,
flag,
children,
className,
...rest
}) => {
const { styles } = useStyles();
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;

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

@ -0,0 +1,45 @@
export interface DataItem {
[field: string]: string | number | number[] | null | undefined;
}
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[];
}

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

@ -0,0 +1,157 @@
import { EllipsisOutlined } from '@ant-design/icons';
import { GridContent } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import { Col, Dropdown, Row } from 'antd';
import type { RangePickerProps } from 'antd/es/date-picker';
import type { Dayjs } from 'dayjs';
import type { FC } from 'react';
import { Suspense, useState } from 'react';
import IntroduceRow from './components/IntroduceRow';
import OfflineData from './components/OfflineData';
import PageLoading from './components/PageLoading';
import ProportionSales from './components/ProportionSales';
import type { TimeType } from './components/SalesCard';
import SalesCard from './components/SalesCard';
import TopSearch from './components/TopSearch';
import type { AnalysisData } from './data.d';
import { fakeChartData } from './service';
import useStyles from './style.style';
import { getTimeDistance } from './utils/utils';
type RangePickerValue = RangePickerProps['value'];
type AnalysisProps = {
dashboardAndanalysis: AnalysisData;
loading: boolean;
};
type SalesType = 'all' | 'online' | 'stores';
const Analysis: FC<AnalysisProps> = () => {
const { styles } = useStyles();
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 Dayjs, 'day') &&
rangePickerValue[1].isSame(value[1] as Dayjs, 'day')
) {
return styles.currentDate;
}
return '';
};
let salesPieData: any;
if (salesType === 'all') {
salesPieData = data?.salesTypeData;
} else {
salesPieData =
salesType === 'online'
? data?.salesTypeDataOnline
: data?.salesTypeDataOffline;
}
const dropdownGroup = (
<span className={styles.iconGroup}>
<Dropdown
menu={{
items: [
{
key: '1',
label: '操作一',
},
{
key: '2',
label: '操作二',
},
],
}}
placement="bottomRight"
>
<EllipsisOutlined />
</Dropdown>
</span>
);
const handleChangeSalesType = (value: SalesType) => {
setSalesType(value);
};
const handleTabChange = (key: string) => {
setCurrentTabKey(key);
};
const activeKey = currentTabKey || 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 '@umijs/max';
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;
}
}
}
}

160
src/pages/dashboard/analysis/style.style.ts

@ -0,0 +1,160 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
iconGroup: {
'span.anticon': {
marginLeft: '16px',
color: token.colorTextSecondary,
cursor: 'pointer',
transition: 'color 0.32s',
'&:hover': {
color: token.colorText,
},
},
},
rankingList: {
margin: '25px 0 0',
padding: '0',
listStyle: 'none',
li: {
display: 'flex',
alignItems: 'center',
marginTop: '16px',
zoom: '1',
'&::before, &::after': {
display: 'table',
content: "' '",
},
'&::after': {
clear: 'both',
height: '0',
fontSize: '0',
visibility: 'hidden',
},
},
[`@media screen and (max-width: ${token.screenLG}px)`]: {
li: {
'span:first-child': { marginRight: '8px' },
},
},
},
rankingItemNumber: {
display: 'inline-block',
width: '20px',
height: '20px',
marginTop: '1.5px',
marginRight: '16px',
fontWeight: '600',
fontSize: '12px',
lineHeight: '20px',
textAlign: 'center',
borderRadius: '20px',
backgroundColor: token.colorBgContainerDisabled,
},
rankingItemTitle: {
flex: '1',
marginRight: '8px',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
rankingItemNumberActive: {
display: 'inline-block',
width: '20px',
height: '20px',
marginTop: '1.5px',
marginRight: '16px',
fontWeight: '600',
fontSize: '12px',
lineHeight: '20px',
textAlign: 'center',
borderRadius: '20px',
color: '#fff',
backgroundColor: token.colorBgSpotlight,
},
salesExtra: {
display: 'inline-block',
marginRight: '24px',
a: {
marginLeft: '24px',
color: token.colorText,
'&:hover': {
color: token.colorPrimary,
},
},
[`@media screen and (max-width: ${token.screenLG}px)`]: {
display: 'none',
},
},
currentDate: {
color: token.colorPrimary,
fontWeight: 'bold',
},
salesBar: {
padding: '0 0 32px 32px',
[`@media screen and (max-width: ${token.screenMD}px)`]: {
padding: '16px',
},
},
salesRank: {
padding: '0 32px 32px 72px',
},
salesCard: {
'.ant-tabs-bar, .ant-tabs-nav-wrap': {
paddingLeft: '16px',
'.ant-tabs-nav .ant-tabs-tab': {
paddingTop: '16px',
paddingBottom: '14px',
lineHeight: '24px',
},
},
'.ant-tabs-extra-content': { paddingRight: '24px', lineHeight: '55px' },
'.ant-card-head': { position: 'relative' },
'.ant-card-head-title': { alignItems: 'normal' },
[`@media screen and (max-width: ${token.screenMD}px)`]: {
padding: '16px',
},
[`@media screen and (max-width: ${token.screenSM}px)`]: {
'.ant-tabs-content': {
paddingTop: '30px',
},
},
},
salesCardExtra: {
height: 'inherit',
},
salesTypeRadio: {
position: 'absolute',
right: '54px',
bottom: '12px',
},
offlineCard: {
'.ant-tabs-ink-bar': { bottom: 'auto' },
'.ant-tabs-bar': { borderBottom: 'none' },
'.ant-tabs-nav-container-scrolling': {
paddingRight: '40px',
paddingLeft: '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: token.colorPrimary },
},
trendText: {
marginLeft: '8px',
color: token.colorTextHeading,
},
rankingTitle: {
[`@media screen and (max-width: ${token.screenMD}px)`]: {
marginTop: '16px',
},
},
salesExtraWrap: {
[`@media screen and (max-width: ${token.screenSM}px)`]: {
display: 'none',
},
},
};
});
export default useStyles;

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: string | number;
}> {
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;
}}
/>
);
}
}

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

@ -0,0 +1,57 @@
import type { RangePickerProps } from 'antd/es/date-picker';
import dayjs from 'dayjs';
type RangePickerValue = RangePickerProps['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 [dayjs(now), dayjs(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 [dayjs(beginTime), dayjs(beginTime + (7 * oneDay - 1000))];
}
const year = now.getFullYear();
if (type === 'month') {
const month = now.getMonth();
const nextDate = dayjs(now).add(1, 'months');
const nextYear = nextDate.year();
const nextMonth = nextDate.month();
return [
dayjs(`${year}-${fixedZero(month + 1)}-01 00:00:00`),
dayjs(
dayjs(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() -
1000,
),
];
}
return [dayjs(`${year}-01-01 00:00:00`), dayjs(`${year}-12-31 23:59:59`)];
}

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

@ -0,0 +1,14 @@
import type { Request, Response } from 'express';
import mockjs from 'mockjs';
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;
}

48
src/pages/dashboard/monitor/components/ActiveChart/index.style.ts

@ -0,0 +1,48 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(() => {
return {
activeChart: {
position: 'relative',
},
activeChartGrid: {
p: { position: 'absolute', top: '80px' },
'p:last-child': { top: '115px' },
},
activeChartLegend: {
position: 'relative',
height: '20px',
marginTop: '8px',
fontSize: '0',
lineHeight: '20px',
span: {
display: 'inline-block',
width: '33.33%',
fontSize: '12px',
textAlign: 'center',
},
'span:first-child': { textAlign: 'left' },
'span:last-child': { textAlign: 'right' },
},
dashedLine: {
position: 'relative',
top: '-70px',
left: '-3px',
height: '1px',
},
line: {
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundImage:
'linear-gradient(to right, transparent 50%, #e9e9e9 50%)',
backgroundSize: '6px',
},
'dashedLine:last-child': {
top: '-36px',
},
};
});
export default useStyles;

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

@ -0,0 +1,93 @@
import { Area } from '@ant-design/plots';
import { Statistic } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import useStyles from './index.style';
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;
}
const ActiveChart = () => {
const timerRef = useRef<number | null>(null);
const requestRef = useRef<number | null>(null);
const { styles } = useStyles();
const [activeData, setActiveData] = useState<{ x: string; y: number }[]>([]);
const loopData = useCallback(() => {
requestRef.current = requestAnimationFrame(() => {
timerRef.current = window.setTimeout(() => {
setActiveData(getActiveData());
loopData();
}, 2000);
});
}, []);
useEffect(() => {
loopData();
return () => {
clearTimeout(timerRef.current as number);
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [loopData]);
return (
<div className={styles.activeChart}>
<Statistic title="目标评估" value="有望达到预期" />
<div
style={{
marginTop: 32,
}}
>
<Area
padding={[0, 0, 0, 0]}
xField="x"
axis={false}
yField="y"
height={84}
style={{
fill: 'linear-gradient(-90deg, white 0%, #6294FA 100%)',
fillOpacity: 0.6,
}}
data={activeData}
/>
</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>
);
};
export default ActiveChart;

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

@ -0,0 +1,225 @@
import React, { Component } from 'react';
import autoHeight from '../autoHeight';
/* 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
ref={(n) => {
this.root = n;
}}
style={{
transform: `scale(${radio})`,
}}
>
<div
style={{
width: height,
height,
overflow: 'hidden',
}}
>
<canvas
ref={(n) => {
this.node = n;
}}
width={height * 2}
height={height * 2}
/>
</div>
<div
style={{
width: height,
}}
>
{title && <span>{title}</span>}
<h4>{percent}%</h4>
</div>
</div>
);
}
}
export default autoHeight()(WaterWave);

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

@ -0,0 +1,78 @@
import React from 'react';
export type IReactComponent<P = any> =
| 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;

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

@ -0,0 +1,153 @@
import { PageLoading } from '@ant-design/pro-components';
import { HeatmapLayer, MapboxScene, PointLayer } from '@antv/l7-react';
import * as React from 'react';
const colors = [
'#eff3ff',
'#c6dbef',
'#9ecae1',
'#6baed6',
'#4292c6',
'#2171b5',
'#084594',
];
export default class MonitorMap 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;
};

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

@ -0,0 +1,203 @@
import { Gauge, Liquid, WordCloud } from '@ant-design/plots';
import { GridContent } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import { Card, Col, Progress, Row, Statistic } from 'antd';
import numeral from 'numeral';
import type { FC } from 'react';
import ActiveChart from './components/ActiveChart';
import MonitorMap from './components/Map';
import { queryTags } from './service';
import useStyles from './style.style';
const deadline = Date.now() + 1000 * 60 * 60 * 24 * 2 + 1000 * 30; // Moment is also OK
const Monitor: FC = () => {
const { styles } = useStyles();
const { loading, data } = useRequest(queryTags);
const 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="活动实时交易情况" variant="borderless">
<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}>
<Statistic.Timer
type="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}>
<MonitorMap />
</div>
</Card>
</Col>
<Col xl={6} lg={24} md={24} sm={24} xs={24}>
<Card
title="活动情况预测"
style={{
marginBottom: 24,
}}
variant="borderless"
>
<ActiveChart />
</Card>
<Card
title="券核效率"
style={{
marginBottom: 24,
}}
styles={{
body: {
textAlign: 'center',
},
}}
variant="borderless"
>
<Gauge
height={180}
data={
{
target: 80,
total: 100,
name: 'score',
thresholds: [20, 40, 60, 80, 100],
} as any
}
padding={-16}
style={{
textContent: () => '优',
}}
meta={{
color: {
range: [
'#6395FA',
'#62DAAB',
'#657798',
'#F7C128',
'#1F8718',
],
},
}}
/>
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col
xl={12}
lg={24}
sm={24}
xs={24}
style={{
marginBottom: 24,
}}
>
<Card title="各品类占比" variant="borderless">
<Row
style={{
padding: '16px 0',
}}
>
<Col span={8}>
<Progress type="dashboard" percent={75} />
</Col>
<Col span={8}>
<Progress type="dashboard" percent={48} />
</Col>
<Col span={8}>
<Progress type="dashboard" percent={33} />
</Col>
</Row>
</Card>
</Col>
<Col
xl={6}
lg={12}
sm={24}
xs={24}
style={{
marginBottom: 24,
}}
>
<Card
title="热门搜索"
loading={loading}
variant="borderless"
styles={{
body: {
overflow: 'hidden',
},
}}
>
<WordCloud
data={wordCloudData}
height={162}
textField="word"
colorField="word"
layout={{ spiral: 'rectangular', fontSize: [10, 20] }}
/>
</Card>
</Col>
<Col
xl={6}
lg={12}
sm={24}
xs={24}
style={{
marginBottom: 24,
}}
>
<Card
title="资源剩余"
styles={{
body: {
textAlign: 'center',
fontSize: 0,
},
}}
variant="borderless"
>
<Liquid height={160} percent={0.35} />
</Card>
</Col>
</Row>
</GridContent>
);
};
export default Monitor;

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

@ -0,0 +1,6 @@
import { request } from '@umijs/max';
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;
}
}

16
src/pages/dashboard/monitor/style.style.ts

@ -0,0 +1,16 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
mapChart: {
height: '452px',
paddingTop: '24px',
img: { display: 'inline-block', maxWidth: '100%', maxHeight: '437px' },
[`@media screen and (max-width: ${token.screenLG}px)`]: {
height: 'auto',
},
},
};
});
export default useStyles;

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

@ -0,0 +1,410 @@
import dayjs from 'dayjs';
import type { Request, Response } from 'express';
import type { DataItem, OfflineDataType, SearchDataType } from './data.d';
// mock data
const visitData: DataItem[] = [];
const beginDay = Date.now();
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: dayjs(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: dayjs(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: Date.now() + 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 as 'ref'],
value: item[key as 'ref'],
});
}
});
});
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;
}
}
}

21
src/pages/dashboard/workplace/components/EditableLinkGroup/index.style.ts

@ -0,0 +1,21 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
linkGroup: {
fontSize: '0',
'& > a': {
display: 'inline-block',
width: '25%',
marginBottom: '13px',
color: token.colorText,
fontSize: token.fontSize,
'&:hover': {
color: token.colorPrimary,
},
},
},
};
});
export default useStyles;

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

@ -0,0 +1,38 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import React, { createElement } from 'react';
import useStyles from './index.style';
export type EditableLink = {
title: string;
href: string;
id?: string;
};
type EditableLinkGroupProps = {
onAdd: () => void;
links: EditableLink[];
linkElement: any;
};
const EditableLinkGroup: React.FC<EditableLinkGroupProps> = (props) => {
const { styles } = useStyles();
const { links = [], linkElement = 'a', 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>
);
};
export default EditableLinkGroup;

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

@ -0,0 +1,111 @@
export interface DataItem {
[field: string]: string | number | number[] | null | undefined;
}
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: {
link?: string;
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;
};

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

@ -0,0 +1,286 @@
import { Radar } from '@ant-design/plots';
import { PageContainer } from '@ant-design/pro-components';
import { Link, useRequest } from '@umijs/max';
import { Avatar, Card, Col, List, Row, Skeleton, Statistic } from 'antd';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { FC } from 'react';
import EditableLinkGroup from './components/EditableLinkGroup';
import type { ActivitiesType, CurrentUser } from './data.d';
import { fakeChartData, queryActivities, queryProjectNotice } from './service';
import useStyles from './style.style';
dayjs.extend(relativeTime);
const links = [
{
title: '操作一',
href: '',
},
{
title: '操作二',
href: '',
},
{
title: '操作三',
href: '',
},
{
title: '操作四',
href: '',
},
{
title: '操作五',
href: '',
},
{
title: '操作六',
href: '',
},
];
const PageHeaderContent: FC<{
currentUser: Partial<CurrentUser>;
}> = ({ currentUser }) => {
const { styles } = useStyles();
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>> = () => {
const { styles } = useStyles();
return (
<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 { styles } = useStyles();
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 as keyof ActivitiesType]) {
const value = item[key as 'user'];
return (
<a href={value?.link} key={value?.name}>
{value.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}>
{dayjs(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="进行中的项目"
variant="borderless"
extra={<Link to="/"></Link>}
loading={projectLoading}
>
{projectNotice.map((item) => (
<Card.Grid className={styles.projectGrid} key={item.id}>
<Card.Meta
title={
<div className={styles.cardTitle}>
<Avatar size="small" src={item.logo} />
<Link to={item.href || '/'}>{item.title}</Link>
</div>
}
description={item.description}
style={{
width: '100%',
}}
/>
<div className={styles.projectItemContent}>
<Link to={item.memberLink || '/'}>{item.member || ''}</Link>
{item.updatedAt && (
<span className={styles.datetime} title={item.updatedAt}>
{dayjs(item.updatedAt).fromNow()}
</span>
)}
</div>
</Card.Grid>
))}
</Card>
<Card
styles={{
body: {
padding: activitiesLoading ? 16 : 0,
},
}}
variant="borderless"
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="快速开始 / 便捷导航"
variant="borderless"
>
<EditableLinkGroup
onAdd={() => {}}
links={links}
linkElement={Link}
/>
</Card>
<Card
style={{
marginBottom: 24,
}}
variant="borderless"
title="XX 指数"
loading={data?.radarData?.length === 0}
>
<Radar
height={343}
data={data?.radarData || []}
xField="label"
colorField="name"
yField="value"
shapeField="smooth"
area={{
style: {
fillOpacity: 0.4,
},
}}
axis={{
y: {
gridStrokeOpacity: 0.5,
},
}}
legend={{
color: {
position: 'bottom',
layout: { justifyContent: 'center' },
},
}}
/>
</Card>
<Card
styles={{
body: {
paddingTop: 12,
paddingBottom: 12,
},
}}
variant="borderless"
title="团队"
loading={projectLoading}
>
<div className={styles.members}>
<Row gutter={48}>
{projectNotice.map((item) => {
return (
<Col span={12} key={`members-item-${item.id}`}>
<a>
<Avatar src={item.logo} size="small" />
<span className={styles.member}>
{item.member.substring(0, 3)}
</span>
</a>
</Col>
);
})}
</Row>
</div>
</Card>
</Col>
</Row>
</PageContainer>
);
};
export default Workplace;

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

@ -0,0 +1,14 @@
import { request } from '@umijs/max';
import type { ActivitiesType, AnalysisData, NoticeType } 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');
}

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

@ -0,0 +1,251 @@
@import '~antd/es/style/themes/default.less';
.textOverflow() {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
word-break: break-all;
}
// mixins for clearfix
// ------------------------
.clearfix() {
zoom: 1;
&::before,
&::after {
display: table;
content: ' ';
}
&::after {
clear: both;
height: 0;
font-size: 0;
visibility: hidden;
}
}
.activitiesList {
padding: 0 24px 8px 24px;
.username {
color: @text-color;
}
.event {
font-weight: normal;
}
}
.pageHeaderContent {
display: flex;
.avatar {
flex: 0 1 72px;
& > 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%;
}
}
}

215
src/pages/dashboard/workplace/style.style.ts

@ -0,0 +1,215 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => {
return {
activitiesList: {
padding: 0,
},
username: {
color: token.colorText,
},
event: {
fontWeight: 'normal',
},
pageHeaderContent: {
display: 'flex',
[`@media screen and (max-width: ${token.screenSM}px)`]: {
display: 'block',
},
},
avatar: {
flex: '0 1 72px',
'& > span': {
display: 'block',
width: '72px',
height: '72px',
borderRadius: '72px',
},
},
content: {
position: 'relative',
top: '4px',
flex: '1 1 auto',
marginLeft: '24px',
color: token.colorTextSecondary,
lineHeight: '22px',
[`@media screen and (max-width: ${token.screenSM}px)`]: {
marginLeft: '0',
},
},
contentTitle: {
marginBottom: '12px',
color: token.colorTextHeading,
fontWeight: '500',
fontSize: '20px',
lineHeight: '28px',
},
extraContent: {
zoom: '1',
'&::before, &::after': { display: 'table', content: "' '" },
'&::after': {
clear: 'both',
height: '0',
fontSize: '0',
visibility: 'hidden',
},
float: 'right',
whiteSpace: 'nowrap',
[`@media screen and (max-width: ${token.screenXL}px) and (min-width: @screen-lg)`]:
{
marginLeft: '-44px',
},
[`@media screen and (max-width: ${token.screenLG}px)`]: {
float: 'none',
marginRight: '0',
},
[`@media screen and (max-width: ${token.screenMD}px)`]: {
marginLeft: '-16px',
},
},
statItem: {
position: 'relative',
display: 'inline-block',
padding: '0 32px',
'> p:first-child': {
marginBottom: '4px',
color: token.colorTextSecondary,
fontSize: token.fontSize,
lineHeight: '22px',
},
'> p': {
margin: '0',
color: token.colorTextHeading,
fontSize: '30px',
lineHeight: '38px',
'> span': {
color: token.colorTextSecondary,
fontSize: '20px',
},
},
'&::after': {
position: 'absolute',
top: '8px',
right: '0',
width: '1px',
height: '40px',
backgroundColor: token.colorSplit,
content: "''",
},
'&:last-child': {
paddingRight: '0',
'&::after': {
display: 'none',
},
},
[`@media screen and (max-width: ${token.screenXL}px) and (min-width: @screen-lg)`]:
{
padding: '0 16px',
},
[`@media screen and (max-width: ${token.screenLG}px)`]: {
padding: '0 16px',
textAlign: 'left',
'&::after': {
display: 'none',
},
},
[`@media screen and (max-width: ${token.screenSM}px)`]: { float: 'none' },
},
members: {
a: {
display: 'block',
height: '24px',
margin: '12px 0',
color: token.colorText,
transition: 'all 0.3s',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
'&:hover': {
color: token.colorPrimary,
},
},
[`@media screen and (max-width: ${token.screenXL}px) and (min-width: @screen-lg)`]:
{
marginBottom: '0',
},
[`@media screen and (max-width: ${token.screenLG}px)`]: {
marginBottom: '0',
},
},
member: {
marginLeft: '12px',
fontSize: token.fontSize,
lineHeight: '24px',
verticalAlign: 'top',
},
projectList: {
'.ant-card-meta-description': {
height: '44px',
overflow: 'hidden',
color: token.colorTextSecondary,
lineHeight: '22px',
},
},
cardTitle: {
fontSize: '0',
a: {
display: 'inline-block',
height: '24px',
marginLeft: '12px',
color: token.colorTextHeading,
fontSize: token.fontSize,
lineHeight: '24px',
verticalAlign: 'top',
'&:hover': {
color: token.colorPrimary,
},
},
},
projectGrid: {
width: '33.33%',
[`@media screen and (max-width: ${token.screenMD}px)`]: { width: '50%' },
[`@media screen and (max-width: ${token.screenXS}px)`]: { width: '100%' },
},
projectItemContent: {
display: 'flex',
height: '20px',
marginTop: '8px',
overflow: 'hidden',
fontSize: '12px',
gap: 'epx',
lineHeight: '20px',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
a: {
display: 'inline-block',
flex: '1 1 0',
color: token.colorTextSecondary,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
'&:hover': {
color: token.colorPrimary,
},
},
},
datetime: {
flex: '0 0 auto',
color: token.colorTextDisabled,
},
activeCard: {
[`@media screen and (max-width: ${token.screenXL}px) and (min-width: @screen-lg)`]:
{
marginBottom: '24px',
},
[`@media screen and (max-width: ${token.screenLG}px)`]: {
marginBottom: '24px',
},
},
};
});
export default useStyles;

17
src/pages/exception/403/index.tsx

@ -0,0 +1,17 @@
import { Link } from '@umijs/max';
import { Button, Card, Result } from 'antd';
export default () => (
<Card variant="borderless">
<Result
status="403"
title="403"
subTitle="Sorry, you are not authorized to access this page."
extra={
<Link to="/">
<Button type="primary">Back to home</Button>
</Link>
}
/>
</Card>
);

17
src/pages/exception/404/index.tsx

@ -0,0 +1,17 @@
import { Link } from '@umijs/max';
import { Button, Card, Result } from 'antd';
export default () => (
<Card variant="borderless">
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={
<Link to="/">
<Button type="primary">Back Home</Button>
</Link>
}
/>
</Card>
);

17
src/pages/exception/500/index.tsx

@ -0,0 +1,17 @@
import { Link } from '@umijs/max';
import { Button, Card, Result } from 'antd';
export default () => (
<Card variant="borderless">
<Result
status="500"
title="500"
subTitle="Sorry, something went wrong."
extra={
<Link to="/">
<Button type="primary">Back Home</Button>
</Link>
}
/>
</Card>
);

7
src/pages/form/advanced-form/_mock.ts

@ -0,0 +1,7 @@
import type { Request, Response } from 'express';
export default {
'POST /api/advancedForm': (_: Request, res: Response) => {
res.send({ data: { message: 'Ok' } });
},
};

268
src/pages/form/advanced-form/components/TableForm.tsx

@ -0,0 +1,268 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button, Divider, Input, message, Popconfirm, Table } from 'antd';
import type { FC } from 'react';
import React, { useState } from 'react';
import useStyles from '../style.style';
type TableFormDateType = {
key: string;
workId?: string;
name?: string;
department?: string;
isNew?: boolean;
editable?: boolean;
};
type TableFormProps = {
value?: TableFormDateType[];
onChange?: (value: TableFormDateType[]) => void;
};
const TableForm: FC<TableFormProps> = ({ value, onChange }) => {
const { styles } = useStyles();
const [clickedCancel, setClickedCancel] = useState(false);
const [loading, setLoading] = useState(false);
const [index, setIndex] = useState(0);
const [cacheOriginData, setCacheOriginData] = useState<Record<string, any>>(
{},
);
const [data, setData] = useState(value);
const getRowByKey = (key: string, newData?: TableFormDateType[]) =>
(newData || data)?.filter((item) => item.key === key)[0];
const toggleEditable = (
e: React.MouseEvent | React.KeyboardEvent,
key: string,
) => {
e.preventDefault();
const newData = data?.map((item) => ({
...item,
}));
const target = getRowByKey(key, newData);
if (target) {
// 进入编辑状态时保存原始数据
if (!target.editable) {
cacheOriginData[key] = {
...target,
};
setCacheOriginData(cacheOriginData);
}
target.editable = !target.editable;
setData(newData);
}
};
const newMember = () => {
const newData =
data?.map((item) => ({
...item,
})) || [];
newData.push({
key: `NEW_TEMP_ID_${index}`,
workId: '',
name: '',
department: '',
editable: true,
isNew: true,
});
setIndex(index + 1);
setData(newData);
};
const remove = (key: string) => {
const newData = data?.filter(
(item) => item.key !== key,
) as TableFormDateType[];
setData(newData);
if (onChange) {
onChange(newData);
}
};
const handleFieldChange = (
e: React.ChangeEvent<HTMLInputElement>,
fieldName: keyof TableFormDateType,
key: string,
) => {
const newData = [...(data as TableFormDateType[])];
const target = getRowByKey(key, newData);
if (target?.[fieldName]) {
target[fieldName as 'key'] = e.target.value;
setData(newData);
}
};
const saveRow = (e: React.MouseEvent | React.KeyboardEvent, key: string) => {
e.persist();
setLoading(true);
setTimeout(() => {
if (clickedCancel) {
setClickedCancel(false);
return;
}
const target = getRowByKey(key) || ({} as any);
if (!target.workId || !target.name || !target.department) {
message.error('请填写完整成员信息。');
(e.target as HTMLInputElement).focus();
setLoading(false);
return;
}
delete target.isNew;
toggleEditable(e, key);
if (onChange) {
onChange(data as TableFormDateType[]);
}
setLoading(false);
}, 500);
};
const handleKeyPress = (e: React.KeyboardEvent, key: string) => {
if (e.key === 'Enter') {
saveRow(e, key);
}
};
const cancel = (e: React.MouseEvent, key: string) => {
setClickedCancel(true);
e.preventDefault();
const newData = [...(data as TableFormDateType[])];
// 编辑前的原始数据
let cacheData = [];
cacheData = newData.map((item) => {
if (item.key === key) {
if (cacheOriginData[key]) {
const originItem = {
...item,
...cacheOriginData[key],
editable: false,
};
delete cacheOriginData[key];
setCacheOriginData(cacheOriginData);
return originItem;
}
}
return item;
});
setData(cacheData);
setClickedCancel(false);
};
const columns = [
{
title: '成员姓名',
dataIndex: 'name',
key: 'name',
width: '20%',
render: (text: string, record: TableFormDateType) => {
if (record.editable) {
return (
<Input
value={text}
autoFocus
onChange={(e) => handleFieldChange(e, 'name', record.key)}
onKeyPress={(e) => handleKeyPress(e, record.key)}
placeholder="成员姓名"
/>
);
}
return text;
},
},
{
title: '工号',
dataIndex: 'workId',
key: 'workId',
width: '20%',
render: (text: string, record: TableFormDateType) => {
if (record.editable) {
return (
<Input
value={text}
onChange={(e) => handleFieldChange(e, 'workId', record.key)}
onKeyPress={(e) => handleKeyPress(e, record.key)}
placeholder="工号"
/>
);
}
return text;
},
},
{
title: '所属部门',
dataIndex: 'department',
key: 'department',
width: '40%',
render: (text: string, record: TableFormDateType) => {
if (record.editable) {
return (
<Input
value={text}
onChange={(e) => handleFieldChange(e, 'department', record.key)}
onKeyPress={(e) => handleKeyPress(e, record.key)}
placeholder="所属部门"
/>
);
}
return text;
},
},
{
title: '操作',
key: 'action',
render: (_text: string, record: TableFormDateType) => {
if (!!record.editable && loading) {
return null;
}
if (record.editable) {
if (record.isNew) {
return (
<span>
<a onClick={(e) => saveRow(e, record.key)}></a>
<Divider type="vertical" />
<Popconfirm
title="是否要删除此行?"
onConfirm={() => remove(record.key)}
>
<a></a>
</Popconfirm>
</span>
);
}
return (
<span>
<a onClick={(e) => saveRow(e, record.key)}></a>
<Divider type="vertical" />
<a onClick={(e) => cancel(e, record.key)}></a>
</span>
);
}
return (
<span>
<a onClick={(e) => toggleEditable(e, record.key)}></a>
<Divider type="vertical" />
<Popconfirm
title="是否要删除此行?"
onConfirm={() => remove(record.key)}
>
<a></a>
</Popconfirm>
</span>
);
},
},
];
return (
<>
<Table<TableFormDateType>
loading={loading}
columns={columns}
dataSource={data}
pagination={false}
rowClassName={(record) => (record.editable ? styles.editable : '')}
/>
<Button
style={{
width: '100%',
marginTop: 16,
marginBottom: 8,
}}
type="dashed"
onClick={newMember}
>
<PlusOutlined />
</Button>
</>
);
};
export default TableForm;

549
src/pages/form/advanced-form/index.tsx

@ -0,0 +1,549 @@
import { CloseCircleOutlined } from '@ant-design/icons';
import type { ProColumnType } from '@ant-design/pro-components';
import {
EditableProTable,
FooterToolbar,
PageContainer,
ProForm,
ProFormDateRangePicker,
ProFormSelect,
ProFormText,
ProFormTimePicker,
} from '@ant-design/pro-components';
import { Card, Col, message, Popover, Row } from 'antd';
import type { FC } from 'react';
import { useState } from 'react';
import { fakeSubmitForm } from './service';
import useStyles from './style.style';
interface TableFormDateType {
key: string;
workId?: string;
name?: string;
department?: string;
isNew?: boolean;
editable?: boolean;
}
type InternalNamePath = (string | number)[];
const fieldLabels = {
name: '仓库名',
url: '仓库域名',
owner: '仓库管理员',
approver: '审批人',
dateRange: '生效日期',
type: '仓库类型',
name2: '任务名',
url2: '任务描述',
owner2: '执行人',
approver2: '责任人',
dateRange2: '生效日期',
type2: '任务类型',
};
const tableData = [
{
key: '1',
workId: '00001',
name: 'John Brown',
department: 'New York No. 1 Lake Park',
},
{
key: '2',
workId: '00002',
name: 'Jim Green',
department: 'London No. 1 Lake Park',
},
{
key: '3',
workId: '00003',
name: 'Joe Black',
department: 'Sidney No. 1 Lake Park',
},
];
interface ErrorField {
name: InternalNamePath;
errors: string[];
}
const AdvancedForm: FC<Record<string, any>> = () => {
const { styles } = useStyles();
const [error, setError] = useState<ErrorField[]>([]);
const getErrorInfo = (errors: ErrorField[]) => {
const errorCount = errors.filter((item) => item.errors.length > 0).length;
if (!errors || errorCount === 0) {
return null;
}
const scrollToField = (fieldKey: string) => {
const labelNode = document.querySelector(`label[for="${fieldKey}"]`);
if (labelNode) {
labelNode.scrollIntoView(true);
}
};
const errorList = errors.map((err) => {
if (!err || err.errors.length === 0) {
return null;
}
const key = err.name[0] as
| 'name'
| 'url'
| 'owner'
| 'approver'
| 'dateRange'
| 'type';
return (
<li
key={key}
className={styles.errorListItem}
onClick={() => scrollToField(key)}
>
<CloseCircleOutlined className={styles.errorIcon} />
<div>{err.errors[0]}</div>
<div className={styles.errorField}>{fieldLabels[key]}</div>
</li>
);
});
return (
<span className={styles.errorIcon}>
<Popover
title="表单校验信息"
content={errorList}
overlayClassName={styles.errorPopover}
trigger="click"
getPopupContainer={(trigger: HTMLElement) => {
if (trigger?.parentNode) {
return trigger.parentNode as HTMLElement;
}
return trigger;
}}
>
<CloseCircleOutlined />
</Popover>
{errorCount}
</span>
);
};
const onFinish = async (values: Record<string, any>) => {
setError([]);
try {
await fakeSubmitForm(values);
message.success('提交成功');
} catch {
// console.log
}
};
const onFinishFailed = (errorInfo: any) => {
setError(errorInfo.errorFields);
};
const columns: ProColumnType<TableFormDateType>[] = [
{
title: '成员姓名',
dataIndex: 'name',
key: 'name',
width: '20%',
},
{
title: '工号',
dataIndex: 'workId',
key: 'workId',
width: '20%',
},
{
title: '所属部门',
dataIndex: 'department',
key: 'department',
width: '40%',
},
{
title: '操作',
key: 'action',
valueType: 'option',
render: (_, record: TableFormDateType, _index, action) => {
return [
<a
key="eidit"
onClick={() => {
action?.startEditable(record.key);
}}
>
</a>,
];
},
},
];
return (
<ProForm
layout="vertical"
hideRequiredMark
submitter={{
render: (_props, dom) => {
return (
<FooterToolbar>
{getErrorInfo(error)}
{dom}
</FooterToolbar>
);
},
}}
initialValues={{
members: tableData,
}}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
>
<PageContainer content="高级表单常见于一次性输入和提交大批量数据的场景。">
<Card title="仓库管理" className={styles.card} variant="borderless">
<Row gutter={16}>
<Col lg={6} md={12} sm={24}>
<ProFormText
label={fieldLabels.name}
name="name"
rules={[
{
required: true,
message: '请输入仓库名称',
},
]}
placeholder="请输入仓库名称"
/>
</Col>
<Col
xl={{
span: 6,
offset: 2,
}}
lg={{
span: 8,
}}
md={{
span: 12,
}}
sm={24}
>
<ProFormText
label={fieldLabels.url}
name="url"
rules={[
{
required: true,
message: '请选择',
},
]}
fieldProps={{
style: {
width: '100%',
},
addonBefore: 'http://',
addonAfter: '.com',
}}
placeholder="请输入"
/>
</Col>
<Col
xl={{
span: 8,
offset: 2,
}}
lg={{
span: 10,
}}
md={{
span: 24,
}}
sm={24}
>
<ProFormSelect
label={fieldLabels.owner}
name="owner"
rules={[
{
required: true,
message: '请选择管理员',
},
]}
options={[
{
label: '付晓晓',
value: 'xiao',
},
{
label: '周毛毛',
value: 'mao',
},
]}
placeholder="请选择管理员"
/>
</Col>
</Row>
<Row gutter={16}>
<Col lg={6} md={12} sm={24}>
<ProFormSelect
label={fieldLabels.approver}
name="approver"
rules={[
{
required: true,
message: '请选择审批员',
},
]}
options={[
{
label: '付晓晓',
value: 'xiao',
},
{
label: '周毛毛',
value: 'mao',
},
]}
placeholder="请选择审批员"
/>
</Col>
<Col
xl={{
span: 6,
offset: 2,
}}
lg={{
span: 8,
}}
md={{
span: 12,
}}
sm={24}
>
<ProFormDateRangePicker
label={fieldLabels.dateRange}
name="dateRange"
fieldProps={{
style: {
width: '100%',
},
}}
rules={[
{
required: true,
message: '请选择生效日期',
},
]}
/>
</Col>
<Col
xl={{
span: 8,
offset: 2,
}}
lg={{
span: 10,
}}
md={{
span: 24,
}}
sm={24}
>
<ProFormSelect
label={fieldLabels.type}
name="type"
rules={[
{
required: true,
message: '请选择仓库类型',
},
]}
options={[
{
label: '私密',
value: 'private',
},
{
label: '公开',
value: 'public',
},
]}
placeholder="请选择仓库类型"
/>
</Col>
</Row>
</Card>
<Card title="任务管理" className={styles.card} variant="borderless">
<Row gutter={16}>
<Col lg={6} md={12} sm={24}>
<ProFormText
label={fieldLabels.name2}
name="name2"
rules={[
{
required: true,
message: '请输入',
},
]}
/>
</Col>
<Col
xl={{
span: 6,
offset: 2,
}}
lg={{
span: 8,
}}
md={{
span: 12,
}}
sm={24}
>
<ProFormText
label={fieldLabels.url2}
name="url2"
rules={[
{
required: true,
message: '请选择',
},
]}
/>
</Col>
<Col
xl={{
span: 8,
offset: 2,
}}
lg={{
span: 10,
}}
md={{
span: 24,
}}
sm={24}
>
<ProFormSelect
label={fieldLabels.owner2}
name="owner2"
rules={[
{
required: true,
message: '请选择管理员',
},
]}
options={[
{
label: '付晓晓',
value: 'xiao',
},
{
label: '周毛毛',
value: 'mao',
},
]}
/>
</Col>
</Row>
<Row gutter={16}>
<Col lg={6} md={12} sm={24}>
<ProFormSelect
label={fieldLabels.approver2}
name="approver2"
rules={[
{
required: true,
message: '请选择审批员',
},
]}
options={[
{
label: '付晓晓',
value: 'xiao',
},
{
label: '周毛毛',
value: 'mao',
},
]}
placeholder="请选择审批员"
/>
</Col>
<Col
xl={{
span: 6,
offset: 2,
}}
lg={{
span: 8,
}}
md={{
span: 12,
}}
sm={24}
>
<ProFormTimePicker
label={fieldLabels.dateRange2}
name="dateRange2"
rules={[
{
required: true,
message: '请输入',
},
]}
placeholder="提醒时间"
fieldProps={{
style: {
width: '100%',
},
}}
/>
</Col>
<Col
xl={{
span: 8,
offset: 2,
}}
lg={{
span: 10,
}}
md={{
span: 24,
}}
sm={24}
>
<ProFormSelect
label={fieldLabels.type2}
name="type2"
rules={[
{
required: true,
message: '请选择仓库类型',
},
]}
options={[
{
label: '私密',
value: 'private',
},
{
label: '公开',
value: 'public',
},
]}
placeholder="请选择仓库类型"
/>
</Col>
</Row>
</Card>
<Card title="成员管理" variant="borderless">
<ProForm.Item name="members">
<EditableProTable<TableFormDateType>
recordCreatorProps={{
record: () => {
return {
key: `0${Date.now()}`,
};
},
}}
columns={columns}
rowKey="key"
/>
</ProForm.Item>
</Card>
</PageContainer>
</ProForm>
);
};
export default AdvancedForm;

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

Loading…
Cancel
Save