Browse Source

Merge branch 'all-blocks' into master

pull/11516/head
afc163 9 months ago
committed by GitHub
parent
commit
9d17ae9e85
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 30
      .github/workflows/deploy.yml
  2. 1
      .npmrc
  3. 7
      biome.json
  4. 12
      config/config.ts
  5. 249
      config/routes.ts
  6. 3
      jest.config.ts
  7. 210
      mock/analysis.mock.ts
  8. 14
      mock/monitor.mock.ts
  9. 418
      mock/workplace.mock.ts
  10. 9
      package.json
  11. 11
      src/app.tsx
  12. 1
      src/components/Footer/index.tsx
  13. 1
      src/components/RightContent/index.tsx
  14. 42
      src/global.style.ts
  15. 2
      src/loading.tsx
  16. 24
      src/pages/404.tsx
  17. 213
      src/pages/TableList/components/UpdateForm.tsx
  18. 69
      src/pages/account/center/Center.style.ts
  19. 249
      src/pages/account/center/_mock.ts
  20. 43
      src/pages/account/center/components/Applications/index.style.ts
  21. 128
      src/pages/account/center/components/Applications/index.tsx
  22. 31
      src/pages/account/center/components/ArticleListContent/index.style.ts
  23. 29
      src/pages/account/center/components/ArticleListContent/index.tsx
  24. 14
      src/pages/account/center/components/Articles/index.style.ts
  25. 70
      src/pages/account/center/components/Articles/index.tsx
  26. 41
      src/pages/account/center/components/AvatarList/index.style.ts
  27. 89
      src/pages/account/center/components/AvatarList/index.tsx
  28. 49
      src/pages/account/center/components/Projects/index.style.ts
  29. 65
      src/pages/account/center/components/Projects/index.tsx
  30. 75
      src/pages/account/center/data.d.ts
  31. 278
      src/pages/account/center/index.tsx
  32. 14
      src/pages/account/center/service.ts
  33. 79
      src/pages/account/settings/_mock.ts
  34. 39
      src/pages/account/settings/components/PhoneView.tsx
  35. 234
      src/pages/account/settings/components/base.tsx
  36. 50
      src/pages/account/settings/components/binding.tsx
  37. 60
      src/pages/account/settings/components/index.style.ts
  38. 46
      src/pages/account/settings/components/notification.tsx
  39. 60
      src/pages/account/settings/components/security.tsx
  40. 43
      src/pages/account/settings/data.d.ts
  41. 1784
      src/pages/account/settings/geographic/city.json
  42. 138
      src/pages/account/settings/geographic/province.json
  43. 108
      src/pages/account/settings/index.tsx
  44. 20
      src/pages/account/settings/service.ts
  45. 74
      src/pages/account/settings/style.style.ts
  46. 210
      src/pages/dashboard/analysis/_mock.ts
  47. 75
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.less
  48. 77
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.style.ts
  49. 109
      src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx
  50. 17
      src/pages/dashboard/analysis/components/Charts/Field/index.less
  51. 22
      src/pages/dashboard/analysis/components/Charts/Field/index.style.ts
  52. 17
      src/pages/dashboard/analysis/components/Charts/Field/index.tsx
  53. 48
      src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx
  54. 225
      src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx
  55. 19
      src/pages/dashboard/analysis/components/Charts/index.less
  56. 23
      src/pages/dashboard/analysis/components/Charts/index.style.ts
  57. 13
      src/pages/dashboard/analysis/components/Charts/index.tsx
  58. 168
      src/pages/dashboard/analysis/components/IntroduceRow.tsx
  59. 68
      src/pages/dashboard/analysis/components/NumberInfo/index.less
  60. 56
      src/pages/dashboard/analysis/components/NumberInfo/index.style.ts
  61. 79
      src/pages/dashboard/analysis/components/NumberInfo/index.tsx
  62. 110
      src/pages/dashboard/analysis/components/OfflineData.tsx
  63. 9
      src/pages/dashboard/analysis/components/PageLoading/index.tsx
  64. 67
      src/pages/dashboard/analysis/components/ProportionSales.tsx
  65. 225
      src/pages/dashboard/analysis/components/SalesCard.tsx
  66. 181
      src/pages/dashboard/analysis/components/TopSearch.tsx
  67. 37
      src/pages/dashboard/analysis/components/Trend/index.less
  68. 32
      src/pages/dashboard/analysis/components/Trend/index.style.ts
  69. 47
      src/pages/dashboard/analysis/components/Trend/index.tsx
  70. 45
      src/pages/dashboard/analysis/data.d.ts
  71. 157
      src/pages/dashboard/analysis/index.tsx
  72. 6
      src/pages/dashboard/analysis/service.ts
  73. 189
      src/pages/dashboard/analysis/style.less
  74. 160
      src/pages/dashboard/analysis/style.style.ts
  75. 33
      src/pages/dashboard/analysis/utils/Yuan.tsx
  76. 57
      src/pages/dashboard/analysis/utils/utils.ts
  77. 14
      src/pages/dashboard/monitor/_mock.ts
  78. 51
      src/pages/dashboard/monitor/components/ActiveChart/index.less
  79. 48
      src/pages/dashboard/monitor/components/ActiveChart/index.style.ts
  80. 93
      src/pages/dashboard/monitor/components/ActiveChart/index.tsx
  81. 225
      src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx
  82. 78
      src/pages/dashboard/monitor/components/Charts/autoHeight.tsx
  83. 153
      src/pages/dashboard/monitor/components/Map/index.tsx
  84. 5
      src/pages/dashboard/monitor/data.d.ts
  85. 197
      src/pages/dashboard/monitor/index.tsx
  86. 6
      src/pages/dashboard/monitor/service.ts
  87. 21
      src/pages/dashboard/monitor/style.less
  88. 16
      src/pages/dashboard/monitor/style.style.ts
  89. 410
      src/pages/dashboard/workplace/_mock.ts
  90. 16
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.less
  91. 21
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.style.ts
  92. 38
      src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx
  93. 111
      src/pages/dashboard/workplace/data.d.ts
  94. 286
      src/pages/dashboard/workplace/index.tsx
  95. 14
      src/pages/dashboard/workplace/service.ts
  96. 251
      src/pages/dashboard/workplace/style.less
  97. 215
      src/pages/dashboard/workplace/style.style.ts
  98. 17
      src/pages/exception/403/index.tsx
  99. 17
      src/pages/exception/404/index.tsx
  100. 17
      src/pages/exception/500/index.tsx

30
.github/workflows/deploy.yml

@ -0,0 +1,30 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- all-blocks
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies with Bun
run: bun install
- name: Build project
run: bun run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
cname: preview.pro.ant.design

1
.npmrc

@ -0,0 +1 @@
legacy-peer-deps=true

7
biome.json

@ -14,6 +14,7 @@
"!**/server/**",
"!**/public/**",
"!**/coverage/**",
"!**/node_modules/**",
"!biome.json"
]
},
@ -28,9 +29,13 @@
"suspicious": {
"noExplicitAny": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"a11y": {
"noStaticElementInteractions": "off",
"useValidAnchor": "off"
"useValidAnchor": "off",
"useKeyWithClickEvents": "off"
}
}
},

12
config/config.ts

@ -4,6 +4,7 @@ import { join } from 'node:path';
import { defineConfig } from '@umijs/max';
import defaultSettings from './defaultSettings';
import proxy from './proxy';
import routes from './routes';
const { REACT_APP_ENV = 'dev' } = process.env;
@ -44,13 +45,9 @@ export default defineConfig({
* @name
* @description less
* @doc antd的主题设置 https://ant.design/docs/react/customize-theme-cn
* @doc umi theme https://umijs.org/docs/api/config#theme
* @doc umi theme https://umijs.org/docs/api/config#theme
*/
theme: {
// 如果不想要 configProvide 动态设置主题需要把这个设置为 default
// 只有设置为 variable, 才能使用 configProvide 动态设置主色调
'root-entry-name': 'variable',
},
// theme: { '@primary-color': '#1DA57A' }
/**
* @name moment
* @description js的包大小
@ -169,6 +166,9 @@ export default defineConfig({
projectName: 'swagger',
},
],
mock: {
include: ['mock/**/*', 'src/pages/**/_mock.ts'],
},
/**
* @name mako
* @description 使 mako

249
config/routes.ts

@ -16,48 +16,253 @@ export default [
layout: false,
routes: [
{
name: 'login',
path: '/user/login',
component: './User/Login',
layout: false,
name: 'login',
component: './user/login',
},
{
path: '/user',
redirect: '/user/login',
},
{
name: 'register-result',
icon: 'smile',
path: '/user/register-result',
component: './user/register-result',
},
{
name: 'register',
icon: 'smile',
path: '/user/register',
component: './user/register',
},
{
component: '404',
path: '/user/*',
},
],
},
{
path: '/welcome',
name: 'welcome',
icon: 'smile',
component: './Welcome',
path: '/dashboard',
name: 'dashboard',
icon: 'dashboard',
routes: [
{
path: '/dashboard',
redirect: '/dashboard/analysis',
},
{
name: 'analysis',
icon: 'smile',
path: '/dashboard/analysis',
component: './dashboard/analysis',
},
{
name: 'monitor',
icon: 'smile',
path: '/dashboard/monitor',
component: './dashboard/monitor',
},
{
name: 'workplace',
icon: 'smile',
path: '/dashboard/workplace',
component: './dashboard/workplace',
},
],
},
{
path: '/admin',
name: 'admin',
icon: 'crown',
access: 'canAdmin',
path: '/form',
icon: 'form',
name: 'form',
routes: [
{
path: '/admin',
redirect: '/admin/sub-page',
path: '/form',
redirect: '/form/basic-form',
},
{
path: '/admin/sub-page',
name: 'sub-page',
component: './Admin',
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',
},
],
},
{
name: 'list.table-list',
icon: 'table',
path: '/list',
component: './TableList',
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: '/',
redirect: '/welcome',
redirect: '/dashboard/analysis',
},
{
path: '*',
layout: false,
component: './404',
component: '404',
path: '/*',
},
];

3
jest.config.ts

@ -1,6 +1,7 @@
import type { Config } from '@jest/types';
import { configUmiAlias, createConfig } from '@umijs/max/test';
export default async () => {
export default async (): Promise<Config.InitialOptions> => {
const config = await configUmiAlias({
...createConfig({
target: 'browser',

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

14
mock/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,
};

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

9
package.json

@ -43,7 +43,12 @@
"classnames": "^2.5.1",
"dayjs": "^1.11.13",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"@ant-design/plots": "^2.6.0",
"@antv/l7-react": "^2.4.3",
"@antv/l7": "^2.22.7",
"numeral": "^2.0.6",
"rc-util": "^5.44.4"
},
"devDependencies": {
"@ant-design/pro-cli": "^3.3.0",
@ -61,7 +66,7 @@
"cross-env": "^7.0.3",
"express": "^4.21.1",
"gh-pages": "^6.1.1",
"husky": "^9.1.6",
"husky": "^9.1.7",
"jest": "^30.0.4",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^16.1.2",

11
src/app.tsx

@ -1,7 +1,7 @@
import { LinkOutlined } from '@ant-design/icons';
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
import { SettingDrawer } from '@ant-design/pro-components';
import type { RunTimeLayoutConfig } from '@umijs/max';
import type { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
import { history, Link } from '@umijs/max';
import React from 'react';
import {
@ -41,7 +41,11 @@ export async function getInitialState(): Promise<{
};
// 如果不是登录页面,执行
const { location } = history;
if (location.pathname !== loginPath) {
if (
![loginPath, '/user/register', '/user/register-result'].includes(
location.pathname,
)
) {
const currentUser = await fetchUserInfo();
return {
fetchUserInfo,
@ -145,6 +149,7 @@ export const layout: RunTimeLayoutConfig = ({
* axios ahooks useRequest
* @doc https://umijs.org/docs/max/request#配置
*/
export const request = {
export const request: RequestConfig = {
baseURL: 'https://proapi.azurewebsites.net',
...errorConfig,
};

1
src/components/Footer/index.tsx

@ -8,6 +8,7 @@ const Footer: React.FC = () => {
style={{
background: 'none',
}}
copyright="Powered by Ant Desgin"
links={[
{
key: 'Ant Design Pro',

1
src/components/RightContent/index.tsx

@ -1,6 +1,5 @@
import { QuestionCircleOutlined } from '@ant-design/icons';
import { SelectLang as UmiSelectLang } from '@umijs/max';
import React from 'react';
export type SiderTheme = 'light' | 'dark';

42
src/global.style.ts

@ -0,0 +1,42 @@
import { createStyles } from 'antd-style';
const useStyles = createStyles(() => {
return {
colorWeak: {
filter: 'invert(80%)',
},
'ant-layout': {
minHeight: '100vh',
},
'ant-pro-sider.ant-layout-sider.ant-pro-sider-fixed': {
left: 'unset',
},
canvas: {
display: 'block',
},
body: {
textRendering: 'optimizeLegibility',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
},
'ul,ol': {
listStyle: 'none',
},
'@media(max-width: 768px)': {
'ant-table': {
width: '100%',
overflowX: 'auto',
'&-thead > tr, &-tbody > tr': {
'> th, > td': {
whiteSpace: 'pre',
'> span': {
display: 'block',
},
},
},
},
},
};
});
export default useStyles;

2
src/loading.tsx

@ -1,7 +1,7 @@
import { Skeleton } from 'antd';
const Loading: React.FC = () => (
<Skeleton style={{ margin: '24px 40px' }} active />
<Skeleton style={{ margin: '24px 40px', height: '60vh' }} active />
);
export default Loading;

24
src/pages/404.tsx

@ -1,18 +1,20 @@
import { history, useIntl } from '@umijs/max';
import { Button, Result } from 'antd';
import { Button, Card, Result } from 'antd';
import React from 'react';
const NoFoundPage: React.FC = () => (
<Result
status="404"
title="404"
subTitle={useIntl().formatMessage({ id: 'pages.404.subTitle' })}
extra={
<Button type="primary" onClick={() => history.push('/')}>
{useIntl().formatMessage({ id: 'pages.404.buttonText' })}
</Button>
}
/>
<Card variant="borderless">
<Result
status="404"
title="404"
subTitle={useIntl().formatMessage({ id: 'pages.404.subTitle' })}
extra={
<Button type="primary" onClick={() => history.push('/')}>
{useIntl().formatMessage({ id: 'pages.404.buttonText' })}
</Button>
}
/>
</Card>
);
export default NoFoundPage;

213
src/pages/TableList/components/UpdateForm.tsx

@ -1,213 +0,0 @@
import {
ProFormDateTimePicker,
ProFormRadio,
ProFormSelect,
ProFormText,
ProFormTextArea,
StepsForm,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Modal } from 'antd';
import React from 'react';
export type FormValueType = {
target?: string;
template?: string;
type?: string;
time?: string;
frequency?: string;
} & Partial<API.RuleListItem>;
export type UpdateFormProps = {
onCancel: (flag?: boolean, formVals?: FormValueType) => void;
onSubmit: (values: FormValueType) => Promise<void>;
updateModalOpen: boolean;
values: Partial<API.RuleListItem>;
};
const UpdateForm: React.FC<UpdateFormProps> = (props) => {
const intl = useIntl();
return (
<StepsForm
stepsProps={{
size: 'small',
}}
stepsFormRender={(dom, submitter) => {
return (
<Modal
width={640}
styles={{
body: {
padding: '32px 40px 48px',
},
}}
destroyOnHidden
title={intl.formatMessage({
id: 'pages.searchTable.updateForm.ruleConfig',
defaultMessage: '规则配置',
})}
open={props.updateModalOpen}
footer={submitter}
onCancel={() => {
props.onCancel();
}}
>
{dom}
</Modal>
);
}}
onFinish={props.onSubmit}
>
<StepsForm.StepForm
initialValues={{
name: props.values.name,
desc: props.values.desc,
}}
title={intl.formatMessage({
id: 'pages.searchTable.updateForm.basicConfig',
defaultMessage: '基本信息',
})}
>
<ProFormText
name="name"
label={intl.formatMessage({
id: 'pages.searchTable.updateForm.ruleName.nameLabel',
defaultMessage: '规则名称',
})}
width="md"
rules={[
{
required: true,
message: (
<FormattedMessage
id="pages.searchTable.updateForm.ruleName.nameRules"
defaultMessage="请输入规则名称!"
/>
),
},
]}
/>
<ProFormTextArea
name="desc"
width="md"
label={intl.formatMessage({
id: 'pages.searchTable.updateForm.ruleDesc.descLabel',
defaultMessage: '规则描述',
})}
placeholder={intl.formatMessage({
id: 'pages.searchTable.updateForm.ruleDesc.descPlaceholder',
defaultMessage: '请输入至少五个字符',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="pages.searchTable.updateForm.ruleDesc.descRules"
defaultMessage="请输入至少五个字符的规则描述!"
/>
),
min: 5,
},
]}
/>
</StepsForm.StepForm>
<StepsForm.StepForm
initialValues={{
target: '0',
template: '0',
}}
title={intl.formatMessage({
id: 'pages.searchTable.updateForm.ruleProps.title',
defaultMessage: '配置规则属性',
})}
>
<ProFormSelect
name="target"
width="md"
label={intl.formatMessage({
id: 'pages.searchTable.updateForm.object',
defaultMessage: '监控对象',
})}
valueEnum={{
0: '表一',
1: '表二',
}}
/>
<ProFormSelect
name="template"
width="md"
label={intl.formatMessage({
id: 'pages.searchTable.updateForm.ruleProps.templateLabel',
defaultMessage: '规则模板',
})}
valueEnum={{
0: '规则模板一',
1: '规则模板二',
}}
/>
<ProFormRadio.Group
name="type"
label={intl.formatMessage({
id: 'pages.searchTable.updateForm.ruleProps.typeLabel',
defaultMessage: '规则类型',
})}
options={[
{
value: '0',
label: '强',
},
{
value: '1',
label: '弱',
},
]}
/>
</StepsForm.StepForm>
<StepsForm.StepForm
initialValues={{
type: '1',
frequency: 'month',
}}
title={intl.formatMessage({
id: 'pages.searchTable.updateForm.schedulingPeriod.title',
defaultMessage: '设定调度周期',
})}
>
<ProFormDateTimePicker
name="time"
width="md"
label={intl.formatMessage({
id: 'pages.searchTable.updateForm.schedulingPeriod.timeLabel',
defaultMessage: '开始时间',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="pages.searchTable.updateForm.schedulingPeriod.timeRules"
defaultMessage="请选择开始时间!"
/>
),
},
]}
/>
<ProFormSelect
name="frequency"
label={intl.formatMessage({
id: 'pages.searchTable.updateForm.object',
defaultMessage: '监控对象',
})}
width="md"
valueEnum={{
month: '月',
week: '周',
}}
/>
</StepsForm.StepForm>
</StepsForm>
);
};
export default UpdateForm;

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;

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

@ -0,0 +1,128 @@
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 Applications: React.FC = () => {
const { styles: stylesApplications } = useStyles();
// 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
});
});
const CardInfo: React.FC<{
activeUser: React.ReactNode;
newUser: React.ReactNode;
}> = ({ activeUser, newUser }) => (
<div className={stylesApplications.cardInfo}>
<div>
<p></p>
<p>{activeUser}</p>
</div>
<div>
<p></p>
<p>{newUser}</p>
</div>
</div>
);
return (
<List<ListItemDataType>
rowKey="id"
className={stylesApplications.filterCardList}
grid={{
gutter: 24,
xxl: 3,
xl: 2,
lg: 2,
md: 2,
sm: 2,
xs: 1,
}}
dataSource={listData?.list || []}
renderItem={(item) => (
<List.Item key={item.id}>
<Card
hoverable
bodyStyle={{
paddingBottom: 20,
}}
actions={[
<Tooltip key="download" title="下载">
<DownloadOutlined />
</Tooltip>,
<Tooltip title="编辑" key="edit">
<EditOutlined />
</Tooltip>,
<Tooltip title="分享" key="share">
<ShareAltOutlined />
</Tooltip>,
<Dropdown
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;

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

@ -0,0 +1,70 @@
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 Articles: React.FC = () => {
const { styles } = useStyles();
const IconText: React.FC<{
icon: React.ReactNode;
text: React.ReactNode;
}> = ({ icon, text }) => (
<span>
{icon} {text}
</span>
);
// 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
});
});
return (
<List<ListItemDataType>
size="large"
className={styles.articleList}
rowKey="id"
itemLayout="vertical"
dataSource={listData?.list || []}
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
bordered={false}
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;

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

@ -0,0 +1,234 @@
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 AvatarView = ({ avatar }: { avatar: string }) => (
<>
<div className={styles.avatar_title}></div>
<div className={styles.avatar}>
<img src={avatar} alt="avatar" />
</div>
<Upload showUploadList={false}>
<div className={styles.button_view}>
<Button>
<UploadOutlined />
</Button>
</div>
</Upload>
</>
);
const { 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;

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

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

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;

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

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

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;

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

@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Card } from 'antd';
import type { CardProps } from 'antd/es/card';
import classNames from 'classnames';
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;
return (
<Card
loading={loading}
styles={{
body: {
padding: '20px 24px 8px 24px',
},
}}
{...rest}
>
{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;
strokeWidth?: number;
percent?: number;
style?: React.CSSProperties;
};
const MiniProgress: React.FC<MiniProgressProps> = ({
targetLabel,
target,
color = 'rgb(19, 194, 194)',
strokeWidth,
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: strokeWidth || 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
bordered={false}
title="总销售额"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
loading={loading}
total={() => <Yuan>126560</Yuan>}
footer={
<Field
label="日销售额"
value={`${numeral(12423).format('0,0')}`}
/>
}
contentHeight={46}
>
<Trend
flag="up"
style={{
marginRight: 16,
}}
>
<span className={styles.trendText}>12%</span>
</Trend>
<Trend flag="down">
<span className={styles.trendText}>11%</span>
</Trend>
</ChartCard>
</Col>
<Col {...topColResponsiveProps}>
<ChartCard
bordered={false}
loading={loading}
title="访问量"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
total={numeral(8846).format('0,0')}
footer={
<Field label="日访问量" value={numeral(1234).format('0,0')} />
}
contentHeight={46}
>
<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
bordered={false}
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}
bordered={false}
title="运营活动效果"
action={
<Tooltip title="指标说明">
<InfoCircleOutlined />
</Tooltip>
}
total="78%"
footer={
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
}}
>
<Trend
flag="up"
style={{
marginRight: 16,
}}
>
<span className={styles.trendText}>12%</span>
</Trend>
<Trend flag="down">
<span className={styles.trendText}>11%</span>
</Trend>
</div>
}
contentHeight={46}
>
<Progress
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}
bordered={false}
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}
bordered={false}
title="线上热门搜索"
extra={dropdownGroup}
style={{
height: '100%',
}}
>
<Row gutter={68}>
<Col
sm={12}
xs={24}
style={{
marginBottom: 24,
}}
>
<NumberInfo
subTitle={
<span>
<Tooltip title="指标说明">
<InfoCircleOutlined
style={{
marginLeft: 8,
}}
/>
</Tooltip>
</span>
}
gap={8}
total={numeral(12321).format('0,0')}
status="up"
subTotal={17.1}
/>
<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;
};

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

@ -0,0 +1,197 @@
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 { Countdown } = Statistic;
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="活动实时交易情况" bordered={false}>
<Row>
<Col md={6} sm={12} xs={24}>
<Statistic
title="今日交易总额"
suffix="元"
value={numeral(124543233).format('0,0')}
/>
</Col>
<Col md={6} sm={12} xs={24}>
<Statistic title="销售目标完成率" value="92%" />
</Col>
<Col md={6} sm={12} xs={24}>
<Countdown
title="活动剩余时间"
value={deadline}
format="HH:mm:ss:SSS"
/>
</Col>
<Col md={6} sm={12} xs={24}>
<Statistic
title="每秒交易总额"
suffix="元"
value={numeral(234).format('0,0')}
/>
</Col>
</Row>
<div className={styles.mapChart}>
<MonitorMap />
</div>
</Card>
</Col>
<Col xl={6} lg={24} md={24} sm={24} xs={24}>
<Card
title="活动情况预测"
style={{
marginBottom: 24,
}}
bordered={false}
>
<ActiveChart />
</Card>
<Card
title="券核效率"
style={{
marginBottom: 24,
}}
bodyStyle={{
textAlign: 'center',
}}
bordered={false}
>
<Gauge
height={180}
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="各品类占比" bordered={false}>
<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}
bordered={false}
bodyStyle={{
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="资源剩余"
bodyStyle={{
textAlign: 'center',
fontSize: 0,
}}
bordered={false}
>
<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>
);

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

Loading…
Cancel
Save