diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..00403017 --- /dev/null +++ b/.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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/biome.json b/biome.json index b5e4ed5f..f007e4c7 100644 --- a/biome.json +++ b/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" } } }, diff --git a/config/config.ts b/config/config.ts index 47c82008..dfd06bc3 100644 --- a/config/config.ts +++ b/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 极速研发 diff --git a/config/routes.ts b/config/routes.ts index 9a68bcb2..aaf6f182 100644 --- a/config/routes.ts +++ b/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: '/*', }, ]; diff --git a/jest.config.ts b/jest.config.ts index 2bf149ca..8fc87ad1 100644 --- a/jest.config.ts +++ b/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 => { const config = await configUmiAlias({ ...createConfig({ target: 'browser', diff --git a/mock/analysis.mock.ts b/mock/analysis.mock.ts new file mode 100644 index 00000000..b09b8b07 --- /dev/null +++ b/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, +}; diff --git a/mock/monitor.mock.ts b/mock/monitor.mock.ts new file mode 100644 index 00000000..dcd1b179 --- /dev/null +++ b/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, +}; diff --git a/mock/workplace.mock.ts b/mock/workplace.mock.ts new file mode 100644 index 00000000..8640d56d --- /dev/null +++ b/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, +}; diff --git a/package.json b/package.json index 00bfdd4e..2b9b616e 100644 --- a/package.json +++ b/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", diff --git a/src/app.tsx b/src/app.tsx index eb530156..599b8111 100644 --- a/src/app.tsx +++ b/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, }; diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx index f204ac29..64359c6a 100644 --- a/src/components/Footer/index.tsx +++ b/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', diff --git a/src/components/RightContent/index.tsx b/src/components/RightContent/index.tsx index 1b1dedc7..5d88fc4f 100644 --- a/src/components/RightContent/index.tsx +++ b/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'; diff --git a/src/global.style.ts b/src/global.style.ts new file mode 100644 index 00000000..193148ff --- /dev/null +++ b/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; diff --git a/src/loading.tsx b/src/loading.tsx index 21a2747f..fd4f25bb 100644 --- a/src/loading.tsx +++ b/src/loading.tsx @@ -1,7 +1,7 @@ import { Skeleton } from 'antd'; const Loading: React.FC = () => ( - + ); export default Loading; diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 46935675..9734cc57 100644 --- a/src/pages/404.tsx +++ b/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 = () => ( - history.push('/')}> - {useIntl().formatMessage({ id: 'pages.404.buttonText' })} - - } - /> + + history.push('/')}> + {useIntl().formatMessage({ id: 'pages.404.buttonText' })} + + } + /> + ); export default NoFoundPage; diff --git a/src/pages/TableList/components/UpdateForm.tsx b/src/pages/TableList/components/UpdateForm.tsx deleted file mode 100644 index 83aa509d..00000000 --- a/src/pages/TableList/components/UpdateForm.tsx +++ /dev/null @@ -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; - -export type UpdateFormProps = { - onCancel: (flag?: boolean, formVals?: FormValueType) => void; - onSubmit: (values: FormValueType) => Promise; - updateModalOpen: boolean; - values: Partial; -}; - -const UpdateForm: React.FC = (props) => { - const intl = useIntl(); - return ( - { - return ( - { - props.onCancel(); - }} - > - {dom} - - ); - }} - onFinish={props.onSubmit} - > - - - ), - }, - ]} - /> - - ), - min: 5, - }, - ]} - /> - - - - - - - - - ), - }, - ]} - /> - - - - ); -}; - -export default UpdateForm; diff --git a/src/pages/account/center/Center.style.ts b/src/pages/account/center/Center.style.ts new file mode 100644 index 00000000..e8d91b75 --- /dev/null +++ b/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; diff --git a/src/pages/account/center/_mock.ts b/src/pages/account/center/_mock.ts new file mode 100644 index 00000000..635dea0a --- /dev/null +++ b/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, +}; diff --git a/src/pages/account/center/components/Applications/index.style.ts b/src/pages/account/center/components/Applications/index.style.ts new file mode 100644 index 00000000..aa149a27 --- /dev/null +++ b/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; diff --git a/src/pages/account/center/components/Applications/index.tsx b/src/pages/account/center/components/Applications/index.tsx new file mode 100644 index 00000000..f0f46237 --- /dev/null +++ b/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 = ( + + {Math.floor(val / 10000)} + + 万 + + + ); + } + 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 }) => ( +
+
+

活跃用户

+

{activeUser}

+
+
+

新增用户

+

{newUser}

+
+
+ ); + return ( + + 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) => ( + + + + , + + + , + + + , + + + , + ]} + > + } + title={item.title} + /> +
+ +
+
+
+ )} + /> + ); +}; +export default Applications; diff --git a/src/pages/account/center/components/ArticleListContent/index.style.ts b/src/pages/account/center/components/ArticleListContent/index.style.ts new file mode 100644 index 00000000..aea3ddbf --- /dev/null +++ b/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; diff --git a/src/pages/account/center/components/ArticleListContent/index.tsx b/src/pages/account/center/components/ArticleListContent/index.tsx new file mode 100644 index 00000000..24f81ad0 --- /dev/null +++ b/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 = ({ + data: { content, updatedAt, avatar, owner, href }, +}) => { + const { styles } = useStyles(); + return ( +
+
{content}
+
+ + {owner} 发布在 {href} + {dayjs(updatedAt).format('YYYY-MM-DD HH:mm')} +
+
+ ); +}; +export default ArticleListContent; diff --git a/src/pages/account/center/components/Articles/index.style.ts b/src/pages/account/center/components/Articles/index.style.ts new file mode 100644 index 00000000..5a9d385f --- /dev/null +++ b/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; diff --git a/src/pages/account/center/components/Articles/index.tsx b/src/pages/account/center/components/Articles/index.tsx new file mode 100644 index 00000000..d2a69eee --- /dev/null +++ b/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 }) => ( + + {icon} {text} + + ); + + // 获取tab列表数据 + const { data: listData } = useRequest(() => { + return queryFakeList({ + count: 30, + }); + }); + return ( + + size="large" + className={styles.articleList} + rowKey="id" + itemLayout="vertical" + dataSource={listData?.list || []} + style={{ + margin: '0 -24px', + }} + renderItem={(item) => ( + } text={item.star} />, + } text={item.like} />, + } + text={item.message} + />, + ]} + > + + {item.title} + + } + description={ + + Ant Design + 设计语言 + 蚂蚁金服 + + } + /> + + + )} + /> + ); +}; +export default Articles; diff --git a/src/pages/account/center/components/AvatarList/index.style.ts b/src/pages/account/center/components/AvatarList/index.style.ts new file mode 100644 index 00000000..d601a1d8 --- /dev/null +++ b/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; diff --git a/src/pages/account/center/components/AvatarList/index.tsx b/src/pages/account/center/components/AvatarList/index.tsx new file mode 100644 index 00000000..67894e6e --- /dev/null +++ b/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; + size?: SizeType; + maxLength?: number; + excessItemsStyle?: React.CSSProperties; + style?: React.CSSProperties; + children: + | React.ReactElement + | React.ReactElement[]; +}; + +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 = ({ + src, + size, + tips, + onClick = () => {}, +}) => { + const { styles } = useStyles(); + const cls = avatarSizeToClassName(styles, size); + return ( +
  • + {tips ? ( + + + + ) : ( + + )} +
  • + ); +}; +const AvatarList: React.FC & { + 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[]; + const childrenWithProps = childrenArray.slice(0, numToShow).map((child) => + React.cloneElement(child, { + size, + }), + ); + if (numToShow < numOfChildren) { + const cls = avatarSizeToClassName(styles, size); + childrenWithProps.push( +
  • + {`+${numOfChildren - maxLength}`} +
  • , + ); + } + return ( +
    +
      {childrenWithProps}
    +
    + ); +}; +AvatarList.Item = Item; +export default AvatarList; diff --git a/src/pages/account/center/components/Projects/index.style.ts b/src/pages/account/center/components/Projects/index.style.ts new file mode 100644 index 00000000..ca34a15b --- /dev/null +++ b/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; diff --git a/src/pages/account/center/components/Projects/index.tsx b/src/pages/account/center/components/Projects/index.tsx new file mode 100644 index 00000000..85b566c7 --- /dev/null +++ b/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 ( + + 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) => ( + + } + > + {item.title}} + description={item.subDescription} + /> +
    + {dayjs(item.updatedAt).fromNow()} +
    + + {item.members.map((member) => ( + + ))} + +
    +
    +
    +
    + )} + /> + ); +}; +export default Projects; diff --git a/src/pages/account/center/data.d.ts b/src/pages/account/center/data.d.ts new file mode 100644 index 00000000..6085c1e5 --- /dev/null +++ b/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[]; +}; diff --git a/src/pages/account/center/index.tsx b/src/pages/account/center/index.tsx new file mode 100644 index 00000000..834cf932 --- /dev/null +++ b/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: ( + + 文章{' '} + + (8) + + + ), + }, + { + key: 'applications', + tab: ( + + 应用{' '} + + (8) + + + ), + }, + { + key: 'projects', + tab: ( + + 项目{' '} + + (8) + + + ), + }, +]; +const TagList: React.FC<{ + tags: CurrentUser['tags']; +}> = ({ tags }) => { + const { styles } = useStyles(); + const ref = useRef(null); + const [newTags, setNewTags] = useState([]); + const [inputVisible, setInputVisible] = useState(false); + const [inputValue, setInputValue] = useState(''); + const showInput = () => { + setInputVisible(true); + if (ref.current) { + // eslint-disable-next-line no-unused-expressions + ref.current?.focus(); + } + }; + const handleInputChange = (e: React.ChangeEvent) => { + 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 ( +
    +
    标签
    + {(tags || []).concat(newTags).map((item) => ( + {item.label} + ))} + {inputVisible && ( + + )} + {!inputVisible && ( + + + + )} +
    + ); +}; +const Center: React.FC = () => { + const { styles } = useStyles(); + const [tabKey, setTabKey] = useState('articles'); + + // 获取用户信息 + const { data: currentUser, loading } = useRequest(() => { + return queryCurrent(); + }); + + // 渲染用户信息 + const renderUserInfo = ({ + title, + group, + geographic, + }: Partial) => { + return ( +
    +

    + + {title} +

    +

    + + {group} +

    +

    + + { + ( + geographic || { + province: { + label: '', + }, + } + ).province.label + } + { + ( + geographic || { + city: { + label: '', + }, + } + ).city.label + } +

    +
    + ); + }; + + // 渲染tab切换 + const renderChildrenByTabKey = (tabValue: tabKeyType) => { + if (tabValue === 'projects') { + return ; + } + if (tabValue === 'applications') { + return ; + } + if (tabValue === 'articles') { + return ; + } + return null; + }; + return ( + + + + + {!loading && currentUser && ( + <> +
    + +
    {currentUser.name}
    +
    {currentUser?.signature}
    +
    + {renderUserInfo(currentUser)} + + + +
    +
    团队
    + + {currentUser.notice?.map((item) => ( + + + + {item.member} + + + ))} + +
    + + )} +
    + + + { + setTabKey(_tabKey as tabKeyType); + }} + > + {renderChildrenByTabKey(tabKey)} + + +
    +
    + ); +}; +export default Center; diff --git a/src/pages/account/center/service.ts b/src/pages/account/center/service.ts new file mode 100644 index 00000000..dee1554e --- /dev/null +++ b/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, + }); +} diff --git a/src/pages/account/settings/_mock.ts b/src/pages/account/settings/_mock.ts new file mode 100644 index 00000000..4e786929 --- /dev/null +++ b/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, +}; diff --git a/src/pages/account/settings/components/PhoneView.tsx b/src/pages/account/settings/components/PhoneView.tsx new file mode 100644 index 00000000..84ea4787 --- /dev/null +++ b/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 = (props) => { + const { styles } = useStyles(); + const { value, onChange } = props; + let values = ['', '']; + if (value) { + values = value.split('-'); + } + return ( + <> + { + if (onChange) { + onChange(`${e.target.value}-${values[1]}`); + } + }} + /> + { + if (onChange) { + onChange(`${values[0]}-${e.target.value}`); + } + }} + value={values[1]} + /> + + ); +}; +export default PhoneView; diff --git a/src/pages/account/settings/components/base.tsx b/src/pages/account/settings/components/base.tsx new file mode 100644 index 00000000..5fe63cb9 --- /dev/null +++ b/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 }) => ( + <> +
    头像
    +
    + avatar +
    + +
    + +
    +
    + + ); + 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 ( +
    + {loading ? null : ( + <> +
    + dom[1], + }} + initialValues={{ + ...currentUser, + phone: currentUser?.phone.split('-'), + }} + hideRequiredMark + > + + + + + + + { + return queryProvince().then(({ data }) => { + return data.map((item) => { + return { + label: item.name, + value: item.id, + }; + }); + }); + }} + /> + + {({ province }) => { + return ( + { + if (!province?.key) { + return []; + } + return queryCity(province.key || '').then( + ({ data }) => { + return data.map((item) => { + return { + label: item.name, + value: item.id, + }; + }); + }, + ); + }} + /> + ); + }} + + + + + + + + +
    +
    + +
    + + )} +
    + ); +}; +export default BaseView; diff --git a/src/pages/account/settings/components/binding.tsx b/src/pages/account/settings/components/binding.tsx new file mode 100644 index 00000000..8ddfc7f3 --- /dev/null +++ b/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: [绑定], + avatar: , + }, + { + title: '绑定支付宝', + description: '当前未绑定支付宝账号', + actions: [绑定], + avatar: , + }, + { + title: '绑定钉钉', + description: '当前未绑定钉钉账号', + actions: [绑定], + avatar: , + }, + ]; + + return ( + + ( + + + + )} + /> + + ); +}; + +export default BindingView; diff --git a/src/pages/account/settings/components/index.style.ts b/src/pages/account/settings/components/index.style.ts new file mode 100644 index 00000000..5e4a7904 --- /dev/null +++ b/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; diff --git a/src/pages/account/settings/components/notification.tsx b/src/pages/account/settings/components/notification.tsx new file mode 100644 index 00000000..628513e2 --- /dev/null +++ b/src/pages/account/settings/components/notification.tsx @@ -0,0 +1,46 @@ +import { List, Switch } from 'antd'; +import React, { Fragment } from 'react'; + +type Unpacked = T extends (infer U)[] ? U : T; + +const NotificationView: React.FC = () => { + const getData = () => { + const Action = ( + + ); + return [ + { + title: '用户消息', + description: '其他用户的消息将以站内信的形式通知', + actions: [Action], + }, + { + title: '系统消息', + description: '系统消息将以站内信的形式通知', + actions: [Action], + }, + { + title: '待办任务', + description: '待办任务将以站内信的形式通知', + actions: [Action], + }, + ]; + }; + + const data = getData(); + return ( + + > + itemLayout="horizontal" + dataSource={data} + renderItem={(item) => ( + + + + )} + /> + + ); +}; + +export default NotificationView; diff --git a/src/pages/account/settings/components/security.tsx b/src/pages/account/settings/components/security.tsx new file mode 100644 index 00000000..97a7436e --- /dev/null +++ b/src/pages/account/settings/components/security.tsx @@ -0,0 +1,60 @@ +import { List } from 'antd'; +import React from 'react'; + +type Unpacked = T extends (infer U)[] ? U : T; + +const passwordStrength = { + strong: , + medium: , + weak: 弱 Weak, +}; + +const SecurityView: React.FC = () => { + const getData = () => [ + { + title: '账户密码', + description: ( + <> + 当前密码强度: + {passwordStrength.strong} + + ), + actions: [修改], + }, + { + title: '密保手机', + description: `已绑定手机:138****8293`, + actions: [修改], + }, + { + title: '密保问题', + description: '未设置密保问题,密保问题可有效保护账户安全', + actions: [设置], + }, + { + title: '备用邮箱', + description: `已绑定邮箱:ant***sign.com`, + actions: [修改], + }, + { + title: 'MFA 设备', + description: '未绑定 MFA 设备,绑定后,可以进行二次确认', + actions: [绑定], + }, + ]; + + const data = getData(); + return ( + > + itemLayout="horizontal" + dataSource={data} + renderItem={(item) => ( + + + + )} + /> + ); +}; + +export default SecurityView; diff --git a/src/pages/account/settings/data.d.ts b/src/pages/account/settings/data.d.ts new file mode 100644 index 00000000..f4a88f30 --- /dev/null +++ b/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; +}; diff --git a/src/pages/account/settings/geographic/city.json b/src/pages/account/settings/geographic/city.json new file mode 100644 index 00000000..29783747 --- /dev/null +++ b/src/pages/account/settings/geographic/city.json @@ -0,0 +1,1784 @@ +{ + "110000": [ + { + "province": "北京市", + "name": "市辖区", + "id": "110100" + } + ], + "120000": [ + { + "province": "天津市", + "name": "市辖区", + "id": "120100" + } + ], + "130000": [ + { + "province": "河北省", + "name": "石家庄市", + "id": "130100" + }, + { + "province": "河北省", + "name": "唐山市", + "id": "130200" + }, + { + "province": "河北省", + "name": "秦皇岛市", + "id": "130300" + }, + { + "province": "河北省", + "name": "邯郸市", + "id": "130400" + }, + { + "province": "河北省", + "name": "邢台市", + "id": "130500" + }, + { + "province": "河北省", + "name": "保定市", + "id": "130600" + }, + { + "province": "河北省", + "name": "张家口市", + "id": "130700" + }, + { + "province": "河北省", + "name": "承德市", + "id": "130800" + }, + { + "province": "河北省", + "name": "沧州市", + "id": "130900" + }, + { + "province": "河北省", + "name": "廊坊市", + "id": "131000" + }, + { + "province": "河北省", + "name": "衡水市", + "id": "131100" + }, + { + "province": "河北省", + "name": "省直辖县级行政区划", + "id": "139000" + } + ], + "140000": [ + { + "province": "山西省", + "name": "太原市", + "id": "140100" + }, + { + "province": "山西省", + "name": "大同市", + "id": "140200" + }, + { + "province": "山西省", + "name": "阳泉市", + "id": "140300" + }, + { + "province": "山西省", + "name": "长治市", + "id": "140400" + }, + { + "province": "山西省", + "name": "晋城市", + "id": "140500" + }, + { + "province": "山西省", + "name": "朔州市", + "id": "140600" + }, + { + "province": "山西省", + "name": "晋中市", + "id": "140700" + }, + { + "province": "山西省", + "name": "运城市", + "id": "140800" + }, + { + "province": "山西省", + "name": "忻州市", + "id": "140900" + }, + { + "province": "山西省", + "name": "临汾市", + "id": "141000" + }, + { + "province": "山西省", + "name": "吕梁市", + "id": "141100" + } + ], + "150000": [ + { + "province": "内蒙古自治区", + "name": "呼和浩特市", + "id": "150100" + }, + { + "province": "内蒙古自治区", + "name": "包头市", + "id": "150200" + }, + { + "province": "内蒙古自治区", + "name": "乌海市", + "id": "150300" + }, + { + "province": "内蒙古自治区", + "name": "赤峰市", + "id": "150400" + }, + { + "province": "内蒙古自治区", + "name": "通辽市", + "id": "150500" + }, + { + "province": "内蒙古自治区", + "name": "鄂尔多斯市", + "id": "150600" + }, + { + "province": "内蒙古自治区", + "name": "呼伦贝尔市", + "id": "150700" + }, + { + "province": "内蒙古自治区", + "name": "巴彦淖尔市", + "id": "150800" + }, + { + "province": "内蒙古自治区", + "name": "乌兰察布市", + "id": "150900" + }, + { + "province": "内蒙古自治区", + "name": "兴安盟", + "id": "152200" + }, + { + "province": "内蒙古自治区", + "name": "锡林郭勒盟", + "id": "152500" + }, + { + "province": "内蒙古自治区", + "name": "阿拉善盟", + "id": "152900" + } + ], + "210000": [ + { + "province": "辽宁省", + "name": "沈阳市", + "id": "210100" + }, + { + "province": "辽宁省", + "name": "大连市", + "id": "210200" + }, + { + "province": "辽宁省", + "name": "鞍山市", + "id": "210300" + }, + { + "province": "辽宁省", + "name": "抚顺市", + "id": "210400" + }, + { + "province": "辽宁省", + "name": "本溪市", + "id": "210500" + }, + { + "province": "辽宁省", + "name": "丹东市", + "id": "210600" + }, + { + "province": "辽宁省", + "name": "锦州市", + "id": "210700" + }, + { + "province": "辽宁省", + "name": "营口市", + "id": "210800" + }, + { + "province": "辽宁省", + "name": "阜新市", + "id": "210900" + }, + { + "province": "辽宁省", + "name": "辽阳市", + "id": "211000" + }, + { + "province": "辽宁省", + "name": "盘锦市", + "id": "211100" + }, + { + "province": "辽宁省", + "name": "铁岭市", + "id": "211200" + }, + { + "province": "辽宁省", + "name": "朝阳市", + "id": "211300" + }, + { + "province": "辽宁省", + "name": "葫芦岛市", + "id": "211400" + } + ], + "220000": [ + { + "province": "吉林省", + "name": "长春市", + "id": "220100" + }, + { + "province": "吉林省", + "name": "吉林市", + "id": "220200" + }, + { + "province": "吉林省", + "name": "四平市", + "id": "220300" + }, + { + "province": "吉林省", + "name": "辽源市", + "id": "220400" + }, + { + "province": "吉林省", + "name": "通化市", + "id": "220500" + }, + { + "province": "吉林省", + "name": "白山市", + "id": "220600" + }, + { + "province": "吉林省", + "name": "松原市", + "id": "220700" + }, + { + "province": "吉林省", + "name": "白城市", + "id": "220800" + }, + { + "province": "吉林省", + "name": "延边朝鲜族自治州", + "id": "222400" + } + ], + "230000": [ + { + "province": "黑龙江省", + "name": "哈尔滨市", + "id": "230100" + }, + { + "province": "黑龙江省", + "name": "齐齐哈尔市", + "id": "230200" + }, + { + "province": "黑龙江省", + "name": "鸡西市", + "id": "230300" + }, + { + "province": "黑龙江省", + "name": "鹤岗市", + "id": "230400" + }, + { + "province": "黑龙江省", + "name": "双鸭山市", + "id": "230500" + }, + { + "province": "黑龙江省", + "name": "大庆市", + "id": "230600" + }, + { + "province": "黑龙江省", + "name": "伊春市", + "id": "230700" + }, + { + "province": "黑龙江省", + "name": "佳木斯市", + "id": "230800" + }, + { + "province": "黑龙江省", + "name": "七台河市", + "id": "230900" + }, + { + "province": "黑龙江省", + "name": "牡丹江市", + "id": "231000" + }, + { + "province": "黑龙江省", + "name": "黑河市", + "id": "231100" + }, + { + "province": "黑龙江省", + "name": "绥化市", + "id": "231200" + }, + { + "province": "黑龙江省", + "name": "大兴安岭地区", + "id": "232700" + } + ], + "310000": [ + { + "province": "上海市", + "name": "市辖区", + "id": "310100" + } + ], + "320000": [ + { + "province": "江苏省", + "name": "南京市", + "id": "320100" + }, + { + "province": "江苏省", + "name": "无锡市", + "id": "320200" + }, + { + "province": "江苏省", + "name": "徐州市", + "id": "320300" + }, + { + "province": "江苏省", + "name": "常州市", + "id": "320400" + }, + { + "province": "江苏省", + "name": "苏州市", + "id": "320500" + }, + { + "province": "江苏省", + "name": "南通市", + "id": "320600" + }, + { + "province": "江苏省", + "name": "连云港市", + "id": "320700" + }, + { + "province": "江苏省", + "name": "淮安市", + "id": "320800" + }, + { + "province": "江苏省", + "name": "盐城市", + "id": "320900" + }, + { + "province": "江苏省", + "name": "扬州市", + "id": "321000" + }, + { + "province": "江苏省", + "name": "镇江市", + "id": "321100" + }, + { + "province": "江苏省", + "name": "泰州市", + "id": "321200" + }, + { + "province": "江苏省", + "name": "宿迁市", + "id": "321300" + } + ], + "330000": [ + { + "province": "浙江省", + "name": "杭州市", + "id": "330100" + }, + { + "province": "浙江省", + "name": "宁波市", + "id": "330200" + }, + { + "province": "浙江省", + "name": "温州市", + "id": "330300" + }, + { + "province": "浙江省", + "name": "嘉兴市", + "id": "330400" + }, + { + "province": "浙江省", + "name": "湖州市", + "id": "330500" + }, + { + "province": "浙江省", + "name": "绍兴市", + "id": "330600" + }, + { + "province": "浙江省", + "name": "金华市", + "id": "330700" + }, + { + "province": "浙江省", + "name": "衢州市", + "id": "330800" + }, + { + "province": "浙江省", + "name": "舟山市", + "id": "330900" + }, + { + "province": "浙江省", + "name": "台州市", + "id": "331000" + }, + { + "province": "浙江省", + "name": "丽水市", + "id": "331100" + } + ], + "340000": [ + { + "province": "安徽省", + "name": "合肥市", + "id": "340100" + }, + { + "province": "安徽省", + "name": "芜湖市", + "id": "340200" + }, + { + "province": "安徽省", + "name": "蚌埠市", + "id": "340300" + }, + { + "province": "安徽省", + "name": "淮南市", + "id": "340400" + }, + { + "province": "安徽省", + "name": "马鞍山市", + "id": "340500" + }, + { + "province": "安徽省", + "name": "淮北市", + "id": "340600" + }, + { + "province": "安徽省", + "name": "铜陵市", + "id": "340700" + }, + { + "province": "安徽省", + "name": "安庆市", + "id": "340800" + }, + { + "province": "安徽省", + "name": "黄山市", + "id": "341000" + }, + { + "province": "安徽省", + "name": "滁州市", + "id": "341100" + }, + { + "province": "安徽省", + "name": "阜阳市", + "id": "341200" + }, + { + "province": "安徽省", + "name": "宿州市", + "id": "341300" + }, + { + "province": "安徽省", + "name": "六安市", + "id": "341500" + }, + { + "province": "安徽省", + "name": "亳州市", + "id": "341600" + }, + { + "province": "安徽省", + "name": "池州市", + "id": "341700" + }, + { + "province": "安徽省", + "name": "宣城市", + "id": "341800" + } + ], + "350000": [ + { + "province": "福建省", + "name": "福州市", + "id": "350100" + }, + { + "province": "福建省", + "name": "厦门市", + "id": "350200" + }, + { + "province": "福建省", + "name": "莆田市", + "id": "350300" + }, + { + "province": "福建省", + "name": "三明市", + "id": "350400" + }, + { + "province": "福建省", + "name": "泉州市", + "id": "350500" + }, + { + "province": "福建省", + "name": "漳州市", + "id": "350600" + }, + { + "province": "福建省", + "name": "南平市", + "id": "350700" + }, + { + "province": "福建省", + "name": "龙岩市", + "id": "350800" + }, + { + "province": "福建省", + "name": "宁德市", + "id": "350900" + } + ], + "360000": [ + { + "province": "江西省", + "name": "南昌市", + "id": "360100" + }, + { + "province": "江西省", + "name": "景德镇市", + "id": "360200" + }, + { + "province": "江西省", + "name": "萍乡市", + "id": "360300" + }, + { + "province": "江西省", + "name": "九江市", + "id": "360400" + }, + { + "province": "江西省", + "name": "新余市", + "id": "360500" + }, + { + "province": "江西省", + "name": "鹰潭市", + "id": "360600" + }, + { + "province": "江西省", + "name": "赣州市", + "id": "360700" + }, + { + "province": "江西省", + "name": "吉安市", + "id": "360800" + }, + { + "province": "江西省", + "name": "宜春市", + "id": "360900" + }, + { + "province": "江西省", + "name": "抚州市", + "id": "361000" + }, + { + "province": "江西省", + "name": "上饶市", + "id": "361100" + } + ], + "370000": [ + { + "province": "山东省", + "name": "济南市", + "id": "370100" + }, + { + "province": "山东省", + "name": "青岛市", + "id": "370200" + }, + { + "province": "山东省", + "name": "淄博市", + "id": "370300" + }, + { + "province": "山东省", + "name": "枣庄市", + "id": "370400" + }, + { + "province": "山东省", + "name": "东营市", + "id": "370500" + }, + { + "province": "山东省", + "name": "烟台市", + "id": "370600" + }, + { + "province": "山东省", + "name": "潍坊市", + "id": "370700" + }, + { + "province": "山东省", + "name": "济宁市", + "id": "370800" + }, + { + "province": "山东省", + "name": "泰安市", + "id": "370900" + }, + { + "province": "山东省", + "name": "威海市", + "id": "371000" + }, + { + "province": "山东省", + "name": "日照市", + "id": "371100" + }, + { + "province": "山东省", + "name": "莱芜市", + "id": "371200" + }, + { + "province": "山东省", + "name": "临沂市", + "id": "371300" + }, + { + "province": "山东省", + "name": "德州市", + "id": "371400" + }, + { + "province": "山东省", + "name": "聊城市", + "id": "371500" + }, + { + "province": "山东省", + "name": "滨州市", + "id": "371600" + }, + { + "province": "山东省", + "name": "菏泽市", + "id": "371700" + } + ], + "410000": [ + { + "province": "河南省", + "name": "郑州市", + "id": "410100" + }, + { + "province": "河南省", + "name": "开封市", + "id": "410200" + }, + { + "province": "河南省", + "name": "洛阳市", + "id": "410300" + }, + { + "province": "河南省", + "name": "平顶山市", + "id": "410400" + }, + { + "province": "河南省", + "name": "安阳市", + "id": "410500" + }, + { + "province": "河南省", + "name": "鹤壁市", + "id": "410600" + }, + { + "province": "河南省", + "name": "新乡市", + "id": "410700" + }, + { + "province": "河南省", + "name": "焦作市", + "id": "410800" + }, + { + "province": "河南省", + "name": "濮阳市", + "id": "410900" + }, + { + "province": "河南省", + "name": "许昌市", + "id": "411000" + }, + { + "province": "河南省", + "name": "漯河市", + "id": "411100" + }, + { + "province": "河南省", + "name": "三门峡市", + "id": "411200" + }, + { + "province": "河南省", + "name": "南阳市", + "id": "411300" + }, + { + "province": "河南省", + "name": "商丘市", + "id": "411400" + }, + { + "province": "河南省", + "name": "信阳市", + "id": "411500" + }, + { + "province": "河南省", + "name": "周口市", + "id": "411600" + }, + { + "province": "河南省", + "name": "驻马店市", + "id": "411700" + }, + { + "province": "河南省", + "name": "省直辖县级行政区划", + "id": "419000" + } + ], + "420000": [ + { + "province": "湖北省", + "name": "武汉市", + "id": "420100" + }, + { + "province": "湖北省", + "name": "黄石市", + "id": "420200" + }, + { + "province": "湖北省", + "name": "十堰市", + "id": "420300" + }, + { + "province": "湖北省", + "name": "宜昌市", + "id": "420500" + }, + { + "province": "湖北省", + "name": "襄阳市", + "id": "420600" + }, + { + "province": "湖北省", + "name": "鄂州市", + "id": "420700" + }, + { + "province": "湖北省", + "name": "荆门市", + "id": "420800" + }, + { + "province": "湖北省", + "name": "孝感市", + "id": "420900" + }, + { + "province": "湖北省", + "name": "荆州市", + "id": "421000" + }, + { + "province": "湖北省", + "name": "黄冈市", + "id": "421100" + }, + { + "province": "湖北省", + "name": "咸宁市", + "id": "421200" + }, + { + "province": "湖北省", + "name": "随州市", + "id": "421300" + }, + { + "province": "湖北省", + "name": "恩施土家族苗族自治州", + "id": "422800" + }, + { + "province": "湖北省", + "name": "省直辖县级行政区划", + "id": "429000" + } + ], + "430000": [ + { + "province": "湖南省", + "name": "长沙市", + "id": "430100" + }, + { + "province": "湖南省", + "name": "株洲市", + "id": "430200" + }, + { + "province": "湖南省", + "name": "湘潭市", + "id": "430300" + }, + { + "province": "湖南省", + "name": "衡阳市", + "id": "430400" + }, + { + "province": "湖南省", + "name": "邵阳市", + "id": "430500" + }, + { + "province": "湖南省", + "name": "岳阳市", + "id": "430600" + }, + { + "province": "湖南省", + "name": "常德市", + "id": "430700" + }, + { + "province": "湖南省", + "name": "张家界市", + "id": "430800" + }, + { + "province": "湖南省", + "name": "益阳市", + "id": "430900" + }, + { + "province": "湖南省", + "name": "郴州市", + "id": "431000" + }, + { + "province": "湖南省", + "name": "永州市", + "id": "431100" + }, + { + "province": "湖南省", + "name": "怀化市", + "id": "431200" + }, + { + "province": "湖南省", + "name": "娄底市", + "id": "431300" + }, + { + "province": "湖南省", + "name": "湘西土家族苗族自治州", + "id": "433100" + } + ], + "440000": [ + { + "province": "广东省", + "name": "广州市", + "id": "440100" + }, + { + "province": "广东省", + "name": "韶关市", + "id": "440200" + }, + { + "province": "广东省", + "name": "深圳市", + "id": "440300" + }, + { + "province": "广东省", + "name": "珠海市", + "id": "440400" + }, + { + "province": "广东省", + "name": "汕头市", + "id": "440500" + }, + { + "province": "广东省", + "name": "佛山市", + "id": "440600" + }, + { + "province": "广东省", + "name": "江门市", + "id": "440700" + }, + { + "province": "广东省", + "name": "湛江市", + "id": "440800" + }, + { + "province": "广东省", + "name": "茂名市", + "id": "440900" + }, + { + "province": "广东省", + "name": "肇庆市", + "id": "441200" + }, + { + "province": "广东省", + "name": "惠州市", + "id": "441300" + }, + { + "province": "广东省", + "name": "梅州市", + "id": "441400" + }, + { + "province": "广东省", + "name": "汕尾市", + "id": "441500" + }, + { + "province": "广东省", + "name": "河源市", + "id": "441600" + }, + { + "province": "广东省", + "name": "阳江市", + "id": "441700" + }, + { + "province": "广东省", + "name": "清远市", + "id": "441800" + }, + { + "province": "广东省", + "name": "东莞市", + "id": "441900" + }, + { + "province": "广东省", + "name": "中山市", + "id": "442000" + }, + { + "province": "广东省", + "name": "潮州市", + "id": "445100" + }, + { + "province": "广东省", + "name": "揭阳市", + "id": "445200" + }, + { + "province": "广东省", + "name": "云浮市", + "id": "445300" + } + ], + "450000": [ + { + "province": "广西壮族自治区", + "name": "南宁市", + "id": "450100" + }, + { + "province": "广西壮族自治区", + "name": "柳州市", + "id": "450200" + }, + { + "province": "广西壮族自治区", + "name": "桂林市", + "id": "450300" + }, + { + "province": "广西壮族自治区", + "name": "梧州市", + "id": "450400" + }, + { + "province": "广西壮族自治区", + "name": "北海市", + "id": "450500" + }, + { + "province": "广西壮族自治区", + "name": "防城港市", + "id": "450600" + }, + { + "province": "广西壮族自治区", + "name": "钦州市", + "id": "450700" + }, + { + "province": "广西壮族自治区", + "name": "贵港市", + "id": "450800" + }, + { + "province": "广西壮族自治区", + "name": "玉林市", + "id": "450900" + }, + { + "province": "广西壮族自治区", + "name": "百色市", + "id": "451000" + }, + { + "province": "广西壮族自治区", + "name": "贺州市", + "id": "451100" + }, + { + "province": "广西壮族自治区", + "name": "河池市", + "id": "451200" + }, + { + "province": "广西壮族自治区", + "name": "来宾市", + "id": "451300" + }, + { + "province": "广西壮族自治区", + "name": "崇左市", + "id": "451400" + } + ], + "460000": [ + { + "province": "海南省", + "name": "海口市", + "id": "460100" + }, + { + "province": "海南省", + "name": "三亚市", + "id": "460200" + }, + { + "province": "海南省", + "name": "三沙市", + "id": "460300" + }, + { + "province": "海南省", + "name": "儋州市", + "id": "460400" + }, + { + "province": "海南省", + "name": "省直辖县级行政区划", + "id": "469000" + } + ], + "500000": [ + { + "province": "重庆市", + "name": "市辖区", + "id": "500100" + }, + { + "province": "重庆市", + "name": "县", + "id": "500200" + } + ], + "510000": [ + { + "province": "四川省", + "name": "成都市", + "id": "510100" + }, + { + "province": "四川省", + "name": "自贡市", + "id": "510300" + }, + { + "province": "四川省", + "name": "攀枝花市", + "id": "510400" + }, + { + "province": "四川省", + "name": "泸州市", + "id": "510500" + }, + { + "province": "四川省", + "name": "德阳市", + "id": "510600" + }, + { + "province": "四川省", + "name": "绵阳市", + "id": "510700" + }, + { + "province": "四川省", + "name": "广元市", + "id": "510800" + }, + { + "province": "四川省", + "name": "遂宁市", + "id": "510900" + }, + { + "province": "四川省", + "name": "内江市", + "id": "511000" + }, + { + "province": "四川省", + "name": "乐山市", + "id": "511100" + }, + { + "province": "四川省", + "name": "南充市", + "id": "511300" + }, + { + "province": "四川省", + "name": "眉山市", + "id": "511400" + }, + { + "province": "四川省", + "name": "宜宾市", + "id": "511500" + }, + { + "province": "四川省", + "name": "广安市", + "id": "511600" + }, + { + "province": "四川省", + "name": "达州市", + "id": "511700" + }, + { + "province": "四川省", + "name": "雅安市", + "id": "511800" + }, + { + "province": "四川省", + "name": "巴中市", + "id": "511900" + }, + { + "province": "四川省", + "name": "资阳市", + "id": "512000" + }, + { + "province": "四川省", + "name": "阿坝藏族羌族自治州", + "id": "513200" + }, + { + "province": "四川省", + "name": "甘孜藏族自治州", + "id": "513300" + }, + { + "province": "四川省", + "name": "凉山彝族自治州", + "id": "513400" + } + ], + "520000": [ + { + "province": "贵州省", + "name": "贵阳市", + "id": "520100" + }, + { + "province": "贵州省", + "name": "六盘水市", + "id": "520200" + }, + { + "province": "贵州省", + "name": "遵义市", + "id": "520300" + }, + { + "province": "贵州省", + "name": "安顺市", + "id": "520400" + }, + { + "province": "贵州省", + "name": "毕节市", + "id": "520500" + }, + { + "province": "贵州省", + "name": "铜仁市", + "id": "520600" + }, + { + "province": "贵州省", + "name": "黔西南布依族苗族自治州", + "id": "522300" + }, + { + "province": "贵州省", + "name": "黔东南苗族侗族自治州", + "id": "522600" + }, + { + "province": "贵州省", + "name": "黔南布依族苗族自治州", + "id": "522700" + } + ], + "530000": [ + { + "province": "云南省", + "name": "昆明市", + "id": "530100" + }, + { + "province": "云南省", + "name": "曲靖市", + "id": "530300" + }, + { + "province": "云南省", + "name": "玉溪市", + "id": "530400" + }, + { + "province": "云南省", + "name": "保山市", + "id": "530500" + }, + { + "province": "云南省", + "name": "昭通市", + "id": "530600" + }, + { + "province": "云南省", + "name": "丽江市", + "id": "530700" + }, + { + "province": "云南省", + "name": "普洱市", + "id": "530800" + }, + { + "province": "云南省", + "name": "临沧市", + "id": "530900" + }, + { + "province": "云南省", + "name": "楚雄彝族自治州", + "id": "532300" + }, + { + "province": "云南省", + "name": "红河哈尼族彝族自治州", + "id": "532500" + }, + { + "province": "云南省", + "name": "文山壮族苗族自治州", + "id": "532600" + }, + { + "province": "云南省", + "name": "西双版纳傣族自治州", + "id": "532800" + }, + { + "province": "云南省", + "name": "大理白族自治州", + "id": "532900" + }, + { + "province": "云南省", + "name": "德宏傣族景颇族自治州", + "id": "533100" + }, + { + "province": "云南省", + "name": "怒江傈僳族自治州", + "id": "533300" + }, + { + "province": "云南省", + "name": "迪庆藏族自治州", + "id": "533400" + } + ], + "540000": [ + { + "province": "西藏自治区", + "name": "拉萨市", + "id": "540100" + }, + { + "province": "西藏自治区", + "name": "日喀则市", + "id": "540200" + }, + { + "province": "西藏自治区", + "name": "昌都市", + "id": "540300" + }, + { + "province": "西藏自治区", + "name": "林芝市", + "id": "540400" + }, + { + "province": "西藏自治区", + "name": "山南市", + "id": "540500" + }, + { + "province": "西藏自治区", + "name": "那曲地区", + "id": "542400" + }, + { + "province": "西藏自治区", + "name": "阿里地区", + "id": "542500" + } + ], + "610000": [ + { + "province": "陕西省", + "name": "西安市", + "id": "610100" + }, + { + "province": "陕西省", + "name": "铜川市", + "id": "610200" + }, + { + "province": "陕西省", + "name": "宝鸡市", + "id": "610300" + }, + { + "province": "陕西省", + "name": "咸阳市", + "id": "610400" + }, + { + "province": "陕西省", + "name": "渭南市", + "id": "610500" + }, + { + "province": "陕西省", + "name": "延安市", + "id": "610600" + }, + { + "province": "陕西省", + "name": "汉中市", + "id": "610700" + }, + { + "province": "陕西省", + "name": "榆林市", + "id": "610800" + }, + { + "province": "陕西省", + "name": "安康市", + "id": "610900" + }, + { + "province": "陕西省", + "name": "商洛市", + "id": "611000" + } + ], + "620000": [ + { + "province": "甘肃省", + "name": "兰州市", + "id": "620100" + }, + { + "province": "甘肃省", + "name": "嘉峪关市", + "id": "620200" + }, + { + "province": "甘肃省", + "name": "金昌市", + "id": "620300" + }, + { + "province": "甘肃省", + "name": "白银市", + "id": "620400" + }, + { + "province": "甘肃省", + "name": "天水市", + "id": "620500" + }, + { + "province": "甘肃省", + "name": "武威市", + "id": "620600" + }, + { + "province": "甘肃省", + "name": "张掖市", + "id": "620700" + }, + { + "province": "甘肃省", + "name": "平凉市", + "id": "620800" + }, + { + "province": "甘肃省", + "name": "酒泉市", + "id": "620900" + }, + { + "province": "甘肃省", + "name": "庆阳市", + "id": "621000" + }, + { + "province": "甘肃省", + "name": "定西市", + "id": "621100" + }, + { + "province": "甘肃省", + "name": "陇南市", + "id": "621200" + }, + { + "province": "甘肃省", + "name": "临夏回族自治州", + "id": "622900" + }, + { + "province": "甘肃省", + "name": "甘南藏族自治州", + "id": "623000" + } + ], + "630000": [ + { + "province": "青海省", + "name": "西宁市", + "id": "630100" + }, + { + "province": "青海省", + "name": "海东市", + "id": "630200" + }, + { + "province": "青海省", + "name": "海北藏族自治州", + "id": "632200" + }, + { + "province": "青海省", + "name": "黄南藏族自治州", + "id": "632300" + }, + { + "province": "青海省", + "name": "海南藏族自治州", + "id": "632500" + }, + { + "province": "青海省", + "name": "果洛藏族自治州", + "id": "632600" + }, + { + "province": "青海省", + "name": "玉树藏族自治州", + "id": "632700" + }, + { + "province": "青海省", + "name": "海西蒙古族藏族自治州", + "id": "632800" + } + ], + "640000": [ + { + "province": "宁夏回族自治区", + "name": "银川市", + "id": "640100" + }, + { + "province": "宁夏回族自治区", + "name": "石嘴山市", + "id": "640200" + }, + { + "province": "宁夏回族自治区", + "name": "吴忠市", + "id": "640300" + }, + { + "province": "宁夏回族自治区", + "name": "固原市", + "id": "640400" + }, + { + "province": "宁夏回族自治区", + "name": "中卫市", + "id": "640500" + } + ], + "650000": [ + { + "province": "新疆维吾尔自治区", + "name": "乌鲁木齐市", + "id": "650100" + }, + { + "province": "新疆维吾尔自治区", + "name": "克拉玛依市", + "id": "650200" + }, + { + "province": "新疆维吾尔自治区", + "name": "吐鲁番市", + "id": "650400" + }, + { + "province": "新疆维吾尔自治区", + "name": "哈密市", + "id": "650500" + }, + { + "province": "新疆维吾尔自治区", + "name": "昌吉回族自治州", + "id": "652300" + }, + { + "province": "新疆维吾尔自治区", + "name": "博尔塔拉蒙古自治州", + "id": "652700" + }, + { + "province": "新疆维吾尔自治区", + "name": "巴音郭楞蒙古自治州", + "id": "652800" + }, + { + "province": "新疆维吾尔自治区", + "name": "阿克苏地区", + "id": "652900" + }, + { + "province": "新疆维吾尔自治区", + "name": "克孜勒苏柯尔克孜自治州", + "id": "653000" + }, + { + "province": "新疆维吾尔自治区", + "name": "喀什地区", + "id": "653100" + }, + { + "province": "新疆维吾尔自治区", + "name": "和田地区", + "id": "653200" + }, + { + "province": "新疆维吾尔自治区", + "name": "伊犁哈萨克自治州", + "id": "654000" + }, + { + "province": "新疆维吾尔自治区", + "name": "塔城地区", + "id": "654200" + }, + { + "province": "新疆维吾尔自治区", + "name": "阿勒泰地区", + "id": "654300" + }, + { + "province": "新疆维吾尔自治区", + "name": "自治区直辖县级行政区划", + "id": "659000" + } + ] +} diff --git a/src/pages/account/settings/geographic/province.json b/src/pages/account/settings/geographic/province.json new file mode 100644 index 00000000..910c83f0 --- /dev/null +++ b/src/pages/account/settings/geographic/province.json @@ -0,0 +1,138 @@ +[ + { + "name": "北京市", + "id": "110000" + }, + { + "name": "天津市", + "id": "120000" + }, + { + "name": "河北省", + "id": "130000" + }, + { + "name": "山西省", + "id": "140000" + }, + { + "name": "内蒙古自治区", + "id": "150000" + }, + { + "name": "辽宁省", + "id": "210000" + }, + { + "name": "吉林省", + "id": "220000" + }, + { + "name": "黑龙江省", + "id": "230000" + }, + { + "name": "上海市", + "id": "310000" + }, + { + "name": "江苏省", + "id": "320000" + }, + { + "name": "浙江省", + "id": "330000" + }, + { + "name": "安徽省", + "id": "340000" + }, + { + "name": "福建省", + "id": "350000" + }, + { + "name": "江西省", + "id": "360000" + }, + { + "name": "山东省", + "id": "370000" + }, + { + "name": "河南省", + "id": "410000" + }, + { + "name": "湖北省", + "id": "420000" + }, + { + "name": "湖南省", + "id": "430000" + }, + { + "name": "广东省", + "id": "440000" + }, + { + "name": "广西壮族自治区", + "id": "450000" + }, + { + "name": "海南省", + "id": "460000" + }, + { + "name": "重庆市", + "id": "500000" + }, + { + "name": "四川省", + "id": "510000" + }, + { + "name": "贵州省", + "id": "520000" + }, + { + "name": "云南省", + "id": "530000" + }, + { + "name": "西藏自治区", + "id": "540000" + }, + { + "name": "陕西省", + "id": "610000" + }, + { + "name": "甘肃省", + "id": "620000" + }, + { + "name": "青海省", + "id": "630000" + }, + { + "name": "宁夏回族自治区", + "id": "640000" + }, + { + "name": "新疆维吾尔自治区", + "id": "650000" + }, + { + "name": "台湾省", + "id": "710000" + }, + { + "name": "香港特别行政区", + "id": "810000" + }, + { + "name": "澳门特别行政区", + "id": "820000" + } +] diff --git a/src/pages/account/settings/index.tsx b/src/pages/account/settings/index.tsx new file mode 100644 index 00000000..3b0a3402 --- /dev/null +++ b/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 = { + base: '基本设置', + security: '安全设置', + binding: '账号绑定', + notification: '新消息通知', + }; + const [initConfig, setInitConfig] = useState({ + mode: 'inline', + selectKey: 'base', + }); + const dom = useRef(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 ; + case 'security': + return ; + case 'binding': + return ; + case 'notification': + return ; + default: + return null; + } + }; + return ( + +
    { + if (ref) { + dom.current = ref; + } + }} + > +
    + { + setInitConfig({ + ...initConfig, + selectKey: key as SettingsStateKeys, + }); + }} + items={getMenu()} + /> +
    +
    +
    {menuMap[initConfig.selectKey]}
    + {renderChildren()} +
    +
    +
    + ); +}; +export default Settings; diff --git a/src/pages/account/settings/service.ts b/src/pages/account/settings/service.ts new file mode 100644 index 00000000..2daaf243 --- /dev/null +++ b/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'); +} diff --git a/src/pages/account/settings/style.style.ts b/src/pages/account/settings/style.style.ts new file mode 100644 index 00000000..8ff05765 --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/analysis/_mock.ts b/src/pages/dashboard/analysis/_mock.ts new file mode 100644 index 00000000..4b6a0767 --- /dev/null +++ b/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, +}; diff --git a/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less new file mode 100644 index 00000000..d7bf6dda --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less @@ -0,0 +1,75 @@ +@import '~antd/es/style/themes/default.less'; + +.chartCard { + position: relative; + .chartTop { + position: relative; + width: 100%; + overflow: hidden; + } + .chartTopMargin { + margin-bottom: 12px; + } + .chartTopHasMargin { + margin-bottom: 20px; + } + .metaWrap { + float: left; + } + .avatar { + position: relative; + top: 4px; + float: left; + margin-right: 20px; + img { + border-radius: 100%; + } + } + .meta { + height: 22px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + .action { + position: absolute; + top: 4px; + right: 0; + line-height: 1; + cursor: pointer; + } + .total { + height: 38px; + margin-top: 4px; + margin-bottom: 0; + overflow: hidden; + color: @heading-color; + font-size: 30px; + line-height: 38px; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + } + .content { + position: relative; + width: 100%; + margin-bottom: 12px; + } + .contentFixed { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + } + .footer { + margin-top: 8px; + padding-top: 9px; + border-top: 1px solid @border-color-split; + & > * { + position: relative; + } + } + .footerMargin { + margin-top: 20px; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/ChartCard/index.style.ts b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.style.ts new file mode 100644 index 00000000..b21e103e --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx new file mode 100644 index 00000000..9ce4ecdc --- /dev/null +++ b/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 = (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 =
    {total()}
    ; + break; + default: + totalDom =
    {total}
    ; + } + return totalDom; + }; + const renderContent = () => { + const { + contentHeight, + title, + avatar, + action, + total, + footer, + children, + loading, + } = props; + if (loading) { + return false; + } + return ( +
    +
    +
    {avatar}
    +
    +
    + {title} + {action} +
    + {renderTotal(total)} +
    +
    + {children && ( +
    +
    + {children} +
    +
    + )} + {footer && ( +
    + {footer} +
    + )} +
    + ); + }; + + const { loading = false, ...rest } = props; + return ( + + {renderContent()} + + ); +}; +export default ChartCard; diff --git a/src/pages/dashboard/analysis/components/Charts/Field/index.less b/src/pages/dashboard/analysis/components/Charts/Field/index.less new file mode 100644 index 00000000..4fe0d1f6 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Field/index.less @@ -0,0 +1,17 @@ +@import '~antd/es/style/themes/default.less'; + +.field { + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + .label, + .number { + font-size: @font-size-base; + line-height: 22px; + } + .number { + margin-left: 8px; + color: @heading-color; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/Field/index.style.ts b/src/pages/dashboard/analysis/components/Charts/Field/index.style.ts new file mode 100644 index 00000000..22192d8a --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/analysis/components/Charts/Field/index.tsx b/src/pages/dashboard/analysis/components/Charts/Field/index.tsx new file mode 100644 index 00000000..97e4a171 --- /dev/null +++ b/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 = ({ label, value, ...rest }) => { + const { styles } = useStyles(); + return ( +
    + {label} + {value} +
    + ); +}; +export default Field; diff --git a/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx b/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx new file mode 100644 index 00000000..55042285 --- /dev/null +++ b/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 = ({ + targetLabel, + target, + color = 'rgb(19, 194, 194)', + strokeWidth, + percent, +}) => { + return ( +
    + +
    + + +
    +
    +
    +
    + ); +}; +export default MiniProgress; diff --git a/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx b/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx new file mode 100644 index 00000000..62ee38db --- /dev/null +++ b/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 { + 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 ( +
    { + this.root = n; + }} + style={{ + transform: `scale(${radio})`, + }} + > +
    + { + this.node = n; + }} + width={height * 2} + height={height * 2} + /> +
    +
    + {title && {title}} +

    {percent}%

    +
    +
    + ); + } +} +export default autoHeight()(WaterWave); diff --git a/src/pages/dashboard/analysis/components/Charts/index.less b/src/pages/dashboard/analysis/components/Charts/index.less new file mode 100644 index 00000000..190428bc --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/index.less @@ -0,0 +1,19 @@ +.miniChart { + position: relative; + width: 100%; + .chartContent { + position: absolute; + bottom: -28px; + width: 100%; + > div { + margin: 0 -5px; + overflow: hidden; + } + } + .chartLoading { + position: absolute; + top: 16px; + left: 50%; + margin-left: -7px; + } +} diff --git a/src/pages/dashboard/analysis/components/Charts/index.style.ts b/src/pages/dashboard/analysis/components/Charts/index.style.ts new file mode 100644 index 00000000..29f85452 --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/analysis/components/Charts/index.tsx b/src/pages/dashboard/analysis/components/Charts/index.tsx new file mode 100644 index 00000000..7ad687f0 --- /dev/null +++ b/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 }; diff --git a/src/pages/dashboard/analysis/components/IntroduceRow.tsx b/src/pages/dashboard/analysis/components/IntroduceRow.tsx new file mode 100644 index 00000000..432d10c0 --- /dev/null +++ b/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 ( + + + + + + } + loading={loading} + total={() => 126560} + footer={ + + } + contentHeight={46} + > + + 周同比 + 12% + + + 日同比 + 11% + + + + + + + + + } + total={numeral(8846).format('0,0')} + footer={ + + } + contentHeight={46} + > + + + + + + + + } + total={numeral(6560).format('0,0')} + footer={} + contentHeight={46} + > + + + + + + + + } + total="78%" + footer={ +
    + + 周同比 + 12% + + + 日同比 + 11% + +
    + } + contentHeight={46} + > + +
    + +
    + ); +}; +export default IntroduceRow; diff --git a/src/pages/dashboard/analysis/components/NumberInfo/index.less b/src/pages/dashboard/analysis/components/NumberInfo/index.less new file mode 100644 index 00000000..847d25ee --- /dev/null +++ b/src/pages/dashboard/analysis/components/NumberInfo/index.less @@ -0,0 +1,68 @@ +@import '~antd/es/style/themes/default.less'; + +.numberInfo { + .suffix { + margin-left: 4px; + color: @text-color; + font-size: 16px; + font-style: normal; + } + .numberInfoTitle { + margin-bottom: 16px; + color: @text-color; + font-size: @font-size-lg; + transition: all 0.3s; + } + .numberInfoSubTitle { + height: 22px; + overflow: hidden; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + } + .numberInfoValue { + margin-top: 4px; + overflow: hidden; + font-size: 0; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + & > span { + display: inline-block; + height: 32px; + margin-right: 32px; + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + .subTotal { + margin-right: 0; + color: @text-color-secondary; + font-size: @font-size-lg; + vertical-align: top; + .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; + } + } +} diff --git a/src/pages/dashboard/analysis/components/NumberInfo/index.style.ts b/src/pages/dashboard/analysis/components/NumberInfo/index.style.ts new file mode 100644 index 00000000..ba70b7b7 --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/analysis/components/NumberInfo/index.tsx b/src/pages/dashboard/analysis/components/NumberInfo/index.tsx new file mode 100644 index 00000000..c34f86f5 --- /dev/null +++ b/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 = ({ + theme, + title, + subTitle, + total, + subTotal, + status, + suffix, + gap, + ...rest +}) => { + const { styles } = useStyles(); + return ( +
    + {title && ( +
    + {title} +
    + )} + {subTitle && ( +
    + {subTitle} +
    + )} +
    + + {total} + {suffix && {suffix}} + + {(status || subTotal) && ( + + {subTotal} + {status && status === 'up' ? ( + + ) : ( + + )} + + )} +
    +
    + ); +}; +export default NumberInfo; diff --git a/src/pages/dashboard/analysis/components/OfflineData.tsx b/src/pages/dashboard/analysis/components/OfflineData.tsx new file mode 100644 index 00000000..a9e5164a --- /dev/null +++ b/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; +}) => ( + + + + + + + + +); + +const OfflineData = ({ + activeKey, + loading, + offlineData, + offlineChartData, + handleTabChange, +}: { + activeKey: string; + loading: boolean; + offlineData: OfflineDataType[]; + offlineChartData: DataItem[]; + handleTabChange: (activeKey: string) => void; +}) => { + const { styles } = useStyles(); + return ( + + ({ + key: shop.name, + label: , + children: ( +
    + +
    + ), + }))} + /> +
    + ); +}; +export default OfflineData; diff --git a/src/pages/dashboard/analysis/components/PageLoading/index.tsx b/src/pages/dashboard/analysis/components/PageLoading/index.tsx new file mode 100644 index 00000000..dd96277b --- /dev/null +++ b/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 () => ( +
    + +
    +); diff --git a/src/pages/dashboard/analysis/components/ProportionSales.tsx b/src/pages/dashboard/analysis/components/ProportionSales.tsx new file mode 100644 index 00000000..4466481a --- /dev/null +++ b/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 ( + + {dropdownGroup} + +
    + } + > + 销售额 + + `${item.x}: ${numeral(item.y).format('0,0')}`, + }} + /> + + ); +}; +export default ProportionSales; diff --git a/src/pages/dashboard/analysis/components/SalesCard.tsx b/src/pages/dashboard/analysis/components/SalesCard.tsx new file mode 100644 index 00000000..31c10cde --- /dev/null +++ b/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 ( + + +
    + + + + +
    + + + } + size="large" + tabBarStyle={{ + marginBottom: 24, + }} + items={[ + { + key: 'sales', + label: '销售额', + children: ( + + +
    + +
    + + +
    +

    门店销售额排名

    +
      + {rankingListData.map((item, i) => ( +
    • + + {i + 1} + + + {item.title} + + {numeral(item.total).format('0,0')} +
    • + ))} +
    +
    + +
    + ), + }, + { + key: 'views', + label: '访问量', + children: ( + + +
    + +
    + + +
    +

    门店访问量排名

    +
      + {rankingListData.map((item, i) => ( +
    • + + {i + 1} + + + {item.title} + + {numeral(item.total).format('0,0')} +
    • + ))} +
    +
    + +
    + ), + }, + ]} + /> +
    + ); +}; +export default SalesCard; diff --git a/src/pages/dashboard/analysis/components/TopSearch.tsx b/src/pages/dashboard/analysis/components/TopSearch.tsx new file mode 100644 index 00000000..d6a0fef4 --- /dev/null +++ b/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) => {text}, + }, + { + 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; + }, + ) => ( + + + {text}% + + + ), + }, + ]; + return ( + + + + + 搜索用户数 + + + + + } + gap={8} + total={numeral(12321).format('0,0')} + status="up" + subTotal={17.1} + /> + + + + + 人均搜索次数 + + + + + } + total={2.7} + status="down" + subTotal={26.2} + gap={8} + /> + + + + + rowKey={(record) => record.index} + size="small" + columns={columns} + dataSource={searchData} + pagination={{ + style: { + marginBottom: 0, + }, + pageSize: 5, + }} + /> + + ); +}; +export default TopSearch; diff --git a/src/pages/dashboard/analysis/components/Trend/index.less b/src/pages/dashboard/analysis/components/Trend/index.less new file mode 100644 index 00000000..3d7fdf99 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Trend/index.less @@ -0,0 +1,37 @@ +@import '~antd/es/style/themes/default.less'; + +.trendItem { + display: inline-block; + font-size: @font-size-base; + line-height: 22px; + + .up, + .down { + position: relative; + top: 1px; + margin-left: 4px; + 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; + } +} diff --git a/src/pages/dashboard/analysis/components/Trend/index.style.ts b/src/pages/dashboard/analysis/components/Trend/index.style.ts new file mode 100644 index 00000000..6d6f9b72 --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/analysis/components/Trend/index.tsx b/src/pages/dashboard/analysis/components/Trend/index.tsx new file mode 100644 index 00000000..110a4aec --- /dev/null +++ b/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 = ({ + 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 ( +
    + {children} + {flag && ( + + {flag === 'up' ? : } + + )} +
    + ); +}; +export default Trend; diff --git a/src/pages/dashboard/analysis/data.d.ts b/src/pages/dashboard/analysis/data.d.ts new file mode 100644 index 00000000..f9b69456 --- /dev/null +++ b/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[]; +} diff --git a/src/pages/dashboard/analysis/index.tsx b/src/pages/dashboard/analysis/index.tsx new file mode 100644 index 00000000..b904e03e --- /dev/null +++ b/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 = () => { + const { styles } = useStyles(); + const [salesType, setSalesType] = useState('all'); + const [currentTabKey, setCurrentTabKey] = useState(''); + const [rangePickerValue, setRangePickerValue] = useState( + 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 = ( + + + + + + ); + const handleChangeSalesType = (value: SalesType) => { + setSalesType(value); + }; + const handleTabChange = (key: string) => { + setCurrentTabKey(key); + }; + const activeKey = currentTabKey || data?.offlineData[0]?.name || ''; + return ( + + }> + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; +export default Analysis; diff --git a/src/pages/dashboard/analysis/service.ts b/src/pages/dashboard/analysis/service.ts new file mode 100644 index 00000000..d24e6262 --- /dev/null +++ b/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'); +} diff --git a/src/pages/dashboard/analysis/style.less b/src/pages/dashboard/analysis/style.less new file mode 100644 index 00000000..38a790b6 --- /dev/null +++ b/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; + } + } + } +} diff --git a/src/pages/dashboard/analysis/style.style.ts b/src/pages/dashboard/analysis/style.style.ts new file mode 100644 index 00000000..a9a5ac97 --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/analysis/utils/Yuan.tsx b/src/pages/dashboard/analysis/utils/Yuan.tsx new file mode 100644 index 00000000..a541e7b4 --- /dev/null +++ b/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 ( + { + this.main = ref; + }} + /> + ); + } +} diff --git a/src/pages/dashboard/analysis/utils/utils.ts b/src/pages/dashboard/analysis/utils/utils.ts new file mode 100644 index 00000000..6a1d4d5e --- /dev/null +++ b/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`)]; +} diff --git a/src/pages/dashboard/monitor/_mock.ts b/src/pages/dashboard/monitor/_mock.ts new file mode 100644 index 00000000..dcd1b179 --- /dev/null +++ b/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, +}; diff --git a/src/pages/dashboard/monitor/components/ActiveChart/index.less b/src/pages/dashboard/monitor/components/ActiveChart/index.less new file mode 100644 index 00000000..2f5d15f2 --- /dev/null +++ b/src/pages/dashboard/monitor/components/ActiveChart/index.less @@ -0,0 +1,51 @@ +.activeChart { + position: relative; +} +.activeChartGrid { + p { + position: absolute; + top: 80px; + } + p:last-child { + top: 115px; + } +} +.activeChartLegend { + position: relative; + height: 20px; + margin-top: 8px; + font-size: 0; + line-height: 20px; + span { + display: inline-block; + width: 33.33%; + font-size: 12px; + text-align: center; + } + span:first-child { + text-align: left; + } + span:last-child { + text-align: right; + } +} +.dashedLine { + position: relative; + top: -70px; + left: -3px; + height: 1px; + + .line { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: linear-gradient(to right, transparent 50%, #e9e9e9 50%); + background-size: 6px; + } +} + +.dashedLine:last-child { + top: -36px; +} diff --git a/src/pages/dashboard/monitor/components/ActiveChart/index.style.ts b/src/pages/dashboard/monitor/components/ActiveChart/index.style.ts new file mode 100644 index 00000000..af0e058c --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/monitor/components/ActiveChart/index.tsx b/src/pages/dashboard/monitor/components/ActiveChart/index.tsx new file mode 100644 index 00000000..006807d4 --- /dev/null +++ b/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(null); + const requestRef = useRef(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 ( +
    + +
    + +
    + {activeData && ( +
    +
    +

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

    +

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

    +
    +
    +
    +
    +
    +
    +
    +
    + )} + {activeData && ( +
    + 00:00 + {activeData[Math.floor(activeData.length / 2)]?.x} + {activeData[activeData.length - 1]?.x} +
    + )} +
    + ); +}; + +export default ActiveChart; diff --git a/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx b/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx new file mode 100644 index 00000000..90ad3eab --- /dev/null +++ b/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 { + 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 ( +
    { + this.root = n; + }} + style={{ + transform: `scale(${radio})`, + }} + > +
    + { + this.node = n; + }} + width={height * 2} + height={height * 2} + /> +
    +
    + {title && {title}} +

    {percent}%

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

    = + | React.ComponentClass

    + | React.ClassicComponentClass

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

    ( + WrappedComponent: React.ComponentClass

    | React.FC

    , + ): React.ComponentClass

    => { + class AutoHeightComponent extends React.Component

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

    + {h > 0 && } +
    + ); + } + } + return AutoHeightComponent; + }; +} +export default autoHeight; diff --git a/src/pages/dashboard/monitor/components/Map/index.tsx b/src/pages/dashboard/monitor/components/Map/index.tsx new file mode 100644 index 00000000..df155e1f --- /dev/null +++ b/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 ? ( + + ) : ( + + {grid && ( + + )} + {data && [ + , + { + return v > 2000; + }, + }} + size={{ + values: 12, + }} + style={{ + opacity: 1, + strokeOpacity: 1, + strokeWidth: 0, + }} + />, + ]} + + ); + } +} diff --git a/src/pages/dashboard/monitor/data.d.ts b/src/pages/dashboard/monitor/data.d.ts new file mode 100644 index 00000000..b6efef35 --- /dev/null +++ b/src/pages/dashboard/monitor/data.d.ts @@ -0,0 +1,5 @@ +export type TagType = { + name: string; + value: number; + type: string; +}; diff --git a/src/pages/dashboard/monitor/index.tsx b/src/pages/dashboard/monitor/index.tsx new file mode 100644 index 00000000..06a9fe9a --- /dev/null +++ b/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 ( + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + + + + + + '优', + }} + meta={{ + color: { + range: [ + '#6395FA', + '#62DAAB', + '#657798', + '#F7C128', + '#1F8718', + ], + }, + }} + /> + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + ); +}; +export default Monitor; diff --git a/src/pages/dashboard/monitor/service.ts b/src/pages/dashboard/monitor/service.ts new file mode 100644 index 00000000..2f4f5bce --- /dev/null +++ b/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'); +} diff --git a/src/pages/dashboard/monitor/style.less b/src/pages/dashboard/monitor/style.less new file mode 100644 index 00000000..d2f0c686 --- /dev/null +++ b/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; + } +} diff --git a/src/pages/dashboard/monitor/style.style.ts b/src/pages/dashboard/monitor/style.style.ts new file mode 100644 index 00000000..e36e78ca --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/workplace/_mock.ts b/src/pages/dashboard/workplace/_mock.ts new file mode 100644 index 00000000..c0dc9b73 --- /dev/null +++ b/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, +}; diff --git a/src/pages/dashboard/workplace/components/EditableLinkGroup/index.less b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.less new file mode 100644 index 00000000..5add1b0b --- /dev/null +++ b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.less @@ -0,0 +1,16 @@ +@import '~antd/es/style/themes/default.less'; + +.linkGroup { + padding: 20px 0 8px 24px; + font-size: 0; + & > a { + display: inline-block; + width: 25%; + margin-bottom: 13px; + color: @text-color; + font-size: @font-size-base; + &:hover { + color: @primary-color; + } + } +} diff --git a/src/pages/dashboard/workplace/components/EditableLinkGroup/index.style.ts b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.style.ts new file mode 100644 index 00000000..dae8f253 --- /dev/null +++ b/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; diff --git a/src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx new file mode 100644 index 00000000..f1c0f4f5 --- /dev/null +++ b/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 = (props) => { + const { styles } = useStyles(); + const { links = [], linkElement = 'a', onAdd = () => {} } = props; + return ( +
    + {links.map((link) => + createElement( + linkElement, + { + key: `linkGroup-item-${link.id || link.title}`, + to: link.href, + href: link.href, + }, + link.title, + ), + )} + +
    + ); +}; + +export default EditableLinkGroup; diff --git a/src/pages/dashboard/workplace/data.d.ts b/src/pages/dashboard/workplace/data.d.ts new file mode 100644 index 00000000..e9ccf288 --- /dev/null +++ b/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; +}; diff --git a/src/pages/dashboard/workplace/index.tsx b/src/pages/dashboard/workplace/index.tsx new file mode 100644 index 00000000..23772a09 --- /dev/null +++ b/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 }) => { + const { styles } = useStyles(); + const loading = currentUser && Object.keys(currentUser).length; + if (!loading) { + return ( + + ); + } + return ( +
    +
    + +
    +
    +
    + 早安, + {currentUser.name} + ,祝你开心每一天! +
    +
    + {currentUser.title} |{currentUser.group} +
    +
    +
    + ); +}; +const ExtraContent: FC> = () => { + const { styles } = useStyles(); + return ( +
    +
    + +
    +
    + +
    +
    + +
    +
    + ); +}; +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 ( + + {value.name} + + ); + } + return key; + }); + return ( + + } + title={ + + {item.user.name} +   + {events} + + } + description={ + + {dayjs(item.updatedAt).fromNow()} + + } + /> + + ); + }; + + return ( + + } + extraContent={} + > + + + 全部项目} + loading={projectLoading} + > + {projectNotice.map((item) => ( + + + + {item.title} +
    + } + description={item.description} + style={{ + width: '100%', + }} + /> +
    + {item.member || ''} + {item.updatedAt && ( + + {dayjs(item.updatedAt).fromNow()} + + )} +
    + + ))} + + + + loading={activitiesLoading} + renderItem={(item) => renderActivities(item)} + dataSource={activities} + className={styles.activitiesList} + size="large" + /> + + + + + {}} + links={links} + linkElement={Link} + /> + + + + + +
    + + {projectNotice.map((item) => { + return ( + + + + + {item.member.substring(0, 3)} + + + + ); + })} + +
    +
    + + + + ); +}; +export default Workplace; diff --git a/src/pages/dashboard/workplace/service.ts b/src/pages/dashboard/workplace/service.ts new file mode 100644 index 00000000..84d15931 --- /dev/null +++ b/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'); +} diff --git a/src/pages/dashboard/workplace/style.less b/src/pages/dashboard/workplace/style.less new file mode 100644 index 00000000..18fddb86 --- /dev/null +++ b/src/pages/dashboard/workplace/style.less @@ -0,0 +1,251 @@ +@import '~antd/es/style/themes/default.less'; + +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} + +.activitiesList { + padding: 0 24px 8px 24px; + + .username { + color: @text-color; + } + .event { + font-weight: normal; + } +} + +.pageHeaderContent { + display: flex; + .avatar { + flex: 0 1 72px; + & > span { + display: block; + width: 72px; + height: 72px; + border-radius: 72px; + } + } + .content { + position: relative; + top: 4px; + flex: 1 1 auto; + margin-left: 24px; + color: @text-color-secondary; + line-height: 22px; + .contentTitle { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } + } +} + +.extraContent { + .clearfix(); + + float: right; + white-space: nowrap; + .statItem { + position: relative; + display: inline-block; + padding: 0 32px; + > p:first-child { + margin-bottom: 4px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + > p { + margin: 0; + color: @heading-color; + font-size: 30px; + line-height: 38px; + > span { + color: @text-color-secondary; + font-size: 20px; + } + } + &::after { + position: absolute; + top: 8px; + right: 0; + width: 1px; + height: 40px; + background-color: @border-color-split; + content: ''; + } + &:last-child { + padding-right: 0; + &::after { + display: none; + } + } + } +} + +.members { + a { + display: block; + height: 24px; + margin: 12px 0; + color: @text-color; + transition: all 0.3s; + .textOverflow(); + .member { + margin-left: 12px; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + } + &:hover { + color: @primary-color; + } + } +} + +.projectList { + :global { + .ant-card-meta-description { + height: 44px; + overflow: hidden; + color: @text-color-secondary; + line-height: 22px; + } + } + .cardTitle { + font-size: 0; + a { + display: inline-block; + height: 24px; + margin-left: 12px; + color: @heading-color; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + &:hover { + color: @primary-color; + } + } + } + .projectGrid { + width: 33.33%; + } + .projectItemContent { + display: flex; + height: 20px; + margin-top: 8px; + overflow: hidden; + font-size: 12px; + line-height: 20px; + .textOverflow(); + a { + display: inline-block; + flex: 1 1 0; + color: @text-color-secondary; + .textOverflow(); + &:hover { + color: @primary-color; + } + } + .datetime { + flex: 0 0 auto; + float: right; + color: @disabled-color; + } + } +} + +.datetime { + color: @disabled-color; +} + +@media screen and (max-width: @screen-xl) and (min-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + margin-left: -44px; + .statItem { + padding: 0 16px; + } + } +} + +@media screen and (max-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + float: none; + margin-right: 0; + .statItem { + padding: 0 16px; + text-align: left; + &::after { + display: none; + } + } + } +} + +@media screen and (max-width: @screen-md) { + .extraContent { + margin-left: -16px; + } + .projectList { + .projectGrid { + width: 50%; + } + } +} + +@media screen and (max-width: @screen-sm) { + .pageHeaderContent { + display: block; + .content { + margin-left: 0; + } + } + .extraContent { + .statItem { + float: none; + } + } +} + +@media screen and (max-width: @screen-xs) { + .projectList { + .projectGrid { + width: 100%; + } + } +} diff --git a/src/pages/dashboard/workplace/style.style.ts b/src/pages/dashboard/workplace/style.style.ts new file mode 100644 index 00000000..2580b975 --- /dev/null +++ b/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; diff --git a/src/pages/exception/403/index.tsx b/src/pages/exception/403/index.tsx new file mode 100644 index 00000000..b5397c0b --- /dev/null +++ b/src/pages/exception/403/index.tsx @@ -0,0 +1,17 @@ +import { Link } from '@umijs/max'; +import { Button, Card, Result } from 'antd'; + +export default () => ( + + + + + } + /> + +); diff --git a/src/pages/exception/404/index.tsx b/src/pages/exception/404/index.tsx new file mode 100644 index 00000000..f2ae5149 --- /dev/null +++ b/src/pages/exception/404/index.tsx @@ -0,0 +1,17 @@ +import { Link } from '@umijs/max'; +import { Button, Card, Result } from 'antd'; + +export default () => ( + + + + + } + /> + +); diff --git a/src/pages/exception/500/index.tsx b/src/pages/exception/500/index.tsx new file mode 100644 index 00000000..55d69de5 --- /dev/null +++ b/src/pages/exception/500/index.tsx @@ -0,0 +1,17 @@ +import { Link } from '@umijs/max'; +import { Button, Card, Result } from 'antd'; + +export default () => ( + + + + + } + /> + +); diff --git a/src/pages/form/advanced-form/_mock.ts b/src/pages/form/advanced-form/_mock.ts new file mode 100644 index 00000000..b719dbc0 --- /dev/null +++ b/src/pages/form/advanced-form/_mock.ts @@ -0,0 +1,7 @@ +import type { Request, Response } from 'express'; + +export default { + 'POST /api/advancedForm': (_: Request, res: Response) => { + res.send({ data: { message: 'Ok' } }); + }, +}; diff --git a/src/pages/form/advanced-form/components/TableForm.tsx b/src/pages/form/advanced-form/components/TableForm.tsx new file mode 100644 index 00000000..e42f0fe9 --- /dev/null +++ b/src/pages/form/advanced-form/components/TableForm.tsx @@ -0,0 +1,268 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Divider, Input, message, Popconfirm, Table } from 'antd'; +import type { FC } from 'react'; +import React, { useState } from 'react'; +import useStyles from '../style.style'; + +type TableFormDateType = { + key: string; + workId?: string; + name?: string; + department?: string; + isNew?: boolean; + editable?: boolean; +}; +type TableFormProps = { + value?: TableFormDateType[]; + onChange?: (value: TableFormDateType[]) => void; +}; +const TableForm: FC = ({ value, onChange }) => { + const { styles } = useStyles(); + const [clickedCancel, setClickedCancel] = useState(false); + const [loading, setLoading] = useState(false); + const [index, setIndex] = useState(0); + const [cacheOriginData, setCacheOriginData] = useState>( + {}, + ); + const [data, setData] = useState(value); + const getRowByKey = (key: string, newData?: TableFormDateType[]) => + (newData || data)?.filter((item) => item.key === key)[0]; + const toggleEditable = ( + e: React.MouseEvent | React.KeyboardEvent, + key: string, + ) => { + e.preventDefault(); + const newData = data?.map((item) => ({ + ...item, + })); + const target = getRowByKey(key, newData); + if (target) { + // 进入编辑状态时保存原始数据 + if (!target.editable) { + cacheOriginData[key] = { + ...target, + }; + setCacheOriginData(cacheOriginData); + } + target.editable = !target.editable; + setData(newData); + } + }; + const newMember = () => { + const newData = + data?.map((item) => ({ + ...item, + })) || []; + newData.push({ + key: `NEW_TEMP_ID_${index}`, + workId: '', + name: '', + department: '', + editable: true, + isNew: true, + }); + setIndex(index + 1); + setData(newData); + }; + const remove = (key: string) => { + const newData = data?.filter( + (item) => item.key !== key, + ) as TableFormDateType[]; + setData(newData); + if (onChange) { + onChange(newData); + } + }; + const handleFieldChange = ( + e: React.ChangeEvent, + fieldName: keyof TableFormDateType, + key: string, + ) => { + const newData = [...(data as TableFormDateType[])]; + const target = getRowByKey(key, newData); + if (target?.[fieldName]) { + target[fieldName as 'key'] = e.target.value; + setData(newData); + } + }; + const saveRow = (e: React.MouseEvent | React.KeyboardEvent, key: string) => { + e.persist(); + setLoading(true); + setTimeout(() => { + if (clickedCancel) { + setClickedCancel(false); + return; + } + const target = getRowByKey(key) || ({} as any); + if (!target.workId || !target.name || !target.department) { + message.error('请填写完整成员信息。'); + (e.target as HTMLInputElement).focus(); + setLoading(false); + return; + } + delete target.isNew; + toggleEditable(e, key); + if (onChange) { + onChange(data as TableFormDateType[]); + } + setLoading(false); + }, 500); + }; + const handleKeyPress = (e: React.KeyboardEvent, key: string) => { + if (e.key === 'Enter') { + saveRow(e, key); + } + }; + const cancel = (e: React.MouseEvent, key: string) => { + setClickedCancel(true); + e.preventDefault(); + const newData = [...(data as TableFormDateType[])]; + // 编辑前的原始数据 + let cacheData = []; + cacheData = newData.map((item) => { + if (item.key === key) { + if (cacheOriginData[key]) { + const originItem = { + ...item, + ...cacheOriginData[key], + editable: false, + }; + delete cacheOriginData[key]; + setCacheOriginData(cacheOriginData); + return originItem; + } + } + return item; + }); + setData(cacheData); + setClickedCancel(false); + }; + const columns = [ + { + title: '成员姓名', + dataIndex: 'name', + key: 'name', + width: '20%', + render: (text: string, record: TableFormDateType) => { + if (record.editable) { + return ( + handleFieldChange(e, 'name', record.key)} + onKeyPress={(e) => handleKeyPress(e, record.key)} + placeholder="成员姓名" + /> + ); + } + return text; + }, + }, + { + title: '工号', + dataIndex: 'workId', + key: 'workId', + width: '20%', + render: (text: string, record: TableFormDateType) => { + if (record.editable) { + return ( + handleFieldChange(e, 'workId', record.key)} + onKeyPress={(e) => handleKeyPress(e, record.key)} + placeholder="工号" + /> + ); + } + return text; + }, + }, + { + title: '所属部门', + dataIndex: 'department', + key: 'department', + width: '40%', + render: (text: string, record: TableFormDateType) => { + if (record.editable) { + return ( + handleFieldChange(e, 'department', record.key)} + onKeyPress={(e) => handleKeyPress(e, record.key)} + placeholder="所属部门" + /> + ); + } + return text; + }, + }, + { + title: '操作', + key: 'action', + render: (_text: string, record: TableFormDateType) => { + if (!!record.editable && loading) { + return null; + } + if (record.editable) { + if (record.isNew) { + return ( + + saveRow(e, record.key)}>添加 + + remove(record.key)} + > + 删除 + + + ); + } + return ( + + saveRow(e, record.key)}>保存 + + cancel(e, record.key)}>取消 + + ); + } + return ( + + toggleEditable(e, record.key)}>编辑 + + remove(record.key)} + > + 删除 + + + ); + }, + }, + ]; + return ( + <> + + loading={loading} + columns={columns} + dataSource={data} + pagination={false} + rowClassName={(record) => (record.editable ? styles.editable : '')} + /> + + + ); +}; +export default TableForm; diff --git a/src/pages/form/advanced-form/index.tsx b/src/pages/form/advanced-form/index.tsx new file mode 100644 index 00000000..4beea994 --- /dev/null +++ b/src/pages/form/advanced-form/index.tsx @@ -0,0 +1,549 @@ +import { CloseCircleOutlined } from '@ant-design/icons'; +import type { ProColumnType } from '@ant-design/pro-components'; +import { + EditableProTable, + FooterToolbar, + PageContainer, + ProForm, + ProFormDateRangePicker, + ProFormSelect, + ProFormText, + ProFormTimePicker, +} from '@ant-design/pro-components'; +import { Card, Col, message, Popover, Row } from 'antd'; +import type { FC } from 'react'; +import { useState } from 'react'; +import { fakeSubmitForm } from './service'; +import useStyles from './style.style'; + +interface TableFormDateType { + key: string; + workId?: string; + name?: string; + department?: string; + isNew?: boolean; + editable?: boolean; +} +type InternalNamePath = (string | number)[]; +const fieldLabels = { + name: '仓库名', + url: '仓库域名', + owner: '仓库管理员', + approver: '审批人', + dateRange: '生效日期', + type: '仓库类型', + name2: '任务名', + url2: '任务描述', + owner2: '执行人', + approver2: '责任人', + dateRange2: '生效日期', + type2: '任务类型', +}; +const tableData = [ + { + key: '1', + workId: '00001', + name: 'John Brown', + department: 'New York No. 1 Lake Park', + }, + { + key: '2', + workId: '00002', + name: 'Jim Green', + department: 'London No. 1 Lake Park', + }, + { + key: '3', + workId: '00003', + name: 'Joe Black', + department: 'Sidney No. 1 Lake Park', + }, +]; +interface ErrorField { + name: InternalNamePath; + errors: string[]; +} +const AdvancedForm: FC> = () => { + const { styles } = useStyles(); + const [error, setError] = useState([]); + const getErrorInfo = (errors: ErrorField[]) => { + const errorCount = errors.filter((item) => item.errors.length > 0).length; + if (!errors || errorCount === 0) { + return null; + } + const scrollToField = (fieldKey: string) => { + const labelNode = document.querySelector(`label[for="${fieldKey}"]`); + if (labelNode) { + labelNode.scrollIntoView(true); + } + }; + const errorList = errors.map((err) => { + if (!err || err.errors.length === 0) { + return null; + } + const key = err.name[0] as + | 'name' + | 'url' + | 'owner' + | 'approver' + | 'dateRange' + | 'type'; + return ( +
  • scrollToField(key)} + > + +
    {err.errors[0]}
    +
    {fieldLabels[key]}
    +
  • + ); + }); + return ( + + { + if (trigger?.parentNode) { + return trigger.parentNode as HTMLElement; + } + return trigger; + }} + > + + + {errorCount} + + ); + }; + const onFinish = async (values: Record) => { + setError([]); + try { + await fakeSubmitForm(values); + message.success('提交成功'); + } catch { + // console.log + } + }; + const onFinishFailed = (errorInfo: any) => { + setError(errorInfo.errorFields); + }; + const columns: ProColumnType[] = [ + { + title: '成员姓名', + dataIndex: 'name', + key: 'name', + width: '20%', + }, + { + title: '工号', + dataIndex: 'workId', + key: 'workId', + width: '20%', + }, + { + title: '所属部门', + dataIndex: 'department', + key: 'department', + width: '40%', + }, + { + title: '操作', + key: 'action', + valueType: 'option', + render: (_, record: TableFormDateType, _index, action) => { + return [ + { + action?.startEditable(record.key); + }} + > + 编辑 + , + ]; + }, + }, + ]; + return ( + { + return ( + + {getErrorInfo(error)} + {dom} + + ); + }, + }} + initialValues={{ + members: tableData, + }} + onFinish={onFinish} + onFinishFailed={onFinishFailed} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + recordCreatorProps={{ + record: () => { + return { + key: `0${Date.now()}`, + }; + }, + }} + columns={columns} + rowKey="key" + /> + + + + + ); +}; +export default AdvancedForm; diff --git a/src/pages/form/advanced-form/service.ts b/src/pages/form/advanced-form/service.ts new file mode 100644 index 00000000..652759d0 --- /dev/null +++ b/src/pages/form/advanced-form/service.ts @@ -0,0 +1,8 @@ +import { request } from '@umijs/max'; + +export async function fakeSubmitForm(params: any) { + return request('/api/advancedForm', { + method: 'POST', + data: params, + }); +} diff --git a/src/pages/form/advanced-form/style.less b/src/pages/form/advanced-form/style.less new file mode 100644 index 00000000..e83ac949 --- /dev/null +++ b/src/pages/form/advanced-form/style.less @@ -0,0 +1,65 @@ +@import '~antd/es/style/themes/default.less'; + +.card { + margin-bottom: 24px; + + :global { + .ant-legacy-form-item .ant-legacy-form-item-control-wrapper { + width: 100%; + } + } +} + +.errorIcon { + margin-right: 24px; + color: @error-color; + cursor: pointer; + + span.anticon { + margin-right: 4px; + } +} + +.errorPopover { + :global { + .ant-popover-inner-content { + min-width: 256px; + max-height: 290px; + padding: 0; + overflow: auto; + } + } +} + +.errorListItem { + padding: 8px 16px; + list-style: none; + border-bottom: 1px solid @border-color-split; + cursor: pointer; + transition: all 0.3s; + &:hover { + background: @item-active-bg; + } + &:last-child { + border: 0; + } + .errorIcon { + float: left; + margin-top: 4px; + margin-right: 12px; + padding-bottom: 22px; + color: @error-color; + } + .errorField { + margin-top: 2px; + color: @text-color-secondary; + font-size: 12px; + } +} + +.editable { + td { + padding-top: 13px !important; + padding-bottom: 12.5px !important; + } +} diff --git a/src/pages/form/advanced-form/style.style.ts b/src/pages/form/advanced-form/style.style.ts new file mode 100644 index 00000000..d06c8c31 --- /dev/null +++ b/src/pages/form/advanced-form/style.style.ts @@ -0,0 +1,48 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + card: { + marginBottom: '24px', + '.ant-legacy-form-item .ant-legacy-form-item-control-wrapper': { + width: '100%', + }, + }, + errorIcon: { + marginRight: '12px', + color: token.colorError, + cursor: 'pointer', + 'span.anticon': { marginRight: '4px' }, + float: 'left', + marginTop: '4px', + paddingBottom: '22px', + }, + errorPopover: { + '.ant-popover-inner-content': { + minWidth: '256px', + maxHeight: '290px', + padding: '0', + overflow: 'auto', + }, + }, + errorListItem: { + padding: '8px 16px', + listStyle: 'none', + borderBottom: `1px solid ${token.colorSplit}`, + cursor: 'pointer', + transition: 'all 0.3s', + '&:hover': { background: token.colorBgTextActive }, + '&:last-child': { border: '0' }, + }, + errorField: { + marginTop: '2px', + color: token.colorTextSecondary, + fontSize: '12px', + }, + editable: { + td: { paddingTop: '13px', paddingBottom: '12.5px' }, + }, + }; +}); + +export default useStyles; diff --git a/src/pages/form/basic-form/_mock.ts b/src/pages/form/basic-form/_mock.ts new file mode 100644 index 00000000..b1f2235c --- /dev/null +++ b/src/pages/form/basic-form/_mock.ts @@ -0,0 +1,7 @@ +import type { Request, Response } from 'express'; + +export default { + 'POST /api/basicForm': (_: Request, res: Response) => { + res.send({ data: { message: 'Ok' } }); + }, +}; diff --git a/src/pages/form/basic-form/index.tsx b/src/pages/form/basic-form/index.tsx new file mode 100644 index 00000000..882fb0b3 --- /dev/null +++ b/src/pages/form/basic-form/index.tsx @@ -0,0 +1,194 @@ +import { + PageContainer, + ProForm, + ProFormDateRangePicker, + ProFormDependency, + ProFormDigit, + ProFormRadio, + ProFormSelect, + ProFormText, + ProFormTextArea, +} from '@ant-design/pro-components'; +import { useRequest } from '@umijs/max'; +import { Card, message } from 'antd'; +import type { FC } from 'react'; +import { fakeSubmitForm } from './service'; +import useStyles from './style.style'; + +const BasicForm: FC> = () => { + const { styles } = useStyles(); + const { run } = useRequest(fakeSubmitForm, { + manual: true, + onSuccess: () => { + message.success('提交成功'); + }, + }); + const onFinish = async (values: Record) => { + run(values); + }; + return ( + + + + + + + + + + + 客户 + (选填) + + } + tooltip="目标的服务对象" + name="client" + placeholder="请描述你服务的客户,内部客户直接 @姓名/工号" + /> + + + 邀评人 + (选填) + + } + name="invites" + placeholder="请直接 @姓名/工号,最多可邀请 5 人" + /> + + + 权重 + (选填) + + } + name="weight" + placeholder="请输入" + min={0} + max={100} + width="xs" + fieldProps={{ + formatter: (value) => `${value || 0}%`, + parser: (value) => Number(value ? value.replace('%', '') : '0'), + }} + /> + + + + {({ publicType }) => { + return ( + + ); + }} + + + + + ); +}; +export default BasicForm; diff --git a/src/pages/form/basic-form/service.ts b/src/pages/form/basic-form/service.ts new file mode 100644 index 00000000..7553f9c4 --- /dev/null +++ b/src/pages/form/basic-form/service.ts @@ -0,0 +1,8 @@ +import { request } from '@umijs/max'; + +export async function fakeSubmitForm(params: any) { + return request('/api/basicForm', { + method: 'POST', + data: params, + }); +} diff --git a/src/pages/form/basic-form/style.less b/src/pages/form/basic-form/style.less new file mode 100644 index 00000000..60a374ba --- /dev/null +++ b/src/pages/form/basic-form/style.less @@ -0,0 +1,6 @@ +@import '~antd/es/style/themes/default.less'; + +.optional { + color: @text-color-secondary; + font-style: normal; +} diff --git a/src/pages/form/basic-form/style.style.ts b/src/pages/form/basic-form/style.style.ts new file mode 100644 index 00000000..e8a8406b --- /dev/null +++ b/src/pages/form/basic-form/style.style.ts @@ -0,0 +1,12 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + optional: { + color: token.colorTextSecondary, + fontStyle: 'normal', + }, + }; +}); + +export default useStyles; diff --git a/src/pages/form/step-form/_mock.ts b/src/pages/form/step-form/_mock.ts new file mode 100644 index 00000000..c010228c --- /dev/null +++ b/src/pages/form/step-form/_mock.ts @@ -0,0 +1,7 @@ +import type { Request, Response } from 'express'; + +export default { + 'POST /api/stepForm': (_: Request, res: Response) => { + res.send({ data: { message: 'Ok' } }); + }, +}; diff --git a/src/pages/form/step-form/data.d.ts b/src/pages/form/step-form/data.d.ts new file mode 100644 index 00000000..4bddb0c8 --- /dev/null +++ b/src/pages/form/step-form/data.d.ts @@ -0,0 +1,9 @@ +export interface StepDataType { + payAccount: string; + receiverAccount: string; + receiverName: string; + amount: string; + receiverMode: string; +} + +export type CurrentTypes = 'base' | 'confirm' | 'result'; diff --git a/src/pages/form/step-form/index.tsx b/src/pages/form/step-form/index.tsx new file mode 100644 index 00000000..4ec294a8 --- /dev/null +++ b/src/pages/form/step-form/index.tsx @@ -0,0 +1,248 @@ +import { + PageContainer, + ProForm, + ProFormDigit, + ProFormSelect, + ProFormText, + StepsForm, +} from '@ant-design/pro-components'; +import type { FormInstance } from 'antd'; +import { + Alert, + Button, + Card, + Descriptions, + Divider, + Result, + Statistic, +} from 'antd'; +import React, { useRef, useState } from 'react'; +import type { StepDataType } from './data.d'; +import useStyles from './style.style'; + +const StepDescriptions: React.FC<{ + stepData: StepDataType; + bordered?: boolean; +}> = ({ stepData, bordered }) => { + const { payAccount, receiverAccount, receiverName, amount } = stepData; + return ( + + {payAccount} + {receiverAccount} + {receiverName} + + + 元 + + } + precision={2} + /> + + + ); +}; +const StepResult: React.FC<{ + onFinish: () => Promise; + children?: React.ReactNode; +}> = (props) => { + const { styles } = useStyles(); + return ( + + + + + } + className={styles.result} + > + {props.children} + + ); +}; +const StepForm: React.FC> = () => { + const { styles } = useStyles(); + const [stepData, setStepData] = useState({ + payAccount: 'ant-design@alipay.com', + receiverAccount: 'test@example.com', + receiverName: 'Alex', + amount: '500', + receiverMode: 'alipay', + }); + const [current, setCurrent] = useState(0); + const formRef = useRef(null); + return ( + + + { + if (props.step === 2) { + return null; + } + return dom; + }, + }} + > + + formRef={formRef} + title="填写转账信息" + initialValues={stepData} + onFinish={async (values) => { + setStepData(values); + return true; + }} + > + + + + + + + + + + + +
    + + + + +
    +
    + + { + setCurrent(0); + formRef.current?.resetFields(); + }} + > + + + +
    + +
    +

    说明

    +

    转账到支付宝账户

    +

    + 如果需要,这里可以放一些关于产品的常见问题说明。如果需要,这里可以放一些关于产品的常见问题说明。如果需要,这里可以放一些关于产品的常见问题说明。 +

    +

    转账到银行卡

    +

    + 如果需要,这里可以放一些关于产品的常见问题说明。如果需要,这里可以放一些关于产品的常见问题说明。如果需要,这里可以放一些关于产品的常见问题说明。 +

    +
    +
    +
    + ); +}; +export default StepForm; diff --git a/src/pages/form/step-form/service.ts b/src/pages/form/step-form/service.ts new file mode 100644 index 00000000..5834a574 --- /dev/null +++ b/src/pages/form/step-form/service.ts @@ -0,0 +1,8 @@ +import { request } from '@umijs/max'; + +export async function fakeSubmitForm(params: any) { + return request('/api/stepForm', { + method: 'POST', + data: params, + }); +} diff --git a/src/pages/form/step-form/style.less b/src/pages/form/step-form/style.less new file mode 100644 index 00000000..443eb2ef --- /dev/null +++ b/src/pages/form/step-form/style.less @@ -0,0 +1,11 @@ +@import '~antd/es/style/themes/default.less'; + +.card { + margin-bottom: 24px; +} + +.result { + max-width: 560px; + margin: 0 auto; + padding: 24px 0 8px; +} diff --git a/src/pages/form/step-form/style.style.ts b/src/pages/form/step-form/style.style.ts new file mode 100644 index 00000000..c4133149 --- /dev/null +++ b/src/pages/form/step-form/style.style.ts @@ -0,0 +1,15 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(() => { + return { + card: { + marginBottom: '24px', + }, + result: { + maxWidth: '560px', + margin: '0 auto', + padding: '24px 0 8px', + }, + }; +}); +export default useStyles; diff --git a/src/pages/list/basic-list/_mock.ts b/src/pages/list/basic-list/_mock.ts new file mode 100644 index 00000000..680f6883 --- /dev/null +++ b/src/pages/list/basic-list/_mock.ts @@ -0,0 +1,165 @@ +import type { Request, Response } from 'express'; +import type { BasicListItemDataType } from './data.d'; + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const covers = [ + 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iXjVmWVHbCJAyqvDxdtx.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png', +]; +const desc = [ + '那是一种内在的东西, 他们到达不了,也无法触及的', + '希望是一个好东西,也许是最好的,好东西是不会消亡的', + '生命就像一盒巧克力,结果往往出人意料', + '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + '那时候我只会想自己想要什么,从不想自己拥有什么', +]; + +const user = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +function fakeList(count: number): BasicListItemDataType[] { + 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; +} + +let sourceData: BasicListItemDataType[] = []; + +function getFakeList(req: Request, res: Response) { + const params = req.query as any; + + const count = Number(params.count) * 1 || 20; + + const result = fakeList(count); + sourceData = result; + return res.json({ + data: { + list: result, + }, + }); +} + +function postFakeList(req: Request, res: Response) { + const { /* url = '', */ body } = req; + // const params = getUrlParams(url); + const { method, id } = body; + // const count = (params.count * 1) || 20; + let result = sourceData || []; + + switch (method) { + case 'delete': + result = result.filter((item) => item.id !== id); + break; + case 'update': + result.forEach((item, i) => { + if (item.id === id) { + result[i] = { ...item, ...body }; + } + }); + break; + case 'post': + result.unshift({ + ...body, + id: `fake-list-${result.length}`, + createdAt: Date.now(), + }); + break; + default: + break; + } + + return res.json({ + data: { + list: result, + }, + }); +} + +export default { + 'GET /api/get_list': getFakeList, + 'POST /api/post_fake_list': postFakeList, +}; diff --git a/src/pages/list/basic-list/components/OperationModal.tsx b/src/pages/list/basic-list/components/OperationModal.tsx new file mode 100644 index 00000000..4f3fb9e9 --- /dev/null +++ b/src/pages/list/basic-list/components/OperationModal.tsx @@ -0,0 +1,129 @@ +import { + ModalForm, + ProFormDateTimePicker, + ProFormSelect, + ProFormText, + ProFormTextArea, +} from '@ant-design/pro-components'; +import { Button, Result } from 'antd'; +import type { FC } from 'react'; +import type { BasicListItemDataType } from '../data.d'; +import useStyles from '../style.style'; + +type OperationModalProps = { + done: boolean; + open: boolean; + current: Partial | undefined; + onDone: () => void; + onSubmit: (values: BasicListItemDataType) => void; + children?: React.ReactNode; +}; +const OperationModal: FC = (props) => { + const { styles } = useStyles(); + const { done, open, current, onDone, onSubmit, children } = props; + if (!open) { + return null; + } + return ( + + open={open} + title={done ? null : `任务${current ? '编辑' : '添加'}`} + className={styles.standardListForm} + width={640} + onFinish={async (values) => { + onSubmit(values); + }} + initialValues={current} + submitter={{ + render: (_, dom) => (done ? null : dom), + }} + trigger={children} + modalProps={{ + onCancel: () => onDone(), + destroyOnClose: true, + bodyStyle: done + ? { + padding: '72px 0', + } + : {}, + }} + > + {!done ? ( + <> + + + + + + ) : ( + + 知道了 + + } + className={styles.formResult} + /> + )} + + ); +}; +export default OperationModal; diff --git a/src/pages/list/basic-list/data.d.ts b/src/pages/list/basic-list/data.d.ts new file mode 100644 index 00000000..f1cfa668 --- /dev/null +++ b/src/pages/list/basic-list/data.d.ts @@ -0,0 +1,29 @@ +export type Member = { + avatar: string; + name: string; + id: string; +}; + +export type BasicListItemDataType = { + id: string; + owner: string; + title: string; + avatar: string; + cover: string; + status: 'normal' | 'exception' | 'active' | 'success'; + percent: number; + logo: string; + href: string; + body?: any; + updatedAt: number; + createdAt: number; + subDescription: string; + description: string; + activeUser: number; + newUser: number; + star: number; + like: number; + message: number; + content: string; + members: Member[]; +}; diff --git a/src/pages/list/basic-list/index.tsx b/src/pages/list/basic-list/index.tsx new file mode 100644 index 00000000..05c8b2d0 --- /dev/null +++ b/src/pages/list/basic-list/index.tsx @@ -0,0 +1,280 @@ +import { DownOutlined, PlusOutlined } from '@ant-design/icons'; +import { PageContainer } from '@ant-design/pro-components'; +import { useRequest } from '@umijs/max'; +import { + Avatar, + Button, + Card, + Col, + Dropdown, + Input, + List, + Modal, + Progress, + Row, + Segmented, +} from 'antd'; +import dayjs from 'dayjs'; +import type { FC } from 'react'; +import React, { useState } from 'react'; +import OperationModal from './components/OperationModal'; +import type { BasicListItemDataType } from './data.d'; +import { + addFakeList, + queryFakeList, + removeFakeList, + updateFakeList, +} from './service'; +import useStyles from './style.style'; + +const { Search } = Input; +const Info: FC<{ + title: React.ReactNode; + value: React.ReactNode; + bordered?: boolean; +}> = ({ title, value, bordered }) => { + const { styles } = useStyles(); + return ( +
    + {title} +

    {value}

    + {bordered && } +
    + ); +}; +const ListContent = ({ + data: { owner, createdAt, percent, status }, +}: { + data: BasicListItemDataType; +}) => { + const { styles } = useStyles(); + return ( +
    +
    + Owner +

    {owner}

    +
    +
    + 开始时间 +

    {dayjs(createdAt).format('YYYY-MM-DD HH:mm')}

    +
    +
    + +
    +
    + ); +}; +export const BasicList: FC = () => { + const { styles } = useStyles(); + const [done, setDone] = useState(false); + const [open, setVisible] = useState(false); + const [current, setCurrent] = useState< + Partial | undefined + >(undefined); + const { + data: listData, + loading, + mutate, + } = useRequest(() => { + return queryFakeList({ + count: 50, + }); + }); + const { run: postRun } = useRequest( + (method, params) => { + if (method === 'remove') { + return removeFakeList(params); + } + if (method === 'update') { + return updateFakeList(params); + } + return addFakeList(params); + }, + { + manual: true, + onSuccess: (result) => { + mutate(result); + }, + }, + ); + const list = listData?.list || []; + const paginationProps = { + showSizeChanger: true, + showQuickJumper: true, + pageSize: 5, + total: list.length, + }; + const showEditModal = (item: BasicListItemDataType) => { + setVisible(true); + setCurrent(item); + }; + const deleteItem = (id: string) => { + postRun('remove', { + id, + }); + }; + const editAndDelete = ( + key: string | number, + currentItem: BasicListItemDataType, + ) => { + if (key === 'edit') showEditModal(currentItem); + else if (key === 'delete') { + Modal.confirm({ + title: '删除任务', + content: '确定删除该任务吗?', + okText: '确认', + cancelText: '取消', + onOk: () => deleteItem(currentItem.id), + }); + } + }; + const extraContent = ( +
    + + ({})} + variant="filled" + /> +
    + ); + const MoreBtn: React.FC<{ + item: BasicListItemDataType; + }> = ({ item }) => ( + editAndDelete(key, item), + items: [ + { + key: 'edit', + label: '编辑', + }, + { + key: 'delete', + label: '删除', + }, + ], + }} + > + + 更多 + + + ); + const handleDone = () => { + setDone(false); + setVisible(false); + setCurrent({}); + }; + const handleSubmit = (values: BasicListItemDataType) => { + setDone(true); + const method = values?.id ? 'update' : 'add'; + postRun(method, values); + }; + return ( +
    + +
    + + + + + + + + + + + + + + + + ( + { + e.preventDefault(); + showEditModal(item); + }} + > + 编辑 + , + , + ]} + > + + } + title={{item.title}} + description={item.subDescription} + /> + + + )} + /> + +
    +
    + + +
    + ); +}; +export default BasicList; diff --git a/src/pages/list/basic-list/service.ts b/src/pages/list/basic-list/service.ts new file mode 100644 index 00000000..99eb3541 --- /dev/null +++ b/src/pages/list/basic-list/service.ts @@ -0,0 +1,50 @@ +import { request } from '@umijs/max'; +import type { BasicListItemDataType } from './data.d'; + +type ParamsType = { + count?: number; +} & Partial; + +export async function queryFakeList( + params: ParamsType, +): Promise<{ data: { list: BasicListItemDataType[] } }> { + return request('/api/get_list', { + params, + }); +} + +export async function removeFakeList( + params: ParamsType, +): Promise<{ data: { list: BasicListItemDataType[] } }> { + return request('/api/post_fake_list', { + method: 'POST', + data: { + ...params, + method: 'delete', + }, + }); +} + +export async function addFakeList( + params: ParamsType, +): Promise<{ data: { list: BasicListItemDataType[] } }> { + return request('/api/post_fake_list', { + method: 'POST', + data: { + ...params, + method: 'post', + }, + }); +} + +export async function updateFakeList( + params: ParamsType, +): Promise<{ data: { list: BasicListItemDataType[] } }> { + return request('/api/post_fake_list', { + method: 'POST', + data: { + ...params, + method: 'update', + }, + }); +} diff --git a/src/pages/list/basic-list/style.style.ts b/src/pages/list/basic-list/style.style.ts new file mode 100644 index 00000000..7e120666 --- /dev/null +++ b/src/pages/list/basic-list/style.style.ts @@ -0,0 +1,141 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + standardList: { + '.ant-card-head': { borderBottom: 'none' }, + '.ant-card-head-title': { padding: '24px 0', lineHeight: '32px' }, + '.ant-card-extra': { padding: '24px 0' }, + '.ant-list-pagination': { marginTop: '24px', textAlign: 'right' }, + '.ant-avatar-lg': { width: '48px', height: '48px', lineHeight: '48px' }, + [`@media screen and (max-width: ${token.screenXS}px)`]: { + '.ant-list-item-content': { + display: 'block', + flex: 'none', + width: '100%', + }, + '.ant-list-item-action': { + marginLeft: '0', + }, + }, + }, + headerInfo: { + position: 'relative', + textAlign: 'center', + '& > span': { + display: 'inline-block', + marginBottom: '4px', + color: token.colorTextSecondary, + fontSize: token.fontSize, + lineHeight: '22px', + }, + '& > p': { + margin: '0', + color: token.colorTextHeading, + fontSize: '24px', + lineHeight: '32px', + }, + '& > em': { + position: 'absolute', + top: '0', + right: '0', + width: '1px', + height: '56px', + backgroundColor: token.colorSplit, + }, + [`@media screen and (max-width: ${token.screenSM}px)`]: { + marginBottom: '16px', + '& > em': { + display: 'none', + }, + }, + }, + listContent: { + fontSize: '0', + [`@media screen and (max-width: ${token.screenXS}px)`]: { + marginLeft: '0', + '& > div': { + marginLeft: '0', + }, + }, + [`@media screen and (max-width: ${token.screenMD}px)`]: { + '& > div': { + display: 'block', + }, + '& > div:last-child': { + top: '0', + width: '100%', + }, + }, + [`@media screen and (max-width: ${token.screenLG}px) and (min-width: @screen-md)`]: + { + '& > div': { + display: 'block', + }, + '& > div:last-child': { + top: '0', + width: '100%', + }, + }, + [`@media screen and (max-width: ${token.screenXL}px)`]: { + '& > div': { + marginLeft: '24px', + }, + '& > div:last-child': { + top: '0', + }, + }, + '@media screen and (max-width: 1400px)': { + textAlign: 'right', + '& > div:last-child': { + top: '0', + }, + }, + }, + listContentItem: { + display: 'inline-block', + marginLeft: '40px', + color: token.colorTextSecondary, + fontSize: token.fontSize, + verticalAlign: 'middle', + '> span': { lineHeight: '20px' }, + '> p': { marginTop: '4px', marginBottom: '0', lineHeight: '22px' }, + }, + extraContentSearch: { + width: '272px', + marginLeft: '16px', + [`@media screen and (max-width: ${token.screenSM}px)`]: { + width: '100%', + marginLeft: '0', + }, + }, + listCard: { + [`@media screen and (max-width: ${token.screenXS}px)`]: { + '.ant-card-head-title': { + overflow: 'open', + }, + }, + [`@media screen and (max-width: ${token.screenMD}px)`]: { + '.ant-radio-group': { + display: 'block', + marginBottom: '8px', + }, + }, + }, + standardListForm: { + '.ant-form-item': { + marginBottom: '12px', + '&:last-child': { + marginBottom: '32px', + paddingTop: '4px', + }, + }, + }, + formResult: { + width: '100%', + "[class^='title']": { marginBottom: '8px' }, + }, + }; +}); + +export default useStyles; diff --git a/src/pages/list/basic-list/utils/utils.style.ts b/src/pages/list/basic-list/utils/utils.style.ts new file mode 100644 index 00000000..0ad5e64a --- /dev/null +++ b/src/pages/list/basic-list/utils/utils.style.ts @@ -0,0 +1,6 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(() => { + return {}; +}); +export default useStyles; diff --git a/src/pages/list/card-list/_mock.ts b/src/pages/list/card-list/_mock.ts new file mode 100644 index 00000000..36b5d755 --- /dev/null +++ b/src/pages/list/card-list/_mock.ts @@ -0,0 +1,125 @@ +import type { Request, Response } from 'express'; +import type { CardListItemDataType } from './data.d'; + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const covers = [ + 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iXjVmWVHbCJAyqvDxdtx.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png', +]; +const desc = [ + '那是一种内在的东西, 他们到达不了,也无法触及的', + '希望是一个好东西,也许是最好的,好东西是不会消亡的', + '生命就像一盒巧克力,结果往往出人意料', + '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + '那时候我只会想自己想要什么,从不想自己拥有什么', +]; + +const user = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +function fakeList(count: number): CardListItemDataType[] { + 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 || 20; + + const result = fakeList(count); + return res.json({ + data: { + list: result, + }, + }); +} + +export default { + 'GET /api/card_fake_list': getFakeList, +}; diff --git a/src/pages/list/card-list/data.d.ts b/src/pages/list/card-list/data.d.ts new file mode 100644 index 00000000..c7e663ff --- /dev/null +++ b/src/pages/list/card-list/data.d.ts @@ -0,0 +1,29 @@ +export type Member = { + avatar: string; + name: string; + id: string; +}; + +export type CardListItemDataType = { + id: string; + owner: string; + title: string; + avatar: string; + cover: string; + status: 'normal' | 'exception' | 'active' | 'success'; + percent: number; + logo: string; + href: string; + body?: any; + updatedAt: number; + createdAt: number; + subDescription: string; + description: string; + activeUser: number; + newUser: number; + star: number; + like: number; + message: number; + content: string; + members: Member[]; +}; diff --git a/src/pages/list/card-list/index.tsx b/src/pages/list/card-list/index.tsx new file mode 100644 index 00000000..a7b52927 --- /dev/null +++ b/src/pages/list/card-list/index.tsx @@ -0,0 +1,124 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { PageContainer } from '@ant-design/pro-components'; +import { useRequest } from '@umijs/max'; +import { Button, Card, List, Typography } from 'antd'; +import type { CardListItemDataType } from './data.d'; +import { queryFakeList } from './service'; +import useStyles from './style.style'; + +const { Paragraph } = Typography; +const CardList = () => { + const { styles } = useStyles(); + const { data, loading } = useRequest(() => { + return queryFakeList({ + count: 8, + }); + }); + const list = data?.list || []; + const content = ( +
    +

    + 段落示意:蚂蚁金服务设计平台 + ant.design,用最小的工作量,无缝接入蚂蚁金服生态, + 提供跨越设计与开发的体验解决方案。 +

    + +
    + ); + const extraContent = ( +
    + 这是一个标题 +
    + ); + const nullData: Partial = {}; + return ( + +
    + > + rowKey="id" + loading={loading} + grid={{ + gutter: 16, + xs: 1, + sm: 2, + md: 3, + lg: 3, + xl: 4, + xxl: 4, + }} + dataSource={[nullData, ...list]} + renderItem={(item) => { + if (item?.id) { + return ( + + 操作一, + 操作二, + ]} + > + + } + title={{item.title}} + description={ + + {item.description} + + } + /> + + + ); + } + return ( + + + + ); + }} + /> +
    +
    + ); +}; +export default CardList; diff --git a/src/pages/list/card-list/service.ts b/src/pages/list/card-list/service.ts new file mode 100644 index 00000000..8095b5f0 --- /dev/null +++ b/src/pages/list/card-list/service.ts @@ -0,0 +1,10 @@ +import { request } from '@umijs/max'; +import type { CardListItemDataType } from './data.d'; + +export async function queryFakeList(params: { + count: number; +}): Promise<{ data: { list: CardListItemDataType[] } }> { + return request('/api/card_fake_list', { + params, + }); +} diff --git a/src/pages/list/card-list/style.style.ts b/src/pages/list/card-list/style.style.ts new file mode 100644 index 00000000..69bbff47 --- /dev/null +++ b/src/pages/list/card-list/style.style.ts @@ -0,0 +1,89 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + card: { + '.ant-card-meta-title': { + marginBottom: '12px', + '& > a': { + display: 'inline-block', + maxWidth: '100%', + color: token.colorTextHeading, + }, + }, + '.ant-card-body:hover': { + '.ant-card-meta-title > a': { + color: token.colorPrimary, + }, + }, + }, + item: { + height: '64px', + }, + cardList: { + '.ant-list .ant-list-item-content-single': { maxWidth: '100%' }, + }, + extraImg: { + width: '155px', + marginTop: '-20px', + textAlign: 'center', + img: { width: '100%' }, + [`@media screen and (max-width: ${token.screenMD}px)`]: { + display: 'none', + }, + }, + newButton: { + width: '100%', + height: '201px', + color: token.colorTextSecondary, + backgroundColor: token.colorBgContainer, + borderColor: token.colorBorder, + }, + cardAvatar: { + width: '48px', + height: '48px', + borderRadius: '48px', + }, + cardDescription: { + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + wordBreak: 'break-all', + }, + pageHeaderContent: { + position: 'relative', + [`@media screen and (max-width: ${token.screenSM}px)`]: { + paddingBottom: '30px', + }, + }, + contentLink: { + marginTop: '16px', + a: { + marginRight: '32px', + img: { + width: '24px', + }, + }, + img: { marginRight: '8px', verticalAlign: 'middle' }, + [`@media screen and (max-width: ${token.screenLG}px)`]: { + a: { + marginRight: '16px', + }, + }, + [`@media screen and (max-width: ${token.screenSM}px)`]: { + position: 'absolute', + bottom: '-4px', + left: '0', + width: '1000px', + a: { + marginRight: '16px', + }, + img: { + marginRight: '4px', + }, + }, + }, + }; +}); + +export default useStyles; diff --git a/src/pages/list/card-list/utils/utils.style.ts b/src/pages/list/card-list/utils/utils.style.ts new file mode 100644 index 00000000..0ad5e64a --- /dev/null +++ b/src/pages/list/card-list/utils/utils.style.ts @@ -0,0 +1,6 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(() => { + return {}; +}); +export default useStyles; diff --git a/src/pages/list/mock/index.ts b/src/pages/list/mock/index.ts new file mode 100644 index 00000000..733e9a0d --- /dev/null +++ b/src/pages/list/mock/index.ts @@ -0,0 +1,6 @@ +import { DefaultOptionType } from 'antd/es/select'; + +export const categoryOptions: DefaultOptionType[] = Array.from({ length: 12 }).map((_, index) => ({ + value: `cat${index + 1}`, + label: `类目${index + 1}`, +})); diff --git a/src/pages/list/search/applications/_mock.ts b/src/pages/list/search/applications/_mock.ts new file mode 100644 index 00000000..4c39b899 --- /dev/null +++ b/src/pages/list/search/applications/_mock.ts @@ -0,0 +1,124 @@ +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 = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +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: any = req.query; + + const count = params.count * 1 || 20; + + const result = fakeList(count); + return res.json({ + data: { + list: result, + }, + }); +} + +export default { + 'GET /api/fake_list': getFakeList, +}; diff --git a/src/pages/list/search/applications/components/StandardFormRow/index.style.ts b/src/pages/list/search/applications/components/StandardFormRow/index.style.ts new file mode 100644 index 00000000..89156097 --- /dev/null +++ b/src/pages/list/search/applications/components/StandardFormRow/index.style.ts @@ -0,0 +1,60 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + standardFormRow: { + display: 'flex', + marginBottom: '16px', + paddingBottom: '16px', + borderBottom: `1px dashed ${token.colorSplit}`, + '.ant-form-item, .ant-legacy-form-item': { marginRight: '24px' }, + '.ant-form-item-label, .ant-legacy-form-item-label': { + label: { + marginRight: '0', + color: token.colorText, + }, + }, + '.ant-form-item-label, .ant-legacy-form-item-label, .ant-form-item-control, .ant-legacy-form-item-control': + { padding: '0', lineHeight: '32px' }, + }, + label: { + flex: '0 0 auto', + marginRight: '24px', + color: token.colorTextHeading, + fontSize: token.fontSize, + textAlign: 'right', + '& > span': { + display: 'inline-block', + height: '32px', + lineHeight: '32px', + '&::after': { + content: "':'", + }, + }, + }, + content: { + flex: '1 1 0', + '.ant-form-item, .ant-legacy-form-item': { + '&:last-child': { + marginRight: '0', + }, + }, + }, + standardFormRowLast: { + marginBottom: '0', + paddingBottom: '0', + border: 'none', + }, + standardFormRowBlock: { + '.ant-form-item, .ant-legacy-form-item, div.ant-form-item-control-wrapper, div.ant-legacy-form-item-control-wrapper': + { display: 'block' }, + }, + standardFormRowGrid: { + '.ant-form-item, .ant-legacy-form-item, div.ant-form-item-control-wrapper, div.ant-legacy-form-item-control-wrapper': + { display: 'block' }, + '.ant-form-item-label, .ant-legacy-form-item-label': { float: 'left' }, + }, + }; +}); + +export default useStyles; diff --git a/src/pages/list/search/applications/components/StandardFormRow/index.tsx b/src/pages/list/search/applications/components/StandardFormRow/index.tsx new file mode 100644 index 00000000..f2ed2175 --- /dev/null +++ b/src/pages/list/search/applications/components/StandardFormRow/index.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames'; +import React from 'react'; +import useStyles from './index.style'; + +type StandardFormRowProps = { + title?: string; + last?: boolean; + block?: boolean; + grid?: boolean; + style?: React.CSSProperties; + children?: React.ReactNode; +}; +const StandardFormRow: React.FC = ({ + title, + children, + last, + block, + grid, + ...rest +}) => { + const { styles } = useStyles(); + const cls = classNames(styles.standardFormRow, { + [styles.standardFormRowBlock]: block, + [styles.standardFormRowLast]: last, + [styles.standardFormRowGrid]: grid, + }); + return ( +
    + {title && ( +
    + {title} +
    + )} +
    {children}
    +
    + ); +}; +export default StandardFormRow; diff --git a/src/pages/list/search/applications/components/TagSelect/index.style.ts b/src/pages/list/search/applications/components/TagSelect/index.style.ts new file mode 100644 index 00000000..d198ab7a --- /dev/null +++ b/src/pages/list/search/applications/components/TagSelect/index.style.ts @@ -0,0 +1,35 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + tagSelect: { + position: 'relative', + maxHeight: '32px', + marginLeft: '-8px', + overflow: 'hidden', + lineHeight: '32px', + transition: 'all 0.3s', + userSelect: 'none', + '.ant-tag': { + marginRight: '24px', + padding: '0 8px', + fontSize: token.fontSize, + }, + }, + trigger: { + position: 'absolute', + top: '0', + right: '0', + 'span.anticon': { fontSize: '12px' }, + }, + expanded: { + maxHeight: '200px', + transition: 'all 0.3s', + }, + hasExpandTag: { + paddingRight: '50px', + }, + }; +}); + +export default useStyles; diff --git a/src/pages/list/search/applications/components/TagSelect/index.tsx b/src/pages/list/search/applications/components/TagSelect/index.tsx new file mode 100644 index 00000000..4c0baf0d --- /dev/null +++ b/src/pages/list/search/applications/components/TagSelect/index.tsx @@ -0,0 +1,160 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons'; +import { Tag } from 'antd'; +import classNames from 'classnames'; +import { useMergedState } from 'rc-util'; +import React, { type FC, useState } from 'react'; +import useStyles from './index.style'; + +const { CheckableTag } = Tag; +export interface TagSelectOptionProps { + value: string | number; + style?: React.CSSProperties; + checked?: boolean; + onChange?: (value: string | number, state: boolean) => void; + children?: React.ReactNode; +} +const TagSelectOption: React.FC & { + isTagSelectOption: boolean; +} = ({ children, checked, onChange, value }) => ( + onChange?.(value, state)} + > + {children} + +); + +TagSelectOption.isTagSelectOption = true; + +type TagSelectOptionElement = React.ReactElement< + TagSelectOptionProps, + typeof TagSelectOption +>; + +export interface TagSelectProps { + onChange?: (value: (string | number)[]) => void; + expandable?: boolean; + value?: (string | number)[]; + defaultValue?: (string | number)[]; + style?: React.CSSProperties; + hideCheckAll?: boolean; + actionsText?: { + expandText?: React.ReactNode; + collapseText?: React.ReactNode; + selectAllText?: React.ReactNode; + }; + className?: string; + Option?: TagSelectOptionProps; + children?: TagSelectOptionElement | TagSelectOptionElement[]; +} +const TagSelect: FC & { + Option: typeof TagSelectOption; +} = (props) => { + const { styles } = useStyles(); + const { + children, + hideCheckAll = false, + className, + style, + expandable, + actionsText = {}, + } = props; + const [expand, setExpand] = useState(false); + + const [value, setValue] = useMergedState<(string | number)[]>( + props.defaultValue || [], + { + value: props.value, + defaultValue: props.defaultValue, + onChange: props.onChange, + }, + ); + + const isTagSelectOption = (node: TagSelectOptionElement) => + node?.type && + (node.type.isTagSelectOption || + node.type.displayName === 'TagSelectOption'); + const getAllTags = () => { + const childrenArray = React.Children.toArray( + children, + ) as TagSelectOptionElement[]; + const checkedTags = childrenArray + .filter((child) => isTagSelectOption(child)) + .map((child) => child.props.value); + return checkedTags || []; + }; + const onSelectAll = (checked: boolean) => { + let checkedTags: (string | number)[] = []; + if (checked) { + checkedTags = getAllTags(); + } + setValue(checkedTags); + }; + const handleTagChange = (tag: string | number, checked: boolean) => { + const checkedTags: (string | number)[] = [...(value || [])]; + const index = checkedTags.indexOf(tag); + if (checked && index === -1) { + checkedTags.push(tag); + } else if (!checked && index > -1) { + checkedTags.splice(index, 1); + } + setValue(checkedTags); + }; + const checkedAll = getAllTags().length === value?.length; + const { + expandText = '展开', + collapseText = '收起', + selectAllText = '全部', + } = actionsText; + const cls = classNames(styles.tagSelect, className, { + [styles.hasExpandTag]: expandable, + [styles.expanded]: expand, + }); + return ( +
    + {hideCheckAll ? null : ( + + {selectAllText} + + )} + {children && + React.Children.map(children, (child: TagSelectOptionElement) => { + if (isTagSelectOption(child)) { + return React.cloneElement(child, { + key: `tag-select-${child.props.value}`, + value: child.props.value, + checked: value && value.indexOf(child.props.value) > -1, + onChange: handleTagChange, + }); + } + return child; + })} + {expandable && ( + { + setExpand(!expand); + }} + > + {expand ? ( + <> + {collapseText} + + ) : ( + <> + {expandText} + + + )} + + )} +
    + ); +}; +TagSelect.Option = TagSelectOption; +export default TagSelect; diff --git a/src/pages/list/search/applications/data.d.ts b/src/pages/list/search/applications/data.d.ts new file mode 100644 index 00000000..f39b0a60 --- /dev/null +++ b/src/pages/list/search/applications/data.d.ts @@ -0,0 +1,33 @@ +export type Member = { + avatar: string; + name: string; + id: string; +}; + +export interface Params { + count: number; +} + +export interface ListItemDataType { + id: string; + owner: string; + title: string; + avatar: string; + cover: string; + status: 'normal' | 'exception' | 'active' | 'success'; + percent: number; + logo: string; + href: string; + body?: any; + updatedAt: number; + createdAt: number; + subDescription: string; + description: string; + activeUser: number; + newUser: number; + star: number; + like: number; + message: number; + content: string; + members: Member[]; +} diff --git a/src/pages/list/search/applications/index.tsx b/src/pages/list/search/applications/index.tsx new file mode 100644 index 00000000..e98b5eb8 --- /dev/null +++ b/src/pages/list/search/applications/index.tsx @@ -0,0 +1,237 @@ +import { + DownloadOutlined, + EditOutlined, + EllipsisOutlined, + ShareAltOutlined, +} from '@ant-design/icons'; +import { useRequest } from '@umijs/max'; +import { + Avatar, + Card, + Col, + Dropdown, + Form, + List, + Row, + Select, + Tooltip, +} from 'antd'; +import numeral from 'numeral'; +import type { FC } from 'react'; +import React from 'react'; +import { categoryOptions } from '../../mock'; +import StandardFormRow from './components/StandardFormRow'; +import TagSelect from './components/TagSelect'; +import type { ListItemDataType } from './data.d'; +import { queryFakeList } from './service'; +import useStyles from './style.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 = ( + + {Math.floor(val / 10000)} + + 万 + + + ); + } + return result; +} +const formItemLayout = { + wrapperCol: { + xs: { + span: 24, + }, + sm: { + span: 16, + }, + }, +}; +const CardInfo: React.FC<{ + activeUser: React.ReactNode; + newUser: React.ReactNode; +}> = ({ activeUser, newUser }) => { + const { styles } = useStyles(); + return ( +
    +
    +

    活跃用户

    +

    {activeUser}

    +
    +
    +

    新增用户

    +

    {newUser}

    +
    +
    + ); +}; +export const Applications: FC> = () => { + const { styles } = useStyles(); + const { data, loading, run } = useRequest((values: any) => { + console.log('form data', values); + return queryFakeList({ + count: 8, + }); + }); + + const list = data?.list || []; + + return ( +
    + +
    { + run(values); + }} + > + + + + {categoryOptions + .filter( + ( + category, + ): category is { value: string | number; label: string } => + category.value !== undefined && category.value !== null, + ) + .map((category) => ( + + {category.label} + + ))} + + + + + + + + + + + + +
    +
    +
    + + rowKey="id" + grid={{ + gutter: 16, + xs: 1, + sm: 2, + md: 3, + lg: 3, + xl: 4, + xxl: 4, + }} + loading={loading} + dataSource={list} + renderItem={(item) => ( + + + + , + + + , + + + , + + + , + ]} + > + } + title={item.title} + /> +
    + +
    +
    +
    + )} + /> +
    + ); +}; +export default Applications; diff --git a/src/pages/list/search/applications/service.ts b/src/pages/list/search/applications/service.ts new file mode 100644 index 00000000..c90e68c8 --- /dev/null +++ b/src/pages/list/search/applications/service.ts @@ -0,0 +1,10 @@ +import { request } from '@umijs/max'; +import type { ListItemDataType, Params } from './data.d'; + +export async function queryFakeList( + params: Params, +): Promise<{ data: { list: ListItemDataType[] } }> { + return request('/api/fake_list', { + params, + }); +} diff --git a/src/pages/list/search/applications/style.style.ts b/src/pages/list/search/applications/style.style.ts new file mode 100644 index 00000000..f304d857 --- /dev/null +++ b/src/pages/list/search/applications/style.style.ts @@ -0,0 +1,42 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + filterCardList: { + '.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; diff --git a/src/pages/list/search/applications/utils/utils.style.ts b/src/pages/list/search/applications/utils/utils.style.ts new file mode 100644 index 00000000..0ad5e64a --- /dev/null +++ b/src/pages/list/search/applications/utils/utils.style.ts @@ -0,0 +1,6 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(() => { + return {}; +}); +export default useStyles; diff --git a/src/pages/list/search/articles/_mock.ts b/src/pages/list/search/articles/_mock.ts new file mode 100644 index 00000000..e988dc15 --- /dev/null +++ b/src/pages/list/search/articles/_mock.ts @@ -0,0 +1,124 @@ +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 = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +function fakeList(count: number): ListItemDataType[] { + const list = []; + for (let i = 0; i < count; i += 1) { + list.push({ + id: `fake-list-${Math.random().toString(36).slice(2, 6)}${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: any = req.query; + + const count = params.count * 1 || 20; + + const result = fakeList(count); + return res.json({ + data: { + list: result, + }, + }); +} + +export default { + 'GET /api/fake_list': getFakeList, +}; diff --git a/src/pages/list/search/articles/components/ArticleListContent/index.style.ts b/src/pages/list/search/articles/components/ArticleListContent/index.style.ts new file mode 100644 index 00000000..c993635f --- /dev/null +++ b/src/pages/list/search/articles/components/ArticleListContent/index.style.ts @@ -0,0 +1,29 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + description: { + maxWidth: '720px', + lineHeight: '22px', + }, + extra: { + marginTop: '16px', + color: token.colorTextSecondary, + lineHeight: '22px', + '& > em': { + marginLeft: '16px', + color: token.colorTextDisabled, + fontStyle: 'normal', + }, + [`@media screen and (max-width: ${token.screenXS}px)`]: { + '& > em': { + display: 'block', + marginTop: '8px', + marginLeft: '0', + }, + }, + }, + }; +}); + +export default useStyles; diff --git a/src/pages/list/search/articles/components/ArticleListContent/index.tsx b/src/pages/list/search/articles/components/ArticleListContent/index.tsx new file mode 100644 index 00000000..0e5f60c0 --- /dev/null +++ b/src/pages/list/search/articles/components/ArticleListContent/index.tsx @@ -0,0 +1,30 @@ +import { Avatar } from 'antd'; +import dayjs from 'dayjs'; +import React from 'react'; +import useStyles from './index.style'; + +type ArticleListContentProps = { + data: { + content: React.ReactNode; + updatedAt: number; + avatar: string; + owner: string; + href: string; + }; +}; +const ArticleListContent: React.FC = ({ + data: { content, updatedAt, avatar, owner, href }, +}) => { + const { styles } = useStyles(); + return ( +
    +
    {content}
    +
    + + {owner} 发布在 {href} + {dayjs(updatedAt).format('YYYY-MM-DD HH:mm')} +
    +
    + ); +}; +export default ArticleListContent; diff --git a/src/pages/list/search/articles/components/StandardFormRow/index.style.ts b/src/pages/list/search/articles/components/StandardFormRow/index.style.ts new file mode 100644 index 00000000..7b5f569d --- /dev/null +++ b/src/pages/list/search/articles/components/StandardFormRow/index.style.ts @@ -0,0 +1,62 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + standardFormRow: { + display: 'flex', + width: '100%', + marginBottom: '16px', + paddingBottom: '16px', + borderBottom: `1px dashed ${token.colorSplit}`, + '.ant-form-item, .ant-legacy-form-item': { marginRight: '24px' }, + '.ant-form-item-label, .ant-legacy-form-item-label': { + label: { + marginRight: '0', + color: token.colorText, + }, + }, + '.ant-form-item-label, .ant-legacy-form-item-label, .ant-form-item-control, .ant-legacy-form-item-control': + { padding: '0', lineHeight: '32px' }, + }, + label: { + flex: '0 0 auto', + marginRight: '24px', + color: token.colorTextHeading, + fontSize: token.fontSize, + textAlign: 'right', + '& > span': { + display: 'inline-block', + height: '32px', + lineHeight: '32px', + '&::after': { + content: "':'", + }, + }, + }, + content: { + flex: '1 1 0', + '.ant-form-item, .ant-legacy-form-item': { + '&:last-child': { + display: 'block', + marginRight: '0', + }, + }, + }, + standardFormRowLast: { + marginBottom: '0', + paddingBottom: '0', + border: 'none', + }, + standardFormRowBlock: { + '.ant-form-item, .ant-legacy-form-item, div.ant-form-item-control-wrapper, div.ant-legacy-form-item-control-wrapper': + { display: 'block' }, + }, + standardFormRowGrid: { + '.ant-form-item, .ant-legacy-form-item, div.ant-form-item-control-wrapper, div.ant-legacy-form-item-control-wrapper': + { display: 'block' }, + '.ant-form-item-label, .ant-legacy-form-item-label': { float: 'left' }, + }, + }; +}); + +export default useStyles; diff --git a/src/pages/list/search/articles/components/StandardFormRow/index.tsx b/src/pages/list/search/articles/components/StandardFormRow/index.tsx new file mode 100644 index 00000000..01236990 --- /dev/null +++ b/src/pages/list/search/articles/components/StandardFormRow/index.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames'; +import React from 'react'; +import useStyles from './index.style'; + +type StandardFormRowProps = { + title?: string; + last?: boolean; + block?: boolean; + grid?: boolean; + children?: React.ReactNode; + style?: React.CSSProperties; +}; +const StandardFormRow: React.FC = ({ + title, + children, + last, + block, + grid, + ...rest +}) => { + const { styles } = useStyles(); + const cls = classNames(styles.standardFormRow, { + [styles.standardFormRowBlock]: block, + [styles.standardFormRowLast]: last, + [styles.standardFormRowGrid]: grid, + }); + return ( +
    + {title && ( +
    + {title} +
    + )} +
    {children}
    +
    + ); +}; +export default StandardFormRow; diff --git a/src/pages/list/search/articles/components/TagSelect/index.style.ts b/src/pages/list/search/articles/components/TagSelect/index.style.ts new file mode 100644 index 00000000..d198ab7a --- /dev/null +++ b/src/pages/list/search/articles/components/TagSelect/index.style.ts @@ -0,0 +1,35 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + tagSelect: { + position: 'relative', + maxHeight: '32px', + marginLeft: '-8px', + overflow: 'hidden', + lineHeight: '32px', + transition: 'all 0.3s', + userSelect: 'none', + '.ant-tag': { + marginRight: '24px', + padding: '0 8px', + fontSize: token.fontSize, + }, + }, + trigger: { + position: 'absolute', + top: '0', + right: '0', + 'span.anticon': { fontSize: '12px' }, + }, + expanded: { + maxHeight: '200px', + transition: 'all 0.3s', + }, + hasExpandTag: { + paddingRight: '50px', + }, + }; +}); + +export default useStyles; diff --git a/src/pages/list/search/articles/components/TagSelect/index.tsx b/src/pages/list/search/articles/components/TagSelect/index.tsx new file mode 100644 index 00000000..4c0baf0d --- /dev/null +++ b/src/pages/list/search/articles/components/TagSelect/index.tsx @@ -0,0 +1,160 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons'; +import { Tag } from 'antd'; +import classNames from 'classnames'; +import { useMergedState } from 'rc-util'; +import React, { type FC, useState } from 'react'; +import useStyles from './index.style'; + +const { CheckableTag } = Tag; +export interface TagSelectOptionProps { + value: string | number; + style?: React.CSSProperties; + checked?: boolean; + onChange?: (value: string | number, state: boolean) => void; + children?: React.ReactNode; +} +const TagSelectOption: React.FC & { + isTagSelectOption: boolean; +} = ({ children, checked, onChange, value }) => ( + onChange?.(value, state)} + > + {children} + +); + +TagSelectOption.isTagSelectOption = true; + +type TagSelectOptionElement = React.ReactElement< + TagSelectOptionProps, + typeof TagSelectOption +>; + +export interface TagSelectProps { + onChange?: (value: (string | number)[]) => void; + expandable?: boolean; + value?: (string | number)[]; + defaultValue?: (string | number)[]; + style?: React.CSSProperties; + hideCheckAll?: boolean; + actionsText?: { + expandText?: React.ReactNode; + collapseText?: React.ReactNode; + selectAllText?: React.ReactNode; + }; + className?: string; + Option?: TagSelectOptionProps; + children?: TagSelectOptionElement | TagSelectOptionElement[]; +} +const TagSelect: FC & { + Option: typeof TagSelectOption; +} = (props) => { + const { styles } = useStyles(); + const { + children, + hideCheckAll = false, + className, + style, + expandable, + actionsText = {}, + } = props; + const [expand, setExpand] = useState(false); + + const [value, setValue] = useMergedState<(string | number)[]>( + props.defaultValue || [], + { + value: props.value, + defaultValue: props.defaultValue, + onChange: props.onChange, + }, + ); + + const isTagSelectOption = (node: TagSelectOptionElement) => + node?.type && + (node.type.isTagSelectOption || + node.type.displayName === 'TagSelectOption'); + const getAllTags = () => { + const childrenArray = React.Children.toArray( + children, + ) as TagSelectOptionElement[]; + const checkedTags = childrenArray + .filter((child) => isTagSelectOption(child)) + .map((child) => child.props.value); + return checkedTags || []; + }; + const onSelectAll = (checked: boolean) => { + let checkedTags: (string | number)[] = []; + if (checked) { + checkedTags = getAllTags(); + } + setValue(checkedTags); + }; + const handleTagChange = (tag: string | number, checked: boolean) => { + const checkedTags: (string | number)[] = [...(value || [])]; + const index = checkedTags.indexOf(tag); + if (checked && index === -1) { + checkedTags.push(tag); + } else if (!checked && index > -1) { + checkedTags.splice(index, 1); + } + setValue(checkedTags); + }; + const checkedAll = getAllTags().length === value?.length; + const { + expandText = '展开', + collapseText = '收起', + selectAllText = '全部', + } = actionsText; + const cls = classNames(styles.tagSelect, className, { + [styles.hasExpandTag]: expandable, + [styles.expanded]: expand, + }); + return ( +
    + {hideCheckAll ? null : ( + + {selectAllText} + + )} + {children && + React.Children.map(children, (child: TagSelectOptionElement) => { + if (isTagSelectOption(child)) { + return React.cloneElement(child, { + key: `tag-select-${child.props.value}`, + value: child.props.value, + checked: value && value.indexOf(child.props.value) > -1, + onChange: handleTagChange, + }); + } + return child; + })} + {expandable && ( + { + setExpand(!expand); + }} + > + {expand ? ( + <> + {collapseText} + + ) : ( + <> + {expandText} + + + )} + + )} +
    + ); +}; +TagSelect.Option = TagSelectOption; +export default TagSelect; diff --git a/src/pages/list/search/articles/data.d.ts b/src/pages/list/search/articles/data.d.ts new file mode 100644 index 00000000..0a4597c9 --- /dev/null +++ b/src/pages/list/search/articles/data.d.ts @@ -0,0 +1,32 @@ +export type Member = { + avatar: string; + name: string; + id: string; +}; + +export interface Params { + count: number; +} +export interface ListItemDataType { + id: string; + owner: string; + title: string; + avatar: string; + cover: string; + status: 'normal' | 'exception' | 'active' | 'success'; + percent: number; + logo: string; + href: string; + body?: any; + updatedAt: number; + createdAt: number; + subDescription: string; + description: string; + activeUser: number; + newUser: number; + star: number; + like: number; + message: number; + content: string; + members: Member[]; +} diff --git a/src/pages/list/search/articles/index.tsx b/src/pages/list/search/articles/index.tsx new file mode 100644 index 00000000..40ba5461 --- /dev/null +++ b/src/pages/list/search/articles/index.tsx @@ -0,0 +1,257 @@ +import { + LikeOutlined, + LoadingOutlined, + MessageOutlined, + StarOutlined, +} from '@ant-design/icons'; +import { useRequest } from '@umijs/max'; +import { Button, Card, Col, Form, List, Row, Select, Tag } from 'antd'; +import type { DefaultOptionType } from 'antd/es/select'; +import type { FC } from 'react'; +import React, { useMemo } from 'react'; +import { categoryOptions } from '../../mock'; +import ArticleListContent from './components/ArticleListContent'; +import StandardFormRow from './components/StandardFormRow'; +import TagSelect from './components/TagSelect'; +import type { ListItemDataType } from './data.d'; +import { queryFakeList } from './service'; +import useStyles from './style.style'; + +const FormItem = Form.Item; + +const pageSize = 5; + +const Articles: FC = () => { + const [form] = Form.useForm(); + + const { styles } = useStyles(); + + const { data, reload, loading, loadMore, loadingMore } = useRequest( + () => { + return queryFakeList({ + count: pageSize, + }); + }, + { + loadMore: true, + }, + ); + + const list = data?.list || []; + + const setOwner = () => { + form.setFieldsValue({ + owner: ['wzj'], + }); + }; + + const owners = [ + { + id: 'wzj', + name: '我自己', + }, + { + id: 'wjh', + name: '吴家豪', + }, + { + id: 'zxx', + name: '周星星', + }, + { + id: 'zly', + name: '赵丽颖', + }, + { + id: 'ym', + name: '姚明', + }, + ]; + + const IconText: React.FC<{ + type: string; + text: React.ReactNode; + }> = ({ type, text }) => { + switch (type) { + case 'star-o': + return ( + + + {text} + + ); + case 'like-o': + return ( + + + {text} + + ); + case 'message': + return ( + + + {text} + + ); + default: + return null; + } + }; + + const formItemLayout = { + wrapperCol: { + xs: { span: 24 }, + sm: { span: 24 }, + md: { span: 12 }, + }, + }; + + const loadMoreDom = list.length > 0 && ( +
    + +
    + ); + + const ownerOptions = useMemo( + () => + owners.map((item) => ({ + label: item.name, + value: item.id, + })), + [], + ); + + return ( + <> + +
    + + + + {categoryOptions + .filter( + ( + category, + ): category is { value: string | number; label: string } => + category.value !== undefined && category.value !== null, + ) + .map((category) => ( + + {category.label} + + ))} + + + + + + + + + + + + + + + + ; + } + + return defaultRender(item); + }, + }, + { + title: '操作', + dataIndex: 'option', + valueType: 'option', + render: (_, record) => [ + { + handleUpdateModalVisible(true); + setCurrentRow(record); + }} + > + 配置 + , + + 订阅警报 + , + ], + }, + ]; + + return ( + + + headerTitle="查询表格" + actionRef={actionRef} + rowKey="key" + search={{ + labelWidth: 120, + }} + toolBarRender={() => [ + , + ]} + request={rule} + columns={columns} + rowSelection={{ + onChange: (_, selectedRows) => { + setSelectedRows(selectedRows); + }, + }} + /> + {selectedRowsState?.length > 0 && ( + + 已选择{' '} + + {selectedRowsState.length} + {' '} + 项    + + 服务调用次数总计{' '} + {selectedRowsState.reduce( + (pre, item) => pre + (item.callNo ?? 0), + 0, + )}{' '} + 万 + +
    + } + > + + + + )} + { + const success = await handleAdd(value as TableListItem); + if (success) { + handleModalVisible(false); + if (actionRef.current) { + actionRef.current.reload(); + } + } + }} + > + + + + { + const success = await handleUpdate(value, currentRow); + + if (success) { + handleUpdateModalVisible(false); + setCurrentRow(undefined); + + if (actionRef.current) { + actionRef.current.reload(); + } + } + }} + onCancel={() => { + handleUpdateModalVisible(false); + setCurrentRow(undefined); + }} + updateModalVisible={updateModalVisible} + values={currentRow || {}} + /> + + { + setCurrentRow(undefined); + setShowDetail(false); + }} + closable={false} + > + {currentRow?.name && ( + + column={2} + title={currentRow?.name} + request={async () => ({ + data: currentRow || {}, + })} + params={{ + id: currentRow?.name, + }} + columns={columns as ProDescriptionsItemProps[]} + /> + )} + + + ); +}; + +export default TableList; diff --git a/src/pages/list/table-list/service.ts b/src/pages/list/table-list/service.ts new file mode 100644 index 00000000..29168469 --- /dev/null +++ b/src/pages/list/table-list/service.ts @@ -0,0 +1,65 @@ +// @ts-ignore +/* eslint-disable */ +import { request } from '@umijs/max'; +import type { TableListItem } from './data'; + +/** 获取规则列表 GET /api/rule */ +export async function rule( + params: { + // query + /** 当前的页码 */ + current?: number; + /** 页面的容量 */ + pageSize?: number; + }, + options?: { [key: string]: any }, +) { + return request<{ + data: TableListItem[]; + /** 列表的内容总数 */ + total?: number; + success?: boolean; + }>('/api/rule', { + method: 'GET', + params: { + ...params, + }, + ...(options || {}), + }); +} + +/** 新建规则 PUT /api/rule */ +export async function updateRule( + data: { [key: string]: any }, + options?: { [key: string]: any }, +) { + return request('/api/rule', { + data, + method: 'PUT', + ...(options || {}), + }); +} + +/** 新建规则 POST /api/rule */ +export async function addRule( + data: { [key: string]: any }, + options?: { [key: string]: any }, +) { + return request('/api/rule', { + data, + method: 'POST', + ...(options || {}), + }); +} + +/** 删除规则 DELETE /api/rule */ +export async function removeRule( + data: { key: number[] }, + options?: { [key: string]: any }, +) { + return request>('/api/rule', { + data, + method: 'DELETE', + ...(options || {}), + }); +} diff --git a/src/pages/profile/advanced/_mock.ts b/src/pages/profile/advanced/_mock.ts new file mode 100644 index 00000000..cc13c2f2 --- /dev/null +++ b/src/pages/profile/advanced/_mock.ts @@ -0,0 +1,81 @@ +import type { Request, Response } from 'express'; + +const advancedOperation1 = [ + { + key: 'op1', + type: '订购关系生效', + name: '曲丽丽', + status: 'agree', + updatedAt: '2017-10-03 19:23:12', + memo: '-', + }, + { + key: 'op2', + type: '财务复审', + name: '付小小', + status: 'reject', + updatedAt: '2017-10-03 19:23:12', + memo: '不通过原因', + }, + { + key: 'op3', + type: '部门初审', + name: '周毛毛', + status: 'agree', + updatedAt: '2017-10-03 19:23:12', + memo: '-', + }, + { + key: 'op4', + type: '提交订单', + name: '林东东', + status: 'agree', + updatedAt: '2017-10-03 19:23:12', + memo: '很棒', + }, + { + key: 'op5', + type: '创建订单', + name: '汗牙牙', + status: 'agree', + updatedAt: '2017-10-03 19:23:12', + memo: '-', + }, +]; + +const advancedOperation2 = [ + { + key: 'op1', + type: '订购关系生效', + name: '曲丽丽', + status: 'agree', + updatedAt: '2017-10-03 19:23:12', + memo: '-', + }, +]; + +const advancedOperation3 = [ + { + key: 'op1', + type: '创建订单', + name: '汗牙牙', + status: 'agree', + updatedAt: '2017-10-03 19:23:12', + memo: '-', + }, +]; + +function getProfileAdvancedData(_req: Request, res: Response) { + const result = { + data: { + advancedOperation1, + advancedOperation2, + advancedOperation3, + }, + }; + return res.json(result); +} + +export default { + 'GET /api/profile/advanced': getProfileAdvancedData, +}; diff --git a/src/pages/profile/advanced/data.d.ts b/src/pages/profile/advanced/data.d.ts new file mode 100644 index 00000000..95f7d31e --- /dev/null +++ b/src/pages/profile/advanced/data.d.ts @@ -0,0 +1,32 @@ +export type AdvancedOperation1 = { + key: string; + type: string; + name: string; + status: string; + updatedAt: string; + memo: string; +}; + +export type AdvancedOperation2 = { + key: string; + type: string; + name: string; + status: string; + updatedAt: string; + memo: string; +}; + +export type AdvancedOperation3 = { + key: string; + type: string; + name: string; + status: string; + updatedAt: string; + memo: string; +}; + +export interface AdvancedProfileData { + advancedOperation1?: AdvancedOperation1[]; + advancedOperation2?: AdvancedOperation2[]; + advancedOperation3?: AdvancedOperation3[]; +} diff --git a/src/pages/profile/advanced/index.tsx b/src/pages/profile/advanced/index.tsx new file mode 100644 index 00000000..092a1794 --- /dev/null +++ b/src/pages/profile/advanced/index.tsx @@ -0,0 +1,496 @@ +import { + DingdingOutlined, + DownOutlined, + EllipsisOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; +import { + GridContent, + PageContainer, + RouteContext, +} from '@ant-design/pro-components'; +import { useRequest } from '@umijs/max'; +import { + Badge, + Button, + Card, + Descriptions, + Divider, + Dropdown, + Empty, + Popover, + Space, + Statistic, + Steps, + Table, + Tooltip, +} from 'antd'; +import classNames from 'classnames'; +import type { FC } from 'react'; +import React, { Fragment, useState } from 'react'; +import type { AdvancedProfileData } from './data.d'; +import { queryAdvancedProfile } from './service'; +import useStyles from './style.style'; + +const { Step } = Steps; +const ButtonGroup = Button.Group; + +const action = ( + + {({ isMobile }) => { + if (isMobile) { + return ( + } + menu={{ + items: [ + { + key: '1', + label: '操作一', + }, + { + key: '2', + label: '操作二', + }, + { + key: '3', + label: '操作三', + }, + ], + }} + placement="bottomRight" + > + 主操作 + + ); + } + return ( + + + + + + + + + + + ); + }} + +); + +const operationTabList = [ + { + key: 'tab1', + tab: '操作日志一', + }, + { + key: 'tab2', + tab: '操作日志二', + }, + { + key: 'tab3', + tab: '操作日志三', + }, +]; +const columns = [ + { + title: '操作类型', + dataIndex: 'type', + key: 'type', + }, + { + title: '操作人', + dataIndex: 'name', + key: 'name', + }, + { + title: '执行结果', + dataIndex: 'status', + key: 'status', + render: (text: string) => { + if (text === 'agree') { + return ; + } + return ; + }, + }, + { + title: '操作时间', + dataIndex: 'updatedAt', + key: 'updatedAt', + }, + { + title: '备注', + dataIndex: 'memo', + key: 'memo', + }, +]; +type AdvancedState = { + operationKey: 'tab1' | 'tab2' | 'tab3'; + tabActiveKey: string; +}; +const Advanced: FC = () => { + const { styles } = useStyles(); + + const extra = ( +
    + + +
    + ); + const description = ( + + {({ isMobile }) => ( + + 曲丽丽 + XX 服务 + 2017-07-07 + + 12421 + + + 2017-07-07 ~ 2017-08-08 + + + 请于两个工作日内确认 + + + )} + + ); + const desc1 = ( +
    + + 曲丽丽 + + +
    2016-12-12 12:32
    +
    + ); + const desc2 = ( +
    + + 周毛毛 + + +
    + 催一下 +
    +
    + ); + + const [tabStatus, seTabStatus] = useState({ + operationKey: 'tab1', + tabActiveKey: 'detail', + }); + + const customDot = ( + dot: React.ReactNode, + { + status, + }: { + status: string; + }, + ) => { + const popoverContent = ( +
    + 吴加号 + + + 未响应 + + } + /> + +
    + 耗时:2小时25分钟 +
    +
    + ); + if (status === 'process') { + return ( + + {dot} + + ); + } + return dot; + }; + + const { data = {}, loading } = useRequest<{ + data: AdvancedProfileData; + }>(queryAdvancedProfile); + const { advancedOperation1, advancedOperation2, advancedOperation3 } = data; + const contentList = { + tab1: ( + + ), + tab2: ( +
    + ), + tab3: ( +
    + ), + }; + const onTabChange = (tabActiveKey: string) => { + seTabStatus({ + ...tabStatus, + tabActiveKey, + }); + }; + const onOperationTabChange = (key: string) => { + seTabStatus({ + ...tabStatus, + operationKey: key as 'tab1', + }); + }; + return ( + +
    + + + + {({ isMobile }) => ( + + + + + + + )} + + + + + 付小小 + + 32943898021309809423 + + + 3321944288191034921 + + + 18112345678 + + + 曲丽丽 18100000000 浙江省杭州市西湖区黄姑山路工专路交叉路口 + + + + 725 + + 2017-08-08 + + + 某某数据 + + + + + } + > + 725 + + + 2017-08-08 + + +

    + 信息组 +

    + + + 林东东 + 1234567 + + XX公司 - YY部 + + + 2017-08-08 + + + 这段描述很长很长很长很长很长很长很长很长很长很长很长很长很长很长... + + + + + + Citrullus lanatus (Thunb.) Matsum. et + Nakai一年生蔓生藤本;茎、枝粗壮,具明显的棱。卷须较粗.. + + + + + 付小小 + 1234568 + + +
    + + + + + {contentList[tabStatus.operationKey] as React.ReactNode} + +
    +
    +
    + ); +}; +export default Advanced; diff --git a/src/pages/profile/advanced/service.ts b/src/pages/profile/advanced/service.ts new file mode 100644 index 00000000..beea5e90 --- /dev/null +++ b/src/pages/profile/advanced/service.ts @@ -0,0 +1,5 @@ +import { request } from '@umijs/max'; + +export async function queryAdvancedProfile() { + return request('/api/profile/advanced'); +} diff --git a/src/pages/profile/advanced/style.style.ts b/src/pages/profile/advanced/style.style.ts new file mode 100644 index 00000000..9594971f --- /dev/null +++ b/src/pages/profile/advanced/style.style.ts @@ -0,0 +1,38 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + main: { + '.ant-descriptions-row > td': { paddingBottom: '8px' }, + '.ant-page-header-heading-extra': { flexDirection: 'column' }, + }, + headerList: { + marginBottom: '4px', + '.ant-descriptions-row > td': { paddingBottom: '8px' }, + }, + stepDescription: { + position: 'relative', + left: '38px', + paddingTop: '8px', + fontSize: '14px', + textAlign: 'left', + '> div': { marginTop: '8px', marginBottom: '4px' }, + [`@media screen and (max-width: ${token.screenSM}px)`]: { left: '8px' }, + }, + pageHeader: { + '.ant-page-header-heading-extra > * + *': { marginLeft: '8px' }, + [`@media screen and (max-width: ${token.screenSM}px)`]: { + '.ant-pro-page-header-wrap-row': { + flexDirection: 'column', + }, + }, + }, + moreInfo: { + display: 'flex', + justifyContent: 'space-between', + width: '200px', + }, + }; +}); + +export default useStyles; diff --git a/src/pages/profile/basic/_mock.ts b/src/pages/profile/basic/_mock.ts new file mode 100644 index 00000000..f432f8d5 --- /dev/null +++ b/src/pages/profile/basic/_mock.ts @@ -0,0 +1,92 @@ +import type { Request, Response } from 'express'; + +const basicGoods = [ + { + id: '1234561', + name: '矿泉水 550ml', + barcode: '12421432143214321', + price: '2.00', + num: '1', + amount: '2.00', + }, + { + id: '1234562', + name: '凉茶 300ml', + barcode: '12421432143214322', + price: '3.00', + num: '2', + amount: '6.00', + }, + { + id: '1234563', + name: '好吃的薯片', + barcode: '12421432143214323', + price: '7.00', + num: '4', + amount: '28.00', + }, + { + id: '1234564', + name: '特别好吃的蛋卷', + barcode: '12421432143214324', + price: '8.50', + num: '3', + amount: '25.50', + }, +]; + +const basicProgress = [ + { + key: '1', + time: '2017-10-01 14:10', + rate: '联系客户', + status: 'processing', + operator: '取货员 ID1234', + cost: '5mins', + }, + { + key: '2', + time: '2017-10-01 14:05', + rate: '取货员出发', + status: 'success', + operator: '取货员 ID1234', + cost: '1h', + }, + { + key: '3', + time: '2017-10-01 13:05', + rate: '取货员接单', + status: 'success', + operator: '取货员 ID1234', + cost: '5mins', + }, + { + key: '4', + time: '2017-10-01 13:00', + rate: '申请审批通过', + status: 'success', + operator: '系统', + cost: '1h', + }, + { + key: '5', + time: '2017-10-01 12:00', + rate: '发起退货申请', + status: 'success', + operator: '用户', + cost: '5mins', + }, +]; + +function getProfileBasic(_: Request, res: Response) { + return res.json({ + data: { + basicProgress, + basicGoods, + }, + }); +} + +export default { + 'GET /api/profile/basic': getProfileBasic, +}; diff --git a/src/pages/profile/basic/data.d.ts b/src/pages/profile/basic/data.d.ts new file mode 100644 index 00000000..5b47678e --- /dev/null +++ b/src/pages/profile/basic/data.d.ts @@ -0,0 +1,17 @@ +export type BasicGood = { + id: string; + name?: string; + barcode?: string; + price?: string; + num?: string | number; + amount?: string | number; +}; + +export type BasicProgress = { + key: string; + time: string; + rate: string; + status: string; + operator: string; + cost: string; +}; diff --git a/src/pages/profile/basic/index.tsx b/src/pages/profile/basic/index.tsx new file mode 100644 index 00000000..31647f2e --- /dev/null +++ b/src/pages/profile/basic/index.tsx @@ -0,0 +1,236 @@ +import type { ProColumns } from '@ant-design/pro-components'; +import { PageContainer, ProTable } from '@ant-design/pro-components'; +import { useRequest } from '@umijs/max'; +import { Badge, Card, Descriptions, Divider } from 'antd'; +import type { FC } from 'react'; +import React from 'react'; +import type { BasicGood, BasicProgress } from './data.d'; +import { queryBasicProfile } from './service'; +import useStyles from './style.style'; + +const progressColumns: ProColumns[] = [ + { + title: '时间', + dataIndex: 'time', + key: 'time', + }, + { + title: '当前进度', + dataIndex: 'rate', + key: 'rate', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (text: React.ReactNode) => { + if (text === 'success') { + return ; + } + return ; + }, + }, + { + title: '操作员ID', + dataIndex: 'operator', + key: 'operator', + }, + { + title: '耗时', + dataIndex: 'cost', + key: 'cost', + }, +]; +const Basic: FC = () => { + const { styles } = useStyles(); + const { data, loading } = useRequest(() => { + return queryBasicProfile(); + }); + const { basicGoods, basicProgress } = data || { + basicGoods: [], + basicProgress: [], + }; + let goodsData: typeof basicGoods = []; + if (basicGoods.length) { + let num = 0; + let amount = 0; + basicGoods.forEach((item) => { + num += Number(item.num); + amount += Number(item.amount); + }); + goodsData = basicGoods.concat({ + id: '总计', + num, + amount, + }); + } + const renderContent = (value: any, _: any, index: any) => { + const obj: { + children: any; + props: { + colSpan?: number; + }; + } = { + children: value, + props: {}, + }; + if (index === basicGoods.length) { + obj.props.colSpan = 0; + } + return obj; + }; + const goodsColumns: ProColumns[] = [ + { + title: '商品编号', + dataIndex: 'id', + key: 'id', + render: (text: React.ReactNode, _: any, index: number) => { + if (index < basicGoods.length) { + return {text}; + } + return { + children: ( + + 总计 + + ), + props: { + colSpan: 4, + }, + }; + }, + }, + { + title: '商品名称', + dataIndex: 'name', + key: 'name', + render: renderContent, + }, + { + title: '商品条码', + dataIndex: 'barcode', + key: 'barcode', + render: renderContent, + }, + { + title: '单价', + dataIndex: 'price', + key: 'price', + align: 'right' as 'left' | 'right' | 'center', + render: renderContent, + }, + { + title: '数量(件)', + dataIndex: 'num', + key: 'num', + align: 'right' as 'left' | 'right' | 'center', + render: (text: React.ReactNode, _: any, index: number) => { + if (index < basicGoods.length) { + return text; + } + return ( + + {text} + + ); + }, + }, + { + title: '金额', + dataIndex: 'amount', + key: 'amount', + align: 'right' as 'left' | 'right' | 'center', + render: (text: React.ReactNode, _: any, index: number) => { + if (index < basicGoods.length) { + return text; + } + return ( + + {text} + + ); + }, + }, + ]; + return ( + + + + 1000000000 + 已取货 + 1234123421 + 3214321432 + + + + 付小小 + 18100000000 + 菜鸟仓储 + + 浙江省杭州市西湖区万塘路18号 + + + + +
    退货商品
    + +
    退货进度
    + +
    +
    + ); +}; +export default Basic; diff --git a/src/pages/profile/basic/service.ts b/src/pages/profile/basic/service.ts new file mode 100644 index 00000000..57602486 --- /dev/null +++ b/src/pages/profile/basic/service.ts @@ -0,0 +1,11 @@ +import { request } from '@umijs/max'; +import type { BasicGood, BasicProgress } from './data.d'; + +export async function queryBasicProfile(): Promise<{ + data: { + basicProgress: BasicProgress[]; + basicGoods: BasicGood[]; + }; +}> { + return request('/api/profile/basic'); +} diff --git a/src/pages/profile/basic/style.style.ts b/src/pages/profile/basic/style.style.ts new file mode 100644 index 00000000..154eb819 --- /dev/null +++ b/src/pages/profile/basic/style.style.ts @@ -0,0 +1,14 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + title: { + marginBottom: '16px', + color: token.colorTextHeading, + fontWeight: '500', + fontSize: '16px', + }, + }; +}); + +export default useStyles; diff --git a/src/pages/result/fail/index.style.ts b/src/pages/result/fail/index.style.ts new file mode 100644 index 00000000..7cb682d9 --- /dev/null +++ b/src/pages/result/fail/index.style.ts @@ -0,0 +1,17 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + error_icon: { + color: token.colorBgTextActive, + }, + title: { + marginBottom: '16px', + color: token.colorTextHeading, + fontWeight: '500', + fontSize: '16px', + }, + }; +}); + +export default useStyles; diff --git a/src/pages/result/fail/index.tsx b/src/pages/result/fail/index.tsx new file mode 100644 index 00000000..d654c758 --- /dev/null +++ b/src/pages/result/fail/index.tsx @@ -0,0 +1,76 @@ +import { CloseCircleOutlined, RightOutlined } from '@ant-design/icons'; +import { GridContent } from '@ant-design/pro-components'; +import { Button, Card, Result } from 'antd'; +import { Fragment } from 'react'; +import useStyles from './index.style'; + +export default () => { + const { styles } = useStyles(); + const Content = ( + +
    + 您提交的内容有如下错误: +
    +
    + + 您的账户已被冻结 + + 立即解冻 + + +
    +
    + + 您的账户还不具备申请资格 + + 立即升级 + + +
    +
    + ); + return ( + + + + 返回修改 + + } + style={{ + marginTop: 48, + marginBottom: 16, + }} + > + {Content} + + + + ); +}; diff --git a/src/pages/result/success/index.style.ts b/src/pages/result/success/index.style.ts new file mode 100644 index 00000000..169a71f1 --- /dev/null +++ b/src/pages/result/success/index.style.ts @@ -0,0 +1,20 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + title: { + position: 'relative', + color: token.colorText, + fontSize: '12px', + textAlign: 'center', + }, + 'head-title': { + marginBottom: '20px', + color: token.colorTextHeading, + fontWeight: '500px', + fontSize: '16px', + }, + }; +}); + +export default useStyles; diff --git a/src/pages/result/success/index.tsx b/src/pages/result/success/index.tsx new file mode 100644 index 00000000..f0714694 --- /dev/null +++ b/src/pages/result/success/index.tsx @@ -0,0 +1,138 @@ +import { DingdingOutlined } from '@ant-design/icons'; +import { GridContent } from '@ant-design/pro-components'; +import { Button, Card, Descriptions, Result, Steps } from 'antd'; +import { Fragment } from 'react'; +import useStyles from './index.style'; + +const { Step } = Steps; + +export default () => { + const { styles } = useStyles(); + const desc1 = ( +
    +
    + 曲丽丽 + +
    +
    2016-12-12 12:32
    +
    + ); + const desc2 = ( +
    +
    + 周毛毛 + + + 催一下 + +
    +
    + ); + const content = ( + <> + + 23421 + 曲丽丽 + + 2016-12-12 ~ 2017-12-12 + + +
    + + + 创建项目 + + } + description={desc1} + /> + + 部门初审 + + } + description={desc2} + /> + + 财务复核 + + } + /> + + 完成 + + } + /> + + + ); + const extra = ( + + + + + + ); + return ( + + + + {content} + + + + ); +}; diff --git a/src/pages/table-list/components/CreateForm.tsx b/src/pages/table-list/components/CreateForm.tsx new file mode 100644 index 00000000..9fc324cc --- /dev/null +++ b/src/pages/table-list/components/CreateForm.tsx @@ -0,0 +1,80 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { + type ActionType, + ModalForm, + ProFormText, + ProFormTextArea, +} from '@ant-design/pro-components'; +import { FormattedMessage, useIntl, useRequest } from '@umijs/max'; +import { Button, message } from 'antd'; +import type { FC } from 'react'; +import { addRule } from '@/services/ant-design-pro/api'; + +interface CreateFormProps { + reload?: ActionType['reload']; +} + +const CreateForm: FC = (props) => { + const { reload } = props; + + const [messageApi, contextHolder] = message.useMessage(); + /** + * @en-US International configuration + * @zh-CN 国际化配置 + * */ + const intl = useIntl(); + + const { run, loading } = useRequest(addRule, { + manual: true, + onSuccess: () => { + messageApi.success('Added successfully'); + reload?.(); + }, + onError: () => { + messageApi.error('Adding failed, please try again!'); + }, + }); + + return ( + <> + {contextHolder} + }> + + + } + width="400px" + modalProps={{ okButtonProps: { loading } }} + onFinish={async (value) => { + await run({ data: value as API.RuleListItem }); + + return true; + }} + > + + ), + }, + ]} + width="md" + name="name" + /> + + + + ); +}; + +export default CreateForm; diff --git a/src/pages/table-list/components/UpdateForm.tsx b/src/pages/table-list/components/UpdateForm.tsx new file mode 100644 index 00000000..a3195418 --- /dev/null +++ b/src/pages/table-list/components/UpdateForm.tsx @@ -0,0 +1,247 @@ +import { + ProFormDateTimePicker, + ProFormRadio, + ProFormSelect, + ProFormText, + ProFormTextArea, + StepsForm, +} from '@ant-design/pro-components'; +import { FormattedMessage, useIntl, useRequest } from '@umijs/max'; +import { Modal, message } from 'antd'; +import React, { cloneElement, useCallback, useState } from 'react'; +import { updateRule } from '@/services/ant-design-pro/api'; + +export type FormValueType = { + target?: string; + template?: string; + type?: string; + time?: string; + frequency?: string; +} & Partial; + +export type UpdateFormProps = { + trigger?: React.ReactElement; + onOk?: () => void; + values: Partial; +}; + +const UpdateForm: React.FC = (props) => { + const { onOk, values, trigger } = props; + + const intl = useIntl(); + + const [open, setOpen] = useState(false); + + const [messageApi, contextHolder] = message.useMessage(); + + const { run } = useRequest(updateRule, { + manual: true, + onSuccess: () => { + messageApi.success('Configuration is successful'); + onOk?.(); + }, + onError: () => { + messageApi.error('Configuration failed, please try again!'); + }, + }); + + const onCancel = useCallback(() => { + setOpen(false); + }, []); + + const onOpen = useCallback(() => { + setOpen(true); + }, []); + + const onFinish = useCallback( + async (values?: any) => { + await run({ data: values }); + + onCancel(); + }, + [onCancel, run], + ); + + return ( + <> + {contextHolder} + {trigger + ? cloneElement(trigger, { + onClick: onOpen, + }) + : null} + { + return ( + + {dom} + + ); + }} + onFinish={onFinish} + > + + + ), + }, + ]} + /> + + ), + min: 5, + }, + ]} + /> + + + + + + + + + ), + }, + ]} + /> + + + + + ); +}; + +export default UpdateForm; diff --git a/src/pages/TableList/index.tsx b/src/pages/table-list/index.tsx similarity index 61% rename from src/pages/TableList/index.tsx rename to src/pages/table-list/index.tsx index 9deccbe9..83f08071 100644 --- a/src/pages/TableList/index.tsx +++ b/src/pages/table-list/index.tsx @@ -1,4 +1,3 @@ -import { PlusOutlined } from '@ant-design/icons'; import type { ActionType, ProColumns, @@ -6,107 +5,21 @@ import type { } from '@ant-design/pro-components'; import { FooterToolbar, - ModalForm, PageContainer, ProDescriptions, - ProFormText, - ProFormTextArea, ProTable, } from '@ant-design/pro-components'; -import { FormattedMessage, useIntl } from '@umijs/max'; +import { FormattedMessage, useIntl, useRequest } from '@umijs/max'; import { Button, Drawer, Input, message } from 'antd'; -import React, { useRef, useState } from 'react'; -import { - addRule, - removeRule, - rule, - updateRule, -} from '@/services/ant-design-pro/api'; -import type { FormValueType } from './components/UpdateForm'; +import React, { useCallback, useRef, useState } from 'react'; +import { removeRule, rule } from '@/services/ant-design-pro/api'; +import CreateForm from './components/CreateForm'; import UpdateForm from './components/UpdateForm'; -/** - * @en-US Add node - * @zh-CN 添加节点 - * @param fields - */ -const handleAdd = async (fields: API.RuleListItem) => { - const hide = message.loading('正在添加'); - try { - await addRule({ ...fields }); - hide(); - message.success('Added successfully'); - return true; - } catch (_error) { - hide(); - message.error('Adding failed, please try again!'); - return false; - } -}; - -/** - * @en-US Update node - * @zh-CN 更新节点 - * - * @param fields - */ -const handleUpdate = async (fields: FormValueType) => { - const hide = message.loading('Configuring'); - try { - await updateRule({ - name: fields.name, - desc: fields.desc, - key: fields.key, - }); - hide(); - - message.success('Configuration is successful'); - return true; - } catch (_error) { - hide(); - message.error('Configuration failed, please try again!'); - return false; - } -}; - -/** - * Delete node - * @zh-CN 删除节点 - * - * @param selectedRows - */ -const handleRemove = async (selectedRows: API.RuleListItem[]) => { - const hide = message.loading('正在删除'); - if (!selectedRows) return true; - try { - await removeRule({ - key: selectedRows.map((row) => row.key), - }); - hide(); - message.success('Deleted successfully and will refresh soon'); - return true; - } catch (_error) { - hide(); - message.error('Delete failed, please try again'); - return false; - } -}; - const TableList: React.FC = () => { - /** - * @en-US Pop-up window of new window - * @zh-CN 新建窗口的弹窗 - * */ - const [createModalOpen, handleModalOpen] = useState(false); - /** - * @en-US The pop-up window of the distribution update window - * @zh-CN 分布更新窗口的弹窗 - * */ - const [updateModalOpen, handleUpdateModalOpen] = useState(false); + const actionRef = useRef(null); const [showDetail, setShowDetail] = useState(false); - - const actionRef = useRef(null); const [currentRow, setCurrentRow] = useState(); const [selectedRowsState, setSelectedRows] = useState([]); @@ -116,6 +29,21 @@ const TableList: React.FC = () => { * */ const intl = useIntl(); + const [messageApi, contextHolder] = message.useMessage(); + + const { run: delRun, loading } = useRequest(removeRule, { + manual: true, + onSuccess: () => { + setSelectedRows([]); + actionRef.current?.reloadAndRest?.(); + + messageApi.success('Deleted successfully and will refresh soon'); + }, + onError: () => { + messageApi.error('Delete failed, please try again'); + }, + }); + const columns: ProColumns[] = [ { title: ( @@ -251,18 +179,19 @@ const TableList: React.FC = () => { dataIndex: 'option', valueType: 'option', render: (_, record) => [ - + + + } key="config" - onClick={() => { - handleUpdateModalOpen(true); - setCurrentRow(record); - }} - > - - , + onOk={actionRef.current?.reload} + values={record} + />, { }, ]; + /** + * Delete node + * @zh-CN 删除节点 + * + * @param selectedRows + */ + const handleRemove = useCallback( + async (selectedRows: API.RuleListItem[]) => { + if (!selectedRows?.length) { + messageApi.warning('请选择删除项'); + + return; + } + + await delRun({ + data: { + key: selectedRows.map((row) => row.key), + }, + }); + }, + [delRun, messageApi.warning], + ); + return ( + {contextHolder} headerTitle={intl.formatMessage({ id: 'pages.searchTable.title', @@ -286,16 +239,7 @@ const TableList: React.FC = () => { labelWidth: 120, }} toolBarRender={() => [ - , + , ]} request={rule} columns={columns} @@ -337,10 +281,9 @@ const TableList: React.FC = () => { } > )} - { - const success = await handleAdd(value as API.RuleListItem); - if (success) { - handleModalOpen(false); - if (actionRef.current) { - actionRef.current.reload(); - } - } - }} - > - - ), - }, - ]} - width="md" - name="name" - /> - - - { - const success = await handleUpdate(value); - if (success) { - handleUpdateModalOpen(false); - setCurrentRow(undefined); - if (actionRef.current) { - actionRef.current.reload(); - } - } - }} - onCancel={() => { - handleUpdateModalOpen(false); - if (!showDetail) { - setCurrentRow(undefined); - } - }} - updateModalOpen={updateModalOpen} - values={currentRow || {}} - />
    - - - + + + + +
    - -
    -
    -
    -
    - -
    - - - - - + Account Login +
    - - - - - + Phone Login +
    +
    +
    +
    +
    +
    +
    -
    -
    - -
    -
    -
    - - Welcome - +
    -
    -
    -
    - 欢迎使用 Ant Design Pro -
    -

    - Ant Design Pro 是一个整合了 umi,Ant Design 和 ProComponents 的脚手架方案。致力于在设计规范和基础组件的基础上,继续向上构建,提炼出典型模板/业务组件/配套设计资源,进一步提升企业级中后台产品设计研发过程中的『用户』和『设计者』的体验。 -

    - -
    -
    -
    -
    -
    + + + + + + + + + +
    +
    -
    -
    -
    + - + + + - +
    + @@ -1113,7 +1078,7 @@ exports[`Login Page should show login form 1`] = ` /> - + Powered by Ant Desgin diff --git a/src/pages/User/Login/index.tsx b/src/pages/user/login/index.tsx similarity index 99% rename from src/pages/User/Login/index.tsx rename to src/pages/user/login/index.tsx index 57d32b67..ff76f4dc 100644 --- a/src/pages/User/Login/index.tsx +++ b/src/pages/user/login/index.tsx @@ -15,7 +15,6 @@ import { import { FormattedMessage, Helmet, - history, SelectLang, useIntl, useModel, @@ -143,7 +142,7 @@ const Login: React.FC = () => { message.success(defaultLoginSuccessMessage); await fetchUserInfo(); const urlParams = new URL(window.location.href).searchParams; - history.push(urlParams.get('redirect') || '/'); + window.location.href = urlParams.get('redirect') || '/'; return; } console.log(msg); diff --git a/src/pages/User/Login/login.test.tsx b/src/pages/user/login/login.test.tsx similarity index 100% rename from src/pages/User/Login/login.test.tsx rename to src/pages/user/login/login.test.tsx diff --git a/src/pages/user/register-result/index.tsx b/src/pages/user/register-result/index.tsx new file mode 100644 index 00000000..1a914e26 --- /dev/null +++ b/src/pages/user/register-result/index.tsx @@ -0,0 +1,38 @@ +import { Link, useSearchParams } from '@umijs/max'; +import { Button, Result } from 'antd'; +import React from 'react'; +import useStyles from './style.style'; + +const RegisterResult: React.FC> = () => { + const { styles } = useStyles(); + const [params] = useSearchParams(); + + const actions = ( +
    + + + + + + +
    + ); + + const email = params?.get('account') || 'AntDesign@example.com'; + return ( + + 你的账户:{email} 注册成功 + + } + subTitle="激活邮件已发送到你的邮箱中,邮件有效期为24小时。请及时登录邮箱,点击邮件中的链接激活帐户。" + extra={actions} + /> + ); +}; +export default RegisterResult; diff --git a/src/pages/user/register-result/style.style.ts b/src/pages/user/register-result/style.style.ts new file mode 100644 index 00000000..ebc1b282 --- /dev/null +++ b/src/pages/user/register-result/style.style.ts @@ -0,0 +1,26 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(() => { + return { + registerResult: { + width: '800px', + minHeight: '400px', + margin: 'auto', + padding: '80px', + background: 'none', + }, + anticon: { + fontSize: '64px', + }, + title: { + marginTop: '32px', + fontSize: '20px', + lineHeight: '28px', + }, + actions: { + marginTop: '40px', + 'a + a': { marginLeft: '8px' }, + }, + }; +}); +export default useStyles; diff --git a/src/pages/user/register/_mock.ts b/src/pages/user/register/_mock.ts new file mode 100644 index 00000000..1edfe6b8 --- /dev/null +++ b/src/pages/user/register/_mock.ts @@ -0,0 +1,9 @@ +import type { Request, Response } from 'express'; + +export default { + 'POST /api/register': (_: Request, res: Response) => { + res.send({ + data: { status: 'ok', currentAuthority: 'user' }, + }); + }, +}; diff --git a/src/pages/user/register/index.tsx b/src/pages/user/register/index.tsx new file mode 100644 index 00000000..33aab441 --- /dev/null +++ b/src/pages/user/register/index.tsx @@ -0,0 +1,309 @@ +import { history, Link, useRequest } from '@umijs/max'; +import { + Button, + Col, + Form, + Input, + message, + Popover, + Progress, + Row, + Select, + Space, +} from 'antd'; +import type { Store } from 'antd/es/form/interface'; +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import type { StateType } from './service'; +import { fakeRegister } from './service'; +import useStyles from './styles'; + +const FormItem = Form.Item; +const { Option } = Select; + +const passwordProgressMap: { + ok: 'success'; + pass: 'normal'; + poor: 'exception'; +} = { + ok: 'success', + pass: 'normal', + poor: 'exception', +}; +const Register: FC = () => { + const { styles } = useStyles(); + const [count, setCount]: [number, any] = useState(0); + const [open, setVisible]: [boolean, any] = useState(false); + const [prefix, setPrefix]: [string, any] = useState('86'); + const [popover, setPopover]: [boolean, any] = useState(false); + const confirmDirty = false; + let interval: number | undefined; + + const passwordStatusMap = { + ok: ( +
    + 强度:强 +
    + ), + pass: ( +
    + 强度:中 +
    + ), + poor: ( +
    + 强度:太短 +
    + ), + }; + + const [form] = Form.useForm(); + useEffect( + () => () => { + clearInterval(interval); + }, + [interval], + ); + const onGetCaptcha = () => { + let counts = 59; + setCount(counts); + interval = window.setInterval(() => { + counts -= 1; + setCount(counts); + if (counts === 0) { + clearInterval(interval); + } + }, 1000); + }; + const getPasswordStatus = () => { + const value = form.getFieldValue('password'); + if (value && value.length > 9) { + return 'ok'; + } + if (value && value.length > 5) { + return 'pass'; + } + return 'poor'; + }; + const { loading: submitting, run: register } = useRequest<{ + data: StateType; + }>(fakeRegister, { + manual: true, + onSuccess: (data, params) => { + if (data.status === 'ok') { + message.success('注册成功!'); + history.push({ + pathname: `/user/register-result?account=${params[0].email}`, + }); + } + }, + }); + const onFinish = (values: Store) => { + register(values); + }; + const checkConfirm = (_: any, value: string) => { + const promise = Promise; + if (value && value !== form.getFieldValue('password')) { + return promise.reject('两次输入的密码不匹配!'); + } + return promise.resolve(); + }; + const checkPassword = (_: any, value: string) => { + const promise = Promise; + // 没有值的情况 + if (!value) { + setVisible(!!value); + return promise.reject('请输入密码!'); + } + // 有值的情况 + if (!open) { + setVisible(!!value); + } + setPopover(!popover); + if (value.length < 6) { + return promise.reject(''); + } + if (value && confirmDirty) { + form.validateFields(['confirm']); + } + return promise.resolve(); + }; + const changePrefix = (value: string) => { + setPrefix(value); + }; + const renderPasswordProgress = () => { + const value = form.getFieldValue('password'); + const passwordStatus = getPasswordStatus(); + return value?.length ? ( +
    + 100 ? 100 : value.length * 10} + showInfo={false} + /> +
    + ) : null; + }; + return ( +
    +

    注册

    +
    + + + + { + if (node?.parentNode) { + return node.parentNode as HTMLElement; + } + return node; + }} + content={ + open && ( +
    + {passwordStatusMap[getPasswordStatus()]} + {renderPasswordProgress()} +
    + 请至少输入 6 个字符。请不要使用容易被猜到的密码。 +
    +
    + ) + } + overlayStyle={{ + width: 240, + }} + placement="right" + open={open} + > + 0 && + styles.password + } + rules={[ + { + validator: checkPassword, + }, + ]} + > + + +
    + + + + + + + + + + + +
    + + + + + + + + + +
    + + + 使用已有账户登录 + +
    +
    + + + ); +}; +export default Register; diff --git a/src/pages/user/register/service.ts b/src/pages/user/register/service.ts new file mode 100644 index 00000000..69caab3d --- /dev/null +++ b/src/pages/user/register/service.ts @@ -0,0 +1,22 @@ +import { request } from '@umijs/max'; + +export interface StateType { + status?: 'ok' | 'error'; + currentAuthority?: 'user' | 'guest' | 'admin'; +} + +export interface UserRegisterParams { + mail: string; + password: string; + confirm: string; + mobile: string; + captcha: string; + prefix: string; +} + +export async function fakeRegister(params: UserRegisterParams) { + return request('/api/register', { + method: 'POST', + data: params, + }); +} diff --git a/src/pages/user/register/styles.ts b/src/pages/user/register/styles.ts new file mode 100644 index 00000000..cad6b68c --- /dev/null +++ b/src/pages/user/register/styles.ts @@ -0,0 +1,46 @@ +import { createStyles } from 'antd-style'; + +const useStyles = createStyles(({ token }) => { + return { + main: { + width: '368px', + margin: '0 auto', + h3: { marginBottom: '20px', fontSize: '16px' }, + }, + password: { + marginBottom: '24px', + '.ant-form-item-explain': { display: 'none' }, + }, + getCaptcha: { + display: 'block', + width: '100%', + }, + + footer: { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + submit: { + width: '50%', + }, + success: { + transition: 'color 0.3s', + color: token.colorSuccess, + }, + warning: { + transition: 'color 0.3s', + color: token.colorWarning, + }, + error: { + transition: 'color 0.3s', + color: token.colorError, + }, + 'progress-pass > .progress': { + '.ant-progress-bg': { backgroundColor: token.colorWarning }, + }, + }; +}); + +export default useStyles; diff --git a/src/typings.d.ts b/src/typings.d.ts index 742f70c6..40f11d06 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -12,9 +12,7 @@ declare module '*.bmp'; declare module '*.tiff'; declare module 'omit.js'; declare module 'numeral'; -declare module '@antv/data-set'; declare module 'mockjs'; declare module 'react-fittext'; -declare module 'bizcharts-plugin-slider'; declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false; diff --git a/tsconfig.json b/tsconfig.json index 6b708b4a..2fa95248 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,17 @@ { "compilerOptions": { + "baseUrl": "./", "target": "esnext", - "module": "esnext", "moduleResolution": "node", - "importHelpers": true, "jsx": "react-jsx", "esModuleInterop": true, - "sourceMap": true, - "baseUrl": "./", - "skipLibCheck": true, "experimentalDecorators": true, "strict": true, + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "declaration": true, + "skipLibCheck": true, "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, "paths": { "@/*": ["./src/*"], "@@/*": ["./src/.umi/*"],