diff --git a/config/config.ts b/config/config.ts index fc977cb8..dee1c080 100644 --- a/config/config.ts +++ b/config/config.ts @@ -1,10 +1,8 @@ // https://umijs.org/config/ import { defineConfig } from 'umi'; import { join } from 'path'; - import defaultSettings from './defaultSettings'; import proxy from './proxy'; -import routes from './routes'; const { REACT_APP_ENV } = process.env; @@ -35,7 +33,289 @@ export default defineConfig({ ie: 11, }, // umi routes: https://umijs.org/docs/routing - routes, + routes: [ + { + path: '/user', + layout: false, + routes: [ + { + path: '/user/login', + layout: false, + name: 'login', + component: './user/Login', + }, + { + path: '/user', + redirect: '/user/login', + }, + { + name: 'register-result', + icon: 'smile', + path: '/user/register-result', + component: './user/register-result', + }, + { + name: 'register', + icon: 'smile', + path: '/user/register', + component: './user/register', + }, + { + component: '404', + }, + ], + }, + { + path: '/dashboard', + name: 'dashboard', + icon: 'dashboard', + routes: [ + { + path: '/dashboard', + redirect: '/dashboard/analysis', + }, + { + name: 'analysis', + icon: 'smile', + path: '/dashboard/analysis', + component: './dashboard/analysis', + }, + { + name: 'monitor', + icon: 'smile', + path: '/dashboard/monitor', + component: './dashboard/monitor', + }, + { + name: 'workplace', + icon: 'smile', + path: '/dashboard/workplace', + component: './dashboard/workplace', + }, + ], + }, + { + path: '/form', + icon: 'form', + name: 'form', + routes: [ + { + path: '/form', + redirect: '/form/basic-form', + }, + { + name: 'basic-form', + icon: 'smile', + path: '/form/basic-form', + component: './form/basic-form', + }, + { + name: 'step-form', + icon: 'smile', + path: '/form/step-form', + component: './form/step-form', + }, + { + name: 'advanced-form', + icon: 'smile', + path: '/form/advanced-form', + component: './form/advanced-form', + }, + ], + }, + { + path: '/list', + icon: 'table', + name: 'list', + routes: [ + { + path: '/list/search', + name: 'search-list', + component: './list/search', + routes: [ + { + path: '/list/search', + redirect: '/list/search/articles', + }, + { + name: 'articles', + icon: 'smile', + path: '/list/search/articles', + component: './list/search/articles', + }, + { + name: 'projects', + icon: 'smile', + path: '/list/search/projects', + component: './list/search/projects', + }, + { + name: 'applications', + icon: 'smile', + path: '/list/search/applications', + component: './list/search/applications', + }, + ], + }, + { + path: '/list', + redirect: '/list/table-list', + }, + { + name: 'table-list', + icon: 'smile', + path: '/list/table-list', + component: './list/table-list', + }, + { + name: 'basic-list', + icon: 'smile', + path: '/list/basic-list', + component: './list/basic-list', + }, + { + name: 'card-list', + icon: 'smile', + path: '/list/card-list', + component: './list/card-list', + }, + ], + }, + { + path: '/profile', + name: 'profile', + icon: 'profile', + routes: [ + { + path: '/profile', + redirect: '/profile/basic', + }, + { + name: 'basic', + icon: 'smile', + path: '/profile/basic', + component: './profile/basic', + }, + { + name: 'advanced', + icon: 'smile', + path: '/profile/advanced', + component: './profile/advanced', + }, + ], + }, + { + name: 'result', + icon: 'CheckCircleOutlined', + path: '/result', + routes: [ + { + path: '/result', + redirect: '/result/success', + }, + { + name: 'success', + icon: 'smile', + path: '/result/success', + component: './result/success', + }, + { + name: 'fail', + icon: 'smile', + path: '/result/fail', + component: './result/fail', + }, + ], + }, + { + name: 'exception', + icon: 'warning', + path: '/exception', + routes: [ + { + path: '/exception', + redirect: '/exception/403', + }, + { + name: '403', + icon: 'smile', + path: '/exception/403', + component: './exception/403', + }, + { + name: '404', + icon: 'smile', + path: '/exception/404', + component: './exception/404', + }, + { + name: '500', + icon: 'smile', + path: '/exception/500', + component: './exception/500', + }, + ], + }, + { + name: 'account', + icon: 'user', + path: '/account', + routes: [ + { + path: '/account', + redirect: '/account/center', + }, + { + name: 'center', + icon: 'smile', + path: '/account/center', + component: './account/center', + }, + { + name: 'settings', + icon: 'smile', + path: '/account/settings', + component: './account/settings', + }, + ], + }, + { + name: 'editor', + icon: 'highlight', + path: '/editor', + routes: [ + { + path: '/editor', + redirect: '/editor/flow', + }, + { + name: 'flow', + icon: 'smile', + path: '/editor/flow', + component: './editor/flow', + }, + { + name: 'mind', + icon: 'smile', + path: '/editor/mind', + component: './editor/mind', + }, + { + name: 'koni', + icon: 'smile', + path: '/editor/koni', + component: './editor/koni', + }, + ], + }, + { + path: '/', + redirect: '/dashboard/analysis', + }, + { + component: '404', + }, + ], // Theme for antd: https://ant.design/docs/react/customize-theme-cn theme: { 'primary-color': defaultSettings.primaryColor, @@ -65,7 +345,9 @@ export default defineConfig({ projectName: 'swagger', }, ], - nodeModulesTransform: { type: 'none' }, + nodeModulesTransform: { + type: 'none', + }, mfsu: {}, webpack5: {}, exportStatic: {}, diff --git a/package.json b/package.json index 3a0615d9..0e7050d8 100644 --- a/package.json +++ b/package.json @@ -54,21 +54,35 @@ "not ie <= 10" ], "dependencies": { + "@ant-design/charts": "^0.9.4", "@ant-design/icons": "^4.5.0", "@ant-design/pro-descriptions": "^1.6.8", "@ant-design/pro-form": "^1.18.3", "@ant-design/pro-layout": "^6.15.3", "@ant-design/pro-table": "^2.30.8", + "@antv/data-set": "^0.11.0", + "@antv/l7": "^2.3.7", + "@antv/l7-maps": "^2.3.7", + "@antv/l7-react": "^2.1.9", "@umijs/route-utils": "^1.0.36", + "ahooks": "^2.0.0", "antd": "^4.14.0", + "bizcharts": "^3.5.3-beta.0", + "bizcharts-plugin-slider": "^2.1.1-beta.1", "classnames": "^2.2.6", + "gg-editor": "^2.0.2", "lodash": "^4.17.11", + "lodash-decorators": "^6.0.0", "moment": "^2.25.3", + "numeral": "^2.0.6", + "nzh": "^1.0.3", "omit.js": "^2.0.2", "react": "^17.0.0", "react-dev-inspector": "^1.1.1", "react-dom": "^17.0.0", + "react-fittext": "^1.0.0", "react-helmet-async": "^1.0.4", + "react-router": "^4.3.1", "umi": "^3.5.0", "umi-serve": "^1.9.10" }, @@ -141,4 +155,4 @@ "gitHooks": { "commit-msg": "fabric verify-commit" } -} +} \ No newline at end of file diff --git a/src/components/RightContent/index.tsx b/src/components/RightContent/index.tsx index 9463b1f1..6e478abb 100644 --- a/src/components/RightContent/index.tsx +++ b/src/components/RightContent/index.tsx @@ -5,6 +5,7 @@ import { useModel, SelectLang } from 'umi'; import Avatar from './AvatarDropdown'; import HeaderSearch from '../HeaderSearch'; import styles from './index.less'; +import NoticeIconView from '../NoticeIcon'; export type SiderTheme = 'light' | 'dark'; @@ -21,6 +22,7 @@ const GlobalHeaderRight: React.FC = () => { if ((navTheme === 'dark' && layout === 'top') || layout === 'mix') { className = `${styles.right} ${styles.dark}`; } + return ( { placeholder="站内搜索" defaultValue="umi ui" options={[ - { label: umi ui, value: 'umi ui' }, + { + label: umi ui, + value: 'umi ui', + }, { label: Ant Design, value: 'Ant Design', @@ -41,8 +46,7 @@ const GlobalHeaderRight: React.FC = () => { label: Pro Layout, value: 'Pro Layout', }, - ]} - // onSearch={value => { + ]} // onSearch={value => { // console.log('input', value); // }} /> @@ -54,9 +58,11 @@ const GlobalHeaderRight: React.FC = () => { > - + + ); }; + export default GlobalHeaderRight; diff --git a/src/pages/account/center/Center.less b/src/pages/account/center/Center.less new file mode 100644 index 00000000..f547b406 --- /dev/null +++ b/src/pages/account/center/Center.less @@ -0,0 +1,86 @@ +@import '~antd/es/style/themes/default.less'; + +.avatarHolder { + margin-bottom: 24px; + text-align: center; + + & > img { + width: 104px; + height: 104px; + margin-bottom: 20px; + } + + .name { + margin-bottom: 4px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } +} + +.detail { + p { + position: relative; + margin-bottom: 8px; + padding-left: 26px; + + &:last-child { + margin-bottom: 0; + } + } + + i { + position: absolute; + top: 4px; + left: 0; + width: 14px; + height: 14px; + } +} + +.tagsTitle, +.teamTitle { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; +} + +.tags { + :global { + .ant-tag { + margin-bottom: 8px; + } + } +} + +.team { + :global { + .ant-avatar { + margin-right: 12px; + } + } + + a { + display: block; + margin-bottom: 24px; + overflow: hidden; + color: @text-color; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + transition: color 0.3s; + + &:hover { + color: @primary-color; + } + } +} + +.tabsCard { + :global { + .ant-card-head { + padding: 0 16px; + } + } +} diff --git a/src/pages/account/center/_mock.ts b/src/pages/account/center/_mock.ts new file mode 100644 index 00000000..67acab0f --- /dev/null +++ b/src/pages/account/center/_mock.ts @@ -0,0 +1,243 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import type { Request, Response } from 'express'; +import type { ListItemDataType } from './data.d'; + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const covers = [ + 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iXjVmWVHbCJAyqvDxdtx.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png', +]; +const desc = [ + '那是一种内在的东西, 他们到达不了,也无法触及的', + '希望是一个好东西,也许是最好的,好东西是不会消亡的', + '生命就像一盒巧克力,结果往往出人意料', + '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + '那时候我只会想自己想要什么,从不想自己拥有什么', +]; + +const user = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +// 当前用户信息 +const currentUseDetail = { + name: 'Serati Ma', + avatar: 'https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png', + userid: '00000001', + email: 'antdesign@alipay.com', + signature: '海纳百川,有容乃大', + title: '交互专家', + group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED', + tags: [ + { + key: '0', + label: '很有想法的', + }, + { + key: '1', + label: '专注设计', + }, + { + key: '2', + label: '辣~', + }, + { + key: '3', + label: '大长腿', + }, + { + key: '4', + label: '川妹子', + }, + { + key: '5', + label: '海纳百川', + }, + ], + notice: [ + { + id: 'xxx1', + title: titles[0], + logo: avatars[0], + description: '那是一种内在的东西,他们到达不了,也无法触及的', + updatedAt: new Date(), + member: '科学搬砖组', + href: '', + memberLink: '', + }, + { + id: 'xxx2', + title: titles[1], + logo: avatars[1], + description: '希望是一个好东西,也许是最好的,好东西是不会消亡的', + updatedAt: new Date('2017-07-24'), + member: '全组都是吴彦祖', + href: '', + memberLink: '', + }, + { + id: 'xxx3', + title: titles[2], + logo: avatars[2], + description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + updatedAt: new Date(), + member: '中二少女团', + href: '', + memberLink: '', + }, + { + id: 'xxx4', + title: titles[3], + logo: avatars[3], + description: '那时候我只会想自己想要什么,从不想自己拥有什么', + updatedAt: new Date('2017-07-23'), + member: '程序员日常', + href: '', + memberLink: '', + }, + { + id: 'xxx5', + title: titles[4], + logo: avatars[4], + description: '凛冬将至', + updatedAt: new Date('2017-07-23'), + member: '高逼格设计天团', + href: '', + memberLink: '', + }, + { + id: 'xxx6', + title: titles[5], + logo: avatars[5], + description: '生命就像一盒巧克力,结果往往出人意料', + updatedAt: new Date('2017-07-23'), + member: '骗你来学计算机', + href: '', + memberLink: '', + }, + ], + notifyCount: 12, + unreadCount: 11, + country: 'China', + geographic: { + province: { + label: '浙江省', + key: '330000', + }, + city: { + label: '杭州市', + key: '330100', + }, + }, + address: '西湖区工专路 77 号', + phone: '0752-268888888', +}; + +function fakeList(count: number): ListItemDataType[] { + const list = []; + for (let i = 0; i < count; i += 1) { + list.push({ + id: `fake-list-${i}`, + owner: user[i % 10], + title: titles[i % 8], + avatar: avatars[i % 8], + cover: parseInt(`${i / 4}`, 10) % 2 === 0 ? covers[i % 4] : covers[3 - (i % 4)], + status: ['active', 'exception', 'normal'][i % 3] as + | 'normal' + | 'exception' + | 'active' + | 'success', + percent: Math.ceil(Math.random() * 50) + 50, + logo: avatars[i % 8], + href: 'https://ant.design', + updatedAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + subDescription: desc[i % 5], + description: + '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。', + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + star: Math.ceil(Math.random() * 100) + 100, + like: Math.ceil(Math.random() * 100) + 100, + message: Math.ceil(Math.random() * 10) + 10, + content: + '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。', + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3', + }, + ], + }); + } + + return list; +} + +function getFakeList(req: Request, res: Response) { + const params = req.query as any; + + const count = Number(params.count) * 1 || 5; + + const result = fakeList(count); + return res.json({ + data: { + list: result, + }, + }); +} + +// 获取用户信息 +function getCurrentUser(req: Request, res: Response) { + return res.json({ + data: currentUseDetail, + }); +} + +export default { + 'GET /api/fake_list_Detail': getFakeList, + // 支持值为 Object 和 Array + 'GET /api/currentUserDetail': getCurrentUser, +}; diff --git a/src/pages/account/center/components/Applications/index.less b/src/pages/account/center/components/Applications/index.less new file mode 100644 index 00000000..c550b333 --- /dev/null +++ b/src/pages/account/center/components/Applications/index.less @@ -0,0 +1,51 @@ +@import '~antd/es/style/themes/default.less'; + +.filterCardList { + margin-bottom: -24px; + :global { + .ant-card-meta-content { + margin-top: 0; + } + // disabled white space + .ant-card-meta-avatar { + font-size: 0; + } + + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } + .cardInfo { + margin-top: 16px; + margin-left: 40px; + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + & > div { + position: relative; + float: left; + width: 50%; + text-align: left; + p { + margin: 0; + font-size: 24px; + line-height: 32px; + } + p:first-child { + margin-bottom: 4px; + color: @text-color-secondary; + font-size: 12px; + line-height: 20px; + } + } + } +} diff --git a/src/pages/account/center/components/Applications/index.tsx b/src/pages/account/center/components/Applications/index.tsx new file mode 100644 index 00000000..c7bd3a9f --- /dev/null +++ b/src/pages/account/center/components/Applications/index.tsx @@ -0,0 +1,123 @@ +import { + DownloadOutlined, + EditOutlined, + EllipsisOutlined, + ShareAltOutlined, +} from '@ant-design/icons'; +import { useRequest } from 'umi'; +import { Avatar, Card, Dropdown, List, Menu, Tooltip } from 'antd'; +import React from 'react'; +import numeral from 'numeral'; +import type { ListItemDataType } from '../../data.d'; +import { queryFakeList } from '../../service'; +import stylesApplications from './index.less'; + +export function formatWan(val: number) { + const v = val * 1; + if (!v || Number.isNaN(v)) return ''; + + let result: React.ReactNode = val; + if (val > 10000) { + result = ( + + {Math.floor(val / 10000)} + + 万 + + + ); + } + return result; +} + +const Applications: React.FC = () => { + // 获取tab列表数据 + const { data: listData } = useRequest(() => { + return queryFakeList({ + count: 30, + }); + }); + + const itemMenu = ( + + + + 1st menu item + + + + + 2nd menu item + + + + + 3d menu item + + + + ); + 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.less b/src/pages/account/center/components/ArticleListContent/index.less new file mode 100644 index 00000000..eca0811c --- /dev/null +++ b/src/pages/account/center/components/ArticleListContent/index.less @@ -0,0 +1,38 @@ +@import '~antd/es/style/themes/default.less'; + +.listContent { + .description { + max-width: 720px; + line-height: 22px; + } + .extra { + margin-top: 16px; + color: @text-color-secondary; + line-height: 22px; + & > :global(.ant-avatar) { + position: relative; + top: 1px; + width: 20px; + height: 20px; + margin-right: 8px; + vertical-align: top; + } + & > em { + margin-left: 16px; + color: @disabled-color; + font-style: normal; + } + } +} + +@media screen and (max-width: @screen-xs) { + .listContent { + .extra { + & > em { + display: block; + margin-top: 8px; + margin-left: 0; + } + } + } +} diff --git a/src/pages/account/center/components/ArticleListContent/index.tsx b/src/pages/account/center/components/ArticleListContent/index.tsx new file mode 100644 index 00000000..183c38e8 --- /dev/null +++ b/src/pages/account/center/components/ArticleListContent/index.tsx @@ -0,0 +1,28 @@ +import { Avatar } from 'antd'; +import React from 'react'; +import moment from 'moment'; +import styles from './index.less'; + +export type ApplicationsProps = { + data: { + content?: string; + updatedAt?: any; + avatar?: string; + owner?: string; + href?: string; + }; +}; +const ArticleListContent: React.FC = ({ + data: { content, updatedAt, avatar, owner, href }, +}) => ( +
+
{content}
+
+ + {owner} 发布在 {href} + {moment(updatedAt).format('YYYY-MM-DD HH:mm')} +
+
+); + +export default ArticleListContent; diff --git a/src/pages/account/center/components/Articles/index.less b/src/pages/account/center/components/Articles/index.less new file mode 100644 index 00000000..e78c412c --- /dev/null +++ b/src/pages/account/center/components/Articles/index.less @@ -0,0 +1,12 @@ +@import '~antd/es/style/themes/default.less'; + +.articleList { + :global { + .ant-list-item:first-child { + padding-top: 0; + } + } +} +a.listItemMetaTitle { + color: @heading-color; +} diff --git a/src/pages/account/center/components/Articles/index.tsx b/src/pages/account/center/components/Articles/index.tsx new file mode 100644 index 00000000..94adefee --- /dev/null +++ b/src/pages/account/center/components/Articles/index.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { StarTwoTone, LikeOutlined, MessageFilled } from '@ant-design/icons'; +import { useRequest } from 'umi'; +import { List, Tag } from 'antd'; +import ArticleListContent from '../ArticleListContent'; +import type { ListItemDataType } from '../../data.d'; +import { queryFakeList } from '../../service'; +import styles from './index.less'; + +const Articles: React.FC = () => { + const IconText: React.FC<{ + icon: React.ReactNode; + text: React.ReactNode; + }> = ({ icon, text }) => ( + + {icon} {text} + + ); + + // 获取tab列表数据 + const { data: listData } = useRequest(() => { + return queryFakeList({ + count: 30, + }); + }); + return ( + + size="large" + className={styles.articleList} + rowKey="id" + itemLayout="vertical" + dataSource={listData?.list || []} + 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.less b/src/pages/account/center/components/AvatarList/index.less new file mode 100644 index 00000000..a7a3d441 --- /dev/null +++ b/src/pages/account/center/components/AvatarList/index.less @@ -0,0 +1,50 @@ +@import '~antd/es/style/themes/default.less'; + +.avatarList { + display: inline-block; + ul { + display: inline-block; + margin-left: 8px; + font-size: 0; + } +} + +.avatarItem { + display: inline-block; + width: @avatar-size-base; + height: @avatar-size-base; + margin-left: -8px; + font-size: @font-size-base; + :global { + .ant-avatar { + border: 1px solid @border-color-base; + } + } +} + +.avatarItemLarge { + width: @avatar-size-lg; + height: @avatar-size-lg; +} + +.avatarItemSmall { + width: @avatar-size-sm; + height: @avatar-size-sm; +} + +.avatarItemMini { + width: 20px; + height: 20px; + :global { + .ant-avatar { + width: 20px; + height: 20px; + line-height: 20px; + + .ant-avatar-string { + font-size: 12px; + line-height: 18px; + } + } + } +} 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..4d57501a --- /dev/null +++ b/src/pages/account/center/components/AvatarList/index.tsx @@ -0,0 +1,84 @@ +import { Avatar, Tooltip } from 'antd'; + +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export declare type SizeType = number | 'small' | 'default' | 'large'; + +export 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 = (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 cls = avatarSizeToClassName(size); + + return ( +
  • + {tips ? ( + + + + ) : ( + + )} +
  • + ); +}; + +const AvatarList: React.FC & { Item: typeof Item } = ({ + children, + size, + maxLength = 5, + excessItemsStyle, + ...other +}) => { + const numOfChildren = React.Children.count(children); + const numToShow = maxLength >= numOfChildren ? numOfChildren : maxLength; + const childrenArray = React.Children.toArray(children) as React.ReactElement[]; + const childrenWithProps = childrenArray.slice(0, numToShow).map((child) => + React.cloneElement(child, { + size, + }), + ); + + if (numToShow < numOfChildren) { + const cls = avatarSizeToClassName(size); + + childrenWithProps.push( +
  • + {`+${numOfChildren - maxLength}`} +
  • , + ); + } + + return ( +
    +
      {childrenWithProps}
    +
    + ); +}; + +AvatarList.Item = Item; + +export default AvatarList; diff --git a/src/pages/account/center/components/Projects/index.less b/src/pages/account/center/components/Projects/index.less new file mode 100644 index 00000000..bfa61475 --- /dev/null +++ b/src/pages/account/center/components/Projects/index.less @@ -0,0 +1,54 @@ +@import '~antd/es/style/themes/default.less'; + +.coverCardList { + .card { + :global { + .ant-card-meta-title { + margin-bottom: 4px; + & > a { + display: inline-block; + max-width: 100%; + color: @heading-color; + } + } + .ant-card-meta-description { + height: 44px; + overflow: hidden; + line-height: 22px; + } + } + + &:hover { + :global { + .ant-card-meta-title > a { + color: @primary-color; + } + } + } + } + + .cardItemContent { + display: flex; + height: 20px; + margin-top: 16px; + margin-bottom: -4px; + line-height: 20px; + & > span { + flex: 1; + color: @text-color-secondary; + font-size: 12px; + } + .avatarList { + flex: 0 1 auto; + } + } + .cardList { + margin-top: 24px; + } + + :global { + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } +} 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..a03c383d --- /dev/null +++ b/src/pages/account/center/components/Projects/index.tsx @@ -0,0 +1,49 @@ +import { Card, List } from 'antd'; +import { useRequest } from 'umi'; +import React from 'react'; +import moment from 'moment'; +import { queryFakeList } from '../../service'; +import AvatarList from '../AvatarList'; +import type { ListItemDataType } from '../../data.d'; +import styles from './index.less'; + +const Projects: React.FC = () => { + // 获取tab列表数据 + const { data: listData } = useRequest(() => { + return queryFakeList({ + count: 30, + }); + }); + + return ( + + 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} /> +
    + {moment(item.updatedAt).fromNow()} +
    + + {item.members.map((member) => ( + + ))} + +
    +
    +
    +
    + )} + /> + ); +}; + +export default Projects; diff --git a/src/pages/account/center/data.d.ts b/src/pages/account/center/data.d.ts new file mode 100644 index 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..2410ff48 --- /dev/null +++ b/src/pages/account/center/index.tsx @@ -0,0 +1,210 @@ +import { PlusOutlined, HomeOutlined, ContactsOutlined, ClusterOutlined } from '@ant-design/icons'; +import { Avatar, Card, Col, Divider, Input, Row, Tag } from 'antd'; +import React, { useState, useRef } from 'react'; +import { GridContent } from '@ant-design/pro-layout'; +import { Link, useRequest } from 'umi'; +import type { RouteChildrenProps } from 'react-router'; +import Projects from './components/Projects'; +import Articles from './components/Articles'; +import Applications from './components/Applications'; +import type { CurrentUser, TagType, tabKeyType } from './data.d'; +import { queryCurrent } from './service'; +import styles from './Center.less'; + +const operationTabList = [ + { + key: 'articles', + tab: ( + + 文章 (8) + + ), + }, + { + key: 'applications', + tab: ( + + 应用 (8) + + ), + }, + { + key: 'projects', + tab: ( + + 项目 (8) + + ), + }, +]; + +const TagList: React.FC<{ tags: CurrentUser['tags'] }> = ({ tags }) => { + 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 [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 && + 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..b6bfad71 --- /dev/null +++ b/src/pages/account/center/service.ts @@ -0,0 +1,14 @@ +import { request } from 'umi'; +import type { CurrentUser, ListItemDataType } from './data.d'; + +export async function queryCurrent(): Promise<{ data: CurrentUser }> { + return request('/api/currentUserDetail'); +} + +export async function queryFakeList(params: { + count: number; +}): Promise<{ data: { list: ListItemDataType[] } }> { + return request('/api/fake_list_Detail', { + params, + }); +} diff --git a/src/pages/account/settings/_mock.ts b/src/pages/account/settings/_mock.ts new file mode 100644 index 00000000..8e4ee132 --- /dev/null +++ b/src/pages/account/settings/_mock.ts @@ -0,0 +1,79 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import type { Request, Response } from 'express'; + +const city = require('./geographic/city.json'); +const province = require('./geographic/province.json'); + +function getProvince(_: Request, res: Response) { + return res.json({ + data: province, + }); +} + +function getCity(req: Request, res: Response) { + return res.json({ + data: city[req.params.province], + }); +} + +function getCurrentUse(req: Request, res: Response) { + return res.json({ + data: { + name: 'Serati Ma', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png', + userid: '00000001', + email: 'antdesign@alipay.com', + signature: '海纳百川,有容乃大', + title: '交互专家', + group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED', + tags: [ + { + key: '0', + label: '很有想法的', + }, + { + key: '1', + label: '专注设计', + }, + { + key: '2', + label: '辣~', + }, + { + key: '3', + label: '大长腿', + }, + { + key: '4', + label: '川妹子', + }, + { + key: '5', + label: '海纳百川', + }, + ], + notifyCount: 12, + unreadCount: 11, + country: 'China', + geographic: { + province: { + label: '浙江省', + key: '330000', + }, + city: { + label: '杭州市', + key: '330100', + }, + }, + address: '西湖区工专路 77 号', + phone: '0752-268888888', + }, + }); +} +// 代码中会兼容本地 service mock 以及部署站点的静态数据 +export default { + // 支持值为 Object 和 Array + 'GET /api/accountSettingCurrentUser': getCurrentUse, + 'GET /api/geographic/province': getProvince, + 'GET /api/geographic/city/:province': getCity, +}; diff --git a/src/pages/account/settings/components/BaseView.less b/src/pages/account/settings/components/BaseView.less new file mode 100644 index 00000000..ca328b47 --- /dev/null +++ b/src/pages/account/settings/components/BaseView.less @@ -0,0 +1,65 @@ +@import '~antd/es/style/themes/default.less'; + +.baseView { + display: flex; + padding-top: 12px; + + :global { + .ant-legacy-form-item .ant-legacy-form-item-control-wrapper { + width: 100%; + } + } + + .left { + min-width: 224px; + max-width: 448px; + } + .right { + flex: 1; + padding-left: 104px; + .avatar_title { + height: 22px; + margin-bottom: 8px; + color: @heading-color; + font-size: @font-size-base; + line-height: 22px; + } + .avatar { + width: 144px; + height: 144px; + margin-bottom: 12px; + overflow: hidden; + img { + width: 100%; + } + } + .button_view { + width: 144px; + text-align: center; + } + } +} + +.area_code { + width: 72px; +} +.phone_number { + width: 214px; +} + +@media screen and (max-width: @screen-xl) { + .baseView { + flex-direction: column-reverse; + + .right { + display: flex; + flex-direction: column; + align-items: center; + max-width: 448px; + padding: 20px; + .avatar_title { + display: none; + } + } + } +} diff --git a/src/pages/account/settings/components/PhoneView.tsx b/src/pages/account/settings/components/PhoneView.tsx new file mode 100644 index 00000000..43894f34 --- /dev/null +++ b/src/pages/account/settings/components/PhoneView.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { Input } from 'antd'; +import styles from './PhoneView.less'; + +type PhoneViewProps = { + value?: string; + onChange?: (value: string) => void; +}; + +const PhoneView: React.FC = (props) => { + 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..88e3ebb7 --- /dev/null +++ b/src/pages/account/settings/components/base.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { UploadOutlined } from '@ant-design/icons'; +import { Button, Input, Upload, message } from 'antd'; +import ProForm, { + ProFormDependency, + ProFormFieldSet, + ProFormSelect, + ProFormText, + ProFormTextArea, +} from '@ant-design/pro-form'; +import { useRequest } from 'umi'; +import { queryCurrent } from '../service'; +import { queryProvince, queryCity } from '../service'; + +import styles from './BaseView.less'; + +const validatorPhone = (rule: any, value: string, callback: (message?: string) => void) => { + const values = value.split('-'); + if (!values[0]) { + callback('Please input your area code!'); + } + if (!values[1]) { + callback('Please input your phone number!'); + } + callback(); +}; +// 头像组件 方便以后独立,增加裁剪之类的功能 +const AvatarView = ({ avatar }: { avatar: string }) => ( + <> +
    头像
    +
    + avatar +
    + +
    + +
    +
    + +); + +const BaseView: React.FC = () => { + const { data: currentUser, loading } = useRequest(() => { + return queryCurrent(); + }); + + const getAvatarURL = () => { + if (currentUser) { + if (currentUser.avatar) { + return currentUser.avatar; + } + const url = 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png'; + return url; + } + return ''; + }; + + const handleFinish = async () => { + message.success('更新基本信息成功'); + }; + return ( +
    + {loading ? null : ( + <> +
    + + + + + + + + { + 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..002c4596 --- /dev/null +++ b/src/pages/account/settings/components/binding.tsx @@ -0,0 +1,46 @@ +import { AlipayOutlined, DingdingOutlined, TaobaoOutlined } from '@ant-design/icons'; +import { List } from 'antd'; +import React, { Fragment } from 'react'; + +const BindingView: React.FC = () => { + const getData = () => [ + { + title: '绑定淘宝', + description: '当前未绑定淘宝账号', + actions: [绑定], + avatar: , + }, + { + title: '绑定支付宝', + description: '当前未绑定支付宝账号', + actions: [绑定], + avatar: , + }, + { + title: '绑定钉钉', + description: '当前未绑定钉钉账号', + actions: [绑定], + avatar: , + }, + ]; + + return ( + + ( + + + + )} + /> + + ); +}; + +export default BindingView; diff --git a/src/pages/account/settings/components/notification.tsx b/src/pages/account/settings/components/notification.tsx new file mode 100644 index 00000000..177c38ce --- /dev/null +++ b/src/pages/account/settings/components/notification.tsx @@ -0,0 +1,44 @@ +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..c0c40200 --- /dev/null +++ b/src/pages/account/settings/components/security.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { List } from 'antd'; + +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..d5131ec5 --- /dev/null +++ b/src/pages/account/settings/index.tsx @@ -0,0 +1,111 @@ +import React, { useState, useRef, useLayoutEffect } from 'react'; +import { GridContent } from '@ant-design/pro-layout'; +import { Menu } from 'antd'; +import BaseView from './components/base'; +import BindingView from './components/binding'; +import NotificationView from './components/notification'; +import SecurityView from './components/security'; +import styles from './style.less'; + +const { Item } = Menu; + +type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification'; +type SettingsState = { + mode: 'inline' | 'horizontal'; + selectKey: SettingsStateKeys; +}; + +const Settings: React.FC = () => { + const menuMap: Record = { + base: '基本设置', + security: '安全设置', + binding: '账号绑定', + notification: '新消息通知', + }; + + const [initConfig, setInitConfig] = useState({ + mode: 'inline', + selectKey: 'base', + }); + const dom = useRef(); + + const resize = () => { + requestAnimationFrame(() => { + if (!dom.current) { + return; + } + let mode: 'inline' | 'horizontal' = 'inline'; + const { offsetWidth } = dom.current; + if (dom.current.offsetWidth < 641 && offsetWidth > 400) { + mode = 'horizontal'; + } + if (window.innerWidth < 768 && offsetWidth > 400) { + mode = 'horizontal'; + } + setInitConfig({ ...initConfig, mode: mode as SettingsState['mode'] }); + }); + }; + + useLayoutEffect(() => { + if (dom.current) { + window.addEventListener('resize', resize); + resize(); + } + return () => { + window.removeEventListener('resize', resize); + }; + }, [dom.current]); + + const getMenu = () => { + return Object.keys(menuMap).map((item) => {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, + }); + }} + > + {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..e2a6031a --- /dev/null +++ b/src/pages/account/settings/service.ts @@ -0,0 +1,18 @@ +import { request } from 'umi'; +import type { CurrentUser, GeographicItemType } from './data'; + +export async function queryCurrent(): Promise<{ data: CurrentUser }> { + return request('/api/accountSettingCurrentUser'); +} + +export async function queryProvince(): Promise<{ data: GeographicItemType[] }> { + return request('/api/geographic/province'); +} + +export async function queryCity(province: string): Promise<{ data: GeographicItemType[] }> { + return request(`/api/geographic/city/${province}`); +} + +export async function query() { + return request('/api/users'); +} diff --git a/src/pages/account/settings/style.less b/src/pages/account/settings/style.less new file mode 100644 index 00000000..fee54c2d --- /dev/null +++ b/src/pages/account/settings/style.less @@ -0,0 +1,93 @@ +@import '~antd/es/style/themes/default.less'; + +.main { + display: flex; + width: 100%; + height: 100%; + padding-top: 16px; + padding-bottom: 16px; + background-color: @menu-bg; + .leftMenu { + width: 224px; + border-right: @border-width-base @border-style-base @border-color-split; + :global { + .ant-menu-inline { + border: none; + } + .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected { + font-weight: bold; + } + } + } + .right { + flex: 1; + padding: 8px 40px; + .title { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } + } + :global { + .ant-list-split .ant-list-item:last-child { + border-bottom: 1px solid @border-color-split; + } + .ant-list-item { + padding-top: 14px; + padding-bottom: 14px; + } + } +} +:global { + .ant-list-item-meta { + // 账号绑定图标 + .taobao { + display: block; + color: #ff4000; + font-size: 48px; + line-height: 48px; + border-radius: @border-radius-base; + } + .dingding { + margin: 2px; + padding: 6px; + color: #fff; + font-size: 32px; + line-height: 32px; + background-color: #2eabff; + border-radius: @border-radius-base; + } + .alipay { + color: #2eabff; + font-size: 48px; + line-height: 48px; + border-radius: @border-radius-base; + } + } + + // 密码强度 + font.strong { + color: @success-color; + } + font.medium { + color: @warning-color; + } + font.weak { + color: @error-color; + } +} + +@media screen and (max-width: @screen-md) { + .main { + flex-direction: column; + .leftMenu { + width: 100%; + border: none; + } + .right { + padding: 40px; + } + } +} diff --git a/src/pages/dashboard/analysis/_mock.ts b/src/pages/dashboard/analysis/_mock.ts new file mode 100644 index 00000000..e811c588 --- /dev/null +++ b/src/pages/dashboard/analysis/_mock.ts @@ -0,0 +1,210 @@ +import moment from 'moment'; +import type { Request, Response } from 'express'; +import type { AnalysisData, RadarData, DataItem } from './data.d'; + +// mock data +const visitData: DataItem[] = []; +const beginDay = new Date().getTime(); + +const fakeY = [7, 5, 4, 2, 4, 7, 5, 6, 5, 9, 6, 3, 1, 5, 3, 6, 5]; +for (let i = 0; i < fakeY.length; i += 1) { + visitData.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY[i], + }); +} + +const visitData2 = []; +const fakeY2 = [1, 6, 4, 8, 3, 7, 2]; +for (let i = 0; i < fakeY2.length; i += 1) { + visitData2.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY2[i], + }); +} + +const salesData = []; +for (let i = 0; i < 12; i += 1) { + salesData.push({ + x: `${i + 1}月`, + y: Math.floor(Math.random() * 1000) + 200, + }); +} +const searchData = []; +for (let i = 0; i < 50; i += 1) { + searchData.push({ + index: i + 1, + keyword: `搜索关键词-${i}`, + count: Math.floor(Math.random() * 1000), + range: Math.floor(Math.random() * 100), + status: Math.floor((Math.random() * 10) % 2), + }); +} +const salesTypeData = [ + { + x: '家用电器', + y: 4544, + }, + { + x: '食用酒水', + y: 3321, + }, + { + x: '个护健康', + y: 3113, + }, + { + x: '服饰箱包', + y: 2341, + }, + { + x: '母婴产品', + y: 1231, + }, + { + x: '其他', + y: 1231, + }, +]; + +const salesTypeDataOnline = [ + { + x: '家用电器', + y: 244, + }, + { + x: '食用酒水', + y: 321, + }, + { + x: '个护健康', + y: 311, + }, + { + x: '服饰箱包', + y: 41, + }, + { + x: '母婴产品', + y: 121, + }, + { + x: '其他', + y: 111, + }, +]; + +const salesTypeDataOffline = [ + { + x: '家用电器', + y: 99, + }, + { + x: '食用酒水', + y: 188, + }, + { + x: '个护健康', + y: 344, + }, + { + x: '服饰箱包', + y: 255, + }, + { + x: '其他', + y: 65, + }, +]; + +const offlineData = []; +for (let i = 0; i < 10; i += 1) { + offlineData.push({ + name: `Stores ${i}`, + cvr: Math.ceil(Math.random() * 9) / 10, + }); +} +const offlineChartData = []; +for (let i = 0; i < 20; i += 1) { + const date = moment(new Date().getTime() + 1000 * 60 * 30 * i).format('HH:mm'); + offlineChartData.push({ + date, + type: '客流量', + value: Math.floor(Math.random() * 100) + 10, + }); + offlineChartData.push({ + date, + type: '支付笔数', + value: Math.floor(Math.random() * 100) + 10, + }); +} + +const radarOriginData = [ + { + name: '个人', + ref: 10, + koubei: 8, + output: 4, + contribute: 5, + hot: 7, + }, + { + name: '团队', + ref: 3, + koubei: 9, + output: 6, + contribute: 3, + hot: 1, + }, + { + name: '部门', + ref: 4, + koubei: 1, + output: 6, + contribute: 5, + hot: 7, + }, +]; + +const radarData: RadarData[] = []; +const radarTitleMap = { + ref: '引用', + koubei: '口碑', + output: '产量', + contribute: '贡献', + hot: '热度', +}; +radarOriginData.forEach((item) => { + Object.keys(item).forEach((key) => { + if (key !== 'name') { + radarData.push({ + name: item.name, + label: radarTitleMap[key], + value: item[key], + }); + } + }); +}); + +const getFakeChartData: AnalysisData = { + visitData, + visitData2, + salesData, + searchData, + offlineData, + offlineChartData, + salesTypeData, + salesTypeDataOnline, + salesTypeDataOffline, + radarData, +}; + +const fakeChartData = (_: Request, res: Response) => { + return res.json({ + data: getFakeChartData, + }); +}; + +export default { + 'GET /api/fake_analysis_chart_data': fakeChartData, +}; diff --git a/src/pages/dashboard/analysis/components/Charts/Bar/index.tsx b/src/pages/dashboard/analysis/components/Charts/Bar/index.tsx new file mode 100644 index 00000000..c2650fff --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Bar/index.tsx @@ -0,0 +1,133 @@ +import { Axis, Chart, Geom, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import Debounce from 'lodash.debounce'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export type BarProps = { + title: React.ReactNode; + color?: string; + padding?: [number, number, number, number]; + height?: number; + data: { + x: string; + y: number; + }[]; + forceFit?: boolean; + autoLabel?: boolean; + style?: React.CSSProperties; +}; + +class Bar extends Component< + BarProps, + { + autoHideXLabels: boolean; + } +> { + state = { + autoHideXLabels: false, + }; + + root: HTMLDivElement | undefined = undefined; + + node: HTMLDivElement | undefined = undefined; + + resize = Debounce(() => { + if (!this.node || !this.node.parentNode) { + return; + } + const canvasWidth = (this.node.parentNode as HTMLDivElement).clientWidth; + const { data = [], autoLabel = true } = this.props; + if (!autoLabel) { + return; + } + const minWidth = data.length * 30; + const { autoHideXLabels } = this.state; + + if (canvasWidth <= minWidth) { + if (!autoHideXLabels) { + this.setState({ + autoHideXLabels: true, + }); + } + } else if (autoHideXLabels) { + this.setState({ + autoHideXLabels: false, + }); + } + }, 500); + + componentDidMount() { + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resize); + } + + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + + handleRef = (n: HTMLDivElement) => { + this.node = n; + }; + + render() { + const { + height = 1, + title, + forceFit = true, + data, + color = 'rgba(24, 144, 255, 0.85)', + padding, + } = this.props; + + const { autoHideXLabels } = this.state; + + const scale = { + x: { + type: 'cat', + }, + y: { + min: 0, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + return ( +
    +
    + {title &&

    {title}

    } + + + + + + +
    +
    + ); + } +} + +export default autoHeight()(Bar); diff --git a/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.less new file mode 100644 index 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.tsx b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx new file mode 100644 index 00000000..e860582d --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/ChartCard/index.tsx @@ -0,0 +1,97 @@ +import { Card } from 'antd'; +import type { CardProps } from 'antd/es/card'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +type totalType = () => React.ReactNode; + +const renderTotal = (total?: number | totalType | React.ReactNode) => { + if (!total && total !== 0) { + return null; + } + let totalDom; + switch (typeof total) { + case 'undefined': + totalDom = null; + break; + case 'function': + totalDom =
    {total()}
    ; + break; + default: + totalDom =
    {total}
    ; + } + return totalDom; +}; + +export type ChartCardProps = { + title: React.ReactNode; + action?: React.ReactNode; + total?: React.ReactNode | number | (() => React.ReactNode | number); + footer?: React.ReactNode; + contentHeight?: number; + avatar?: React.ReactNode; + style?: React.CSSProperties; +} & CardProps; + +class ChartCard extends React.Component { + renderContent = () => { + const { contentHeight, title, avatar, action, total, footer, children, loading } = this.props; + if (loading) { + return false; + } + return ( +
    +
    +
    {avatar}
    +
    +
    + {title} + {action} +
    + {renderTotal(total)} +
    +
    + {children && ( +
    +
    {children}
    +
    + )} + {footer && ( +
    + {footer} +
    + )} +
    + ); + }; + + render() { + const { + loading = false, + contentHeight, + title, + avatar, + action, + total, + footer, + children, + ...rest + } = this.props; + return ( + + {this.renderContent()} + + ); + } +} + +export default ChartCard; diff --git a/src/pages/dashboard/analysis/components/Charts/Field/index.less b/src/pages/dashboard/analysis/components/Charts/Field/index.less new file mode 100644 index 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.tsx b/src/pages/dashboard/analysis/components/Charts/Field/index.tsx new file mode 100644 index 00000000..a6fb64a9 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Field/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from './index.less'; + +export type FieldProps = { + label: React.ReactNode; + value: React.ReactNode; + style?: React.CSSProperties; +}; + +const Field: React.FC = ({ label, value, ...rest }) => ( +
    + {label} + {value} +
    +); + +export default Field; diff --git a/src/pages/dashboard/analysis/components/Charts/Gauge/index.tsx b/src/pages/dashboard/analysis/components/Charts/Gauge/index.tsx new file mode 100644 index 00000000..b3e5047a --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Gauge/index.tsx @@ -0,0 +1,179 @@ +import { Axis, Chart, Coord, Geom, Guide, Shape } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; + +const { Arc, Html, Line } = Guide; + +export type GaugeProps = { + title: React.ReactNode; + color?: string; + height?: number; + bgColor?: number; + percent: number; + forceFit?: boolean; + style?: React.CSSProperties; + formatter: (value: string) => string; +}; + +const defaultFormatter = (val: string): string => { + switch (val) { + case '2': + return '差'; + case '4': + return '中'; + case '6': + return '良'; + case '8': + return '优'; + default: + return ''; + } +}; + +if (Shape.registerShape) { + Shape.registerShape('point', 'pointer', { + drawShape(cfg: any, group: any) { + let point = cfg.points[0]; + point = (this as any).parsePoint(point); + const center = (this as any).parsePoint({ + x: 0, + y: 0, + }); + group.addShape('line', { + attrs: { + x1: center.x, + y1: center.y, + x2: point.x, + y2: point.y, + stroke: cfg.color, + lineWidth: 2, + lineCap: 'round', + }, + }); + return group.addShape('circle', { + attrs: { + x: center.x, + y: center.y, + r: 6, + stroke: cfg.color, + lineWidth: 3, + fill: '#fff', + }, + }); + }, + }); +} + +const Gauge: React.FC = (props) => { + const { + title, + height = 1, + percent, + forceFit = true, + formatter = defaultFormatter, + color = '#2F9CFF', + bgColor = '#F0F2F5', + } = props; + const cols = { + value: { + type: 'linear', + min: 0, + max: 10, + tickCount: 6, + nice: true, + }, + }; + const data = [{ value: percent / 10 }]; + const renderHtml = () => ` +
    +
    ${title}
    +
    + ${(data[0].value * 10).toFixed(2)}% +
    +
    `; + const textStyle: { + fontSize: number; + fill: string; + textAlign: 'center'; + } = { + fontSize: 12, + fill: 'rgba(0, 0, 0, 0.65)', + textAlign: 'center', + }; + + return ( + + + + + + + + + + + + + + + ); +}; + +export default autoHeight()(Gauge); diff --git a/src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx b/src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx new file mode 100644 index 00000000..5b20ae85 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/MiniArea/index.tsx @@ -0,0 +1,131 @@ +import type { AxisProps } from 'bizcharts'; +import { Axis, Chart, Geom, Tooltip } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export type MiniAreaProps = { + color?: string; + height?: number; + borderColor?: string; + line?: boolean; + animate?: boolean; + xAxis?: AxisProps; + forceFit?: boolean; + scale?: { + x?: { + tickCount: number; + }; + y?: { + tickCount: number; + }; + }; + yAxis?: Partial; + borderWidth?: number; + data: { + x: number | string; + y: number; + }[]; +}; + +const MiniArea: React.FC = (props) => { + const { + height = 1, + data = [], + forceFit = true, + color = 'rgba(24, 144, 255, 0.2)', + borderColor = '#1089ff', + scale = { x: {}, y: {} }, + borderWidth = 2, + line, + xAxis, + yAxis, + animate = true, + } = props; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const scaleProps = { + x: { + type: 'cat', + range: [0, 1], + ...scale.x, + }, + y: { + min: 0, + ...scale.y, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + const chartHeight = height + 54; + + return ( +
    +
    + {height > 0 && ( + + + + + + {line ? ( + + ) : ( + + )} + + )} +
    +
    + ); +}; + +export default autoHeight()(MiniArea); diff --git a/src/pages/dashboard/analysis/components/Charts/MiniBar/index.tsx b/src/pages/dashboard/analysis/components/Charts/MiniBar/index.tsx new file mode 100644 index 00000000..46be2760 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/MiniBar/index.tsx @@ -0,0 +1,54 @@ +import { Chart, Geom, Tooltip } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export type MiniBarProps = { + color?: string; + height?: number; + data: { + x: number | string; + y: number; + }[]; + forceFit?: boolean; + style?: React.CSSProperties; +}; + +const MiniBar: React.FC = (props) => { + const { height = 0, forceFit = true, color = '#1890FF', data = [] } = props; + + const scale = { + x: { + type: 'cat', + }, + y: { + min: 0, + }, + }; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + // for tooltip not to be hide + const chartHeight = height + 54; + + return ( +
    +
    + + + + +
    +
    + ); +}; +export default autoHeight()(MiniBar); diff --git a/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx b/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx new file mode 100644 index 00000000..ad0a2d2e --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/MiniProgress/index.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import styles from './index.less'; + +export 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, +}) => ( +
    + +
    + + +
    +
    +
    +
    +
    +
    +); + +export default MiniProgress; diff --git a/src/pages/dashboard/analysis/components/Charts/Pie/index.tsx b/src/pages/dashboard/analysis/components/Charts/Pie/index.tsx new file mode 100644 index 00000000..e996c1e3 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/Pie/index.tsx @@ -0,0 +1,309 @@ +import { Chart, Coord, Geom, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import { DataView } from '@antv/data-set'; +import Debounce from 'lodash.debounce'; +import { Divider } from 'antd'; +import ReactFitText from 'react-fittext'; +import classNames from 'classnames'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +export type PieProps = { + animate?: boolean; + color?: string; + colors?: string[]; + selected?: boolean; + height?: number; + margin?: [number, number, number, number]; + hasLegend?: boolean; + padding?: [number, number, number, number]; + percent?: number; + data?: { + x: string | string; + y: number; + }[]; + inner?: number; + lineWidth?: number; + forceFit?: boolean; + style?: React.CSSProperties; + className?: string; + total?: React.ReactNode | number | (() => React.ReactNode | number); + title?: React.ReactNode; + tooltip?: boolean; + valueFormat?: (value: string) => string | React.ReactNode; + subTitle?: React.ReactNode; +}; +type PieState = { + legendData: { checked: boolean; x: string; color: string; percent: number; y: string }[]; + legendBlock: boolean; +}; +class Pie extends Component { + state: PieState = { + legendData: [], + legendBlock: false, + }; + + requestRef: number | undefined = undefined; + + root: HTMLDivElement | undefined = undefined; + + chart: G2.Chart | undefined = undefined; + + // for window resize auto responsive legend + resize = Debounce(() => { + const { hasLegend } = this.props; + const { legendBlock } = this.state; + if (!hasLegend || !this.root) { + window.removeEventListener('resize', this.resize); + return; + } + if ( + this.root && + this.root.parentNode && + (this.root.parentNode as HTMLElement).clientWidth <= 380 + ) { + if (!legendBlock) { + this.setState({ + legendBlock: true, + }); + } + } else if (legendBlock) { + this.setState({ + legendBlock: false, + }); + } + }, 400); + + componentDidMount() { + window.addEventListener( + 'resize', + () => { + this.requestRef = requestAnimationFrame(() => this.resize()); + }, + { passive: true }, + ); + } + + componentDidUpdate(preProps: PieProps) { + const { data } = this.props; + if (data !== preProps.data) { + // because of charts data create when rendered + // so there is a trick for get rendered time + this.getLegendData(); + } + } + + componentWillUnmount() { + if (this.requestRef) { + window.cancelAnimationFrame(this.requestRef); + } + window.removeEventListener('resize', this.resize); + if (this.resize) { + (this.resize as any).cancel(); + } + } + + getG2Instance = (chart: G2.Chart) => { + this.chart = chart; + requestAnimationFrame(() => { + this.getLegendData(); + this.resize(); + }); + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + const items = (geom as any).get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map((item: { color: any; _origin: any }[]) => { + /* eslint no-underscore-dangle:0 */ + const origin = item[0]._origin; + origin.color = item[0].color; + origin.checked = true; + return origin; + }); + + this.setState({ + legendData, + }); + }; + + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + + handleLegendClick = (item: any, i: string | number) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter((l) => l.checked).map((l) => l.x); + + if (this.chart) { + this.chart.filter('x', (val) => filteredLegendData.indexOf(`${val}`) > -1); + } + + this.setState({ + legendData, + }); + }; + + render() { + const { + valueFormat, + subTitle, + total, + hasLegend = false, + className, + style, + height = 0, + forceFit = true, + percent, + color, + inner = 0.75, + animate = true, + colors, + lineWidth = 1, + } = this.props; + + const { legendData, legendBlock } = this.state; + const pieClassName = classNames(styles.pie, className, { + [styles.hasLegend]: !!hasLegend, + [styles.legendBlock]: legendBlock, + }); + + const { + data: propsData, + selected: propsSelected = true, + tooltip: propsTooltip = true, + } = this.props; + + let data = propsData || []; + let selected = propsSelected; + let tooltip = propsTooltip; + + const defaultColors = colors; + data = data || []; + selected = selected || true; + tooltip = tooltip || true; + let formatColor; + + const scale = { + x: { + type: 'cat', + range: [0, 1], + }, + y: { + min: 0, + }, + }; + + if (percent || percent === 0) { + selected = false; + tooltip = false; + formatColor = (value: string) => { + if (value === '占比') { + return color || 'rgba(24, 144, 255, 0.85)'; + } + return '#F0F2F5'; + }; + + data = [ + { + x: '占比', + y: parseFloat(`${percent}`), + }, + { + x: '反比', + y: 100 - parseFloat(`${percent}`), + }, + ]; + } + + const tooltipFormat: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*percent', + (x: string, p: number) => ({ + name: x, + value: `${(p * 100).toFixed(2)}%`, + }), + ]; + + const padding = [12, 0, 12, 0] as [number, number, number, number]; + + const dv = new DataView(); + dv.source(data).transform({ + type: 'percent', + field: 'y', + dimension: 'x', + as: 'percent', + }); + + return ( +
    + +
    + + {!!tooltip && } + + + + + {(subTitle || total) && ( +
    + {subTitle &&

    {subTitle}

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

    {title}

    } + + + + + + +
    + +
    +
    +
    + ); +}; + +export default autoHeight()(TimelineChart); diff --git a/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx b/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx new file mode 100644 index 00000000..a9b64110 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/WaterWave/index.tsx @@ -0,0 +1,235 @@ +import React, { Component } from 'react'; + +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-return-assign: 0 */ +/* eslint no-mixed-operators: 0 */ +// riddle: https://riddle.alibaba-inc.com/riddles/2d9a4b90 + +export type WaterWaveProps = { + title: React.ReactNode; + color?: string; + height?: number; + percent: number; + style?: React.CSSProperties; +}; + +class WaterWave extends Component { + 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/autoHeight.tsx b/src/pages/dashboard/analysis/components/Charts/autoHeight.tsx new file mode 100644 index 00000000..5ec7bf39 --- /dev/null +++ b/src/pages/dashboard/analysis/components/Charts/autoHeight.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +export type IReactComponent

    = + | React.StatelessComponent

    + | React.ComponentClass

    + | React.ClassicComponentClass

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

    ( + WrappedComponent: React.ComponentClass

    | React.FC

    , + ): React.ComponentClass

    => { + class AutoHeightComponent extends React.Component

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

    + {h > 0 && } +
    + ); + } + } + return AutoHeightComponent; + }; +} +export default autoHeight; diff --git a/src/pages/dashboard/analysis/components/Charts/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.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..cf0f3fd0 --- /dev/null +++ b/src/pages/dashboard/analysis/components/IntroduceRow.tsx @@ -0,0 +1,135 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { TinyArea, TinyColumn, Progress } from '@ant-design/charts'; +import { Col, Row, Tooltip } from 'antd'; + +import numeral from 'numeral'; +import { ChartCard, Field } from './Charts'; +import type { DataItem } from '../data.d'; +import Trend from './Trend'; +import Yuan from '../utils/Yuan'; +import styles from '../style.less'; + +const topColResponsiveProps = { + xs: 24, + sm: 12, + md: 12, + lg: 12, + xl: 6, + style: { marginBottom: 24 }, +}; + +const IntroduceRow = ({ loading, visitData }: { loading: boolean; visitData: DataItem[] }) => ( + + + + + + } + 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.tsx b/src/pages/dashboard/analysis/components/NumberInfo/index.tsx new file mode 100644 index 00000000..60b4e946 --- /dev/null +++ b/src/pages/dashboard/analysis/components/NumberInfo/index.tsx @@ -0,0 +1,62 @@ +import { CaretUpOutlined, CaretDownOutlined } from '@ant-design/icons'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export type NumberInfoProps = { + title?: React.ReactNode | string; + subTitle?: React.ReactNode | string; + total?: React.ReactNode | string; + status?: 'up' | 'down'; + theme?: string; + gap?: number; + subTotal?: number; + suffix?: string; + style?: React.CSSProperties; +}; +const NumberInfo: React.FC = ({ + theme, + title, + subTitle, + total, + subTotal, + status, + suffix, + gap, + ...rest +}) => ( +
    + {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..eca987bd --- /dev/null +++ b/src/pages/dashboard/analysis/components/OfflineData.tsx @@ -0,0 +1,76 @@ +import { Card, Col, Row, Tabs } from 'antd'; +import { RingProgress, Line } from '@ant-design/charts'; +import type { OfflineDataType, DataItem } from '../data.d'; + +import NumberInfo from './NumberInfo'; +import styles from '../style.less'; + +const CustomTab = ({ + data, + currentTabKey: currentKey, +}: { + data: OfflineDataType; + currentTabKey: string; +}) => ( + + + + + + + + +); + +const { TabPane } = Tabs; + +const OfflineData = ({ + activeKey, + loading, + offlineData, + offlineChartData, + handleTabChange, +}: { + activeKey: string; + loading: boolean; + offlineData: OfflineDataType[]; + offlineChartData: DataItem[]; + handleTabChange: (activeKey: string) => void; +}) => ( + + + {offlineData.map((shop) => ( + } key={shop.name}> +
    + +
    +
    + ))} +
    +
    +); + +export default OfflineData; diff --git a/src/pages/dashboard/analysis/components/PageLoading/index.tsx b/src/pages/dashboard/analysis/components/PageLoading/index.tsx new file mode 100644 index 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..f96d0ea1 --- /dev/null +++ b/src/pages/dashboard/analysis/components/ProportionSales.tsx @@ -0,0 +1,76 @@ +import { Card, Radio, Typography } from 'antd'; +import numeral from 'numeral'; +import type { RadioChangeEvent } from 'antd/es/radio'; +import { Donut } from '@ant-design/charts'; +import type { DonutConfig } from '@ant-design/charts/es/donut'; +import React from 'react'; +import type { DataItem } from '../data.d'; +import styles from '../style.less'; + +const { Text } = Typography; + +const ProportionSales = ({ + dropdownGroup, + salesType, + loading, + salesPieData, + handleChangeSalesType, +}: { + loading: boolean; + dropdownGroup: React.ReactNode; + salesType: 'all' | 'online' | 'stores'; + salesPieData: DataItem[]; + handleChangeSalesType?: (e: RadioChangeEvent) => void; +}) => ( + + {dropdownGroup} +
    + + 全部渠道 + 线上 + 门店 + +
    +
    + } + > +
    + 销售额 + { + // eslint-disable-next-line no-underscore-dangle + return `${item._origin.x}: ${numeral(item._origin.y).format('0,0')}`; + }, + }} + statistic={ + { + totalLabel: '销售额', + } as DonutConfig['statistic'] + } + /> +
    + +); + +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..152d9cef --- /dev/null +++ b/src/pages/dashboard/analysis/components/SalesCard.tsx @@ -0,0 +1,189 @@ +import { Card, Col, DatePicker, Row, Tabs } from 'antd'; +import type { RangePickerProps } from 'antd/es/date-picker/generatePicker'; +import type moment from 'moment'; +import { Column } from '@ant-design/charts'; + +import numeral from 'numeral'; +import type { DataItem } from '../data.d'; +import styles from '../style.less'; + +type RangePickerValue = RangePickerProps['value']; +export type TimeType = 'today' | 'week' | 'month' | 'year'; + +const { RangePicker } = DatePicker; +const { TabPane } = Tabs; + +const rankingListData: { title: string; total: number }[] = []; +for (let i = 0; i < 7; i += 1) { + rankingListData.push({ + title: `工专路 ${i} 号店`, + total: 323234, + }); +} + +const SalesCard = ({ + rangePickerValue, + salesData, + isActive, + handleRangePickerChange, + loading, + selectDate, +}: { + rangePickerValue: RangePickerValue; + isActive: (key: TimeType) => string; + salesData: DataItem[]; + loading: boolean; + handleRangePickerChange: (dates: RangePickerValue, dateStrings: [string, string]) => void; + selectDate: (key: TimeType) => void; +}) => ( + + + } + size="large" + tabBarStyle={{ marginBottom: 24 }} + > + + + +
    + +
    + + +
    +

    门店销售额排名

    +
      + {rankingListData.map((item, i) => ( +
    • + + {i + 1} + + + {item.title} + + + {numeral(item.total).format('0,0')} + +
    • + ))} +
    +
    + +
    +
    + + + +
    + +
    + + +
    +

    门店访问量排名

    +
      + {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..48bfe03a --- /dev/null +++ b/src/pages/dashboard/analysis/components/TopSearch.tsx @@ -0,0 +1,113 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Card, Col, Row, Table, Tooltip } from 'antd'; +import { TinyArea } from '@ant-design/charts'; +import React from 'react'; +import numeral from 'numeral'; +import type { DataItem } from '../data.d'; + +import NumberInfo from './NumberInfo'; +import Trend from './Trend'; +import styles from '../style.less'; + +const columns = [ + { + title: '排名', + dataIndex: 'index', + key: 'index', + }, + { + title: '搜索关键词', + dataIndex: 'keyword', + key: 'keyword', + render: (text: React.ReactNode) => {text}, + }, + { + title: '用户数', + dataIndex: 'count', + key: 'count', + sorter: (a: { count: number }, b: { count: number }) => a.count - b.count, + className: styles.alignRight, + }, + { + title: '周涨幅', + dataIndex: 'range', + key: 'range', + sorter: (a: { range: number }, b: { range: number }) => a.range - b.range, + render: (text: React.ReactNode, record: { status: number }) => ( + + {text}% + + ), + }, +]; + +const TopSearch = ({ + loading, + visitData2, + searchData, + dropdownGroup, +}: { + loading: boolean; + visitData2: DataItem[]; + dropdownGroup: React.ReactNode; + searchData: DataItem[]; +}) => ( + + + + + 搜索用户数 + + + + + } + 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.tsx b/src/pages/dashboard/analysis/components/Trend/index.tsx new file mode 100644 index 00000000..58f7196a --- /dev/null +++ b/src/pages/dashboard/analysis/components/Trend/index.tsx @@ -0,0 +1,42 @@ +import { CaretUpOutlined, CaretDownOutlined } from '@ant-design/icons'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export type TrendProps = { + colorful?: boolean; + flag: 'up' | 'down'; + style?: React.CSSProperties; + reverseColor?: boolean; + className?: string; +}; + +const Trend: React.FC = ({ + colorful = true, + reverseColor = false, + flag, + children, + className, + ...rest +}) => { + 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..7604d10d --- /dev/null +++ b/src/pages/dashboard/analysis/data.d.ts @@ -0,0 +1,46 @@ +import { DataItem } from '@antv/g2plot/esm/interface/config'; + +export { DataItem }; + +export interface VisitDataType { + x: string; + y: number; +} + +export type SearchDataType = { + index: number; + keyword: string; + count: number; + range: number; + status: number; +}; + +export type OfflineDataType = { + name: string; + cvr: number; +}; + +export interface OfflineChartData { + date: number; + type: number; + value: number; +} + +export type RadarData = { + name: string; + label: string; + value: number; +}; + +export interface AnalysisData { + visitData: DataItem[]; + visitData2: DataItem[]; + salesData: DataItem[]; + searchData: DataItem[]; + offlineData: OfflineDataType[]; + offlineChartData: DataItem[]; + salesTypeData: DataItem[]; + salesTypeDataOnline: DataItem[]; + salesTypeDataOffline: DataItem[]; + radarData: RadarData[]; +} diff --git a/src/pages/dashboard/analysis/index.tsx b/src/pages/dashboard/analysis/index.tsx new file mode 100644 index 00000000..331d7e6a --- /dev/null +++ b/src/pages/dashboard/analysis/index.tsx @@ -0,0 +1,162 @@ +import type { FC } from 'react'; +import { Suspense, useState } from 'react'; +import { EllipsisOutlined } from '@ant-design/icons'; +import { Col, Dropdown, Menu, Row } from 'antd'; +import { GridContent } from '@ant-design/pro-layout'; +import type { RadioChangeEvent } from 'antd/es/radio'; +import type { RangePickerProps } from 'antd/es/date-picker/generatePicker'; +import type moment from 'moment'; +import IntroduceRow from './components/IntroduceRow'; +import SalesCard from './components/SalesCard'; +import TopSearch from './components/TopSearch'; +import ProportionSales from './components/ProportionSales'; +import OfflineData from './components/OfflineData'; +import { useRequest } from 'umi'; + +import { fakeChartData } from './service'; +import PageLoading from './components/PageLoading'; +import type { TimeType } from './components/SalesCard'; +import { getTimeDistance } from './utils/utils'; +import type { AnalysisData } from './data.d'; +import styles from './style.less'; + +type RangePickerValue = RangePickerProps['value']; + +type AnalysisProps = { + dashboardAndanalysis: AnalysisData; + loading: boolean; +}; + +type SalesType = 'all' | 'online' | 'stores'; + +const Analysis: FC = () => { + 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 moment.Moment, 'day') && + rangePickerValue[1].isSame(value[1] as moment.Moment, 'day') + ) { + return styles.currentDate; + } + return ''; + }; + + let salesPieData; + if (salesType === 'all') { + salesPieData = data?.salesTypeData; + } else { + salesPieData = salesType === 'online' ? data?.salesTypeDataOnline : data?.salesTypeDataOffline; + } + + const menu = ( + + 操作一 + 操作二 + + ); + + const dropdownGroup = ( + + + + + + ); + + const handleChangeSalesType = (e: RadioChangeEvent) => { + setSalesType(e.target.value); + }; + + const handleTabChange = (key: string) => { + setCurrentTabKey(key); + }; + + const activeKey = currentTabKey || (data?.offlineData[0] && data?.offlineData[0].name) || ''; + + return ( + + <> + }> + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +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..c8a6b08a --- /dev/null +++ b/src/pages/dashboard/analysis/service.ts @@ -0,0 +1,6 @@ +import { request } from 'umi'; +import type { AnalysisData } from './data'; + +export async function fakeChartData(): Promise<{ data: AnalysisData }> { + return request('/api/fake_analysis_chart_data'); +} 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/utils/Yuan.tsx b/src/pages/dashboard/analysis/utils/Yuan.tsx new file mode 100644 index 00000000..7027d3c3 --- /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: React.ReactText; +}> { + main: HTMLSpanElement | undefined | null = null; + + componentDidMount() { + this.renderToHtml(); + } + + componentDidUpdate() { + this.renderToHtml(); + } + + renderToHtml = () => { + const { children } = this.props; + if (this.main) { + this.main.innerHTML = yuan(children); + } + }; + + render() { + return ( + { + this.main = ref; + }} + /> + ); + } +} diff --git a/src/pages/dashboard/analysis/utils/utils.less b/src/pages/dashboard/analysis/utils/utils.less new file mode 100644 index 00000000..de1aa642 --- /dev/null +++ b/src/pages/dashboard/analysis/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + margin-right: -1em; + padding-right: 1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: 0.2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} diff --git a/src/pages/dashboard/analysis/utils/utils.ts b/src/pages/dashboard/analysis/utils/utils.ts new file mode 100644 index 00000000..6a0da9a2 --- /dev/null +++ b/src/pages/dashboard/analysis/utils/utils.ts @@ -0,0 +1,52 @@ +import moment from 'moment'; +import type { RangePickerProps } from 'antd/es/date-picker/generatePicker'; + +type RangePickerValue = RangePickerProps['value']; + +export function fixedZero(val: number) { + return val * 1 < 10 ? `0${val}` : val; +} + +export function getTimeDistance(type: 'today' | 'week' | 'month' | 'year'): RangePickerValue { + const now = new Date(); + const oneDay = 1000 * 60 * 60 * 24; + + if (type === 'today') { + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + return [moment(now), moment(now.getTime() + (oneDay - 1000))]; + } + + if (type === 'week') { + let day = now.getDay(); + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + + if (day === 0) { + day = 6; + } else { + day -= 1; + } + + const beginTime = now.getTime() - day * oneDay; + + return [moment(beginTime), moment(beginTime + (7 * oneDay - 1000))]; + } + const year = now.getFullYear(); + + if (type === 'month') { + const month = now.getMonth(); + const nextDate = moment(now).add(1, 'months'); + const nextYear = nextDate.year(); + const nextMonth = nextDate.month(); + + return [ + moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), + moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000), + ]; + } + + return [moment(`${year}-01-01 00:00:00`), moment(`${year}-12-31 23:59:59`)]; +} diff --git a/src/pages/dashboard/monitor/_mock.ts b/src/pages/dashboard/monitor/_mock.ts new file mode 100644 index 00000000..7ab870eb --- /dev/null +++ b/src/pages/dashboard/monitor/_mock.ts @@ -0,0 +1,14 @@ +import mockjs from 'mockjs'; +import type { Request, Response } from 'express'; + +const getTags = (_: Request, res: Response) => { + return res.json({ + data: mockjs.mock({ + 'list|100': [{ name: '@city', 'value|1-100': 150, 'type|0-2': 1 }], + }), + }); +}; + +export default { + 'GET /api/tags': getTags, +}; 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.tsx b/src/pages/dashboard/monitor/components/ActiveChart/index.tsx new file mode 100644 index 00000000..9f0df840 --- /dev/null +++ b/src/pages/dashboard/monitor/components/ActiveChart/index.tsx @@ -0,0 +1,90 @@ +import { Component } from 'react'; +import { TinyArea } from '@ant-design/charts'; + +import { Statistic } from 'antd'; +import styles from './index.less'; + +function fixedZero(val: number) { + return val * 1 < 10 ? `0${val}` : val; +} + +function getActiveData() { + const activeData = []; + for (let i = 0; i < 24; i += 1) { + activeData.push({ + x: `${fixedZero(i)}:00`, + y: Math.floor(Math.random() * 200) + i * 50, + }); + } + return activeData; +} + +export default class ActiveChart extends Component { + state = { + activeData: getActiveData(), + }; + + timer: number | undefined = undefined; + + requestRef: number | undefined = undefined; + + componentDidMount() { + this.loopData(); + } + + componentWillUnmount() { + clearTimeout(this.timer); + if (this.requestRef) { + cancelAnimationFrame(this.requestRef); + } + } + + loopData = () => { + this.requestRef = requestAnimationFrame(() => { + this.timer = window.setTimeout(() => { + this.setState( + { + activeData: getActiveData(), + }, + () => { + this.loopData(); + }, + ); + }, 1000); + }); + }; + + render() { + const { activeData = [] } = this.state; + + return ( +
    + +
    + +
    + {activeData && ( +
    +
    +

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

    +

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

    +
    +
    +
    +
    +
    +
    +
    +
    + )} + {activeData && ( +
    + 00:00 + {activeData[Math.floor(activeData.length / 2)].x} + {activeData[activeData.length - 1].x} +
    + )} +
    + ); + } +} diff --git a/src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx b/src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx new file mode 100644 index 00000000..a4975fa6 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/Gauge/index.tsx @@ -0,0 +1,180 @@ +import { Axis, Chart, Coord, Geom, Guide, Shape } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; + +const { Arc, Html, Line } = Guide; + +export type GaugeProps = { + title: React.ReactNode; + color?: string; + height?: number; + bgColor?: number; + percent: number; + forceFit?: boolean; + style?: React.CSSProperties; + formatter?: (value: string) => string; +}; + +const defaultFormatter = (val: string): string => { + switch (val) { + case '2': + return '差'; + case '4': + return '中'; + case '6': + return '良'; + case '8': + return '优'; + default: + return ''; + } +}; + +if (Shape.registerShape) { + Shape.registerShape('point', 'pointer', { + drawShape(cfg: any, group: any) { + let point = cfg.points[0]; + point = (this as any).parsePoint(point); + const center = (this as any).parsePoint({ + x: 0, + y: 0, + }); + group.addShape('line', { + attrs: { + x1: center.x, + y1: center.y, + x2: point.x, + y2: point.y, + stroke: cfg.color, + lineWidth: 2, + lineCap: 'round', + }, + }); + return group.addShape('circle', { + attrs: { + x: center.x, + y: center.y, + r: 6, + stroke: cfg.color, + lineWidth: 3, + fill: '#fff', + }, + }); + }, + }); +} + +const Gauge: React.FC = (props) => { + const { + title, + height = 1, + percent, + forceFit = true, + formatter = defaultFormatter, + color = '#2F9CFF', + bgColor = '#F0F2F5', + } = props; + const cols = { + value: { + type: 'linear', + min: 0, + max: 10, + tickCount: 6, + nice: true, + }, + }; + const data = [{ value: percent / 10 }]; + + const renderHtml = () => ` +
    +
    ${title}
    +
    + ${(data[0].value * 10).toFixed(2)}% +
    +
    `; + + const textStyle: { + fontSize: number; + fill: string; + textAlign: 'center'; + } = { + fontSize: 12, + fill: 'rgba(0, 0, 0, 0.65)', + textAlign: 'center', + }; + return ( + + + + + + + + + + + + + + + ); +}; + +export default autoHeight()(Gauge); diff --git a/src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx b/src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx new file mode 100644 index 00000000..5b20ae85 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/MiniArea/index.tsx @@ -0,0 +1,131 @@ +import type { AxisProps } from 'bizcharts'; +import { Axis, Chart, Geom, Tooltip } from 'bizcharts'; + +import React from 'react'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export type MiniAreaProps = { + color?: string; + height?: number; + borderColor?: string; + line?: boolean; + animate?: boolean; + xAxis?: AxisProps; + forceFit?: boolean; + scale?: { + x?: { + tickCount: number; + }; + y?: { + tickCount: number; + }; + }; + yAxis?: Partial; + borderWidth?: number; + data: { + x: number | string; + y: number; + }[]; +}; + +const MiniArea: React.FC = (props) => { + const { + height = 1, + data = [], + forceFit = true, + color = 'rgba(24, 144, 255, 0.2)', + borderColor = '#1089ff', + scale = { x: {}, y: {} }, + borderWidth = 2, + line, + xAxis, + yAxis, + animate = true, + } = props; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const scaleProps = { + x: { + type: 'cat', + range: [0, 1], + ...scale.x, + }, + y: { + min: 0, + ...scale.y, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + const chartHeight = height + 54; + + return ( +
    +
    + {height > 0 && ( + + + + + + {line ? ( + + ) : ( + + )} + + )} +
    +
    + ); +}; + +export default autoHeight()(MiniArea); diff --git a/src/pages/dashboard/monitor/components/Charts/Pie/index.tsx b/src/pages/dashboard/monitor/components/Charts/Pie/index.tsx new file mode 100644 index 00000000..520c5c57 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/Pie/index.tsx @@ -0,0 +1,310 @@ +import { Chart, Coord, Geom, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import { DataView } from '@antv/data-set'; +import Debounce from 'lodash.debounce'; +import { Divider } from 'antd'; +import ReactFitText from 'react-fittext'; +import classNames from 'classnames'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +export type PieProps = { + animate?: boolean; + color?: string; + colors?: string[]; + selected?: boolean; + height?: number; + margin?: [number, number, number, number]; + hasLegend?: boolean; + padding?: [number, number, number, number]; + percent?: number; + data?: { + x: string | string; + y: number; + }[]; + inner?: number; + lineWidth?: number; + forceFit?: boolean; + style?: React.CSSProperties; + className?: string; + total?: React.ReactNode | number | (() => React.ReactNode | number); + title?: React.ReactNode; + tooltip?: boolean; + valueFormat?: (value: string) => string | React.ReactNode; + subTitle?: React.ReactNode; +}; +type PieState = { + legendData: { checked: boolean; x: string; color: string; percent: number; y: string }[]; + legendBlock: boolean; +}; +class Pie extends Component { + state: PieState = { + legendData: [], + legendBlock: false, + }; + + chart: G2.Chart | undefined = undefined; + + root: HTMLDivElement | undefined = undefined; + + requestRef: number | undefined = 0; + + // for window resize auto responsive legend + resize = Debounce(() => { + const { hasLegend } = this.props; + const { legendBlock } = this.state; + if (!hasLegend || !this.root) { + window.removeEventListener('resize', this.resize); + return; + } + if ( + this.root && + this.root.parentNode && + (this.root.parentNode as HTMLElement).clientWidth <= 380 + ) { + if (!legendBlock) { + this.setState({ + legendBlock: true, + }); + } + } else if (legendBlock) { + this.setState({ + legendBlock: false, + }); + } + }, 300); + + componentDidMount() { + window.addEventListener( + 'resize', + () => { + this.requestRef = requestAnimationFrame(() => this.resize()); + }, + { passive: true }, + ); + } + + componentDidUpdate(preProps: PieProps) { + const { data } = this.props; + if (data !== preProps.data) { + // because of charts data create when rendered + // so there is a trick for get rendered time + this.getLegendData(); + } + } + + componentWillUnmount() { + if (this.requestRef) { + window.cancelAnimationFrame(this.requestRef); + } + window.removeEventListener('resize', this.resize); + if (this.resize) { + (this.resize as any).cancel(); + } + } + + getG2Instance = (chart: G2.Chart) => { + this.chart = chart; + requestAnimationFrame(() => { + this.getLegendData(); + this.resize(); + }); + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + // g2 的类型有问题 + const items = (geom as any).get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map((item: { color: any; _origin: any }[]) => { + /* eslint no-underscore-dangle:0 */ + const origin = item[0]._origin; + origin.color = item[0].color; + origin.checked = true; + return origin; + }); + + this.setState({ + legendData, + }); + }; + + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + + handleLegendClick = (item: { checked: boolean }, i: string | number) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter((l) => l.checked).map((l) => l.x); + + if (this.chart) { + this.chart.filter('x', (val) => filteredLegendData.indexOf(`${val}`) > -1); + } + + this.setState({ + legendData, + }); + }; + + render() { + const { + valueFormat, + subTitle, + total, + hasLegend = false, + className, + style, + height = 0, + forceFit = true, + percent, + color, + inner = 0.75, + animate = true, + colors, + lineWidth = 1, + } = this.props; + + const { legendData, legendBlock } = this.state; + const pieClassName = classNames(styles.pie, className, { + [styles.hasLegend]: !!hasLegend, + [styles.legendBlock]: legendBlock, + }); + + const { + data: propsData, + selected: propsSelected = true, + tooltip: propsTooltip = true, + } = this.props; + + let data = propsData || []; + let selected = propsSelected; + let tooltip = propsTooltip; + + const defaultColors = colors; + data = data || []; + selected = selected || true; + tooltip = tooltip || true; + let formatColor; + + const scale = { + x: { + type: 'cat', + range: [0, 1], + }, + y: { + min: 0, + }, + }; + + if (percent || percent === 0) { + selected = false; + tooltip = false; + formatColor = (value: string) => { + if (value === '占比') { + return color || 'rgba(24, 144, 255, 0.85)'; + } + return '#F0F2F5'; + }; + + data = [ + { + x: '占比', + y: parseFloat(`${percent}`), + }, + { + x: '反比', + y: 100 - parseFloat(`${percent}`), + }, + ]; + } + + const tooltipFormat: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*percent', + (x: string, p: number) => ({ + name: x, + value: `${(p * 100).toFixed(2)}%`, + }), + ]; + + const padding = [12, 0, 12, 0] as [number, number, number, number]; + + const dv = new DataView(); + dv.source(data).transform({ + type: 'percent', + field: 'y', + dimension: 'x', + as: 'percent', + }); + + return ( +
    + +
    + + {!!tooltip && } + + + + + {(subTitle || total) && ( +
    + {subTitle &&

    {subTitle}

    } + {/* eslint-disable-next-line */} + {total && ( +
    {typeof total === 'function' ? total() : total}
    + )} +
    + )} +
    +
    + + {hasLegend && ( +
      + {legendData.map((item, i) => ( +
    • this.handleLegendClick(item, i)}> + + {item.x} + + + {`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`} + + {valueFormat ? valueFormat(item.y) : item.y} +
    • + ))} +
    + )} +
    + ); + } +} + +export default autoHeight()(Pie); diff --git a/src/pages/dashboard/monitor/components/Charts/TagCloud/index.tsx b/src/pages/dashboard/monitor/components/Charts/TagCloud/index.tsx new file mode 100644 index 00000000..f3ee5192 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/TagCloud/index.tsx @@ -0,0 +1,211 @@ +import { Chart, Coord, Geom, Shape, Tooltip } from 'bizcharts'; +import React, { Component } from 'react'; + +import DataSet from '@antv/data-set'; +import Debounce from 'lodash.debounce'; +import classNames from 'classnames'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-underscore-dangle: 0 */ +/* eslint no-param-reassign: 0 */ + +const imgUrl = 'https://gw.alipayobjects.com/zos/rmsportal/gWyeGLCdFFRavBGIDzWk.png'; + +export type TagCloudProps = { + data: { + name: string; + value: string; + }[]; + height?: number; + className?: string; + style?: React.CSSProperties; +}; + +type TagCloudState = { + dv: any; + height?: number; + width: number; +}; + +class TagCloud extends Component { + state = { + dv: null, + height: 0, + width: 0, + }; + + requestRef: number = 0; + + isUnmount: boolean = false; + + root: HTMLDivElement | undefined = undefined; + + imageMask: HTMLImageElement | undefined = undefined; + + componentDidMount() { + requestAnimationFrame(() => { + this.initTagCloud(); + this.renderChart(this.props); + }); + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentDidUpdate(preProps?: TagCloudProps) { + const { data } = this.props; + if (preProps && JSON.stringify(preProps.data) !== JSON.stringify(data)) { + this.renderChart(this.props); + } + } + + componentWillUnmount() { + this.isUnmount = true; + window.cancelAnimationFrame(this.requestRef); + window.removeEventListener('resize', this.resize); + } + + resize = () => { + this.requestRef = requestAnimationFrame(() => { + this.renderChart(this.props); + }); + }; + + saveRootRef = (node: HTMLDivElement) => { + this.root = node; + }; + + initTagCloud = () => { + function getTextAttrs(cfg: { + x?: any; + y?: any; + style?: any; + opacity?: any; + origin?: any; + color?: any; + }) { + return { + ...cfg.style, + fillOpacity: cfg.opacity, + fontSize: cfg.origin._origin.size, + rotate: cfg.origin._origin.rotate, + text: cfg.origin._origin.text, + textAlign: 'center', + fontFamily: cfg.origin._origin.font, + fill: cfg.color, + textBaseline: 'Alphabetic', + }; + } + + (Shape as any).registerShape('point', 'cloud', { + drawShape( + cfg: { x: any; y: any }, + container: { addShape: (arg0: string, arg1: { attrs: any }) => void }, + ) { + const attrs = getTextAttrs(cfg); + return container.addShape('text', { + attrs: { + ...attrs, + x: cfg.x, + y: cfg.y, + }, + }); + }, + }); + }; + + renderChart = Debounce((nextProps: TagCloudProps) => { + // const colors = ['#1890FF', '#41D9C7', '#2FC25B', '#FACC14', '#9AE65C']; + const { data, height } = nextProps || this.props; + if (data.length < 1 || !this.root) { + return; + } + + const h = height; + const w = this.root.offsetWidth; + + const onload = () => { + const dv = new DataSet.View().source(data); + const range = dv.range('value'); + const [min, max] = range; + dv.transform({ + type: 'tag-cloud', + fields: ['name', 'value'], + imageMask: this.imageMask, + font: 'Verdana', + size: [w, h], // 宽高设置最好根据 imageMask 做调整 + padding: 0, + timeInterval: 5000, // max execute time + rotate() { + return 0; + }, + fontSize(d: { value: number }) { + const size = ((d.value - min) / (max - min)) ** 2; + return size * (17.5 - 5) + 5; + }, + }); + + if (this.isUnmount) { + return; + } + + this.setState({ + dv, + width: w, + height: h, + }); + }; + + if (!this.imageMask) { + this.imageMask = new Image(); + this.imageMask.crossOrigin = ''; + this.imageMask.src = imgUrl; + + this.imageMask.onload = onload; + } else { + onload(); + } + }, 200); + + render() { + const { className, height } = this.props; + const { dv, width, height: stateHeight } = this.state; + + return ( +
    + {dv && ( + + + + + + )} +
    + ); + } +} + +export default autoHeight()(TagCloud); diff --git a/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx b/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx new file mode 100644 index 00000000..a9b64110 --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/WaterWave/index.tsx @@ -0,0 +1,235 @@ +import React, { Component } from 'react'; + +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-return-assign: 0 */ +/* eslint no-mixed-operators: 0 */ +// riddle: https://riddle.alibaba-inc.com/riddles/2d9a4b90 + +export type WaterWaveProps = { + title: React.ReactNode; + color?: string; + height?: number; + percent: number; + style?: React.CSSProperties; +}; + +class WaterWave extends Component { + 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..f37acace --- /dev/null +++ b/src/pages/dashboard/monitor/components/Charts/autoHeight.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +export type IReactComponent

    = + | React.StatelessComponent

    + | React.ComponentClass

    + | React.ClassicComponentClass

    ; + +function computeHeight(node: HTMLDivElement) { + const { style } = node; + style.height = '100%'; + const totalHeight = parseInt(`${getComputedStyle(node).height}`, 10); + const padding = + parseInt(`${getComputedStyle(node).paddingTop}`, 10) + + parseInt(`${getComputedStyle(node).paddingBottom}`, 10); + return totalHeight - padding; +} + +function getAutoHeight(n: HTMLDivElement) { + if (!n) { + return 0; + } + + const node = n; + + let height = computeHeight(node); + const parentNode = node.parentNode as HTMLDivElement; + if (parentNode) { + height = computeHeight(parentNode); + } + + return height; +} + +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..ff9d924f --- /dev/null +++ b/src/pages/dashboard/monitor/components/Map/index.tsx @@ -0,0 +1,145 @@ +import * as React from 'react'; +import { HeatmapLayer, MapboxScene, PointLayer } from '@antv/l7-react'; +import { PageLoading } from '@ant-design/pro-layout'; + +const colors = ['#eff3ff', '#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#084594']; +export default class Map extends React.Component { + state = { + data: null, + grid: null, + loading: false, + }; + + public async componentDidMount() { + const [geoData, gridData] = await Promise.all([ + fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/c5dba875-b6ea-4e88-b778-66a862906c93.json', + ).then((d) => d.json()), + fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/8990e8b4-c58e-419b-afb9-8ea3daff2dd1.json', + ).then((d) => d.json()), + ]); + this.setState({ + data: geoData, + grid: gridData, + loading: true, + }); + } + + public render() { + const { data, grid, loading } = this.state; + return loading === false ? ( + + ) : ( + + {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..779d200a --- /dev/null +++ b/src/pages/dashboard/monitor/index.tsx @@ -0,0 +1,152 @@ +import { Card, Col, Row, Statistic } from 'antd'; +import { useRequest } from 'umi'; +import type { FC } from 'react'; +import { Gauge, WordCloud, Liquid, RingProgress } from '@ant-design/charts'; +import type { WordCloudData } from '@antv/g2plot/esm/plots/word-cloud/layer'; + +import { GridContent } from '@ant-design/pro-layout'; +import numeral from 'numeral'; +import Map from './components/Map'; +import ActiveChart from './components/ActiveChart'; +import { queryTags } from './service'; +import styles from './style.less'; + +const { Countdown } = Statistic; + +const deadline = Date.now() + 1000 * 60 * 60 * 24 * 2 + 1000 * 30; // Moment is also OK + +const Monitor: FC = () => { + const { loading, data } = useRequest(queryTags); + + const wordCloudData: WordCloudData[] = (data?.list || []).map((item) => { + return { + id: +Date.now(), + word: item.name, + weight: item.value, + }; + }); + + return ( + + <> + + + + + + + + + + + + + + + + + +
    + +
    +
    + + + + + + + + + +
    + + + + + + + {/* */} + + + + + + + + + + + + + + {/* */} + + + + + `${((100 * value) / 10000).toFixed(1)}%`, + }} + /> + + + + +
    + ); +}; + +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..431c4106 --- /dev/null +++ b/src/pages/dashboard/monitor/service.ts @@ -0,0 +1,6 @@ +import { request } from 'umi'; +import type { TagType } from './data'; + +export async function queryTags(): Promise<{ data: { list: TagType[] } }> { + return request('/api/tags'); +} 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/workplace/_mock.ts b/src/pages/dashboard/workplace/_mock.ts new file mode 100644 index 00000000..41b9e38a --- /dev/null +++ b/src/pages/dashboard/workplace/_mock.ts @@ -0,0 +1,410 @@ +import moment from 'moment'; +import type { Request, Response } from 'express'; +import type { SearchDataType, OfflineDataType, DataItem } from './data.d'; + +// mock data +const visitData: DataItem[] = []; +const beginDay = new Date().getTime(); + +const fakeY = [7, 5, 4, 2, 4, 7, 5, 6, 5, 9, 6, 3, 1, 5, 3, 6, 5]; +for (let i = 0; i < fakeY.length; i += 1) { + visitData.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY[i], + }); +} + +const visitData2: DataItem[] = []; +const fakeY2 = [1, 6, 4, 8, 3, 7, 2]; +for (let i = 0; i < fakeY2.length; i += 1) { + visitData2.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY2[i], + }); +} + +const salesData: DataItem[] = []; +for (let i = 0; i < 12; i += 1) { + salesData.push({ + x: `${i + 1}月`, + y: Math.floor(Math.random() * 1000) + 200, + }); +} +const searchData: SearchDataType[] = []; +for (let i = 0; i < 50; i += 1) { + searchData.push({ + index: i + 1, + keyword: `搜索关键词-${i}`, + count: Math.floor(Math.random() * 1000), + range: Math.floor(Math.random() * 100), + status: Math.floor((Math.random() * 10) % 2), + }); +} +const salesTypeData = [ + { + x: '家用电器', + y: 4544, + }, + { + x: '食用酒水', + y: 3321, + }, + { + x: '个护健康', + y: 3113, + }, + { + x: '服饰箱包', + y: 2341, + }, + { + x: '母婴产品', + y: 1231, + }, + { + x: '其他', + y: 1231, + }, +]; + +const salesTypeDataOnline = [ + { + x: '家用电器', + y: 244, + }, + { + x: '食用酒水', + y: 321, + }, + { + x: '个护健康', + y: 311, + }, + { + x: '服饰箱包', + y: 41, + }, + { + x: '母婴产品', + y: 121, + }, + { + x: '其他', + y: 111, + }, +]; + +const salesTypeDataOffline = [ + { + x: '家用电器', + y: 99, + }, + { + x: '食用酒水', + y: 188, + }, + { + x: '个护健康', + y: 344, + }, + { + x: '服饰箱包', + y: 255, + }, + { + x: '其他', + y: 65, + }, +]; + +const offlineData: OfflineDataType[] = []; +for (let i = 0; i < 10; i += 1) { + offlineData.push({ + name: `Stores ${i}`, + cvr: Math.ceil(Math.random() * 9) / 10, + }); +} +const offlineChartData: DataItem[] = []; +for (let i = 0; i < 20; i += 1) { + offlineChartData.push({ + x: new Date().getTime() + 1000 * 60 * 30 * i, + y1: Math.floor(Math.random() * 100) + 10, + y2: Math.floor(Math.random() * 100) + 10, + }); +} + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const avatars2 = [ + 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png', + 'https://gw.alipayobjects.com/zos/rmsportal/cnrhVkzwxjPwAaCfPbdc.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gaOngJwsRYRaVAuXXcmB.png', + 'https://gw.alipayobjects.com/zos/rmsportal/ubnKSIfAJTxIgXOKlciN.png', + 'https://gw.alipayobjects.com/zos/rmsportal/WhxKECPNujWoWEFNdnJE.png', + 'https://gw.alipayobjects.com/zos/rmsportal/jZUIxmJycoymBprLOUbT.png', + 'https://gw.alipayobjects.com/zos/rmsportal/psOgztMplJMGpVEqfcgF.png', + 'https://gw.alipayobjects.com/zos/rmsportal/ZpBqSxLxVEXfcUNoPKrz.png', + 'https://gw.alipayobjects.com/zos/rmsportal/laiEnJdGHVOhJrUShBaJ.png', + 'https://gw.alipayobjects.com/zos/rmsportal/UrQsqscbKEpNuJcvBZBu.png', +]; + +const getNotice = (_: Request, res: Response) => { + res.json({ + data: [ + { + id: 'xxx1', + title: titles[0], + logo: avatars[0], + description: '那是一种内在的东西,他们到达不了,也无法触及的', + updatedAt: new Date(), + member: '科学搬砖组', + href: '', + memberLink: '', + }, + { + id: 'xxx2', + title: titles[1], + logo: avatars[1], + description: '希望是一个好东西,也许是最好的,好东西是不会消亡的', + updatedAt: new Date('2017-07-24'), + member: '全组都是吴彦祖', + href: '', + memberLink: '', + }, + { + id: 'xxx3', + title: titles[2], + logo: avatars[2], + description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + updatedAt: new Date(), + member: '中二少女团', + href: '', + memberLink: '', + }, + { + id: 'xxx4', + title: titles[3], + logo: avatars[3], + description: '那时候我只会想自己想要什么,从不想自己拥有什么', + updatedAt: new Date('2017-07-23'), + member: '程序员日常', + href: '', + memberLink: '', + }, + { + id: 'xxx5', + title: titles[4], + logo: avatars[4], + description: '凛冬将至', + updatedAt: new Date('2017-07-23'), + member: '高逼格设计天团', + href: '', + memberLink: '', + }, + { + id: 'xxx6', + title: titles[5], + logo: avatars[5], + description: '生命就像一盒巧克力,结果往往出人意料', + updatedAt: new Date('2017-07-23'), + member: '骗你来学计算机', + href: '', + memberLink: '', + }, + ], + }); +}; + +const getActivities = (_: Request, res: Response) => { + res.json({ + data: [ + { + id: 'trend-1', + updatedAt: new Date(), + user: { + name: '曲丽丽', + avatar: avatars2[0], + }, + group: { + name: '高逼格设计天团', + link: 'http://github.com/', + }, + project: { + name: '六月迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, + { + id: 'trend-2', + updatedAt: new Date(), + user: { + name: '付小小', + avatar: avatars2[1], + }, + group: { + name: '高逼格设计天团', + link: 'http://github.com/', + }, + project: { + name: '六月迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, + { + id: 'trend-3', + updatedAt: new Date(), + user: { + name: '林东东', + avatar: avatars2[2], + }, + group: { + name: '中二少女团', + link: 'http://github.com/', + }, + project: { + name: '六月迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, + { + id: 'trend-4', + updatedAt: new Date(), + user: { + name: '周星星', + avatar: avatars2[4], + }, + project: { + name: '5 月日常迭代', + link: 'http://github.com/', + }, + template: '将 @{project} 更新至已发布状态', + }, + { + id: 'trend-5', + updatedAt: new Date(), + user: { + name: '朱偏右', + avatar: avatars2[3], + }, + project: { + name: '工程效能', + link: 'http://github.com/', + }, + comment: { + name: '留言', + link: 'http://github.com/', + }, + template: '在 @{project} 发布了 @{comment}', + }, + { + id: 'trend-6', + updatedAt: new Date(), + user: { + name: '乐哥', + avatar: avatars2[5], + }, + group: { + name: '程序员日常', + link: 'http://github.com/', + }, + project: { + name: '品牌迭代', + link: 'http://github.com/', + }, + template: '在 @{group} 新建项目 @{project}', + }, + ], + }); +}; + +const radarOriginData = [ + { + name: '个人', + ref: 10, + koubei: 8, + output: 4, + contribute: 5, + hot: 7, + }, + { + name: '团队', + ref: 3, + koubei: 9, + output: 6, + contribute: 3, + hot: 1, + }, + { + name: '部门', + ref: 4, + koubei: 1, + output: 6, + contribute: 5, + hot: 7, + }, +]; + +const radarData: any[] = []; +const radarTitleMap = { + ref: '引用', + koubei: '口碑', + output: '产量', + contribute: '贡献', + hot: '热度', +}; +radarOriginData.forEach((item) => { + Object.keys(item).forEach((key) => { + if (key !== 'name') { + radarData.push({ + name: item.name, + label: radarTitleMap[key], + value: item[key], + }); + } + }); +}); + +const getChartData = (_: Request, res: Response) => { + res.json({ + data: { + visitData, + visitData2, + salesData, + searchData, + offlineData, + offlineChartData, + salesTypeData, + salesTypeDataOnline, + salesTypeDataOffline, + radarData, + }, + }); +}; + +export default { + 'GET /api/project/notice': getNotice, + 'GET /api/activities': getActivities, + 'GET /api/fake_workplace_chart_data': getChartData, +}; 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.tsx b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx new file mode 100644 index 00000000..73b34384 --- /dev/null +++ b/src/pages/dashboard/workplace/components/EditableLinkGroup/index.tsx @@ -0,0 +1,47 @@ +import React, { createElement } from 'react'; +import { PlusOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; + +import styles from './index.less'; + +export type EditableLink = { + title: string; + href: string; + id?: string; +}; + +type EditableLinkGroupProps = { + onAdd: () => void; + links: EditableLink[]; + linkElement: any; +}; + +const EditableLinkGroup: React.FC = (props) => { + const { links, linkElement, onAdd } = props; + return ( +
    + {links.map((link) => + createElement( + linkElement, + { + key: `linkGroup-item-${link.id || link.title}`, + to: link.href, + href: link.href, + }, + link.title, + ), + )} + +
    + ); +}; + +EditableLinkGroup.defaultProps = { + links: [], + onAdd: () => {}, + linkElement: 'a', +}; + +export default EditableLinkGroup; diff --git a/src/pages/dashboard/workplace/components/Radar/autoHeight.tsx b/src/pages/dashboard/workplace/components/Radar/autoHeight.tsx new file mode 100644 index 00000000..5ec7bf39 --- /dev/null +++ b/src/pages/dashboard/workplace/components/Radar/autoHeight.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +export type IReactComponent

    = + | React.StatelessComponent

    + | React.ComponentClass

    + | React.ClassicComponentClass

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

    ( + WrappedComponent: React.ComponentClass

    | React.FC

    , + ): React.ComponentClass

    => { + class AutoHeightComponent extends React.Component

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

    + {h > 0 && } +
    + ); + } + } + return AutoHeightComponent; + }; +} +export default autoHeight; diff --git a/src/pages/dashboard/workplace/components/Radar/index.tsx b/src/pages/dashboard/workplace/components/Radar/index.tsx new file mode 100644 index 00000000..f7b8596a --- /dev/null +++ b/src/pages/dashboard/workplace/components/Radar/index.tsx @@ -0,0 +1,219 @@ +import { Axis, Chart, Coord, Geom, Tooltip } from 'bizcharts'; +import { Col, Row } from 'antd'; +import React, { Component } from 'react'; + +import autoHeight from './autoHeight'; +import styles from './index.less'; + +export type RadarProps = { + title?: React.ReactNode; + height?: number; + padding?: [number, number, number, number]; + hasLegend?: boolean; + data: { + name: string; + label: string; + value: string | number; + }[]; + colors?: string[]; + animate?: boolean; + forceFit?: boolean; + tickCount?: number; + style?: React.CSSProperties; +}; +type RadarState = { + legendData: { + checked: boolean; + name: string; + color: string; + percent: number; + value: string; + }[]; +}; +/* eslint react/no-danger:0 */ +class Radar extends Component { + state: RadarState = { + legendData: [], + }; + + chart: G2.Chart | undefined = undefined; + + node: HTMLDivElement | undefined = undefined; + + componentDidMount() { + this.getLegendData(); + } + + componentDidUpdate(preProps: RadarProps) { + const { data } = this.props; + if (data !== preProps.data) { + this.getLegendData(); + } + } + + getG2Instance = (chart: G2.Chart) => { + this.chart = chart; + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + const items = (geom as any).get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map((item: { color: any; _origin: any }[]) => { + // eslint-disable-next-line no-underscore-dangle + const origins = item.map((t) => t._origin); + const result = { + name: origins[0].name, + color: item[0].color, + checked: true, + value: origins.reduce((p, n) => p + n.value, 0), + }; + + return result; + }); + + this.setState({ + legendData, + }); + }; + + handleRef = (n: HTMLDivElement) => { + this.node = n; + }; + + handleLegendClick = ( + item: { + checked: boolean; + name: string; + }, + i: string | number, + ) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter((l) => l.checked).map((l) => l.name); + + if (this.chart) { + this.chart.filter('name', (val) => filteredLegendData.indexOf(`${val}`) > -1); + this.chart.repaint(); + } + + this.setState({ + legendData, + }); + }; + + render() { + const defaultColors = [ + '#1890FF', + '#FACC14', + '#2FC25B', + '#8543E0', + '#F04864', + '#13C2C2', + '#fa8c16', + '#a0d911', + ]; + + const { + data = [], + height = 0, + title, + hasLegend = false, + forceFit = true, + tickCount = 5, + padding = [35, 30, 16, 30] as [number, number, number, number], + animate = true, + colors = defaultColors, + } = this.props; + + const { legendData } = this.state; + + const scale = { + value: { + min: 0, + tickCount, + }, + }; + + const chartHeight = height - (hasLegend ? 80 : 22); + + return ( +
    + {title &&

    {title}

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

    + + {item.name} +

    +
    {item.value}
    +
    + + ))} +
    + )} +
    + ); + } +} + +export default autoHeight()(Radar); diff --git a/src/pages/dashboard/workplace/data.d.ts b/src/pages/dashboard/workplace/data.d.ts new file mode 100644 index 00000000..e96271ec --- /dev/null +++ b/src/pages/dashboard/workplace/data.d.ts @@ -0,0 +1,111 @@ +import { DataItem } from '@antv/g2plot/esm/interface/config'; + +export { DataItem }; + +export interface TagType { + key: string; + label: string; +} + +export type SearchDataType = { + index: number; + keyword: string; + count: number; + range: number; + status: number; +}; + +export type OfflineDataType = { + name: string; + cvr: number; +}; + +export interface RadarData { + name: string; + label: string; + value: number; +} + +export type AnalysisData = { + visitData: VisitDataType[]; + visitData2: VisitDataType[]; + salesData: VisitDataType[]; + searchData: SearchDataType[]; + offlineData: OfflineDataType[]; + offlineChartData: OfflineChartData[]; + salesTypeData: VisitDataType[]; + salesTypeDataOnline: VisitDataType[]; + salesTypeDataOffline: VisitDataType[]; + radarData: DataItem[]; +}; + +export type GeographicType = { + province: { + label: string; + key: string; + }; + city: { + label: string; + key: string; + }; +}; + +export type NoticeType = { + id: string; + title: string; + logo: string; + description: string; + updatedAt: string; + member: string; + href: string; + memberLink: string; +}; + +export type CurrentUser = { + name: string; + avatar: string; + userid: string; + notice: NoticeType[]; + email: string; + signature: string; + title: string; + group: string; + tags: TagType[]; + notifyCount: number; + unreadCount: number; + country: string; + geographic: GeographicType; + address: string; + phone: string; +}; + +export type Member = { + avatar: string; + name: string; + id: string; +}; + +export type ActivitiesType = { + id: string; + updatedAt: string; + user: { + name: string; + avatar: string; + }; + group: { + name: string; + link: string; + }; + project: { + name: string; + link: string; + }; + + template: string; +}; + +export type RadarDataType = { + label: string; + name: string; + value: number; +}; diff --git a/src/pages/dashboard/workplace/index.tsx b/src/pages/dashboard/workplace/index.tsx new file mode 100644 index 00000000..701e64a1 --- /dev/null +++ b/src/pages/dashboard/workplace/index.tsx @@ -0,0 +1,242 @@ +import type { FC } from 'react'; +import { Avatar, Card, Col, List, Skeleton, Row, Statistic } from 'antd'; +import { Radar } from '@ant-design/charts'; + +import { Link, useRequest } from 'umi'; +import { PageContainer } from '@ant-design/pro-layout'; +import moment from 'moment'; +import EditableLinkGroup from './components/EditableLinkGroup'; +import styles from './style.less'; +import type { ActivitiesType, CurrentUser } from './data.d'; +import { queryProjectNotice, queryActivities, fakeChartData } from './service'; + +const links = [ + { + title: '操作一', + href: '', + }, + { + title: '操作二', + href: '', + }, + { + title: '操作三', + href: '', + }, + { + title: '操作四', + href: '', + }, + { + title: '操作五', + href: '', + }, + { + title: '操作六', + href: '', + }, +]; + +const PageHeaderContent: FC<{ currentUser: Partial }> = ({ currentUser }) => { + const loading = currentUser && Object.keys(currentUser).length; + if (!loading) { + return ; + } + return ( +
    +
    + +
    +
    +
    + 早安, + {currentUser.name} + ,祝你开心每一天! +
    +
    + {currentUser.title} |{currentUser.group} +
    +
    +
    + ); +}; + +const ExtraContent: FC> = () => ( +
    +
    + +
    +
    + +
    +
    + +
    +
    +); + +const Workplace: FC = () => { + const { loading: projectLoading, data: projectNotice = [] } = useRequest(queryProjectNotice); + const { loading: activitiesLoading, data: activities = [] } = useRequest(queryActivities); + const { data } = useRequest(fakeChartData); + + const renderActivities = (item: ActivitiesType) => { + const events = item.template.split(/@\{([^{}]*)\}/gi).map((key) => { + if (item[key]) { + return ( + + {item[key].name} + + ); + } + return key; + }); + return ( + + } + title={ + + {item.user.name} +   + {events} + + } + description={ + + {moment(item.updatedAt).fromNow()} + + } + /> + + ); + }; + + return ( + + } + extraContent={} + > + + + 全部项目} + loading={projectLoading} + bodyStyle={{ padding: 0 }} + > + {projectNotice.map((item) => ( + + + + + {item.title} +
    + } + description={item.description} + /> +
    + {item.member || ''} + {item.updatedAt && ( + + {moment(item.updatedAt).fromNow()} + + )} +
    + + + ))} + + + + loading={activitiesLoading} + renderItem={(item) => renderActivities(item)} + dataSource={activities} + className={styles.activitiesList} + size="large" + /> + + + + + {}} links={links} linkElement={Link} /> + + +
    + +
    +
    + +
    + + {projectNotice.map((item) => ( + + + + {item.member} + + + ))} + +
    +
    + + + + ); +}; + +export default Workplace; diff --git a/src/pages/dashboard/workplace/service.ts b/src/pages/dashboard/workplace/service.ts new file mode 100644 index 00000000..9d5cd915 --- /dev/null +++ b/src/pages/dashboard/workplace/service.ts @@ -0,0 +1,14 @@ +import { request } from 'umi'; +import type { NoticeType, ActivitiesType, AnalysisData } from './data'; + +export async function queryProjectNotice(): Promise<{ data: NoticeType[] }> { + return request('/api/project/notice'); +} + +export async function queryActivities(): Promise<{ data: ActivitiesType[] }> { + return request('/api/activities'); +} + +export async function fakeChartData(): Promise<{ data: AnalysisData }> { + return request('/api/fake_workplace_chart_data'); +} diff --git a/src/pages/dashboard/workplace/style.less b/src/pages/dashboard/workplace/style.less new file mode 100644 index 00000000..46c10e14 --- /dev/null +++ b/src/pages/dashboard/workplace/style.less @@ -0,0 +1,250 @@ +@import '~antd/es/style/themes/default.less'; + +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} + +.activitiesList { + padding: 0 24px 8px 24px; + .username { + color: @text-color; + } + .event { + font-weight: normal; + } +} + +.pageHeaderContent { + display: flex; + .avatar { + flex: 0 1 72px; + & > span { + display: block; + width: 72px; + height: 72px; + border-radius: 72px; + } + } + .content { + position: relative; + top: 4px; + flex: 1 1 auto; + margin-left: 24px; + color: @text-color-secondary; + line-height: 22px; + .contentTitle { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } + } +} + +.extraContent { + .clearfix(); + + float: right; + white-space: nowrap; + .statItem { + position: relative; + display: inline-block; + padding: 0 32px; + > p:first-child { + margin-bottom: 4px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + > p { + margin: 0; + color: @heading-color; + font-size: 30px; + line-height: 38px; + > span { + color: @text-color-secondary; + font-size: 20px; + } + } + &::after { + position: absolute; + top: 8px; + right: 0; + width: 1px; + height: 40px; + background-color: @border-color-split; + content: ''; + } + &:last-child { + padding-right: 0; + &::after { + display: none; + } + } + } +} + +.members { + a { + display: block; + height: 24px; + margin: 12px 0; + color: @text-color; + transition: all 0.3s; + .textOverflow(); + .member { + margin-left: 12px; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + } + &:hover { + color: @primary-color; + } + } +} + +.projectList { + :global { + .ant-card-meta-description { + height: 44px; + overflow: hidden; + color: @text-color-secondary; + line-height: 22px; + } + } + .cardTitle { + font-size: 0; + a { + display: inline-block; + height: 24px; + margin-left: 12px; + color: @heading-color; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + &:hover { + color: @primary-color; + } + } + } + .projectGrid { + width: 33.33%; + } + .projectItemContent { + display: flex; + height: 20px; + margin-top: 8px; + overflow: hidden; + font-size: 12px; + line-height: 20px; + .textOverflow(); + a { + display: inline-block; + flex: 1 1 0; + color: @text-color-secondary; + .textOverflow(); + &:hover { + color: @primary-color; + } + } + .datetime { + flex: 0 0 auto; + float: right; + color: @disabled-color; + } + } +} + +.datetime { + color: @disabled-color; +} + +@media screen and (max-width: @screen-xl) and (min-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + margin-left: -44px; + .statItem { + padding: 0 16px; + } + } +} + +@media screen and (max-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + float: none; + margin-right: 0; + .statItem { + padding: 0 16px; + text-align: left; + &::after { + display: none; + } + } + } +} + +@media screen and (max-width: @screen-md) { + .extraContent { + margin-left: -16px; + } + .projectList { + .projectGrid { + width: 50%; + } + } +} + +@media screen and (max-width: @screen-sm) { + .pageHeaderContent { + display: block; + .content { + margin-left: 0; + } + } + .extraContent { + .statItem { + float: none; + } + } +} + +@media screen and (max-width: @screen-xs) { + .projectList { + .projectGrid { + width: 100%; + } + } +} diff --git a/src/pages/editor/flow/common/IconFont/index.ts b/src/pages/editor/flow/common/IconFont/index.ts new file mode 100644 index 00000000..987647c0 --- /dev/null +++ b/src/pages/editor/flow/common/IconFont/index.ts @@ -0,0 +1,7 @@ +import { createFromIconfontCN } from '@ant-design/icons'; + +const IconFont = createFromIconfontCN({ + scriptUrl: 'https://at.alicdn.com/t/font_1101588_01zniftxm9yp.js', +}); + +export default IconFont; diff --git a/src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx b/src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx new file mode 100644 index 00000000..c5cc4e50 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/FlowContextMenu.tsx @@ -0,0 +1,34 @@ +import { CanvasMenu, ContextMenu, EdgeMenu, GroupMenu, MultiMenu, NodeMenu } from 'gg-editor'; + +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const FlowContextMenu = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default FlowContextMenu; diff --git a/src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx b/src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx new file mode 100644 index 00000000..8b049a5e --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/KoniContextMenu.tsx @@ -0,0 +1,3 @@ +import FlowContextMenu from './FlowContextMenu'; + +export default FlowContextMenu; diff --git a/src/pages/editor/flow/components/EditorContextMenu/MenuItem.tsx b/src/pages/editor/flow/components/EditorContextMenu/MenuItem.tsx new file mode 100644 index 00000000..297967ab --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/MenuItem.tsx @@ -0,0 +1,27 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +type MenuItemProps = { + command: string; + icon?: string; + text?: string; +}; +const MenuItem: React.FC = (props) => { + const { command, icon, text } = props; + + return ( + +
    + + {text || upperFirst(command)} +
    +
    + ); +}; + +export default MenuItem; diff --git a/src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx b/src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx new file mode 100644 index 00000000..5a8b4076 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/MindContextMenu.tsx @@ -0,0 +1,22 @@ +import { CanvasMenu, ContextMenu, NodeMenu } from 'gg-editor'; + +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const MindContextMenu = () => ( + + + + + + + + + + + + + +); + +export default MindContextMenu; diff --git a/src/pages/editor/flow/components/EditorContextMenu/index.less b/src/pages/editor/flow/components/EditorContextMenu/index.less new file mode 100644 index 00000000..39cbd2e9 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/index.less @@ -0,0 +1,41 @@ +@import '~antd/es/style/themes/default.less'; + +.contextMenu { + display: none; + overflow: hidden; + background: @component-background; + border-radius: 4px; + box-shadow: @box-shadow-base; + + .item { + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + transition: all 0.3s; + user-select: none; + + &:hover { + background: @select-item-selected-bg; + } + + span.anticon { + margin-right: 8px; + } + } + + :global { + .disable { + :local { + .item { + color: @disabled-color; + cursor: auto; + + &:hover { + background: @item-hover-bg; + } + } + } + } + } +} diff --git a/src/pages/editor/flow/components/EditorContextMenu/index.tsx b/src/pages/editor/flow/components/EditorContextMenu/index.tsx new file mode 100644 index 00000000..6ee93423 --- /dev/null +++ b/src/pages/editor/flow/components/EditorContextMenu/index.tsx @@ -0,0 +1,5 @@ +import FlowContextMenu from './FlowContextMenu'; +import KoniContextMenu from './KoniContextMenu'; +import MindContextMenu from './MindContextMenu'; + +export { FlowContextMenu, MindContextMenu, KoniContextMenu }; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx b/src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx new file mode 100644 index 00000000..c89265b6 --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/DetailForm.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Card, Input, Select, Form } from 'antd'; +import { withPropsAPI } from 'gg-editor'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +const { Item } = Form; +const { Option } = Select; + +const inlineFormItemLayout = { + labelCol: { + sm: { span: 8 }, + }, + wrapperCol: { + sm: { span: 16 }, + }, +}; + +type DetailFormProps = { + type: string; + propsAPI?: any; +}; + +class DetailForm extends React.Component { + get item() { + const { propsAPI } = this.props; + return propsAPI.getSelected()[0]; + } + + handleFieldChange = (values: any) => { + const { propsAPI } = this.props; + const { getSelected, executeCommand, update } = propsAPI; + + setTimeout(() => { + const item = getSelected()[0]; + if (!item) { + return; + } + executeCommand(() => { + update(item, { + ...values, + }); + }); + }, 0); + }; + + handleInputBlur = (type: string) => (e: React.FormEvent) => { + e.preventDefault(); + this.handleFieldChange({ + [type]: e.currentTarget.value, + }); + }; + + renderNodeDetail = () => { + const { label } = this.item.getModel(); + + return ( +
    + + + +
    + ); + }; + + renderEdgeDetail = () => { + const { label = '', shape = 'flow-smooth' } = this.item.getModel(); + + return ( +
    + + + + + + +
    + ); + }; + + renderGroupDetail = () => { + const { label = '新建分组' } = this.item.getModel(); + + return ( +
    + + + +
    + ); + }; + + render() { + const { type } = this.props; + if (!this.item) { + return null; + } + + return ( + + {type === 'node' && this.renderNodeDetail()} + {type === 'edge' && this.renderEdgeDetail()} + {type === 'group' && this.renderGroupDetail()} + + ); + } +} + +export default withPropsAPI(DetailForm as any); diff --git a/src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx b/src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx new file mode 100644 index 00000000..bb7035c9 --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/FlowDetailPanel.tsx @@ -0,0 +1,27 @@ +import { CanvasPanel, DetailPanel, EdgePanel, GroupPanel, MultiPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const FlowDetailPanel = () => ( + + + + + + + + + + + + + + + + + +); + +export default FlowDetailPanel; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx b/src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx new file mode 100644 index 00000000..18aea9a4 --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/KoniDetailPanel.tsx @@ -0,0 +1,3 @@ +import FlowDetailPanel from './FlowDetailPanel'; + +export default FlowDetailPanel; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx b/src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx new file mode 100644 index 00000000..dceaf36e --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/MindDetailPanel.tsx @@ -0,0 +1,18 @@ +import { CanvasPanel, DetailPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const MindDetailPanel = () => ( + + + + + + + + +); + +export default MindDetailPanel; diff --git a/src/pages/editor/flow/components/EditorDetailPanel/index.less b/src/pages/editor/flow/components/EditorDetailPanel/index.less new file mode 100644 index 00000000..0b2c564b --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/index.less @@ -0,0 +1,6 @@ +@import '~antd/es/style/themes/default.less'; + +.detailPanel { + flex: 1; + background-color: @component-background; +} diff --git a/src/pages/editor/flow/components/EditorDetailPanel/index.tsx b/src/pages/editor/flow/components/EditorDetailPanel/index.tsx new file mode 100644 index 00000000..50aa37a8 --- /dev/null +++ b/src/pages/editor/flow/components/EditorDetailPanel/index.tsx @@ -0,0 +1,5 @@ +import FlowDetailPanel from './FlowDetailPanel'; +import KoniDetailPanel from './KoniDetailPanel'; +import MindDetailPanel from './MindDetailPanel'; + +export { FlowDetailPanel, MindDetailPanel, KoniDetailPanel }; diff --git a/src/pages/editor/flow/components/EditorItemPanel/FlowItemPanel.tsx b/src/pages/editor/flow/components/EditorItemPanel/FlowItemPanel.tsx new file mode 100644 index 00000000..c7dcbe60 --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/FlowItemPanel.tsx @@ -0,0 +1,53 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import styles from './index.less'; + +const FlowItemPanel = () => ( + + + + + + + + +); + +export default FlowItemPanel; diff --git a/src/pages/editor/flow/components/EditorItemPanel/KoniItemPanel.tsx b/src/pages/editor/flow/components/EditorItemPanel/KoniItemPanel.tsx new file mode 100644 index 00000000..3901d292 --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/KoniItemPanel.tsx @@ -0,0 +1,49 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import styles from './index.less'; + +const KoniItemPanel = () => ( + + + + + + + +); + +export default KoniItemPanel; diff --git a/src/pages/editor/flow/components/EditorItemPanel/index.less b/src/pages/editor/flow/components/EditorItemPanel/index.less new file mode 100644 index 00000000..b594a66b --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/index.less @@ -0,0 +1,19 @@ +@import '~antd/es/style/themes/default.less'; + +.itemPanel { + flex: 1; + + :global { + .ant-card { + height: 100%; + } + .ant-card-body { + display: flex; + flex-direction: column; + align-items: center; + > div { + margin-bottom: 16px; + } + } + } +} diff --git a/src/pages/editor/flow/components/EditorItemPanel/index.tsx b/src/pages/editor/flow/components/EditorItemPanel/index.tsx new file mode 100644 index 00000000..2ba03fbb --- /dev/null +++ b/src/pages/editor/flow/components/EditorItemPanel/index.tsx @@ -0,0 +1,4 @@ +import FlowItemPanel from './FlowItemPanel'; +import KoniItemPanel from './KoniItemPanel'; + +export { FlowItemPanel, KoniItemPanel }; diff --git a/src/pages/editor/flow/components/EditorMinimap/index.tsx b/src/pages/editor/flow/components/EditorMinimap/index.tsx new file mode 100644 index 00000000..b337edbf --- /dev/null +++ b/src/pages/editor/flow/components/EditorMinimap/index.tsx @@ -0,0 +1,10 @@ +import { Card } from 'antd'; +import { Minimap } from 'gg-editor'; + +const EditorMinimap = () => ( + + + +); + +export default EditorMinimap; diff --git a/src/pages/editor/flow/components/EditorToolbar/FlowToolbar.tsx b/src/pages/editor/flow/components/EditorToolbar/FlowToolbar.tsx new file mode 100644 index 00000000..90494876 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/FlowToolbar.tsx @@ -0,0 +1,29 @@ +import { Divider } from 'antd'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/flow/components/EditorToolbar/KoniToolbar.tsx b/src/pages/editor/flow/components/EditorToolbar/KoniToolbar.tsx new file mode 100644 index 00000000..f222007a --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/KoniToolbar.tsx @@ -0,0 +1,3 @@ +import FlowToolbar from './FlowToolbar'; + +export default FlowToolbar; diff --git a/src/pages/editor/flow/components/EditorToolbar/MindToolbar.tsx b/src/pages/editor/flow/components/EditorToolbar/MindToolbar.tsx new file mode 100644 index 00000000..23761093 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/MindToolbar.tsx @@ -0,0 +1,24 @@ +import { Divider } from 'antd'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/flow/components/EditorToolbar/ToolbarButton.tsx b/src/pages/editor/flow/components/EditorToolbar/ToolbarButton.tsx new file mode 100644 index 00000000..94f9e9c6 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/ToolbarButton.tsx @@ -0,0 +1,31 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import { Tooltip } from 'antd'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +type ToolbarButtonProps = { + command: string; + icon?: string; + text?: string; +}; +const ToolbarButton: React.FC = (props) => { + const { command, icon, text } = props; + + return ( + + + + + + ); +}; + +export default ToolbarButton; diff --git a/src/pages/editor/flow/components/EditorToolbar/index.less b/src/pages/editor/flow/components/EditorToolbar/index.less new file mode 100644 index 00000000..d9e98d1f --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/index.less @@ -0,0 +1,40 @@ +@import '~antd/es/style/themes/default.less'; + +.toolbar { + display: flex; + align-items: center; + + :global { + .command .anticon { + display: inline-block; + width: 27px; + height: 27px; + margin: 0 6px; + padding-top: 6px; + text-align: center; + cursor: pointer; + + &:hover { + border: 1px solid @item-active-bg; + } + } + + .disable .anticon { + color: @text-color-secondary; + cursor: auto; + + &:hover { + border: 1px solid @border-color-base; + } + } + } +} + +.tooltip { + :global { + .ant-tooltip-inner { + font-size: 12px; + border-radius: 0; + } + } +} diff --git a/src/pages/editor/flow/components/EditorToolbar/index.tsx b/src/pages/editor/flow/components/EditorToolbar/index.tsx new file mode 100644 index 00000000..58f1d277 --- /dev/null +++ b/src/pages/editor/flow/components/EditorToolbar/index.tsx @@ -0,0 +1,5 @@ +import FlowToolbar from './FlowToolbar'; +import KoniToolbar from './KoniToolbar'; +import MindToolbar from './MindToolbar'; + +export { FlowToolbar, MindToolbar, KoniToolbar }; diff --git a/src/pages/editor/flow/index.less b/src/pages/editor/flow/index.less new file mode 100644 index 00000000..92dac57d --- /dev/null +++ b/src/pages/editor/flow/index.less @@ -0,0 +1,48 @@ +@import '~antd/es/style/themes/default.less'; + +.editor { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: calc(100vh - 250px); + background: @component-background; +} + +.editorHd { + padding: 8px; + background: @descriptions-bg; + border: 1px solid @item-active-bg; +} + +.editorBd { + flex: 1; +} + +.editorSidebar, +.editorContent { + display: flex; + flex-direction: column; +} + +.editorSidebar { + background: @descriptions-bg; + :global { + .g6-editor-minimap-container { + background: none !important ; + } + } + &:first-child { + border-right: 1px solid @item-active-bg; + } + + &:last-child { + border-left: 1px solid @item-active-bg; + } +} + +.flow, +.mind, +.koni { + flex: 1; +} diff --git a/src/pages/editor/flow/index.tsx b/src/pages/editor/flow/index.tsx new file mode 100644 index 00000000..b8da7bb3 --- /dev/null +++ b/src/pages/editor/flow/index.tsx @@ -0,0 +1,37 @@ +import { Col, Row } from 'antd'; +import GGEditor, { Flow } from 'gg-editor'; + +import { PageContainer } from '@ant-design/pro-layout'; +import EditorMinimap from './components/EditorMinimap'; +import { FlowContextMenu } from './components/EditorContextMenu'; +import { FlowDetailPanel } from './components/EditorDetailPanel'; +import { FlowItemPanel } from './components/EditorItemPanel'; +import { FlowToolbar } from './components/EditorToolbar'; +import styles from './index.less'; + +GGEditor.setTrackable(false); + +export default () => ( + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/src/pages/editor/koni/common/IconFont/index.ts b/src/pages/editor/koni/common/IconFont/index.ts new file mode 100644 index 00000000..987647c0 --- /dev/null +++ b/src/pages/editor/koni/common/IconFont/index.ts @@ -0,0 +1,7 @@ +import { createFromIconfontCN } from '@ant-design/icons'; + +const IconFont = createFromIconfontCN({ + scriptUrl: 'https://at.alicdn.com/t/font_1101588_01zniftxm9yp.js', +}); + +export default IconFont; diff --git a/src/pages/editor/koni/components/EditorContextMenu/FlowContextMenu.tsx b/src/pages/editor/koni/components/EditorContextMenu/FlowContextMenu.tsx new file mode 100644 index 00000000..c5cc4e50 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/FlowContextMenu.tsx @@ -0,0 +1,34 @@ +import { CanvasMenu, ContextMenu, EdgeMenu, GroupMenu, MultiMenu, NodeMenu } from 'gg-editor'; + +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const FlowContextMenu = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default FlowContextMenu; diff --git a/src/pages/editor/koni/components/EditorContextMenu/KoniContextMenu.tsx b/src/pages/editor/koni/components/EditorContextMenu/KoniContextMenu.tsx new file mode 100644 index 00000000..8b049a5e --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/KoniContextMenu.tsx @@ -0,0 +1,3 @@ +import FlowContextMenu from './FlowContextMenu'; + +export default FlowContextMenu; diff --git a/src/pages/editor/koni/components/EditorContextMenu/MenuItem.tsx b/src/pages/editor/koni/components/EditorContextMenu/MenuItem.tsx new file mode 100644 index 00000000..297967ab --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/MenuItem.tsx @@ -0,0 +1,27 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +type MenuItemProps = { + command: string; + icon?: string; + text?: string; +}; +const MenuItem: React.FC = (props) => { + const { command, icon, text } = props; + + return ( + +
    + + {text || upperFirst(command)} +
    +
    + ); +}; + +export default MenuItem; diff --git a/src/pages/editor/koni/components/EditorContextMenu/MindContextMenu.tsx b/src/pages/editor/koni/components/EditorContextMenu/MindContextMenu.tsx new file mode 100644 index 00000000..5a8b4076 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/MindContextMenu.tsx @@ -0,0 +1,22 @@ +import { CanvasMenu, ContextMenu, NodeMenu } from 'gg-editor'; + +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const MindContextMenu = () => ( + + + + + + + + + + + + + +); + +export default MindContextMenu; diff --git a/src/pages/editor/koni/components/EditorContextMenu/index.less b/src/pages/editor/koni/components/EditorContextMenu/index.less new file mode 100644 index 00000000..25d68b2c --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/index.less @@ -0,0 +1,40 @@ +@import '~antd/es/style/themes/default.less'; + +.contextMenu { + display: none; + overflow: hidden; + background: @component-background; + border-radius: 4px; + box-shadow: @box-shadow-base; + .item { + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + transition: all 0.3s; + user-select: none; + + &:hover { + background: @select-item-selected-bg; + } + + .anticon { + margin-right: 8px; + } + } + + :global { + .disable { + :local { + .item { + color: @disabled-color; + cursor: auto; + + &:hover { + background: @item-hover-bg; + } + } + } + } + } +} diff --git a/src/pages/editor/koni/components/EditorContextMenu/index.tsx b/src/pages/editor/koni/components/EditorContextMenu/index.tsx new file mode 100644 index 00000000..6ee93423 --- /dev/null +++ b/src/pages/editor/koni/components/EditorContextMenu/index.tsx @@ -0,0 +1,5 @@ +import FlowContextMenu from './FlowContextMenu'; +import KoniContextMenu from './KoniContextMenu'; +import MindContextMenu from './MindContextMenu'; + +export { FlowContextMenu, MindContextMenu, KoniContextMenu }; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/DetailForm.tsx b/src/pages/editor/koni/components/EditorDetailPanel/DetailForm.tsx new file mode 100644 index 00000000..c89265b6 --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/DetailForm.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Card, Input, Select, Form } from 'antd'; +import { withPropsAPI } from 'gg-editor'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +const { Item } = Form; +const { Option } = Select; + +const inlineFormItemLayout = { + labelCol: { + sm: { span: 8 }, + }, + wrapperCol: { + sm: { span: 16 }, + }, +}; + +type DetailFormProps = { + type: string; + propsAPI?: any; +}; + +class DetailForm extends React.Component { + get item() { + const { propsAPI } = this.props; + return propsAPI.getSelected()[0]; + } + + handleFieldChange = (values: any) => { + const { propsAPI } = this.props; + const { getSelected, executeCommand, update } = propsAPI; + + setTimeout(() => { + const item = getSelected()[0]; + if (!item) { + return; + } + executeCommand(() => { + update(item, { + ...values, + }); + }); + }, 0); + }; + + handleInputBlur = (type: string) => (e: React.FormEvent) => { + e.preventDefault(); + this.handleFieldChange({ + [type]: e.currentTarget.value, + }); + }; + + renderNodeDetail = () => { + const { label } = this.item.getModel(); + + return ( +
    + + + +
    + ); + }; + + renderEdgeDetail = () => { + const { label = '', shape = 'flow-smooth' } = this.item.getModel(); + + return ( +
    + + + + + + +
    + ); + }; + + renderGroupDetail = () => { + const { label = '新建分组' } = this.item.getModel(); + + return ( +
    + + + +
    + ); + }; + + render() { + const { type } = this.props; + if (!this.item) { + return null; + } + + return ( + + {type === 'node' && this.renderNodeDetail()} + {type === 'edge' && this.renderEdgeDetail()} + {type === 'group' && this.renderGroupDetail()} + + ); + } +} + +export default withPropsAPI(DetailForm as any); diff --git a/src/pages/editor/koni/components/EditorDetailPanel/FlowDetailPanel.tsx b/src/pages/editor/koni/components/EditorDetailPanel/FlowDetailPanel.tsx new file mode 100644 index 00000000..bb7035c9 --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/FlowDetailPanel.tsx @@ -0,0 +1,27 @@ +import { CanvasPanel, DetailPanel, EdgePanel, GroupPanel, MultiPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const FlowDetailPanel = () => ( + + + + + + + + + + + + + + + + + +); + +export default FlowDetailPanel; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/KoniDetailPanel.tsx b/src/pages/editor/koni/components/EditorDetailPanel/KoniDetailPanel.tsx new file mode 100644 index 00000000..18aea9a4 --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/KoniDetailPanel.tsx @@ -0,0 +1,3 @@ +import FlowDetailPanel from './FlowDetailPanel'; + +export default FlowDetailPanel; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/MindDetailPanel.tsx b/src/pages/editor/koni/components/EditorDetailPanel/MindDetailPanel.tsx new file mode 100644 index 00000000..dceaf36e --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/MindDetailPanel.tsx @@ -0,0 +1,18 @@ +import { CanvasPanel, DetailPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const MindDetailPanel = () => ( + + + + + + + + +); + +export default MindDetailPanel; diff --git a/src/pages/editor/koni/components/EditorDetailPanel/index.less b/src/pages/editor/koni/components/EditorDetailPanel/index.less new file mode 100644 index 00000000..0b2c564b --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/index.less @@ -0,0 +1,6 @@ +@import '~antd/es/style/themes/default.less'; + +.detailPanel { + flex: 1; + background-color: @component-background; +} diff --git a/src/pages/editor/koni/components/EditorDetailPanel/index.tsx b/src/pages/editor/koni/components/EditorDetailPanel/index.tsx new file mode 100644 index 00000000..50aa37a8 --- /dev/null +++ b/src/pages/editor/koni/components/EditorDetailPanel/index.tsx @@ -0,0 +1,5 @@ +import FlowDetailPanel from './FlowDetailPanel'; +import KoniDetailPanel from './KoniDetailPanel'; +import MindDetailPanel from './MindDetailPanel'; + +export { FlowDetailPanel, MindDetailPanel, KoniDetailPanel }; diff --git a/src/pages/editor/koni/components/EditorItemPanel/FlowItemPanel.tsx b/src/pages/editor/koni/components/EditorItemPanel/FlowItemPanel.tsx new file mode 100644 index 00000000..c7dcbe60 --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/FlowItemPanel.tsx @@ -0,0 +1,53 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import styles from './index.less'; + +const FlowItemPanel = () => ( + + + + + + + + +); + +export default FlowItemPanel; diff --git a/src/pages/editor/koni/components/EditorItemPanel/KoniItemPanel.tsx b/src/pages/editor/koni/components/EditorItemPanel/KoniItemPanel.tsx new file mode 100644 index 00000000..3901d292 --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/KoniItemPanel.tsx @@ -0,0 +1,49 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import styles from './index.less'; + +const KoniItemPanel = () => ( + + + + + + + +); + +export default KoniItemPanel; diff --git a/src/pages/editor/koni/components/EditorItemPanel/index.less b/src/pages/editor/koni/components/EditorItemPanel/index.less new file mode 100644 index 00000000..b594a66b --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/index.less @@ -0,0 +1,19 @@ +@import '~antd/es/style/themes/default.less'; + +.itemPanel { + flex: 1; + + :global { + .ant-card { + height: 100%; + } + .ant-card-body { + display: flex; + flex-direction: column; + align-items: center; + > div { + margin-bottom: 16px; + } + } + } +} diff --git a/src/pages/editor/koni/components/EditorItemPanel/index.tsx b/src/pages/editor/koni/components/EditorItemPanel/index.tsx new file mode 100644 index 00000000..2ba03fbb --- /dev/null +++ b/src/pages/editor/koni/components/EditorItemPanel/index.tsx @@ -0,0 +1,4 @@ +import FlowItemPanel from './FlowItemPanel'; +import KoniItemPanel from './KoniItemPanel'; + +export { FlowItemPanel, KoniItemPanel }; diff --git a/src/pages/editor/koni/components/EditorMinimap/index.tsx b/src/pages/editor/koni/components/EditorMinimap/index.tsx new file mode 100644 index 00000000..b337edbf --- /dev/null +++ b/src/pages/editor/koni/components/EditorMinimap/index.tsx @@ -0,0 +1,10 @@ +import { Card } from 'antd'; +import { Minimap } from 'gg-editor'; + +const EditorMinimap = () => ( + + + +); + +export default EditorMinimap; diff --git a/src/pages/editor/koni/components/EditorToolbar/FlowToolbar.tsx b/src/pages/editor/koni/components/EditorToolbar/FlowToolbar.tsx new file mode 100644 index 00000000..90494876 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/FlowToolbar.tsx @@ -0,0 +1,29 @@ +import { Divider } from 'antd'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/koni/components/EditorToolbar/KoniToolbar.tsx b/src/pages/editor/koni/components/EditorToolbar/KoniToolbar.tsx new file mode 100644 index 00000000..f222007a --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/KoniToolbar.tsx @@ -0,0 +1,3 @@ +import FlowToolbar from './FlowToolbar'; + +export default FlowToolbar; diff --git a/src/pages/editor/koni/components/EditorToolbar/MindToolbar.tsx b/src/pages/editor/koni/components/EditorToolbar/MindToolbar.tsx new file mode 100644 index 00000000..23761093 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/MindToolbar.tsx @@ -0,0 +1,24 @@ +import { Divider } from 'antd'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/koni/components/EditorToolbar/ToolbarButton.tsx b/src/pages/editor/koni/components/EditorToolbar/ToolbarButton.tsx new file mode 100644 index 00000000..94f9e9c6 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/ToolbarButton.tsx @@ -0,0 +1,31 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import { Tooltip } from 'antd'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +type ToolbarButtonProps = { + command: string; + icon?: string; + text?: string; +}; +const ToolbarButton: React.FC = (props) => { + const { command, icon, text } = props; + + return ( + + + + + + ); +}; + +export default ToolbarButton; diff --git a/src/pages/editor/koni/components/EditorToolbar/index.less b/src/pages/editor/koni/components/EditorToolbar/index.less new file mode 100644 index 00000000..4edd156f --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/index.less @@ -0,0 +1,39 @@ +@import '~antd/es/style/themes/default.less'; + +.toolbar { + display: flex; + align-items: center; + :global { + .command .anticon { + display: inline-block; + width: 27px; + height: 27px; + margin: 0 6px; + padding-top: 6px; + text-align: center; + cursor: pointer; + + &:hover { + border: 1px solid @item-active-bg; + } + } + + .disable .anticon { + color: @text-color-secondary; + cursor: auto; + + &:hover { + border: 1px solid @border-color-base; + } + } + } +} + +.tooltip { + :global { + .ant-tooltip-inner { + font-size: 12px; + border-radius: 0; + } + } +} diff --git a/src/pages/editor/koni/components/EditorToolbar/index.tsx b/src/pages/editor/koni/components/EditorToolbar/index.tsx new file mode 100644 index 00000000..58f1d277 --- /dev/null +++ b/src/pages/editor/koni/components/EditorToolbar/index.tsx @@ -0,0 +1,5 @@ +import FlowToolbar from './FlowToolbar'; +import KoniToolbar from './KoniToolbar'; +import MindToolbar from './MindToolbar'; + +export { FlowToolbar, MindToolbar, KoniToolbar }; diff --git a/src/pages/editor/koni/index.less b/src/pages/editor/koni/index.less new file mode 100644 index 00000000..42923fcb --- /dev/null +++ b/src/pages/editor/koni/index.less @@ -0,0 +1,56 @@ +@import '~antd/es/style/themes/default.less'; + +.editor { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: calc(100vh - 250px); + background: @descriptions-bg; +} + +.editorHd { + padding: 8px; + background: @descriptions-bg; + border: 1px solid @item-active-bg; +} + +.editorBd { + flex: 1; +} + +.editorSidebar, +.editorContent { + display: flex; + flex-direction: column; +} + +.editorContent { + :global { + .graph-container canvas { + vertical-align: middle; + } + } +} + +.editorSidebar { + background: @descriptions-bg; + :global { + .g6-editor-minimap-container { + background: none !important ; + } + } + &:first-child { + border-right: 1px solid @item-active-bg; + } + + &:last-child { + border-left: 1px solid @item-active-bg; + } +} + +.flow, +.mind, +.koni { + flex: 1; +} diff --git a/src/pages/editor/koni/index.tsx b/src/pages/editor/koni/index.tsx new file mode 100644 index 00000000..f94da640 --- /dev/null +++ b/src/pages/editor/koni/index.tsx @@ -0,0 +1,37 @@ +import { Col, Row } from 'antd'; +import GGEditor, { Koni } from 'gg-editor'; + +import { PageContainer } from '@ant-design/pro-layout'; +import EditorMinimap from './components/EditorMinimap'; +import { KoniContextMenu } from './components/EditorContextMenu'; +import { KoniDetailPanel } from './components/EditorDetailPanel'; +import { KoniItemPanel } from './components/EditorItemPanel'; +import { KoniToolbar } from './components/EditorToolbar'; +import styles from './index.less'; + +GGEditor.setTrackable(false); + +export default () => ( + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/src/pages/editor/mind/common/IconFont/index.ts b/src/pages/editor/mind/common/IconFont/index.ts new file mode 100644 index 00000000..987647c0 --- /dev/null +++ b/src/pages/editor/mind/common/IconFont/index.ts @@ -0,0 +1,7 @@ +import { createFromIconfontCN } from '@ant-design/icons'; + +const IconFont = createFromIconfontCN({ + scriptUrl: 'https://at.alicdn.com/t/font_1101588_01zniftxm9yp.js', +}); + +export default IconFont; diff --git a/src/pages/editor/mind/components/EditorContextMenu/FlowContextMenu.tsx b/src/pages/editor/mind/components/EditorContextMenu/FlowContextMenu.tsx new file mode 100644 index 00000000..c5cc4e50 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/FlowContextMenu.tsx @@ -0,0 +1,34 @@ +import { CanvasMenu, ContextMenu, EdgeMenu, GroupMenu, MultiMenu, NodeMenu } from 'gg-editor'; + +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const FlowContextMenu = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default FlowContextMenu; diff --git a/src/pages/editor/mind/components/EditorContextMenu/KoniContextMenu.tsx b/src/pages/editor/mind/components/EditorContextMenu/KoniContextMenu.tsx new file mode 100644 index 00000000..8b049a5e --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/KoniContextMenu.tsx @@ -0,0 +1,3 @@ +import FlowContextMenu from './FlowContextMenu'; + +export default FlowContextMenu; diff --git a/src/pages/editor/mind/components/EditorContextMenu/MenuItem.tsx b/src/pages/editor/mind/components/EditorContextMenu/MenuItem.tsx new file mode 100644 index 00000000..297967ab --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/MenuItem.tsx @@ -0,0 +1,27 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +type MenuItemProps = { + command: string; + icon?: string; + text?: string; +}; +const MenuItem: React.FC = (props) => { + const { command, icon, text } = props; + + return ( + +
    + + {text || upperFirst(command)} +
    +
    + ); +}; + +export default MenuItem; diff --git a/src/pages/editor/mind/components/EditorContextMenu/MindContextMenu.tsx b/src/pages/editor/mind/components/EditorContextMenu/MindContextMenu.tsx new file mode 100644 index 00000000..5a8b4076 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/MindContextMenu.tsx @@ -0,0 +1,22 @@ +import { CanvasMenu, ContextMenu, NodeMenu } from 'gg-editor'; + +import MenuItem from './MenuItem'; +import styles from './index.less'; + +const MindContextMenu = () => ( + + + + + + + + + + + + + +); + +export default MindContextMenu; diff --git a/src/pages/editor/mind/components/EditorContextMenu/index.less b/src/pages/editor/mind/components/EditorContextMenu/index.less new file mode 100644 index 00000000..25d68b2c --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/index.less @@ -0,0 +1,40 @@ +@import '~antd/es/style/themes/default.less'; + +.contextMenu { + display: none; + overflow: hidden; + background: @component-background; + border-radius: 4px; + box-shadow: @box-shadow-base; + .item { + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + transition: all 0.3s; + user-select: none; + + &:hover { + background: @select-item-selected-bg; + } + + .anticon { + margin-right: 8px; + } + } + + :global { + .disable { + :local { + .item { + color: @disabled-color; + cursor: auto; + + &:hover { + background: @item-hover-bg; + } + } + } + } + } +} diff --git a/src/pages/editor/mind/components/EditorContextMenu/index.tsx b/src/pages/editor/mind/components/EditorContextMenu/index.tsx new file mode 100644 index 00000000..6ee93423 --- /dev/null +++ b/src/pages/editor/mind/components/EditorContextMenu/index.tsx @@ -0,0 +1,5 @@ +import FlowContextMenu from './FlowContextMenu'; +import KoniContextMenu from './KoniContextMenu'; +import MindContextMenu from './MindContextMenu'; + +export { FlowContextMenu, MindContextMenu, KoniContextMenu }; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/DetailForm.tsx b/src/pages/editor/mind/components/EditorDetailPanel/DetailForm.tsx new file mode 100644 index 00000000..c89265b6 --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/DetailForm.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Card, Input, Select, Form } from 'antd'; +import { withPropsAPI } from 'gg-editor'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +const { Item } = Form; +const { Option } = Select; + +const inlineFormItemLayout = { + labelCol: { + sm: { span: 8 }, + }, + wrapperCol: { + sm: { span: 16 }, + }, +}; + +type DetailFormProps = { + type: string; + propsAPI?: any; +}; + +class DetailForm extends React.Component { + get item() { + const { propsAPI } = this.props; + return propsAPI.getSelected()[0]; + } + + handleFieldChange = (values: any) => { + const { propsAPI } = this.props; + const { getSelected, executeCommand, update } = propsAPI; + + setTimeout(() => { + const item = getSelected()[0]; + if (!item) { + return; + } + executeCommand(() => { + update(item, { + ...values, + }); + }); + }, 0); + }; + + handleInputBlur = (type: string) => (e: React.FormEvent) => { + e.preventDefault(); + this.handleFieldChange({ + [type]: e.currentTarget.value, + }); + }; + + renderNodeDetail = () => { + const { label } = this.item.getModel(); + + return ( +
    + + + +
    + ); + }; + + renderEdgeDetail = () => { + const { label = '', shape = 'flow-smooth' } = this.item.getModel(); + + return ( +
    + + + + + + +
    + ); + }; + + renderGroupDetail = () => { + const { label = '新建分组' } = this.item.getModel(); + + return ( +
    + + + +
    + ); + }; + + render() { + const { type } = this.props; + if (!this.item) { + return null; + } + + return ( + + {type === 'node' && this.renderNodeDetail()} + {type === 'edge' && this.renderEdgeDetail()} + {type === 'group' && this.renderGroupDetail()} + + ); + } +} + +export default withPropsAPI(DetailForm as any); diff --git a/src/pages/editor/mind/components/EditorDetailPanel/FlowDetailPanel.tsx b/src/pages/editor/mind/components/EditorDetailPanel/FlowDetailPanel.tsx new file mode 100644 index 00000000..bb7035c9 --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/FlowDetailPanel.tsx @@ -0,0 +1,27 @@ +import { CanvasPanel, DetailPanel, EdgePanel, GroupPanel, MultiPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const FlowDetailPanel = () => ( + + + + + + + + + + + + + + + + + +); + +export default FlowDetailPanel; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/KoniDetailPanel.tsx b/src/pages/editor/mind/components/EditorDetailPanel/KoniDetailPanel.tsx new file mode 100644 index 00000000..18aea9a4 --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/KoniDetailPanel.tsx @@ -0,0 +1,3 @@ +import FlowDetailPanel from './FlowDetailPanel'; + +export default FlowDetailPanel; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/MindDetailPanel.tsx b/src/pages/editor/mind/components/EditorDetailPanel/MindDetailPanel.tsx new file mode 100644 index 00000000..dceaf36e --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/MindDetailPanel.tsx @@ -0,0 +1,18 @@ +import { CanvasPanel, DetailPanel, NodePanel } from 'gg-editor'; + +import { Card } from 'antd'; +import DetailForm from './DetailForm'; +import styles from './index.less'; + +const MindDetailPanel = () => ( + + + + + + + + +); + +export default MindDetailPanel; diff --git a/src/pages/editor/mind/components/EditorDetailPanel/index.less b/src/pages/editor/mind/components/EditorDetailPanel/index.less new file mode 100644 index 00000000..0b2c564b --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/index.less @@ -0,0 +1,6 @@ +@import '~antd/es/style/themes/default.less'; + +.detailPanel { + flex: 1; + background-color: @component-background; +} diff --git a/src/pages/editor/mind/components/EditorDetailPanel/index.tsx b/src/pages/editor/mind/components/EditorDetailPanel/index.tsx new file mode 100644 index 00000000..50aa37a8 --- /dev/null +++ b/src/pages/editor/mind/components/EditorDetailPanel/index.tsx @@ -0,0 +1,5 @@ +import FlowDetailPanel from './FlowDetailPanel'; +import KoniDetailPanel from './KoniDetailPanel'; +import MindDetailPanel from './MindDetailPanel'; + +export { FlowDetailPanel, MindDetailPanel, KoniDetailPanel }; diff --git a/src/pages/editor/mind/components/EditorItemPanel/FlowItemPanel.tsx b/src/pages/editor/mind/components/EditorItemPanel/FlowItemPanel.tsx new file mode 100644 index 00000000..c7dcbe60 --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/FlowItemPanel.tsx @@ -0,0 +1,53 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import styles from './index.less'; + +const FlowItemPanel = () => ( + + + + + + + + +); + +export default FlowItemPanel; diff --git a/src/pages/editor/mind/components/EditorItemPanel/KoniItemPanel.tsx b/src/pages/editor/mind/components/EditorItemPanel/KoniItemPanel.tsx new file mode 100644 index 00000000..3901d292 --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/KoniItemPanel.tsx @@ -0,0 +1,49 @@ +import { Item, ItemPanel } from 'gg-editor'; + +import { Card } from 'antd'; +import styles from './index.less'; + +const KoniItemPanel = () => ( + + + + + + + +); + +export default KoniItemPanel; diff --git a/src/pages/editor/mind/components/EditorItemPanel/index.less b/src/pages/editor/mind/components/EditorItemPanel/index.less new file mode 100644 index 00000000..b594a66b --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/index.less @@ -0,0 +1,19 @@ +@import '~antd/es/style/themes/default.less'; + +.itemPanel { + flex: 1; + + :global { + .ant-card { + height: 100%; + } + .ant-card-body { + display: flex; + flex-direction: column; + align-items: center; + > div { + margin-bottom: 16px; + } + } + } +} diff --git a/src/pages/editor/mind/components/EditorItemPanel/index.tsx b/src/pages/editor/mind/components/EditorItemPanel/index.tsx new file mode 100644 index 00000000..2ba03fbb --- /dev/null +++ b/src/pages/editor/mind/components/EditorItemPanel/index.tsx @@ -0,0 +1,4 @@ +import FlowItemPanel from './FlowItemPanel'; +import KoniItemPanel from './KoniItemPanel'; + +export { FlowItemPanel, KoniItemPanel }; diff --git a/src/pages/editor/mind/components/EditorMinimap/index.tsx b/src/pages/editor/mind/components/EditorMinimap/index.tsx new file mode 100644 index 00000000..b337edbf --- /dev/null +++ b/src/pages/editor/mind/components/EditorMinimap/index.tsx @@ -0,0 +1,10 @@ +import { Card } from 'antd'; +import { Minimap } from 'gg-editor'; + +const EditorMinimap = () => ( + + + +); + +export default EditorMinimap; diff --git a/src/pages/editor/mind/components/EditorToolbar/FlowToolbar.tsx b/src/pages/editor/mind/components/EditorToolbar/FlowToolbar.tsx new file mode 100644 index 00000000..90494876 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/FlowToolbar.tsx @@ -0,0 +1,29 @@ +import { Divider } from 'antd'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/mind/components/EditorToolbar/KoniToolbar.tsx b/src/pages/editor/mind/components/EditorToolbar/KoniToolbar.tsx new file mode 100644 index 00000000..f222007a --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/KoniToolbar.tsx @@ -0,0 +1,3 @@ +import FlowToolbar from './FlowToolbar'; + +export default FlowToolbar; diff --git a/src/pages/editor/mind/components/EditorToolbar/MindToolbar.tsx b/src/pages/editor/mind/components/EditorToolbar/MindToolbar.tsx new file mode 100644 index 00000000..23761093 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/MindToolbar.tsx @@ -0,0 +1,24 @@ +import { Divider } from 'antd'; +import { Toolbar } from 'gg-editor'; +import ToolbarButton from './ToolbarButton'; +import styles from './index.less'; + +const FlowToolbar = () => ( + + + + + + + + + + + + + + + +); + +export default FlowToolbar; diff --git a/src/pages/editor/mind/components/EditorToolbar/ToolbarButton.tsx b/src/pages/editor/mind/components/EditorToolbar/ToolbarButton.tsx new file mode 100644 index 00000000..94f9e9c6 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/ToolbarButton.tsx @@ -0,0 +1,31 @@ +import { Command } from 'gg-editor'; +import React from 'react'; +import { Tooltip } from 'antd'; +import IconFont from '../../common/IconFont'; +import styles from './index.less'; + +const upperFirst = (str: string) => + str.toLowerCase().replace(/( |^)[a-z]/g, (l: string) => l.toUpperCase()); + +type ToolbarButtonProps = { + command: string; + icon?: string; + text?: string; +}; +const ToolbarButton: React.FC = (props) => { + const { command, icon, text } = props; + + return ( + + + + + + ); +}; + +export default ToolbarButton; diff --git a/src/pages/editor/mind/components/EditorToolbar/index.less b/src/pages/editor/mind/components/EditorToolbar/index.less new file mode 100644 index 00000000..d9e98d1f --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/index.less @@ -0,0 +1,40 @@ +@import '~antd/es/style/themes/default.less'; + +.toolbar { + display: flex; + align-items: center; + + :global { + .command .anticon { + display: inline-block; + width: 27px; + height: 27px; + margin: 0 6px; + padding-top: 6px; + text-align: center; + cursor: pointer; + + &:hover { + border: 1px solid @item-active-bg; + } + } + + .disable .anticon { + color: @text-color-secondary; + cursor: auto; + + &:hover { + border: 1px solid @border-color-base; + } + } + } +} + +.tooltip { + :global { + .ant-tooltip-inner { + font-size: 12px; + border-radius: 0; + } + } +} diff --git a/src/pages/editor/mind/components/EditorToolbar/index.tsx b/src/pages/editor/mind/components/EditorToolbar/index.tsx new file mode 100644 index 00000000..58f1d277 --- /dev/null +++ b/src/pages/editor/mind/components/EditorToolbar/index.tsx @@ -0,0 +1,5 @@ +import FlowToolbar from './FlowToolbar'; +import KoniToolbar from './KoniToolbar'; +import MindToolbar from './MindToolbar'; + +export { FlowToolbar, MindToolbar, KoniToolbar }; diff --git a/src/pages/editor/mind/index.less b/src/pages/editor/mind/index.less new file mode 100644 index 00000000..42923fcb --- /dev/null +++ b/src/pages/editor/mind/index.less @@ -0,0 +1,56 @@ +@import '~antd/es/style/themes/default.less'; + +.editor { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: calc(100vh - 250px); + background: @descriptions-bg; +} + +.editorHd { + padding: 8px; + background: @descriptions-bg; + border: 1px solid @item-active-bg; +} + +.editorBd { + flex: 1; +} + +.editorSidebar, +.editorContent { + display: flex; + flex-direction: column; +} + +.editorContent { + :global { + .graph-container canvas { + vertical-align: middle; + } + } +} + +.editorSidebar { + background: @descriptions-bg; + :global { + .g6-editor-minimap-container { + background: none !important ; + } + } + &:first-child { + border-right: 1px solid @item-active-bg; + } + + &:last-child { + border-left: 1px solid @item-active-bg; + } +} + +.flow, +.mind, +.koni { + flex: 1; +} diff --git a/src/pages/editor/mind/index.tsx b/src/pages/editor/mind/index.tsx new file mode 100644 index 00000000..0760684d --- /dev/null +++ b/src/pages/editor/mind/index.tsx @@ -0,0 +1,34 @@ +import { Col, Row } from 'antd'; +import GGEditor, { Mind } from 'gg-editor'; + +import { PageContainer } from '@ant-design/pro-layout'; +import EditorMinimap from './components/EditorMinimap'; +import { MindContextMenu } from './components/EditorContextMenu'; +import { MindDetailPanel } from './components/EditorDetailPanel'; +import { MindToolbar } from './components/EditorToolbar'; +import data from './worldCup2018.json'; +import styles from './index.less'; + +GGEditor.setTrackable(false); + +export default () => ( + + + + + + + + + + + + + + + + + + + +); diff --git a/src/pages/editor/mind/worldCup2018.json b/src/pages/editor/mind/worldCup2018.json new file mode 100644 index 00000000..44f3e63f --- /dev/null +++ b/src/pages/editor/mind/worldCup2018.json @@ -0,0 +1,129 @@ +{ + "roots": [ + { + "label": "法国", + "children": [ + { + "label": "克罗地亚", + "side": "left", + "children": [ + { + "label": "克罗地亚", + "children": [ + { + "label": "克罗地亚", + "children": [ + { + "label": "克罗地亚" + }, + { + "label": "丹麦" + } + ] + }, + { + "label": "俄罗斯", + "children": [ + { + "label": "俄罗斯" + }, + { + "label": "西班牙" + } + ] + } + ] + }, + { + "label": "英格兰", + "children": [ + { + "label": "英格兰", + "children": [ + { + "label": "英格兰" + }, + { + "label": "哥伦比亚" + } + ] + }, + { + "label": "瑞典", + "children": [ + { + "label": "瑞士" + }, + { + "label": "瑞典" + } + ] + } + ] + } + ] + }, + { + "label": "法国", + "side": "right", + "children": [ + { + "label": "法国", + "children": [ + { + "label": "法国", + "children": [ + { + "label": "法国" + }, + { + "label": "阿根廷" + } + ] + }, + { + "label": "乌拉圭", + "children": [ + { + "label": "乌拉圭" + }, + { + "label": "葡萄牙" + } + ] + } + ] + }, + { + "label": "比利时", + "children": [ + { + "label": "比利时", + "children": [ + { + "label": "比利时" + }, + { + "label": "日本" + } + ] + }, + { + "label": "巴西", + "children": [ + { + "label": "巴西" + }, + { + "label": "墨西哥" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/pages/exception/403/index.tsx b/src/pages/exception/403/index.tsx new file mode 100644 index 00000000..1c73f934 --- /dev/null +++ b/src/pages/exception/403/index.tsx @@ -0,0 +1,18 @@ +import { Link } from 'umi'; +import { Result, Button } 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..60473b3f --- /dev/null +++ b/src/pages/exception/404/index.tsx @@ -0,0 +1,18 @@ +import { Link } from 'umi'; +import { Result, Button } 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..44efc5b2 --- /dev/null +++ b/src/pages/exception/500/index.tsx @@ -0,0 +1,18 @@ +import { Link } from 'umi'; +import { Result, Button } 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..67f34e09 --- /dev/null +++ b/src/pages/form/advanced-form/_mock.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +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..26059b67 --- /dev/null +++ b/src/pages/form/advanced-form/components/TableForm.tsx @@ -0,0 +1,257 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Divider, Input, Popconfirm, Table, message } from 'antd'; +import type { FC } from 'react'; +import React, { useState } from 'react'; + +import styles from '../style.less'; + +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 [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: string, + key: string, + ) => { + const newData = [...(data as TableFormDateType[])]; + const target = getRowByKey(key, newData); + if (target) { + target[fieldName] = 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..ed611856 --- /dev/null +++ b/src/pages/form/advanced-form/index.tsx @@ -0,0 +1,386 @@ +import { CloseCircleOutlined } from '@ant-design/icons'; +import { Card, Col, Popover, Row, message } from 'antd'; + +import type { FC } from 'react'; +import { useState } from 'react'; +import ProForm, { + ProFormDateRangePicker, + ProFormSelect, + ProFormText, + ProFormTimePicker, +} from '@ant-design/pro-form'; +import type { ProColumnType } from '@ant-design/pro-table'; +import { EditableProTable } from '@ant-design/pro-table'; +import { PageContainer, FooterToolbar } from '@ant-design/pro-layout'; +import { fakeSubmitForm } from './service'; +import styles from './style.less'; + +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 [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 string; + return ( +
  • scrollToField(key)}> + +
    {err.errors[0]}
    +
    {fieldLabels[key]}
    +
  • + ); + }); + return ( + + { + if (trigger && 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..dd58aff5 --- /dev/null +++ b/src/pages/form/advanced-form/service.ts @@ -0,0 +1,8 @@ +import { request } from 'umi'; + +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/basic-form/_mock.ts b/src/pages/form/basic-form/_mock.ts new file mode 100644 index 00000000..2a5a4779 --- /dev/null +++ b/src/pages/form/basic-form/_mock.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +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..d1e44831 --- /dev/null +++ b/src/pages/form/basic-form/index.tsx @@ -0,0 +1,188 @@ +import { Card, message } from 'antd'; +import ProForm, { + ProFormDateRangePicker, + ProFormDependency, + ProFormDigit, + ProFormRadio, + ProFormSelect, + ProFormText, + ProFormTextArea, +} from '@ant-design/pro-form'; +import { useRequest } from 'umi'; +import type { FC } from 'react'; +import { PageContainer } from '@ant-design/pro-layout'; +import { fakeSubmitForm } from './service'; +import styles from './style.less'; + +const BasicForm: FC> = () => { + 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) => (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..b2648f0c --- /dev/null +++ b/src/pages/form/basic-form/service.ts @@ -0,0 +1,8 @@ +import { request } from 'umi'; + +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/step-form/_mock.ts b/src/pages/form/step-form/_mock.ts new file mode 100644 index 00000000..dac59aba --- /dev/null +++ b/src/pages/form/step-form/_mock.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +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..e374dc3e --- /dev/null +++ b/src/pages/form/step-form/index.tsx @@ -0,0 +1,196 @@ +import React, { useRef, useState } from 'react'; +import type { FormInstance } from 'antd'; +import { Card, Result, Button, Descriptions, Divider, Alert, Statistic } from 'antd'; +import { PageContainer } from '@ant-design/pro-layout'; +import ProForm, { ProFormDigit, ProFormSelect, ProFormText, StepsForm } from '@ant-design/pro-form'; +import type { StepDataType } from './data.d'; +import styles from './style.less'; + +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; +}> = (props) => { + return ( + + + + + } + className={styles.result} + > + {props.children} + + ); +}; + +const StepForm: React.FC> = () => { + 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(); + + 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..51237c74 --- /dev/null +++ b/src/pages/form/step-form/service.ts @@ -0,0 +1,8 @@ +import { request } from 'umi'; + +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/list/basic-list/_mock.ts b/src/pages/list/basic-list/_mock.ts new file mode 100644 index 00000000..438153b5 --- /dev/null +++ b/src/pages/list/basic-list/_mock.ts @@ -0,0 +1,160 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +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(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + subDescription: desc[i % 5], + description: + '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。', + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + star: Math.ceil(Math.random() * 100) + 100, + like: Math.ceil(Math.random() * 100) + 100, + message: Math.ceil(Math.random() * 10) + 10, + content: + '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。', + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3', + }, + ], + }); + } + + return list; +} + +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: new Date().getTime(), + }); + 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..d524727d --- /dev/null +++ b/src/pages/list/basic-list/components/OperationModal.tsx @@ -0,0 +1,105 @@ +import type { FC } from 'react'; +import { + ModalForm, + ProFormSelect, + ProFormDateTimePicker, + ProFormText, + ProFormTextArea, +} from '@ant-design/pro-form'; +import type { BasicListItemDataType } from '../data.d'; +import styles from '../style.less'; +import { Button, Result } from 'antd'; + +type OperationModalProps = { + done: boolean; + visible: boolean; + current: Partial | undefined; + onDone: () => void; + onSubmit: (values: BasicListItemDataType) => void; +}; + +const OperationModal: FC = (props) => { + const { done, visible, current, onDone, onSubmit, children } = props; + if (!visible) { + return null; + } + return ( + + visible={visible} + 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..a0d03139 --- /dev/null +++ b/src/pages/list/basic-list/index.tsx @@ -0,0 +1,246 @@ +import type { FC } from 'react'; +import React, { useState } from 'react'; +import { DownOutlined, PlusOutlined } from '@ant-design/icons'; +import { + Avatar, + Button, + Card, + Col, + Dropdown, + Input, + List, + Menu, + Modal, + Progress, + Radio, + Row, +} from 'antd'; + +import { PageContainer } from '@ant-design/pro-layout'; +import { useRequest } from 'umi'; +import moment from 'moment'; +import OperationModal from './components/OperationModal'; +import { addFakeList, queryFakeList, removeFakeList, updateFakeList } from './service'; +import type { BasicListItemDataType } from './data.d'; +import styles from './style.less'; + +const RadioButton = Radio.Button; +const RadioGroup = Radio.Group; +const { Search } = Input; + +const Info: FC<{ + title: React.ReactNode; + value: React.ReactNode; + bordered?: boolean; +}> = ({ title, value, bordered }) => ( +
    + {title} +

    {value}

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

    {owner}

    +
    +
    + 开始时间 +

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

    +
    +
    + +
    +
    +); + +export const BasicList: FC = () => { + const [done, setDone] = useState(false); + const [visible, setVisible] = useState(false); + const [current, setCurrent] = useState | 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 = ( +
    + + 全部 + 进行中 + 等待中 + + ({})} /> +
    + ); + + const MoreBtn: React.FC<{ + item: BasicListItemDataType; + }> = ({ item }) => ( + editAndDelete(key, item)}> + 编辑 + 删除 + + } + > + + 更多 + + + ); + + 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..4aa8f8d8 --- /dev/null +++ b/src/pages/list/basic-list/service.ts @@ -0,0 +1,50 @@ +import { request } from 'umi'; +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.less b/src/pages/list/basic-list/style.less new file mode 100644 index 00000000..2d3a58cc --- /dev/null +++ b/src/pages/list/basic-list/style.less @@ -0,0 +1,195 @@ +@import '~antd/es/style/themes/default.less'; +@import './utils/utils.less'; + +.standardList { + :global { + .ant-card-head { + border-bottom: none; + } + .ant-card-head-title { + padding: 24px 0; + line-height: 32px; + } + .ant-card-extra { + padding: 24px 0; + } + .ant-list-pagination { + margin-top: 24px; + text-align: right; + } + .ant-avatar-lg { + width: 48px; + height: 48px; + line-height: 48px; + } + } + .headerInfo { + position: relative; + text-align: center; + & > span { + display: inline-block; + margin-bottom: 4px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + & > p { + margin: 0; + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + & > em { + position: absolute; + top: 0; + right: 0; + width: 1px; + height: 56px; + background-color: @border-color-split; + } + } + .listContent { + font-size: 0; + .listContentItem { + display: inline-block; + margin-left: 40px; + color: @text-color-secondary; + font-size: @font-size-base; + vertical-align: middle; + > span { + line-height: 20px; + } + > p { + margin-top: 4px; + margin-bottom: 0; + line-height: 22px; + } + } + } + .extraContentSearch { + width: 272px; + margin-left: 16px; + } +} + +@media screen and (max-width: @screen-xs) { + .standardList { + :global { + .ant-list-item-content { + display: block; + flex: none; + width: 100%; + } + .ant-list-item-action { + margin-left: 0; + } + } + .listContent { + margin-left: 0; + & > div { + margin-left: 0; + } + } + .listCard { + :global { + .ant-card-head-title { + overflow: visible; + } + } + } + } +} + +@media screen and (max-width: @screen-sm) { + .standardList { + .extraContentSearch { + width: 100%; + margin-left: 0; + } + .headerInfo { + margin-bottom: 16px; + & > em { + display: none; + } + } + } +} + +@media screen and (max-width: @screen-md) { + .standardList { + .listContent { + & > div { + display: block; + } + & > div:last-child { + top: 0; + width: 100%; + } + } + } + .listCard { + :global { + .ant-radio-group { + display: block; + margin-bottom: 8px; + } + } + } +} + +@media screen and (max-width: @screen-lg) and (min-width: @screen-md) { + .standardList { + .listContent { + & > div { + display: block; + } + & > div:last-child { + top: 0; + width: 100%; + } + } + } +} + +@media screen and (max-width: @screen-xl) { + .standardList { + .listContent { + & > div { + margin-left: 24px; + } + & > div:last-child { + top: 0; + } + } + } +} + +@media screen and (max-width: 1400px) { + .standardList { + .listContent { + text-align: right; + & > div:last-child { + top: 0; + } + } + } +} + +.standardListForm { + :global { + .ant-form-item { + margin-bottom: 12px; + &:last-child { + margin-bottom: 32px; + padding-top: 4px; + } + } + } +} + +.formResult { + width: 100%; + [class^='title'] { + margin-bottom: 8px; + } +} diff --git a/src/pages/list/basic-list/utils/utils.less b/src/pages/list/basic-list/utils/utils.less new file mode 100644 index 00000000..de1aa642 --- /dev/null +++ b/src/pages/list/basic-list/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + margin-right: -1em; + padding-right: 1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: 0.2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} diff --git a/src/pages/list/card-list/_mock.ts b/src/pages/list/card-list/_mock.ts new file mode 100644 index 00000000..0aed36a3 --- /dev/null +++ b/src/pages/list/card-list/_mock.ts @@ -0,0 +1,120 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +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(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + subDescription: desc[i % 5], + description: + '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。', + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + star: Math.ceil(Math.random() * 100) + 100, + like: Math.ceil(Math.random() * 100) + 100, + message: Math.ceil(Math.random() * 10) + 10, + content: + '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。', + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3', + }, + ], + }); + } + + return list; +} + +function getFakeList(req: Request, res: Response) { + const params = req.query as any; + + const count = Number(params.count) * 1 || 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..d7c46ef1 --- /dev/null +++ b/src/pages/list/card-list/index.tsx @@ -0,0 +1,104 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Card, List, Typography } from 'antd'; +import { PageContainer } from '@ant-design/pro-layout'; +import { useRequest } from 'umi'; +import { queryFakeList } from './service'; +import type { CardListItemDataType } from './data.d'; +import styles from './style.less'; + +const { Paragraph } = Typography; + +const CardList = () => { + 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 && 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..fee9a314 --- /dev/null +++ b/src/pages/list/card-list/service.ts @@ -0,0 +1,10 @@ +import { request } from 'umi'; +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.less b/src/pages/list/card-list/style.less new file mode 100644 index 00000000..24a5bc67 --- /dev/null +++ b/src/pages/list/card-list/style.less @@ -0,0 +1,107 @@ +@import '~antd/es/style/themes/default.less'; +@import './utils/utils.less'; + +.cardList { + .card { + :global { + .ant-card-meta-title { + margin-bottom: 12px; + & > a { + display: inline-block; + max-width: 100%; + color: @heading-color; + } + } + .ant-card-body:hover { + .ant-card-meta-title > a { + color: @primary-color; + } + } + } + } + .item { + height: 64px; + } + + :global { + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } +} + +.extraImg { + width: 155px; + margin-top: -20px; + text-align: center; + img { + width: 100%; + } +} + +.newButton { + width: 100%; + height: 201px; + color: @text-color-secondary; + background-color: @component-background; + border-color: @border-color-base; +} + +.cardAvatar { + width: 48px; + height: 48px; + border-radius: 48px; +} + +.cardDescription { + .textOverflowMulti(); +} + +.pageHeaderContent { + position: relative; +} + +.contentLink { + margin-top: 16px; + a { + margin-right: 32px; + img { + width: 24px; + } + } + img { + margin-right: 8px; + vertical-align: middle; + } +} + +@media screen and (max-width: @screen-lg) { + .contentLink { + a { + margin-right: 16px; + } + } +} +@media screen and (max-width: @screen-md) { + .extraImg { + display: none; + } +} + +@media screen and (max-width: @screen-sm) { + .pageHeaderContent { + padding-bottom: 30px; + } + .contentLink { + position: absolute; + bottom: -4px; + left: 0; + width: 1000px; + a { + margin-right: 16px; + } + img { + margin-right: 4px; + } + } +} diff --git a/src/pages/list/card-list/utils/utils.less b/src/pages/list/card-list/utils/utils.less new file mode 100644 index 00000000..de1aa642 --- /dev/null +++ b/src/pages/list/card-list/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + margin-right: -1em; + padding-right: 1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: 0.2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} diff --git a/src/pages/list/search/applications/_mock.ts b/src/pages/list/search/applications/_mock.ts new file mode 100644 index 00000000..affe8bcc --- /dev/null +++ b/src/pages/list/search/applications/_mock.ts @@ -0,0 +1,119 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import type { Request, Response } from 'express'; +import type { ListItemDataType } from './data.d'; + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const covers = [ + 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iXjVmWVHbCJAyqvDxdtx.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png', +]; +const desc = [ + '那是一种内在的东西, 他们到达不了,也无法触及的', + '希望是一个好东西,也许是最好的,好东西是不会消亡的', + '生命就像一盒巧克力,结果往往出人意料', + '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + '那时候我只会想自己想要什么,从不想自己拥有什么', +]; +const user = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +function fakeList(count: number): ListItemDataType[] { + const list = []; + for (let i = 0; i < count; i += 1) { + list.push({ + id: `fake-list-${i}`, + owner: user[i % 10], + title: titles[i % 8], + avatar: avatars[i % 8], + cover: parseInt(`${i / 4}`, 10) % 2 === 0 ? covers[i % 4] : covers[3 - (i % 4)], + status: ['active', 'exception', 'normal'][i % 3] as + | 'normal' + | 'exception' + | 'active' + | 'success', + percent: Math.ceil(Math.random() * 50) + 50, + logo: avatars[i % 8], + href: 'https://ant.design', + updatedAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + subDescription: desc[i % 5], + description: + '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。', + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + star: Math.ceil(Math.random() * 100) + 100, + like: Math.ceil(Math.random() * 100) + 100, + message: Math.ceil(Math.random() * 10) + 10, + content: + '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。', + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3', + }, + ], + }); + } + + return list; +} + +function getFakeList(req: Request, res: Response) { + const params: 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.less b/src/pages/list/search/applications/components/StandardFormRow/index.less new file mode 100644 index 00000000..4067215f --- /dev/null +++ b/src/pages/list/search/applications/components/StandardFormRow/index.less @@ -0,0 +1,87 @@ +@import '~antd/es/style/themes/default.less'; + +.standardFormRow { + display: flex; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px dashed @border-color-split; + + :global { + .ant-form-item, + .ant-legacy-form-item { + margin-right: 24px; + } + .ant-form-item-label, + .ant-legacy-form-item-label { + label { + margin-right: 0; + color: @text-color; + } + } + .ant-form-item-label, + .ant-legacy-form-item-label, + .ant-form-item-control, + .ant-legacy-form-item-control { + padding: 0; + line-height: 32px; + } + } + .label { + flex: 0 0 auto; + margin-right: 24px; + color: @heading-color; + font-size: @font-size-base; + text-align: right; + & > span { + display: inline-block; + height: 32px; + line-height: 32px; + &::after { + content: ':'; + } + } + } + .content { + flex: 1 1 0; + :global { + .ant-form-item, + .ant-legacy-form-item { + &:last-child { + margin-right: 0; + } + } + } + } +} + +.standardFormRowLast { + margin-bottom: 0; + padding-bottom: 0; + border: none; +} + +.standardFormRowBlock { + :global { + .ant-form-item, + .ant-legacy-form-item, + div.ant-form-item-control-wrapper, + div.ant-legacy-form-item-control-wrapper { + display: block; + } + } +} + +.standardFormRowGrid { + :global { + .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; + } + } +} 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..fe4cb388 --- /dev/null +++ b/src/pages/list/search/applications/components/StandardFormRow/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +type StandardFormRowProps = { + title?: string; + last?: boolean; + block?: boolean; + grid?: boolean; + style?: React.CSSProperties; +}; + +const StandardFormRow: React.FC = ({ + title, + children, + last, + block, + grid, + ...rest +}) => { + 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.less b/src/pages/list/search/applications/components/TagSelect/index.less new file mode 100644 index 00000000..f366c587 --- /dev/null +++ b/src/pages/list/search/applications/components/TagSelect/index.less @@ -0,0 +1,34 @@ +@import '~antd/es/style/themes/default.less'; + +.tagSelect { + position: relative; + max-height: 32px; + margin-left: -8px; + overflow: hidden; + line-height: 32px; + transition: all 0.3s; + user-select: none; + :global { + .ant-tag { + margin-right: 24px; + padding: 0 8px; + font-size: @font-size-base; + } + } + &.expanded { + max-height: 200px; + transition: all 0.3s; + } + .trigger { + position: absolute; + top: 0; + right: 0; + + span.anticon { + font-size: 12px; + } + } + &.hasExpandTag { + padding-right: 50px; + } +} 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..5f10efd4 --- /dev/null +++ b/src/pages/list/search/applications/components/TagSelect/index.tsx @@ -0,0 +1,142 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons'; +import { useBoolean, useControllableValue } from 'ahooks'; +import { Tag } from 'antd'; +import classNames from 'classnames'; +import type { FC } from 'react'; +import React from 'react'; +import styles from './index.less'; + +const { CheckableTag } = Tag; + +export interface TagSelectOptionProps { + value: string | number; + style?: React.CSSProperties; + checked?: boolean; + onChange?: (value: string | number, state: boolean) => void; +} + +const TagSelectOption: React.FC & { + isTagSelectOption: boolean; +} = ({ children, checked, onChange, value }) => ( + onChange && onChange(value, state)} + > + {children} + +); + +TagSelectOption.isTagSelectOption = true; + +type TagSelectOptionElement = React.ReactElement; +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 { children, hideCheckAll = false, className, style, expandable, actionsText = {} } = props; + + const [expand, { toggle }] = useBoolean(); + + const [value, setValue] = useControllableValue<(string | number)[]>(props); + + const isTagSelectOption = (node: TagSelectOptionElement) => + node && + 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 && ( + { + toggle(); + }} + > + {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..abb16cac --- /dev/null +++ b/src/pages/list/search/applications/index.tsx @@ -0,0 +1,195 @@ +import { + DownloadOutlined, + EditOutlined, + EllipsisOutlined, + ShareAltOutlined, +} from '@ant-design/icons'; +import { Avatar, Card, Col, Dropdown, Form, List, Menu, Row, Select, Tooltip } from 'antd'; +import numeral from 'numeral'; +import type { FC } from 'react'; +import React from 'react'; +import { useRequest } from 'umi'; +import StandardFormRow from './components/StandardFormRow'; +import TagSelect from './components/TagSelect'; +import type { ListItemDataType } from './data.d'; +import { queryFakeList } from './service'; +import styles from './style.less'; + +const { Option } = Select; + +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 }) => ( +
    +
    +

    活跃用户

    +

    {activeUser}

    +
    +
    +

    新增用户

    +

    {newUser}

    +
    +
    +); + +export const Applications: FC> = () => { + const { data, loading, run } = useRequest((values: any) => { + console.log('form data', values); + return queryFakeList({ + count: 8, + }); + }); + + const list = data?.list || []; + + const itemMenu = ( + + + + 1st menu item + + + + + 2nd menu item + + + + + 3d menu item + + + + ); + + return ( +
    + +
    { + run(values); + }} + > + + + + 类目一 + 类目二 + 类目三 + 类目四 + 类目五 + 类目六 + 类目七 + 类目八 + 类目九 + 类目十 + 类目十一 + 类目十二 + + + + + + + + + + + + + + + + + +
    +
    +
    + + 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..b6fb5712 --- /dev/null +++ b/src/pages/list/search/applications/service.ts @@ -0,0 +1,10 @@ +import { request } from 'umi'; +import type { Params, ListItemDataType } 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.less b/src/pages/list/search/applications/style.less new file mode 100644 index 00000000..3daa5c3e --- /dev/null +++ b/src/pages/list/search/applications/style.less @@ -0,0 +1,50 @@ +@import '~antd/es/style/themes/default.less'; + +.filterCardList { + :global { + .ant-card-meta-content { + margin-top: 0; + } + // disabled white space + .ant-card-meta-avatar { + font-size: 0; + } + + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } + .cardInfo { + margin-top: 16px; + margin-left: 40px; + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + & > div { + position: relative; + float: left; + width: 50%; + text-align: left; + p { + margin: 0; + font-size: 24px; + line-height: 32px; + } + p:first-child { + margin-bottom: 4px; + color: @text-color-secondary; + font-size: 12px; + line-height: 20px; + } + } + } +} diff --git a/src/pages/list/search/applications/utils/utils.less b/src/pages/list/search/applications/utils/utils.less new file mode 100644 index 00000000..de1aa642 --- /dev/null +++ b/src/pages/list/search/applications/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + margin-right: -1em; + padding-right: 1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: 0.2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} diff --git a/src/pages/list/search/articles/_mock.ts b/src/pages/list/search/articles/_mock.ts new file mode 100644 index 00000000..e3fd954b --- /dev/null +++ b/src/pages/list/search/articles/_mock.ts @@ -0,0 +1,119 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import type { Request, Response } from 'express'; +import type { ListItemDataType } from './data.d'; + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack', +]; +const avatars = [ + 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap + 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React + 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue + 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack +]; + +const covers = [ + 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iXjVmWVHbCJAyqvDxdtx.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png', +]; +const desc = [ + '那是一种内在的东西, 他们到达不了,也无法触及的', + '希望是一个好东西,也许是最好的,好东西是不会消亡的', + '生命就像一盒巧克力,结果往往出人意料', + '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + '那时候我只会想自己想要什么,从不想自己拥有什么', +]; +const user = [ + '付小小', + '曲丽丽', + '林东东', + '周星星', + '吴加好', + '朱偏右', + '鱼酱', + '乐哥', + '谭小仪', + '仲尼', +]; + +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(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + subDescription: desc[i % 5], + description: + '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。', + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + star: Math.ceil(Math.random() * 100) + 100, + like: Math.ceil(Math.random() * 100) + 100, + message: Math.ceil(Math.random() * 10) + 10, + content: + '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。', + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3', + }, + ], + }); + } + + return list; +} + +function getFakeList(req: Request, res: Response) { + const params: 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.less b/src/pages/list/search/articles/components/ArticleListContent/index.less new file mode 100644 index 00000000..eca0811c --- /dev/null +++ b/src/pages/list/search/articles/components/ArticleListContent/index.less @@ -0,0 +1,38 @@ +@import '~antd/es/style/themes/default.less'; + +.listContent { + .description { + max-width: 720px; + line-height: 22px; + } + .extra { + margin-top: 16px; + color: @text-color-secondary; + line-height: 22px; + & > :global(.ant-avatar) { + position: relative; + top: 1px; + width: 20px; + height: 20px; + margin-right: 8px; + vertical-align: top; + } + & > em { + margin-left: 16px; + color: @disabled-color; + font-style: normal; + } + } +} + +@media screen and (max-width: @screen-xs) { + .listContent { + .extra { + & > em { + display: block; + margin-top: 8px; + margin-left: 0; + } + } + } +} diff --git a/src/pages/list/search/articles/components/ArticleListContent/index.tsx b/src/pages/list/search/articles/components/ArticleListContent/index.tsx new file mode 100644 index 00000000..b5734538 --- /dev/null +++ b/src/pages/list/search/articles/components/ArticleListContent/index.tsx @@ -0,0 +1,29 @@ +import { Avatar } from 'antd'; +import React from 'react'; +import moment from 'moment'; +import styles from './index.less'; + +type ArticleListContentProps = { + data: { + content: React.ReactNode; + updatedAt: number; + avatar: string; + owner: string; + href: string; + }; +}; + +const ArticleListContent: React.FC = ({ + data: { content, updatedAt, avatar, owner, href }, +}) => ( +
    +
    {content}
    +
    + + {owner} 发布在 {href} + {moment(updatedAt).format('YYYY-MM-DD HH:mm')} +
    +
    +); + +export default ArticleListContent; diff --git a/src/pages/list/search/articles/components/StandardFormRow/index.less b/src/pages/list/search/articles/components/StandardFormRow/index.less new file mode 100644 index 00000000..d619cd2c --- /dev/null +++ b/src/pages/list/search/articles/components/StandardFormRow/index.less @@ -0,0 +1,88 @@ +@import '~antd/es/style/themes/default.less'; + +.standardFormRow { + display: flex; + width: 100%; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px dashed @border-color-split; + :global { + .ant-form-item, + .ant-legacy-form-item { + margin-right: 24px; + } + .ant-form-item-label, + .ant-legacy-form-item-label { + label { + margin-right: 0; + color: @text-color; + } + } + .ant-form-item-label, + .ant-legacy-form-item-label, + .ant-form-item-control, + .ant-legacy-form-item-control { + padding: 0; + line-height: 32px; + } + } + .label { + flex: 0 0 auto; + margin-right: 24px; + color: @heading-color; + font-size: @font-size-base; + text-align: right; + & > span { + display: inline-block; + height: 32px; + line-height: 32px; + &::after { + content: ':'; + } + } + } + .content { + flex: 1 1 0; + :global { + .ant-form-item, + .ant-legacy-form-item { + &:last-child { + display: block; + margin-right: 0; + } + } + } + } +} + +.standardFormRowLast { + margin-bottom: 0; + padding-bottom: 0; + border: none; +} + +.standardFormRowBlock { + :global { + .ant-form-item, + .ant-legacy-form-item, + div.ant-form-item-control-wrapper, + div.ant-legacy-form-item-control-wrapper { + display: block; + } + } +} + +.standardFormRowGrid { + :global { + .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; + } + } +} 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..fe4cb388 --- /dev/null +++ b/src/pages/list/search/articles/components/StandardFormRow/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +type StandardFormRowProps = { + title?: string; + last?: boolean; + block?: boolean; + grid?: boolean; + style?: React.CSSProperties; +}; + +const StandardFormRow: React.FC = ({ + title, + children, + last, + block, + grid, + ...rest +}) => { + 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.less b/src/pages/list/search/articles/components/TagSelect/index.less new file mode 100644 index 00000000..b174f042 --- /dev/null +++ b/src/pages/list/search/articles/components/TagSelect/index.less @@ -0,0 +1,33 @@ +@import '~antd/es/style/themes/default.less'; + +.tagSelect { + position: relative; + max-height: 32px; + margin-left: -8px; + overflow: hidden; + line-height: 32px; + transition: all 0.3s; + user-select: none; + :global { + .ant-tag { + margin-right: 24px; + padding: 0 8px; + font-size: @font-size-base; + } + } + &.expanded { + max-height: 200px; + transition: all 0.3s; + } + .trigger { + position: absolute; + top: 0; + right: 0; + span.anticon { + font-size: 12px; + } + } + &.hasExpandTag { + padding-right: 50px; + } +} 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..5f10efd4 --- /dev/null +++ b/src/pages/list/search/articles/components/TagSelect/index.tsx @@ -0,0 +1,142 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons'; +import { useBoolean, useControllableValue } from 'ahooks'; +import { Tag } from 'antd'; +import classNames from 'classnames'; +import type { FC } from 'react'; +import React from 'react'; +import styles from './index.less'; + +const { CheckableTag } = Tag; + +export interface TagSelectOptionProps { + value: string | number; + style?: React.CSSProperties; + checked?: boolean; + onChange?: (value: string | number, state: boolean) => void; +} + +const TagSelectOption: React.FC & { + isTagSelectOption: boolean; +} = ({ children, checked, onChange, value }) => ( + onChange && onChange(value, state)} + > + {children} + +); + +TagSelectOption.isTagSelectOption = true; + +type TagSelectOptionElement = React.ReactElement; +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 { children, hideCheckAll = false, className, style, expandable, actionsText = {} } = props; + + const [expand, { toggle }] = useBoolean(); + + const [value, setValue] = useControllableValue<(string | number)[]>(props); + + const isTagSelectOption = (node: TagSelectOptionElement) => + node && + 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 && ( + { + toggle(); + }} + > + {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..e80bac27 --- /dev/null +++ b/src/pages/list/search/articles/index.tsx @@ -0,0 +1,224 @@ +import { LikeOutlined, LoadingOutlined, MessageOutlined, StarOutlined } from '@ant-design/icons'; +import { Button, Card, Col, Form, List, Row, Select, Tag } from 'antd'; +import type { FC } from 'react'; +import React from 'react'; +import { useRequest } from 'umi'; +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 styles from './style.less'; + +const { Option } = Select; +const FormItem = Form.Item; + +const pageSize = 5; + +const Articles: FC = () => { + const [form] = Form.useForm(); + + 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 && ( +
    + +
    + ); + + return ( + <> + +
    + + + + 类目一 + 类目二 + 类目三 + 类目四 + 类目五 + 类目六 + 类目七 + 类目八 + 类目九 + 类目十 + 类目十一 + 类目十二 + + + + + + + + + 只看自己的 + + + + + + + + + + + + + + + + +
    +
    + + + size="large" + loading={loading} + rowKey="id" + itemLayout="vertical" + loadMore={loadMoreDom} + dataSource={list} + renderItem={(item) => ( + , + , + , + ]} + extra={
    } + > + + {item.title} + + } + description={ + + Ant Design + 设计语言 + 蚂蚁金服 + + } + /> + + + )} + /> + + + ); +}; + +export default Articles; diff --git a/src/pages/list/search/articles/service.ts b/src/pages/list/search/articles/service.ts new file mode 100644 index 00000000..b6fb5712 --- /dev/null +++ b/src/pages/list/search/articles/service.ts @@ -0,0 +1,10 @@ +import { request } from 'umi'; +import type { Params, ListItemDataType } 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/articles/style.less b/src/pages/list/search/articles/style.less new file mode 100644 index 00000000..fe07d851 --- /dev/null +++ b/src/pages/list/search/articles/style.less @@ -0,0 +1,31 @@ +@import '~antd/es/style/themes/default.less'; + +a.listItemMetaTitle { + color: @heading-color; +} +.listItemExtra { + width: 272px; + height: 1px; +} +.selfTrigger { + margin-left: 12px; +} + +@media screen and (max-width: @screen-xs) { + .selfTrigger { + display: block; + margin-left: 0; + } +} +@media screen and (max-width: @screen-md) { + .selfTrigger { + display: block; + margin-left: 0; + } +} +@media screen and (max-width: @screen-lg) { + .listItemExtra { + width: 0; + height: 1px; + } +} diff --git a/src/pages/list/search/index.tsx b/src/pages/list/search/index.tsx new file mode 100644 index 00000000..bfffb33d --- /dev/null +++ b/src/pages/list/search/index.tsx @@ -0,0 +1,87 @@ +import { PageContainer } from '@ant-design/pro-layout'; +import { Input } from 'antd'; +import type { FC } from 'react'; +import { history } from 'umi'; + +type SearchProps = { + match: { + url: string; + path: string; + }; + location: { + pathname: string; + }; +}; + +const tabList = [ + { + key: 'articles', + tab: '文章', + }, + { + key: 'projects', + tab: '项目', + }, + { + key: 'applications', + tab: '应用', + }, +]; + +const Search: FC = (props) => { + const handleTabChange = (key: string) => { + const { match } = props; + const url = match.url === '/' ? '' : match.url; + switch (key) { + case 'articles': + history.push(`${url}/articles`); + break; + case 'applications': + history.push(`${url}/applications`); + break; + case 'projects': + history.push(`${url}/projects`); + break; + default: + break; + } + }; + + const handleFormSubmit = (value: string) => { + // eslint-disable-next-line no-console + console.log(value); + }; + + const getTabKey = () => { + const { match, location } = props; + const url = match.path === '/' ? '' : match.path; + const tabKey = location.pathname.replace(`${url}/`, ''); + if (tabKey && tabKey !== '/') { + return tabKey; + } + return 'articles'; + }; + + return ( + + +
    + } + tabList={tabList} + tabActiveKey={getTabKey()} + onTabChange={handleTabChange} + > + {props.children} + + ); +}; + +export default Search; diff --git a/src/pages/list/search/projects/_mock.ts b/src/pages/list/search/projects/_mock.ts new file mode 100644 index 00000000..4f9f4a36 --- /dev/null +++ b/src/pages/list/search/projects/_mock.ts @@ -0,0 +1,118 @@ +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(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 2 * i).getTime(), + subDescription: desc[i % 5], + description: + '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。', + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + star: Math.ceil(Math.random() * 100) + 100, + like: Math.ceil(Math.random() * 100) + 100, + message: Math.ceil(Math.random() * 10) + 10, + content: + '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。', + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2', + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3', + }, + ], + }); + } + + return list; +} + +function getFakeList(req: Request, res: Response) { + const params: 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/projects/components/AvatarList/index.less b/src/pages/list/search/projects/components/AvatarList/index.less new file mode 100644 index 00000000..a7a3d441 --- /dev/null +++ b/src/pages/list/search/projects/components/AvatarList/index.less @@ -0,0 +1,50 @@ +@import '~antd/es/style/themes/default.less'; + +.avatarList { + display: inline-block; + ul { + display: inline-block; + margin-left: 8px; + font-size: 0; + } +} + +.avatarItem { + display: inline-block; + width: @avatar-size-base; + height: @avatar-size-base; + margin-left: -8px; + font-size: @font-size-base; + :global { + .ant-avatar { + border: 1px solid @border-color-base; + } + } +} + +.avatarItemLarge { + width: @avatar-size-lg; + height: @avatar-size-lg; +} + +.avatarItemSmall { + width: @avatar-size-sm; + height: @avatar-size-sm; +} + +.avatarItemMini { + width: 20px; + height: 20px; + :global { + .ant-avatar { + width: 20px; + height: 20px; + line-height: 20px; + + .ant-avatar-string { + font-size: 12px; + line-height: 18px; + } + } + } +} diff --git a/src/pages/list/search/projects/components/AvatarList/index.tsx b/src/pages/list/search/projects/components/AvatarList/index.tsx new file mode 100644 index 00000000..4d57501a --- /dev/null +++ b/src/pages/list/search/projects/components/AvatarList/index.tsx @@ -0,0 +1,84 @@ +import { Avatar, Tooltip } from 'antd'; + +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export declare type SizeType = number | 'small' | 'default' | 'large'; + +export type AvatarItemProps = { + tips: React.ReactNode; + src: string; + size?: SizeType; + style?: React.CSSProperties; + onClick?: () => void; +}; + +export type AvatarListProps = { + Item?: React.ReactElement; + size?: SizeType; + maxLength?: number; + excessItemsStyle?: React.CSSProperties; + style?: React.CSSProperties; + children: React.ReactElement | React.ReactElement[]; +}; + +const avatarSizeToClassName = (size?: SizeType | 'mini') => + classNames(styles.avatarItem, { + [styles.avatarItemLarge]: size === 'large', + [styles.avatarItemSmall]: size === 'small', + [styles.avatarItemMini]: size === 'mini', + }); + +const Item: React.FC = ({ src, size, tips, onClick = () => {} }) => { + const cls = avatarSizeToClassName(size); + + return ( +
  • + {tips ? ( + + + + ) : ( + + )} +
  • + ); +}; + +const AvatarList: React.FC & { Item: typeof Item } = ({ + children, + size, + maxLength = 5, + excessItemsStyle, + ...other +}) => { + const numOfChildren = React.Children.count(children); + const numToShow = maxLength >= numOfChildren ? numOfChildren : maxLength; + const childrenArray = React.Children.toArray(children) as React.ReactElement[]; + const childrenWithProps = childrenArray.slice(0, numToShow).map((child) => + React.cloneElement(child, { + size, + }), + ); + + if (numToShow < numOfChildren) { + const cls = avatarSizeToClassName(size); + + childrenWithProps.push( +
  • + {`+${numOfChildren - maxLength}`} +
  • , + ); + } + + return ( +
    +
      {childrenWithProps}
    +
    + ); +}; + +AvatarList.Item = Item; + +export default AvatarList; diff --git a/src/pages/list/search/projects/components/StandardFormRow/index.less b/src/pages/list/search/projects/components/StandardFormRow/index.less new file mode 100644 index 00000000..d619cd2c --- /dev/null +++ b/src/pages/list/search/projects/components/StandardFormRow/index.less @@ -0,0 +1,88 @@ +@import '~antd/es/style/themes/default.less'; + +.standardFormRow { + display: flex; + width: 100%; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px dashed @border-color-split; + :global { + .ant-form-item, + .ant-legacy-form-item { + margin-right: 24px; + } + .ant-form-item-label, + .ant-legacy-form-item-label { + label { + margin-right: 0; + color: @text-color; + } + } + .ant-form-item-label, + .ant-legacy-form-item-label, + .ant-form-item-control, + .ant-legacy-form-item-control { + padding: 0; + line-height: 32px; + } + } + .label { + flex: 0 0 auto; + margin-right: 24px; + color: @heading-color; + font-size: @font-size-base; + text-align: right; + & > span { + display: inline-block; + height: 32px; + line-height: 32px; + &::after { + content: ':'; + } + } + } + .content { + flex: 1 1 0; + :global { + .ant-form-item, + .ant-legacy-form-item { + &:last-child { + display: block; + margin-right: 0; + } + } + } + } +} + +.standardFormRowLast { + margin-bottom: 0; + padding-bottom: 0; + border: none; +} + +.standardFormRowBlock { + :global { + .ant-form-item, + .ant-legacy-form-item, + div.ant-form-item-control-wrapper, + div.ant-legacy-form-item-control-wrapper { + display: block; + } + } +} + +.standardFormRowGrid { + :global { + .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; + } + } +} diff --git a/src/pages/list/search/projects/components/StandardFormRow/index.tsx b/src/pages/list/search/projects/components/StandardFormRow/index.tsx new file mode 100644 index 00000000..fe4cb388 --- /dev/null +++ b/src/pages/list/search/projects/components/StandardFormRow/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +type StandardFormRowProps = { + title?: string; + last?: boolean; + block?: boolean; + grid?: boolean; + style?: React.CSSProperties; +}; + +const StandardFormRow: React.FC = ({ + title, + children, + last, + block, + grid, + ...rest +}) => { + 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/projects/components/TagSelect/index.less b/src/pages/list/search/projects/components/TagSelect/index.less new file mode 100644 index 00000000..b174f042 --- /dev/null +++ b/src/pages/list/search/projects/components/TagSelect/index.less @@ -0,0 +1,33 @@ +@import '~antd/es/style/themes/default.less'; + +.tagSelect { + position: relative; + max-height: 32px; + margin-left: -8px; + overflow: hidden; + line-height: 32px; + transition: all 0.3s; + user-select: none; + :global { + .ant-tag { + margin-right: 24px; + padding: 0 8px; + font-size: @font-size-base; + } + } + &.expanded { + max-height: 200px; + transition: all 0.3s; + } + .trigger { + position: absolute; + top: 0; + right: 0; + span.anticon { + font-size: 12px; + } + } + &.hasExpandTag { + padding-right: 50px; + } +} diff --git a/src/pages/list/search/projects/components/TagSelect/index.tsx b/src/pages/list/search/projects/components/TagSelect/index.tsx new file mode 100644 index 00000000..5f10efd4 --- /dev/null +++ b/src/pages/list/search/projects/components/TagSelect/index.tsx @@ -0,0 +1,142 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons'; +import { useBoolean, useControllableValue } from 'ahooks'; +import { Tag } from 'antd'; +import classNames from 'classnames'; +import type { FC } from 'react'; +import React from 'react'; +import styles from './index.less'; + +const { CheckableTag } = Tag; + +export interface TagSelectOptionProps { + value: string | number; + style?: React.CSSProperties; + checked?: boolean; + onChange?: (value: string | number, state: boolean) => void; +} + +const TagSelectOption: React.FC & { + isTagSelectOption: boolean; +} = ({ children, checked, onChange, value }) => ( + onChange && onChange(value, state)} + > + {children} + +); + +TagSelectOption.isTagSelectOption = true; + +type TagSelectOptionElement = React.ReactElement; +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 { children, hideCheckAll = false, className, style, expandable, actionsText = {} } = props; + + const [expand, { toggle }] = useBoolean(); + + const [value, setValue] = useControllableValue<(string | number)[]>(props); + + const isTagSelectOption = (node: TagSelectOptionElement) => + node && + 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 && ( + { + toggle(); + }} + > + {expand ? ( + <> + {collapseText} + + ) : ( + <> + {expandText} + + + )} + + )} +
    + ); +}; + +TagSelect.Option = TagSelectOption; + +export default TagSelect; diff --git a/src/pages/list/search/projects/data.d.ts b/src/pages/list/search/projects/data.d.ts new file mode 100644 index 00000000..0a4597c9 --- /dev/null +++ b/src/pages/list/search/projects/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/projects/index.tsx b/src/pages/list/search/projects/index.tsx new file mode 100644 index 00000000..19eafebe --- /dev/null +++ b/src/pages/list/search/projects/index.tsx @@ -0,0 +1,135 @@ +import { Card, Col, Form, List, Row, Select, Typography } from 'antd'; +import moment from 'moment'; +import type { FC } from 'react'; +import { useRequest } from 'umi'; +import AvatarList from './components/AvatarList'; +import StandardFormRow from './components/StandardFormRow'; +import TagSelect from './components/TagSelect'; +import type { ListItemDataType } from './data.d'; +import { queryFakeList } from './service'; +import styles from './style.less'; + +const { Option } = Select; +const FormItem = Form.Item; +const { Paragraph } = Typography; + +const getKey = (id: string, index: number) => `${id}-${index}`; + +const Projects: FC = () => { + const { data, loading, run } = useRequest((values: any) => { + console.log('form data', values); + return queryFakeList({ + count: 8, + }); + }); + + const list = data?.list || []; + + const cardList = list && ( + + rowKey="id" + loading={loading} + grid={{ + gutter: 16, + xs: 1, + sm: 2, + md: 3, + lg: 3, + xl: 4, + xxl: 4, + }} + dataSource={list} + renderItem={(item) => ( + + }> + {item.title}} + description={ + + {item.subDescription} + + } + /> +
    + {moment(item.updatedAt).fromNow()} +
    + + {item.members.map((member, i) => ( + + ))} + +
    +
    +
    +
    + )} + /> + ); + + const formItemLayout = { + wrapperCol: { + xs: { span: 24 }, + sm: { span: 16 }, + }, + }; + + return ( +
    + +
    { + // 表单项变化时请求数据 + // 模拟查询表单生效 + run(values); + }} + > + + + + 类目一 + 类目二 + 类目三 + 类目四 + 类目五 + 类目六 + 类目七 + 类目八 + 类目九 + 类目十 + 类目十一 + 类目十二 + + + + + + + + + + + + + + + + + +
    +
    +
    {cardList}
    +
    + ); +}; + +export default Projects; diff --git a/src/pages/list/search/projects/service.ts b/src/pages/list/search/projects/service.ts new file mode 100644 index 00000000..fd1bcf6f --- /dev/null +++ b/src/pages/list/search/projects/service.ts @@ -0,0 +1,10 @@ +import { request } from 'umi'; +import type { Params, ListItemDataType } from './data'; + +export async function queryFakeList( + params: Params, +): Promise<{ data: { list: ListItemDataType[] } }> { + return request('/api/fake_list', { + params, + }); +} diff --git a/src/pages/list/search/projects/style.less b/src/pages/list/search/projects/style.less new file mode 100644 index 00000000..e2cf6787 --- /dev/null +++ b/src/pages/list/search/projects/style.less @@ -0,0 +1,55 @@ +@import '~antd/es/style/themes/default.less'; +@import './utils/utils.less'; + +.coverCardList { + .card { + :global { + .ant-card-meta-title { + margin-bottom: 4px; + & > a { + display: inline-block; + max-width: 100%; + color: @heading-color; + } + } + .ant-card-meta-description { + height: 44px; + overflow: hidden; + line-height: 22px; + } + } + + &:hover { + :global { + .ant-card-meta-title > a { + color: @primary-color; + } + } + } + } + + .cardItemContent { + display: flex; + height: 20px; + margin-top: 16px; + margin-bottom: -4px; + line-height: 20px; + & > span { + flex: 1; + color: @text-color-secondary; + font-size: 12px; + } + .avatarList { + flex: 0 1 auto; + } + } + .cardList { + margin-top: 24px; + } + + :global { + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } +} diff --git a/src/pages/list/search/projects/utils/utils.less b/src/pages/list/search/projects/utils/utils.less new file mode 100644 index 00000000..de1aa642 --- /dev/null +++ b/src/pages/list/search/projects/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + margin-right: -1em; + padding-right: 1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: 0.2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} diff --git a/src/pages/list/table-list/_mock.ts b/src/pages/list/table-list/_mock.ts new file mode 100644 index 00000000..26724780 --- /dev/null +++ b/src/pages/list/table-list/_mock.ts @@ -0,0 +1,177 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import type { Request, Response } from 'express'; +import { parse } from 'url'; +import type { TableListItem, TableListParams } from './data.d'; + +// mock tableListDataSource +const genList = (current: number, pageSize: number) => { + const tableListDataSource: TableListItem[] = []; + + for (let i = 0; i < pageSize; i += 1) { + const index = (current - 1) * 10 + i; + tableListDataSource.push({ + key: index, + disabled: i % 6 === 0, + href: 'https://ant.design', + avatar: [ + 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', + 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png', + ][i % 2], + name: `TradeCode ${index}`, + owner: '曲丽丽', + desc: '这是一段描述', + callNo: Math.floor(Math.random() * 1000), + status: (Math.floor(Math.random() * 10) % 4).toString(), + updatedAt: new Date(), + createdAt: new Date(), + progress: Math.ceil(Math.random() * 100), + }); + } + tableListDataSource.reverse(); + return tableListDataSource; +}; + +let tableListDataSource = genList(1, 100); + +function getRule(req: Request, res: Response, u: string) { + let realUrl = u; + if (!realUrl || Object.prototype.toString.call(realUrl) !== '[object String]') { + realUrl = req.url; + } + const { current = 1, pageSize = 10 } = req.query; + const params = parse(realUrl, true).query as unknown as TableListParams; + + let dataSource = [...tableListDataSource].slice( + ((current as number) - 1) * (pageSize as number), + (current as number) * (pageSize as number), + ); + if (params.sorter) { + const sorter = JSON.parse(params.sorter as any); + dataSource = dataSource.sort((prev, next) => { + let sortNumber = 0; + Object.keys(sorter).forEach((key) => { + if (sorter[key] === 'descend') { + if (prev[key] - next[key] > 0) { + sortNumber += -1; + } else { + sortNumber += 1; + } + return; + } + if (prev[key] - next[key] > 0) { + sortNumber += 1; + } else { + sortNumber += -1; + } + }); + return sortNumber; + }); + } + if (params.filter) { + const filter = JSON.parse(params.filter as any) as Record; + if (Object.keys(filter).length > 0) { + dataSource = dataSource.filter((item) => { + return Object.keys(filter).some((key) => { + if (!filter[key]) { + return true; + } + if (filter[key].includes(`${item[key]}`)) { + return true; + } + return false; + }); + }); + } + } + + if (params.name) { + dataSource = dataSource.filter((data) => data.name.includes(params.name || '')); + } + + let finalPageSize = 10; + if (params.pageSize) { + finalPageSize = parseInt(`${params.pageSize}`, 10); + } + + const result = { + data: dataSource, + total: tableListDataSource.length, + success: true, + pageSize: finalPageSize, + current: parseInt(`${params.currentPage}`, 10) || 1, + }; + + return res.json(result); +} + +function postRule(req: Request, res: Response, u: string, b: Request) { + let realUrl = u; + if (!realUrl || Object.prototype.toString.call(realUrl) !== '[object String]') { + realUrl = req.url; + } + + const body = (b && b.body) || req.body; + const { name, desc, key } = body; + + switch (req.method) { + /* eslint no-case-declarations:0 */ + case 'DELETE': + tableListDataSource = tableListDataSource.filter((item) => key.indexOf(item.key) === -1); + break; + case 'POST': + (() => { + const i = Math.ceil(Math.random() * 10000); + const newRule = { + key: tableListDataSource.length, + href: 'https://ant.design', + avatar: [ + 'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', + 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png', + ][i % 2], + name, + owner: '曲丽丽', + desc, + callNo: Math.floor(Math.random() * 1000), + status: (Math.floor(Math.random() * 10) % 2).toString(), + updatedAt: new Date(), + createdAt: new Date(), + progress: Math.ceil(Math.random() * 100), + }; + tableListDataSource.unshift(newRule); + return res.json(newRule); + })(); + return; + + case 'PUT': + (() => { + let newRule = {}; + tableListDataSource = tableListDataSource.map((item) => { + if (item.key === key) { + newRule = { ...item, desc, name }; + return { ...item, desc, name }; + } + return item; + }); + return res.json(newRule); + })(); + return; + default: + break; + } + + const result = { + list: tableListDataSource, + pagination: { + total: tableListDataSource.length, + }, + }; + + res.json(result); +} + +export default { + 'GET /api/rule': getRule, + 'POST /api/rule': postRule, + 'DELETE /api/rule': postRule, + 'PUT /api/rule': postRule, +}; diff --git a/src/pages/list/table-list/components/CreateForm.tsx b/src/pages/list/table-list/components/CreateForm.tsx new file mode 100644 index 00000000..f7ecb093 --- /dev/null +++ b/src/pages/list/table-list/components/CreateForm.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Modal } from 'antd'; + +type CreateFormProps = { + modalVisible: boolean; + onCancel: () => void; +}; + +const CreateForm: React.FC = (props) => { + const { modalVisible, onCancel } = props; + + return ( + onCancel()} + footer={null} + > + {props.children} + + ); +}; + +export default CreateForm; diff --git a/src/pages/list/table-list/components/UpdateForm.tsx b/src/pages/list/table-list/components/UpdateForm.tsx new file mode 100644 index 00000000..d704c132 --- /dev/null +++ b/src/pages/list/table-list/components/UpdateForm.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { Modal } from 'antd'; +import { + ProFormSelect, + ProFormText, + ProFormTextArea, + StepsForm, + ProFormRadio, + ProFormDateTimePicker, +} from '@ant-design/pro-form'; +import type { TableListItem } from '../data'; + +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; + updateModalVisible: boolean; + values: Partial; +}; + +const UpdateForm: React.FC = (props) => { + return ( + { + return ( + { + props.onCancel(); + }} + > + {dom} + + ); + }} + onFinish={props.onSubmit} + > + + + + + + + + + + + + + + + ); +}; + +export default UpdateForm; diff --git a/src/pages/list/table-list/data.d.ts b/src/pages/list/table-list/data.d.ts new file mode 100644 index 00000000..5063cf37 --- /dev/null +++ b/src/pages/list/table-list/data.d.ts @@ -0,0 +1,36 @@ +export type TableListItem = { + key: number; + disabled?: boolean; + href: string; + avatar: string; + name: string; + owner: string; + desc: string; + callNo: number; + status: string; + updatedAt: Date; + createdAt: Date; + progress: number; +}; + +export type TableListPagination = { + total: number; + pageSize: number; + current: number; +}; + +export type TableListData = { + list: TableListItem[]; + pagination: Partial; +}; + +export type TableListParams = { + status?: string; + name?: string; + desc?: string; + key?: number; + pageSize?: number; + currentPage?: number; + filter?: Record; + sorter?: Record; +}; diff --git a/src/pages/list/table-list/index.tsx b/src/pages/list/table-list/index.tsx new file mode 100644 index 00000000..bc295de8 --- /dev/null +++ b/src/pages/list/table-list/index.tsx @@ -0,0 +1,320 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { Button, message, Input, Drawer } from 'antd'; +import React, { useState, useRef } from 'react'; +import { PageContainer, FooterToolbar } from '@ant-design/pro-layout'; +import type { ProColumns, ActionType } from '@ant-design/pro-table'; +import ProTable from '@ant-design/pro-table'; +import { ModalForm, ProFormText, ProFormTextArea } from '@ant-design/pro-form'; +import type { ProDescriptionsItemProps } from '@ant-design/pro-descriptions'; +import ProDescriptions from '@ant-design/pro-descriptions'; +import type { FormValueType } from './components/UpdateForm'; +import UpdateForm from './components/UpdateForm'; +import { rule, addRule, updateRule, removeRule } from './service'; +import type { TableListItem, TableListPagination } from './data'; +/** + * 添加节点 + * + * @param fields + */ + +const handleAdd = async (fields: TableListItem) => { + const hide = message.loading('正在添加'); + + try { + await addRule({ ...fields }); + hide(); + message.success('添加成功'); + return true; + } catch (error) { + hide(); + message.error('添加失败请重试!'); + return false; + } +}; +/** + * 更新节点 + * + * @param fields + */ + +const handleUpdate = async (fields: FormValueType, currentRow?: TableListItem) => { + const hide = message.loading('正在配置'); + + try { + await updateRule({ + ...currentRow, + ...fields, + }); + hide(); + message.success('配置成功'); + return true; + } catch (error) { + hide(); + message.error('配置失败请重试!'); + return false; + } +}; +/** + * 删除节点 + * + * @param selectedRows + */ + +const handleRemove = async (selectedRows: TableListItem[]) => { + const hide = message.loading('正在删除'); + if (!selectedRows) return true; + + try { + await removeRule({ + key: selectedRows.map((row) => row.key), + }); + hide(); + message.success('删除成功,即将刷新'); + return true; + } catch (error) { + hide(); + message.error('删除失败,请重试'); + return false; + } +}; + +const TableList: React.FC = () => { + /** 新建窗口的弹窗 */ + const [createModalVisible, handleModalVisible] = useState(false); + /** 分布更新窗口的弹窗 */ + + const [updateModalVisible, handleUpdateModalVisible] = useState(false); + const [showDetail, setShowDetail] = useState(false); + const actionRef = useRef(); + const [currentRow, setCurrentRow] = useState(); + const [selectedRowsState, setSelectedRows] = useState([]); + /** 国际化配置 */ + + const columns: ProColumns[] = [ + { + title: '规则名称', + dataIndex: 'name', + tip: '规则名称是唯一的 key', + render: (dom, entity) => { + return ( + { + setCurrentRow(entity); + setShowDetail(true); + }} + > + {dom} + + ); + }, + }, + { + title: '描述', + dataIndex: 'desc', + valueType: 'textarea', + }, + { + title: '服务调用次数', + dataIndex: 'callNo', + sorter: true, + hideInForm: true, + renderText: (val: string) => `${val}万`, + }, + { + title: '状态', + dataIndex: 'status', + hideInForm: true, + valueEnum: { + 0: { + text: '关闭', + status: 'Default', + }, + 1: { + text: '运行中', + status: 'Processing', + }, + 2: { + text: '已上线', + status: 'Success', + }, + 3: { + text: '异常', + status: 'Error', + }, + }, + }, + { + title: '上次调度时间', + sorter: true, + dataIndex: 'updatedAt', + valueType: 'dateTime', + renderFormItem: (item, { defaultRender, ...rest }, form) => { + const status = form.getFieldValue('status'); + + if (`${status}` === '0') { + return false; + } + + if (`${status}` === '3') { + return ; + } + + 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)} 万 + +
    + } + > + + + + )} + { + 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..dc1d74a2 --- /dev/null +++ b/src/pages/list/table-list/service.ts @@ -0,0 +1,56 @@ +// @ts-ignore +/* eslint-disable */ +import { request } from 'umi'; +import { 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..1bf3b384 --- /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..bf59e86c --- /dev/null +++ b/src/pages/profile/advanced/index.tsx @@ -0,0 +1,356 @@ +import { + DingdingOutlined, + DownOutlined, + EllipsisOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; +import { + Badge, + Button, + Card, + Statistic, + Descriptions, + Divider, + Dropdown, + Menu, + Popover, + Steps, + Table, + Tooltip, + Empty, +} from 'antd'; +import { GridContent, PageContainer, RouteContext } from '@ant-design/pro-layout'; +import type { FC } from 'react'; +import React, { Fragment, useState } from 'react'; + +import classNames from 'classnames'; +import { useRequest } from 'umi'; +import type { AdvancedProfileData } from './data.d'; +import { queryAdvancedProfile } from './service'; +import styles from './style.less'; + +const { Step } = Steps; +const ButtonGroup = Button.Group; + +const menu = ( + + 选项一 + 选项二 + 选项三 + +); + +const mobileMenu = ( + + 操作一 + 操作二 + 选项一 + 选项二 + 选项三 + +); + +const action = ( + + {({ isMobile }) => { + if (isMobile) { + return ( + } + overlay={mobileMenu} + placement="bottomRight" + > + 主操作 + + ); + } + return ( + + + + + + + + + + + ); + }} + +); + +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 popoverContent = ( +
    + 吴加号 + + 未响应} /> + +
    + 耗时:2小时25分钟 +
    +
    +); + +const customDot = (dot: React.ReactNode, { status }: { status: string }) => { + if (status === 'process') { + return ( + + {dot} + + ); + } + return dot; +}; + +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: string; + tabActiveKey: string; +}; + +const Advanced: FC = () => { + const [tabStatus, seTabStatus] = useState({ + operationKey: 'tab1', + tabActiveKey: 'detail', + }); + 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 }); + }; + + 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]} + +
    +
    +
    + ); +}; + +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..7c00a9b8 --- /dev/null +++ b/src/pages/profile/advanced/service.ts @@ -0,0 +1,5 @@ +import { request } from 'umi'; + +export async function queryAdvancedProfile() { + return request('/api/profile/advanced'); +} diff --git a/src/pages/profile/advanced/style.less b/src/pages/profile/advanced/style.less new file mode 100644 index 00000000..fa376f28 --- /dev/null +++ b/src/pages/profile/advanced/style.less @@ -0,0 +1,60 @@ +@import '~antd/es/style/themes/default.less'; + +.main { + :global { + .ant-descriptions-row > td { + padding-bottom: 8px; + } + .ant-page-header-heading-extra { + flex-direction: column; + } + } +} + +.headerList { + margin-bottom: 4px; + :global { + .ant-descriptions-row > td { + padding-bottom: 8px; + } + } + + .stepDescription { + position: relative; + left: 38px; + padding-top: 8px; + font-size: 14px; + text-align: left; + + > div { + margin-top: 8px; + margin-bottom: 4px; + } + } +} + +.pageHeader { + :global { + .ant-page-header-heading-extra > * + * { + margin-left: 8px; + } + } + .moreInfo { + display: flex; + justify-content: space-between; + width: 200px; + } +} + +@media screen and (max-width: @screen-sm) { + .stepDescription { + left: 8px; + } + .pageHeader { + :global { + .ant-pro-page-header-wrap-row { + flex-direction: column; + } + } + } +} 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..5f8ae892 --- /dev/null +++ b/src/pages/profile/basic/index.tsx @@ -0,0 +1,193 @@ +import { PageContainer } from '@ant-design/pro-layout'; +import type { ProColumns } from '@ant-design/pro-table'; +import ProTable from '@ant-design/pro-table'; +import { Badge, Card, Descriptions, Divider } from 'antd'; +import type { FC } from 'react'; +import React from 'react'; +import { useRequest } from 'umi'; +import type { BasicGood, BasicProgress } from './data.d'; +import { queryBasicProfile } from './service'; +import styles from './style.less'; + +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 { 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..df7f52f0 --- /dev/null +++ b/src/pages/profile/basic/service.ts @@ -0,0 +1,11 @@ +import { request } from 'umi'; +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.less b/src/pages/profile/basic/style.less new file mode 100644 index 00000000..68f3c93f --- /dev/null +++ b/src/pages/profile/basic/style.less @@ -0,0 +1,8 @@ +@import '~antd/es/style/themes/default.less'; + +.title { + margin-bottom: 16px; + color: @heading-color; + font-weight: 500; + font-size: 16px; +} diff --git a/src/pages/result/fail/index.less b/src/pages/result/fail/index.less new file mode 100644 index 00000000..85373f2e --- /dev/null +++ b/src/pages/result/fail/index.less @@ -0,0 +1,11 @@ +@import '~antd/es/style/themes/default.less'; + +.error_icon { + color: @highlight-color; +} +.title { + margin-bottom: 16px; + color: @heading-color; + font-weight: 500; + font-size: 16px; +} diff --git a/src/pages/result/fail/index.tsx b/src/pages/result/fail/index.tsx new file mode 100644 index 00000000..9887ced7 --- /dev/null +++ b/src/pages/result/fail/index.tsx @@ -0,0 +1,50 @@ +import { CloseCircleOutlined, RightOutlined } from '@ant-design/icons'; +import { Button, Card, Result } from 'antd'; +import { Fragment } from 'react'; + +import { GridContent } from '@ant-design/pro-layout'; +import styles from './index.less'; + +const Content = ( + +
    + 您提交的内容有如下错误: +
    +
    + + 您的账户已被冻结 + + 立即解冻 + + +
    +
    + + 您的账户还不具备申请资格 + + 立即升级 + + +
    +
    +); + +export default () => ( + + + + 返回修改 + + } + style={{ marginTop: 48, marginBottom: 16 }} + > + {Content} + + + +); diff --git a/src/pages/result/success/index.less b/src/pages/result/success/index.less new file mode 100644 index 00000000..211772d5 --- /dev/null +++ b/src/pages/result/success/index.less @@ -0,0 +1,15 @@ +@import '~antd/es/style/themes/default.less'; + +.title { + position: relative; + color: @text-color; + font-size: 12px; + text-align: center; +} + +.head-title { + margin-bottom: 20px; + color: @heading-color; + font-weight: 500px; + font-size: 16px; +} diff --git a/src/pages/result/success/index.tsx b/src/pages/result/success/index.tsx new file mode 100644 index 00000000..ba0b599b --- /dev/null +++ b/src/pages/result/success/index.tsx @@ -0,0 +1,71 @@ +import { DingdingOutlined } from '@ant-design/icons'; +import { Button, Card, Steps, Result, Descriptions } from 'antd'; +import { Fragment } from 'react'; +import { GridContent } from '@ant-design/pro-layout'; + +import styles from './index.less'; + +const { Step } = Steps; + +const desc1 = ( +
    +
    + 曲丽丽 + +
    +
    2016-12-12 12:32
    +
    +); + +const desc2 = ( +
    +
    + 周毛毛 + + + 催一下 + +
    +
    +); + +const content = ( + <> + + 23421 + 曲丽丽 + 2016-12-12 ~ 2017-12-12 + +
    + + 创建项目} description={desc1} /> + 部门初审} description={desc2} /> + 财务复核} /> + 完成} /> + + +); + +const extra = ( + + + + + +); + +export default () => ( + + + + {content} + + + +); diff --git a/src/pages/user/register-result/index.tsx b/src/pages/user/register-result/index.tsx new file mode 100644 index 00000000..8b4fac58 --- /dev/null +++ b/src/pages/user/register-result/index.tsx @@ -0,0 +1,42 @@ +import { Button, Result } from 'antd'; +import { Link } from 'umi'; +import React from 'react'; +import type { RouteChildrenProps } from 'react-router'; + +import styles from './style.less'; + +const actions = ( +
    + + + + + + +
    +); + +export type LocationState = Record; + +const RegisterResult: React.FC = ({ location }) => { + const email = location.state + ? (location.state as LocationState).account + : 'AntDesign@example.com'; + return ( + + 你的账户:{email} 注册成功 + + } + subTitle="激活邮件已发送到你的邮箱中,邮件有效期为24小时。请及时登录邮箱,点击邮件中的链接激活帐户。" + extra={actions} + /> + ); +}; + +export default RegisterResult; diff --git a/src/pages/user/register-result/style.less b/src/pages/user/register-result/style.less new file mode 100644 index 00000000..dc1b8905 --- /dev/null +++ b/src/pages/user/register-result/style.less @@ -0,0 +1,23 @@ +.registerResult { + width: 800px; + min-height: 400px; + margin: auto; + padding: 80px; + background: none; + :global { + .anticon { + font-size: 64px; + } + } + .title { + margin-top: 32px; + font-size: 20px; + line-height: 28px; + } + .actions { + margin-top: 40px; + a + a { + margin-left: 8px; + } + } +} diff --git a/src/pages/user/register/_mock.ts b/src/pages/user/register/_mock.ts new file mode 100644 index 00000000..aa7ac78f --- /dev/null +++ b/src/pages/user/register/_mock.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +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..47c9e848 --- /dev/null +++ b/src/pages/user/register/index.tsx @@ -0,0 +1,285 @@ +import type { FC } from 'react'; +import { useState, useEffect } from 'react'; +import { Form, Button, Col, Input, Popover, Progress, Row, Select, message } from 'antd'; +import type { Store } from 'antd/es/form/interface'; +import { Link, useRequest, history } from 'umi'; +import type { StateType } from './service'; +import { fakeRegister } from './service'; + +import styles from './style.less'; + +const FormItem = Form.Item; +const { Option } = Select; +const InputGroup = Input.Group; + +const passwordStatusMap = { + ok: ( +
    + 强度:强 +
    + ), + pass: ( +
    + 强度:中 +
    + ), + poor: ( +
    + 强度:太短 +
    + ), +}; + +const passwordProgressMap: { + ok: 'success'; + pass: 'normal'; + poor: 'exception'; +} = { + ok: 'success', + pass: 'normal', + poor: 'exception', +}; + +const Register: FC = () => { + const [count, setCount]: [number, any] = useState(0); + const [visible, 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 [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', + state: { + account: params.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 (!visible) { + 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 && value.length ? ( +
    + 100 ? 100 : value.length * 10} + showInfo={false} + /> +
    + ) : null; + }; + + return ( +
    +

    注册

    +
    + + + + { + if (node && node.parentNode) { + return node.parentNode as HTMLElement; + } + return node; + }} + content={ + visible && ( +
    + {passwordStatusMap[getPasswordStatus()]} + {renderPasswordProgress()} +
    + 请至少输入 6 个字符。请不要使用容易被猜到的密码。 +
    +
    + ) + } + overlayStyle={{ width: 240 }} + placement="right" + visible={visible} + > + 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..83801841 --- /dev/null +++ b/src/pages/user/register/service.ts @@ -0,0 +1,22 @@ +import { request } from 'umi'; + +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/style.less b/src/pages/user/register/style.less new file mode 100644 index 00000000..e13868f0 --- /dev/null +++ b/src/pages/user/register/style.less @@ -0,0 +1,60 @@ +@import '~antd/es/style/themes/default.less'; + +.main { + width: 368px; + margin: 0 auto; + + h3 { + margin-bottom: 20px; + font-size: 16px; + } + + .password { + margin-bottom: 24px; + :global { + .ant-form-item-explain { + display: none; + } + } + } + + .getCaptcha { + display: block; + width: 100%; + } + + .submit { + width: 50%; + } + + .login { + float: right; + line-height: @btn-height-lg; + } +} + +.success, +.warning, +.error { + transition: color 0.3s; +} + +.success { + color: @success-color; +} + +.warning { + color: @warning-color; +} + +.error { + color: @error-color; +} + +.progress-pass > .progress { + :global { + .ant-progress-bg { + background-color: @warning-color; + } + } +}