提交 1dcd171e 作者: 郁骅焌

first commit

上级 323695e7
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
/functions/mock/**
/scripts
/config
**/node_modules/**
_scripts
\ No newline at end of file
const fabric = require('@umijs/fabric');
const path = require('path');
module.exports = {
...fabric.default,
rules: {
...fabric.default.rules,
'@typescript-eslint/camelcase': 0,
'@typescript-eslint/class-name-casing': 0,
'import/no-extraneous-dependencies': 0,
},
globals: {
ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: true,
page: true,
},
};
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
**/node_modules
# roadhog-api-doc ignore
/src/utils/request-temp.js
_roadhog-api-doc
# production
/dist
/.vscode
# misc
.DS_Store
npm-debug.log*
yarn-error.log
/coverage
.idea
yarn.lock
package-lock.json
*bak
.vscode
# visual studio code
.history
*.log
functions/mock
.temp/**
# umi
.umi
.umi-production
# screenshot
screenshot
.firebase
.eslintcache
**/*.svg
package.json
.umi
.umi-production
const fabric = require('@umijs/fabric');
module.exports = {
...fabric.prettier,
};
const fabric = require('@umijs/fabric');
module.exports = {
...fabric.stylelint,
};
export default {
plugins: [
[
'umi-plugin-block-dev',
{
layout: 'ant-design-pro',
menu: {
name: '主页',
icon: 'home',
},
},
],
[
'umi-plugin-react',
{
dva: true,
locale: true,
antd: true,
},
],
],
};
/yarn.lock
/package-lock.json
/dist
/node_modules
.umi
.umi-production
# @umi-blocks/ant-design-pro/accountcenter
AccountCenter
## Usage
```sh
umi block add ant-design-pro/AccountCenter
```
## SNAPSHOT
![SNAPSHOT](./snapshot.png)
## LICENSE
MIT
{
"name": "@umi-block/account-center",
"version": "0.0.1",
"description": "AccountCenter",
"repository": {
"type": "git",
"url": "https://github.com/umijs/umi-blocks/ant-design-pro/accountcenter"
},
"license": "ISC",
"main": "src/index.js",
"scripts": {
"dev": "umi dev"
},
"dependencies": {
"classnames": "^2.2.6",
"dva": "^2.4.0",
"moment": "^2.24.0",
"numeral": "^2.0.6",
"react-router": "^4.3.1",
"redux": "^4.0.1",
"umi-request": "^1.0.0"
},
"peerDependencies": {
"@ant-design/pro-layout": "^4.5.5"
},
"blockConfig": {
"specVersion": "0.1"
}
}
\ No newline at end of file
@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;
background: url(https://gw.alipayobjects.com/zos/rmsportal/pBjWzVAHnOOtAUvZmZfy.svg);
&.title {
background-position: 0 0;
}
&.group {
background-position: 0 -22px;
}
&.address {
background-position: 0 -44px;
}
}
}
.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;
}
}
}
import { 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: { query: any }, res: { json: (arg0: ListItemDataType[]) => void }) {
const params = req.query;
const count = params.count * 1 || 5;
const result = fakeList(count);
return res.json(result);
}
export default {
'GET /api/fake_list': getFakeList,
// 支持值为 Object 和 Array
'GET /api/currentUser': {
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',
},
};
@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;
}
}
}
}
import { Avatar, Card, Dropdown, Icon, List, Menu, Tooltip } from 'antd';
import React, { Component } from 'react';
import { connect } from 'dva';
import numeral from 'numeral';
import { ModalState } from '../../model';
import stylesApplications from './index.less';
export function formatWan(val: number) {
const v = val * 1;
if (!v || Number.isNaN(v)) return '';
let result: React.ReactNode = val;
if (val > 10000) {
result = (
<span>
{Math.floor(val / 10000)}
<span
style={{
position: 'relative',
top: -2,
fontSize: 14,
fontStyle: 'normal',
marginLeft: 2,
}}
>
</span>
</span>
);
}
return result;
}
@connect(({ BLOCK_NAME_CAMEL_CASE }: { BLOCK_NAME_CAMEL_CASE: ModalState }) => ({
list: BLOCK_NAME_CAMEL_CASE.list,
}))
class Applications extends Component<Partial<ModalState>> {
render() {
const { list } = this.props;
const itemMenu = (
<Menu>
<Menu.Item>
<a target="_blank" rel="noopener noreferrer" href="https://www.alipay.com/">
1st menu item
</a>
</Menu.Item>
<Menu.Item>
<a target="_blank" rel="noopener noreferrer" href="https://www.taobao.com/">
2nd menu item
</a>
</Menu.Item>
<Menu.Item>
<a target="_blank" rel="noopener noreferrer" href="https://www.tmall.com/">
3d menu item
</a>
</Menu.Item>
</Menu>
);
const CardInfo: React.FC<{
activeUser: React.ReactNode;
newUser: React.ReactNode;
}> = ({ activeUser, newUser }) => (
<div className={stylesApplications.cardInfo}>
<div>
<p>活跃用户</p>
<p>{activeUser}</p>
</div>
<div>
<p>新增用户</p>
<p>{newUser}</p>
</div>
</div>
);
return (
<List
rowKey="id"
className={stylesApplications.filterCardList}
grid={{ gutter: 24, xxl: 3, xl: 2, lg: 2, md: 2, sm: 2, xs: 1 }}
dataSource={list}
renderItem={item => (
<List.Item key={item.id}>
<Card
hoverable
bodyStyle={{ paddingBottom: 20 }}
actions={[
<Tooltip key="download" title="下载">
<Icon type="download" />
</Tooltip>,
<Tooltip title="编辑" key="edit">
<Icon type="edit" />
</Tooltip>,
<Tooltip title="分享" key="share">
<Icon type="share-alt" />
</Tooltip>,
<Dropdown overlay={itemMenu} key="ellipsis">
<Icon type="ellipsis" />
</Dropdown>,
]}
>
<Card.Meta avatar={<Avatar size="small" src={item.avatar} />} title={item.title} />
<div className={stylesApplications.cardItemContent}>
<CardInfo
activeUser={formatWan(item.activeUser)}
newUser={numeral(item.newUser).format('0,0')}
/>
</div>
</Card>
</List.Item>
)}
/>
);
}
}
export default Applications;
@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;
}
}
}
}
import { Avatar } from 'antd';
import React from 'react';
import moment from 'moment';
import styles from './index.less';
export interface ApplicationsProps {
data: {
content?: string;
updatedAt?: any;
avatar?: string;
owner?: string;
href?: string;
};
}
const ArticleListContent: React.FC<ApplicationsProps> = ({
data: { content, updatedAt, avatar, owner, href },
}) => (
<div className={styles.listContent}>
<div className={styles.description}>{content}</div>
<div className={styles.extra}>
<Avatar src={avatar} size="small" />
<a href={href}>{owner}</a> 发布在 <a href={href}>{href}</a>
<em>{moment(updatedAt).format('YYYY-MM-DD HH:mm')}</em>
</div>
</div>
);
export default ArticleListContent;
@import '~antd/es/style/themes/default.less';
.articleList {
:global {
.ant-list-item:first-child {
padding-top: 0;
}
}
}
a.listItemMetaTitle {
color: @heading-color;
}
import { Icon, List, Tag } from 'antd';
import React, { Component } from 'react';
import { connect } from 'dva';
import ArticleListContent from '../ArticleListContent';
import { ListItemDataType } from '../../data.d';
import { ModalState } from '../../model';
import styles from './index.less';
@connect(({ BLOCK_NAME_CAMEL_CASE }: { BLOCK_NAME_CAMEL_CASE: ModalState }) => ({
list: BLOCK_NAME_CAMEL_CASE.list,
}))
class Articles extends Component<Partial<ModalState>> {
render() {
const { list } = this.props;
const IconText: React.FC<{
type: string;
text: React.ReactNode;
}> = ({ type, text }) => (
<span>
<Icon type={type} style={{ marginRight: 8 }} />
{text}
</span>
);
return (
<List<ListItemDataType>
size="large"
className={styles.articleList}
rowKey="id"
itemLayout="vertical"
dataSource={list}
renderItem={item => (
<List.Item
key={item.id}
actions={[
<IconText key="star" type="star-o" text={item.star} />,
<IconText key="like" type="like-o" text={item.like} />,
<IconText key="message" type="message" text={item.message} />,
]}
>
<List.Item.Meta
title={
<a className={styles.listItemMetaTitle} href={item.href}>
{item.title}
</a>
}
description={
<span>
<Tag>Ant Design</Tag>
<Tag>设计语言</Tag>
<Tag>蚂蚁金服</Tag>
</span>
}
/>
<ArticleListContent data={item} />
</List.Item>
)}
/>
);
}
}
export default Articles;
@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 #fff;
}
}
}
.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;
}
}
}
}
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 interface AvatarItemProps {
tips: React.ReactNode;
src: string;
size?: SizeType;
style?: React.CSSProperties;
onClick?: () => void;
}
export interface AvatarListProps {
Item?: React.ReactElement<AvatarItemProps>;
size?: SizeType;
maxLength?: number;
excessItemsStyle?: React.CSSProperties;
style?: React.CSSProperties;
children: React.ReactElement<AvatarItemProps> | React.ReactElement<AvatarItemProps>[];
}
const avatarSizeToClassName = (size?: SizeType | 'mini') =>
classNames(styles.avatarItem, {
[styles.avatarItemLarge]: size === 'large',
[styles.avatarItemSmall]: size === 'small',
[styles.avatarItemMini]: size === 'mini',
});
const Item: React.FC<AvatarItemProps> = ({ src, size, tips, onClick = () => {} }) => {
const cls = avatarSizeToClassName(size);
return (
<li className={cls} onClick={onClick}>
{tips ? (
<Tooltip title={tips}>
<Avatar src={src} size={size} style={{ cursor: 'pointer' }} />
</Tooltip>
) : (
<Avatar src={src} size={size} />
)}
</li>
);
};
const AvatarList: React.FC<AvatarListProps> & { Item: typeof Item } = ({
children,
size,
maxLength = 5,
excessItemsStyle,
...other
}) => {
const numOfChildren = React.Children.count(children);
const numToShow = maxLength >= numOfChildren ? numOfChildren : maxLength;
const childrenArray = React.Children.toArray(children) as React.ReactElement<AvatarItemProps>[];
const childrenWithProps = childrenArray.slice(0, numToShow).map(child =>
React.cloneElement(child, {
size,
}),
);
if (numToShow < numOfChildren) {
const cls = avatarSizeToClassName(size);
childrenWithProps.push(
<li key="exceed" className={cls}>
<Avatar size={size} style={excessItemsStyle}>{`+${numOfChildren - maxLength}`}</Avatar>
</li>,
);
}
return (
<div {...other} className={styles.avatarList}>
<ul> {childrenWithProps} </ul>
</div>
);
};
AvatarList.Item = Item;
export default AvatarList;
@import '~antd/es/style/themes/default.less';
.coverCardList {
margin-bottom: -24px;
.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%;
}
}
}
import { Card, List } from 'antd';
import React, { Component } from 'react';
import { connect } from 'dva';
import moment from 'moment';
import AvatarList from '../AvatarList';
import { ListItemDataType } from '../../data.d';
import { ModalState } from '../../model';
import styles from './index.less';
@connect(({ BLOCK_NAME_CAMEL_CASE }: { BLOCK_NAME_CAMEL_CASE: ModalState }) => ({
list: BLOCK_NAME_CAMEL_CASE.list,
}))
class Projects extends Component<Partial<ModalState>> {
render() {
const { list } = this.props;
return (
<List<ListItemDataType>
className={styles.coverCardList}
rowKey="id"
grid={{ gutter: 24, xxl: 3, xl: 2, lg: 2, md: 2, sm: 2, xs: 1 }}
dataSource={list}
renderItem={item => (
<List.Item>
<Card
className={styles.card}
hoverable
cover={<img alt={item.title} src={item.cover} />}
>
<Card.Meta title={<a>{item.title}</a>} description={item.subDescription} />
<div className={styles.cardItemContent}>
<span>{moment(item.updatedAt).fromNow()}</span>
<div className={styles.avatarList}>
<AvatarList size="small">
{item.members.map(member => (
<AvatarList.Item
key={`${item.id}-avatar-${member.id}`}
src={member.avatar}
tips={member.name}
/>
))}
</AvatarList>
</div>
</div>
</Card>
</List.Item>
)}
/>
);
}
}
export default Projects;
export interface TagType {
key: string;
label: string;
}
export interface GeographicType {
province: {
label: string;
key: string;
};
city: {
label: string;
key: string;
};
}
export interface NoticeType {
id: string;
title: string;
logo: string;
description: string;
updatedAt: string;
member: string;
href: string;
memberLink: string;
}
export interface 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 interface Member {
avatar: string;
name: string;
id: string;
}
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[];
}
import { Avatar, Card, Col, Divider, Icon, Input, Row, Tag } from 'antd';
import React, { PureComponent } from 'react';
import { Dispatch } from 'redux';
import { GridContent } from '@ant-design/pro-layout';
import Link from 'umi/link';
import { RouteChildrenProps } from 'react-router';
import { connect } from 'dva';
import { ModalState } from './model';
import Projects from './components/Projects';
import Articles from './components/Articles';
import Applications from './components/Applications';
import { CurrentUser, TagType } from './data.d';
import styles from './Center.less';
const operationTabList = [
{
key: 'articles',
tab: (
<span>
文章 <span style={{ fontSize: 14 }}>(8)</span>
</span>
),
},
{
key: 'applications',
tab: (
<span>
应用 <span style={{ fontSize: 14 }}>(8)</span>
</span>
),
},
{
key: 'projects',
tab: (
<span>
项目 <span style={{ fontSize: 14 }}>(8)</span>
</span>
),
},
];
interface BLOCK_NAME_CAMEL_CASEProps extends RouteChildrenProps {
dispatch: Dispatch<any>;
currentUser: CurrentUser;
currentUserLoading: boolean;
}
interface BLOCK_NAME_CAMEL_CASEState {
newTags: TagType[];
tabKey: 'articles' | 'applications' | 'projects';
inputVisible: boolean;
inputValue: string;
}
@connect(
({
loading,
BLOCK_NAME_CAMEL_CASE,
}: {
loading: { effects: { [key: string]: boolean } };
BLOCK_NAME_CAMEL_CASE: ModalState;
}) => ({
currentUser: BLOCK_NAME_CAMEL_CASE.currentUser,
currentUserLoading: loading.effects['BLOCK_NAME_CAMEL_CASE/fetchCurrent'],
}),
)
class PAGE_NAME_UPPER_CAMEL_CASE extends PureComponent<
BLOCK_NAME_CAMEL_CASEProps,
BLOCK_NAME_CAMEL_CASEState
> {
// static getDerivedStateFromProps(
// props: BLOCK_NAME_CAMEL_CASEProps,
// state: BLOCK_NAME_CAMEL_CASEState,
// ) {
// const { match, location } = props;
// const { tabKey } = state;
// const path = match && match.path;
// const urlTabKey = location.pathname.replace(`${path}/`, '');
// if (urlTabKey && urlTabKey !== '/' && tabKey !== urlTabKey) {
// return {
// tabKey: urlTabKey,
// };
// }
// return null;
// }
state: BLOCK_NAME_CAMEL_CASEState = {
newTags: [],
inputVisible: false,
inputValue: '',
tabKey: 'articles',
};
public input: Input | null | undefined = undefined;
componentDidMount() {
const { dispatch } = this.props;
dispatch({
type: 'BLOCK_NAME_CAMEL_CASE/fetchCurrent',
});
dispatch({
type: 'BLOCK_NAME_CAMEL_CASE/fetch',
});
}
onTabChange = (key: string) => {
// If you need to sync state to url
// const { match } = this.props;
// router.push(`${match.url}/${key}`);
this.setState({
tabKey: key as BLOCK_NAME_CAMEL_CASEState['tabKey'],
});
};
showInput = () => {
this.setState({ inputVisible: true }, () => this.input && this.input.focus());
};
saveInputRef = (input: Input | null) => {
this.input = input;
};
handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ inputValue: e.target.value });
};
handleInputConfirm = () => {
const { state } = this;
const { inputValue } = state;
let { newTags } = state;
if (inputValue && newTags.filter(tag => tag.label === inputValue).length === 0) {
newTags = [...newTags, { key: `new-${newTags.length}`, label: inputValue }];
}
this.setState({
newTags,
inputVisible: false,
inputValue: '',
});
};
renderChildrenByTabKey = (tabKey: BLOCK_NAME_CAMEL_CASEState['tabKey']) => {
if (tabKey === 'projects') {
return <Projects />;
}
if (tabKey === 'applications') {
return <Applications />;
}
if (tabKey === 'articles') {
return <Articles />;
}
return null;
};
render() {
const { newTags, inputVisible, inputValue, tabKey } = this.state;
const { currentUser, currentUserLoading } = this.props;
const dataLoading = currentUserLoading || !(currentUser && Object.keys(currentUser).length);
return (
<GridContent>
<Row gutter={24}>
<Col lg={7} md={24}>
<Card bordered={false} style={{ marginBottom: 24 }} loading={dataLoading}>
{!dataLoading ? (
<div>
<div className={styles.avatarHolder}>
<img alt="" src={currentUser.avatar} />
<div className={styles.name}>{currentUser.name}</div>
<div>{currentUser.signature}</div>
</div>
<div className={styles.detail}>
<p>
<i className={styles.title} />
{currentUser.title}
</p>
<p>
<i className={styles.group} />
{currentUser.group}
</p>
<p>
<i className={styles.address} />
{currentUser.geographic.province.label}
{currentUser.geographic.city.label}
</p>
</div>
<Divider dashed />
<div className={styles.tags}>
<div className={styles.tagsTitle}>标签</div>
{currentUser.tags.concat(newTags).map(item => (
<Tag key={item.key}>{item.label}</Tag>
))}
{inputVisible && (
<Input
ref={ref => this.saveInputRef(ref)}
type="text"
size="small"
style={{ width: 78 }}
value={inputValue}
onChange={this.handleInputChange}
onBlur={this.handleInputConfirm}
onPressEnter={this.handleInputConfirm}
/>
)}
{!inputVisible && (
<Tag
onClick={this.showInput}
style={{ background: '#fff', borderStyle: 'dashed' }}
>
<Icon type="plus" />
</Tag>
)}
</div>
<Divider style={{ marginTop: 16 }} dashed />
<div className={styles.team}>
<div className={styles.teamTitle}>团队</div>
<Row gutter={36}>
{currentUser.notice &&
currentUser.notice.map(item => (
<Col key={item.id} lg={24} xl={12}>
<Link to={item.href}>
<Avatar size="small" src={item.logo} />
{item.member}
</Link>
</Col>
))}
</Row>
</div>
</div>
) : null}
</Card>
</Col>
<Col lg={17} md={24}>
<Card
className={styles.tabsCard}
bordered={false}
tabList={operationTabList}
activeTabKey={tabKey}
onTabChange={this.onTabChange}
>
{this.renderChildrenByTabKey(tabKey)}
</Card>
</Col>
</Row>
</GridContent>
);
}
}
export default PAGE_NAME_UPPER_CAMEL_CASE;
import { AnyAction, Reducer } from 'redux';
import { EffectsCommandMap } from 'dva';
import { CurrentUser, ListItemDataType } from './data.d';
import { queryCurrent, queryFakeList } from './service';
export interface ModalState {
currentUser: Partial<CurrentUser>;
list: ListItemDataType[];
}
export type Effect = (
action: AnyAction,
effects: EffectsCommandMap & { select: <T>(func: (state: ModalState) => T) => T },
) => void;
export interface ModelType {
namespace: string;
state: ModalState;
effects: {
fetchCurrent: Effect;
fetch: Effect;
};
reducers: {
saveCurrentUser: Reducer<ModalState>;
queryList: Reducer<ModalState>;
};
}
const Model: ModelType = {
namespace: 'BLOCK_NAME_CAMEL_CASE',
state: {
currentUser: {},
list: [],
},
effects: {
*fetchCurrent(_, { call, put }) {
const response = yield call(queryCurrent);
yield put({
type: 'saveCurrentUser',
payload: response,
});
},
*fetch({ payload }, { call, put }) {
const response = yield call(queryFakeList, payload);
yield put({
type: 'queryList',
payload: Array.isArray(response) ? response : [],
});
},
},
reducers: {
saveCurrentUser(state, action) {
return {
...(state as ModalState),
currentUser: action.payload || {},
};
},
queryList(state, action) {
return {
...(state as ModalState),
list: action.payload,
};
},
},
};
export default Model;
import request from 'umi-request';
export async function queryCurrent() {
return request('/api/currentUser');
}
export async function queryFakeList(params: { count: number }) {
return request('/api/fake_list', {
params,
});
}
/yarn.lock
/package-lock.json
/dist
/node_modules
.umi
.umi-production
# @umi-blocks/ant-design-pro/accountsettings
AccountSettings
## Usage
```sh
umi block add ant-design-pro/AccountSettings
```
## SNAPSHOT
![SNAPSHOT](./snapshot.png)
## LICENSE
MIT
{
"name": "@umi-block/account-settings",
"version": "0.0.1",
"description": "AccountSettings",
"repository": {
"type": "git",
"url": "https://github.com/umijs/umi-blocks/ant-design-pro/accountsettings"
},
"license": "ISC",
"main": "src/index.js",
"scripts": {
"dev": "umi dev"
},
"dependencies": {
"dva": "^2.4.0",
"redux": "^4.0.1",
"umi-request": "^1.0.0"
},
"peerDependencies": {
"@ant-design/pro-layout": "^4.5.5"
},
"blockConfig": {
"specVersion": "0.1"
}
}
\ No newline at end of file
import city from './geographic/city.json';
import province from './geographic/province.json';
function getProvince(req: any, res: { json: (arg0: { name: string; id: string }[]) => void }) {
return res.json(province);
}
function getCity(
req: { params: { province: string | number } },
res: { json: (arg: any) => void },
) {
return res.json(city[req.params.province]);
}
// 代码中会兼容本地 service mock 以及部署站点的静态数据
export default {
// 支持值为 Object 和 Array
'GET /api/currentUser': {
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',
},
'GET /api/geographic/province': getProvince,
'GET /api/geographic/city/:province': getCity,
};
@import '~antd/es/style/themes/default.less';
.baseView {
display: flex;
padding-top: 12px;
.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;
}
}
}
@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;
}
}
}
}
@import '~antd/es/style/themes/default.less';
.row {
.item {
width: 50%;
max-width: 220px;
}
.item:first-child {
width: ~'calc(50% - 8px)';
margin-right: 8px;
}
}
@media screen and (max-width: @screen-sm) {
.item:first-child {
margin: 0;
margin-bottom: 8px;
}
}
import React, { Component } from 'react';
import { Select, Spin } from 'antd';
import { Dispatch } from 'redux';
import { connect } from 'dva';
import { GeographicItemType } from '../data.d';
import styles from './GeographicView.less';
const { Option } = Select;
interface SelectItem {
label: string;
key: string;
}
const nullSelectItem: SelectItem = {
label: '',
key: '',
};
interface GeographicViewProps {
dispatch?: Dispatch<any>;
province?: GeographicItemType[];
city?: GeographicItemType[];
value?: {
province: SelectItem;
city: SelectItem;
};
loading?: boolean;
onChange?: (value: { province: SelectItem; city: SelectItem }) => void;
}
@connect(
({
BLOCK_NAME_CAMEL_CASE,
loading,
}: {
BLOCK_NAME_CAMEL_CASE: {
province: GeographicItemType[];
city: GeographicItemType[];
};
loading: any;
}) => {
const { province, city } = BLOCK_NAME_CAMEL_CASE;
return {
province,
city,
loading: loading.models.BLOCK_NAME_CAMEL_CASE,
};
},
)
class GeographicView extends Component<GeographicViewProps> {
componentDidMount = () => {
const { dispatch } = this.props;
if (dispatch) {
dispatch({
type: 'BLOCK_NAME_CAMEL_CASE/fetchProvince',
});
}
};
componentDidUpdate(props: GeographicViewProps) {
const { dispatch, value } = this.props;
if (!props.value && !!value && !!value.province) {
if (dispatch) {
dispatch({
type: 'BLOCK_NAME_CAMEL_CASE/fetchCity',
payload: value.province.key,
});
}
}
}
getProvinceOption() {
const { province } = this.props;
if (province) {
return this.getOption(province);
}
return [];
}
getCityOption = () => {
const { city } = this.props;
if (city) {
return this.getOption(city);
}
return [];
};
getOption = (list: GeographicItemType[]) => {
if (!list || list.length < 1) {
return (
<Option key={0} value={0}>
没有找到选项
</Option>
);
}
return list.map(item => (
<Option key={item.id} value={item.id}>
{item.name}
</Option>
));
};
selectProvinceItem = (item: SelectItem) => {
const { dispatch, onChange } = this.props;
if (dispatch) {
dispatch({
type: 'BLOCK_NAME_CAMEL_CASE/fetchCity',
payload: item.key,
});
}
if (onChange) {
onChange({
province: item,
city: nullSelectItem,
});
}
};
selectCityItem = (item: SelectItem) => {
const { value, onChange } = this.props;
if (value && onChange) {
onChange({
province: value.province,
city: item,
});
}
};
conversionObject() {
const { value } = this.props;
if (!value) {
return {
province: nullSelectItem,
city: nullSelectItem,
};
}
const { province, city } = value;
return {
province: province || nullSelectItem,
city: city || nullSelectItem,
};
}
render() {
const { province, city } = this.conversionObject();
const { loading } = this.props;
return (
<Spin spinning={loading} wrapperClassName={styles.row}>
<Select
className={styles.item}
value={province}
labelInValue
showSearch
onSelect={this.selectProvinceItem}
>
{this.getProvinceOption()}
</Select>
<Select
className={styles.item}
value={city}
labelInValue
showSearch
onSelect={this.selectCityItem}
>
{this.getCityOption()}
</Select>
</Spin>
);
}
}
export default GeographicView;
@import '~antd/es/style/themes/default.less';
.area_code {
width: 30%;
max-width: 128px;
margin-right: 8px;
}
.phone_number {
width: ~'calc(70% - 8px)';
max-width: 312px;
}
import React, { Fragment, PureComponent } from 'react';
import { Input } from 'antd';
import styles from './PhoneView.less';
interface PhoneViewProps {
value?: string;
onChange?: (value: string) => void;
}
class PhoneView extends PureComponent<PhoneViewProps> {
render() {
const { value, onChange } = this.props;
let values = ['', ''];
if (value) {
values = value.split('-');
}
return (
<Fragment>
<Input
className={styles.area_code}
value={values[0]}
onChange={e => {
if (onChange) {
onChange(`${e.target.value}-${values[1]}`);
}
}}
/>
<Input
className={styles.phone_number}
onChange={e => {
if (onChange) {
onChange(`${values[0]}-${e.target.value}`);
}
}}
value={values[1]}
/>
</Fragment>
);
}
}
export default PhoneView;
import { Button, Form, Input, Select, Upload, message } from 'antd';
import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale';
import React, { Component, Fragment } from 'react';
import { FormComponentProps } from 'antd/es/form';
import { connect } from 'dva';
import { CurrentUser } from '../data.d';
import GeographicView from './GeographicView';
import PhoneView from './PhoneView';
import styles from './BaseView.less';
const FormItem = Form.Item;
const { Option } = Select;
// 头像组件 方便以后独立,增加裁剪之类的功能
const AvatarView = ({ avatar }: { avatar: string }) => (
<Fragment>
<div className={styles.avatar_title}>
<FormattedMessage id="BLOCK_NAME.basic.avatar" defaultMessage="Avatar" />
</div>
<div className={styles.avatar}>
<img src={avatar} alt="avatar" />
</div>
<Upload fileList={[]}>
<div className={styles.button_view}>
<Button icon="upload">
<FormattedMessage id="BLOCK_NAME.basic.change-avatar" defaultMessage="Change avatar" />
</Button>
</div>
</Upload>
</Fragment>
);
interface SelectItem {
label: string;
key: string;
}
const validatorGeographic = (
_: any,
value: {
province: SelectItem;
city: SelectItem;
},
callback: (message?: string) => void,
) => {
const { province, city } = value;
if (!province.key) {
callback('Please input your province!');
}
if (!city.key) {
callback('Please input your city!');
}
callback();
};
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();
};
interface BaseViewProps extends FormComponentProps {
currentUser?: CurrentUser;
}
@connect(({ BLOCK_NAME_CAMEL_CASE }: { BLOCK_NAME_CAMEL_CASE: { currentUser: CurrentUser } }) => ({
currentUser: BLOCK_NAME_CAMEL_CASE.currentUser,
}))
class BaseView extends Component<BaseViewProps> {
view: HTMLDivElement | undefined = undefined;
componentDidMount() {
this.setBaseInfo();
}
setBaseInfo = () => {
const { currentUser, form } = this.props;
if (currentUser) {
Object.keys(form.getFieldsValue()).forEach(key => {
const obj = {};
obj[key] = currentUser[key] || null;
form.setFieldsValue(obj);
});
}
};
getAvatarURL() {
const { currentUser } = this.props;
if (currentUser) {
if (currentUser.avatar) {
return currentUser.avatar;
}
const url = 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png';
return url;
}
return '';
}
getViewDom = (ref: HTMLDivElement) => {
this.view = ref;
};
handlerSubmit = (event: React.MouseEvent) => {
event.preventDefault();
const { form } = this.props;
form.validateFields(err => {
if (!err) {
message.success(formatMessage({ id: 'BLOCK_NAME.basic.update.success' }));
}
});
};
render() {
const {
form: { getFieldDecorator },
} = this.props;
return (
<div className={styles.baseView} ref={this.getViewDom}>
<div className={styles.left}>
<Form layout="vertical" hideRequiredMark>
<FormItem label={formatMessage({ id: 'BLOCK_NAME.basic.email' })}>
{getFieldDecorator('email', {
rules: [
{
required: true,
message: formatMessage({ id: 'BLOCK_NAME.basic.email-message' }, {}),
},
],
})(<Input />)}
</FormItem>
<FormItem label={formatMessage({ id: 'BLOCK_NAME.basic.nickname' })}>
{getFieldDecorator('name', {
rules: [
{
required: true,
message: formatMessage({ id: 'BLOCK_NAME.basic.nickname-message' }, {}),
},
],
})(<Input />)}
</FormItem>
<FormItem label={formatMessage({ id: 'BLOCK_NAME.basic.profile' })}>
{getFieldDecorator('profile', {
rules: [
{
required: true,
message: formatMessage({ id: 'BLOCK_NAME.basic.profile-message' }, {}),
},
],
})(
<Input.TextArea
placeholder={formatMessage({ id: 'BLOCK_NAME.basic.profile-placeholder' })}
rows={4}
/>,
)}
</FormItem>
<FormItem label={formatMessage({ id: 'BLOCK_NAME.basic.country' })}>
{getFieldDecorator('country', {
rules: [
{
required: true,
message: formatMessage({ id: 'BLOCK_NAME.basic.country-message' }, {}),
},
],
})(
<Select style={{ maxWidth: 220 }}>
<Option value="China">中国</Option>
</Select>,
)}
</FormItem>
<FormItem label={formatMessage({ id: 'BLOCK_NAME.basic.geographic' })}>
{getFieldDecorator('geographic', {
rules: [
{
required: true,
message: formatMessage({ id: 'BLOCK_NAME.basic.geographic-message' }, {}),
},
{
validator: validatorGeographic,
},
],
})(<GeographicView />)}
</FormItem>
<FormItem label={formatMessage({ id: 'BLOCK_NAME.basic.address' })}>
{getFieldDecorator('address', {
rules: [
{
required: true,
message: formatMessage({ id: 'BLOCK_NAME.basic.address-message' }, {}),
},
],
})(<Input />)}
</FormItem>
<FormItem label={formatMessage({ id: 'BLOCK_NAME.basic.phone' })}>
{getFieldDecorator('phone', {
rules: [
{
required: true,
message: formatMessage({ id: 'BLOCK_NAME.basic.phone-message' }, {}),
},
{ validator: validatorPhone },
],
})(<PhoneView />)}
</FormItem>
<Button type="primary" onClick={this.handlerSubmit}>
<FormattedMessage id="BLOCK_NAME.basic.update" defaultMessage="Update Information" />
</Button>
</Form>
</div>
<div className={styles.right}>
<AvatarView avatar={this.getAvatarURL()} />
</div>
</div>
);
}
}
export default Form.create<BaseViewProps>()(BaseView);
import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale';
import { Icon, List } from 'antd';
import React, { Component, Fragment } from 'react';
class BindingView extends Component {
getData = () => [
{
title: formatMessage({ id: 'BLOCK_NAME.binding.taobao' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.binding.taobao-description' }, {}),
actions: [
<a key="Bind">
<FormattedMessage id="BLOCK_NAME.binding.bind" defaultMessage="Bind" />
</a>,
],
avatar: <Icon type="taobao" className="taobao" />,
},
{
title: formatMessage({ id: 'BLOCK_NAME.binding.alipay' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.binding.alipay-description' }, {}),
actions: [
<a key="Bind">
<FormattedMessage id="BLOCK_NAME.binding.bind" defaultMessage="Bind" />
</a>,
],
avatar: <Icon type="alipay" className="alipay" />,
},
{
title: formatMessage({ id: 'BLOCK_NAME.binding.dingding' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.binding.dingding-description' }, {}),
actions: [
<a key="Bind">
<FormattedMessage id="BLOCK_NAME.binding.bind" defaultMessage="Bind" />
</a>,
],
avatar: <Icon type="dingding" className="dingding" />,
},
];
render() {
return (
<Fragment>
<List
itemLayout="horizontal"
dataSource={this.getData()}
renderItem={item => (
<List.Item actions={item.actions}>
<List.Item.Meta
avatar={item.avatar}
title={item.title}
description={item.description}
/>
</List.Item>
)}
/>
</Fragment>
);
}
}
export default BindingView;
import { List, Switch } from 'antd';
import React, { Component, Fragment } from 'react';
import { formatMessage } from 'umi-plugin-react/locale';
type Unpacked<T> = T extends (infer U)[] ? U : T;
class NotificationView extends Component {
getData = () => {
const Action = (
<Switch
checkedChildren={formatMessage({ id: 'BLOCK_NAME.settings.open' })}
unCheckedChildren={formatMessage({ id: 'BLOCK_NAME.settings.close' })}
defaultChecked
/>
);
return [
{
title: formatMessage({ id: 'BLOCK_NAME.notification.password' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.notification.password-description' }, {}),
actions: [Action],
},
{
title: formatMessage({ id: 'BLOCK_NAME.notification.messages' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.notification.messages-description' }, {}),
actions: [Action],
},
{
title: formatMessage({ id: 'BLOCK_NAME.notification.todo' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.notification.todo-description' }, {}),
actions: [Action],
},
];
};
render() {
const data = this.getData();
return (
<Fragment>
<List<Unpacked<typeof data>>
itemLayout="horizontal"
dataSource={data}
renderItem={item => (
<List.Item actions={item.actions}>
<List.Item.Meta title={item.title} description={item.description} />
</List.Item>
)}
/>
</Fragment>
);
}
}
export default NotificationView;
import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale';
import React, { Component, Fragment } from 'react';
import { List } from 'antd';
type Unpacked<T> = T extends (infer U)[] ? U : T;
const passwordStrength = {
strong: (
<span className="strong">
<FormattedMessage id="BLOCK_NAME.security.strong" defaultMessage="Strong" />
</span>
),
medium: (
<span className="medium">
<FormattedMessage id="BLOCK_NAME.security.medium" defaultMessage="Medium" />
</span>
),
weak: (
<span className="weak">
<FormattedMessage id="BLOCK_NAME.security.weak" defaultMessage="Weak" />
Weak
</span>
),
};
class SecurityView extends Component {
getData = () => [
{
title: formatMessage({ id: 'BLOCK_NAME.security.password' }, {}),
description: (
<Fragment>
{formatMessage({ id: 'BLOCK_NAME.security.password-description' })}
{passwordStrength.strong}
</Fragment>
),
actions: [
<a key="Modify">
<FormattedMessage id="BLOCK_NAME.security.modify" defaultMessage="Modify" />
</a>,
],
},
{
title: formatMessage({ id: 'BLOCK_NAME.security.phone' }, {}),
description: `${formatMessage(
{ id: 'BLOCK_NAME.security.phone-description' },
{},
)}:138****8293`,
actions: [
<a key="Modify">
<FormattedMessage id="BLOCK_NAME.security.modify" defaultMessage="Modify" />
</a>,
],
},
{
title: formatMessage({ id: 'BLOCK_NAME.security.question' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.security.question-description' }, {}),
actions: [
<a key="Set">
<FormattedMessage id="BLOCK_NAME.security.set" defaultMessage="Set" />
</a>,
],
},
{
title: formatMessage({ id: 'BLOCK_NAME.security.email' }, {}),
description: `${formatMessage(
{ id: 'BLOCK_NAME.security.email-description' },
{},
)}:ant***sign.com`,
actions: [
<a key="Modify">
<FormattedMessage id="BLOCK_NAME.security.modify" defaultMessage="Modify" />
</a>,
],
},
{
title: formatMessage({ id: 'BLOCK_NAME.security.mfa' }, {}),
description: formatMessage({ id: 'BLOCK_NAME.security.mfa-description' }, {}),
actions: [
<a key="bind">
<FormattedMessage id="BLOCK_NAME.security.bind" defaultMessage="Bind" />
</a>,
],
},
];
render() {
const data = this.getData();
return (
<Fragment>
<List<Unpacked<typeof data>>
itemLayout="horizontal"
dataSource={data}
renderItem={item => (
<List.Item actions={item.actions}>
<List.Item.Meta title={item.title} description={item.description} />
</List.Item>
)}
/>
</Fragment>
);
}
}
export default SecurityView;
export interface TagType {
key: string;
label: string;
}
export interface GeographicItemType {
name: string;
id: string;
}
export interface GeographicType {
province: GeographicItemType;
city: GeographicItemType;
}
export interface NoticeType {
id: string;
title: string;
logo: string;
description: string;
updatedAt: string;
member: string;
href: string;
memberLink: string;
}
export interface 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;
}
[
{
"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"
}
]
import React, { Component } from 'react';
import { Dispatch } from 'redux';
import { FormattedMessage } from 'umi-plugin-react/locale';
import { GridContent } from '@ant-design/pro-layout';
import { Menu } from 'antd';
import { connect } from 'dva';
import BaseView from './components/base';
import BindingView from './components/binding';
import { CurrentUser } from './data.d';
import NotificationView from './components/notification';
import SecurityView from './components/security';
import styles from './style.less';
const { Item } = Menu;
interface PAGE_NAME_UPPER_CAMEL_CASEProps {
dispatch: Dispatch<any>;
currentUser: CurrentUser;
}
type PAGE_NAME_UPPER_CAMEL_CASEStateKeys = 'base' | 'security' | 'binding' | 'notification';
interface PAGE_NAME_UPPER_CAMEL_CASEState {
mode: 'inline' | 'horizontal';
menuMap: {
[key: string]: React.ReactNode;
};
selectKey: PAGE_NAME_UPPER_CAMEL_CASEStateKeys;
}
@connect(({ BLOCK_NAME_CAMEL_CASE }: { BLOCK_NAME_CAMEL_CASE: { currentUser: CurrentUser } }) => ({
currentUser: BLOCK_NAME_CAMEL_CASE.currentUser,
}))
class PAGE_NAME_UPPER_CAMEL_CASE extends Component<
PAGE_NAME_UPPER_CAMEL_CASEProps,
PAGE_NAME_UPPER_CAMEL_CASEState
> {
main: HTMLDivElement | undefined = undefined;
constructor(props: PAGE_NAME_UPPER_CAMEL_CASEProps) {
super(props);
const menuMap = {
base: <FormattedMessage id="BLOCK_NAME.menuMap.basic" defaultMessage="Basic Settings" />,
security: (
<FormattedMessage id="BLOCK_NAME.menuMap.security" defaultMessage="Security Settings" />
),
binding: (
<FormattedMessage id="BLOCK_NAME.menuMap.binding" defaultMessage="Account Binding" />
),
notification: (
<FormattedMessage
id="BLOCK_NAME.menuMap.notification"
defaultMessage="New Message Notification"
/>
),
};
this.state = {
mode: 'inline',
menuMap,
selectKey: 'base',
};
}
componentDidMount() {
const { dispatch } = this.props;
dispatch({
type: 'BLOCK_NAME_CAMEL_CASE/fetchCurrent',
});
window.addEventListener('resize', this.resize);
this.resize();
}
componentWillUnmount() {
window.removeEventListener('resize', this.resize);
}
getMenu = () => {
const { menuMap } = this.state;
return Object.keys(menuMap).map(item => <Item key={item}>{menuMap[item]}</Item>);
};
getRightTitle = () => {
const { selectKey, menuMap } = this.state;
return menuMap[selectKey];
};
selectKey = (key: PAGE_NAME_UPPER_CAMEL_CASEStateKeys) => {
this.setState({
selectKey: key,
});
};
resize = () => {
if (!this.main) {
return;
}
requestAnimationFrame(() => {
if (!this.main) {
return;
}
let mode: 'inline' | 'horizontal' = 'inline';
const { offsetWidth } = this.main;
if (this.main.offsetWidth < 641 && offsetWidth > 400) {
mode = 'horizontal';
}
if (window.innerWidth < 768 && offsetWidth > 400) {
mode = 'horizontal';
}
this.setState({
mode,
});
});
};
renderChildren = () => {
const { selectKey } = this.state;
switch (selectKey) {
case 'base':
return <BaseView />;
case 'security':
return <SecurityView />;
case 'binding':
return <BindingView />;
case 'notification':
return <NotificationView />;
default:
break;
}
return null;
};
render() {
const { currentUser } = this.props;
if (!currentUser.userid) {
return '';
}
const { mode, selectKey } = this.state;
return (
<GridContent>
<div
className={styles.main}
ref={ref => {
if (ref) {
this.main = ref;
}
}}
>
<div className={styles.leftMenu}>
<Menu
mode={mode}
selectedKeys={[selectKey]}
onClick={({ key }) => this.selectKey(key as PAGE_NAME_UPPER_CAMEL_CASEStateKeys)}
>
{this.getMenu()}
</Menu>
</div>
<div className={styles.right}>
<div className={styles.title}>{this.getRightTitle()}</div>
{this.renderChildren()}
</div>
</div>
</GridContent>
);
}
}
export default PAGE_NAME_UPPER_CAMEL_CASE;
export default {
'BLOCK_NAME.menuMap.basic': 'Basic Settings',
'BLOCK_NAME.menuMap.security': 'Security Settings',
'BLOCK_NAME.menuMap.binding': 'Account Binding',
'BLOCK_NAME.menuMap.notification': 'New Message Notification',
'BLOCK_NAME.basic.avatar': 'Avatar',
'BLOCK_NAME.basic.change-avatar': 'Change avatar',
'BLOCK_NAME.basic.email': 'Email',
'BLOCK_NAME.basic.email-message': 'Please input your email!',
'BLOCK_NAME.basic.nickname': 'Nickname',
'BLOCK_NAME.basic.nickname-message': 'Please input your Nickname!',
'BLOCK_NAME.basic.profile': 'Personal profile',
'BLOCK_NAME.basic.profile-message': 'Please input your personal profile!',
'BLOCK_NAME.basic.profile-placeholder': 'Brief introduction to yourself',
'BLOCK_NAME.basic.country': 'Country/Region',
'BLOCK_NAME.basic.country-message': 'Please input your country!',
'BLOCK_NAME.basic.geographic': 'Province or city',
'BLOCK_NAME.basic.geographic-message': 'Please input your geographic info!',
'BLOCK_NAME.basic.address': 'Street Address',
'BLOCK_NAME.basic.address-message': 'Please input your address!',
'BLOCK_NAME.basic.phone': 'Phone Number',
'BLOCK_NAME.basic.phone-message': 'Please input your phone!',
'BLOCK_NAME.basic.update': 'Update Information',
'BLOCK_NAME.basic.update.success': 'Update basic information successfully',
'BLOCK_NAME.security.strong': 'Strong',
'BLOCK_NAME.security.medium': 'Medium',
'BLOCK_NAME.security.weak': 'Weak',
'BLOCK_NAME.security.password': 'Account Password',
'BLOCK_NAME.security.password-description': 'Current password strength:',
'BLOCK_NAME.security.phone': 'Security Phone',
'BLOCK_NAME.security.phone-description': 'Bound phone:',
'BLOCK_NAME.security.question': 'Security Question',
'BLOCK_NAME.security.question-description':
'The security question is not set, and the security policy can effectively protect the account security',
'BLOCK_NAME.security.email': 'Backup Email',
'BLOCK_NAME.security.email-description': 'Bound Email:',
'BLOCK_NAME.security.mfa': 'MFA Device',
'BLOCK_NAME.security.mfa-description':
'Unbound MFA device, after binding, can be confirmed twice',
'BLOCK_NAME.security.modify': 'Modify',
'BLOCK_NAME.security.set': 'Set',
'BLOCK_NAME.security.bind': 'Bind',
'BLOCK_NAME.binding.taobao': 'Binding Taobao',
'BLOCK_NAME.binding.taobao-description': 'Currently unbound Taobao account',
'BLOCK_NAME.binding.alipay': 'Binding Alipay',
'BLOCK_NAME.binding.alipay-description': 'Currently unbound Alipay account',
'BLOCK_NAME.binding.dingding': 'Binding DingTalk',
'BLOCK_NAME.binding.dingding-description': 'Currently unbound DingTalk account',
'BLOCK_NAME.binding.bind': 'Bind',
'BLOCK_NAME.notification.password': 'Account Password',
'BLOCK_NAME.notification.password-description':
'Messages from other users will be notified in the form of a station letter',
'BLOCK_NAME.notification.messages': 'System Messages',
'BLOCK_NAME.notification.messages-description':
'System messages will be notified in the form of a station letter',
'BLOCK_NAME.notification.todo': 'To-do Notification',
'BLOCK_NAME.notification.todo-description':
'The to-do list will be notified in the form of a letter from the station',
'BLOCK_NAME.settings.open': 'Open',
'BLOCK_NAME.settings.close': 'Close',
};
export default {
'BLOCK_NAME.menuMap.basic': '基本设置',
'BLOCK_NAME.menuMap.security': '安全设置',
'BLOCK_NAME.menuMap.binding': '账号绑定',
'BLOCK_NAME.menuMap.notification': '新消息通知',
'BLOCK_NAME.basic.avatar': '头像',
'BLOCK_NAME.basic.change-avatar': '更换头像',
'BLOCK_NAME.basic.email': '邮箱',
'BLOCK_NAME.basic.email-message': '请输入您的邮箱!',
'BLOCK_NAME.basic.nickname': '昵称',
'BLOCK_NAME.basic.nickname-message': '请输入您的昵称!',
'BLOCK_NAME.basic.profile': '个人简介',
'BLOCK_NAME.basic.profile-message': '请输入个人简介!',
'BLOCK_NAME.basic.profile-placeholder': '个人简介',
'BLOCK_NAME.basic.country': '国家/地区',
'BLOCK_NAME.basic.country-message': '请输入您的国家或地区!',
'BLOCK_NAME.basic.geographic': '所在省市',
'BLOCK_NAME.basic.geographic-message': '请输入您的所在省市!',
'BLOCK_NAME.basic.address': '街道地址',
'BLOCK_NAME.basic.address-message': '请输入您的街道地址!',
'BLOCK_NAME.basic.phone': '联系电话',
'BLOCK_NAME.basic.phone-message': '请输入您的联系电话!',
'BLOCK_NAME.basic.update': '更新基本信息',
'BLOCK_NAME.basic.update.success': '更新基本信息成功',
'BLOCK_NAME.security.strong': '强',
'BLOCK_NAME.security.medium': '中',
'BLOCK_NAME.security.weak': '弱',
'BLOCK_NAME.security.password': '账户密码',
'BLOCK_NAME.security.password-description': '当前密码强度:',
'BLOCK_NAME.security.phone': '密保手机',
'BLOCK_NAME.security.phone-description': '已绑定手机:',
'BLOCK_NAME.security.question': '密保问题',
'BLOCK_NAME.security.question-description': '未设置密保问题,密保问题可有效保护账户安全',
'BLOCK_NAME.security.email': '备用邮箱',
'BLOCK_NAME.security.email-description': '已绑定邮箱:',
'BLOCK_NAME.security.mfa': 'MFA 设备',
'BLOCK_NAME.security.mfa-description': '未绑定 MFA 设备,绑定后,可以进行二次确认',
'BLOCK_NAME.security.modify': '修改',
'BLOCK_NAME.security.set': '设置',
'BLOCK_NAME.security.bind': '绑定',
'BLOCK_NAME.binding.taobao': '绑定淘宝',
'BLOCK_NAME.binding.taobao-description': '当前未绑定淘宝账号',
'BLOCK_NAME.binding.alipay': '绑定支付宝',
'BLOCK_NAME.binding.alipay-description': '当前未绑定支付宝账号',
'BLOCK_NAME.binding.dingding': '绑定钉钉',
'BLOCK_NAME.binding.dingding-description': '当前未绑定钉钉账号',
'BLOCK_NAME.binding.bind': '绑定',
'BLOCK_NAME.notification.password': '账户密码',
'BLOCK_NAME.notification.password-description': '其他用户的消息将以站内信的形式通知',
'BLOCK_NAME.notification.messages': '系统消息',
'BLOCK_NAME.notification.messages-description': '系统消息将以站内信的形式通知',
'BLOCK_NAME.notification.todo': '待办任务',
'BLOCK_NAME.notification.todo-description': '待办任务将以站内信的形式通知',
'BLOCK_NAME.settings.open': '开',
'BLOCK_NAME.settings.close': '关',
};
export default {
'BLOCK_NAME.menuMap.basic': '基本設置',
'BLOCK_NAME.menuMap.security': '安全設置',
'BLOCK_NAME.menuMap.binding': '賬號綁定',
'BLOCK_NAME.menuMap.notification': '新消息通知',
'BLOCK_NAME.basic.avatar': '頭像',
'BLOCK_NAME.basic.change-avatar': '更換頭像',
'BLOCK_NAME.basic.email': '郵箱',
'BLOCK_NAME.basic.email-message': '請輸入您的郵箱!',
'BLOCK_NAME.basic.nickname': '昵稱',
'BLOCK_NAME.basic.nickname-message': '請輸入您的昵稱!',
'BLOCK_NAME.basic.profile': '個人簡介',
'BLOCK_NAME.basic.profile-message': '請輸入個人簡介!',
'BLOCK_NAME.basic.profile-placeholder': '個人簡介',
'BLOCK_NAME.basic.country': '國家/地區',
'BLOCK_NAME.basic.country-message': '請輸入您的國家或地區!',
'BLOCK_NAME.basic.geographic': '所在省市',
'BLOCK_NAME.basic.geographic-message': '請輸入您的所在省市!',
'BLOCK_NAME.basic.address': '街道地址',
'BLOCK_NAME.basic.address-message': '請輸入您的街道地址!',
'BLOCK_NAME.basic.phone': '聯系電話',
'BLOCK_NAME.basic.phone-message': '請輸入您的聯系電話!',
'BLOCK_NAME.basic.update': '更新基本信息',
'BLOCK_NAME.basic.update.success': '更新基本信息成功',
'BLOCK_NAME.security.strong': '強',
'BLOCK_NAME.security.medium': '中',
'BLOCK_NAME.security.weak': '弱',
'BLOCK_NAME.security.password': '賬戶密碼',
'BLOCK_NAME.security.password-description': '當前密碼強度:',
'BLOCK_NAME.security.phone': '密保手機',
'BLOCK_NAME.security.phone-description': '已綁定手機:',
'BLOCK_NAME.security.question': '密保問題',
'BLOCK_NAME.security.question-description': '未設置密保問題,密保問題可有效保護賬戶安全',
'BLOCK_NAME.security.email': '備用郵箱',
'BLOCK_NAME.security.email-description': '已綁定郵箱:',
'BLOCK_NAME.security.mfa': 'MFA 設備',
'BLOCK_NAME.security.mfa-description': '未綁定 MFA 設備,綁定後,可以進行二次確認',
'BLOCK_NAME.security.modify': '修改',
'BLOCK_NAME.security.set': '設置',
'BLOCK_NAME.security.bind': '綁定',
'BLOCK_NAME.binding.taobao': '綁定淘寶',
'BLOCK_NAME.binding.taobao-description': '當前未綁定淘寶賬號',
'BLOCK_NAME.binding.alipay': '綁定支付寶',
'BLOCK_NAME.binding.alipay-description': '當前未綁定支付寶賬號',
'BLOCK_NAME.binding.dingding': '綁定釘釘',
'BLOCK_NAME.binding.dingding-description': '當前未綁定釘釘賬號',
'BLOCK_NAME.binding.bind': '綁定',
'BLOCK_NAME.notification.password': '賬戶密碼',
'BLOCK_NAME.notification.password-description': '其他用戶的消息將以站內信的形式通知',
'BLOCK_NAME.notification.messages': '系統消息',
'BLOCK_NAME.notification.messages-description': '系統消息將以站內信的形式通知',
'BLOCK_NAME.notification.todo': '待辦任務',
'BLOCK_NAME.notification.todo-description': '待辦任務將以站內信的形式通知',
'BLOCK_NAME.settings.open': '開',
'BLOCK_NAME.settings.close': '關',
};
import { AnyAction, Reducer } from 'redux';
import { EffectsCommandMap } from 'dva';
import { CurrentUser, GeographicItemType } from './data.d';
import { queryCity, queryCurrent, queryProvince, query as queryUsers } from './service';
export interface ModalState {
currentUser?: Partial<CurrentUser>;
province?: GeographicItemType[];
city?: GeographicItemType[];
isLoading?: boolean;
}
export type Effect = (
action: AnyAction,
effects: EffectsCommandMap & { select: <T>(func: (state: ModalState) => T) => T },
) => void;
export interface ModelType {
namespace: string;
state: ModalState;
effects: {
fetchCurrent: Effect;
fetch: Effect;
fetchProvince: Effect;
fetchCity: Effect;
};
reducers: {
saveCurrentUser: Reducer<ModalState>;
changeNotifyCount: Reducer<ModalState>;
setProvince: Reducer<ModalState>;
setCity: Reducer<ModalState>;
changeLoading: Reducer<ModalState>;
};
}
const Model: ModelType = {
namespace: 'BLOCK_NAME_CAMEL_CASE',
state: {
currentUser: {},
province: [],
city: [],
isLoading: false,
},
effects: {
*fetch(_, { call, put }) {
const response = yield call(queryUsers);
yield put({
type: 'save',
payload: response,
});
},
*fetchCurrent(_, { call, put }) {
const response = yield call(queryCurrent);
yield put({
type: 'saveCurrentUser',
payload: response,
});
},
*fetchProvince(_, { call, put }) {
yield put({
type: 'changeLoading',
payload: true,
});
const response = yield call(queryProvince);
yield put({
type: 'setProvince',
payload: response,
});
},
*fetchCity({ payload }, { call, put }) {
const response = yield call(queryCity, payload);
yield put({
type: 'setCity',
payload: response,
});
},
},
reducers: {
saveCurrentUser(state, action) {
return {
...state,
currentUser: action.payload || {},
};
},
changeNotifyCount(state = {}, action) {
return {
...state,
currentUser: {
...state.currentUser,
notifyCount: action.payload.totalCount,
unreadCount: action.payload.unreadCount,
},
};
},
setProvince(state, action) {
return {
...state,
province: action.payload,
};
},
setCity(state, action) {
return {
...state,
city: action.payload,
};
},
changeLoading(state, action) {
return {
...state,
isLoading: action.payload,
};
},
},
};
export default Model;
import request from 'umi-request';
export async function queryCurrent() {
return request('/api/currentUser');
}
export async function queryProvince() {
return request('/api/geographic/province');
}
export async function queryCity(province: string) {
return request(`/api/geographic/city/${province}`);
}
export async function query() {
return request('/api/users');
}
@import '~antd/es/style/themes/default.less';
.main {
display: flex;
width: 100%;
height: 100%;
padding-top: 16px;
padding-bottom: 16px;
overflow: auto;
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-top: 8px;
padding-right: 40px;
padding-bottom: 8px;
padding-left: 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;
}
}
}
/yarn.lock
/package-lock.json
/dist
/node_modules
.umi
.umi-production
# @umi-blocks/ant-design-pro/analysis
Analysis
## Usage
```sh
umi block add ant-design-pro/analysis
```
## SNAPSHOT
![SNAPSHOT](./snapshot.png)
## LICENSE
MIT
{
"name": "@umi-block/analysis",
"version": "0.0.1",
"description": "Analysis",
"repository": {
"type": "git",
"url": "https://github.com/umijs/umi-blocks/ant-design-pro/analysis"
},
"license": "ISC",
"main": "src/index.js",
"scripts": {
"dev": "umi dev"
},
"dependencies": {
"@antv/data-set": "^0.10.2",
"@types/lodash.debounce": "^4.0.6",
"bizcharts": "^3.5.3-beta.0",
"bizcharts-plugin-slider": "^2.1.1-beta.1",
"classnames": "^2.2.6",
"dva": "^2.4.0",
"lodash.debounce": "^4.0.8",
"moment": "^2.22.2",
"numeral": "^2.0.6",
"react-fittext": "^1.0.0",
"redux": "^4.0.1",
"umi-request": "^1.0.0"
},
"devDependencies": {
"@types/numeral": "^0.0.25"
},
"peerDependencies": {
"@ant-design/pro-layout": "^4.5.5"
},
"blockConfig": {
"specVersion": "0.1"
}
}
\ No newline at end of file
import moment from 'moment';
import { AnalysisData, RadarData, VisitDataType } from './data.d';
// mock data
const visitData: VisitDataType[] = [];
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) {
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 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,
};
export default {
'GET /api/fake_chart_data': getFakeChartData,
};
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 interface BarProps {
title: React.ReactNode;
color?: string;
padding?: [number, number, number, number];
height?: number;
data: {
x: string;
y: number;
}[];
forceFit?: boolean;
autoLabel?: boolean;
style?: React.CSSProperties;
}
class Bar extends Component<
BarProps,
{
autoHideXLabels: boolean;
}
> {
state = {
autoHideXLabels: false,
};
root: HTMLDivElement | undefined = undefined;
node: HTMLDivElement | undefined = undefined;
resize = Debounce(() => {
if (!this.node || !this.node.parentNode) {
return;
}
const canvasWidth = (this.node.parentNode as HTMLDivElement).clientWidth;
const { data = [], autoLabel = true } = this.props;
if (!autoLabel) {
return;
}
const minWidth = data.length * 30;
const { autoHideXLabels } = this.state;
if (canvasWidth <= minWidth) {
if (!autoHideXLabels) {
this.setState({
autoHideXLabels: true,
});
}
} else if (autoHideXLabels) {
this.setState({
autoHideXLabels: false,
});
}
}, 500);
componentDidMount() {
window.addEventListener('resize', this.resize, { passive: true });
}
componentWillUnmount() {
window.removeEventListener('resize', this.resize);
}
handleRoot = (n: HTMLDivElement) => {
this.root = n;
};
handleRef = (n: HTMLDivElement) => {
this.node = n;
};
render() {
const {
height = 1,
title,
forceFit = true,
data,
color = 'rgba(24, 144, 255, 0.85)',
padding,
} = this.props;
const { autoHideXLabels } = this.state;
const scale = {
x: {
type: 'cat',
},
y: {
min: 0,
},
};
const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [
'x*y',
(x: string, y: string) => ({
name: x,
value: y,
}),
];
return (
<div className={styles.chart} style={{ height }} ref={this.handleRoot}>
<div ref={this.handleRef}>
{title && <h4 style={{ marginBottom: 20 }}>{title}</h4>}
<Chart
scale={scale}
height={title ? height - 41 : height}
forceFit={forceFit}
data={data}
padding={padding || 'auto'}
>
<Axis
name="x"
title={false}
label={autoHideXLabels ? undefined : {}}
tickLine={autoHideXLabels ? undefined : {}}
/>
<Axis name="y" min={0} />
<Tooltip showTitle={false} crosshairs={false} />
<Geom type="interval" position="x*y" color={color} tooltip={tooltip} />
</Chart>
</div>
</div>
);
}
}
export default autoHeight()(Bar);
@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;
}
}
import { Card } from 'antd';
import { 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) {
return null;
}
let totalDom;
switch (typeof total) {
case 'undefined':
totalDom = null;
break;
case 'function':
totalDom = <div className={styles.total}>{total()}</div>;
break;
default:
totalDom = <div className={styles.total}>{total}</div>;
}
return totalDom;
};
export interface ChartCardProps extends CardProps {
title: React.ReactNode;
action?: React.ReactNode;
total?: React.ReactNode | number | (() => React.ReactNode | number);
footer?: React.ReactNode;
contentHeight?: number;
avatar?: React.ReactNode;
style?: React.CSSProperties;
}
class ChartCard extends React.Component<ChartCardProps> {
renderContent = () => {
const { contentHeight, title, avatar, action, total, footer, children, loading } = this.props;
if (loading) {
return false;
}
return (
<div className={styles.chartCard}>
<div
className={classNames(styles.chartTop, {
[styles.chartTopMargin]: !children && !footer,
})}
>
<div className={styles.avatar}>{avatar}</div>
<div className={styles.metaWrap}>
<div className={styles.meta}>
<span className={styles.title}>{title}</span>
<span className={styles.action}>{action}</span>
</div>
{renderTotal(total)}
</div>
</div>
{children && (
<div className={styles.content} style={{ height: contentHeight || 'auto' }}>
<div className={contentHeight && styles.contentFixed}>{children}</div>
</div>
)}
{footer && (
<div
className={classNames(styles.footer, {
[styles.footerMargin]: !children,
})}
>
{footer}
</div>
)}
</div>
);
};
render() {
const {
loading = false,
contentHeight,
title,
avatar,
action,
total,
footer,
children,
...rest
} = this.props;
return (
<Card loading={loading} bodyStyle={{ padding: '20px 24px 8px 24px' }} {...rest}>
{this.renderContent()}
</Card>
);
}
}
export default ChartCard;
@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;
}
}
import React from 'react';
import styles from './index.less';
export interface FieldProps {
label: React.ReactNode;
value: React.ReactNode;
style?: React.CSSProperties;
}
const Field: React.FC<FieldProps> = ({ label, value, ...rest }) => (
<div className={styles.field} {...rest}>
<span className={styles.label}>{label}</span>
<span className={styles.number}>{value}</span>
</div>
);
export default Field;
import { Axis, Chart, Coord, Geom, Guide, Shape } from 'bizcharts';
import React from 'react';
import autoHeight from '../autoHeight';
const { Arc, Html, Line } = Guide;
export interface GaugeProps {
title: React.ReactNode;
color?: string;
height?: number;
bgColor?: number;
percent: number;
forceFit?: boolean;
style?: React.CSSProperties;
formatter: (value: string) => string;
}
const defaultFormatter = (val: string): string => {
switch (val) {
case '2':
return '差';
case '4':
return '中';
case '6':
return '良';
case '8':
return '优';
default:
return '';
}
};
if (Shape.registerShape) {
Shape.registerShape('point', 'pointer', {
drawShape(cfg: any, group: any) {
let point = cfg.points[0];
point = (this as any).parsePoint(point);
const center = (this as any).parsePoint({
x: 0,
y: 0,
});
group.addShape('line', {
attrs: {
x1: center.x,
y1: center.y,
x2: point.x,
y2: point.y,
stroke: cfg.color,
lineWidth: 2,
lineCap: 'round',
},
});
return group.addShape('circle', {
attrs: {
x: center.x,
y: center.y,
r: 6,
stroke: cfg.color,
lineWidth: 3,
fill: '#fff',
},
});
},
});
}
const Gauge: React.FC<GaugeProps> = props => {
const {
title,
height = 1,
percent,
forceFit = true,
formatter = defaultFormatter,
color = '#2F9CFF',
bgColor = '#F0F2F5',
} = props;
const cols = {
value: {
type: 'linear',
min: 0,
max: 10,
tickCount: 6,
nice: true,
},
};
const data = [{ value: percent / 10 }];
const renderHtml = () => `
<div style="width: 300px;text-align: center;font-size: 12px!important;">
<p style="font-size: 14px; color: rgba(0,0,0,0.43);margin: 0;">${title}</p>
<p style="font-size: 24px;color: rgba(0,0,0,0.85);margin: 0;">
${(data[0].value * 10).toFixed(2)}%
</p>
</div>`;
const textStyle: {
fontSize: number;
fill: string;
textAlign: 'center';
} = {
fontSize: 12,
fill: 'rgba(0, 0, 0, 0.65)',
textAlign: 'center',
};
return (
<Chart height={height} data={data} scale={cols} padding={[-16, 0, 16, 0]} forceFit={forceFit}>
<Coord type="polar" startAngle={-1.25 * Math.PI} endAngle={0.25 * Math.PI} radius={0.8} />
<Axis name="1" line={undefined} />
<Axis
line={undefined}
tickLine={undefined}
subTickLine={undefined}
name="value"
zIndex={2}
label={{
offset: -12,
formatter,
textStyle,
}}
/>
<Guide>
<Line
start={[3, 0.905]}
end={[3, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 2,
}}
/>
<Line
start={[5, 0.905]}
end={[5, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 3,
}}
/>
<Line
start={[7, 0.905]}
end={[7, 0.85]}
lineStyle={{
stroke: color,
lineDash: undefined,
lineWidth: 3,
}}
/>
<Arc
start={[0, 0.965]}
end={[10, 0.965]}
style={{
stroke: bgColor,
lineWidth: 10,
}}
/>
<Arc
start={[0, 0.965]}
end={[data[0].value, 0.965]}
style={{
stroke: color,
lineWidth: 10,
}}
/>
<Html position={['50%', '95%']} html={renderHtml()} />
</Guide>
<Geom
line={false}
type="point"
position="value*1"
shape="pointer"
color={color}
active={false}
/>
</Chart>
);
};
export default autoHeight()(Gauge);
import { Axis, Chart, Geom, Tooltip, AxisProps } from 'bizcharts';
import React from 'react';
import autoHeight from '../autoHeight';
import styles from '../index.less';
export interface MiniAreaProps {
color?: string;
height?: number;
borderColor?: string;
line?: boolean;
animate?: boolean;
xAxis?: AxisProps;
forceFit?: boolean;
scale?: {
x?: {
tickCount: number;
};
y?: {
tickCount: number;
};
};
yAxis?: Partial<AxisProps>;
borderWidth?: number;
data: {
x: number | string;
y: number;
}[];
}
const MiniArea: React.FC<MiniAreaProps> = props => {
const {
height = 1,
data = [],
forceFit = true,
color = 'rgba(24, 144, 255, 0.2)',
borderColor = '#1089ff',
scale = { x: {}, y: {} },
borderWidth = 2,
line,
xAxis,
yAxis,
animate = true,
} = props;
const padding: [number, number, number, number] = [36, 5, 30, 5];
const scaleProps = {
x: {
type: 'cat',
range: [0, 1],
...scale.x,
},
y: {
min: 0,
...scale.y,
},
};
const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [
'x*y',
(x: string, y: string) => ({
name: x,
value: y,
}),
];
const chartHeight = height + 54;
return (
<div className={styles.miniChart} style={{ height }}>
<div className={styles.chartContent}>
{height > 0 && (
<Chart
animate={animate}
scale={scaleProps}
height={chartHeight}
forceFit={forceFit}
data={data}
padding={padding}
>
<Axis
key="axis-x"
name="x"
label={null}
line={null}
tickLine={null}
grid={null}
{...xAxis}
/>
<Axis
key="axis-y"
name="y"
label={null}
line={null}
tickLine={null}
grid={null}
{...yAxis}
/>
<Tooltip showTitle={false} crosshairs={false} />
<Geom
type="area"
position="x*y"
color={color}
tooltip={tooltip}
shape="smooth"
style={{
fillOpacity: 1,
}}
/>
{line ? (
<Geom
type="line"
position="x*y"
shape="smooth"
color={borderColor}
size={borderWidth}
tooltip={false}
/>
) : (
<span style={{ display: 'none' }} />
)}
</Chart>
)}
</div>
</div>
);
};
export default autoHeight()(MiniArea);
import { Chart, Geom, Tooltip } from 'bizcharts';
import React from 'react';
import autoHeight from '../autoHeight';
import styles from '../index.less';
export interface MiniBarProps {
color?: string;
height?: number;
data: {
x: number | string;
y: number;
}[];
forceFit?: boolean;
style?: React.CSSProperties;
}
const MiniBar: React.FC<MiniBarProps> = props => {
const { height = 0, forceFit = true, color = '#1890FF', data = [] } = props;
const scale = {
x: {
type: 'cat',
},
y: {
min: 0,
},
};
const padding: [number, number, number, number] = [36, 5, 30, 5];
const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [
'x*y',
(x: string, y: string) => ({
name: x,
value: y,
}),
];
// for tooltip not to be hide
const chartHeight = height + 54;
return (
<div className={styles.miniChart} style={{ height }}>
<div className={styles.chartContent}>
<Chart scale={scale} height={chartHeight} forceFit={forceFit} data={data} padding={padding}>
<Tooltip showTitle={false} crosshairs={false} />
<Geom type="interval" position="x*y" color={color} tooltip={tooltip} />
</Chart>
</div>
</div>
);
};
export default autoHeight()(MiniBar);
@import '~antd/es/style/themes/default.less';
.miniProgress {
position: relative;
width: 100%;
padding: 5px 0;
.progressWrap {
position: relative;
background-color: @background-color-base;
}
.progress {
width: 0;
height: 100%;
background-color: @primary-color;
border-radius: 1px 0 0 1px;
transition: all 0.4s cubic-bezier(0.08, 0.82, 0.17, 1) 0s;
}
.target {
position: absolute;
top: 0;
bottom: 0;
z-index: 9;
width: 20px;
span {
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 4px;
border-radius: 100px;
}
span:last-child {
top: auto;
bottom: 0;
}
}
}
import React from 'react';
import { Tooltip } from 'antd';
import styles from './index.less';
export interface MiniProgressProps {
target: number;
targetLabel?: string;
color?: string;
strokeWidth?: number;
percent?: number;
style?: React.CSSProperties;
}
const MiniProgress: React.FC<MiniProgressProps> = ({
targetLabel,
target,
color = 'rgb(19, 194, 194)',
strokeWidth,
percent,
}) => (
<div className={styles.miniProgress}>
<Tooltip title={targetLabel}>
<div className={styles.target} style={{ left: target ? `${target}%` : undefined }}>
<span style={{ backgroundColor: color || undefined }} />
<span style={{ backgroundColor: color || undefined }} />
</div>
</Tooltip>
<div className={styles.progressWrap}>
<div
className={styles.progress}
style={{
backgroundColor: color || undefined,
width: percent ? `${percent}%` : undefined,
height: strokeWidth || undefined,
}}
/>
</div>
</div>
);
export default MiniProgress;
@import '~antd/es/style/themes/default.less';
.pie {
position: relative;
.chart {
position: relative;
}
&.hasLegend .chart {
width: ~'calc(100% - 240px)';
}
.legend {
position: absolute;
top: 50%;
right: 0;
min-width: 200px;
margin: 0 20px;
padding: 0;
list-style: none;
transform: translateY(-50%);
li {
height: 22px;
margin-bottom: 16px;
line-height: 22px;
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
}
}
.dot {
position: relative;
top: -1px;
display: inline-block;
width: 8px;
height: 8px;
margin-right: 8px;
border-radius: 8px;
}
.line {
display: inline-block;
width: 1px;
height: 16px;
margin-right: 8px;
background-color: @border-color-split;
}
.legendTitle {
color: @text-color;
}
.percent {
color: @text-color-secondary;
}
.value {
position: absolute;
right: 0;
}
.title {
margin-bottom: 8px;
}
.total {
position: absolute;
top: 50%;
left: 50%;
max-height: 62px;
text-align: center;
transform: translate(-50%, -50%);
& > h4 {
height: 22px;
margin-bottom: 8px;
color: @text-color-secondary;
font-weight: normal;
font-size: 14px;
line-height: 22px;
}
& > p {
display: block;
height: 32px;
color: @heading-color;
font-size: 1.2em;
line-height: 32px;
white-space: nowrap;
}
}
}
.legendBlock {
&.hasLegend .chart {
width: 100%;
margin: 0 0 32px 0;
}
.legend {
position: relative;
transform: none;
}
}
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 interface 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;
}
interface PieState {
legendData: { checked: boolean; x: string; color: string; percent: number; y: string }[];
legendBlock: boolean;
}
class Pie extends Component<PieProps, PieState> {
state: PieState = {
legendData: [],
legendBlock: false,
};
requestRef: number | undefined = undefined;
root: HTMLDivElement | undefined = undefined;
chart: G2.Chart | undefined = undefined;
// for window resize auto responsive legend
resize = Debounce(() => {
const { hasLegend } = this.props;
const { legendBlock } = this.state;
if (!hasLegend || !this.root) {
window.removeEventListener('resize', this.resize);
return;
}
if (
this.root &&
this.root.parentNode &&
(this.root.parentNode as HTMLElement).clientWidth <= 380
) {
if (!legendBlock) {
this.setState({
legendBlock: true,
});
}
} else if (legendBlock) {
this.setState({
legendBlock: false,
});
}
}, 400);
componentDidMount() {
window.addEventListener(
'resize',
() => {
this.requestRef = requestAnimationFrame(() => this.resize());
},
{ passive: true },
);
}
componentDidUpdate(preProps: PieProps) {
const { data } = this.props;
if (data !== preProps.data) {
// because of charts data create when rendered
// so there is a trick for get rendered time
this.getLegendData();
}
}
componentWillUnmount() {
if (this.requestRef) {
window.cancelAnimationFrame(this.requestRef);
}
window.removeEventListener('resize', this.resize);
if (this.resize) {
(this.resize as any).cancel();
}
}
getG2Instance = (chart: G2.Chart) => {
this.chart = chart;
requestAnimationFrame(() => {
this.getLegendData();
this.resize();
});
};
// for custom lengend view
getLegendData = () => {
if (!this.chart) return;
const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形
if (!geom) return;
const items = (geom as any).get('dataArray') || []; // 获取图形对应的
const legendData = items.map((item: { color: any; _origin: any }[]) => {
/* eslint no-underscore-dangle:0 */
const origin = item[0]._origin;
origin.color = item[0].color;
origin.checked = true;
return origin;
});
this.setState({
legendData,
});
};
handleRoot = (n: HTMLDivElement) => {
this.root = n;
};
handleLegendClick = (item: any, i: string | number) => {
const newItem = item;
newItem.checked = !newItem.checked;
const { legendData } = this.state;
legendData[i] = newItem;
const filteredLegendData = legendData.filter(l => l.checked).map(l => l.x);
if (this.chart) {
this.chart.filter('x', val => filteredLegendData.indexOf(`${val}`) > -1);
}
this.setState({
legendData,
});
};
render() {
const {
valueFormat,
subTitle,
total,
hasLegend = false,
className,
style,
height = 0,
forceFit = true,
percent,
color,
inner = 0.75,
animate = true,
colors,
lineWidth = 1,
} = this.props;
const { legendData, legendBlock } = this.state;
const pieClassName = classNames(styles.pie, className, {
[styles.hasLegend]: !!hasLegend,
[styles.legendBlock]: legendBlock,
});
const {
data: propsData,
selected: propsSelected = true,
tooltip: propsTooltip = true,
} = this.props;
let data = propsData || [];
let selected = propsSelected;
let tooltip = propsTooltip;
const defaultColors = colors;
data = data || [];
selected = selected || true;
tooltip = tooltip || true;
let formatColor;
const scale = {
x: {
type: 'cat',
range: [0, 1],
},
y: {
min: 0,
},
};
if (percent || percent === 0) {
selected = false;
tooltip = false;
formatColor = (value: string) => {
if (value === '占比') {
return color || 'rgba(24, 144, 255, 0.85)';
}
return '#F0F2F5';
};
data = [
{
x: '占比',
y: parseFloat(`${percent}`),
},
{
x: '反比',
y: 100 - parseFloat(`${percent}`),
},
];
}
const tooltipFormat: [string, (...args: any[]) => { name?: string; value: string }] = [
'x*percent',
(x: string, p: number) => ({
name: x,
value: `${(p * 100).toFixed(2)}%`,
}),
];
const padding = [12, 0, 12, 0] as [number, number, number, number];
const dv = new DataView();
dv.source(data).transform({
type: 'percent',
field: 'y',
dimension: 'x',
as: 'percent',
});
return (
<div ref={this.handleRoot} className={pieClassName} style={style}>
<ReactFitText maxFontSize={25}>
<div className={styles.chart}>
<Chart
scale={scale}
height={height}
forceFit={forceFit}
data={dv}
padding={padding}
animate={animate}
onGetG2Instance={this.getG2Instance}
>
{!!tooltip && <Tooltip showTitle={false} />}
<Coord type="theta" innerRadius={inner} />
<Geom
style={{ lineWidth, stroke: '#fff' }}
tooltip={tooltip ? tooltipFormat : undefined}
type="intervalStack"
position="percent"
color={['x', percent || percent === 0 ? formatColor : defaultColors] as any}
selected={selected}
/>
</Chart>
{(subTitle || total) && (
<div className={styles.total}>
{subTitle && <h4 className="pie-sub-title">{subTitle}</h4>}
{/* eslint-disable-next-line */}
{total && (
<div className="pie-stat">{typeof total === 'function' ? total() : total}</div>
)}
</div>
)}
</div>
</ReactFitText>
{hasLegend && (
<ul className={styles.legend}>
{legendData.map((item, i) => (
<li key={item.x} onClick={() => this.handleLegendClick(item, i)}>
<span
className={styles.dot}
style={{
backgroundColor: !item.checked ? '#aaa' : item.color,
}}
/>
<span className={styles.legendTitle}>{item.x}</span>
<Divider type="vertical" />
<span className={styles.percent}>
{`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`}
</span>
<span className={styles.value}>{valueFormat ? valueFormat(item.y) : item.y}</span>
</li>
))}
</ul>
)}
</div>
);
}
}
export default autoHeight()(Pie);
.tagCloud {
overflow: hidden;
canvas {
transform-origin: 0 0;
}
}
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 interface TagCloudProps {
data: {
name: string;
value: number;
}[];
height?: number;
className?: string;
style?: React.CSSProperties;
}
interface TagCloudState {
dv: any;
height?: number;
width: number;
}
class TagCloud extends Component<TagCloudProps, TagCloudState> {
state = {
dv: null,
height: 0,
width: 0,
};
isUnmount: boolean = false;
requestRef: number = 0;
root: HTMLDivElement | undefined = undefined;
imageMask: HTMLImageElement | undefined = undefined;
componentDidMount() {
requestAnimationFrame(() => {
this.initTagCloud();
this.renderChart(this.props);
});
window.addEventListener('resize', this.resize, { passive: true });
}
componentDidUpdate(preProps?: TagCloudProps) {
const { data } = this.props;
if (preProps && JSON.stringify(preProps.data) !== JSON.stringify(data)) {
this.renderChart(this.props);
}
}
componentWillUnmount() {
this.isUnmount = true;
window.cancelAnimationFrame(this.requestRef);
window.removeEventListener('resize', this.resize);
}
resize = () => {
this.requestRef = requestAnimationFrame(() => {
this.renderChart(this.props);
});
};
saveRootRef = (node: HTMLDivElement) => {
this.root = node;
};
initTagCloud = () => {
function getTextAttrs(cfg: {
x?: any;
y?: any;
style?: any;
opacity?: any;
origin?: any;
color?: any;
}) {
return {
...cfg.style,
fillOpacity: cfg.opacity,
fontSize: cfg.origin._origin.size,
rotate: cfg.origin._origin.rotate,
text: cfg.origin._origin.text,
textAlign: 'center',
fontFamily: cfg.origin._origin.font,
fill: cfg.color,
textBaseline: 'Alphabetic',
};
}
(Shape as any).registerShape('point', 'cloud', {
drawShape(
cfg: { x: any; y: any },
container: { addShape: (arg0: string, arg1: { attrs: any }) => void },
) {
const attrs = getTextAttrs(cfg);
return container.addShape('text', {
attrs: {
...attrs,
x: cfg.x,
y: cfg.y,
},
});
},
});
};
renderChart = Debounce((nextProps: TagCloudProps) => {
// const colors = ['#1890FF', '#41D9C7', '#2FC25B', '#FACC14', '#9AE65C'];
const { data, height } = nextProps || this.props;
if (data.length < 1 || !this.root) {
return;
}
const h = height;
const w = this.root.offsetWidth;
const onload = () => {
const dv = new DataSet.View().source(data);
const range = dv.range('value');
const [min, max] = range;
dv.transform({
type: 'tag-cloud',
fields: ['name', 'value'],
imageMask: this.imageMask,
font: 'Verdana',
size: [w, h], // 宽高设置最好根据 imageMask 做调整
padding: 0,
timeInterval: 5000, // max execute time
rotate() {
return 0;
},
fontSize(d: { value: number }) {
const size = ((d.value - min) / (max - min)) ** 2;
return size * (17.5 - 5) + 5;
},
});
if (this.isUnmount) {
return;
}
this.setState({
dv,
width: w,
height: h,
});
};
if (!this.imageMask) {
this.imageMask = new Image();
this.imageMask.crossOrigin = '';
this.imageMask.src = imgUrl;
this.imageMask.onload = onload;
} else {
onload();
}
}, 500);
render() {
const { className, height } = this.props;
const { dv, width, height: stateHeight } = this.state;
return (
<div
className={classNames(styles.tagCloud, className)}
style={{ width: '100%', height }}
ref={this.saveRootRef}
>
{dv && (
<Chart
width={width}
height={stateHeight}
data={dv}
padding={0}
scale={{
x: { nice: false },
y: { nice: false },
}}
>
<Tooltip showTitle={false} />
<Coord reflect="y" />
<Geom
type="point"
position="x*y"
color="text"
shape="cloud"
tooltip={[
'text*value',
function trans(text, value) {
return { name: text, value };
},
]}
/>
</Chart>
)}
</div>
);
}
}
export default autoHeight()(TagCloud);
@import '~antd/es/style/themes/default.less';
.timelineChart {
background: @component-background;
}
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 interface TimelineChartProps {
data: {
x: number;
y1: number;
y2: number;
}[];
title?: string;
titleMap: { y1: string; y2: string };
padding?: [number, number, number, number];
height?: number;
style?: React.CSSProperties;
borderWidth?: number;
}
const TimelineChart: React.FC<TimelineChartProps> = props => {
const {
title,
height = 400,
padding = [60, 20, 40, 40] as [number, number, number, number],
titleMap = {
y1: 'y1',
y2: 'y2',
},
borderWidth = 2,
data: sourceData,
} = props;
const data = Array.isArray(sourceData) ? sourceData : [{ x: 0, y1: 0, y2: 0 }];
data.sort((a, b) => a.x - b.x);
let max;
if (data[0] && data[0].y1 && data[0].y2) {
max = Math.max(
[...data].sort((a, b) => b.y1 - a.y1)[0].y1,
[...data].sort((a, b) => b.y2 - a.y2)[0].y2,
);
}
const ds = new DataSet({
state: {
start: data[0].x,
end: data[data.length - 1].x,
},
});
const dv = ds.createView();
dv.source(data)
.transform({
type: 'filter',
callback: (obj: { x: string }) => {
const date = obj.x;
return date <= ds.state.end && date >= ds.state.start;
},
})
.transform({
type: 'map',
callback(row: { y1: string; y2: string }) {
const newRow = { ...row };
newRow[titleMap.y1] = row.y1;
newRow[titleMap.y2] = row.y2;
return newRow;
},
})
.transform({
type: 'fold',
fields: [titleMap.y1, titleMap.y2], // 展开字段集
key: 'key', // key字段
value: 'value', // value字段
});
const timeScale = {
type: 'time',
tickInterval: 60 * 60 * 1000,
mask: 'HH:mm',
range: [0, 1],
};
const cols = {
x: timeScale,
value: {
max,
min: 0,
},
};
const SliderGen = () => (
<Slider
padding={[0, padding[1] + 20, 0, padding[3]]}
width="auto"
height={26}
xAxis="x"
yAxis="y1"
scales={{ x: timeScale }}
data={data}
start={ds.state.start}
end={ds.state.end}
backgroundChart={{ type: 'line' }}
onChange={({ startValue, endValue }: { startValue: string; endValue: string }) => {
ds.setState('start', startValue);
ds.setState('end', endValue);
}}
/>
);
return (
<div className={styles.timelineChart} style={{ height: height + 30 }}>
<div>
{title && <h4>{title}</h4>}
<Chart height={height} padding={padding} data={dv} scale={cols} forceFit>
<Axis name="x" />
<Tooltip />
<Legend name="key" position="top" />
<Geom type="line" position="x*value" size={borderWidth} color="key" />
</Chart>
<div style={{ marginRight: -20 }}>
<SliderGen />
</div>
</div>
</div>
);
};
export default autoHeight()(TimelineChart);
差异被折叠。 点击展开。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论