diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..ecd70ca Binary files /dev/null and b/public/favicon.png differ diff --git a/public/icons/icon-128x128.png b/public/icons/icon-128x128.png new file mode 100644 index 0000000..ecd70ca Binary files /dev/null and b/public/icons/icon-128x128.png differ diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png new file mode 100644 index 0000000..ecd70ca Binary files /dev/null and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png new file mode 100644 index 0000000..ecd70ca Binary files /dev/null and b/public/icons/icon-512x512.png differ diff --git a/scripts/generateMock.js b/scripts/generateMock.js new file mode 100644 index 0000000..54c7d6d --- /dev/null +++ b/scripts/generateMock.js @@ -0,0 +1,3 @@ +const generateMock = require('merge-umi-mock-data'); +const path = require('path'); +generateMock(path.join(__dirname, '../mock'), path.join(__dirname, '../functions/mock/index.js')); diff --git a/scripts/getPrettierFiles.js b/scripts/getPrettierFiles.js new file mode 100644 index 0000000..dda20c9 --- /dev/null +++ b/scripts/getPrettierFiles.js @@ -0,0 +1,23 @@ +const glob = require('glob'); + +const getPrettierFiles = () => { + let files = []; + const configFiles = glob.sync('config/**/*.js*', { ignore: ['**/node_modules/**', 'build/**'] }); + const mockFiles = glob.sync('mock/**/*.js*', { ignore: ['**/node_modules/**', 'build/**'] }); + const jsFiles = glob.sync('src/**/*.js*', { ignore: ['**/node_modules/**', 'build/**'] }); + const scriptFiles = glob.sync('scripts/**/*.js'); + const tsFiles = glob.sync('src/**/*.ts*', { ignore: ['**/node_modules/**', 'build/**'] }); + const lessFiles = glob.sync('src/**/*.less*', { ignore: ['**/node_modules/**', 'build/**'] }); + files = files.concat(configFiles); + files = files.concat(mockFiles); + files = files.concat(jsFiles); + files = files.concat(scriptFiles); + files = files.concat(tsFiles); + files = files.concat(lessFiles); + if (!files.length) { + return; + } + return files; +}; + +module.exports = getPrettierFiles; diff --git a/scripts/lint-prettier.js b/scripts/lint-prettier.js new file mode 100644 index 0000000..677f793 --- /dev/null +++ b/scripts/lint-prettier.js @@ -0,0 +1,50 @@ +/** + * copy to https://github.com/facebook/react/blob/master/scripts/prettier/index.js + * prettier api doc https://prettier.io/docs/en/api.html + *----------*****-------------- + * lint file is prettier + *----------*****-------------- + */ + +const prettier = require('prettier'); +const fs = require('fs'); +const chalk = require('chalk'); +const prettierConfigPath = require.resolve('../.prettierrc'); + +const files = process.argv.slice(2); + +let didError = false; + +files.forEach(file => { + Promise.all([ + prettier.resolveConfig(file, { + config: prettierConfigPath, + }), + prettier.getFileInfo(file), + ]) + .then(resolves => { + const [options, fileInfo] = resolves; + if (fileInfo.ignored) { + return; + } + const input = fs.readFileSync(file, 'utf8'); + const withParserOptions = { + ...options, + parser: fileInfo.inferredParser, + }; + const output = prettier.format(input, withParserOptions); + if (output !== input) { + fs.writeFileSync(file, output, 'utf8'); + console.log(chalk.green(`${file} is prettier`)); + } + }) + .catch(e => { + didError = true; + }) + .finally(() => { + if (didError) { + process.exit(1); + } + console.log(chalk.hex('#1890FF')('prettier success!')); + }); +}); diff --git a/scripts/prettier.js b/scripts/prettier.js new file mode 100644 index 0000000..17ded6c --- /dev/null +++ b/scripts/prettier.js @@ -0,0 +1,46 @@ +/** + * copy to https://github.com/facebook/react/blob/master/scripts/prettier/index.js + * prettier api doc https://prettier.io/docs/en/api.html + *----------*****-------------- + * prettier all js and all ts. + *----------*****-------------- + */ + +const prettier = require('prettier'); +const fs = require('fs'); +const getPrettierFiles = require('./getPrettierFiles'); +const prettierConfigPath = require.resolve('../.prettierrc'); +const chalk = require('chalk'); + +let didError = false; + +const files = getPrettierFiles(); + +files.forEach(file => { + const options = prettier.resolveConfig.sync(file, { + config: prettierConfigPath, + }); + const fileInfo = prettier.getFileInfo.sync(file); + if (fileInfo.ignored) { + return; + } + try { + const input = fs.readFileSync(file, 'utf8'); + const withParserOptions = { + ...options, + parser: fileInfo.inferredParser, + }; + const output = prettier.format(input, withParserOptions); + if (output !== input) { + fs.writeFileSync(file, output, 'utf8'); + console.log(chalk.green(`${file} is prettier`)); + } + } catch (e) { + didError = true; + } +}); + +if (didError) { + process.exit(1); +} +console.log(chalk.hex('#1890FF')('prettier success!')); diff --git a/src/actions/client.js b/src/actions/client.js new file mode 100644 index 0000000..c8b55da --- /dev/null +++ b/src/actions/client.js @@ -0,0 +1,36 @@ +export const CLIENT_NAMESPACE = 'client'; + +export function CLIENT_LIST(payload) { + return { + type: `${CLIENT_NAMESPACE}/fetchList`, + payload, + }; +} + +export function CLIENT_DETAIL(id) { + return { + type: `${CLIENT_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function CLIENT_CLEAR_DETAIL() { + return { + type: `${CLIENT_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function CLIENT_SUBMIT(payload) { + return { + type: `${CLIENT_NAMESPACE}/submit`, + payload, + }; +} + +export function CLIENT_REMOVE(payload) { + return { + type: `${CLIENT_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/code.js b/src/actions/code.js new file mode 100644 index 0000000..a4f5998 --- /dev/null +++ b/src/actions/code.js @@ -0,0 +1,43 @@ +export const CODE_NAMESPACE = 'code'; + +export function CODE_LIST(payload) { + return { + type: `${CODE_NAMESPACE}/fetchList`, + payload, + }; +} + +export function CODE_INIT() { + return { + type: `${CODE_NAMESPACE}/fetchInit`, + payload: { code: 'yes_no' }, + }; +} + +export function CODE_DETAIL(id) { + return { + type: `${CODE_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function CODE_CLEAR_DETAIL() { + return { + type: `${CODE_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function CODE_SUBMIT(payload) { + return { + type: `${CODE_NAMESPACE}/submit`, + payload, + }; +} + +export function CODE_REMOVE(payload) { + return { + type: `${CODE_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/datasource.js b/src/actions/datasource.js new file mode 100644 index 0000000..45ba363 --- /dev/null +++ b/src/actions/datasource.js @@ -0,0 +1,36 @@ +export const DATASOURCE_NAMESPACE = 'datasource'; + +export function DATASOURCE_LIST(payload) { + return { + type: `${DATASOURCE_NAMESPACE}/fetchList`, + payload, + }; +} + +export function DATASOURCE_DETAIL(id) { + return { + type: `${DATASOURCE_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function DATASOURCE_CLEAR_DETAIL() { + return { + type: `${DATASOURCE_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function DATASOURCE_SUBMIT(payload) { + return { + type: `${DATASOURCE_NAMESPACE}/submit`, + payload, + }; +} + +export function DATASOURCE_REMOVE(payload) { + return { + type: `${DATASOURCE_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/dept.js b/src/actions/dept.js new file mode 100644 index 0000000..d8cbf3d --- /dev/null +++ b/src/actions/dept.js @@ -0,0 +1,43 @@ +export const DEPT_NAMESPACE = 'dept'; + +export function DEPT_LIST(payload) { + return { + type: `${DEPT_NAMESPACE}/fetchList`, + payload, + }; +} + +export function DEPT_INIT() { + return { + type: `${DEPT_NAMESPACE}/fetchInit`, + payload: {}, + }; +} + +export function DEPT_DETAIL(id) { + return { + type: `${DEPT_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function DEPT_CLEAR_DETAIL() { + return { + type: `${DEPT_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function DEPT_SUBMIT(payload) { + return { + type: `${DEPT_NAMESPACE}/submit`, + payload, + }; +} + +export function DEPT_REMOVE(payload) { + return { + type: `${DEPT_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/dict.js b/src/actions/dict.js new file mode 100644 index 0000000..5c127bd --- /dev/null +++ b/src/actions/dict.js @@ -0,0 +1,43 @@ +export const DICT_NAMESPACE = 'dict'; + +export function DICT_LIST(payload) { + return { + type: `${DICT_NAMESPACE}/fetchList`, + payload, + }; +} + +export function DICT_INIT() { + return { + type: `${DICT_NAMESPACE}/fetchInit`, + payload: { code: 'DICT' }, + }; +} + +export function DICT_DETAIL(id) { + return { + type: `${DICT_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function DICT_CLEAR_DETAIL() { + return { + type: `${DICT_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function DICT_SUBMIT(payload) { + return { + type: `${DICT_NAMESPACE}/submit`, + payload, + }; +} + +export function DICT_REMOVE(payload) { + return { + type: `${DICT_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/log.js b/src/actions/log.js new file mode 100644 index 0000000..f56630a --- /dev/null +++ b/src/actions/log.js @@ -0,0 +1,43 @@ +export const LOG_NAMESPACE = 'log'; + +export function LOG_USUAL_LIST(payload) { + return { + type: `${LOG_NAMESPACE}/fetchUsualList`, + payload, + }; +} + +export function LOG_USUAL_DETAIL(id) { + return { + type: `${LOG_NAMESPACE}/fetchUsualDetail`, + payload: { id }, + }; +} + +export function LOG_API_LIST(payload) { + return { + type: `${LOG_NAMESPACE}/fetchApiList`, + payload, + }; +} + +export function LOG_API_DETAIL(id) { + return { + type: `${LOG_NAMESPACE}/fetchApiDetail`, + payload: { id }, + }; +} + +export function LOG_ERROR_LIST(payload) { + return { + type: `${LOG_NAMESPACE}/fetchErrorList`, + payload, + }; +} + +export function LOG_ERROR_DETAIL(id) { + return { + type: `${LOG_NAMESPACE}/fetchErrorDetail`, + payload: { id }, + }; +} diff --git a/src/actions/menu.js b/src/actions/menu.js new file mode 100644 index 0000000..4c0bdfb --- /dev/null +++ b/src/actions/menu.js @@ -0,0 +1,61 @@ +import { getAuthority } from '../utils/authority'; + +export const MENU_NAMESPACE = 'menu'; + +export function MENU_REFRESH_DATA() { + return { + type: `${MENU_NAMESPACE}/fetchMenuData`, + payload: { authority: getAuthority() }, + }; +} + +export function MENU_LIST(payload) { + return { + type: `${MENU_NAMESPACE}/fetchList`, + payload, + }; +} + +export function MENU_INIT() { + return { + type: `${MENU_NAMESPACE}/fetchInit`, + payload: {}, + }; +} + +export function MENU_DETAIL(id) { + return { + type: `${MENU_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function MENU_CLEAR_DETAIL() { + return { + type: `${MENU_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function MENU_SUBMIT(payload) { + return { + type: `${MENU_NAMESPACE}/submit`, + payload, + }; +} + +export function MENU_REMOVE(payload) { + return { + type: `${MENU_NAMESPACE}/remove`, + payload, + }; +} + +export function MENU_SELECT_ICON(icon) { + return { + type: `${MENU_NAMESPACE}/selectIcon`, + payload: { + source: icon, + }, + }; +} diff --git a/src/actions/notice.js b/src/actions/notice.js new file mode 100644 index 0000000..77f0f11 --- /dev/null +++ b/src/actions/notice.js @@ -0,0 +1,36 @@ +export const NOTICE_NAMESPACE = 'notice'; + +export function NOTICE_LIST(payload) { + return { + type: `${NOTICE_NAMESPACE}/fetchList`, + payload, + }; +} + +export function NOTICE_INIT() { + return { + type: `${NOTICE_NAMESPACE}/fetchInit`, + payload: { code: 'notice' }, + }; +} + +export function NOTICE_DETAIL(id) { + return { + type: `${NOTICE_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function NOTICE_SUBMIT(payload) { + return { + type: `${NOTICE_NAMESPACE}/submit`, + payload, + }; +} + +export function NOTICE_REMOVE(payload) { + return { + type: `${NOTICE_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/param.js b/src/actions/param.js new file mode 100644 index 0000000..7191c2f --- /dev/null +++ b/src/actions/param.js @@ -0,0 +1,36 @@ +export const PARAM_NAMESPACE = 'param'; + +export function PARAM_LIST(payload) { + return { + type: `${PARAM_NAMESPACE}/fetchList`, + payload, + }; +} + +export function PARAM_DETAIL(id) { + return { + type: `${PARAM_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function PARAM_CLEAR_DETAIL() { + return { + type: `${PARAM_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function PARAM_SUBMIT(payload) { + return { + type: `${PARAM_NAMESPACE}/submit`, + payload, + }; +} + +export function PARAM_REMOVE(payload) { + return { + type: `${PARAM_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/role.js b/src/actions/role.js new file mode 100644 index 0000000..4f46544 --- /dev/null +++ b/src/actions/role.js @@ -0,0 +1,72 @@ +export const ROLE_NAMESPACE = 'role'; + +export function ROLE_LIST(payload) { + return { + type: `${ROLE_NAMESPACE}/fetchList`, + payload, + }; +} + +export function ROLE_INIT() { + return { + type: `${ROLE_NAMESPACE}/fetchInit`, + payload: {}, + }; +} + +export function ROLE_DETAIL(id) { + return { + type: `${ROLE_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function ROLE_CLEAR_DETAIL() { + return { + type: `${ROLE_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function ROLE_GRANT_TREE(payload) { + return { + type: `${ROLE_NAMESPACE}/grantTree`, + payload, + }; +} + +export function ROLE_TREE_KEYS(payload) { + return { + type: `${ROLE_NAMESPACE}/roleTreeKeys`, + payload, + }; +} + +export function ROLE_SET_TREE_KEYS(payload) { + return { + type: `${ROLE_NAMESPACE}/setRoleTreeKeys`, + payload, + }; +} + +export function ROLE_GRANT(payload, callback) { + return { + type: `${ROLE_NAMESPACE}/grant`, + payload, + callback, + }; +} + +export function ROLE_SUBMIT(payload) { + return { + type: `${ROLE_NAMESPACE}/submit`, + payload, + }; +} + +export function ROLE_REMOVE(payload) { + return { + type: `${ROLE_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/tenant.js b/src/actions/tenant.js new file mode 100644 index 0000000..dbf253c --- /dev/null +++ b/src/actions/tenant.js @@ -0,0 +1,36 @@ +export const TENANT_NAMESPACE = 'tenant'; + +export function TENANT_LIST(payload) { + return { + type: `${TENANT_NAMESPACE}/fetchList`, + payload, + }; +} + +export function TENANT_DETAIL(id) { + return { + type: `${TENANT_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function TENANT_CLEAR_DETAIL() { + return { + type: `${TENANT_NAMESPACE}/clearDetail`, + payload: {}, + }; +} + +export function TENANT_SUBMIT(payload) { + return { + type: `${TENANT_NAMESPACE}/submit`, + payload, + }; +} + +export function TENANT_REMOVE(payload) { + return { + type: `${TENANT_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/actions/user.js b/src/actions/user.js new file mode 100644 index 0000000..0a1fe88 --- /dev/null +++ b/src/actions/user.js @@ -0,0 +1,58 @@ +export const USER_NAMESPACE = 'user'; + +export function USER_LIST(payload) { + return { + type: `${USER_NAMESPACE}/fetchList`, + payload, + }; +} + +export function USER_INIT() { + return { + type: `${USER_NAMESPACE}/fetchInit`, + payload: {}, + }; +} + +export function USER_CHANGE_INIT(payload) { + return { + type: `${USER_NAMESPACE}/fetchChangeInit`, + payload, + }; +} + +export function USER_DETAIL(id) { + return { + type: `${USER_NAMESPACE}/fetchDetail`, + payload: { id }, + }; +} + +export function USER_ROLE_GRANT(payload, callback) { + return { + type: `${USER_NAMESPACE}/grant`, + payload, + callback, + }; +} + +export function USER_SUBMIT(payload) { + return { + type: `${USER_NAMESPACE}/submit`, + payload, + }; +} + +export function USER_UPDATE(payload) { + return { + type: `${USER_NAMESPACE}/update`, + payload, + }; +} + +export function USER_REMOVE(payload) { + return { + type: `${USER_NAMESPACE}/remove`, + payload, + }; +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..5507a4a --- /dev/null +++ b/src/app.js @@ -0,0 +1,43 @@ +import { routesAuthority } from './services/menu'; + +export const dva = { + config: { + onError(err) { + err.preventDefault(); + }, + }, +}; + +let authRoutes = { + '/form/advanced-form': { authority: ['admin', 'user'] }, +}; + +function ergodicRoutes(routes, authKey, authority) { + routes.forEach(element => { + if (element.path === authKey) { + if (!element.authority) element.authority = []; // eslint-disable-line + Object.assign(element.authority, authority || []); + } else if (element.routes) { + ergodicRoutes(element.routes, authKey, authority); + } + return element; + }); +} + +export function patchRoutes(routes) { + if (authRoutes !== null && authRoutes !== undefined) { + Object.keys(authRoutes).map(authKey => + ergodicRoutes(routes, authKey, authRoutes[authKey].authority) + ); + window.g_routes = routes; + } +} + +export function render(oldRender) { + routesAuthority().then(response => { + if (response && response.success) { + authRoutes = response.data; + } + oldRender(); + }); +} diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 0000000..0a1c688 --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1,160 @@ + + + + diff --git a/src/components/ActiveChart/index.js b/src/components/ActiveChart/index.js new file mode 100644 index 0000000..3d9fbba --- /dev/null +++ b/src/components/ActiveChart/index.js @@ -0,0 +1,101 @@ +import React, { Component } from 'react'; +import { MiniArea } from '../Charts'; +import NumberInfo from '../NumberInfo'; + +import styles from './index.less'; + +function fixedZero(val) { + return val * 1 < 10 ? `0${val}` : val; +} + +function getActiveData() { + const activeData = []; + for (let i = 0; i < 24; i += 1) { + activeData.push({ + x: `${fixedZero(i)}:00`, + y: Math.floor(Math.random() * 200) + i * 50, + }); + } + return activeData; +} + +export default class ActiveChart extends Component { + state = { + activeData: getActiveData(), + }; + + componentDidMount() { + this.loopData(); + } + + componentWillUnmount() { + clearTimeout(this.timer); + cancelAnimationFrame(this.requestRef); + } + + loopData = () => { + this.requestRef = requestAnimationFrame(() => { + this.timer = setTimeout(() => { + this.setState( + { + activeData: getActiveData(), + }, + () => { + this.loopData(); + } + ); + }, 1000); + }); + }; + + render() { + const { activeData = [] } = this.state; + + return ( +
+ +
+ +
+ {activeData && ( +
+
+

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

+

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

+
+
+
+
+
+
+
+
+ )} + {activeData && ( +
+ 00:00 + {activeData[Math.floor(activeData.length / 2)].x} + {activeData[activeData.length - 1].x} +
+ )} +
+ ); + } +} diff --git a/src/components/ActiveChart/index.less b/src/components/ActiveChart/index.less new file mode 100644 index 0000000..2f5d15f --- /dev/null +++ b/src/components/ActiveChart/index.less @@ -0,0 +1,51 @@ +.activeChart { + position: relative; +} +.activeChartGrid { + p { + position: absolute; + top: 80px; + } + p:last-child { + top: 115px; + } +} +.activeChartLegend { + position: relative; + height: 20px; + margin-top: 8px; + font-size: 0; + line-height: 20px; + span { + display: inline-block; + width: 33.33%; + font-size: 12px; + text-align: center; + } + span:first-child { + text-align: left; + } + span:last-child { + text-align: right; + } +} +.dashedLine { + position: relative; + top: -70px; + left: -3px; + height: 1px; + + .line { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: linear-gradient(to right, transparent 50%, #e9e9e9 50%); + background-size: 6px; + } +} + +.dashedLine:last-child { + top: -36px; +} diff --git a/src/components/AdvancedTable/withExpandRow.js b/src/components/AdvancedTable/withExpandRow.js new file mode 100644 index 0000000..2def49b --- /dev/null +++ b/src/components/AdvancedTable/withExpandRow.js @@ -0,0 +1,54 @@ +import React from 'react'; + +export default ({ + expendAll = false, + expandedRowRender, + updateExpandRowKeys, + initExpandedRowKeys, +}) => { + return WrappedComponent => { + return class extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + expandedRowKeys: initExpandedRowKeys || [], + expandRowByClick: false, + }; + } + + componentDidMount() { + if (initExpandedRowKeys) { + this.onExpandedRowsChange(initExpandedRowKeys); + } + } + + onExpandedRowsChange = rows => { + if (updateExpandRowKeys) updateExpandRowKeys(rows); + this.setState({ + expandedRowKeys: rows, + }); + }; + + expandRow = row => { + this.setState({ + expandedRowKeys: row ? [row] : [], + }); + }; + + render() { + const { expandedRowKeys } = this.state; + // const expandRowByClick = true; + return ( + + ); + } + }; + }; +}; diff --git a/src/components/ArticleListContent/index.js b/src/components/ArticleListContent/index.js new file mode 100644 index 0000000..c4525d6 --- /dev/null +++ b/src/components/ArticleListContent/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import moment from 'moment'; +import { Avatar } from 'antd'; +import styles from './index.less'; + +const ArticleListContent = ({ data: { content, updatedAt, avatar, owner, href } }) => ( +
+
{content}
+
+ + {owner} 发布在 {href} + {moment(updatedAt).format('YYYY-MM-DD HH:mm')} +
+
+); + +export default ArticleListContent; diff --git a/src/components/ArticleListContent/index.less b/src/components/ArticleListContent/index.less new file mode 100644 index 0000000..dd0baa1 --- /dev/null +++ b/src/components/ArticleListContent/index.less @@ -0,0 +1,38 @@ +@import '~antd/lib/style/themes/default.less'; + +.listContent { + .description { + max-width: 720px; + line-height: 22px; + } + .extra { + margin-top: 16px; + color: @text-color-secondary; + line-height: 22px; + & > :global(.ant-avatar) { + position: relative; + top: 1px; + width: 20px; + height: 20px; + margin-right: 8px; + vertical-align: top; + } + & > em { + margin-left: 16px; + color: @disabled-color; + font-style: normal; + } + } +} + +@media screen and (max-width: @screen-xs) { + .listContent { + .extra { + & > em { + display: block; + margin-top: 8px; + margin-left: 0; + } + } + } +} diff --git a/src/components/Authorized/Authorized.js b/src/components/Authorized/Authorized.js new file mode 100644 index 0000000..75d57b8 --- /dev/null +++ b/src/components/Authorized/Authorized.js @@ -0,0 +1,8 @@ +import CheckPermissions from './CheckPermissions'; + +const Authorized = ({ children, authority, noMatch = null }) => { + const childrenRender = typeof children === 'undefined' ? null : children; + return CheckPermissions(authority, childrenRender, noMatch); +}; + +export default Authorized; diff --git a/src/components/Authorized/AuthorizedRoute.d.ts b/src/components/Authorized/AuthorizedRoute.d.ts new file mode 100644 index 0000000..912b283 --- /dev/null +++ b/src/components/Authorized/AuthorizedRoute.d.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { RouteProps } from 'react-router'; + +type authorityFN = (currentAuthority?: string) => boolean; + +type authority = string | string[] | authorityFN | Promise; + +export interface IAuthorizedRouteProps extends RouteProps { + authority: authority; +} +export { authority }; + +export class AuthorizedRoute extends React.Component {} diff --git a/src/components/Authorized/AuthorizedRoute.js b/src/components/Authorized/AuthorizedRoute.js new file mode 100644 index 0000000..39c6a66 --- /dev/null +++ b/src/components/Authorized/AuthorizedRoute.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; +import Authorized from './Authorized'; + +// TODO: umi只会返回render和rest +const AuthorizedRoute = ({ component: Component, render, authority, redirectPath, ...rest }) => ( + } />} + > + (Component ? : render(props))} /> + +); + +export default AuthorizedRoute; diff --git a/src/components/Authorized/CheckPermissions.js b/src/components/Authorized/CheckPermissions.js new file mode 100644 index 0000000..ba83f5b --- /dev/null +++ b/src/components/Authorized/CheckPermissions.js @@ -0,0 +1,88 @@ +import React from 'react'; +import PromiseRender from './PromiseRender'; +import { CURRENT } from './renderAuthorize'; + +function isPromise(obj) { + return ( + !!obj && + (typeof obj === 'object' || typeof obj === 'function') && + typeof obj.then === 'function' + ); +} + +/** + * 通用权限检查方法 + * Common check permissions method + * @param { 权限判定 Permission judgment type string |array | Promise | Function } authority + * @param { 你的权限 Your permission description type:string} currentAuthority + * @param { 通过的组件 Passing components } target + * @param { 未通过的组件 no pass components } Exception + */ +const checkPermissions = (authority, currentAuthority, target, Exception) => { + // 没有判定权限.默认查看所有 + // Retirement authority, return target; + if (!authority) { + return target; + } + // 数组处理 + if (Array.isArray(authority)) { + if (authority.indexOf(currentAuthority) >= 0) { + return target; + } + if (Array.isArray(currentAuthority)) { + for (let i = 0; i < currentAuthority.length; i += 1) { + const element = currentAuthority[i]; + if (authority.indexOf(element) >= 0) { + return target; + } + } + } + return Exception; + } + + // string 处理 + if (typeof authority === 'string') { + if (authority === currentAuthority) { + return target; + } + if (Array.isArray(currentAuthority)) { + for (let i = 0; i < currentAuthority.length; i += 1) { + const element = currentAuthority[i]; + if (authority === element) { + return target; + } + } + } + return Exception; + } + + // Promise 处理 + if (isPromise(authority)) { + return ; + } + + // Function 处理 + if (typeof authority === 'function') { + try { + const bool = authority(currentAuthority); + // 函数执行后返回值是 Promise + if (isPromise(bool)) { + return ; + } + if (bool) { + return target; + } + return Exception; + } catch (error) { + throw error; + } + } + throw new Error('unsupported parameters'); +}; + +export { checkPermissions }; + +const check = (authority, target, Exception) => + checkPermissions(authority, CURRENT, target, Exception); + +export default check; diff --git a/src/components/Authorized/CheckPermissions.test.js b/src/components/Authorized/CheckPermissions.test.js new file mode 100644 index 0000000..3988d85 --- /dev/null +++ b/src/components/Authorized/CheckPermissions.test.js @@ -0,0 +1,55 @@ +import { checkPermissions } from './CheckPermissions'; + +const target = 'ok'; +const error = 'error'; + +describe('test CheckPermissions', () => { + it('Correct string permission authentication', () => { + expect(checkPermissions('user', 'user', target, error)).toEqual('ok'); + }); + it('Correct string permission authentication', () => { + expect(checkPermissions('user', 'NULL', target, error)).toEqual('error'); + }); + it('authority is undefined , return ok', () => { + expect(checkPermissions(null, 'NULL', target, error)).toEqual('ok'); + }); + it('currentAuthority is undefined , return error', () => { + expect(checkPermissions('admin', null, target, error)).toEqual('error'); + }); + it('Wrong string permission authentication', () => { + expect(checkPermissions('admin', 'user', target, error)).toEqual('error'); + }); + it('Correct Array permission authentication', () => { + expect(checkPermissions(['user', 'admin'], 'user', target, error)).toEqual('ok'); + }); + it('Wrong Array permission authentication,currentAuthority error', () => { + expect(checkPermissions(['user', 'admin'], 'user,admin', target, error)).toEqual('error'); + }); + it('Wrong Array permission authentication', () => { + expect(checkPermissions(['user', 'admin'], 'guest', target, error)).toEqual('error'); + }); + it('Wrong Function permission authentication', () => { + expect(checkPermissions(() => false, 'guest', target, error)).toEqual('error'); + }); + it('Correct Function permission authentication', () => { + expect(checkPermissions(() => true, 'guest', target, error)).toEqual('ok'); + }); + it('authority is string, currentAuthority is array, return ok', () => { + expect(checkPermissions('user', ['user'], target, error)).toEqual('ok'); + }); + it('authority is string, currentAuthority is array, return ok', () => { + expect(checkPermissions('user', ['user', 'admin'], target, error)).toEqual('ok'); + }); + it('authority is array, currentAuthority is array, return ok', () => { + expect(checkPermissions(['user', 'admin'], ['user', 'admin'], target, error)).toEqual('ok'); + }); + it('Wrong Function permission authentication', () => { + expect(checkPermissions(() => false, ['user'], target, error)).toEqual('error'); + }); + it('Correct Function permission authentication', () => { + expect(checkPermissions(() => true, ['user'], target, error)).toEqual('ok'); + }); + it('authority is undefined , return ok', () => { + expect(checkPermissions(null, ['user'], target, error)).toEqual('ok'); + }); +}); diff --git a/src/components/Authorized/PromiseRender.js b/src/components/Authorized/PromiseRender.js new file mode 100644 index 0000000..8e2a405 --- /dev/null +++ b/src/components/Authorized/PromiseRender.js @@ -0,0 +1,65 @@ +import React from 'react'; +import { Spin } from 'antd'; + +export default class PromiseRender extends React.PureComponent { + state = { + component: null, + }; + + componentDidMount() { + this.setRenderComponent(this.props); + } + + componentDidUpdate(nextProps) { + // new Props enter + this.setRenderComponent(nextProps); + } + + // set render Component : ok or error + setRenderComponent(props) { + const ok = this.checkIsInstantiation(props.ok); + const error = this.checkIsInstantiation(props.error); + props.promise + .then(() => { + this.setState({ + component: ok, + }); + }) + .catch(() => { + this.setState({ + component: error, + }); + }); + } + + // Determine whether the incoming component has been instantiated + // AuthorizedRoute is already instantiated + // Authorized render is already instantiated, children is no instantiated + // Secured is not instantiated + checkIsInstantiation = target => { + if (!React.isValidElement(target)) { + return target; + } + return () => target; + }; + + render() { + const { component: Component } = this.state; + const { ok, error, promise, ...rest } = this.props; + return Component ? ( + + ) : ( +
+ +
+ ); + } +} diff --git a/src/components/Authorized/Secured.js b/src/components/Authorized/Secured.js new file mode 100644 index 0000000..c935183 --- /dev/null +++ b/src/components/Authorized/Secured.js @@ -0,0 +1,55 @@ +import React from 'react'; +import Exception from '../Exception'; +import CheckPermissions from './CheckPermissions'; +/** + * 默认不能访问任何页面 + * default is "NULL" + */ +const Exception403 = () => ; + +// Determine whether the incoming component has been instantiated +// AuthorizedRoute is already instantiated +// Authorized render is already instantiated, children is no instantiated +// Secured is not instantiated +const checkIsInstantiation = target => { + if (!React.isValidElement(target)) { + return target; + } + return () => target; +}; + +/** + * 用于判断是否拥有权限访问此view权限 + * authority 支持传入 string, function:()=>boolean|Promise + * e.g. 'user' 只有user用户能访问 + * e.g. 'user,admin' user和 admin 都能访问 + * e.g. ()=>boolean 返回true能访问,返回false不能访问 + * e.g. Promise then 能访问 catch不能访问 + * e.g. authority support incoming string, function: () => boolean | Promise + * e.g. 'user' only user user can access + * e.g. 'user, admin' user and admin can access + * e.g. () => boolean true to be able to visit, return false can not be accessed + * e.g. Promise then can not access the visit to catch + * @param {string | function | Promise} authority + * @param {ReactNode} error 非必需参数 + */ +const authorize = (authority, error) => { + /** + * conversion into a class + * 防止传入字符串时找不到staticContext造成报错 + * String parameters can cause staticContext not found error + */ + let classError = false; + if (error) { + classError = () => error; + } + if (!authority) { + throw new Error('authority is required'); + } + return function decideAuthority(target) { + const component = CheckPermissions(authority, target, classError || Exception403); + return checkIsInstantiation(component); + }; +}; + +export default authorize; diff --git a/src/components/Authorized/demo/AuthorizedArray.md b/src/components/Authorized/demo/AuthorizedArray.md new file mode 100644 index 0000000..46eaf76 --- /dev/null +++ b/src/components/Authorized/demo/AuthorizedArray.md @@ -0,0 +1,23 @@ +--- +order: 1 +title: + zh-CN: 使用数组作为参数 + en-US: Use Array as a parameter +--- + +Use Array as a parameter + +```jsx +import RenderAuthorized from 'ant-design-pro/lib/Authorized'; +import { Alert } from 'antd'; + +const Authorized = RenderAuthorized('user'); +const noMatch = ; + +ReactDOM.render( + + + , + mountNode, +); +``` diff --git a/src/components/Authorized/demo/AuthorizedFunction.md b/src/components/Authorized/demo/AuthorizedFunction.md new file mode 100644 index 0000000..8ad8b91 --- /dev/null +++ b/src/components/Authorized/demo/AuthorizedFunction.md @@ -0,0 +1,31 @@ +--- +order: 2 +title: + zh-CN: 使用方法作为参数 + en-US: Use function as a parameter +--- + +Use Function as a parameter + +```jsx +import RenderAuthorized from 'ant-design-pro/lib/Authorized'; +import { Alert } from 'antd'; + +const Authorized = RenderAuthorized('user'); +const noMatch = ; + +const havePermission = () => { + return false; +}; + +ReactDOM.render( + + + , + mountNode, +); +``` diff --git a/src/components/Authorized/demo/basic.md b/src/components/Authorized/demo/basic.md new file mode 100644 index 0000000..a5f12f2 --- /dev/null +++ b/src/components/Authorized/demo/basic.md @@ -0,0 +1,25 @@ +--- +order: 0 +title: + zh-CN: 基本使用 + en-US: Basic use +--- + +Basic use + +```jsx +import RenderAuthorized from 'ant-design-pro/lib/Authorized'; +import { Alert } from 'antd'; + +const Authorized = RenderAuthorized('user'); +const noMatch = ; + +ReactDOM.render( +
+ + + +
, + mountNode, +); +``` diff --git a/src/components/Authorized/demo/secured.md b/src/components/Authorized/demo/secured.md new file mode 100644 index 0000000..1e9537a --- /dev/null +++ b/src/components/Authorized/demo/secured.md @@ -0,0 +1,28 @@ +--- +order: 3 +title: + zh-CN: 注解基本使用 + en-US: Basic use secured +--- + +secured demo used + +```jsx +import RenderAuthorized from 'ant-design-pro/lib/Authorized'; +import { Alert } from 'antd'; + +const { Secured } = RenderAuthorized('user'); + +@Secured('admin') +class TestSecuredString extends React.Component { + render() { + ; + } +} +ReactDOM.render( +
+ +
, + mountNode, +); +``` diff --git a/src/components/Authorized/index.d.ts b/src/components/Authorized/index.d.ts new file mode 100644 index 0000000..b3e2f56 --- /dev/null +++ b/src/components/Authorized/index.d.ts @@ -0,0 +1,32 @@ +import * as React from 'react'; +import AuthorizedRoute, { authority } from './AuthorizedRoute'; +export type IReactComponent

= + | React.StatelessComponent

+ | React.ComponentClass

+ | React.ClassicComponentClass

; + +type Secured = ( + authority: authority, + error?: React.ReactNode +) => (target: T) => T; + +type check = ( + authority: authority, + target: T, + Exception: S +) => T | S; + +export interface IAuthorizedProps { + authority: authority; + noMatch?: React.ReactNode; +} + +export class Authorized extends React.Component { + public static Secured: Secured; + public static AuthorizedRoute: typeof AuthorizedRoute; + public static check: check; +} + +declare function renderAuthorize(currentAuthority: string): typeof Authorized; + +export default renderAuthorize; diff --git a/src/components/Authorized/index.js b/src/components/Authorized/index.js new file mode 100644 index 0000000..22ac664 --- /dev/null +++ b/src/components/Authorized/index.js @@ -0,0 +1,11 @@ +import Authorized from './Authorized'; +import AuthorizedRoute from './AuthorizedRoute'; +import Secured from './Secured'; +import check from './CheckPermissions'; +import renderAuthorize from './renderAuthorize'; + +Authorized.Secured = Secured; +Authorized.AuthorizedRoute = AuthorizedRoute; +Authorized.check = check; + +export default renderAuthorize(Authorized); diff --git a/src/components/Authorized/index.md b/src/components/Authorized/index.md new file mode 100644 index 0000000..f3b2f80 --- /dev/null +++ b/src/components/Authorized/index.md @@ -0,0 +1,58 @@ +--- +title: + en-US: Authorized + zh-CN: Authorized +subtitle: 权限 +cols: 1 +order: 15 +--- + +权限组件,通过比对现有权限与准入权限,决定相关元素的展示。 + +## API + +### RenderAuthorized + +`RenderAuthorized: (currentAuthority: string | () => string) => Authorized` + +权限组件默认 export RenderAuthorized 函数,它接收当前权限作为参数,返回一个权限对象,该对象提供以下几种使用方式。 + + +### Authorized + +最基础的权限控制。 + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| children | 正常渲染的元素,权限判断通过时展示 | ReactNode | - | +| authority | 准入权限/权限判断 | `string | array | Promise | (currentAuthority) => boolean | Promise` | - | +| noMatch | 权限异常渲染元素,权限判断不通过时展示 | ReactNode | - | + +### Authorized.AuthorizedRoute + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| authority | 准入权限/权限判断 | `string | array | Promise | (currentAuthority) => boolean | Promise` | - | +| redirectPath | 权限异常时重定向的页面路由 | string | - | + +其余参数与 `Route` 相同。 + +### Authorized.Secured + +注解方式,`@Authorized.Secured(authority, error)` + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| authority | 准入权限/权限判断 | `string | Promise | (currentAuthority) => boolean | Promise` | - | +| error | 权限异常时渲染元素 | ReactNode | | + +### Authorized.check + +函数形式的 Authorized,用于某些不能被 HOC 包裹的组件。 `Authorized.check(authority, target, Exception)` +注意:传入一个 Promise 时,无论正确还是错误返回的都是一个 ReactClass。 + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| authority | 准入权限/权限判断 | `string | Promise | (currentAuthority) => boolean | Promise` | - | +| target | 权限判断通过时渲染的元素 | ReactNode | - | +| Exception | 权限异常时渲染元素 | ReactNode | - | diff --git a/src/components/Authorized/renderAuthorize.js b/src/components/Authorized/renderAuthorize.js new file mode 100644 index 0000000..be373d9 --- /dev/null +++ b/src/components/Authorized/renderAuthorize.js @@ -0,0 +1,25 @@ +/* eslint-disable import/no-mutable-exports */ +let CURRENT = 'NULL'; +/** + * use authority or getAuthority + * @param {string|()=>String} currentAuthority + */ +const renderAuthorize = Authorized => currentAuthority => { + if (currentAuthority) { + if (typeof currentAuthority === 'function') { + CURRENT = currentAuthority(); + } + if ( + Object.prototype.toString.call(currentAuthority) === '[object String]' || + Array.isArray(currentAuthority) + ) { + CURRENT = currentAuthority; + } + } else { + CURRENT = 'NULL'; + } + return Authorized; +}; + +export { CURRENT }; +export default Authorized => renderAuthorize(Authorized); diff --git a/src/components/AvatarList/AvatarItem.d.ts b/src/components/AvatarList/AvatarItem.d.ts new file mode 100644 index 0000000..5681de7 --- /dev/null +++ b/src/components/AvatarList/AvatarItem.d.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +export interface IAvatarItemProps { + tips: React.ReactNode; + src: string; + style?: React.CSSProperties; +} + +export default class AvatarItem extends React.Component { + constructor(props: IAvatarItemProps); +} diff --git a/src/components/AvatarList/demo/maxLength.md b/src/components/AvatarList/demo/maxLength.md new file mode 100644 index 0000000..76c6b42 --- /dev/null +++ b/src/components/AvatarList/demo/maxLength.md @@ -0,0 +1,24 @@ +--- +order: 0 +title: + zh-CN: 要显示的最大项目 + en-US: Max Items to Show +--- + +`maxLength` attribute specifies the maximum number of items to show while `excessItemsStyle` style the excess +item component. + +````jsx +import AvatarList from 'ant-design-pro/lib/AvatarList'; + +ReactDOM.render( + + + + + + + + +, mountNode); +```` diff --git a/src/components/AvatarList/demo/simple.md b/src/components/AvatarList/demo/simple.md new file mode 100644 index 0000000..e941aea --- /dev/null +++ b/src/components/AvatarList/demo/simple.md @@ -0,0 +1,20 @@ +--- +order: 0 +title: + zh-CN: 基础样例 + en-US: Basic Usage +--- + +Simplest of usage. + +````jsx +import AvatarList from 'ant-design-pro/lib/AvatarList'; + +ReactDOM.render( + + + + + +, mountNode); +```` diff --git a/src/components/AvatarList/index.d.ts b/src/components/AvatarList/index.d.ts new file mode 100644 index 0000000..f49ca01 --- /dev/null +++ b/src/components/AvatarList/index.d.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; +import AvatarItem from './AvatarItem'; + +export interface IAvatarListProps { + size?: 'large' | 'small' | 'mini' | 'default'; + maxLength?: number; + excessItemsStyle?: React.CSSProperties; + style?: React.CSSProperties; + children: React.ReactElement | Array>; +} + +export default class AvatarList extends React.Component { + public static Item: typeof AvatarItem; +} diff --git a/src/components/AvatarList/index.en-US.md b/src/components/AvatarList/index.en-US.md new file mode 100644 index 0000000..7fc39cc --- /dev/null +++ b/src/components/AvatarList/index.en-US.md @@ -0,0 +1,24 @@ +--- +title: AvatarList +order: 1 +cols: 1 +--- + +A list of user's avatar for project or group member list frequently. If a large or small AvatarList is desired, set the `size` property to either `large` or `small` and `mini` respectively. Omit the `size` property for a AvatarList with the default size. + +## API + +### AvatarList + +| Property | Description | Type | Default | +| ---------------- | --------------------- | ---------------------------------- | --------- | +| size | size of list | `large`、`small` 、`mini`, `default` | `default` | +| maxLength | max items to show | number | - | +| excessItemsStyle | the excess item style | CSSProperties | - | + +### AvatarList.Item + +| Property | Description | Type | Default | +| -------- | -------------------------------------------- | --------- | ------- | +| tips | title tips for avatar item | ReactNode | - | +| src | the address of the image for an image avatar | string | - | diff --git a/src/components/AvatarList/index.js b/src/components/AvatarList/index.js new file mode 100644 index 0000000..9af32bc --- /dev/null +++ b/src/components/AvatarList/index.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { Tooltip, Avatar } from 'antd'; +import classNames from 'classnames'; + +import styles from './index.less'; + +const avatarSizeToClassName = size => + classNames(styles.avatarItem, { + [styles.avatarItemLarge]: size === 'large', + [styles.avatarItemSmall]: size === 'small', + [styles.avatarItemMini]: size === 'mini', + }); + +const AvatarList = ({ children, size, maxLength, excessItemsStyle, ...other }) => { + const numOfChildren = React.Children.count(children); + const numToShow = maxLength >= numOfChildren ? numOfChildren : maxLength; + + const childrenWithProps = React.Children.toArray(children) + .slice(0, numToShow) + .map(child => + React.cloneElement(child, { + size, + }) + ); + + if (numToShow < numOfChildren) { + const cls = avatarSizeToClassName(size); + + childrenWithProps.push( +

  • + {`+${numOfChildren - maxLength}`} +
  • + ); + } + + return ( +
    +
      {childrenWithProps}
    +
    + ); +}; + +const Item = ({ src, size, tips, onClick = () => {} }) => { + const cls = avatarSizeToClassName(size); + + return ( +
  • + {tips ? ( + + + + ) : ( + + )} +
  • + ); +}; + +AvatarList.Item = Item; + +export default AvatarList; diff --git a/src/components/AvatarList/index.less b/src/components/AvatarList/index.less new file mode 100644 index 0000000..45904ce --- /dev/null +++ b/src/components/AvatarList/index.less @@ -0,0 +1,50 @@ +@import '~antd/lib/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; + } + } + } +} diff --git a/src/components/AvatarList/index.test.js b/src/components/AvatarList/index.test.js new file mode 100644 index 0000000..2b5bc43 --- /dev/null +++ b/src/components/AvatarList/index.test.js @@ -0,0 +1,29 @@ +import React from 'react'; +import range from 'lodash/range'; +import { mount } from 'enzyme'; +import AvatarList from './index'; + +const renderItems = numItems => + range(numItems).map(i => ( + + )); + +describe('AvatarList', () => { + it('renders all items', () => { + const wrapper = mount({renderItems(4)}); + expect(wrapper.find('AvatarList').length).toBe(1); + expect(wrapper.find('Item').length).toBe(4); + expect(wrapper.findWhere(node => node.key() === 'exceed').length).toBe(0); + }); + + it('renders max of 3 items', () => { + const wrapper = mount({renderItems(4)}); + expect(wrapper.find('AvatarList').length).toBe(1); + expect(wrapper.find('Item').length).toBe(3); + expect(wrapper.findWhere(node => node.key() === 'exceed').length).toBe(1); + }); +}); diff --git a/src/components/AvatarList/index.zh-CN.md b/src/components/AvatarList/index.zh-CN.md new file mode 100644 index 0000000..bdab181 --- /dev/null +++ b/src/components/AvatarList/index.zh-CN.md @@ -0,0 +1,25 @@ +--- +title: AvatarList +subtitle: 用户头像列表 +order: 1 +cols: 1 +--- + +一组用户头像,常用在项目/团队成员列表。可通过设置 `size` 属性来指定头像大小。 + +## API + +### AvatarList + +| 参数 | 说明 | 类型 | 默认值 | +| ---------------- | -------- | ---------------------------------- | --------- | +| size | 头像大小 | `large`、`small` 、`mini`, `default` | `default` | +| maxLength | 要显示的最大项目 | number | - | +| excessItemsStyle | 多余的项目风格 | CSSProperties | - | + +### AvatarList.Item + +| 参数 | 说明 | 类型 | 默认值 | +| ---- | ------ | --------- | --- | +| tips | 头像展示文案 | ReactNode | - | +| src | 头像图片连接 | string | - | diff --git a/src/components/Charts/Bar/index.d.ts b/src/components/Charts/Bar/index.d.ts new file mode 100644 index 0000000..4899082 --- /dev/null +++ b/src/components/Charts/Bar/index.d.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +export interface IBarProps { + title: React.ReactNode; + color?: string; + padding?: [number, number, number, number]; + height: number; + data: Array<{ + x: string; + y: number; + }>; + autoLabel?: boolean; + style?: React.CSSProperties; +} + +export default class Bar extends React.Component {} diff --git a/src/components/Charts/Bar/index.js b/src/components/Charts/Bar/index.js new file mode 100644 index 0000000..f0cb65f --- /dev/null +++ b/src/components/Charts/Bar/index.js @@ -0,0 +1,113 @@ +import React, { Component } from 'react'; +import { Chart, Axis, Tooltip, Geom } from 'bizcharts'; +import Debounce from 'lodash-decorators/debounce'; +import Bind from 'lodash-decorators/bind'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +@autoHeight() +class Bar extends Component { + state = { + autoHideXLabels: false, + }; + + componentDidMount() { + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resize); + } + + handleRoot = n => { + this.root = n; + }; + + handleRef = n => { + this.node = n; + }; + + @Bind() + @Debounce(400) + resize() { + if (!this.node) { + return; + } + const canvasWidth = this.node.parentNode.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, + }); + } + } + + render() { + const { + height, + 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 = [ + 'x*y', + (x, y) => ({ + name: x, + value: y, + }), + ]; + + return ( +
    +
    + {title &&

    {title}

    } + + + + + + +
    +
    + ); + } +} + +export default Bar; diff --git a/src/components/Charts/ChartCard/index.d.ts b/src/components/Charts/ChartCard/index.d.ts new file mode 100644 index 0000000..0437c0c --- /dev/null +++ b/src/components/Charts/ChartCard/index.d.ts @@ -0,0 +1,14 @@ +import { CardProps } from 'antd/lib/card'; +import * as React from 'react'; + +export interface IChartCardProps 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; +} + +export default class ChartCard extends React.Component {} diff --git a/src/components/Charts/ChartCard/index.js b/src/components/Charts/ChartCard/index.js new file mode 100644 index 0000000..ca6bcb2 --- /dev/null +++ b/src/components/Charts/ChartCard/index.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { Card } from 'antd'; +import classNames from 'classnames'; + +import styles from './index.less'; + +const renderTotal = total => { + let totalDom; + switch (typeof total) { + case 'undefined': + totalDom = null; + break; + case 'function': + totalDom =
    {total()}
    ; + break; + default: + totalDom =
    {total}
    ; + } + return totalDom; +}; + +class ChartCard extends React.PureComponent { + renderConnet = () => { + const { contentHeight, title, avatar, action, total, footer, children, loading } = this.props; + if (loading) { + return false; + } + return ( +
    +
    +
    {avatar}
    +
    +
    + {title} + {action} +
    + {renderTotal(total)} +
    +
    + {children && ( +
    +
    {children}
    +
    + )} + {footer && ( +
    + {footer} +
    + )} +
    + ); + }; + + render() { + const { + loading = false, + contentHeight, + title, + avatar, + action, + total, + footer, + children, + ...rest + } = this.props; + return ( + + {this.renderConnet()} + + ); + } +} + +export default ChartCard; diff --git a/src/components/Charts/ChartCard/index.less b/src/components/Charts/ChartCard/index.less new file mode 100644 index 0000000..0ddc486 --- /dev/null +++ b/src/components/Charts/ChartCard/index.less @@ -0,0 +1,75 @@ +@import '~antd/lib/style/themes/default.less'; + +.chartCard { + position: relative; + .chartTop { + position: relative; + overflow: hidden; + width: 100%; + } + .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 { + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + height: 22px; + } + .action { + cursor: pointer; + position: absolute; + top: 4px; + right: 0; + line-height: 1; + } + .total { + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + white-space: nowrap; + color: @heading-color; + margin-top: 4px; + margin-bottom: 0; + font-size: 30px; + line-height: 38px; + height: 38px; + } + .content { + margin-bottom: 12px; + position: relative; + width: 100%; + } + .contentFixed { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + } + .footer { + border-top: 1px solid @border-color-split; + padding-top: 9px; + margin-top: 8px; + & > * { + position: relative; + } + } + .footerMargin { + margin-top: 20px; + } +} diff --git a/src/components/Charts/Field/index.d.ts b/src/components/Charts/Field/index.d.ts new file mode 100644 index 0000000..975fb66 --- /dev/null +++ b/src/components/Charts/Field/index.d.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; +export interface IFieldProps { + label: React.ReactNode; + value: React.ReactNode; + style?: React.CSSProperties; +} + +export default class Field extends React.Component {} diff --git a/src/components/Charts/Field/index.js b/src/components/Charts/Field/index.js new file mode 100644 index 0000000..22dca86 --- /dev/null +++ b/src/components/Charts/Field/index.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import styles from './index.less'; + +const Field = ({ label, value, ...rest }) => ( +
    + {label} + {value} +
    +); + +export default Field; diff --git a/src/components/Charts/Field/index.less b/src/components/Charts/Field/index.less new file mode 100644 index 0000000..4124471 --- /dev/null +++ b/src/components/Charts/Field/index.less @@ -0,0 +1,17 @@ +@import '~antd/lib/style/themes/default.less'; + +.field { + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + .label, + .number { + font-size: @font-size-base; + line-height: 22px; + } + .number { + margin-left: 8px; + color: @heading-color; + } +} diff --git a/src/components/Charts/Gauge/index.d.ts b/src/components/Charts/Gauge/index.d.ts new file mode 100644 index 0000000..66e3c00 --- /dev/null +++ b/src/components/Charts/Gauge/index.d.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +export interface IGaugeProps { + title: React.ReactNode; + color?: string; + height: number; + bgColor?: number; + percent: number; + style?: React.CSSProperties; +} + +export default class Gauge extends React.Component {} diff --git a/src/components/Charts/Gauge/index.js b/src/components/Charts/Gauge/index.js new file mode 100644 index 0000000..2249211 --- /dev/null +++ b/src/components/Charts/Gauge/index.js @@ -0,0 +1,167 @@ +import React from 'react'; +import { Chart, Geom, Axis, Coord, Guide, Shape } from 'bizcharts'; +import autoHeight from '../autoHeight'; + +const { Arc, Html, Line } = Guide; + +const defaultFormatter = val => { + switch (val) { + case '2': + return '差'; + case '4': + return '中'; + case '6': + return '良'; + case '8': + return '优'; + default: + return ''; + } +}; + +Shape.registerShape('point', 'pointer', { + drawShape(cfg, group) { + let point = cfg.points[0]; + point = this.parsePoint(point); + const center = this.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', + }, + }); + }, +}); + +@autoHeight() +class Gauge extends React.Component { + render() { + const { + title, + height, + percent, + forceFit = true, + formatter = defaultFormatter, + color = '#2F9CFF', + bgColor = '#F0F2F5', + } = this.props; + const cols = { + value: { + type: 'linear', + min: 0, + max: 10, + tickCount: 6, + nice: true, + }, + }; + const data = [{ value: percent / 10 }]; + return ( + + + + + + + + + + + ` +
    +

    ${title}

    +

    + ${data[0].value * 10}% +

    +
    `} + /> +
    + +
    + ); + } +} + +export default Gauge; diff --git a/src/components/Charts/MiniArea/index.d.ts b/src/components/Charts/MiniArea/index.d.ts new file mode 100644 index 0000000..b223b68 --- /dev/null +++ b/src/components/Charts/MiniArea/index.d.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; + +// g2已经更新到3.0 +// 不带的写了 + +export interface IAxis { + title: any; + line: any; + gridAlign: any; + labels: any; + tickLine: any; + grid: any; +} + +export interface IMiniAreaProps { + color?: string; + height: number; + borderColor?: string; + line?: boolean; + animate?: boolean; + xAxis?: IAxis; + yAxis?: IAxis; + data: Array<{ + x: number | string; + y: number; + }>; +} + +export default class MiniArea extends React.Component {} diff --git a/src/components/Charts/MiniArea/index.js b/src/components/Charts/MiniArea/index.js new file mode 100644 index 0000000..d3209be --- /dev/null +++ b/src/components/Charts/MiniArea/index.js @@ -0,0 +1,108 @@ +import React from 'react'; +import { Chart, Axis, Tooltip, Geom } from 'bizcharts'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +@autoHeight() +class MiniArea extends React.PureComponent { + render() { + const { + height, + data = [], + forceFit = true, + color = 'rgba(24, 144, 255, 0.2)', + borderColor = '#1089ff', + scale = {}, + borderWidth = 2, + line, + xAxis, + yAxis, + animate = true, + } = this.props; + + const padding = [36, 5, 30, 5]; + + const scaleProps = { + x: { + type: 'cat', + range: [0, 1], + ...scale.x, + }, + y: { + min: 0, + ...scale.y, + }, + }; + + const tooltip = [ + 'x*y', + (x, y) => ({ + name: x, + value: y, + }), + ]; + + const chartHeight = height + 54; + + return ( +
    +
    + {height > 0 && ( + + + + + + {line ? ( + + ) : ( + + )} + + )} +
    +
    + ); + } +} + +export default MiniArea; diff --git a/src/components/Charts/MiniBar/index.d.ts b/src/components/Charts/MiniBar/index.d.ts new file mode 100644 index 0000000..0c4bd6c --- /dev/null +++ b/src/components/Charts/MiniBar/index.d.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +export interface IMiniBarProps { + color?: string; + height: number; + data: Array<{ + x: number | string; + y: number; + }>; + style?: React.CSSProperties; +} + +export default class MiniBar extends React.Component {} diff --git a/src/components/Charts/MiniBar/index.js b/src/components/Charts/MiniBar/index.js new file mode 100644 index 0000000..18e4d8c --- /dev/null +++ b/src/components/Charts/MiniBar/index.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { Chart, Tooltip, Geom } from 'bizcharts'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +@autoHeight() +class MiniBar extends React.Component { + render() { + const { height, forceFit = true, color = '#1890FF', data = [] } = this.props; + + const scale = { + x: { + type: 'cat', + }, + y: { + min: 0, + }, + }; + + const padding = [36, 5, 30, 5]; + + const tooltip = [ + 'x*y', + (x, y) => ({ + name: x, + value: y, + }), + ]; + + // for tooltip not to be hide + const chartHeight = height + 54; + + return ( +
    +
    + + + + +
    +
    + ); + } +} +export default MiniBar; diff --git a/src/components/Charts/MiniProgress/index.d.ts b/src/components/Charts/MiniProgress/index.d.ts new file mode 100644 index 0000000..aaeb726 --- /dev/null +++ b/src/components/Charts/MiniProgress/index.d.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +export interface IMiniProgressProps { + target: number; + color?: string; + strokeWidth?: number; + percent?: number; + style?: React.CSSProperties; +} + +export default class MiniProgress extends React.Component {} diff --git a/src/components/Charts/MiniProgress/index.js b/src/components/Charts/MiniProgress/index.js new file mode 100644 index 0000000..795c79b --- /dev/null +++ b/src/components/Charts/MiniProgress/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { Tooltip } from 'antd'; + +import styles from './index.less'; + +const MiniProgress = ({ target, color = 'rgb(19, 194, 194)', strokeWidth, percent }) => ( +
    + +
    + + +
    +
    +
    +
    +
    +
    +); + +export default MiniProgress; diff --git a/src/components/Charts/MiniProgress/index.less b/src/components/Charts/MiniProgress/index.less new file mode 100644 index 0000000..40ba70b --- /dev/null +++ b/src/components/Charts/MiniProgress/index.less @@ -0,0 +1,35 @@ +@import '~antd/lib/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; + span { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 4px; + border-radius: 100px; + } + span:last-child { + top: auto; + bottom: 0; + } + } +} diff --git a/src/components/Charts/Pie/index.d.ts b/src/components/Charts/Pie/index.d.ts new file mode 100644 index 0000000..66c93ee --- /dev/null +++ b/src/components/Charts/Pie/index.d.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +export interface IPieProps { + animate?: boolean; + color?: string; + colors?: string[]; + height: number; + hasLegend?: boolean; + padding?: [number, number, number, number]; + percent?: number; + data?: Array<{ + x: string | string; + y: number; + }>; + total?: React.ReactNode | number | (() => React.ReactNode | number); + title?: React.ReactNode; + tooltip?: boolean; + valueFormat?: (value: string) => string | React.ReactNode; + subTitle?: React.ReactNode; +} + +export default class Pie extends React.Component {} diff --git a/src/components/Charts/Pie/index.js b/src/components/Charts/Pie/index.js new file mode 100644 index 0000000..7dff512 --- /dev/null +++ b/src/components/Charts/Pie/index.js @@ -0,0 +1,271 @@ +import React, { Component } from 'react'; +import { Chart, Tooltip, Geom, Coord } from 'bizcharts'; +import { DataView } from '@antv/data-set'; +import { Divider } from 'antd'; +import classNames from 'classnames'; +import ReactFitText from 'react-fittext'; +import Debounce from 'lodash-decorators/debounce'; +import Bind from 'lodash-decorators/bind'; +import autoHeight from '../autoHeight'; + +import styles from './index.less'; + +/* eslint react/no-danger:0 */ +@autoHeight() +class Pie extends Component { + state = { + legendData: [], + legendBlock: false, + }; + + componentDidMount() { + window.addEventListener( + 'resize', + () => { + this.requestRef = requestAnimationFrame(() => this.resize()); + }, + { passive: true } + ); + } + + componentDidUpdate(preProps) { + 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() { + window.cancelAnimationFrame(this.requestRef); + window.removeEventListener('resize', this.resize); + this.resize.cancel(); + } + + getG2Instance = 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.get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map(item => { + /* 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 => { + this.root = n; + }; + + handleLegendClick = (item, i) => { + 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, + }); + }; + + // for window resize auto responsive legend + @Bind() + @Debounce(300) + resize() { + const { hasLegend } = this.props; + const { legendBlock } = this.state; + if (!hasLegend || !this.root) { + window.removeEventListener('resize', this.resize); + return; + } + if (this.root.parentNode.clientWidth <= 380) { + if (!legendBlock) { + this.setState({ + legendBlock: true, + }); + } + } else if (legendBlock) { + this.setState({ + legendBlock: false, + }); + } + } + + render() { + const { + valueFormat, + subTitle, + total, + hasLegend = false, + className, + style, + height, + 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 => { + if (value === '占比') { + return color || 'rgba(24, 144, 255, 0.85)'; + } + return '#F0F2F5'; + }; + + data = [ + { + x: '占比', + y: parseFloat(percent), + }, + { + x: '反比', + y: 100 - parseFloat(percent), + }, + ]; + } + + const tooltipFormat = [ + 'x*percent', + (x, p) => ({ + name: x, + value: `${(p * 100).toFixed(2)}%`, + }), + ]; + + const padding = [12, 0, 12, 0]; + + const dv = new DataView(); + dv.source(data).transform({ + type: 'percent', + field: 'y', + dimension: 'x', + as: 'percent', + }); + + return ( +
    + +
    + + {!!tooltip && } + + + + + {(subTitle || total) && ( +
    + {subTitle &&

    {subTitle}

    } + {/* eslint-disable-next-line */} + {total && ( +
    {typeof total === 'function' ? total() : total}
    + )} +
    + )} +
    +
    + + {hasLegend && ( +
      + {legendData.map((item, i) => ( +
    • this.handleLegendClick(item, i)}> + + {item.x} + + + {`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`} + + {valueFormat ? valueFormat(item.y) : item.y} +
    • + ))} +
    + )} +
    + ); + } +} + +export default Pie; diff --git a/src/components/Charts/Pie/index.less b/src/components/Charts/Pie/index.less new file mode 100644 index 0000000..fc961b4 --- /dev/null +++ b/src/components/Charts/Pie/index.less @@ -0,0 +1,94 @@ +@import '~antd/lib/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; + } +} diff --git a/src/components/Charts/Radar/index.d.ts b/src/components/Charts/Radar/index.d.ts new file mode 100644 index 0000000..963ac8c --- /dev/null +++ b/src/components/Charts/Radar/index.d.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +export interface IRadarProps { + title?: React.ReactNode; + height: number; + padding?: [number, number, number, number]; + hasLegend?: boolean; + data: Array<{ + name: string; + label: string; + value: string; + }>; + style?: React.CSSProperties; +} + +export default class Radar extends React.Component {} diff --git a/src/components/Charts/Radar/index.js b/src/components/Charts/Radar/index.js new file mode 100644 index 0000000..a0aa7fa --- /dev/null +++ b/src/components/Charts/Radar/index.js @@ -0,0 +1,184 @@ +import React, { Component } from 'react'; +import { Chart, Tooltip, Geom, Coord, Axis } from 'bizcharts'; +import { Row, Col } from 'antd'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint react/no-danger:0 */ +@autoHeight() +class Radar extends Component { + state = { + legendData: [], + }; + + componentDidMount() { + this.getLegendData(); + } + + componentDidUpdate(preProps) { + const { data } = this.props; + if (data !== preProps.data) { + this.getLegendData(); + } + } + + getG2Instance = chart => { + this.chart = chart; + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + const items = geom.get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map(item => { + // eslint-disable-next-line + const origins = item.map(t => t._origin); + const result = { + name: origins[0].name, + color: item[0].color, + checked: true, + value: origins.reduce((p, n) => p + n.value, 0), + }; + + return result; + }); + + this.setState({ + legendData, + }); + }; + + handleRef = n => { + this.node = n; + }; + + handleLegendClick = (item, i) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter(l => l.checked).map(l => l.name); + + if (this.chart) { + this.chart.filter('name', val => filteredLegendData.indexOf(val) > -1); + this.chart.repaint(); + } + + this.setState({ + legendData, + }); + }; + + render() { + const defaultColors = [ + '#1890FF', + '#FACC14', + '#2FC25B', + '#8543E0', + '#F04864', + '#13C2C2', + '#fa8c16', + '#a0d911', + ]; + + const { + data = [], + height = 0, + title, + hasLegend = false, + forceFit = true, + tickCount = 5, + padding = [35, 30, 16, 30], + animate = true, + colors = defaultColors, + } = this.props; + + const { legendData } = this.state; + + const scale = { + value: { + min: 0, + tickCount, + }, + }; + + const chartHeight = height - (hasLegend ? 80 : 22); + + return ( +
    + {title &&

    {title}

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

    + + {item.name} +

    +
    {item.value}
    +
    + + ))} +
    + )} +
    + ); + } +} + +export default Radar; diff --git a/src/components/Charts/Radar/index.less b/src/components/Charts/Radar/index.less new file mode 100644 index 0000000..437a712 --- /dev/null +++ b/src/components/Charts/Radar/index.less @@ -0,0 +1,46 @@ +@import '~antd/lib/style/themes/default.less'; + +.radar { + .legend { + margin-top: 16px; + .legendItem { + position: relative; + color: @text-color-secondary; + line-height: 22px; + text-align: center; + cursor: pointer; + p { + margin: 0; + } + h6 { + margin-top: 4px; + margin-bottom: 0; + padding-left: 16px; + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + &::after { + position: absolute; + top: 8px; + right: 0; + width: 1px; + height: 40px; + background-color: @border-color-split; + content: ''; + } + } + > :last-child .legendItem::after { + display: none; + } + .dot { + position: relative; + top: -1px; + display: inline-block; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 6px; + } + } +} diff --git a/src/components/Charts/TagCloud/index.d.ts b/src/components/Charts/TagCloud/index.d.ts new file mode 100644 index 0000000..462650c --- /dev/null +++ b/src/components/Charts/TagCloud/index.d.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +export interface ITagCloudProps { + data: Array<{ + name: string; + value: number; + }>; + height: number; + style?: React.CSSProperties; +} + +export default class TagCloud extends React.Component {} diff --git a/src/components/Charts/TagCloud/index.js b/src/components/Charts/TagCloud/index.js new file mode 100644 index 0000000..d94699b --- /dev/null +++ b/src/components/Charts/TagCloud/index.js @@ -0,0 +1,182 @@ +import React, { Component } from 'react'; +import { Chart, Geom, Coord, Shape, Tooltip } from 'bizcharts'; +import DataSet from '@antv/data-set'; +import Debounce from 'lodash-decorators/debounce'; +import Bind from 'lodash-decorators/bind'; +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'; + +@autoHeight() +class TagCloud extends Component { + state = { + dv: null, + }; + + componentDidMount() { + requestAnimationFrame(() => { + this.initTagCloud(); + this.renderChart(); + }); + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentDidUpdate(preProps) { + const { data } = this.props; + if (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(); + }); + }; + + saveRootRef = node => { + this.root = node; + }; + + initTagCloud = () => { + function getTextAttrs(cfg) { + return Object.assign( + {}, + { + 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', + }, + cfg.style + ); + } + + // 给point注册一个词云的shape + Shape.registerShape('point', 'cloud', { + drawShape(cfg, container) { + const attrs = getTextAttrs(cfg); + return container.addShape('text', { + attrs: Object.assign(attrs, { + x: cfg.x, + y: cfg.y, + }), + }); + }, + }); + }; + + @Bind() + @Debounce(500) + renderChart(nextProps) { + // 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) { + // eslint-disable-next-line + return Math.pow((d.value - min) / (max - min), 2) * (17.5 - 5) + 5; + }, + }); + + if (this.isUnmount) { + return; + } + + this.setState({ + dv, + w, + h, + }); + }; + + if (!this.imageMask) { + this.imageMask = new Image(); + this.imageMask.crossOrigin = ''; + this.imageMask.src = imgUrl; + + this.imageMask.onload = onload; + } else { + onload(); + } + } + + render() { + const { className, height } = this.props; + const { dv, w, h } = this.state; + + return ( +
    + {dv && ( + + + + + + )} +
    + ); + } +} + +export default TagCloud; diff --git a/src/components/Charts/TagCloud/index.less b/src/components/Charts/TagCloud/index.less new file mode 100644 index 0000000..db8e4da --- /dev/null +++ b/src/components/Charts/TagCloud/index.less @@ -0,0 +1,6 @@ +.tagCloud { + overflow: hidden; + canvas { + transform-origin: 0 0; + } +} diff --git a/src/components/Charts/TimelineChart/index.d.ts b/src/components/Charts/TimelineChart/index.d.ts new file mode 100644 index 0000000..40b9432 --- /dev/null +++ b/src/components/Charts/TimelineChart/index.d.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; +export interface ITimelineChartProps { + data: Array<{ + x: number; + y1: number; + y2?: number; + }>; + titleMap: { y1: string; y2?: string }; + padding?: [number, number, number, number]; + height?: number; + style?: React.CSSProperties; +} + +export default class TimelineChart extends React.Component {} diff --git a/src/components/Charts/TimelineChart/index.js b/src/components/Charts/TimelineChart/index.js new file mode 100644 index 0000000..d82623c --- /dev/null +++ b/src/components/Charts/TimelineChart/index.js @@ -0,0 +1,120 @@ +import React from 'react'; +import { Chart, Tooltip, Geom, Legend, Axis } from 'bizcharts'; +import DataSet from '@antv/data-set'; +import Slider from 'bizcharts-plugin-slider'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +@autoHeight() +class TimelineChart extends React.Component { + render() { + const { + title, + height = 400, + padding = [60, 20, 40, 40], + titleMap = { + y1: 'y1', + y2: 'y2', + }, + borderWidth = 2, + data: sourceData, + } = this.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 => { + const date = obj.x; + return date <= ds.state.end && date >= ds.state.start; + }, + }) + .transform({ + type: 'map', + callback(row) { + const newRow = { ...row }; + newRow[titleMap.y1] = row.y1; + newRow[titleMap.y2] = row.y2; + return newRow; + }, + }) + .transform({ + type: 'fold', + fields: [titleMap.y1, titleMap.y2], // 展开字段集 + key: 'key', // key字段 + value: 'value', // value字段 + }); + + const timeScale = { + type: 'time', + tickInterval: 60 * 60 * 1000, + mask: 'HH:mm', + range: [0, 1], + }; + + const cols = { + x: timeScale, + value: { + max, + min: 0, + }, + }; + + const SliderGen = () => ( + { + ds.setState('start', startValue); + ds.setState('end', endValue); + }} + /> + ); + + return ( +
    +
    + {title &&

    {title}

    } + + + + + + +
    + +
    +
    +
    + ); + } +} + +export default TimelineChart; diff --git a/src/components/Charts/TimelineChart/index.less b/src/components/Charts/TimelineChart/index.less new file mode 100644 index 0000000..1751975 --- /dev/null +++ b/src/components/Charts/TimelineChart/index.less @@ -0,0 +1,3 @@ +.timelineChart { + background: #fff; +} diff --git a/src/components/Charts/WaterWave/index.d.ts b/src/components/Charts/WaterWave/index.d.ts new file mode 100644 index 0000000..8f5588d --- /dev/null +++ b/src/components/Charts/WaterWave/index.d.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +export interface IWaterWaveProps { + title: React.ReactNode; + color?: string; + height: number; + percent: number; + style?: React.CSSProperties; +} + +export default class WaterWave extends React.Component {} diff --git a/src/components/Charts/WaterWave/index.js b/src/components/Charts/WaterWave/index.js new file mode 100644 index 0000000..055f7c7 --- /dev/null +++ b/src/components/Charts/WaterWave/index.js @@ -0,0 +1,213 @@ +import React, { PureComponent } from 'react'; +import autoHeight from '../autoHeight'; +import styles from './index.less'; + +/* eslint no-return-assign: 0 */ +/* eslint no-mixed-operators: 0 */ +// riddle: https://riddle.alibaba-inc.com/riddles/2d9a4b90 + +@autoHeight() +class WaterWave extends PureComponent { + state = { + radio: 1, + }; + + componentDidMount() { + this.renderChart(); + this.resize(); + window.addEventListener( + 'resize', + () => { + requestAnimationFrame(() => this.resize()); + }, + { passive: true } + ); + } + + componentDidUpdate(props) { + const { percent } = this.props; + if (props.percent !== percent) { + // 不加这个会造成绘制缓慢 + this.renderChart('update'); + } + } + + componentWillUnmount() { + cancelAnimationFrame(this.timer); + if (this.node) { + this.node.innerHTML = ''; + } + window.removeEventListener('resize', this.resize); + } + + resize = () => { + if (this.root) { + const { height } = this.props; + const { offsetWidth } = this.root.parentNode; + this.setState({ + radio: offsetWidth < height ? offsetWidth / height : 1, + }); + } + }; + + renderChart(type) { + const { percent, color = '#1890FF' } = this.props; + const data = percent / 100; + const self = this; + cancelAnimationFrame(this.timer); + + if (!this.node || (data !== 0 && !data)) { + return; + } + + const canvas = this.node; + const ctx = canvas.getContext('2d'); + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + const radius = canvasWidth / 2; + const lineWidth = 2; + const cR = radius - lineWidth; + + ctx.beginPath(); + ctx.lineWidth = lineWidth * 2; + + const axisLength = canvasWidth - lineWidth; + const unit = axisLength / 8; + const range = 0.2; // 振幅 + let currRange = range; + const xOffset = lineWidth; + let sp = 0; // 周期偏移量 + let currData = 0; + const waveupsp = 0.005; // 水波上涨速度 + + let arcStack = []; + const bR = radius - lineWidth; + const circleOffset = -(Math.PI / 2); + let circleLock = true; + + for (let i = circleOffset; i < circleOffset + 2 * Math.PI; i += 1 / (8 * Math.PI)) { + arcStack.push([radius + bR * Math.cos(i), radius + bR * Math.sin(i)]); + } + + const cStartPoint = arcStack.shift(); + ctx.strokeStyle = color; + ctx.moveTo(cStartPoint[0], cStartPoint[1]); + + function drawSin() { + ctx.beginPath(); + ctx.save(); + + const sinStack = []; + for (let i = xOffset; i <= xOffset + axisLength; i += 20 / axisLength) { + const x = sp + (xOffset + i) / unit; + const y = Math.sin(x) * currRange; + const dx = i; + const dy = 2 * cR * (1 - currData) + (radius - cR) - unit * y; + + ctx.lineTo(dx, dy); + sinStack.push([dx, dy]); + } + + const startPoint = sinStack.shift(); + + ctx.lineTo(xOffset + axisLength, canvasHeight); + ctx.lineTo(xOffset, canvasHeight); + ctx.lineTo(startPoint[0], startPoint[1]); + + const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeight); + gradient.addColorStop(0, '#ffffff'); + gradient.addColorStop(1, color); + ctx.fillStyle = gradient; + ctx.fill(); + ctx.restore(); + } + + function render() { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (circleLock && type !== 'update') { + if (arcStack.length) { + const temp = arcStack.shift(); + ctx.lineTo(temp[0], temp[1]); + ctx.stroke(); + } else { + circleLock = false; + ctx.lineTo(cStartPoint[0], cStartPoint[1]); + ctx.stroke(); + arcStack = null; + + ctx.globalCompositeOperation = 'destination-over'; + ctx.beginPath(); + ctx.lineWidth = lineWidth; + ctx.arc(radius, radius, bR, 0, 2 * Math.PI, 1); + + ctx.beginPath(); + ctx.save(); + ctx.arc(radius, radius, radius - 3 * lineWidth, 0, 2 * Math.PI, 1); + + ctx.restore(); + ctx.clip(); + ctx.fillStyle = color; + } + } else { + if (data >= 0.85) { + if (currRange > range / 4) { + const t = range * 0.01; + currRange -= t; + } + } else if (data <= 0.1) { + if (currRange < range * 1.5) { + const t = range * 0.01; + currRange += t; + } + } else { + if (currRange <= range) { + const t = range * 0.01; + currRange += t; + } + if (currRange >= range) { + const t = range * 0.01; + currRange -= t; + } + } + if (data - currData > 0) { + currData += waveupsp; + } + if (data - currData < 0) { + currData -= waveupsp; + } + + sp += 0.07; + drawSin(); + } + self.timer = requestAnimationFrame(render); + } + render(); + } + + render() { + const { radio } = this.state; + const { percent, title, height } = this.props; + return ( +
    (this.root = n)} + style={{ transform: `scale(${radio})` }} + > +
    + (this.node = n)} + width={height * 2} + height={height * 2} + /> +
    +
    + {title && {title}} +

    {percent}%

    +
    +
    + ); + } +} + +export default WaterWave; diff --git a/src/components/Charts/WaterWave/index.less b/src/components/Charts/WaterWave/index.less new file mode 100644 index 0000000..2e75f21 --- /dev/null +++ b/src/components/Charts/WaterWave/index.less @@ -0,0 +1,28 @@ +@import '~antd/lib/style/themes/default.less'; + +.waterWave { + position: relative; + display: inline-block; + transform-origin: left; + .text { + position: absolute; + top: 32px; + left: 0; + width: 100%; + text-align: center; + span { + color: @text-color-secondary; + font-size: 14px; + line-height: 22px; + } + h4 { + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + } + .waterWaveCanvasWrapper { + transform: scale(0.5); + transform-origin: 0 0; + } +} diff --git a/src/components/Charts/autoHeight.js b/src/components/Charts/autoHeight.js new file mode 100644 index 0000000..6ee9e09 --- /dev/null +++ b/src/components/Charts/autoHeight.js @@ -0,0 +1,62 @@ +/* eslint eqeqeq: 0 */ +import React from 'react'; + +function computeHeight(node) { + const totalHeight = parseInt(getComputedStyle(node).height, 10); + const padding = + parseInt(getComputedStyle(node).paddingTop, 10) + + parseInt(getComputedStyle(node).paddingBottom, 10); + return totalHeight - padding; +} + +function getAutoHeight(n) { + if (!n) { + return 0; + } + + let node = n; + + let height = computeHeight(node); + + while (!height) { + node = node.parentNode; + if (node) { + height = computeHeight(node); + } else { + break; + } + } + + return height; +} + +const autoHeight = () => WrappedComponent => + class extends React.Component { + state = { + computedHeight: 0, + }; + + componentDidMount() { + const { height } = this.props; + if (!height) { + const h = getAutoHeight(this.root); + // eslint-disable-next-line + this.setState({ computedHeight: h }); + } + } + + handleRoot = node => { + this.root = node; + }; + + render() { + const { height } = this.props; + const { computedHeight } = this.state; + const h = height || computedHeight; + return ( +
    {h > 0 && }
    + ); + } + }; + +export default autoHeight; diff --git a/src/components/Charts/bizcharts.d.ts b/src/components/Charts/bizcharts.d.ts new file mode 100644 index 0000000..0815ffe --- /dev/null +++ b/src/components/Charts/bizcharts.d.ts @@ -0,0 +1,3 @@ +import * as BizChart from 'bizcharts'; + +export = BizChart; diff --git a/src/components/Charts/bizcharts.js b/src/components/Charts/bizcharts.js new file mode 100644 index 0000000..e08db8d --- /dev/null +++ b/src/components/Charts/bizcharts.js @@ -0,0 +1,3 @@ +import * as BizChart from 'bizcharts'; + +export default BizChart; diff --git a/src/components/Charts/demo/bar.md b/src/components/Charts/demo/bar.md new file mode 100644 index 0000000..955f44e --- /dev/null +++ b/src/components/Charts/demo/bar.md @@ -0,0 +1,26 @@ +--- +order: 4 +title: 柱状图 +--- + +通过设置 `x`,`y` 属性,可以快速的构建出一个漂亮的柱状图,各种纬度的关系则是通过自定义的数据展现。 + +````jsx +import { Bar } from 'ant-design-pro/lib/Charts'; + +const salesData = []; +for (let i = 0; i < 12; i += 1) { + salesData.push({ + x: `${i + 1}月`, + y: Math.floor(Math.random() * 1000) + 200, + }); +} + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Charts/demo/chart-card.md b/src/components/Charts/demo/chart-card.md new file mode 100644 index 0000000..4da852b --- /dev/null +++ b/src/components/Charts/demo/chart-card.md @@ -0,0 +1,95 @@ +--- +order: 1 +title: 图表卡片 +--- + +用于展示图表的卡片容器,可以方便的配合其它图表套件展示丰富信息。 + +```jsx +import { ChartCard, yuan, Field } from 'ant-design-pro/lib/Charts'; +import Trend from 'ant-design-pro/lib/Trend'; +import { Row, Col, Icon, Tooltip } from 'antd'; +import numeral from 'numeral'; + +ReactDOM.render( + + + + + + } + total={() => ( + + )} + footer={ + + } + contentHeight={46} + > + + 周同比 + + 12% + + + + 日环比 + + 11% + + + + + + + } + action={ + + + + } + total={() => ( + + )} + footer={ + + } + /> + + + + } + action={ + + + + } + total={() => ( + + )} + /> + + , + mountNode, +); +``` diff --git a/src/components/Charts/demo/gauge.md b/src/components/Charts/demo/gauge.md new file mode 100644 index 0000000..f53465d --- /dev/null +++ b/src/components/Charts/demo/gauge.md @@ -0,0 +1,18 @@ +--- +order: 7 +title: 仪表盘 +--- + +仪表盘是一种进度展示方式,可以更直观的展示当前的进展情况,通常也可表示占比。 + +````jsx +import { Gauge } from 'ant-design-pro/lib/Charts'; + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Charts/demo/mini-area.md b/src/components/Charts/demo/mini-area.md new file mode 100644 index 0000000..2b9bfb4 --- /dev/null +++ b/src/components/Charts/demo/mini-area.md @@ -0,0 +1,28 @@ +--- +order: 2 +col: 2 +title: 迷你区域图 +--- + +````jsx +import { MiniArea } from 'ant-design-pro/lib/Charts'; +import moment from 'moment'; + +const visitData = []; +const beginDay = new Date().getTime(); +for (let i = 0; i < 20; i += 1) { + visitData.push({ + x: moment(new Date(beginDay + (1000 * 60 * 60 * 24 * i))).format('YYYY-MM-DD'), + y: Math.floor(Math.random() * 100) + 10, + }); +} + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Charts/demo/mini-bar.md b/src/components/Charts/demo/mini-bar.md new file mode 100644 index 0000000..fef301b --- /dev/null +++ b/src/components/Charts/demo/mini-bar.md @@ -0,0 +1,28 @@ +--- +order: 2 +col: 2 +title: 迷你柱状图 +--- + +迷你柱状图更适合展示简单的区间数据,简洁的表现方式可以很好的减少大数据量的视觉展现压力。 + +````jsx +import { MiniBar } from 'ant-design-pro/lib/Charts'; +import moment from 'moment'; + +const visitData = []; +const beginDay = new Date().getTime(); +for (let i = 0; i < 20; i += 1) { + visitData.push({ + x: moment(new Date(beginDay + (1000 * 60 * 60 * 24 * i))).format('YYYY-MM-DD'), + y: Math.floor(Math.random() * 100) + 10, + }); +} + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Charts/demo/mini-pie.md b/src/components/Charts/demo/mini-pie.md new file mode 100644 index 0000000..9b1abf0 --- /dev/null +++ b/src/components/Charts/demo/mini-pie.md @@ -0,0 +1,16 @@ +--- +order: 6 +title: 迷你饼状图 +--- + +通过简化 `Pie` 属性的设置,可以快速的实现极简的饼状图,可配合 `ChartCard` 组合展 +现更多业务场景。 + +```jsx +import { Pie } from 'ant-design-pro/lib/Charts'; + +ReactDOM.render( + , + mountNode +); +``` diff --git a/src/components/Charts/demo/mini-progress.md b/src/components/Charts/demo/mini-progress.md new file mode 100644 index 0000000..6308a8f --- /dev/null +++ b/src/components/Charts/demo/mini-progress.md @@ -0,0 +1,12 @@ +--- +order: 3 +title: 迷你进度条 +--- + +````jsx +import { MiniProgress } from 'ant-design-pro/lib/Charts'; + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Charts/demo/mix.md b/src/components/Charts/demo/mix.md new file mode 100644 index 0000000..fc64110 --- /dev/null +++ b/src/components/Charts/demo/mix.md @@ -0,0 +1,84 @@ +--- +order: 0 +title: 图表套件组合展示 +--- + +利用 Ant Design Pro 提供的图表套件,可以灵活组合符合设计规范的图表来满足复杂的业务需求。 + +````jsx +import { ChartCard, Field, MiniArea, MiniBar, MiniProgress } from 'ant-design-pro/lib/Charts'; +import Trend from 'ant-design-pro/lib/Trend'; +import NumberInfo from 'ant-design-pro/lib/NumberInfo'; +import { Row, Col, Icon, Tooltip } from 'antd'; +import numeral from 'numeral'; +import moment from 'moment'; + +const visitData = []; +const beginDay = new Date().getTime(); +for (let i = 0; i < 20; i += 1) { + visitData.push({ + x: moment(new Date(beginDay + (1000 * 60 * 60 * 24 * i))).format('YYYY-MM-DD'), + y: Math.floor(Math.random() * 100) + 10, + }); +} + +ReactDOM.render( + + + + 本周访问} + total={numeral(12321).format('0,0')} + status="up" + subTotal={17.1} + /> + + + + + } + total={numeral(8846).format('0,0')} + footer={} + contentHeight={46} + > + + + + + } + total="78%" + footer={ +
    + + 周同比 + 12% + + + 日环比 + 11% + +
    + } + contentHeight={46} + > + +
    + +
    +, mountNode); +```` diff --git a/src/components/Charts/demo/pie.md b/src/components/Charts/demo/pie.md new file mode 100644 index 0000000..9c87161 --- /dev/null +++ b/src/components/Charts/demo/pie.md @@ -0,0 +1,54 @@ +--- +order: 5 +title: 饼状图 +--- + +```jsx +import { Pie, yuan } from 'ant-design-pro/lib/Charts'; + +const salesPieData = [ + { + x: '家用电器', + y: 4544, + }, + { + x: '食用酒水', + y: 3321, + }, + { + x: '个护健康', + y: 3113, + }, + { + x: '服饰箱包', + y: 2341, + }, + { + x: '母婴产品', + y: 1231, + }, + { + x: '其他', + y: 1231, + }, +]; + +ReactDOM.render( + ( + now.y + pre, 0)) + }} + /> + )} + data={salesPieData} + valueFormat={val => } + height={294} + />, + mountNode, +); +``` diff --git a/src/components/Charts/demo/radar.md b/src/components/Charts/demo/radar.md new file mode 100644 index 0000000..584344a --- /dev/null +++ b/src/components/Charts/demo/radar.md @@ -0,0 +1,64 @@ +--- +order: 7 +title: 雷达图 +--- + +````jsx +import { Radar, ChartCard } from 'ant-design-pro/lib/Charts'; + +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 = []; +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], + }); + } + }); +}); + +ReactDOM.render( + + + +, mountNode); +```` diff --git a/src/components/Charts/demo/tag-cloud.md b/src/components/Charts/demo/tag-cloud.md new file mode 100644 index 0000000..c66f6fe --- /dev/null +++ b/src/components/Charts/demo/tag-cloud.md @@ -0,0 +1,25 @@ +--- +order: 9 +title: 标签云 +--- + +标签云是一套相关的标签以及与此相应的权重展示方式,一般典型的标签云有 30 至 150 个标签,而权重影响使用的字体大小或其他视觉效果。 + +````jsx +import { TagCloud } from 'ant-design-pro/lib/Charts'; + +const tags = []; +for (let i = 0; i < 50; i += 1) { + tags.push({ + name: `TagClout-Title-${i}`, + value: Math.floor((Math.random() * 50)) + 20, + }); +} + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Charts/demo/timeline-chart.md b/src/components/Charts/demo/timeline-chart.md new file mode 100644 index 0000000..60773b5 --- /dev/null +++ b/src/components/Charts/demo/timeline-chart.md @@ -0,0 +1,27 @@ +--- +order: 9 +title: 带有时间轴的图表 +--- + +使用 `TimelineChart` 组件可以实现带有时间轴的柱状图展现,而其中的 `x` 属性,则是时间值的指向,默认最多支持同时展现两个指标,分别是 `y1` 和 `y2`。 + +````jsx +import { TimelineChart } from 'ant-design-pro/lib/Charts'; + +const chartData = []; +for (let i = 0; i < 20; i += 1) { + chartData.push({ + x: (new Date().getTime()) + (1000 * 60 * 30 * i), + y1: Math.floor(Math.random() * 100) + 1000, + y2: Math.floor(Math.random() * 100) + 10, + }); +} + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Charts/demo/waterwave.md b/src/components/Charts/demo/waterwave.md new file mode 100644 index 0000000..74d290f --- /dev/null +++ b/src/components/Charts/demo/waterwave.md @@ -0,0 +1,20 @@ +--- +order: 8 +title: 水波图 +--- + +水波图是一种比例的展示方式,可以更直观的展示关键值的占比。 + +````jsx +import { WaterWave } from 'ant-design-pro/lib/Charts'; + +ReactDOM.render( +
    + +
    +, mountNode); +```` diff --git a/src/components/Charts/g2.js b/src/components/Charts/g2.js new file mode 100644 index 0000000..21e22c2 --- /dev/null +++ b/src/components/Charts/g2.js @@ -0,0 +1,15 @@ +// 全局 G2 设置 +import { track, setTheme } from 'bizcharts'; + +track(false); + +const config = { + defaultColor: '#1089ff', + shape: { + interval: { + fillOpacity: 1, + }, + }, +}; + +setTheme(config); diff --git a/src/components/Charts/index.d.ts b/src/components/Charts/index.d.ts new file mode 100644 index 0000000..1ff27af --- /dev/null +++ b/src/components/Charts/index.d.ts @@ -0,0 +1,17 @@ +import * as numeral from 'numeral'; +export { default as ChartCard } from './ChartCard'; +export { default as Bar } from './Bar'; +export { default as Pie } from './Pie'; +export { default as Radar } from './Radar'; +export { default as Gauge } from './Gauge'; +export { default as MiniArea } from './MiniArea'; +export { default as MiniBar } from './MiniBar'; +export { default as MiniProgress } from './MiniProgress'; +export { default as Field } from './Field'; +export { default as WaterWave } from './WaterWave'; +export { default as TagCloud } from './TagCloud'; +export { default as TimelineChart } from './TimelineChart'; + +declare const yuan: (value: number | string) => string; + +export { yuan }; diff --git a/src/components/Charts/index.js b/src/components/Charts/index.js new file mode 100644 index 0000000..78863fa --- /dev/null +++ b/src/components/Charts/index.js @@ -0,0 +1,49 @@ +import numeral from 'numeral'; +import './g2'; +import ChartCard from './ChartCard'; +import Bar from './Bar'; +import Pie from './Pie'; +import Radar from './Radar'; +import Gauge from './Gauge'; +import MiniArea from './MiniArea'; +import MiniBar from './MiniBar'; +import MiniProgress from './MiniProgress'; +import Field from './Field'; +import WaterWave from './WaterWave'; +import TagCloud from './TagCloud'; +import TimelineChart from './TimelineChart'; + +const yuan = val => `¥ ${numeral(val).format('0,0')}`; + +const Charts = { + yuan, + Bar, + Pie, + Gauge, + Radar, + MiniBar, + MiniArea, + MiniProgress, + ChartCard, + Field, + WaterWave, + TagCloud, + TimelineChart, +}; + +export { + Charts as default, + yuan, + Bar, + Pie, + Gauge, + Radar, + MiniBar, + MiniArea, + MiniProgress, + ChartCard, + Field, + WaterWave, + TagCloud, + TimelineChart, +}; diff --git a/src/components/Charts/index.less b/src/components/Charts/index.less new file mode 100644 index 0000000..190428b --- /dev/null +++ b/src/components/Charts/index.less @@ -0,0 +1,19 @@ +.miniChart { + position: relative; + width: 100%; + .chartContent { + position: absolute; + bottom: -28px; + width: 100%; + > div { + margin: 0 -5px; + overflow: hidden; + } + } + .chartLoading { + position: absolute; + top: 16px; + left: 50%; + margin-left: -7px; + } +} diff --git a/src/components/Charts/index.md b/src/components/Charts/index.md new file mode 100644 index 0000000..cb7c9c9 --- /dev/null +++ b/src/components/Charts/index.md @@ -0,0 +1,132 @@ +--- +title: + en-US: Charts + zh-CN: Charts +subtitle: 图表 +order: 2 +cols: 2 +--- + +Ant Design Pro 提供的业务中常用的图表类型,都是基于 [G2](https://antv.alipay.com/g2/doc/index.html) 按照 Ant Design 图表规范封装,需要注意的是 Ant Design Pro 的图表组件以套件形式提供,可以任意组合实现复杂的业务需求。 + +因为结合了 Ant Design 的标准设计,本着极简的设计思想以及开箱即用的理念,简化了大量 API 配置,所以如果需要灵活定制图表,可以参考 Ant Design Pro 图表实现,自行基于 [G2](https://antv.alipay.com/g2/doc/index.html) 封装图表组件使用。 + +## API + +### ChartCard + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| title | 卡片标题 | ReactNode\|string | - | +| action | 卡片操作 | ReactNode | - | +| total | 数据总量 | ReactNode \| number \| function | - | +| footer | 卡片底部 | ReactNode | - | +| contentHeight | 内容区域高度 | number | - | +| avatar | 右侧图标 | React.ReactNode | - | +### MiniBar + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| color | 图表颜色 | string | `#1890FF` | +| height | 图表高度 | number | - | +| data | 数据 | array<{x, y}> | - | + +### MiniArea + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| color | 图表颜色 | string | `rgba(24, 144, 255, 0.2)` | +| borderColor | 图表边颜色 | string | `#1890FF` | +| height | 图表高度 | number | - | +| line | 是否显示描边 | boolean | false | +| animate | 是否显示动画 | boolean | true | +| xAxis | [x 轴配置](http://antvis.github.io/g2/doc/tutorial/start/axis.html) | object | - | +| yAxis | [y 轴配置](http://antvis.github.io/g2/doc/tutorial/start/axis.html) | object | - | +| data | 数据 | array<{x, y}> | - | + +### MiniProgress + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| target | 目标比例 | number | - | +| color | 进度条颜色 | string | - | +| strokeWidth | 进度条高度 | number | - | +| percent | 进度比例 | number | - | + +### Bar + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| title | 图表标题 | ReactNode\|string | - | +| color | 图表颜色 | string | `rgba(24, 144, 255, 0.85)` | +| padding | 图表内部间距 | [array](https://github.com/alibaba/BizCharts/blob/master/doc/api/chart.md#7padding-object--number--array-) | `'auto'` | +| height | 图表高度 | number | - | +| data | 数据 | array<{x, y}> | - | +| autoLabel | 在宽度不足时,自动隐藏 x 轴的 label | boolean | `true` | + +### Pie + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| animate | 是否显示动画 | boolean | true | +| color | 图表颜色 | string | `rgba(24, 144, 255, 0.85)` | +| height | 图表高度 | number | - | +| hasLegend | 是否显示 legend | boolean | `false` | +| padding | 图表内部间距 | [array](https://github.com/alibaba/BizCharts/blob/master/doc/api/chart.md#7padding-object--number--array-) | `'auto'` | +| percent | 占比 | number | - | +| tooltip | 是否显示 tooltip | boolean | true | +| valueFormat | 显示值的格式化函数 | function | - | +| title | 图表标题 | ReactNode\|string | - | +| subTitle | 图表子标题 | ReactNode\|string | - | +| total | 图标中央的总数 | string | function | - | + +### Radar + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| title | 图表标题 | ReactNode\|string | - | +| height | 图表高度 | number | - | +| hasLegend | 是否显示 legend | boolean | `false` | +| padding | 图表内部间距 | [array](https://github.com/alibaba/BizCharts/blob/master/doc/api/chart.md#7padding-object--number--array-) | `'auto'` | +| data | 图标数据 | array<{name,label,value}> | - | + +### Gauge + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| title | 图表标题 | ReactNode\|string | - | +| height | 图表高度 | number | - | +| color | 图表颜色 | string | `#2F9CFF` | +| bgColor | 图表背景颜色 | string | `#F0F2F5` | +| percent | 进度比例 | number | - | + +### WaterWave + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| title | 图表标题 | ReactNode\|string | - | +| height | 图表高度 | number | - | +| color | 图表颜色 | string | `#1890FF` | +| percent | 进度比例 | number | - | + +### TagCloud + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| data | 标题 | Array | - | +| height | 高度值 | number | - | + +### TimelineChart + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| data | 标题 | Array | - | +| titleMap | 指标别名 | Object{y1: '客流量', y2: '支付笔数'} | - | +| height | 高度值 | number | 400 | + +### Field + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| label | 标题 | ReactNode\|string | - | +| value | 值 | ReactNode\|string | - | diff --git a/src/components/CountDown/demo/simple.md b/src/components/CountDown/demo/simple.md new file mode 100644 index 0000000..e42cbf1 --- /dev/null +++ b/src/components/CountDown/demo/simple.md @@ -0,0 +1,24 @@ +--- +order: 0 +title: + zh-CN: 基本 + en-US: Basic +--- + +## zh-CN + +简单的倒计时组件使用。 + +## en-US + +The simplest usage. + +````jsx +import CountDown from 'ant-design-pro/lib/CountDown'; + +const targetTime = new Date().getTime() + 3900000; + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/CountDown/index.d.ts b/src/components/CountDown/index.d.ts new file mode 100644 index 0000000..d39a2e9 --- /dev/null +++ b/src/components/CountDown/index.d.ts @@ -0,0 +1,9 @@ +import * as React from 'react'; +export interface ICountDownProps { + format?: (time: number) => void; + target: Date | number; + onEnd?: () => void; + style?: React.CSSProperties; +} + +export default class CountDown extends React.Component {} diff --git a/src/components/CountDown/index.en-US.md b/src/components/CountDown/index.en-US.md new file mode 100644 index 0000000..7b45240 --- /dev/null +++ b/src/components/CountDown/index.en-US.md @@ -0,0 +1,15 @@ +--- +title: CountDown +cols: 1 +order: 3 +--- + +Simple CountDown Component. + +## API + +| Property | Description | Type | Default | +|----------|------------------------------------------|-------------|-------| +| format | Formatter of time | Function(time) | | +| target | Target time | Date | - | +| onEnd | Countdown to the end callback | funtion | -| diff --git a/src/components/CountDown/index.js b/src/components/CountDown/index.js new file mode 100644 index 0000000..7565bd8 --- /dev/null +++ b/src/components/CountDown/index.js @@ -0,0 +1,121 @@ +import React, { Component } from 'react'; + +function fixedZero(val) { + return val * 1 < 10 ? `0${val}` : val; +} +const initTime = props => { + let lastTime = 0; + let targetTime = 0; + try { + if (Object.prototype.toString.call(props.target) === '[object Date]') { + targetTime = props.target.getTime(); + } else { + targetTime = new Date(props.target).getTime(); + } + } catch (e) { + throw new Error('invalid target prop', e); + } + + lastTime = targetTime - new Date().getTime(); + return { + lastTime: lastTime < 0 ? 0 : lastTime, + }; +}; + +class CountDown extends Component { + timer = 0; + + interval = 1000; + + constructor(props) { + super(props); + const { lastTime } = initTime(props); + this.state = { + lastTime, + }; + } + + static getDerivedStateFromProps(nextProps, preState) { + const { lastTime } = initTime(nextProps); + if (preState.lastTime !== lastTime) { + return { + lastTime, + }; + } + return null; + } + + componentDidMount() { + this.tick(); + } + + componentDidUpdate(prevProps) { + const { target } = this.props; + if (target !== prevProps.target) { + clearTimeout(this.timer); + this.tick(); + } + } + + componentWillUnmount() { + clearTimeout(this.timer); + } + + // defaultFormat = time => ( + // {moment(time).format('hh:mm:ss')} + // ); + defaultFormat = time => { + const hours = 60 * 60 * 1000; + const minutes = 60 * 1000; + + const h = Math.floor(time / hours); + const m = Math.floor((time - h * hours) / minutes); + const s = Math.floor((time - h * hours - m * minutes) / 1000); + return ( + + {fixedZero(h)}:{fixedZero(m)}:{fixedZero(s)} + + ); + }; + + tick = () => { + const { onEnd } = this.props; + let { lastTime } = this.state; + + this.timer = setTimeout(() => { + if (lastTime < this.interval) { + clearTimeout(this.timer); + this.setState( + { + lastTime: 0, + }, + () => { + if (onEnd) { + onEnd(); + } + } + ); + } else { + lastTime -= this.interval; + this.setState( + { + lastTime, + }, + () => { + this.tick(); + } + ); + } + }, this.interval); + }; + + render() { + const { format = this.defaultFormat, onEnd, ...rest } = this.props; + const { lastTime } = this.state; + const result = format(lastTime); + + return {result}; + } +} + +export default CountDown; diff --git a/src/components/CountDown/index.zh-CN.md b/src/components/CountDown/index.zh-CN.md new file mode 100644 index 0000000..7e00ba1 --- /dev/null +++ b/src/components/CountDown/index.zh-CN.md @@ -0,0 +1,16 @@ +--- +title: CountDown +subtitle: 倒计时 +cols: 1 +order: 3 +--- + +倒计时组件。 + +## API + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| format | 时间格式化显示 | Function(time) | | +| target | 目标时间 | Date | - | +| onEnd | 倒计时结束回调 | funtion | -| diff --git a/src/components/DescriptionList/Description.d.ts b/src/components/DescriptionList/Description.d.ts new file mode 100644 index 0000000..2a17be3 --- /dev/null +++ b/src/components/DescriptionList/Description.d.ts @@ -0,0 +1,9 @@ +import * as React from 'react'; + +export default class Description extends React.Component< + { + term: React.ReactNode; + style?: React.CSSProperties; + }, + any +> {} diff --git a/src/components/DescriptionList/Description.js b/src/components/DescriptionList/Description.js new file mode 100644 index 0000000..fce9fd3 --- /dev/null +++ b/src/components/DescriptionList/Description.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Col } from 'antd'; +import styles from './index.less'; +import responsive from './responsive'; + +const Description = ({ term, column, children, ...restProps }) => ( + + {term &&
    {term}
    } + {children !== null && children !== undefined &&
    {children}
    } + +); + +Description.defaultProps = { + term: '', +}; + +Description.propTypes = { + term: PropTypes.node, +}; + +export default Description; diff --git a/src/components/DescriptionList/DescriptionList.js b/src/components/DescriptionList/DescriptionList.js new file mode 100644 index 0000000..84bdbd7 --- /dev/null +++ b/src/components/DescriptionList/DescriptionList.js @@ -0,0 +1,33 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Row } from 'antd'; +import styles from './index.less'; + +const DescriptionList = ({ + className, + title, + col = 3, + layout = 'horizontal', + gutter = 32, + children, + size, + ...restProps +}) => { + const clsString = classNames(styles.descriptionList, styles[layout], className, { + [styles.small]: size === 'small', + [styles.large]: size === 'large', + }); + const column = col > 4 ? 4 : col; + return ( +
    + {title ?
    {title}
    : null} + + {React.Children.map(children, child => + child ? React.cloneElement(child, { column }) : child + )} + +
    + ); +}; + +export default DescriptionList; diff --git a/src/components/DescriptionList/demo/basic.md b/src/components/DescriptionList/demo/basic.md new file mode 100644 index 0000000..8795455 --- /dev/null +++ b/src/components/DescriptionList/demo/basic.md @@ -0,0 +1,43 @@ +--- +order: 0 +title: + zh-CN: 基本 + en-US: Basic +--- + +## zh-CN + +基本描述列表。 + +## en-US + +Basic DescriptionList. + +````jsx +import DescriptionList from 'ant-design-pro/lib/DescriptionList'; + +const { Description } = DescriptionList; + +ReactDOM.render( + + + A free, open source, cross-platform, + graphical web browser developed by the + Mozilla Corporation and hundreds of + volunteers. + + + A free, open source, cross-platform, + graphical web browser developed by the + Mozilla Corporation and hundreds of + volunteers. + + + A free, open source, cross-platform, + graphical web browser developed by the + Mozilla Corporation and hundreds of + volunteers. + + +, mountNode); +```` diff --git a/src/components/DescriptionList/demo/vertical.md b/src/components/DescriptionList/demo/vertical.md new file mode 100644 index 0000000..2742f7c --- /dev/null +++ b/src/components/DescriptionList/demo/vertical.md @@ -0,0 +1,43 @@ +--- +order: 1 +title: + zh-CN: 垂直型 + en-US: Vertical +--- + +## zh-CN + +垂直布局。 + +## en-US + +Vertical layout. + +````jsx +import DescriptionList from 'ant-design-pro/lib/DescriptionList'; + +const { Description } = DescriptionList; + +ReactDOM.render( + + + A free, open source, cross-platform, + graphical web browser developed by the + Mozilla Corporation and hundreds of + volunteers. + + + A free, open source, cross-platform, + graphical web browser developed by the + Mozilla Corporation and hundreds of + volunteers. + + + A free, open source, cross-platform, + graphical web browser developed by the + Mozilla Corporation and hundreds of + volunteers. + + +, mountNode); +```` diff --git a/src/components/DescriptionList/index.d.ts b/src/components/DescriptionList/index.d.ts new file mode 100644 index 0000000..96ccfa7 --- /dev/null +++ b/src/components/DescriptionList/index.d.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +import Description from './Description'; + +export interface IDescriptionListProps { + layout?: 'horizontal' | 'vertical'; + col?: number; + title: React.ReactNode; + gutter?: number; + size?: 'large' | 'small'; + style?: React.CSSProperties; +} + +export default class DescriptionList extends React.Component { + public static Description: typeof Description; +} diff --git a/src/components/DescriptionList/index.en-US.md b/src/components/DescriptionList/index.en-US.md new file mode 100644 index 0000000..089f30b --- /dev/null +++ b/src/components/DescriptionList/index.en-US.md @@ -0,0 +1,33 @@ +--- +title: DescriptionList +cols: 1 +order: 4 +--- + +Groups display multiple read-only fields, which are common to informational displays on detail pages. + +## API + +### DescriptionList + +| Property | Description | Type | Default | +|----------|------------------------------------------|-------------|---------| +| layout | type of layout | Enum{'horizontal', 'vertical'} | 'horizontal' | +| col | specify the maximum number of columns to display, the final columns number is determined by col setting combined with [Responsive Rules](/components/DescriptionList#Responsive-Rules) | number(0 < col <= 4) | 3 | +| title | title | ReactNode | - | +| gutter | specify the distance between two items, unit is `px` | number | 32 | +| size | size of list | Enum{'large', 'small'} | - | + +#### Responsive Rules + +| Window Width | Columns Number | +|---------------------|---------------------------------------------| +| `≥768px` | `col` | +| `≥576px` | `col < 2 ? col : 2` | +| `<576px` | `1` | + +### DescriptionList.Description + +| Property | Description | Type | Default | +|----------|------------------------------------------|-------------|-------| +| term | item title | ReactNode | - | diff --git a/src/components/DescriptionList/index.js b/src/components/DescriptionList/index.js new file mode 100644 index 0000000..357f479 --- /dev/null +++ b/src/components/DescriptionList/index.js @@ -0,0 +1,5 @@ +import DescriptionList from './DescriptionList'; +import Description from './Description'; + +DescriptionList.Description = Description; +export default DescriptionList; diff --git a/src/components/DescriptionList/index.less b/src/components/DescriptionList/index.less new file mode 100644 index 0000000..4048a12 --- /dev/null +++ b/src/components/DescriptionList/index.less @@ -0,0 +1,76 @@ +@import '~antd/lib/style/themes/default.less'; + +.descriptionList { + // offset the padding-bottom of last row + :global { + .ant-row { + margin-bottom: -16px; + overflow: hidden; + } + } + + .title { + margin-bottom: 16px; + color: @heading-color; + font-weight: 500; + font-size: 14px; + } + + .term { + display: table-cell; + padding-bottom: 16px; + color: @heading-color; + // Line-height is 22px IE dom height will calculate error + line-height: 20px; + white-space: nowrap; + + &::after { + position: relative; + top: -0.5px; + margin: 0 8px 0 2px; + content: ':'; + } + } + + .detail { + display: table-cell; + width: 100%; + padding-bottom: 16px; + color: @text-color; + line-height: 20px; + } + + &.small { + // offset the padding-bottom of last row + :global { + .ant-row { + margin-bottom: -8px; + } + } + .title { + margin-bottom: 12px; + color: @text-color; + } + .term, + .detail { + padding-bottom: 8px; + } + } + + &.large { + .title { + font-size: 16px; + } + } + + &.vertical { + .term { + display: block; + padding-bottom: 8px; + } + + .detail { + display: block; + } + } +} diff --git a/src/components/DescriptionList/index.zh-CN.md b/src/components/DescriptionList/index.zh-CN.md new file mode 100644 index 0000000..b16a7fe --- /dev/null +++ b/src/components/DescriptionList/index.zh-CN.md @@ -0,0 +1,37 @@ +--- +title: DescriptionList +subtitle: 描述列表 +cols: 1 +order: 4 +--- + +成组展示多个只读字段,常见于详情页的信息展示。 + +## API + +### DescriptionList + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| layout | 布局方式 | Enum{'horizontal', 'vertical'} | 'horizontal' | +| col | 指定信息最多分几列展示,最终一行几列由 col 配置结合[响应式规则](/components/DescriptionList#响应式规则)决定 | number(0 < col <= 4) | 3 | +| title | 列表标题 | ReactNode | - | +| gutter | 列表项间距,单位为 `px` | number | 32 | +| size | 列表型号 | Enum{'large', 'small'} | - | + +#### 响应式规则 + +| 窗口宽度 | 展示列数 | +|---------------------|---------------------------------------------| +| `≥768px` | `col` | +| `≥576px` | `col < 2 ? col : 2` | +| `<576px` | `1` | + +### DescriptionList.Description + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| term | 列表项标题 | ReactNode | - | + + + diff --git a/src/components/DescriptionList/responsive.js b/src/components/DescriptionList/responsive.js new file mode 100644 index 0000000..a5aa73f --- /dev/null +++ b/src/components/DescriptionList/responsive.js @@ -0,0 +1,6 @@ +export default { + 1: { xs: 24 }, + 2: { xs: 24, sm: 12 }, + 3: { xs: 24, sm: 12, md: 8 }, + 4: { xs: 24, sm: 12, md: 6 }, +}; diff --git a/src/components/EditableItem/index.js b/src/components/EditableItem/index.js new file mode 100644 index 0000000..40034d0 --- /dev/null +++ b/src/components/EditableItem/index.js @@ -0,0 +1,50 @@ +import React, { PureComponent } from 'react'; +import { Input, Icon } from 'antd'; +import styles from './index.less'; + +export default class EditableItem extends PureComponent { + constructor(props) { + super(props); + this.state = { + value: props.value, + editable: false, + }; + } + + handleChange = e => { + const { value } = e.target; + this.setState({ value }); + }; + + check = () => { + this.setState({ editable: false }); + const { value } = this.state; + const { onChange } = this.props; + if (onChange) { + onChange(value); + } + }; + + edit = () => { + this.setState({ editable: true }); + }; + + render() { + const { value, editable } = this.state; + return ( +
    + {editable ? ( +
    + + +
    + ) : ( +
    + {value || ' '} + +
    + )} +
    + ); + } +} diff --git a/src/components/EditableItem/index.less b/src/components/EditableItem/index.less new file mode 100644 index 0000000..7fdf337 --- /dev/null +++ b/src/components/EditableItem/index.less @@ -0,0 +1,25 @@ +@import '~antd/lib/style/themes/default.less'; + +.editableItem { + display: table; + width: 100%; + margin-top: (@font-size-base * @line-height-base - @input-height-base) / 2; + line-height: @input-height-base; + + .wrapper { + display: table-row; + + & > * { + display: table-cell; + } + + & > *:first-child { + width: 85%; + } + + .icon { + text-align: right; + cursor: pointer; + } + } +} diff --git a/src/components/EditableLinkGroup/index.js b/src/components/EditableLinkGroup/index.js new file mode 100644 index 0000000..ae3d93c --- /dev/null +++ b/src/components/EditableLinkGroup/index.js @@ -0,0 +1,46 @@ +import React, { PureComponent, createElement } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'antd'; +import styles from './index.less'; + +// TODO: 添加逻辑 + +class EditableLinkGroup extends PureComponent { + static propTypes = { + links: PropTypes.array, + onAdd: PropTypes.func, + linkElement: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + }; + + static defaultProps = { + links: [], + onAdd: () => {}, + linkElement: 'a', + }; + + render() { + const { links, linkElement, onAdd } = this.props; + return ( +
    + {links.map(link => + createElement( + linkElement, + { + key: `linkGroup-item-${link.id || link.title}`, + to: link.href, + href: link.href, + }, + link.title + ) + )} + { + + } +
    + ); + } +} + +export default EditableLinkGroup; diff --git a/src/components/EditableLinkGroup/index.less b/src/components/EditableLinkGroup/index.less new file mode 100644 index 0000000..ba53315 --- /dev/null +++ b/src/components/EditableLinkGroup/index.less @@ -0,0 +1,16 @@ +@import '~antd/lib/style/themes/default.less'; + +.linkGroup { + padding: 20px 0 8px 24px; + font-size: 0; + & > a { + display: inline-block; + width: 25%; + margin-bottom: 13px; + color: @text-color; + font-size: @font-size-base; + &:hover { + color: @primary-color; + } + } +} diff --git a/src/components/Ellipsis/demo/line.md b/src/components/Ellipsis/demo/line.md new file mode 100644 index 0000000..bc31170 --- /dev/null +++ b/src/components/Ellipsis/demo/line.md @@ -0,0 +1,31 @@ +--- +order: 1 +title: + zh-CN: 按照行数省略 + en-US: Truncate according to the number of rows +--- + +## zh-CN + +通过设置 `lines` 属性指定最大行数,如果超过这个行数的文本会自动截取。但是在这种模式下所有 `children` 将会被转换成纯文本。 + +并且注意在这种模式下,外容器需要有指定的宽度(或设置自身宽度)。 + +## en-US + +`lines` attribute specifies the maximum number of rows where the text will automatically be truncated when exceeded. In this mode, all children will be converted to plain text. + +Also note that, in this mode, the outer container needs to have a specified width (or set its own width). + + +````jsx +import Ellipsis from 'ant-design-pro/lib/Ellipsis'; + +const article =

    There were injuries alleged in three cases in 2015, and a fourth incident in September, according to the safety recall report. After meeting with US regulators in October, the firm decided to issue a voluntary recall.

    ; + +ReactDOM.render( +
    + {article} +
    +, mountNode); +```` diff --git a/src/components/Ellipsis/demo/number.md b/src/components/Ellipsis/demo/number.md new file mode 100644 index 0000000..0bc1a0f --- /dev/null +++ b/src/components/Ellipsis/demo/number.md @@ -0,0 +1,28 @@ +--- +order: 0 +title: + zh-CN: 按照字符数省略 + en-US: Truncate according to the number of character +--- + +## zh-CN + +通过设置 `length` 属性指定文本最长长度,如果超过这个长度会自动截取。 + +## en-US + +`length` attribute specifies the maximum length where the text will automatically be truncated when exceeded. + +````jsx +import Ellipsis from 'ant-design-pro/lib/Ellipsis'; + +const article = 'There were injuries alleged in three cases in 2015, and a fourth incident in September, according to the safety recall report. After meeting with US regulators in October, the firm decided to issue a voluntary recall.'; + +ReactDOM.render( +
    + {article} +

    Show Tooltip

    + {article} +
    +, mountNode); +```` diff --git a/src/components/Ellipsis/index.d.ts b/src/components/Ellipsis/index.d.ts new file mode 100644 index 0000000..37d508d --- /dev/null +++ b/src/components/Ellipsis/index.d.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { TooltipProps } from 'antd/lib/tooltip'; + +export interface IEllipsisTooltipProps extends TooltipProps { + title?: undefined; + overlayStyle?: undefined; +} + +export interface IEllipsisProps { + tooltip?: boolean | IEllipsisTooltipProps; + length?: number; + lines?: number; + style?: React.CSSProperties; + className?: string; + fullWidthRecognition?: boolean; +} + +export function getStrFullLength(str: string): number; +export function cutStrByFullLength(str: string, maxLength: number): string; + +export default class Ellipsis extends React.Component {} diff --git a/src/components/Ellipsis/index.en-US.md b/src/components/Ellipsis/index.en-US.md new file mode 100644 index 0000000..15139cc --- /dev/null +++ b/src/components/Ellipsis/index.en-US.md @@ -0,0 +1,16 @@ +--- +title: Ellipsis +cols: 1 +order: 10 +--- + +When the text is too long, the Ellipsis automatically shortens it according to its length or the maximum number of lines. + +## API + +Property | Description | Type | Default +----|------|-----|------ +tooltip | tooltip for showing the full text content when hovering over | boolean | - +length | maximum number of characters in the text before being truncated | number | - +lines | maximum number of rows in the text before being truncated | number | `1` +fullWidthRecognition | whether consider full-width character length as 2 when calculate string length | boolean | - diff --git a/src/components/Ellipsis/index.js b/src/components/Ellipsis/index.js new file mode 100644 index 0000000..de700b7 --- /dev/null +++ b/src/components/Ellipsis/index.js @@ -0,0 +1,270 @@ +import React, { Component } from 'react'; +import { Tooltip } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +/* eslint react/no-did-mount-set-state: 0 */ +/* eslint no-param-reassign: 0 */ + +const isSupportLineClamp = document.body.style.webkitLineClamp !== undefined; + +const TooltipOverlayStyle = { + overflowWrap: 'break-word', + wordWrap: 'break-word', +}; + +export const getStrFullLength = (str = '') => + str.split('').reduce((pre, cur) => { + const charCode = cur.charCodeAt(0); + if (charCode >= 0 && charCode <= 128) { + return pre + 1; + } + return pre + 2; + }, 0); + +export const cutStrByFullLength = (str = '', maxLength) => { + let showLength = 0; + return str.split('').reduce((pre, cur) => { + const charCode = cur.charCodeAt(0); + if (charCode >= 0 && charCode <= 128) { + showLength += 1; + } else { + showLength += 2; + } + if (showLength <= maxLength) { + return pre + cur; + } + return pre; + }, ''); +}; + +const getTooltip = ({ tooltip, overlayStyle, title, children }) => { + if (tooltip) { + const props = tooltip === true ? { overlayStyle, title } : { ...tooltip, overlayStyle, title }; + return {children}; + } + return children; +}; + +const EllipsisText = ({ text, length, tooltip, fullWidthRecognition, ...other }) => { + if (typeof text !== 'string') { + throw new Error('Ellipsis children must be string.'); + } + const textLength = fullWidthRecognition ? getStrFullLength(text) : text.length; + if (textLength <= length || length < 0) { + return {text}; + } + const tail = '...'; + let displayText; + if (length - tail.length <= 0) { + displayText = ''; + } else { + displayText = fullWidthRecognition ? cutStrByFullLength(text, length) : text.slice(0, length); + } + + const spanAttrs = tooltip ? {} : { ...other }; + return getTooltip({ + tooltip, + overlayStyle: TooltipOverlayStyle, + title: text, + children: ( + + {displayText} + {tail} + + ), + }); +}; + +export default class Ellipsis extends Component { + state = { + text: '', + targetCount: 0, + }; + + componentDidMount() { + if (this.node) { + this.computeLine(); + } + } + + componentDidUpdate(perProps) { + const { lines } = this.props; + if (lines !== perProps.lines) { + this.computeLine(); + } + } + + computeLine = () => { + const { lines } = this.props; + if (lines && !isSupportLineClamp) { + const text = this.shadowChildren.innerText || this.shadowChildren.textContent; + const lineHeight = parseInt(getComputedStyle(this.root).lineHeight, 10); + const targetHeight = lines * lineHeight; + this.content.style.height = `${targetHeight}px`; + const totalHeight = this.shadowChildren.offsetHeight; + const shadowNode = this.shadow.firstChild; + + if (totalHeight <= targetHeight) { + this.setState({ + text, + targetCount: text.length, + }); + return; + } + + // bisection + const len = text.length; + const mid = Math.ceil(len / 2); + + const count = this.bisection(targetHeight, mid, 0, len, text, shadowNode); + + this.setState({ + text, + targetCount: count, + }); + } + }; + + bisection = (th, m, b, e, text, shadowNode) => { + const suffix = '...'; + let mid = m; + let end = e; + let begin = b; + shadowNode.innerHTML = text.substring(0, mid) + suffix; + let sh = shadowNode.offsetHeight; + + if (sh <= th) { + shadowNode.innerHTML = text.substring(0, mid + 1) + suffix; + sh = shadowNode.offsetHeight; + if (sh > th || mid === begin) { + return mid; + } + begin = mid; + if (end - begin === 1) { + mid = 1 + begin; + } else { + mid = Math.floor((end - begin) / 2) + begin; + } + return this.bisection(th, mid, begin, end, text, shadowNode); + } + if (mid - 1 < 0) { + return mid; + } + shadowNode.innerHTML = text.substring(0, mid - 1) + suffix; + sh = shadowNode.offsetHeight; + if (sh <= th) { + return mid - 1; + } + end = mid; + mid = Math.floor((end - begin) / 2) + begin; + return this.bisection(th, mid, begin, end, text, shadowNode); + }; + + handleRoot = n => { + this.root = n; + }; + + handleContent = n => { + this.content = n; + }; + + handleNode = n => { + this.node = n; + }; + + handleShadow = n => { + this.shadow = n; + }; + + handleShadowChildren = n => { + this.shadowChildren = n; + }; + + render() { + const { text, targetCount } = this.state; + const { + children, + lines, + length, + className, + tooltip, + fullWidthRecognition, + ...restProps + } = this.props; + + const cls = classNames(styles.ellipsis, className, { + [styles.lines]: lines && !isSupportLineClamp, + [styles.lineClamp]: lines && isSupportLineClamp, + }); + + if (!lines && !length) { + return ( + + {children} + + ); + } + + // length + if (!lines) { + return ( + + ); + } + + const id = `antd-pro-ellipsis-${`${new Date().getTime()}${Math.floor(Math.random() * 100)}`}`; + + // support document.body.style.webkitLineClamp + if (isSupportLineClamp) { + const style = `#${id}{-webkit-line-clamp:${lines};-webkit-box-orient: vertical;}`; + + const node = ( +
    + + {children} +
    + ); + + return getTooltip({ + tooltip, + overlayStyle: TooltipOverlayStyle, + title: children, + children: node, + }); + } + + const childNode = ( + + {targetCount > 0 && text.substring(0, targetCount)} + {targetCount > 0 && targetCount < text.length && '...'} + + ); + + return ( +
    +
    + {getTooltip({ + tooltip, + overlayStyle: TooltipOverlayStyle, + title: text, + children: childNode, + })} +
    + {children} +
    +
    + {text} +
    +
    +
    + ); + } +} diff --git a/src/components/Ellipsis/index.less b/src/components/Ellipsis/index.less new file mode 100644 index 0000000..3c0360c --- /dev/null +++ b/src/components/Ellipsis/index.less @@ -0,0 +1,24 @@ +.ellipsis { + display: inline-block; + width: 100%; + overflow: hidden; + word-break: break-all; +} + +.lines { + position: relative; + .shadow { + position: absolute; + z-index: -999; + display: block; + color: transparent; + opacity: 0; + } +} + +.lineClamp { + position: relative; + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/Ellipsis/index.test.js b/src/components/Ellipsis/index.test.js new file mode 100644 index 0000000..4d057b2 --- /dev/null +++ b/src/components/Ellipsis/index.test.js @@ -0,0 +1,13 @@ +import { getStrFullLength, cutStrByFullLength } from './index'; + +describe('test calculateShowLength', () => { + it('get full length', () => { + expect(getStrFullLength('一二,a,')).toEqual(8); + }); + it('cut str by full length', () => { + expect(cutStrByFullLength('一二,a,', 7)).toEqual('一二,a'); + }); + it('cut str when length small', () => { + expect(cutStrByFullLength('一22三', 5)).toEqual('一22'); + }); +}); diff --git a/src/components/Ellipsis/index.zh-CN.md b/src/components/Ellipsis/index.zh-CN.md new file mode 100644 index 0000000..f7a70ea --- /dev/null +++ b/src/components/Ellipsis/index.zh-CN.md @@ -0,0 +1,17 @@ +--- +title: Ellipsis +subtitle: 文本自动省略号 +cols: 1 +order: 10 +--- + +文本过长自动处理省略号,支持按照文本长度和最大行数两种方式截取。 + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +tooltip | 移动到文本展示完整内容的提示 | boolean | - +length | 在按照长度截取下的文本最大字符数,超过则截取省略 | number | - +lines | 在按照行数截取下最大的行数,超过则截取省略 | number | `1` +fullWidthRecognition | 是否将全角字符的长度视为2来计算字符串长度 | boolean | - diff --git a/src/components/Exception/demo/403.md b/src/components/Exception/demo/403.md new file mode 100644 index 0000000..bb46037 --- /dev/null +++ b/src/components/Exception/demo/403.md @@ -0,0 +1,29 @@ +--- +order: 2 +title: + zh-CN: 403 + en-US: 403 +--- + +## zh-CN + +403 页面,配合自定义操作。 + +## en-US + +403 page with custom operations. + +````jsx +import Exception from 'ant-design-pro/lib/Exception'; +import { Button } from 'antd'; + +const actions = ( +
    + + +
    +); +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Exception/demo/404.md b/src/components/Exception/demo/404.md new file mode 100644 index 0000000..db50de6 --- /dev/null +++ b/src/components/Exception/demo/404.md @@ -0,0 +1,22 @@ +--- +order: 0 +title: + zh-CN: 404 + en-US: 404 +--- + +## zh-CN + +404 页面。 + +## en-US + +404 page. + +````jsx +import Exception from 'ant-design-pro/lib/Exception'; + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Exception/demo/500.md b/src/components/Exception/demo/500.md new file mode 100644 index 0000000..096ca8e --- /dev/null +++ b/src/components/Exception/demo/500.md @@ -0,0 +1,22 @@ +--- +order: 1 +title: + zh-CN: 500 + en-US: 500 +--- + +## zh-CN + +500 页面。 + +## en-US + +500 page. + +````jsx +import Exception from 'ant-design-pro/lib/Exception'; + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Exception/index.d.ts b/src/components/Exception/index.d.ts new file mode 100644 index 0000000..a74abb1 --- /dev/null +++ b/src/components/Exception/index.d.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +export interface IExceptionProps { + type?: '403' | '404' | '500'; + title?: React.ReactNode; + desc?: React.ReactNode; + img?: string; + actions?: React.ReactNode; + linkElement?: string | React.ComponentType; + style?: React.CSSProperties; + className?: string; + backText?: React.ReactNode; + redirect?: string; +} + +export default class Exception extends React.Component {} diff --git a/src/components/Exception/index.en-US.md b/src/components/Exception/index.en-US.md new file mode 100644 index 0000000..37e7e80 --- /dev/null +++ b/src/components/Exception/index.en-US.md @@ -0,0 +1,20 @@ +--- +title: Exception +cols: 1 +order: 5 +--- + +Exceptions page is used to provide feedback on specific abnormal state. Usually, it contains an explanation of the error status, and provides users with suggestions or operations, to prevent users from feeling lost and confused. + +## API + +Property | Description | Type | Default +---------|-------------|------|-------- +| backText | default return button text | ReactNode | back to home | +type | type of exception, the corresponding default `title`, `desc`, `img` will be given if set, which can be overridden by explicit setting of `title`, `desc`, `img` | Enum {'403', '404', '500'} | - +title | title | ReactNode | - +desc | supplementary description | ReactNode | - +img | the url of background image | string | - +actions | suggested operations, a default 'Home' link will show if not set | ReactNode | - +linkElement | to specify the element of link | string\|ReactElement | 'a' +redirect | redirect path | string | '/' \ No newline at end of file diff --git a/src/components/Exception/index.js b/src/components/Exception/index.js new file mode 100644 index 0000000..2c7223c --- /dev/null +++ b/src/components/Exception/index.js @@ -0,0 +1,61 @@ +import React, { createElement } from 'react'; +import classNames from 'classnames'; +import { Button } from 'antd'; +import config from './typeConfig'; +import styles from './index.less'; + +class Exception extends React.PureComponent { + static defaultProps = { + backText: 'back to home', + redirect: '/', + }; + + constructor(props) { + super(props); + this.state = {}; + } + + render() { + const { + className, + backText, + linkElement = 'a', + type, + title, + desc, + img, + actions, + redirect, + ...rest + } = this.props; + const pageType = type in config ? type : '404'; + const clsString = classNames(styles.exception, className); + return ( +
    +
    +
    +
    +
    +

    {title || config[pageType].title}

    +
    {desc || config[pageType].desc}
    +
    + {actions || + createElement( + linkElement, + { + to: redirect, + href: redirect, + }, + + )} +
    +
    +
    + ); + } +} + +export default Exception; diff --git a/src/components/Exception/index.less b/src/components/Exception/index.less new file mode 100644 index 0000000..45a2844 --- /dev/null +++ b/src/components/Exception/index.less @@ -0,0 +1,89 @@ +@import '~antd/lib/style/themes/default.less'; + +.exception { + display: flex; + align-items: center; + height: 80%; + min-height: 500px; + + .imgBlock { + flex: 0 0 62.5%; + width: 62.5%; + padding-right: 152px; + zoom: 1; + &::before, + &::after { + content: ' '; + display: table; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } + } + + .imgEle { + float: right; + width: 100%; + max-width: 430px; + height: 360px; + background-repeat: no-repeat; + background-position: 50% 50%; + background-size: contain; + } + + .content { + flex: auto; + + h1 { + margin-bottom: 24px; + color: #434e59; + font-weight: 600; + font-size: 72px; + line-height: 72px; + } + + .desc { + margin-bottom: 16px; + color: @text-color-secondary; + font-size: 20px; + line-height: 28px; + } + + .actions { + button:not(:last-child) { + margin-right: 8px; + } + } + } +} + +@media screen and (max-width: @screen-xl) { + .exception { + .imgBlock { + padding-right: 88px; + } + } +} + +@media screen and (max-width: @screen-sm) { + .exception { + display: block; + text-align: center; + .imgBlock { + margin: 0 auto 24px; + padding-right: 0; + } + } +} + +@media screen and (max-width: @screen-xs) { + .exception { + .imgBlock { + margin-bottom: -24px; + overflow: hidden; + } + } +} diff --git a/src/components/Exception/index.zh-CN.md b/src/components/Exception/index.zh-CN.md new file mode 100644 index 0000000..2e64399 --- /dev/null +++ b/src/components/Exception/index.zh-CN.md @@ -0,0 +1,21 @@ +--- +title: Exception +subtitle: 异常 +cols: 1 +order: 5 +--- + +异常页用于对页面特定的异常状态进行反馈。通常,它包含对错误状态的阐述,并向用户提供建议或操作,避免用户感到迷失和困惑。 + +## API + +| 参数 | 说明| 类型 | 默认值 | +|-------------|------------------------------------------|-------------|-------| +| backText| 默认的返回按钮文本 | ReactNode| back to home | +| type| 页面类型,若配置,则自带对应类型默认的 `title`,`desc`,`img`,此默认设置可以被 `title`,`desc`,`img` 覆盖 | Enum {'403', '404', '500'} | - | +| title | 标题 | ReactNode| -| +| desc| 补充描述| ReactNode| -| +| img | 背景图片地址 | string| -| +| actions | 建议操作,配置此属性时默认的『返回首页』按钮不生效| ReactNode| -| +| linkElement | 定义链接的元素 | string\|ReactElement | 'a' | +| redirect | 返回按钮的跳转地址 | string | '/' diff --git a/src/components/Exception/typeConfig.js b/src/components/Exception/typeConfig.js new file mode 100644 index 0000000..b6e1ee5 --- /dev/null +++ b/src/components/Exception/typeConfig.js @@ -0,0 +1,19 @@ +const config = { + 403: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/wZcnGqRDyhPOEYFcZDnb.svg', + title: '403', + desc: '抱歉,你无权访问该页面', + }, + 404: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/KpnpchXsobRgLElEozzI.svg', + title: '404', + desc: '抱歉,你访问的页面不存在', + }, + 500: { + img: 'https://gw.alipayobjects.com/zos/rmsportal/RVRUAYdCGeYNBWoKiIwB.svg', + title: '500', + desc: '抱歉,服务器出错了', + }, +}; + +export default config; diff --git a/src/components/FooterToolbar/demo/basic.md b/src/components/FooterToolbar/demo/basic.md new file mode 100644 index 0000000..3043dbf --- /dev/null +++ b/src/components/FooterToolbar/demo/basic.md @@ -0,0 +1,44 @@ +--- +order: 0 +title: + zh-CN: 演示 + en-US: demo +iframe: 400 +--- + +## zh-CN + +浮动固定页脚。 + +## en-US + +Fixed to the footer. + +````jsx +import FooterToolbar from 'ant-design-pro/lib/FooterToolbar'; +import { Button } from 'antd'; + +ReactDOM.render( +
    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    +

    Content Content Content Content

    + + + + +
    +, mountNode); +```` \ No newline at end of file diff --git a/src/components/FooterToolbar/index.d.ts b/src/components/FooterToolbar/index.d.ts new file mode 100644 index 0000000..9c6ac5b --- /dev/null +++ b/src/components/FooterToolbar/index.d.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; +export interface IFooterToolbarProps { + extra: React.ReactNode; + style?: React.CSSProperties; +} + +export default class FooterToolbar extends React.Component {} diff --git a/src/components/FooterToolbar/index.en-US.md b/src/components/FooterToolbar/index.en-US.md new file mode 100644 index 0000000..69fd80b --- /dev/null +++ b/src/components/FooterToolbar/index.en-US.md @@ -0,0 +1,18 @@ +--- +title: FooterToolbar +cols: 1 +order: 6 +--- + +A toolbar fixed at the bottom. + +## Usage + +It is fixed at the bottom of the content area and does not move along with the scroll bar, which is usually used for data collection and submission for long pages. + +## API + +Property | Description | Type | Default +---------|-------------|------|-------- +children | toolbar content, align to the right | ReactNode | - +extra | extra information, align to the left | ReactNode | - \ No newline at end of file diff --git a/src/components/FooterToolbar/index.js b/src/components/FooterToolbar/index.js new file mode 100644 index 0000000..d43f72f --- /dev/null +++ b/src/components/FooterToolbar/index.js @@ -0,0 +1,47 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import styles from './index.less'; + +export default class FooterToolbar extends Component { + static contextTypes = { + isMobile: PropTypes.bool, + }; + + state = { + width: undefined, + }; + + componentDidMount() { + window.addEventListener('resize', this.resizeFooterToolbar); + this.resizeFooterToolbar(); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resizeFooterToolbar); + } + + resizeFooterToolbar = () => { + const sider = document.querySelector('.ant-layout-sider'); + if (sider == null) { + return; + } + const { isMobile } = this.context; + const width = isMobile ? null : `calc(100% - ${sider.style.width})`; + const { width: stateWidth } = this.state; + if (stateWidth !== width) { + this.setState({ width }); + } + }; + + render() { + const { children, className, extra, ...restProps } = this.props; + const { width } = this.state; + return ( +
    +
    {extra}
    +
    {children}
    +
    + ); + } +} diff --git a/src/components/FooterToolbar/index.less b/src/components/FooterToolbar/index.less new file mode 100644 index 0000000..5073cff --- /dev/null +++ b/src/components/FooterToolbar/index.less @@ -0,0 +1,33 @@ +@import '~antd/lib/style/themes/default.less'; + +.toolbar { + position: fixed; + right: 0; + bottom: 0; + z-index: 9; + width: 100%; + height: 56px; + padding: 0 24px; + line-height: 56px; + background: #fff; + border-top: 1px solid @border-color-split; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.03); + + &::after { + display: block; + clear: both; + content: ''; + } + + .left { + float: left; + } + + .right { + float: right; + } + + button + button { + margin-left: 8px; + } +} diff --git a/src/components/FooterToolbar/index.zh-CN.md b/src/components/FooterToolbar/index.zh-CN.md new file mode 100644 index 0000000..421ac08 --- /dev/null +++ b/src/components/FooterToolbar/index.zh-CN.md @@ -0,0 +1,19 @@ +--- +title: FooterToolbar +subtitle: 底部工具栏 +cols: 1 +order: 6 +--- + +固定在底部的工具栏。 + +## 何时使用 + +固定在内容区域的底部,不随滚动条移动,常用于长页面的数据搜集和提交工作。 + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +children | 工具栏内容,向右对齐 | ReactNode | - +extra | 额外信息,向左对齐 | ReactNode | - diff --git a/src/components/GlobalFooter/demo/basic.md b/src/components/GlobalFooter/demo/basic.md new file mode 100644 index 0000000..9a06bad --- /dev/null +++ b/src/components/GlobalFooter/demo/basic.md @@ -0,0 +1,37 @@ +--- +order: 0 +title: 演示 +iframe: 400 +--- + +基本页脚。 + +````jsx +import GlobalFooter from 'ant-design-pro/lib/GlobalFooter'; +import { Icon } from 'antd'; + +const links = [{ + key: '帮助', + title: '帮助', + href: '', +}, { + key: 'github', + title: , + href: 'https://github.com/ant-design/ant-design-pro', + blankTarget: true, +}, { + key: '条款', + title: '条款', + href: '', + blankTarget: true, +}]; + +const copyright =
    Copyright 2017 蚂蚁金服体验技术部出品
    ; + +ReactDOM.render( +
    +
    + +
    +, mountNode); +```` diff --git a/src/components/GlobalFooter/index.d.ts b/src/components/GlobalFooter/index.d.ts new file mode 100644 index 0000000..3fa5c42 --- /dev/null +++ b/src/components/GlobalFooter/index.d.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +export interface IGlobalFooterProps { + links?: Array<{ + key?: string; + title: React.ReactNode; + href: string; + blankTarget?: boolean; + }>; + copyright?: React.ReactNode; + style?: React.CSSProperties; +} + +export default class GlobalFooter extends React.Component {} diff --git a/src/components/GlobalFooter/index.js b/src/components/GlobalFooter/index.js new file mode 100644 index 0000000..1c2fb74 --- /dev/null +++ b/src/components/GlobalFooter/index.js @@ -0,0 +1,28 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +const GlobalFooter = ({ className, links, copyright }) => { + const clsString = classNames(styles.globalFooter, className); + return ( +
    + {links && ( +
    + {links.map(link => ( + + {link.title} + + ))} +
    + )} + {copyright &&
    {copyright}
    } +
    + ); +}; + +export default GlobalFooter; diff --git a/src/components/GlobalFooter/index.less b/src/components/GlobalFooter/index.less new file mode 100644 index 0000000..e4b3dfd --- /dev/null +++ b/src/components/GlobalFooter/index.less @@ -0,0 +1,29 @@ +@import '~antd/lib/style/themes/default.less'; + +.globalFooter { + margin: 48px 0 24px 0; + padding: 0 16px; + text-align: center; + + .links { + margin-bottom: 8px; + + a { + color: @text-color-secondary; + transition: all 0.3s; + + &:not(:last-child) { + margin-right: 40px; + } + + &:hover { + color: @text-color; + } + } + } + + .copyright { + color: @text-color-secondary; + font-size: @font-size-base; + } +} diff --git a/src/components/GlobalFooter/index.md b/src/components/GlobalFooter/index.md new file mode 100644 index 0000000..55b4be4 --- /dev/null +++ b/src/components/GlobalFooter/index.md @@ -0,0 +1,17 @@ +--- +title: + en-US: GlobalFooter + zh-CN: GlobalFooter +subtitle: 全局页脚 +cols: 1 +order: 7 +--- + +页脚属于全局导航的一部分,作为对顶部导航的补充,通过传递数据控制展示内容。 + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +links | 链接数据 | array<{ title: ReactNode, href: string, blankTarget?: boolean }> | - +copyright | 版权信息 | ReactNode | - diff --git a/src/components/GlobalHeader/RightContent.js b/src/components/GlobalHeader/RightContent.js new file mode 100644 index 0000000..4fe012e --- /dev/null +++ b/src/components/GlobalHeader/RightContent.js @@ -0,0 +1,218 @@ +import React, { PureComponent } from 'react'; +import { FormattedMessage, formatMessage } from 'umi/locale'; +import { Spin, Tag, Menu, Icon, Avatar, Tooltip } from 'antd'; +import moment from 'moment'; +import groupBy from 'lodash/groupBy'; +import NoticeIcon from '../NoticeIcon'; +import HeaderSearch from '../HeaderSearch'; +import HeaderDropdown from '../HeaderDropdown'; +import SelectLang from '../SelectLang'; +import styles from './index.less'; + +export default class GlobalHeaderRight extends PureComponent { + getNoticeData() { + const { notices = [] } = this.props; + if (notices.length === 0) { + return {}; + } + const newNotices = notices.map(notice => { + const newNotice = { ...notice }; + if (newNotice.datetime) { + newNotice.datetime = moment(notice.datetime).fromNow(); + } + if (newNotice.id) { + newNotice.key = newNotice.id; + } + if (newNotice.extra && newNotice.status) { + const color = { + todo: '', + processing: 'blue', + urgent: 'red', + doing: 'gold', + }[newNotice.status]; + newNotice.extra = ( + + {newNotice.extra} + + ); + } + return newNotice; + }); + return groupBy(newNotices, 'type'); + } + + getUnreadData = noticeData => { + const unreadMsg = {}; + Object.entries(noticeData).forEach(([key, value]) => { + if (!unreadMsg[key]) { + unreadMsg[key] = 0; + } + if (Array.isArray(value)) { + unreadMsg[key] = value.filter(item => !item.read).length; + } + }); + return unreadMsg; + }; + + changeReadState = clickedItem => { + const { id } = clickedItem; + const { dispatch } = this.props; + dispatch({ + type: 'global/changeNoticeReadState', + payload: id, + }); + }; + + fetchMoreNotices = tabProps => { + const { list, name } = tabProps; + const { dispatch, notices = [] } = this.props; + const lastItemId = notices[notices.length - 1].id; + dispatch({ + type: 'global/fetchMoreNotices', + payload: { + lastItemId, + type: name, + offset: list.length, + }, + }); + }; + + render() { + const { + currentUser, + fetchingMoreNotices, + fetchingNotices, + loadedAllNotices, + onNoticeVisibleChange, + onMenuClick, + onNoticeClear, + skeletonCount, + theme, + } = this.props; + const menu = ( + + + + + + + + + + + + + + + + ); + const loadMoreProps = { + skeletonCount, + loadedAll: loadedAllNotices, + loading: fetchingMoreNotices, + }; + const noticeData = this.getNoticeData(); + const unreadMsg = this.getUnreadData(noticeData); + let className = styles.right; + if (theme === 'dark') { + className = `${styles.right} ${styles.dark}`; + } + return ( +
    + { + console.log('input', value); // eslint-disable-line + }} + onPressEnter={value => { + console.log('enter', value); // eslint-disable-line + }} + /> + + + + + + { + console.log(item, tabProps); // eslint-disable-line + this.changeReadState(item, tabProps); + }} + locale={{ + emptyText: formatMessage({ id: 'component.noticeIcon.empty' }), + clear: formatMessage({ id: 'component.noticeIcon.clear' }), + loadedAll: formatMessage({ id: 'component.noticeIcon.loaded' }), + loadMore: formatMessage({ id: 'component.noticeIcon.loading-more' }), + }} + onClear={onNoticeClear} + onLoadMore={this.fetchMoreNotices} + onPopupVisibleChange={onNoticeVisibleChange} + loading={fetchingNotices} + clearClose + > + + + + + {currentUser.name ? ( + + + + {currentUser.name} + + + ) : ( + + )} + +
    + ); + } +} diff --git a/src/components/GlobalHeader/index.js b/src/components/GlobalHeader/index.js new file mode 100644 index 0000000..fe96cde --- /dev/null +++ b/src/components/GlobalHeader/index.js @@ -0,0 +1,41 @@ +import React, { PureComponent } from 'react'; +import { Icon } from 'antd'; +import Link from 'umi/link'; +import Debounce from 'lodash-decorators/debounce'; +import styles from './index.less'; +import RightContent from './RightContent'; + +export default class GlobalHeader extends PureComponent { + componentWillUnmount() { + this.triggerResizeEvent.cancel(); + } + /* eslint-disable*/ + @Debounce(600) + triggerResizeEvent() { + // eslint-disable-line + const event = document.createEvent('HTMLEvents'); + event.initEvent('resize', true, false); + window.dispatchEvent(event); + } + toggle = () => { + const { collapsed, onCollapse } = this.props; + onCollapse(!collapsed); + this.triggerResizeEvent(); + }; + render() { + const { collapsed, isMobile, logo } = this.props; + return ( +
    + {isMobile && ( + + logo + + )} + + + + +
    + ); + } +} diff --git a/src/components/GlobalHeader/index.less b/src/components/GlobalHeader/index.less new file mode 100644 index 0000000..e66510b --- /dev/null +++ b/src/components/GlobalHeader/index.less @@ -0,0 +1,130 @@ +@import '~antd/lib/style/themes/default.less'; + +@pro-header-hover-bg: rgba(0, 0, 0, 0.025); + +.header { + position: relative; + height: @layout-header-height; + padding: 0; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); +} + +.logo { + display: inline-block; + height: @layout-header-height; + padding: 0 0 0 24px; + font-size: 20px; + line-height: @layout-header-height; + vertical-align: top; + cursor: pointer; + img { + display: inline-block; + vertical-align: middle; + } +} + +.menu { + :global(.anticon) { + margin-right: 8px; + } + :global(.ant-dropdown-menu-item) { + min-width: 160px; + } +} + +.trigger { + height: @layout-header-height; + padding: ~'calc((@{layout-header-height} - 20px) / 2)' 24px; + font-size: 20px; + cursor: pointer; + transition: all 0.3s, padding 0s; + &:hover { + background: @pro-header-hover-bg; + } +} + +.right { + float: right; + height: 100%; + overflow: hidden; + .action { + display: inline-block; + height: 100%; + padding: 0 12px; + cursor: pointer; + transition: all 0.3s; + > i { + color: @text-color; + vertical-align: middle; + } + &:hover { + background: @pro-header-hover-bg; + } + &:global(.opened) { + background: @pro-header-hover-bg; + } + } + .search { + padding: 0 12px; + &:hover { + background: transparent; + } + } + .account { + .avatar { + margin: ~'calc((@{layout-header-height} - 24px) / 2)' 0; + margin-right: 8px; + color: @primary-color; + vertical-align: top; + background: rgba(255, 255, 255, 0.85); + } + } +} + +.dark { + height: @layout-header-height; + .action { + color: rgba(255, 255, 255, 0.85); + > i { + color: rgba(255, 255, 255, 0.85); + } + &:hover, + &:global(.opened) { + background: @primary-color; + } + :global(.ant-badge) { + color: rgba(255, 255, 255, 0.85); + } + } +} + +@media only screen and (max-width: @screen-md) { + .header { + :global(.ant-divider-vertical) { + vertical-align: unset; + } + .name { + display: none; + } + i.trigger { + padding: 22px 12px; + } + .logo { + position: relative; + padding-right: 12px; + padding-left: 12px; + } + .right { + position: absolute; + top: 0; + right: 12px; + background: #fff; + .account { + .avatar { + margin-right: 0; + } + } + } + } +} diff --git a/src/components/HeaderDropdown/index.js b/src/components/HeaderDropdown/index.js new file mode 100644 index 0000000..a19c471 --- /dev/null +++ b/src/components/HeaderDropdown/index.js @@ -0,0 +1,13 @@ +import React, { PureComponent } from 'react'; +import { Dropdown } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +export default class HeaderDropdown extends PureComponent { + render() { + const { overlayClassName, ...props } = this.props; + return ( + + ); + } +} diff --git a/src/components/HeaderDropdown/index.less b/src/components/HeaderDropdown/index.less new file mode 100644 index 0000000..ef0c759 --- /dev/null +++ b/src/components/HeaderDropdown/index.less @@ -0,0 +1,16 @@ +@import '~antd/lib/style/themes/default.less'; + +.container > * { + background-color: #fff; + border-radius: 4px; + box-shadow: @shadow-1-down; +} + +@media screen and (max-width: @screen-xs) { + .container { + width: 100% !important; + } + .container > * { + border-radius: 0 !important; + } +} diff --git a/src/components/HeaderSearch/demo/basic.md b/src/components/HeaderSearch/demo/basic.md new file mode 100644 index 0000000..2139207 --- /dev/null +++ b/src/components/HeaderSearch/demo/basic.md @@ -0,0 +1,34 @@ +--- +order: 0 +title: 全局搜索 +--- + +通常放置在导航工具条右侧。(点击搜索图标预览效果) + +````jsx +import HeaderSearch from 'ant-design-pro/lib/HeaderSearch'; + +ReactDOM.render( +
    + { + console.log('input', value); // eslint-disable-line + }} + onPressEnter={(value) => { + console.log('enter', value); // eslint-disable-line + }} + /> +
    +, mountNode); +```` diff --git a/src/components/HeaderSearch/index.d.ts b/src/components/HeaderSearch/index.d.ts new file mode 100644 index 0000000..d78fde4 --- /dev/null +++ b/src/components/HeaderSearch/index.d.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +export interface IHeaderSearchProps { + placeholder?: string; + dataSource?: string[]; + defaultOpen?: boolean; + open?: boolean; + onSearch?: (value: string) => void; + onChange?: (value: string) => void; + onVisibleChange?: (visible: boolean) => void; + onPressEnter?: (value: string) => void; + style?: React.CSSProperties; + className?: string; +} + +export default class HeaderSearch extends React.Component {} diff --git a/src/components/HeaderSearch/index.en-US.md b/src/components/HeaderSearch/index.en-US.md new file mode 100644 index 0000000..a1cf841 --- /dev/null +++ b/src/components/HeaderSearch/index.en-US.md @@ -0,0 +1,23 @@ +--- +title: + en-US: HeaderSearch + zh-CN: HeaderSearch +subtitle: Top search box +cols: 1 +order: 8 +--- + +Usually placed as an entry to the global search, placed on the right side of the navigation toolbar. + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +placeholder | placeholder text | string | - +dataSource | current list of prompts | string[] | - +onSearch | Callback when selecting an item or pressing Enter | function(value) | - +onChange | Enter a callback for the search text | function(value) | - +onPressEnter | Callback when pressing Enter | function(value) | - +onVisibleChange | Show or hide the callback of the text box | function(value) |- +defaultOpen | The input box is displayed for the first time. | boolean | false +open | The input box is displayed | booelan |false \ No newline at end of file diff --git a/src/components/HeaderSearch/index.js b/src/components/HeaderSearch/index.js new file mode 100644 index 0000000..04f8b38 --- /dev/null +++ b/src/components/HeaderSearch/index.js @@ -0,0 +1,145 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Input, Icon, AutoComplete } from 'antd'; +import classNames from 'classnames'; +import Debounce from 'lodash-decorators/debounce'; +import Bind from 'lodash-decorators/bind'; +import styles from './index.less'; + +export default class HeaderSearch extends PureComponent { + static propTypes = { + className: PropTypes.string, + placeholder: PropTypes.string, + onSearch: PropTypes.func, + onChange: PropTypes.func, + onPressEnter: PropTypes.func, + defaultActiveFirstOption: PropTypes.bool, + dataSource: PropTypes.array, + defaultOpen: PropTypes.bool, + onVisibleChange: PropTypes.func, + }; + + static defaultProps = { + defaultActiveFirstOption: false, + onPressEnter: () => {}, + onSearch: () => {}, + onChange: () => {}, + className: '', + placeholder: '', + dataSource: [], + defaultOpen: false, + onVisibleChange: () => {}, + }; + + static getDerivedStateFromProps(props) { + if ('open' in props) { + return { + searchMode: props.open, + }; + } + return null; + } + + constructor(props) { + super(props); + this.state = { + searchMode: props.defaultOpen, + value: '', + }; + } + + componentWillUnmount() { + clearTimeout(this.timeout); + } + + onKeyDown = e => { + if (e.key === 'Enter') { + const { onPressEnter } = this.props; + const { value } = this.state; + this.timeout = setTimeout(() => { + onPressEnter(value); // Fix duplicate onPressEnter + }, 0); + } + }; + + onChange = value => { + const { onSearch, onChange } = this.props; + this.setState({ value }); + if (onSearch) { + onSearch(value); + } + if (onChange) { + onChange(value); + } + }; + + enterSearchMode = () => { + const { onVisibleChange } = this.props; + onVisibleChange(true); + this.setState({ searchMode: true }, () => { + const { searchMode } = this.state; + if (searchMode) { + this.input.focus(); + } + }); + }; + + leaveSearchMode = () => { + this.setState({ + searchMode: false, + value: '', + }); + }; + + // NOTE: 不能小于500,如果长按某键,第一次触发auto repeat的间隔是500ms,小于500会导致触发2次 + @Bind() + @Debounce(500, { + leading: true, + trailing: false, + }) + debouncePressEnter() { + const { onPressEnter } = this.props; + const { value } = this.state; + onPressEnter(value); + } + + render() { + const { className, placeholder, open, ...restProps } = this.props; + const { searchMode, value } = this.state; + delete restProps.defaultOpen; // for rc-select not affected + const inputClass = classNames(styles.input, { + [styles.show]: searchMode, + }); + return ( + { + if (propertyName === 'width' && !searchMode) { + const { onVisibleChange } = this.props; + onVisibleChange(searchMode); + } + }} + > + + + { + this.input = node; + }} + aria-label={placeholder} + placeholder={placeholder} + onKeyDown={this.onKeyDown} + onBlur={this.leaveSearchMode} + /> + + + ); + } +} diff --git a/src/components/HeaderSearch/index.less b/src/components/HeaderSearch/index.less new file mode 100644 index 0000000..88fadec --- /dev/null +++ b/src/components/HeaderSearch/index.less @@ -0,0 +1,32 @@ +@import '~antd/lib/style/themes/default.less'; + +.headerSearch { + :global(.anticon-search) { + font-size: 16px; + cursor: pointer; + } + .input { + width: 0; + background: transparent; + border-radius: 0; + transition: width 0.3s, margin-left 0.3s; + :global(.ant-select-selection) { + background: transparent; + } + input { + padding-right: 0; + padding-left: 0; + border: 0; + box-shadow: none !important; + } + &, + &:hover, + &:focus { + border-bottom: 1px solid @border-color-base; + } + &.show { + width: 210px; + margin-left: 8px; + } + } +} diff --git a/src/components/HeaderSearch/index.zh-CN.md b/src/components/HeaderSearch/index.zh-CN.md new file mode 100644 index 0000000..a9a0475 --- /dev/null +++ b/src/components/HeaderSearch/index.zh-CN.md @@ -0,0 +1,23 @@ +--- +title: + en-US: HeaderSearch + zh-CN: HeaderSearch +subtitle: 顶部搜索框 +cols: 1 +order: 8 +--- + +通常作为全局搜索的入口,放置在导航工具条右侧。 + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +placeholder | 占位文字 | string | - +dataSource | 当前提示内容列表 | string[] | - +onSearch | 选择某项或按下回车时的回调 | function(value) | - +onChange | 输入搜索字符的回调 | function(value) | - +onPressEnter | 按下回车时的回调 | function(value) | - +onVisibleChange | 显示或隐藏文本框的回调 | function(value) |- +defaultOpen | 输入框首次显示是否显示 | boolean | false +open | 控制输入框是否显示 | booelan |false \ No newline at end of file diff --git a/src/components/IconPreview/IconData.js b/src/components/IconPreview/IconData.js new file mode 100644 index 0000000..98c76a8 --- /dev/null +++ b/src/components/IconPreview/IconData.js @@ -0,0 +1,371 @@ +export const iconData = [ + { + category: 'direction', + description: '方向性图标', + icons: [ + 'step-backward', + 'step-forward', + 'fast-backward', + 'fast-forward', + 'shrink', + 'arrows-alt', + 'down', + 'up', + 'left', + 'right', + 'caret-up', + 'caret-down', + 'caret-left', + 'caret-right', + 'up-circle', + 'down-circle', + 'left-circle', + 'right-circle', + 'double-right', + 'double-left', + 'vertical-left', + 'vertical-right', + 'forward', + 'backward', + 'rollback', + 'enter', + 'retweet', + 'swap', + 'swap-left', + 'swap-right', + 'arrow-up', + 'arrow-down', + 'arrow-left', + 'arrow-right', + 'play-circle', + 'up-square', + 'down-square', + 'left-square', + 'right-square', + 'login', + 'logout', + 'menu-fold', + 'menu-unfold', + 'border-bottom', + 'border-horizontal', + 'border-inner', + 'border-left', + 'border-right', + 'border-top', + 'border-verticle', + 'pic-center', + 'pic-left', + 'pic-right', + 'radius-bottomleft', + 'radius-bottomright', + 'radius-upleft', + 'fullscreen', + 'fullscreen-exit', + ], + }, + { + category: 'suggestion', + description: '提示建议性图标', + icons: [ + 'question', + 'question-circle', + 'plus', + 'plus-circle', + 'pause', + 'pause-circle', + 'minus', + 'minus-circle', + 'plus-square', + 'minus-square', + 'info', + 'info-circle', + 'exclamation', + 'exclamation-circle', + 'close', + 'close-circle', + 'close-square', + 'check', + 'check-circle', + 'check-square', + 'clock-circle', + 'warning', + 'issues-close', + 'stop', + ], + }, + { + category: 'edit', + description: '编辑类图标', + icons: [ + 'edit', + 'form', + 'copy', + 'scissor', + 'delete', + 'snippets', + 'diff', + 'highlight', + 'align-center', + 'align-left', + 'align-right', + 'bg-colors', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'redo', + 'undo', + 'zoom-in', + 'zoom-out', + 'font-colors', + 'font-size', + 'line-height', + 'colum-height', + 'dash', + 'small-dash', + 'sort-ascending', + 'sort-descending', + 'drag', + 'ordered-list', + 'radius-setting', + ], + }, + { + category: 'data:', + description: '数据类图标', + icons: [ + 'area-chart', + 'pie-chart', + 'bar-chart', + 'dot-chart', + 'line-chart', + 'radar-chart', + 'heat-map', + 'fall', + 'rise', + 'stock', + 'box-plot', + 'fund', + 'sliders', + ], + }, + { + category: 'other', + description: '网站通用图标', + icons: [ + 'lock', + 'unlock', + 'bars', + 'book', + 'calendar', + 'cloud', + 'cloud-download', + 'code', + 'copy', + 'credit-card', + 'delete', + 'desktop', + 'download', + 'ellipsis', + 'file', + 'file-text', + 'file-unknown', + 'file-pdf', + 'file-word', + 'file-excel', + 'file-jpg', + 'file-ppt', + 'file-markdown', + 'file-add', + 'folder', + 'folder-open', + 'folder-add', + 'hdd', + 'frown', + 'meh', + 'smile', + 'inbox', + 'laptop', + 'appstore', + 'link', + 'mail', + 'mobile', + 'notification', + 'paper-clip', + 'picture', + 'poweroff', + 'reload', + 'search', + 'setting', + 'share-alt', + 'shopping-cart', + 'tablet', + 'tag', + 'tags', + 'to-top', + 'upload', + 'user', + 'video-camera', + 'home', + 'loading', + 'loading-3-quarters', + 'cloud-upload', + 'star', + 'heart', + 'environment', + 'eye', + 'eye-invisible', + 'camera', + 'save', + 'team', + 'solution', + 'phone', + 'filter', + 'exception', + 'export', + 'customer-service', + 'qrcode', + 'scan', + 'like', + 'dislike', + 'message', + 'pay-circle', + 'calculator', + 'pushpin', + 'bulb', + 'select', + 'switcher', + 'rocket', + 'bell', + 'disconnect', + 'database', + 'compass', + 'barcode', + 'hourglass', + 'key', + 'flag', + 'layout', + 'printer', + 'sound', + 'usb', + 'skin', + 'tool', + 'sync', + 'wifi', + 'car', + 'schedule', + 'user-add', + 'user-delete', + 'usergroup-add', + 'usergroup-delete', + 'man', + 'woman', + 'shop', + 'gift', + 'idcard', + 'medicine-box', + 'red-envelope', + 'coffee', + 'copyright', + 'trademark', + 'safety', + 'wallet', + 'bank', + 'trophy', + 'contacts', + 'global', + 'shake', + 'api', + 'fork', + 'dashboard', + 'table', + 'profile', + 'alert', + 'audit', + 'branches', + 'build', + 'border', + 'crown', + 'experiment', + 'fire', + 'money-collect', + 'property-safety', + 'read', + 'reconciliation', + 'rest', + 'security-scan', + 'insurance', + 'interation', + 'safety-certificate', + 'project', + 'thunderbolt', + 'block', + 'cluster', + 'deployment-unit', + 'dollar', + 'euro', + 'pound', + 'file-done', + 'file-exclamation', + 'file-protect', + 'file-search', + 'file-sync', + 'gateway', + 'gold', + 'robot', + 'shopping', + ], + }, + { + category: 'logo', + description: '品牌和标识图标', + icons: [ + 'android', + 'apple', + 'windows', + 'ie', + 'chrome', + 'github', + 'aliwangwang', + 'dingding', + 'weibo-square', + 'weibo-circle', + 'taobao-circle', + 'html5', + 'weibo', + 'twitter', + 'wechat', + 'youtube', + 'alipay-circle', + 'taobao', + 'skype', + 'qq', + 'medium-workmark', + 'gitlab', + 'medium', + 'linkedin', + 'google-plus', + 'dropbox', + 'facebook', + 'codepen', + 'code-sandbox', + 'amazon', + 'google', + 'codepen-circle', + 'alipay', + 'ant-design', + 'aliyun', + 'zhihu', + 'slack', + 'slack-square', + 'behance', + 'behance-square', + 'dribbble', + 'dribbble-square', + 'instagram', + 'yuque', + 'alibaba', + 'yahoo', + ], + }, +]; diff --git a/src/components/IconPreview/index.js b/src/components/IconPreview/index.js new file mode 100644 index 0000000..3a18eb8 --- /dev/null +++ b/src/components/IconPreview/index.js @@ -0,0 +1,45 @@ +import { connect } from 'dva'; +import { Card, Col, Icon, Row, Tabs } from 'antd'; +import React, { PureComponent } from 'react'; +import styles from '../../layouts/Sword.less'; +import { iconData } from './IconData'; +import { MENU_SELECT_ICON } from '../../actions/menu'; + +const { TabPane } = Tabs; + +@connect(({ menu }) => ({ + menu, +})) +class IconPreview extends PureComponent { + handelClick = type => { + const { onCancel, dispatch } = this.props; + dispatch(MENU_SELECT_ICON(type.icon)); + onCancel(); + }; + + render() { + return ( + + {iconData.map(data => ( + + + + {data.icons.map(icon => ( + + { + this.handelClick({ icon }); + }} + /> + + ))} + + + + ))} + + ); + } +} +export default IconPreview; diff --git a/src/components/Login/LoginItem.d.ts b/src/components/Login/LoginItem.d.ts new file mode 100644 index 0000000..30a7a2d --- /dev/null +++ b/src/components/Login/LoginItem.d.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +export interface ILoginItemProps { + name?: string; + rules?: any[]; + style?: React.CSSProperties; + onGetCaptcha?: () => void; + placeholder?: string; + buttonText?: React.ReactNode; +} + +export class LoginItem extends React.Component {} diff --git a/src/components/Login/LoginItem.js b/src/components/Login/LoginItem.js new file mode 100644 index 0000000..6761f89 --- /dev/null +++ b/src/components/Login/LoginItem.js @@ -0,0 +1,198 @@ +import React, { Component } from 'react'; +import { Form, Input, Button, Row, Col } from 'antd'; +import { formatMessage } from 'umi/locale'; +import omit from 'omit.js'; +import styles from './index.less'; +import ItemMap from './map'; +import LoginContext from './loginContext'; +import { getCaptchaImage } from '../../services/user'; +import { setCaptchaKey } from '../../utils/authority'; + +const FormItem = Form.Item; + +class WrapFormItem extends Component { + static defaultProps = { + getCaptchaButtonText: 'captcha', + getCaptchaSecondText: 'second', + }; + + constructor(props) { + super(props); + this.state = { + count: 0, + // 默认白色背景 + image: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + }; + } + + componentWillMount() { + const { mode, type } = this.props; + if (type === 'Captcha' && mode === 'image') { + this.refreshCaptcha(); + } + } + + componentDidMount() { + const { updateActive, name } = this.props; + if (updateActive) { + updateActive(name); + } + } + + componentWillUnmount() { + // 清除计数器 + clearInterval(this.interval); + } + + refreshCaptcha = () => { + // 获取验证码 + getCaptchaImage().then(resp => { + const {data} = resp; + if (data.key) { + this.setState({ image: data.image }); + setCaptchaKey(data.key); + } + }); + }; + + onGetCaptcha = () => { + const { onGetCaptcha } = this.props; + const result = onGetCaptcha ? onGetCaptcha() : null; + if (result === false) { + return; + } + if (result instanceof Promise) { + result.then(this.runGetCaptchaCountDown); + } else { + this.runGetCaptchaCountDown(); + } + }; + + getFormItemOptions = ({ onChange, defaultValue, customprops, rules }) => { + const options = { + rules: rules || customprops.rules, + }; + if (onChange) { + options.onChange = onChange; + } + if (defaultValue) { + options.initialValue = defaultValue; + } + return options; + }; + + runGetCaptchaCountDown = () => { + const { countDown } = this.props; + let count = countDown || 59; + this.setState({ count }); + this.interval = setInterval(() => { + count -= 1; + this.setState({ count }); + if (count === 0) { + clearInterval(this.interval); + } + }, 1000); + }; + + render() { + const { count, image } = this.state; + + const { + form: { getFieldDecorator }, + } = this.props; + + // 这么写是为了防止restProps中 带入 onChange, defaultValue, rules props + const { + onChange, + customprops, + defaultValue, + rules, + name, + mode, + getCaptchaButtonText, + getCaptchaSecondText, + updateActive, + type, + ...restProps + } = this.props; + + // get getFieldDecorator props + const options = this.getFormItemOptions(this.props); + + const otherProps = restProps || {}; + if (type === 'Captcha') { + if (mode === 'mobile') { + const inputProps = omit(otherProps, ['onGetCaptcha', 'countDown']); + return ( + + + + {getFieldDecorator(name, options)()} + + + + + + + ); + } + if (mode === 'image') { + return ( + + + + {getFieldDecorator(name, options)( + + )} + + + captcha + + + + ); + } + } + return ( + + {getFieldDecorator(name, options)()} + + ); + } +} + +const LoginItem = {}; +Object.keys(ItemMap).forEach(key => { + const item = ItemMap[key]; + LoginItem[key] = props => ( + + {context => ( + + )} + + ); +}); + +export default LoginItem; diff --git a/src/components/Login/LoginSubmit.js b/src/components/Login/LoginSubmit.js new file mode 100644 index 0000000..4aebabf --- /dev/null +++ b/src/components/Login/LoginSubmit.js @@ -0,0 +1,17 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Button, Form } from 'antd'; +import styles from './index.less'; + +const FormItem = Form.Item; + +const LoginSubmit = ({ className, ...rest }) => { + const clsString = classNames(styles.submit, className); + return ( + +
    + ); +} diff --git a/src/components/NoticeIcon/NoticeList.less b/src/components/NoticeIcon/NoticeList.less new file mode 100644 index 0000000..fc566ad --- /dev/null +++ b/src/components/NoticeIcon/NoticeList.less @@ -0,0 +1,94 @@ +@import '~antd/lib/style/themes/default.less'; + +.list { + max-height: 400px; + overflow: auto; + &::-webkit-scrollbar { + display: none; + } + .item { + padding-right: 24px; + padding-left: 24px; + overflow: hidden; + cursor: pointer; + transition: all 0.3s; + + .meta { + width: 100%; + } + + .avatar { + margin-top: 4px; + background: #fff; + } + .iconElement { + font-size: 32px; + } + + &.read { + opacity: 0.4; + } + &:last-child { + border-bottom: 0; + } + &:hover { + background: @primary-1; + } + .title { + margin-bottom: 8px; + font-weight: normal; + } + .description { + font-size: 12px; + line-height: @line-height-base; + } + .datetime { + margin-top: 4px; + font-size: 12px; + line-height: @line-height-base; + } + .extra { + float: right; + margin-top: -1.5px; + margin-right: 0; + color: @text-color-secondary; + font-weight: normal; + } + } + .loadMore { + padding: 8px 0; + color: @primary-6; + text-align: center; + cursor: pointer; + &.loadedAll { + color: rgba(0, 0, 0, 0.25); + cursor: unset; + } + } +} + +.notFound { + padding: 73px 0 88px 0; + color: @text-color-secondary; + text-align: center; + img { + display: inline-block; + height: 76px; + margin-bottom: 16px; + } +} + +.clear { + height: 46px; + color: @text-color; + line-height: 46px; + text-align: center; + border-top: 1px solid @border-color-split; + border-radius: 0 0 @border-radius-base @border-radius-base; + cursor: pointer; + transition: all 0.3s; + + &:hover { + color: @heading-color; + } +} diff --git a/src/components/NoticeIcon/demo/basic.md b/src/components/NoticeIcon/demo/basic.md new file mode 100644 index 0000000..dc9afea --- /dev/null +++ b/src/components/NoticeIcon/demo/basic.md @@ -0,0 +1,12 @@ +--- +order: 1 +title: 通知图标 +--- + +通常用在导航工具栏上。 + +````jsx +import NoticeIcon from 'ant-design-pro/lib/NoticeIcon'; + +ReactDOM.render(, mountNode); +```` diff --git a/src/components/NoticeIcon/demo/popover.md b/src/components/NoticeIcon/demo/popover.md new file mode 100644 index 0000000..be6bb86 --- /dev/null +++ b/src/components/NoticeIcon/demo/popover.md @@ -0,0 +1,181 @@ +--- +order: 2 +title: 带浮层卡片 +--- + +点击展开通知卡片,展现多种类型的通知,通常放在导航工具栏。 + +````jsx +import NoticeIcon from 'ant-design-pro/lib/NoticeIcon'; +import moment from 'moment'; +import groupBy from 'lodash/groupBy'; +import { Tag } from 'antd'; + +const data = [{ + id: '000000001', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png', + title: '你收到了 14 份新周报', + datetime: '2017-08-09', + type: '通知', +}, { + id: '000000002', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png', + title: '你推荐的 曲妮妮 已通过第三轮面试', + datetime: '2017-08-08', + type: '通知', +}, { + id: '000000003', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png', + title: '这种模板可以区分多种通知类型', + datetime: '2017-08-07', + read: true, + type: '通知', +}, { + id: '000000004', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png', + title: '左侧图标用于区分不同的类型', + datetime: '2017-08-07', + type: '通知', +}, { + id: '000000005', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png', + title: '内容不要超过两行字,超出时自动截断', + datetime: '2017-08-07', + type: '通知', +}, { + id: '000000006', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg', + title: '曲丽丽 评论了你', + description: '描述信息描述信息描述信息', + datetime: '2017-08-07', + type: '消息', + clickClose: true, +}, { + id: '000000007', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg', + title: '朱偏右 回复了你', + description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像', + datetime: '2017-08-07', + type: '消息', + clickClose: true, +}, { + id: '000000008', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg', + title: '标题', + description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像', + datetime: '2017-08-07', + type: '消息', + clickClose: true, +}, { + id: '000000009', + title: '任务名称', + description: '任务需要在 2017-01-12 20:00 前启动', + extra: '未开始', + status: 'todo', + type: '待办', +}, { + id: '000000010', + title: '第三方紧急代码变更', + description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务', + extra: '马上到期', + status: 'urgent', + type: '待办', +}, { + id: '000000011', + title: '信息安全考试', + description: '指派竹尔于 2017-01-09 前完成更新并发布', + extra: '已耗时 8 天', + status: 'doing', + type: '待办', +}, { + id: '000000012', + title: 'ABCD 版本发布', + description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务', + extra: '进行中', + status: 'processing', + type: '待办', +}]; + +function onItemClick(item, tabProps) { + console.log(item, tabProps); +} + +function onClear(tabTitle) { + console.log(tabTitle); +} + +function getNoticeData(notices) { + if (notices.length === 0) { + return {}; + } + const newNotices = notices.map((notice) => { + const newNotice = { ...notice }; + if (newNotice.datetime) { + newNotice.datetime = moment(notice.datetime).fromNow(); + } + // transform id to item key + if (newNotice.id) { + newNotice.key = newNotice.id; + } + if (newNotice.extra && newNotice.status) { + const color = ({ + todo: '', + processing: 'blue', + urgent: 'red', + doing: 'gold', + })[newNotice.status]; + newNotice.extra = {newNotice.extra}; + } + return newNotice; + }); + return groupBy(newNotices, 'type'); +} + +const noticeData = getNoticeData(data); + +ReactDOM.render( +
    + + + + + +
    +, mountNode); +```` + +```css + +``` diff --git a/src/components/NoticeIcon/index.d.ts b/src/components/NoticeIcon/index.d.ts new file mode 100644 index 0000000..f7d6479 --- /dev/null +++ b/src/components/NoticeIcon/index.d.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import NoticeIconTab, { INoticeIconData } from './NoticeIconTab'; + +export interface INoticeIconProps { + count?: number; + bell?: React.ReactNode; + className?: string; + loading?: boolean; + onClear?: (tabName: string) => void; + onItemClick?: (item: INoticeIconData, tabProps: INoticeIconProps) => void; + onLoadMore?: (tabProps: INoticeIconProps) => void; + onTabChange?: (tabTile: string) => void; + style?: React.CSSProperties; + onPopupVisibleChange?: (visible: boolean) => void; + popupVisible?: boolean; + locale?: { + emptyText: string; + clear: string; + loadedAll: string; + loadMore: string; + }; + clearClose?: boolean; +} + +export default class NoticeIcon extends React.Component { + public static Tab: typeof NoticeIconTab; +} diff --git a/src/components/NoticeIcon/index.en-US.md b/src/components/NoticeIcon/index.en-US.md new file mode 100644 index 0000000..a5a5f21 --- /dev/null +++ b/src/components/NoticeIcon/index.en-US.md @@ -0,0 +1,52 @@ +--- +title: NoticeIcon +subtitle: Notification Menu +cols: 1 +order: 9 +--- + +用在导航工具栏上,作为整个产品统一的通知中心。 + +## API + +Property | Description | Type | Default +----|------|-----|------ +count | Total number of messages | number | - +bell | Change the bell Icon | ReactNode | `` +loading | Popup card loading status | boolean | `false` +onClear | Click to clear button the callback | function(tabName) | - +onItemClick | Click on the list item's callback | function(item, tabProps) | - +onLoadMore | Callback of click for loading more | function(tabProps, event) | - +onPopupVisibleChange | Popup Card Showing or Hiding Callbacks | function(visible) | - +onTabChange | Switching callbacks for tabs | function(tabTitle) | - +popupVisible | Popup card display state | boolean | - +locale | Default message text | Object | `{ emptyText: 'No notifications', clear: 'Clear', loadedAll: 'Loaded', loadMore: 'Loading more' }` +clearClose | Close menu after clear | boolean | `false` + +### NoticeIcon.Tab + +Property | Description | Type | Default +----|------|-----|------ +count | Unread messages count of this tab | number | list.length +emptyText | Message text when list is empty | ReactNode | - +emptyImage | Image when list is empty | string | - +list | List data, format refer to the following table | Array | `[]` +loadedAll | All messages have been loaded | boolean | `true` +loading | Loading status of this tab | boolean | `false` +name | identifier for message Tab | string | - +scrollToLoad | Scroll to load | boolean | `true` +skeletonCount | Number of skeleton when tab is loading | number | `5` +skeletonProps | Props of skeleton | SkeletonProps | `{}` +showClear | Clear button display status | boolean | `true` +title | header for message Tab | string | - + +### Tab data + +Property | Description | Type | Default +----|------|-----|------ +avatar | avatar img url | string \| ReactNode | - +title | title | ReactNode | - +description | description info | ReactNode | - +datetime | Timestamps | ReactNode | - +extra | Additional information in the upper right corner of the list item | ReactNode | - +clickClose | Close menu after clicking list item | boolean | `false` diff --git a/src/components/NoticeIcon/index.js b/src/components/NoticeIcon/index.js new file mode 100644 index 0000000..133819b --- /dev/null +++ b/src/components/NoticeIcon/index.js @@ -0,0 +1,159 @@ +import React, { PureComponent, Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import { Icon, Tabs, Badge, Spin } from 'antd'; +import classNames from 'classnames'; +import HeaderDropdown from '../HeaderDropdown'; +import List from './NoticeList'; +import styles from './index.less'; + +const { TabPane } = Tabs; + +export default class NoticeIcon extends PureComponent { + static Tab = TabPane; + + static defaultProps = { + onItemClick: () => {}, + onPopupVisibleChange: () => {}, + onTabChange: () => {}, + onClear: () => {}, + loading: false, + clearClose: false, + locale: { + emptyText: 'No notifications', + clear: 'Clear', + loadedAll: 'Loaded', + loadMore: 'Loading more', + }, + emptyImage: 'https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg', + }; + + state = { + visible: false, + }; + + onItemClick = (item, tabProps) => { + const { onItemClick } = this.props; + const { clickClose } = item; + onItemClick(item, tabProps); + if (clickClose) { + this.popover.click(); + } + }; + + onClear = name => { + const { onClear, clearClose } = this.props; + onClear(name); + if (clearClose) { + this.popover.click(); + } + }; + + onTabChange = tabType => { + const { onTabChange } = this.props; + onTabChange(tabType); + }; + + onLoadMore = (tabProps, event) => { + const { onLoadMore } = this.props; + onLoadMore(tabProps, event); + }; + + getNotificationBox() { + const { visible } = this.state; + const { children, loading, locale } = this.props; + if (!children) { + return null; + } + const panes = React.Children.map(children, child => { + const { + list, + title, + name, + count, + emptyText, + emptyImage, + showClear, + loadedAll, + scrollToLoad, + skeletonCount, + skeletonProps, + loading: tabLoading, + } = child.props; + const len = list && list.length ? list.length : 0; + const msgCount = count || count === 0 ? count : len; + const tabTitle = msgCount > 0 ? `${title} (${msgCount})` : title; + return ( + + this.onClear(name)} + onClick={item => this.onItemClick(item, child.props)} + onLoadMore={event => this.onLoadMore(child.props, event)} + scrollToLoad={scrollToLoad} + showClear={showClear} + skeletonCount={skeletonCount} + skeletonProps={skeletonProps} + title={title} + visible={visible} + /> + + ); + }); + return ( + + + + {panes} + + + + ); + } + + handleVisibleChange = visible => { + const { onPopupVisibleChange } = this.props; + this.setState({ visible }); + onPopupVisibleChange(visible); + }; + + render() { + const { className, count, popupVisible, bell } = this.props; + const { visible } = this.state; + const noticeButtonClass = classNames(className, styles.noticeButton); + const notificationBox = this.getNotificationBox(); + const NoticeBellIcon = bell || ; + const trigger = ( + + + {NoticeBellIcon} + + + ); + if (!notificationBox) { + return trigger; + } + const popoverProps = {}; + if ('popupVisible' in this.props) { + popoverProps.visible = popupVisible; + } + return ( + (this.popover = ReactDOM.findDOMNode(node))} // eslint-disable-line + > + {trigger} + + ); + } +} diff --git a/src/components/NoticeIcon/index.less b/src/components/NoticeIcon/index.less new file mode 100644 index 0000000..1c0593e --- /dev/null +++ b/src/components/NoticeIcon/index.less @@ -0,0 +1,31 @@ +@import '~antd/lib/style/themes/default.less'; + +.popover { + position: relative; + width: 336px; +} + +.noticeButton { + display: inline-block; + cursor: pointer; + transition: all 0.3s; +} +.icon { + padding: 4px; + vertical-align: middle; +} + +.badge { + font-size: 16px; +} + +.tabs { + :global { + .ant-tabs-nav-scroll { + text-align: center; + } + .ant-tabs-bar { + margin-bottom: 0; + } + } +} diff --git a/src/components/NoticeIcon/index.zh-CN.md b/src/components/NoticeIcon/index.zh-CN.md new file mode 100644 index 0000000..23dab22 --- /dev/null +++ b/src/components/NoticeIcon/index.zh-CN.md @@ -0,0 +1,52 @@ +--- +title: NoticeIcon +subtitle: 通知菜单 +cols: 1 +order: 9 +--- + +用在导航工具栏上,作为整个产品统一的通知中心。 + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +count | 图标上的消息总数 | number | - +bell | translate this please -> Change the bell Icon | ReactNode | `` +loading | 弹出卡片加载状态 | boolean | `false` +onClear | 点击清空按钮的回调 | function(tabName) | - +onItemClick | 点击列表项的回调 | function(item, tabProps) | - +onLoadMore | 加载更多的回调 | function(tabProps, event) | - +onPopupVisibleChange | 弹出卡片显隐的回调 | function(visible) | - +onTabChange | 切换页签的回调 | function(tabTitle) | - +popupVisible | 控制弹层显隐 | boolean | - +locale | 默认文案 | Object | `{ emptyText: 'No notifications', clear: 'Clear', loadedAll: 'Loaded', loadMore: 'Loading more' }` +clearClose | 点击清空按钮后关闭通知菜单 | boolean | `false` + +### NoticeIcon.Tab + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +count | 当前 Tab 未读消息数量 | number | list.length +emptyText | 针对每个 Tab 定制空数据文案 | ReactNode | - +emptyImage | 针对每个 Tab 定制空数据图片 | string | - +list | 列表数据,格式参照下表 | Array | `[]` +loadedAll | 已加载完所有消息 | boolean | `true` +loading | 当前 Tab 的加载状态 | boolean | `false` +name | 消息分类的标识符 | string | - +scrollToLoad | 允许滚动自加载 | boolean | `true` +skeletonCount | 加载时占位骨架的数量 | number | `5` +skeletonProps | 加载时占位骨架的属性 | SkeletonProps | `{}` +showClear | 是否显示清空按钮 | boolean | `true` +title | 消息分类的页签标题 | string | - + +### Tab data + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +avatar | 头像图片链接 | string \| ReactNode | - +title | 标题 | ReactNode | - +description | 描述信息 | ReactNode | - +datetime | 时间戳 | ReactNode | - +extra | 额外信息,在列表项右上角 | ReactNode | - +clickClose | 点击列表项关闭通知菜单 | boolean | `false` diff --git a/src/components/NumberInfo/demo/basic.md b/src/components/NumberInfo/demo/basic.md new file mode 100644 index 0000000..b399655 --- /dev/null +++ b/src/components/NumberInfo/demo/basic.md @@ -0,0 +1,30 @@ +--- +order: 0 +title: + zh-CN: 演示 + en-US: Demo +--- + +## zh-CN + +各种数据文案的展现方式。 + +## en-US + +Used for presenting various numerical data. + +````jsx +import NumberInfo from 'ant-design-pro/lib/NumberInfo'; +import numeral from 'numeral'; + +ReactDOM.render( +
    + Visits this week} + total={numeral(12321).format('0,0')} + status="up" + subTotal={17.1} + /> +
    +, mountNode); +```` diff --git a/src/components/NumberInfo/index.d.ts b/src/components/NumberInfo/index.d.ts new file mode 100644 index 0000000..ca93ba5 --- /dev/null +++ b/src/components/NumberInfo/index.d.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +export interface INumberInfoProps { + title?: React.ReactNode | string; + subTitle?: React.ReactNode | string; + total?: React.ReactNode | string; + status?: 'up' | 'down'; + theme?: string; + gap?: number; + subTotal?: number; + style?: React.CSSProperties; +} + +export default class NumberInfo extends React.Component {} diff --git a/src/components/NumberInfo/index.en-US.md b/src/components/NumberInfo/index.en-US.md new file mode 100644 index 0000000..b82afbe --- /dev/null +++ b/src/components/NumberInfo/index.en-US.md @@ -0,0 +1,19 @@ +--- +title: NumberInfo +cols: 1 +order: 10 +--- + +Often used in data cards for highlighting the business data. + +## API + +Property | Description | Type | Default +----|------|-----|------ +title | title | ReactNode\|string | - +subTitle | subtitle | ReactNode\|string | - +total | total amount | ReactNode\|string | - +subTotal | total amount of additional information | ReactNode\|string | - +status | increase state | 'up \| down' | - +theme | state style | string | 'light' +gap | set the spacing (pixels) between numbers and descriptions | number | 8 diff --git a/src/components/NumberInfo/index.js b/src/components/NumberInfo/index.js new file mode 100644 index 0000000..717aee9 --- /dev/null +++ b/src/components/NumberInfo/index.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { Icon } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +const NumberInfo = ({ theme, title, subTitle, total, subTotal, status, suffix, gap, ...rest }) => ( +
    + {title && ( +
    + {title} +
    + )} + {subTitle && ( +
    + {subTitle} +
    + )} +
    + + {total} + {suffix && {suffix}} + + {(status || subTotal) && ( + + {subTotal} + {status && } + + )} +
    +
    +); + +export default NumberInfo; diff --git a/src/components/NumberInfo/index.less b/src/components/NumberInfo/index.less new file mode 100644 index 0000000..4a77288 --- /dev/null +++ b/src/components/NumberInfo/index.less @@ -0,0 +1,68 @@ +@import '~antd/lib/style/themes/default.less'; + +.numberInfo { + .suffix { + margin-left: 4px; + color: @text-color; + font-size: 16px; + font-style: normal; + } + .numberInfoTitle { + margin-bottom: 16px; + color: @text-color; + font-size: @font-size-lg; + transition: all 0.3s; + } + .numberInfoSubTitle { + height: 22px; + overflow: hidden; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + } + .numberInfoValue { + margin-top: 4px; + overflow: hidden; + font-size: 0; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + & > span { + display: inline-block; + height: 32px; + margin-right: 32px; + color: @heading-color; + font-size: 24px; + line-height: 32px; + } + .subTotal { + margin-right: 0; + color: @text-color-secondary; + font-size: @font-size-lg; + vertical-align: top; + i { + margin-left: 4px; + font-size: 12px; + transform: scale(0.82); + } + :global { + .anticon-caret-up { + color: @red-6; + } + .anticon-caret-down { + color: @green-6; + } + } + } + } +} +.numberInfolight { + .numberInfoValue { + & > span { + color: @text-color; + } + } +} diff --git a/src/components/NumberInfo/index.zh-CN.md b/src/components/NumberInfo/index.zh-CN.md new file mode 100644 index 0000000..7198539 --- /dev/null +++ b/src/components/NumberInfo/index.zh-CN.md @@ -0,0 +1,20 @@ +--- +title: NumberInfo +subtitle: 数据文本 +cols: 1 +order: 10 +--- + +常用在数据卡片中,用于突出展示某个业务数据。 + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +title | 标题 | ReactNode\|string | - +subTitle | 子标题 | ReactNode\|string | - +total | 总量 | ReactNode\|string | - +subTotal | 子总量 | ReactNode\|string | - +status | 增加状态 | 'up \| down' | - +theme | 状态样式 | string | 'light' +gap | 设置数字和描述之间的间距(像素)| number | 8 diff --git a/src/components/PageHeader/breadcrumb.d.ts b/src/components/PageHeader/breadcrumb.d.ts new file mode 100644 index 0000000..cfed402 --- /dev/null +++ b/src/components/PageHeader/breadcrumb.d.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { IPageHeaderProps } from './index'; + +export default class BreadcrumbView extends React.Component {} + +export function getBreadcrumb(breadcrumbNameMap: object, url: string): object; diff --git a/src/components/PageHeader/breadcrumb.js b/src/components/PageHeader/breadcrumb.js new file mode 100644 index 0000000..e6afcb5 --- /dev/null +++ b/src/components/PageHeader/breadcrumb.js @@ -0,0 +1,178 @@ +import React, { PureComponent, createElement } from 'react'; +import pathToRegexp from 'path-to-regexp'; +import { Breadcrumb } from 'antd'; +import styles from './index.less'; +import { urlToList } from '../_utils/pathTools'; + +export const getBreadcrumb = (breadcrumbNameMap, url) => { + let breadcrumb = breadcrumbNameMap[url]; + if (!breadcrumb) { + Object.keys(breadcrumbNameMap).forEach(item => { + if (pathToRegexp(item).test(url)) { + breadcrumb = breadcrumbNameMap[item]; + } + }); + } + return breadcrumb || {}; +}; + +export default class BreadcrumbView extends PureComponent { + state = { + breadcrumb: null, + }; + + componentDidMount() { + this.getBreadcrumbDom(); + } + + componentDidUpdate(preProps) { + const { location } = this.props; + if (!location || !preProps.location) { + return; + } + const prePathname = preProps.location.pathname; + if (prePathname !== location.pathname) { + this.getBreadcrumbDom(); + } + } + + getBreadcrumbDom = () => { + const breadcrumb = this.conversionBreadcrumbList(); + this.setState({ + breadcrumb, + }); + }; + + getBreadcrumbProps = () => { + const { routes, params, location, breadcrumbNameMap } = this.props; + return { + routes, + params, + routerLocation: location, + breadcrumbNameMap, + }; + }; + + // Generated according to props + conversionFromProps = () => { + const { breadcrumbList, breadcrumbSeparator, itemRender, linkElement = 'a' } = this.props; + return ( + + {breadcrumbList.map(item => { + const title = itemRender ? itemRender(item) : item.title; + return ( + + {item.href + ? createElement( + linkElement, + { + [linkElement === 'a' ? 'href' : 'to']: item.href, + }, + title + ) + : title} + + ); + })} + + ); + }; + + conversionFromLocation = (routerLocation, breadcrumbNameMap) => { + const { breadcrumbSeparator, home, itemRender, linkElement = 'a' } = this.props; + // Convert the url to an array + const pathSnippets = urlToList(routerLocation.pathname); + // Loop data mosaic routing + const extraBreadcrumbItems = pathSnippets.map((url, index) => { + const currentBreadcrumb = getBreadcrumb(breadcrumbNameMap, url); + if (currentBreadcrumb.inherited) { + return null; + } + const isLinkable = index !== pathSnippets.length - 1 && currentBreadcrumb.component; + const name = itemRender ? itemRender(currentBreadcrumb) : currentBreadcrumb.name; + return currentBreadcrumb.name && !currentBreadcrumb.hideInBreadcrumb ? ( + + {createElement( + isLinkable ? linkElement : 'span', + { [linkElement === 'a' ? 'href' : 'to']: url }, + name + )} + + ) : null; + }); + // Add home breadcrumbs to your head if defined + if (home) { + extraBreadcrumbItems.unshift( + + {createElement( + linkElement, + { + [linkElement === 'a' ? 'href' : 'to']: '/', + }, + home + )} + + ); + } + return ( + + {extraBreadcrumbItems} + + ); + }; + + /** + * 将参数转化为面包屑 + * Convert parameters into breadcrumbs + */ + conversionBreadcrumbList = () => { + const { breadcrumbList, breadcrumbSeparator } = this.props; + const { routes, params, routerLocation, breadcrumbNameMap } = this.getBreadcrumbProps(); + if (breadcrumbList && breadcrumbList.length) { + return this.conversionFromProps(); + } + // 如果传入 routes 和 params 属性 + // If pass routes and params attributes + if (routes && params) { + return ( + route.breadcrumbName)} + params={params} + itemRender={this.itemRender} + separator={breadcrumbSeparator} + /> + ); + } + // 根据 location 生成 面包屑 + // Generate breadcrumbs based on location + if (routerLocation && routerLocation.pathname) { + return this.conversionFromLocation(routerLocation, breadcrumbNameMap); + } + return null; + }; + + // 渲染Breadcrumb 子节点 + // Render the Breadcrumb child node + itemRender = (route, params, routes, paths) => { + const { linkElement = 'a' } = this.props; + const last = routes.indexOf(route) === routes.length - 1; + return last || !route.component ? ( + {route.breadcrumbName} + ) : ( + createElement( + linkElement, + { + href: paths.join('/') || '/', + to: paths.join('/') || '/', + }, + route.breadcrumbName + ) + ); + }; + + render() { + const { breadcrumb } = this.state; + return breadcrumb; + } +} diff --git a/src/components/PageHeader/demo/image.md b/src/components/PageHeader/demo/image.md new file mode 100644 index 0000000..511bac5 --- /dev/null +++ b/src/components/PageHeader/demo/image.md @@ -0,0 +1,75 @@ +--- +order: 2 +title: With Image +--- + +带图片的页头。 + +````jsx +import PageHeader from 'ant-design-pro/lib/PageHeader'; + +const content = ( +
    +

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

    + +
    +); + +const extra = ( +
    + +
    +); + +const breadcrumbList = [{ + title: '一级菜单', + href: '/', +}, { + title: '二级菜单', + href: '/', +}, { + title: '三级菜单', +}]; + +ReactDOM.render( +
    + +
    +, mountNode); +```` + + diff --git a/src/components/PageHeader/demo/simple.md b/src/components/PageHeader/demo/simple.md new file mode 100644 index 0000000..d0ad1f7 --- /dev/null +++ b/src/components/PageHeader/demo/simple.md @@ -0,0 +1,32 @@ +--- +order: 3 +title: Simple +--- + +简单的页头。 + +````jsx +import PageHeader from 'ant-design-pro/lib/PageHeader'; + +const breadcrumbList = [{ + title: '一级菜单', + href: '/', +}, { + title: '二级菜单', + href: '/', +}, { + title: '三级菜单', +}]; + +ReactDOM.render( +
    + +
    +, mountNode); +```` + + diff --git a/src/components/PageHeader/demo/standard.md b/src/components/PageHeader/demo/standard.md new file mode 100644 index 0000000..5c59c93 --- /dev/null +++ b/src/components/PageHeader/demo/standard.md @@ -0,0 +1,102 @@ +--- +order: 1 +title: Standard +--- + +标准页头。 + +````jsx +import PageHeader from 'ant-design-pro/lib/PageHeader'; +import DescriptionList from 'ant-design-pro/lib/DescriptionList'; +import { Button, Menu, Dropdown, Icon, Row, Col } from 'antd'; + +const { Description } = DescriptionList; +const ButtonGroup = Button.Group; + +const description = ( + + 曲丽丽 + XX 服务 + 2017-07-07 + 12421 + +); + +const menu = ( + + 选项一 + 选项二 + 选项三 + +); + +const action = ( +
    + + + + + + + + +
    +); + +const extra = ( + + +
    状态
    +
    待审批
    + + +
    订单金额
    +
    ¥ 568.08
    + +
    +); + +const breadcrumbList = [{ + title: '一级菜单', + href: '/', +}, { + title: '二级菜单', + href: '/', +}, { + title: '三级菜单', +}]; + +const tabList = [{ + key: 'detail', + tab: '详情', +}, { + key: 'rule', + tab: '规则', +}]; + +function onTabChange(key) { + console.log(key); +} + +ReactDOM.render( +
    + } + action={action} + content={description} + extraContent={extra} + breadcrumbList={breadcrumbList} + tabList={tabList} + tabActiveKey="detail" + onTabChange={onTabChange} + /> +
    +, mountNode); +```` + + diff --git a/src/components/PageHeader/demo/structure.md b/src/components/PageHeader/demo/structure.md new file mode 100644 index 0000000..429eed6 --- /dev/null +++ b/src/components/PageHeader/demo/structure.md @@ -0,0 +1,68 @@ +--- +order: 0 +title: Structure +--- + +基本结构,具备响应式布局功能,主要断点为 768px 和 576px,拖动窗口改变大小试试看。 + +````jsx +import PageHeader from 'ant-design-pro/lib/PageHeader'; + +const breadcrumbList = [{ + title: '面包屑', +}]; + +const tabList = [{ + key: '1', + tab: '页签一', +}, { + key: '2', + tab: '页签二', +}, { + key: '3', + tab: '页签三', +}]; + +ReactDOM.render( +
    + Title
    } + logo={
    logo
    } + action={
    action
    } + content={
    content
    } + extraContent={
    extraContent
    } + breadcrumbList={breadcrumbList} + tabList={tabList} + tabActiveKey="1" + /> +
    +, mountNode); +```` + + diff --git a/src/components/PageHeader/index.d.ts b/src/components/PageHeader/index.d.ts new file mode 100644 index 0000000..eacbb2d --- /dev/null +++ b/src/components/PageHeader/index.d.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +export interface IPageHeaderProps { + title?: React.ReactNode | string; + logo?: React.ReactNode | string; + action?: React.ReactNode | string; + content?: React.ReactNode; + extraContent?: React.ReactNode; + routes?: any[]; + params?: any; + breadcrumbList?: Array<{ title: React.ReactNode; href?: string }>; + tabList?: Array<{ key: string; tab: React.ReactNode }>; + tabActiveKey?: string; + tabDefaultActiveKey?: string; + onTabChange?: (key: string) => void; + tabBarExtraContent?: React.ReactNode; + linkElement?: React.ReactNode; + style?: React.CSSProperties; + home?: React.ReactNode; + wide?: boolean; + hiddenBreadcrumb?: boolean; +} + +export default class PageHeader extends React.Component {} diff --git a/src/components/PageHeader/index.js b/src/components/PageHeader/index.js new file mode 100644 index 0000000..f9813e1 --- /dev/null +++ b/src/components/PageHeader/index.js @@ -0,0 +1,84 @@ +import React, { PureComponent } from 'react'; +import { Tabs, Skeleton } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; +import BreadcrumbView from './breadcrumb'; + +const { TabPane } = Tabs; +export default class PageHeader extends PureComponent { + onChange = key => { + const { onTabChange } = this.props; + if (onTabChange) { + onTabChange(key); + } + }; + + render() { + const { + title, + logo, + action, + content, + extraContent, + tabList, + className, + tabActiveKey, + tabDefaultActiveKey, + tabBarExtraContent, + loading = false, + wide = false, + hiddenBreadcrumb = false, + } = this.props; + + const clsString = classNames(styles.pageHeader, className); + const activeKeyProps = {}; + if (tabDefaultActiveKey !== undefined) { + activeKeyProps.defaultActiveKey = tabDefaultActiveKey; + } + if (tabActiveKey !== undefined) { + activeKeyProps.activeKey = tabActiveKey; + } + return ( +
    +
    + + {hiddenBreadcrumb ? null : } +
    + {logo &&
    {logo}
    } +
    + {title && ( +
    +

    {title}

    + {action &&
    {action}
    } +
    + )} +
    + {content &&
    {content}
    } + {extraContent &&
    {extraContent}
    } +
    +
    +
    + {tabList && tabList.length ? ( + + {tabList.map(item => ( + + ))} + + ) : null} +
    +
    +
    + ); + } +} diff --git a/src/components/PageHeader/index.less b/src/components/PageHeader/index.less new file mode 100644 index 0000000..81125bb --- /dev/null +++ b/src/components/PageHeader/index.less @@ -0,0 +1,161 @@ +@import '~antd/lib/style/themes/default.less'; + +.pageHeader { + padding: 16px 32px 0 32px; + background: @component-background; + border-bottom: @border-width-base @border-style-base @border-color-split; + .wide { + max-width: 1200px; + margin: auto; + } + .detail { + display: flex; + } + + .row { + display: flex; + width: 100%; + } + + .breadcrumb { + margin-bottom: 16px; + } + + .tabs { + margin: 0 0 0 -8px; + + :global { + // 1px 可以让选中效果显示完成 + .ant-tabs-bar { + margin-bottom: 1px; + border-bottom: none; + } + } + } + + .logo { + flex: 0 1 auto; + margin-right: 16px; + padding-top: 1px; + > img { + display: block; + width: 28px; + height: 28px; + border-radius: @border-radius-base; + } + } + + .title { + color: @heading-color; + font-weight: 500; + font-size: 20px; + } + + .action { + min-width: 266px; + margin-left: 56px; + + :global { + .ant-btn-group:not(:last-child), + .ant-btn:not(:last-child) { + margin-right: 8px; + } + + .ant-btn-group > .ant-btn { + margin-right: 0; + } + } + } + + .title, + .content { + flex: auto; + } + + .action, + .extraContent, + .main { + flex: 0 1 auto; + } + + .main { + width: 100%; + } + + .title, + .action { + margin-bottom: 16px; + } + + .logo, + .content, + .extraContent { + margin-bottom: 16px; + } + + .action, + .extraContent { + text-align: right; + } + + .extraContent { + min-width: 242px; + margin-left: 88px; + } +} + +@media screen and (max-width: @screen-xl) { + .pageHeader { + .extraContent { + margin-left: 44px; + } + } +} + +@media screen and (max-width: @screen-lg) { + .pageHeader { + .extraContent { + margin-left: 20px; + } + } +} + +@media screen and (max-width: @screen-md) { + .pageHeader { + .row { + display: block; + } + + .action, + .extraContent { + margin-left: 0; + text-align: left; + } + } +} + +@media screen and (max-width: @screen-sm) { + .pageHeader { + .detail { + display: block; + } + } +} + +@media screen and (max-width: @screen-xs) { + .pageHeader { + .action { + :global { + .ant-btn-group, + .ant-btn { + display: block; + margin-bottom: 8px; + } + .ant-btn-group > .ant-btn { + display: inline-block; + margin-bottom: 0; + } + } + } + } +} diff --git a/src/components/PageHeader/index.md b/src/components/PageHeader/index.md new file mode 100644 index 0000000..e82c8b8 --- /dev/null +++ b/src/components/PageHeader/index.md @@ -0,0 +1,36 @@ +--- +title: + en-US: PageHeader + zh-CN: PageHeader +subtitle: 页头 +cols: 1 +order: 11 +--- + +页头用来声明页面的主题,包含了用户所关注的最重要的信息,使用户可以快速理解当前页面是什么以及它的功能。 + +## API + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| title | title 区域 | ReactNode | - | +| logo | logo区域 | ReactNode | - | +| action | 操作区,位于 title 行的行尾 | ReactNode | - | +| home | 默认的主页说明文字 | ReactNode | - | +| content | 内容区 | ReactNode | - | +| extraContent | 额外内容区,位于content的右侧 | ReactNode | - | +| breadcrumbList | 面包屑数据,配置了此属性时 `routes` `params` `location` `breadcrumbNameMap` 无效 | array<{title: ReactNode, href?: string}> | - | +| hiddenBreadcrumb |隐藏面包屑 | boolean | false | +| routes | 面包屑相关属性,router 的路由栈信息 | object[] | - | +| params | 面包屑相关属性,路由的参数 | object | - | +| location | 面包屑相关属性,当前的路由信息 | object | - | +| breadcrumbNameMap | 面包屑相关属性,路由的地址-名称映射表 | object | - | +| tabList | tab 标题列表 | array<{key: string, tab: ReactNode}> | - | +| tabActiveKey | 当前高亮的 tab 项 | string | - | +| tabDefaultActiveKey | 默认高亮的 tab 项 | string | 第一项 | +| wide | 是否定宽 | boolean | false | +| onTabChange | 切换面板的回调 | (key) => void | - | +| itemRender | 自定义节点方法 | (menuItem) => ReactNode | - | +| linkElement | 定义链接的元素,默认为 `a`,可传入 react-router 的 Link | string\|ReactElement | - | + +> 面包屑的配置方式有三种,一是直接配置 `breadcrumbList`,二是结合 `react-router@2` `react-router@3`,配置 `routes` 及 `params` 实现,类似 [面包屑 Demo](https://ant.design/components/breadcrumb-cn/#components-breadcrumb-demo-router),三是结合 `react-router@4`,配置 `location` `breadcrumbNameMap`,优先级依次递减,脚手架中使用最后一种。 对于后两种用法,你也可以将 `routes` `params` 及 `location` `breadcrumbNameMap` 放到 context 中,组件会自动获取。 diff --git a/src/components/PageHeader/index.test.js b/src/components/PageHeader/index.test.js new file mode 100644 index 0000000..d22706e --- /dev/null +++ b/src/components/PageHeader/index.test.js @@ -0,0 +1,43 @@ +import { getBreadcrumb } from './breadcrumb'; +import { urlToList } from '../_utils/pathTools'; + +const routerData = { + '/dashboard/analysis': { + name: '分析页', + }, + '/userinfo': { + name: '用户列表', + }, + '/userinfo/:id': { + name: '用户信息', + }, + '/userinfo/:id/addr': { + name: '收货订单', + }, +}; +describe('test getBreadcrumb', () => { + it('Simple url', () => { + expect(getBreadcrumb(routerData, '/dashboard/analysis').name).toEqual('分析页'); + }); + it('Parameters url', () => { + expect(getBreadcrumb(routerData, '/userinfo/2144').name).toEqual('用户信息'); + }); + it('The middle parameter url', () => { + expect(getBreadcrumb(routerData, '/userinfo/2144/addr').name).toEqual('收货订单'); + }); + it('Loop through the parameters', () => { + const urlNameList = urlToList('/userinfo/2144/addr').map( + url => getBreadcrumb(routerData, url).name + ); + expect(urlNameList).toEqual(['用户列表', '用户信息', '收货订单']); + }); + + it('a path', () => { + const urlNameList = urlToList('/userinfo').map(url => getBreadcrumb(routerData, url).name); + expect(urlNameList).toEqual(['用户列表']); + }); + it('Secondary path', () => { + const urlNameList = urlToList('/userinfo/2144').map(url => getBreadcrumb(routerData, url).name); + expect(urlNameList).toEqual(['用户列表', '用户信息']); + }); +}); diff --git a/src/components/PageHeaderWrapper/GridContent.js b/src/components/PageHeaderWrapper/GridContent.js new file mode 100644 index 0000000..931ea20 --- /dev/null +++ b/src/components/PageHeaderWrapper/GridContent.js @@ -0,0 +1,18 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'dva'; +import styles from './GridContent.less'; + +class GridContent extends PureComponent { + render() { + const { contentWidth, children } = this.props; + let className = `${styles.main}`; + if (contentWidth === 'Fixed') { + className = `${styles.main} ${styles.wide}`; + } + return
    {children}
    ; + } +} + +export default connect(({ setting }) => ({ + contentWidth: setting.contentWidth, +}))(GridContent); diff --git a/src/components/PageHeaderWrapper/GridContent.less b/src/components/PageHeaderWrapper/GridContent.less new file mode 100644 index 0000000..d5496e9 --- /dev/null +++ b/src/components/PageHeaderWrapper/GridContent.less @@ -0,0 +1,10 @@ +.main { + width: 100%; + height: 100%; + min-height: 100%; + transition: 0.3s; + &.wide { + max-width: 1200px; + margin: 0 auto; + } +} diff --git a/src/components/PageHeaderWrapper/index.js b/src/components/PageHeaderWrapper/index.js new file mode 100644 index 0000000..cd745f6 --- /dev/null +++ b/src/components/PageHeaderWrapper/index.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { FormattedMessage } from 'umi/locale'; +import Link from 'umi/link'; +import PageHeader from '@/components/PageHeader'; +import { connect } from 'dva'; +import GridContent from './GridContent'; +import styles from './index.less'; +import MenuContext from '@/layouts/MenuContext'; + +const PageHeaderWrapper = ({ children, contentWidth, wrapperClassName, top, ...restProps }) => ( +
    + {top} + + {value => ( + } + {...value} + key="pageheader" + {...restProps} + linkElement={Link} + itemRender={item => { + if (item.locale) { + return ; + } + return item.title; + }} + /> + )} + + {children ? ( +
    + {children} +
    + ) : null} +
    +); + +export default connect(({ setting }) => ({ + contentWidth: setting.contentWidth, +}))(PageHeaderWrapper); diff --git a/src/components/PageHeaderWrapper/index.less b/src/components/PageHeaderWrapper/index.less new file mode 100644 index 0000000..39a4496 --- /dev/null +++ b/src/components/PageHeaderWrapper/index.less @@ -0,0 +1,11 @@ +@import '~antd/lib/style/themes/default.less'; + +.content { + margin: 24px 24px 0; +} + +@media screen and (max-width: @screen-sm) { + .content { + margin: 24px 0 0; + } +} diff --git a/src/components/PageLoading/index.js b/src/components/PageLoading/index.js new file mode 100644 index 0000000..77c0f16 --- /dev/null +++ b/src/components/PageLoading/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { Spin } from 'antd'; + +// loading components from code split +// https://umijs.org/plugin/umi-plugin-react.html#dynamicimport +export default () => ( +
    + +
    +); diff --git a/src/components/Panel/index.js b/src/components/Panel/index.js new file mode 100644 index 0000000..fc539fb --- /dev/null +++ b/src/components/Panel/index.js @@ -0,0 +1,37 @@ +import React, { PureComponent } from 'react'; +import { Button } from 'antd'; +import { FormattedMessage } from 'umi/locale'; +import router from 'umi/router'; +import PageHeaderWrapper from '@/components/PageHeaderWrapper'; + +export default class Panel extends PureComponent { + back = () => { + const { back } = this.props; + router.push(back); + }; + + render() { + const { title, content, back, action, children } = this.props; + + const actionGroup = back ? ( +
    + {action} + +
    + ) : ( + action + ); + + return ( + + {children} + + ); + } +} diff --git a/src/components/Result/demo/classic.md b/src/components/Result/demo/classic.md new file mode 100644 index 0000000..0cd9d14 --- /dev/null +++ b/src/components/Result/demo/classic.md @@ -0,0 +1,80 @@ +--- +order: 1 +title: Classic +--- + +典型结果页面。 + +````jsx +import Result from 'ant-design-pro/lib/Result'; +import { Button, Row, Col, Icon, Steps } from 'antd'; + +const { Step } = Steps; + +const desc1 = ( +
    +
    + 曲丽丽 + +
    +
    2016-12-12 12:32
    +
    +); + +const desc2 = ( +
    +
    + 周毛毛 + +
    + +
    +); + +const extra = ( +
    +
    + 项目名称 +
    + + + 项目 ID: + 23421 + + + 负责人: + 曲丽丽 + + + 生效时间: + 2016-12-12 ~ 2017-12-12 + + + + + + + + +
    +); + +const actions = ( +
    + + + +
    +); + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Result/demo/error.md b/src/components/Result/demo/error.md new file mode 100644 index 0000000..5fd25cf --- /dev/null +++ b/src/components/Result/demo/error.md @@ -0,0 +1,39 @@ +--- +order: 2 +title: Failed +--- + +提交失败。 + +````jsx +import Result from 'ant-design-pro/lib/Result'; +import { Button, Icon } from 'antd'; + +const extra = ( +
    +
    + 您提交的内容有如下错误: +
    +
    + 您的账户已被冻结 + 立即解冻 +
    +
    + 您的账户还不具备申请资格 + 立即升级 +
    +
    +); + +const actions = ; + +ReactDOM.render( + +, mountNode); +```` diff --git a/src/components/Result/demo/structure.md b/src/components/Result/demo/structure.md new file mode 100644 index 0000000..7fcecfd --- /dev/null +++ b/src/components/Result/demo/structure.md @@ -0,0 +1,20 @@ +--- +order: 0 +title: Structure +--- + +结构包含 `处理结果`,`补充信息` 以及 `操作建议` 三个部分,其中 `处理结果` 由 `提示图标`,`标题` 和 `结果描述` 组成。 + +````jsx +import Result from 'ant-design-pro/lib/Result'; + +ReactDOM.render( + 标题
    } + description={
    结果描述
    } + extra="其他补充信息,自带灰底效果" + actions={
    操作建议,一般放置按钮组
    } + /> +, mountNode); +```` diff --git a/src/components/Result/index.d.ts b/src/components/Result/index.d.ts new file mode 100644 index 0000000..0c34c25 --- /dev/null +++ b/src/components/Result/index.d.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +export interface IResultProps { + type: 'success' | 'error'; + title: React.ReactNode; + description?: React.ReactNode; + extra?: React.ReactNode; + actions?: React.ReactNode; + style?: React.CSSProperties; +} + +export default class Result extends React.Component {} diff --git a/src/components/Result/index.js b/src/components/Result/index.js new file mode 100644 index 0000000..89f9f31 --- /dev/null +++ b/src/components/Result/index.js @@ -0,0 +1,29 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Icon } from 'antd'; +import styles from './index.less'; + +export default function Result({ + className, + type, + title, + description, + extra, + actions, + ...restProps +}) { + const iconMap = { + error: , + success: , + }; + const clsString = classNames(styles.result, className); + return ( +
    +
    {iconMap[type]}
    +
    {title}
    + {description &&
    {description}
    } + {extra &&
    {extra}
    } + {actions &&
    {actions}
    } +
    + ); +} diff --git a/src/components/Result/index.less b/src/components/Result/index.less new file mode 100644 index 0000000..00c9587 --- /dev/null +++ b/src/components/Result/index.less @@ -0,0 +1,58 @@ +@import '~antd/lib/style/themes/default.less'; + +.result { + width: 72%; + margin: 0 auto; + text-align: center; + @media screen and (max-width: @screen-xs) { + width: 100%; + } + + .icon { + margin-bottom: 24px; + font-size: 72px; + line-height: 72px; + + & > .success { + color: @success-color; + } + + & > .error { + color: @error-color; + } + } + + .title { + margin-bottom: 16px; + color: @heading-color; + font-weight: 500; + font-size: 24px; + line-height: 32px; + } + + .description { + margin-bottom: 24px; + color: @text-color-secondary; + font-size: 14px; + line-height: 22px; + } + + .extra { + padding: 24px 40px; + text-align: left; + background: #fafafa; + border-radius: @border-radius-sm; + + @media screen and (max-width: @screen-xs) { + padding: 18px 20px; + } + } + + .actions { + margin-top: 32px; + + button:not(:last-child) { + margin-right: 8px; + } + } +} diff --git a/src/components/Result/index.md b/src/components/Result/index.md new file mode 100644 index 0000000..dc11206 --- /dev/null +++ b/src/components/Result/index.md @@ -0,0 +1,20 @@ +--- +title: + en-US: Result + zh-CN: Result +subtitle: 处理结果 +cols: 1 +order: 12 +--- + +结果页用于对用户进行的一系列任务处理结果进行反馈。 + +## API + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| type | 类型,不同类型自带对应的图标 | Enum {'success', 'error'} | - | +| title | 标题 | ReactNode | - | +| description | 结果描述 | ReactNode | - | +| extra | 补充信息,有默认的灰色背景 | ReactNode | - | +| actions | 操作建议,推荐放置跳转链接,按钮组等 | ReactNode | - | diff --git a/src/components/SelectLang/index.js b/src/components/SelectLang/index.js new file mode 100644 index 0000000..f83cffc --- /dev/null +++ b/src/components/SelectLang/index.js @@ -0,0 +1,47 @@ +import React, { PureComponent } from 'react'; +import { formatMessage, setLocale, getLocale } from 'umi/locale'; +import { Menu, Icon } from 'antd'; +import classNames from 'classnames'; +import HeaderDropdown from '../HeaderDropdown'; +import styles from './index.less'; + +export default class SelectLang extends PureComponent { + changeLang = ({ key }) => { + setLocale(key); + }; + + render() { + const { className } = this.props; + const selectedLang = getLocale(); + const locales = ['zh-CN', 'zh-TW', 'en-US']; + const languageLabels = { + 'zh-CN': '简体中文', + 'zh-TW': '繁体中文', + 'en-US': 'English', + }; + const languageIcons = { + 'zh-CN': '', + 'zh-TW': '', + 'en-US': '', + }; + const langMenu = ( + + {locales.map(locale => ( + + + {languageIcons[locale]} + {' '} + {languageLabels[locale]} + + ))} + + ); + return ( + + + + + + ); + } +} diff --git a/src/components/SelectLang/index.less b/src/components/SelectLang/index.less new file mode 100644 index 0000000..9f41ade --- /dev/null +++ b/src/components/SelectLang/index.less @@ -0,0 +1,24 @@ +@import '~antd/lib/style/themes/default.less'; + +.menu { + :global(.anticon) { + margin-right: 8px; + } + :global(.ant-dropdown-menu-item) { + min-width: 160px; + } +} + +.dropDown { + line-height: @layout-header-height; + vertical-align: top; + cursor: pointer; + > i { + font-size: 16px !important; + transform: none !important; + svg { + position: relative; + top: -1px; + } + } +} diff --git a/src/components/SettingDrawer/BlockCheckbox.js b/src/components/SettingDrawer/BlockCheckbox.js new file mode 100644 index 0000000..faa0855 --- /dev/null +++ b/src/components/SettingDrawer/BlockCheckbox.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { Tooltip, Icon } from 'antd'; +import style from './index.less'; + +const BlockCheckbox = ({ value, onChange, list }) => ( +
    + {list.map(item => ( + +
    onChange(item.key)}> + {item.key} +
    + +
    +
    +
    + ))} +
    +); + +export default BlockCheckbox; diff --git a/src/components/SettingDrawer/ThemeColor.js b/src/components/SettingDrawer/ThemeColor.js new file mode 100644 index 0000000..e5d66d4 --- /dev/null +++ b/src/components/SettingDrawer/ThemeColor.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { Tooltip, Icon } from 'antd'; +import { formatMessage } from 'umi/locale'; +import styles from './ThemeColor.less'; + +const Tag = ({ color, check, ...rest }) => ( +
    + {check ? : ''} +
    +); + +const ThemeColor = ({ colors, title, value, onChange }) => { + let colorList = colors; + if (!colors) { + colorList = [ + { + key: 'dust', + color: '#F5222D', + }, + { + key: 'volcano', + color: '#FA541C', + }, + { + key: 'sunset', + color: '#FAAD14', + }, + { + key: 'cyan', + color: '#13C2C2', + }, + { + key: 'green', + color: '#52C41A', + }, + { + key: 'daybreak', + color: '#1890FF', + }, + { + key: 'geekblue', + color: '#2F54EB', + }, + { + key: 'purple', + color: '#722ED1', + }, + ]; + } + return ( +
    +

    {title}

    +
    + {colorList.map(({ key, color }) => ( + + onChange && onChange(color)} + /> + + ))} +
    +
    + ); +}; + +export default ThemeColor; diff --git a/src/components/SettingDrawer/ThemeColor.less b/src/components/SettingDrawer/ThemeColor.less new file mode 100644 index 0000000..52e63be --- /dev/null +++ b/src/components/SettingDrawer/ThemeColor.less @@ -0,0 +1,21 @@ +.themeColor { + margin-top: 24px; + overflow: hidden; + .title { + margin-bottom: 12px; + color: rgba(0, 0, 0, 0.65); + font-size: 14px; + line-height: 22px; + } + .colorBlock { + float: left; + width: 20px; + height: 20px; + margin-right: 8px; + color: #fff; + font-weight: bold; + text-align: center; + border-radius: 2px; + cursor: pointer; + } +} diff --git a/src/components/SettingDrawer/index.js b/src/components/SettingDrawer/index.js new file mode 100644 index 0000000..4b279f5 --- /dev/null +++ b/src/components/SettingDrawer/index.js @@ -0,0 +1,254 @@ +import React, { PureComponent } from 'react'; +import { Select, message, Drawer, List, Switch, Divider, Icon, Button, Alert, Tooltip } from 'antd'; +import { formatMessage } from 'umi/locale'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import { connect } from 'dva'; +import omit from 'omit.js'; +import styles from './index.less'; +import ThemeColor from './ThemeColor'; +import BlockCheckbox from './BlockCheckbox'; + +const { Option } = Select; + +const Body = ({ children, title, style }) => ( +
    +

    {title}

    + {children} +
    +); + +@connect(({ setting }) => ({ setting })) +class SettingDrawer extends PureComponent { + state = { + collapse: false, + }; + + getLayoutSetting = () => { + const { + setting: { contentWidth, fixedHeader, layout, autoHideHeader, fixSiderbar }, + } = this.props; + return [ + { + title: formatMessage({ id: 'app.setting.content-width' }), + action: ( + + ), + }, + { + title: formatMessage({ id: 'app.setting.fixedheader' }), + action: ( + this.changeSetting('fixedHeader', checked)} + /> + ), + }, + { + title: formatMessage({ id: 'app.setting.hideheader' }), + disabled: !fixedHeader, + disabledReason: formatMessage({ id: 'app.setting.hideheader.hint' }), + action: ( + this.changeSetting('autoHideHeader', checked)} + /> + ), + }, + { + title: formatMessage({ id: 'app.setting.fixedsidebar' }), + disabled: layout === 'topmenu', + disabledReason: formatMessage({ id: 'app.setting.fixedsidebar.hint' }), + action: ( + this.changeSetting('fixSiderbar', checked)} + /> + ), + }, + ]; + }; + + changeSetting = (key, value) => { + const { setting } = this.props; + const nextState = { ...setting }; + nextState[key] = value; + if (key === 'layout') { + nextState.contentWidth = value === 'topmenu' ? 'Fixed' : 'Fluid'; + } else if (key === 'fixedHeader' && !value) { + nextState.autoHideHeader = false; + } + this.setState(nextState, () => { + const { dispatch } = this.props; + dispatch({ + type: 'setting/changeSetting', + payload: this.state, + }); + }); + }; + + togglerContent = () => { + const { collapse } = this.state; + this.setState({ collapse: !collapse }); + }; + + renderLayoutSettingItem = item => { + const action = React.cloneElement(item.action, { + disabled: item.disabled, + }); + return ( + + + {item.title} + + + ); + }; + + render() { + const { setting } = this.props; + const { navTheme, primaryColor, layout, colorWeak } = setting; + const { collapse } = this.state; + return ( + + +
    + } + onHandleClick={this.togglerContent} + style={{ + zIndex: 999, + }} + > +
    + + this.changeSetting('navTheme', value)} + /> + + + this.changeSetting('primaryColor', color)} + /> + + + + + this.changeSetting('layout', value)} + /> + + + + + + + + this.changeSetting('colorWeak', checked)} + />, + ]} + > + {formatMessage({ id: 'app.setting.weakmode' })} + + + + message.success(formatMessage({ id: 'app.setting.copyinfo' }))} + > + + + + {formatMessage({ id: 'app.setting.production.hint' })}{' '} + + src/defaultSettings.js + +
    + } + /> +
    + + ); + } +} + +export default SettingDrawer; diff --git a/src/components/SettingDrawer/index.less b/src/components/SettingDrawer/index.less new file mode 100644 index 0000000..4ee941c --- /dev/null +++ b/src/components/SettingDrawer/index.less @@ -0,0 +1,74 @@ +@import '~antd/lib/style/themes/default.less'; + +.content { + position: relative; + min-height: 100%; + background: #fff; +} + +.blockChecbox { + display: flex; + .item { + position: relative; + margin-right: 16px; + // box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.1); + border-radius: @border-radius-base; + cursor: pointer; + img { + width: 48px; + } + } + .selectIcon { + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + padding-top: 15px; + padding-left: 24px; + color: @primary-color; + font-weight: bold; + font-size: 14px; + } +} + +.color_block { + display: inline-block; + width: 38px; + height: 22px; + margin: 4px; + margin-right: 12px; + vertical-align: middle; + border-radius: 4px; + cursor: pointer; +} + +.title { + margin-bottom: 12px; + color: @heading-color; + font-size: 14px; + line-height: 22px; +} + +.handle { + position: absolute; + top: 240px; + right: 300px; + z-index: 0; + display: flex; + justify-content: center; + align-items: center; + width: 48px; + height: 48px; + font-size: 16px; + text-align: center; + background: @primary-color; + border-radius: 4px 0 0 4px; + cursor: pointer; + pointer-events: auto; +} + +.productionHint { + margin-top: 16px; + font-size: 12px; +} diff --git a/src/components/SiderMenu/BaseMenu.js b/src/components/SiderMenu/BaseMenu.js new file mode 100644 index 0000000..10a0eb7 --- /dev/null +++ b/src/components/SiderMenu/BaseMenu.js @@ -0,0 +1,161 @@ +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import { Menu, Icon } from 'antd'; +import Link from 'umi/link'; +import { urlToList } from '../_utils/pathTools'; +import { getMenuMatches } from './SiderMenuUtils'; +import { isUrl } from '@/utils/utils'; +import styles from './index.less'; + +const { SubMenu } = Menu; + +// Allow menu.js config icon as string or ReactNode +// icon: 'setting', +// icon: 'http://demo.com/icon.png', +// icon: , +const getIcon = icon => { + if (typeof icon === 'string' && isUrl(icon)) { + return icon} />; + } + if (typeof icon === 'string') { + return ; + } + return icon; +}; + +export default class BaseMenu extends PureComponent { + /** + * 获得菜单子节点 + * @memberof SiderMenu + */ + getNavMenuItems = (menusData, parent) => { + if (!menusData) { + return []; + } + return menusData + .filter(item => item.name && !item.hideInMenu) + .map(item => this.getSubMenuOrItem(item, parent)) + .filter(item => item); + }; + + // Get the currently selected menu + getSelectedMenuKeys = pathname => { + const { flatMenuKeys } = this.props; + return urlToList(pathname).map(itemPath => getMenuMatches(flatMenuKeys, itemPath).pop()); + }; + + /** + * get SubMenu or Item + */ + getSubMenuOrItem = item => { + // doc: add hideChildrenInMenu + if (item.children && !item.hideChildrenInMenu && item.children.some(child => child.name)) { + const { name } = item; + return ( + + {getIcon(item.icon)} + {name} + + ) : ( + name + ) + } + key={item.path} + > + {this.getNavMenuItems(item.children)} + + ); + } + return {this.getMenuItemPath(item)}; + }; + + /** + * 判断是否是http链接.返回 Link 或 a + * Judge whether it is http link.return a or Link + * @memberof SiderMenu + */ + getMenuItemPath = item => { + const { name } = item; + const itemPath = this.conversionPath(item.path); + const icon = getIcon(item.icon); + const { target } = item; + // Is it a http link + if (/^https?:\/\//.test(itemPath)) { + return ( + + {icon} + {name} + + ); + } + const { location, isMobile, onCollapse } = this.props; + return ( + { + onCollapse(true); + } + : undefined + } + > + {icon} + {name} + + ); + }; + + conversionPath = path => { + if (path && path.indexOf('http') === 0) { + return path; + } + return `/${path || ''}`.replace(/\/+/g, '/'); + }; + + render() { + const { + openKeys, + theme, + mode, + location: { pathname }, + className, + collapsed, + } = this.props; + // if pathname can't match, use the nearest parent's key + let selectedKeys = this.getSelectedMenuKeys(pathname); + if (!selectedKeys.length && openKeys) { + selectedKeys = [openKeys[openKeys.length - 1]]; + } + let props = {}; + if (openKeys && !collapsed) { + props = { + openKeys: openKeys.length === 0 ? [...selectedKeys] : openKeys, + }; + } + const { handleOpenChange, style, menuData } = this.props; + const cls = classNames(className, { + 'top-nav-menu': mode === 'horizontal', + }); + + return ( + + {this.getNavMenuItems(menuData)} + + ); + } +} diff --git a/src/components/SiderMenu/SiderMenu.js b/src/components/SiderMenu/SiderMenu.js new file mode 100644 index 0000000..dd57380 --- /dev/null +++ b/src/components/SiderMenu/SiderMenu.js @@ -0,0 +1,98 @@ +import React, { PureComponent, Suspense } from 'react'; +import { Layout } from 'antd'; +import classNames from 'classnames'; +import Link from 'umi/link'; +import styles from './index.less'; +import PageLoading from '../PageLoading'; +import { getDefaultCollapsedSubMenus } from './SiderMenuUtils'; + +const BaseMenu = React.lazy(() => import('./BaseMenu')); +const { Sider } = Layout; + +let firstMount = true; + +export default class SiderMenu extends PureComponent { + constructor(props) { + super(props); + this.state = { + openKeys: getDefaultCollapsedSubMenus(props), + }; + } + + componentDidMount() { + firstMount = false; + } + + static getDerivedStateFromProps(props, state) { + const { pathname, flatMenuKeysLen } = state; + if (props.location.pathname !== pathname || props.flatMenuKeys.length !== flatMenuKeysLen) { + return { + pathname: props.location.pathname, + flatMenuKeysLen: props.flatMenuKeys.length, + openKeys: getDefaultCollapsedSubMenus(props), + }; + } + return null; + } + + isMainMenu = key => { + const { menuData } = this.props; + return menuData.some(item => { + if (key) { + return item.key === key || item.path === key; + } + return false; + }); + }; + + handleOpenChange = openKeys => { + const moreThanOne = openKeys.filter(openKey => this.isMainMenu(openKey)).length > 1; + this.setState({ + openKeys: moreThanOne ? [openKeys.pop()] : [...openKeys], + }); + }; + + render() { + const { logo, collapsed, onCollapse, fixSiderbar, theme, isMobile } = this.props; + const { openKeys } = this.state; + const defaultProps = collapsed ? {} : { openKeys }; + + const siderClassName = classNames(styles.sider, { + [styles.fixSiderBar]: fixSiderbar, + [styles.light]: theme === 'light', + }); + return ( + { + if (firstMount || !isMobile) { + onCollapse(collapse); + } + }} + width={256} + theme={theme} + className={siderClassName} + > + + }> + + + + ); + } +} diff --git a/src/components/SiderMenu/SiderMenu.test.js b/src/components/SiderMenu/SiderMenu.test.js new file mode 100644 index 0000000..3d280da --- /dev/null +++ b/src/components/SiderMenu/SiderMenu.test.js @@ -0,0 +1,39 @@ +import { getFlatMenuKeys } from './SiderMenuUtils'; + +const menu = [ + { + path: '/dashboard', + children: [ + { + path: '/dashboard/name', + }, + ], + }, + { + path: '/userinfo', + children: [ + { + path: '/userinfo/:id', + children: [ + { + path: '/userinfo/:id/info', + }, + ], + }, + ], + }, +]; + +const flatMenuKeys = getFlatMenuKeys(menu); + +describe('test convert nested menu to flat menu', () => { + it('simple menu', () => { + expect(flatMenuKeys).toEqual([ + '/dashboard', + '/dashboard/name', + '/userinfo', + '/userinfo/:id', + '/userinfo/:id/info', + ]); + }); +}); diff --git a/src/components/SiderMenu/SiderMenuUtils.js b/src/components/SiderMenu/SiderMenuUtils.js new file mode 100644 index 0000000..6e04ec1 --- /dev/null +++ b/src/components/SiderMenu/SiderMenuUtils.js @@ -0,0 +1,40 @@ +import pathToRegexp from 'path-to-regexp'; +import { urlToList } from '../_utils/pathTools'; + +/** + * Recursively flatten the data + * [{path:string},{path:string}] => {path,path2} + * @param menus + */ +export const getFlatMenuKeys = menuData => { + let keys = []; + menuData.forEach(item => { + keys.push(item.path); + if (item.children) { + keys = keys.concat(getFlatMenuKeys(item.children)); + } + }); + return keys; +}; + +export const getMenuMatches = (flatMenuKeys, path) => + flatMenuKeys.filter(item => { + if (item) { + return pathToRegexp(item).test(path); + } + return false; + }); +/** + * 获得菜单子节点 + * @memberof SiderMenu + */ +export const getDefaultCollapsedSubMenus = props => { + const { + location: { pathname }, + flatMenuKeys, + } = props; + return urlToList(pathname) + .map(item => getMenuMatches(flatMenuKeys, item)[0]) + .filter(item => item) + .reduce((acc, curr) => [...acc, curr], ['/']); +}; diff --git a/src/components/SiderMenu/index.js b/src/components/SiderMenu/index.js new file mode 100644 index 0000000..0be2733 --- /dev/null +++ b/src/components/SiderMenu/index.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { Drawer } from 'antd'; +import SiderMenu from './SiderMenu'; +import { getFlatMenuKeys } from './SiderMenuUtils'; + +const SiderMenuWrapper = React.memo(props => { + const { isMobile, menuData, collapsed, onCollapse } = props; + const flatMenuKeys = getFlatMenuKeys(menuData); + return isMobile ? ( + onCollapse(true)} + style={{ + padding: 0, + height: '100vh', + }} + > + + + ) : ( + + ); +}); + +export default SiderMenuWrapper; diff --git a/src/components/SiderMenu/index.less b/src/components/SiderMenu/index.less new file mode 100644 index 0000000..88722c8 --- /dev/null +++ b/src/components/SiderMenu/index.less @@ -0,0 +1,105 @@ +@import '~antd/lib/style/themes/default.less'; + +@nav-header-height: @layout-header-height; + +.logo { + position: relative; + height: @nav-header-height; + padding-left: (@menu-collapsed-width - 32px) / 2; + overflow: hidden; + line-height: @nav-header-height; + background: #002140; + transition: all 0.3s; + img { + display: inline-block; + height: 32px; + vertical-align: middle; + } + h1 { + display: inline-block; + margin: 0 0 0 12px; + color: white; + font-weight: 600; + font-size: 20px; + font-family: Avenir, 'Helvetica Neue', Arial, Helvetica, sans-serif; + vertical-align: middle; + } +} +.sider { + position: relative; + z-index: 10; + min-height: 100vh; + box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35); + &.fixSiderBar { + position: fixed; + top: 0; + left: 0; + box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05); + :global { + .ant-menu-root { + height: ~'calc(100vh - @{nav-header-height})'; + overflow-y: auto; + } + .ant-menu-inline { + border-right: 0; + .ant-menu-item, + .ant-menu-submenu-title { + width: 100%; + } + } + } + } + &.light { + background-color: white; + box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05); + .logo { + background: white; + box-shadow: 1px 1px 0 0 @border-color-split; + h1 { + color: @primary-color; + } + } + :global(.ant-menu-light) { + border-right-color: transparent; + } + } +} + +.icon { + width: 14px; + vertical-align: baseline; +} + +:global { + .top-nav-menu li.ant-menu-item { + height: @nav-header-height; + line-height: @nav-header-height; + } + .drawer .drawer-content { + background: #001529; + } + .ant-menu-inline-collapsed { + & > .ant-menu-item .sider-menu-item-img + span, + & + > .ant-menu-item-group + > .ant-menu-item-group-list + > .ant-menu-item + .sider-menu-item-img + + span, + & > .ant-menu-submenu > .ant-menu-submenu-title .sider-menu-item-img + span { + display: inline-block; + max-width: 0; + opacity: 0; + } + } + .ant-menu-item .sider-menu-item-img + span, + .ant-menu-submenu-title .sider-menu-item-img + span { + opacity: 1; + transition: opacity 0.3s @ease-in-out, width 0.3s @ease-in-out; + } + .ant-drawer-left { + .ant-drawer-body { + padding: 0; + } + } +} diff --git a/src/components/StandardFormRow/index.js b/src/components/StandardFormRow/index.js new file mode 100644 index 0000000..8cb0e44 --- /dev/null +++ b/src/components/StandardFormRow/index.js @@ -0,0 +1,24 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +const StandardFormRow = ({ title, children, last, block, grid, ...rest }) => { + const cls = classNames(styles.standardFormRow, { + [styles.standardFormRowBlock]: block, + [styles.standardFormRowLast]: last, + [styles.standardFormRowGrid]: grid, + }); + + return ( +
    + {title && ( +
    + {title} +
    + )} +
    {children}
    +
    + ); +}; + +export default StandardFormRow; diff --git a/src/components/StandardFormRow/index.less b/src/components/StandardFormRow/index.less new file mode 100644 index 0000000..0427233 --- /dev/null +++ b/src/components/StandardFormRow/index.less @@ -0,0 +1,72 @@ +@import '~antd/lib/style/themes/default.less'; + +.standardFormRow { + display: flex; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px dashed @border-color-split; + :global { + .ant-form-item { + margin-right: 24px; + } + .ant-form-item-label label { + margin-right: 0; + color: @text-color; + } + .ant-form-item-label, + .ant-form-item-control { + padding: 0; + line-height: 32px; + } + } + .label { + flex: 0 0 auto; + margin-right: 24px; + color: @heading-color; + font-size: @font-size-base; + text-align: right; + & > span { + display: inline-block; + height: 32px; + line-height: 32px; + &::after { + content: ':'; + } + } + } + .content { + flex: 1 1 0; + :global { + .ant-form-item:last-child { + margin-right: 0; + } + } + } +} + +.standardFormRowLast { + margin-bottom: 0; + padding-bottom: 0; + border: none; +} + +.standardFormRowBlock { + :global { + .ant-form-item, + div.ant-form-item-control-wrapper { + display: block; + } + } +} + +.standardFormRowGrid { + :global { + .ant-form-item, + div.ant-form-item-control-wrapper { + display: block; + } + .ant-form-item-label { + float: left; + } + } +} diff --git a/src/components/StandardTable/index.js b/src/components/StandardTable/index.js new file mode 100644 index 0000000..97cdda7 --- /dev/null +++ b/src/components/StandardTable/index.js @@ -0,0 +1,135 @@ +import React, { PureComponent, Fragment } from 'react'; +import { Table, Alert } from 'antd'; +import styles from './index.less'; + +function initTotalList(columns) { + const totalList = []; + columns.forEach(column => { + if (column.needTotal) { + totalList.push({ ...column, total: 0 }); + } + }); + return totalList; +} + +class StandardTable extends PureComponent { + constructor(props) { + super(props); + const { columns, rowKey = 'id' } = props; + const needTotalList = initTotalList(columns); + + // 使用外层传入的 selectedRows 初始化选中行,避免使用 Grid 时重新初始化 StandardTable 导致的状态异常 + const selectedRowKeys = props.selectedRows ? props.selectedRows.map(row => row[rowKey]) : []; + + this.state = { + selectedRowKeys, + needTotalList, + expandProps: props.expandProps, + }; + } + + static getDerivedStateFromProps(nextProps) { + // clean state + if (nextProps.selectedRows.length === 0) { + const needTotalList = initTotalList(nextProps.columns); + return { + selectedRowKeys: [], + needTotalList, + }; + } + if (nextProps.expandProps) { + return { + expandProps: nextProps.expandProps, + }; + } + return null; + } + + handleRowSelectChange = (selectedRowKeys, selectedRows) => { + let { needTotalList } = this.state; + needTotalList = needTotalList.map(item => ({ + ...item, + total: selectedRows.reduce((sum, val) => sum + parseFloat(val[item.dataIndex], 10), 0), + })); + const { onSelectRow } = this.props; + if (onSelectRow) { + onSelectRow(selectedRows); + } + + this.setState({ selectedRowKeys, needTotalList }); + }; + + handleTableChange = (pagination, filters, sorter) => { + const { onChange } = this.props; + if (onChange) { + onChange(pagination, filters, sorter); + } + }; + + cleanSelectedKeys = () => { + this.handleRowSelectChange([], []); + }; + + render() { + const { selectedRowKeys, needTotalList, expandProps } = this.state; + const { data = {}, rowKey, alert = false, ...rest } = this.props; + const { list = [], pagination } = data; + + const paginationProps = pagination + ? { + showSizeChanger: true, + showQuickJumper: true, + ...pagination, + } + : false; + + const rowSelection = { + selectedRowKeys, + onChange: this.handleRowSelectChange, + getCheckboxProps: record => ({ + disabled: record.disabled, + }), + }; + + return ( +
    + {alert ? ( +
    + + 已选择 {selectedRowKeys.length} 项   + {needTotalList.map(item => ( + + {item.title} + 总计  + + {item.render ? item.render(item.total) : item.total} + + + ))} + + 清空 + + + } + type="info" + showIcon + /> +
    + ) : null} + + + ); + } +} + +export default StandardTable; diff --git a/src/components/StandardTable/index.less b/src/components/StandardTable/index.less new file mode 100644 index 0000000..817be99 --- /dev/null +++ b/src/components/StandardTable/index.less @@ -0,0 +1,13 @@ +@import '~antd/lib/style/themes/default.less'; + +.standardTable { + :global { + .ant-table-pagination { + margin-top: 24px; + } + } + + .tableAlert { + margin-bottom: 16px; + } +} diff --git a/src/components/Sword/Grid.js b/src/components/Sword/Grid.js new file mode 100644 index 0000000..0b8f796 --- /dev/null +++ b/src/components/Sword/Grid.js @@ -0,0 +1,273 @@ +import React, { Fragment, PureComponent } from 'react'; +import { Card, Divider, message, Modal } from 'antd'; +import { formatMessage, FormattedMessage } from 'umi/locale'; +import router from 'umi/router'; +import { getButton } from '../../utils/authority'; +import styles from './SwordPage.less'; +import ToolBar from './ToolBar'; +import SearchBox from './SearchBox'; +import StandardTable from '../StandardTable'; +import { requestApi } from '../../services/api'; + +export default class Grid extends PureComponent { + constructor(props) { + super(props); + this.state = { + current: 1, + size: 10, + formValues: {}, + selectedRows: [], + buttons: getButton(props.code), + }; + } + + componentDidMount() { + this.handleSearch(); + } + + handleSearch = e => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + const { form } = this.props; + + form.validateFields(async (err, fieldsValue) => { + if (err) { + return; + } + + const values = { + ...fieldsValue, + }; + + await this.setState({ + formValues: values, + }); + + this.refreshTable(true); + }); + }; + + handleFormReset = async () => { + const { form, onReset } = this.props; + form.resetFields(); + await this.setState({ + current: 1, + size: 10, + formValues: {}, + selectedRows: [], + }); + if (onReset) { + onReset(); + } + this.refreshTable(); + }; + + handleStandardTableChange = async pagination => { + await this.setState({ + current: pagination.current, + size: pagination.pageSize, + }); + + this.refreshTable(); + }; + + refreshTable = (firstPage = false) => { + const { onSearch } = this.props; + const { current, size, formValues } = this.state; + + const params = { + current: firstPage ? 1 : current, + size, + ...formValues, + }; + if (onSearch) { + onSearch(params); + } + }; + + handleSelectRows = rows => { + this.setState({ + selectedRows: rows, + }); + + const { onSelectRow } = this.props; + if (onSelectRow) { + onSelectRow(rows); + } + }; + + getSelectKeys = () => { + const { selectedRows } = this.state; + const { pkField = 'id', childPkField = 'id' } = this.props; + return selectedRows.map(row => { + const selectKey = row[pkField] || row[childPkField]; + if (`${selectKey}`.indexOf(',') > 0) { + return `${selectKey}`.split(','); + } + return selectKey; + }); + }; + + handleToolBarClick = btn => { + const { selectedRows } = this.state; + const keys = this.getSelectKeys(); + this.handleClick(btn, keys, selectedRows); + }; + + handleClick = (btn, keys = [], rows) => { + const { path, alias } = btn; + const { btnCallBack } = this.props; + const refresh = (temp = true) => this.refreshTable(temp); + if (alias === 'add') { + if (keys.length > 1) { + message.warn('父记录只能选择一条!'); + return; + } + if (keys.length === 1) { + router.push(`${path}/${keys[0]}`); + return; + } + router.push(path); + return; + } + if (alias === 'edit') { + if (keys.length <= 0) { + message.warn('请先选择一条数据!'); + return; + } + if (keys.length > 1) { + message.warn('只能选择一条数据!'); + return; + } + router.push(`${path}/${keys[0]}`); + return; + } + if (alias === 'view') { + if (keys.length <= 0) { + message.warn('请先选择一条数据!'); + return; + } + if (keys.length > 1) { + message.warn('只能选择一条数据!'); + return; + } + router.push(`${path}/${keys[0]}`); + return; + } + if (alias === 'delete') { + if (keys.length <= 0) { + message.warn('请先选择要删除的记录!'); + return; + } + + Modal.confirm({ + title: '删除确认', + content: '确定删除选中记录?', + okText: '确定', + okType: 'danger', + cancelText: '取消', + async onOk() { + const response = await requestApi(path, { ids: keys.join(',') }); + if (response.success) { + message.success(response.msg); + refresh(); + } else { + message.error(response.msg || '删除失败'); + } + }, + onCancel() {}, + }); + return; + } + if (btnCallBack) { + btnCallBack({ btn, keys, rows, refresh }); + } + }; + + render() { + const { buttons, selectedRows } = this.state; + const { + loading = false, + rowKey, + pkField, + childPkField, + data, + scroll, + tblProps, + cardProps, + actionColumnWidth, + renderSearchForm, + renderLeftButton, + renderRightButton, + renderActionButton, + } = this.props; + let { columns } = this.props; + + const actionButtons = buttons.filter(button => button.action === 2 || button.action === 3); + + if (columns && Array.isArray(columns) && (actionButtons.length > 0 || renderActionButton)) { + const key = pkField || rowKey || 'id'; + columns = [ + ...columns, + { + title: formatMessage({ id: 'table.columns.action' }), + width: actionColumnWidth || 200, + render: (text, record) => ( + +
    + {actionButtons.map((button, index) => ( + + {index > 0 ? : null} + + this.handleClick(button, [record[childPkField || key]], [record]) + } + > + + + + ))} + {renderActionButton + ? renderActionButton([record[childPkField || key]], [record]) + : null} +
    +
    + ), + }, + ]; + } + + return ( + +
    + + {renderSearchForm(this.handleFormReset)} + + + +
    +
    + ); + } +} diff --git a/src/components/Sword/SearchBox.js b/src/components/Sword/SearchBox.js new file mode 100644 index 0000000..0e3e9bc --- /dev/null +++ b/src/components/Sword/SearchBox.js @@ -0,0 +1,16 @@ +import React, { PureComponent } from 'react'; +import { Form } from 'antd'; +import styles from './SwordPage.less'; + +export default class SearchBox extends PureComponent { + render() { + const { onSubmit, children } = this.props; + return ( +
    +
    + {children} + +
    + ); + } +} diff --git a/src/components/Sword/SwordPage.less b/src/components/Sword/SwordPage.less new file mode 100644 index 0000000..ce8f710 --- /dev/null +++ b/src/components/Sword/SwordPage.less @@ -0,0 +1,38 @@ +@import '~antd/lib/style/themes/default.less'; +@import '~@/utils/utils.less'; + +.swordPage { + .form { + // 重写 ant-form-item、ant-form-item-label、ant-form-item-control、ant-form-item-control-wrapper + :global { + .ant-form-item { + margin-bottom: 24px; + margin-right: 0; + display: flex; + > .ant-form-item-label { + //min-width: 120px; + //width: auto; + line-height: 32px; + padding-right: 8px; + } + .ant-form-item-control { + line-height: 32px; + } + } + .ant-form-item-control-wrapper { + flex: 1; + } + } + + .submitButtons { + white-space: nowrap; + margin-bottom: 24px; + } + } + .operator { + margin-bottom: 16px; + button { + margin-right: 16px; + } + } +} diff --git a/src/components/Sword/ToolBar.js b/src/components/Sword/ToolBar.js new file mode 100644 index 0000000..67cdacc --- /dev/null +++ b/src/components/Sword/ToolBar.js @@ -0,0 +1,40 @@ +import React, { PureComponent } from 'react'; +import { Button } from 'antd'; +import { FormattedMessage } from 'umi/locale'; +import styles from './SwordPage.less'; + +export default class ToolBar extends PureComponent { + render() { + const { buttons, renderLeftButton, renderRightButton, onClick } = this.props; + return ( +
    +
    + {buttons + .filter(button => button.action === 1 || button.action === 3) + .map(button => ( + + ))} + {renderLeftButton ? renderLeftButton() : null} + {renderRightButton ? ( +
    {renderRightButton()}
    + ) : null} +
    +
    + ); + } +} diff --git a/src/components/TagSelect/TagSelectOption.d.ts b/src/components/TagSelect/TagSelectOption.d.ts new file mode 100644 index 0000000..366b297 --- /dev/null +++ b/src/components/TagSelect/TagSelectOption.d.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export interface ITagSelectOptionProps { + value: string | number; + style?: React.CSSProperties; +} + +export default class TagSelectOption extends React.Component {} diff --git a/src/components/TagSelect/demo/controlled.md b/src/components/TagSelect/demo/controlled.md new file mode 100644 index 0000000..4e9defa --- /dev/null +++ b/src/components/TagSelect/demo/controlled.md @@ -0,0 +1,50 @@ +--- +order: 3 +title: 受控模式 +--- + +结合 `Tag` 的 `TagSelect` 组件,方便的应用于筛选类目的业务场景中。 + +```jsx +import { Button } from 'antd'; +import TagSelect from 'ant-design-pro/lib/TagSelect'; + +class Demo extends React.Component { + state = { + value: ['cat1'], + }; + handleFormSubmit = value => { + this.setState({ + value, + }); + }; + checkAll = () => { + this.setState({ + value: ['cat1', 'cat2', 'cat3', 'cat4', 'cat5', 'cat6'], + }); + }; + render() { + return ( +
    + +
    + + 类目一 + 类目二 + 类目三 + 类目四 + 类目五 + 类目六 + +
    +
    + ); + } +} + +ReactDOM.render(, mountNode); +``` diff --git a/src/components/TagSelect/demo/expandable.md b/src/components/TagSelect/demo/expandable.md new file mode 100644 index 0000000..c45a30a --- /dev/null +++ b/src/components/TagSelect/demo/expandable.md @@ -0,0 +1,31 @@ +--- +order: 1 +title: 可展开和收起 +--- + +使用 `expandable` 属性,让标签组可以收起,避免过高。 + +````jsx +import TagSelect from 'ant-design-pro/lib/TagSelect'; + +function handleFormSubmit(checkedValue) { + console.log(checkedValue); +} + +ReactDOM.render( + + 类目一 + 类目二 + 类目三 + 类目四 + 类目五 + 类目六 + 类目七 + 类目八 + 类目九 + 类目十 + 类目十一 + 类目十二 + +, mountNode); +```` diff --git a/src/components/TagSelect/demo/simple.md b/src/components/TagSelect/demo/simple.md new file mode 100644 index 0000000..9e7a13a --- /dev/null +++ b/src/components/TagSelect/demo/simple.md @@ -0,0 +1,25 @@ +--- +order: 0 +title: 基础样例 +--- + +结合 `Tag` 的 `TagSelect` 组件,方便的应用于筛选类目的业务场景中。 + +````jsx +import TagSelect from 'ant-design-pro/lib/TagSelect'; + +function handleFormSubmit(checkedValue) { + console.log(checkedValue); +} + +ReactDOM.render( + + 类目一 + 类目二 + 类目三 + 类目四 + 类目五 + 类目六 + +, mountNode); +```` diff --git a/src/components/TagSelect/index.d.ts b/src/components/TagSelect/index.d.ts new file mode 100644 index 0000000..9de3026 --- /dev/null +++ b/src/components/TagSelect/index.d.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; +import TagSelectOption from './TagSelectOption'; + +export interface ITagSelectProps { + onChange?: (value: string[]) => void; + expandable?: boolean; + value?: string[] | number[]; + style?: React.CSSProperties; + hideCheckAll?: boolean; + actionsText?: { expandText?: string; collapseText?: string; selectAllText?: string }; +} + +export default class TagSelect extends React.Component { + public static Option: typeof TagSelectOption; + private children: + | React.ReactElement + | Array>; +} diff --git a/src/components/TagSelect/index.js b/src/components/TagSelect/index.js new file mode 100644 index 0000000..6d0394d --- /dev/null +++ b/src/components/TagSelect/index.js @@ -0,0 +1,143 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { Tag, Icon } from 'antd'; + +import styles from './index.less'; + +const { CheckableTag } = Tag; + +const TagSelectOption = ({ children, checked, onChange, value }) => ( + onChange(value, state)}> + {children} + +); + +TagSelectOption.isTagSelectOption = true; + +class TagSelect extends Component { + static propTypes = { + actionsText: PropTypes.object, + hideCheckAll: PropTypes.bool, + }; + + static defaultProps = { + hideCheckAll: false, + actionsText: { + expandText: 'Expand', + collapseText: 'Collapse', + selectAllText: 'All', + }, + }; + + constructor(props) { + super(props); + this.state = { + expand: false, + value: props.value || props.defaultValue || [], + }; + } + + static getDerivedStateFromProps(nextProps) { + if ('value' in nextProps) { + return { value: nextProps.value || [] }; + } + return null; + } + + onChange = value => { + const { onChange } = this.props; + if (!('value' in this.props)) { + this.setState({ value }); + } + if (onChange) { + onChange(value); + } + }; + + onSelectAll = checked => { + let checkedTags = []; + if (checked) { + checkedTags = this.getAllTags(); + } + this.onChange(checkedTags); + }; + + getAllTags() { + let { children } = this.props; + children = React.Children.toArray(children); + const checkedTags = children + .filter(child => this.isTagSelectOption(child)) + .map(child => child.props.value); + return checkedTags || []; + } + + handleTagChange = (value, checked) => { + const { value: StateValue } = this.state; + const checkedTags = [...StateValue]; + + const index = checkedTags.indexOf(value); + if (checked && index === -1) { + checkedTags.push(value); + } else if (!checked && index > -1) { + checkedTags.splice(index, 1); + } + this.onChange(checkedTags); + }; + + handleExpand = () => { + const { expand } = this.state; + this.setState({ + expand: !expand, + }); + }; + + isTagSelectOption = node => + node && + node.type && + (node.type.isTagSelectOption || node.type.displayName === 'TagSelectOption'); + + render() { + const { value, expand } = this.state; + const { children, hideCheckAll, className, style, expandable, actionsText } = this.props; + const checkedAll = this.getAllTags().length === value.length; + const { expandText = 'Expand', collapseText = 'Collapse', selectAllText = 'All' } = + actionsText === null ? {} : actionsText; + + const cls = classNames(styles.tagSelect, className, { + [styles.hasExpandTag]: expandable, + [styles.expanded]: expand, + }); + + return ( +
    + {hideCheckAll ? null : ( + + {selectAllText} + + )} + {value && + React.Children.map(children, child => { + if (this.isTagSelectOption(child)) { + return React.cloneElement(child, { + key: `tag-select-${child.props.value}`, + value: child.props.value, + checked: value.indexOf(child.props.value) > -1, + onChange: this.handleTagChange, + }); + } + return child; + })} + {expandable && ( + + {expand ? collapseText : expandText} + + )} +
    + ); + } +} + +TagSelect.Option = TagSelectOption; + +export default TagSelect; diff --git a/src/components/TagSelect/index.less b/src/components/TagSelect/index.less new file mode 100644 index 0000000..9369465 --- /dev/null +++ b/src/components/TagSelect/index.less @@ -0,0 +1,33 @@ +@import '~antd/lib/style/themes/default.less'; + +.tagSelect { + position: relative; + max-height: 32px; + margin-left: -8px; + overflow: hidden; + line-height: 32px; + transition: all 0.3s; + user-select: none; + :global { + .ant-tag { + margin-right: 24px; + padding: 0 8px; + font-size: @font-size-base; + } + } + &.expanded { + max-height: 200px; + transition: all 0.3s; + } + .trigger { + position: absolute; + top: 0; + right: 0; + i { + font-size: 12px; + } + } + &.hasExpandTag { + padding-right: 50px; + } +} diff --git a/src/components/TagSelect/index.md b/src/components/TagSelect/index.md new file mode 100644 index 0000000..25a42b5 --- /dev/null +++ b/src/components/TagSelect/index.md @@ -0,0 +1,29 @@ +--- +title: + en-US: TagSelect + zh-CN: TagSelect +subtitle: 标签选择器 +cols: 1 +order: 13 +--- + +可进行多选,带折叠收起和展开更多功能,常用于对列表进行筛选。 + +## API + +### TagSelect + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| value |选中的项 |string[] \| number[] | | +| defaultValue |默认选中的项 |string[] \| number[] | | +| onChange | 标签选择的回调函数 | Function(checkedTags) | | +| expandable | 是否展示 `展开/收起` 按钮 | Boolean | false | +| hideCheckAll | 隐藏 `全部` 按钮 | Boolean | false | + +### TagSelectOption + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| value | TagSelect的值 | string\| number | - | +| children | tag的内容 | string \| ReactNode | - | diff --git a/src/components/TopNavHeader/index.js b/src/components/TopNavHeader/index.js new file mode 100644 index 0000000..8ae89e8 --- /dev/null +++ b/src/components/TopNavHeader/index.js @@ -0,0 +1,51 @@ +import React, { PureComponent } from 'react'; +import Link from 'umi/link'; +import RightContent from '../GlobalHeader/RightContent'; +import BaseMenu from '../SiderMenu/BaseMenu'; +import { getFlatMenuKeys } from '../SiderMenu/SiderMenuUtils'; +import styles from './index.less'; + +export default class TopNavHeader extends PureComponent { + state = { + maxWidth: undefined, + }; + + static getDerivedStateFromProps(props) { + return { + maxWidth: (props.contentWidth === 'Fixed' ? 1200 : window.innerWidth) - 280 - 165 - 40, + }; + } + + render() { + const { theme, contentWidth, menuData, logo } = this.props; + const { maxWidth } = this.state; + const flatMenuKeys = getFlatMenuKeys(menuData); + return ( +
    +
    { + this.maim = ref; + }} + className={`${styles.main} ${contentWidth === 'Fixed' ? styles.wide : ''}`} + > +
    + +
    + +
    +
    + +
    +
    + ); + } +} diff --git a/src/components/TopNavHeader/index.less b/src/components/TopNavHeader/index.less new file mode 100644 index 0000000..aad3d74 --- /dev/null +++ b/src/components/TopNavHeader/index.less @@ -0,0 +1,72 @@ +@import '~antd/lib/style/themes/default.less'; + +.head { + position: relative; + width: 100%; + height: @layout-header-height; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); + transition: background 0.3s, width 0.2s; + :global { + .ant-menu-submenu.ant-menu-submenu-horizontal { + height: 100%; + line-height: @layout-header-height; + .ant-menu-submenu-title { + height: 100%; + } + } + } + &.light { + background-color: #fff; + } + .main { + display: flex; + height: @layout-header-height; + padding-left: 24px; + &.wide { + max-width: 1200px; + margin: auto; + padding-left: 0; + } + .left { + display: flex; + flex: 1; + } + .right { + width: 324px; + } + } +} + +.logo { + position: relative; + width: 165px; + height: @layout-header-height; + overflow: hidden; + line-height: @layout-header-height; + transition: all 0.3s; + img { + display: inline-block; + height: 32px; + vertical-align: middle; + } + h1 { + display: inline-block; + margin: 0 0 0 12px; + color: #fff; + font-weight: 400; + font-size: 16px; + vertical-align: top; + } +} + +.light { + h1 { + color: #002140; + } +} + +.menu { + height: @layout-header-height; + line-height: @layout-header-height; + border: none; +} diff --git a/src/components/Trend/demo/basic.md b/src/components/Trend/demo/basic.md new file mode 100644 index 0000000..da771dc --- /dev/null +++ b/src/components/Trend/demo/basic.md @@ -0,0 +1,17 @@ +--- +order: 0 +title: 演示 +--- + +在数值背后添加一个小图标来标识涨跌情况。 + +```jsx +import Trend from 'ant-design-pro/lib/Trend'; + +ReactDOM.render( +
    + 12% + 11% +
    +, mountNode); +``` diff --git a/src/components/Trend/demo/reverse.md b/src/components/Trend/demo/reverse.md new file mode 100644 index 0000000..26f7366 --- /dev/null +++ b/src/components/Trend/demo/reverse.md @@ -0,0 +1,17 @@ +--- +order: 0 +title: 颜色反转 +--- + +在数值背后添加一个小图标来标识涨跌情况。 + +```jsx +import Trend from 'ant-design-pro/lib/Trend'; + +ReactDOM.render( +
    + 12% + 11% +
    +, mountNode); +``` diff --git a/src/components/Trend/index.d.ts b/src/components/Trend/index.d.ts new file mode 100644 index 0000000..7dc0201 --- /dev/null +++ b/src/components/Trend/index.d.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; + +export interface ITrendProps { + colorful?: boolean; + flag: 'up' | 'down'; + style?: React.CSSProperties; + reverseColor?: boolean; +} + +export default class Trend extends React.Component {} diff --git a/src/components/Trend/index.js b/src/components/Trend/index.js new file mode 100644 index 0000000..c476ef6 --- /dev/null +++ b/src/components/Trend/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { Icon } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +const Trend = ({ colorful = true, reverseColor = false, flag, children, className, ...rest }) => { + const classString = classNames( + styles.trendItem, + { + [styles.trendItemGrey]: !colorful, + [styles.reverseColor]: reverseColor && colorful, + }, + className + ); + return ( +
    + {children} + {flag && ( + + + + )} +
    + ); +}; + +export default Trend; diff --git a/src/components/Trend/index.less b/src/components/Trend/index.less new file mode 100644 index 0000000..1361883 --- /dev/null +++ b/src/components/Trend/index.less @@ -0,0 +1,37 @@ +@import '~antd/lib/style/themes/default.less'; + +.trendItem { + display: inline-block; + font-size: @font-size-base; + line-height: 22px; + + .up, + .down { + position: relative; + top: 1px; + margin-left: 4px; + i { + font-size: 12px; + transform: scale(0.83); + } + } + .up { + color: @red-6; + } + .down { + top: -1px; + color: @green-6; + } + + &.trendItemGrey .up, + &.trendItemGrey .down { + color: @text-color; + } + + &.reverseColor .up { + color: @green-6; + } + &.reverseColor .down { + color: @red-6; + } +} diff --git a/src/components/Trend/index.md b/src/components/Trend/index.md new file mode 100644 index 0000000..3e3ac07 --- /dev/null +++ b/src/components/Trend/index.md @@ -0,0 +1,22 @@ +--- +title: + en-US: Trend + zh-CN: Trend +subtitle: 趋势标记 +cols: 1 +order: 14 +--- + +趋势符号,标记上升和下降趋势。通常用绿色代表“好”,红色代表“不好”,股票涨跌场景除外。 + +## API + +```html +50% +``` + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| colorful | 是否彩色标记 | Boolean | true | +| flag | 上升下降标识:`up|down` | string | - | +| reverseColor | 颜色反转 | Boolean | false | diff --git a/src/components/_utils/pathTools.js b/src/components/_utils/pathTools.js new file mode 100644 index 0000000..bfb94e7 --- /dev/null +++ b/src/components/_utils/pathTools.js @@ -0,0 +1,6 @@ +// /userinfo/2144/id => ['/userinfo','/useinfo/2144,'/userindo/2144/id'] +// eslint-disable-next-line import/prefer-default-export +export function urlToList(url) { + const urllist = url.split('/').filter(i => i); + return urllist.map((urlItem, index) => `/${urllist.slice(0, index + 1).join('/')}`); +} diff --git a/src/components/_utils/pathTools.test.js b/src/components/_utils/pathTools.test.js new file mode 100644 index 0000000..a9b9315 --- /dev/null +++ b/src/components/_utils/pathTools.test.js @@ -0,0 +1,17 @@ +import { urlToList } from './pathTools'; + +describe('test urlToList', () => { + it('A path', () => { + expect(urlToList('/userinfo')).toEqual(['/userinfo']); + }); + it('Secondary path', () => { + expect(urlToList('/userinfo/2144')).toEqual(['/userinfo', '/userinfo/2144']); + }); + it('Three paths', () => { + expect(urlToList('/userinfo/2144/addr')).toEqual([ + '/userinfo', + '/userinfo/2144', + '/userinfo/2144/addr', + ]); + }); +}); diff --git a/src/defaultSettings.js b/src/defaultSettings.js new file mode 100644 index 0000000..4d39b6e --- /dev/null +++ b/src/defaultSettings.js @@ -0,0 +1,18 @@ +module.exports = { + title: 'Sword企业级开发平台', + clientId: 'sword', // 客户端id + clientSecret: 'sword_secret', // 客户端密钥 + tenantMode: true, // 开启租户模式 + navTheme: 'dark', // theme for nav menu + primaryColor: '#1890FF', // primary color of ant design + layout: 'sidemenu', // nav menu position: sidemenu or topmenu + contentWidth: 'Fluid', // layout of content: Fluid or Fixed, only works when layout is topmenu + fixedHeader: true, // sticky header + autoHideHeader: false, // auto hide header + fixSiderbar: true, // sticky siderbar + collapse: true, + menu: { + disableLocal: false, + }, + pwa: true, +}; diff --git a/src/e2e/baseLayout.e2e.js b/src/e2e/baseLayout.e2e.js new file mode 100644 index 0000000..7493871 --- /dev/null +++ b/src/e2e/baseLayout.e2e.js @@ -0,0 +1,34 @@ +import RouterConfig from '../../config/router.config'; + +const BASE_URL = `http://localhost:${process.env.PORT || 8000}`; + +function formatter(data) { + return data + .reduce((pre, item) => { + pre.push(item.path); + return pre; + }, []) + .filter(item => item); +} + +describe('Homepage', async () => { + const testPage = path => async () => { + await page.goto(`${BASE_URL}${path}`); + await page.waitForSelector('footer', { + timeout: 2000, + }); + const haveFooter = await page.evaluate( + () => document.getElementsByTagName('footer').length > 0 + ); + expect(haveFooter).toBeTruthy(); + }; + + beforeAll(async () => { + jest.setTimeout(1000000); + await page.setCacheEnabled(false); + }); + const routers = formatter(RouterConfig[1].routes); + routers.forEach(route => { + it(`test pages ${route}`, testPage(route)); + }); +}); diff --git a/src/e2e/home.e2e.js b/src/e2e/home.e2e.js new file mode 100644 index 0000000..0531d5f --- /dev/null +++ b/src/e2e/home.e2e.js @@ -0,0 +1,15 @@ +const BASE_URL = `http://localhost:${process.env.PORT || 8000}`; + +describe('Homepage', () => { + beforeAll(async () => { + jest.setTimeout(1000000); + }); + it('it should have logo text', async () => { + await page.goto(BASE_URL); + await page.waitForSelector('h1', { + timeout: 5000, + }); + const text = await page.evaluate(() => document.getElementsByTagName('h1')[0].innerText); + expect(text).toContain('Ant Design Pro'); + }); +}); diff --git a/src/e2e/login.e2e.js b/src/e2e/login.e2e.js new file mode 100644 index 0000000..b991af4 --- /dev/null +++ b/src/e2e/login.e2e.js @@ -0,0 +1,34 @@ +const BASE_URL = `http://localhost:${process.env.PORT || 8000}`; + +describe('Login', () => { + beforeAll(async () => { + jest.setTimeout(1000000); + }); + + beforeEach(async () => { + await page.goto(`${BASE_URL}/user/login`, { waitUntil: 'networkidle2' }); + await page.evaluate(() => window.localStorage.setItem('antd-pro-authority', 'guest')); + }); + + it('should login with failure', async () => { + await page.waitForSelector('#userName', { + timeout: 2000, + }); + await page.type('#userName', 'mockuser'); + await page.type('#password', 'wrong_password'); + await page.click('button[type="submit"]'); + await page.waitForSelector('.ant-alert-error'); // should display error + }); + + it('should login successfully', async () => { + await page.waitForSelector('#userName', { + timeout: 2000, + }); + await page.type('#userName', 'admin'); + await page.type('#password', 'ant.design'); + await page.click('button[type="submit"]'); + await page.waitForSelector('.ant-layout-sider h1'); // should display error + const text = await page.evaluate(() => document.body.innerHTML); + expect(text).toContain('

    Ant Design Pro

    '); + }); +}); diff --git a/src/e2e/topMenu.e2e.js b/src/e2e/topMenu.e2e.js new file mode 100644 index 0000000..51ff9f3 --- /dev/null +++ b/src/e2e/topMenu.e2e.js @@ -0,0 +1,18 @@ +const BASE_URL = `http://localhost:${process.env.PORT || 8000}`; + +describe('Homepage', () => { + beforeAll(async () => { + jest.setTimeout(1000000); + }); + it('topmenu should have footer', async () => { + const params = '/form/basic-form?navTheme=light&layout=topmenu'; + await page.goto(`${BASE_URL}${params}`); + await page.waitForSelector('footer', { + timeout: 2000, + }); + const haveFooter = await page.evaluate( + () => document.getElementsByTagName('footer').length > 0 + ); + expect(haveFooter).toBeTruthy(); + }); +}); diff --git a/src/e2e/userLayout.e2e.js b/src/e2e/userLayout.e2e.js new file mode 100644 index 0000000..a2edfc7 --- /dev/null +++ b/src/e2e/userLayout.e2e.js @@ -0,0 +1,32 @@ +import RouterConfig from '../../config/router.config'; + +const BASE_URL = `http://localhost:${process.env.PORT || 8000}`; + +function formatter(data) { + return data + .reduce((pre, item) => { + pre.push(item.path); + return pre; + }, []) + .filter(item => item); +} + +describe('Homepage', () => { + const testPage = path => async () => { + await page.goto(`${BASE_URL}${path}`); + await page.waitForSelector('footer', { + timeout: 2000, + }); + const haveFooter = await page.evaluate( + () => document.getElementsByTagName('footer').length > 0 + ); + expect(haveFooter).toBeTruthy(); + }; + + beforeAll(async () => { + jest.setTimeout(1000000); + }); + formatter(RouterConfig[0].routes).forEach(route => { + it(`test pages ${route}`, testPage(route)); + }); +}); diff --git a/src/global.js b/src/global.js new file mode 100644 index 0000000..bf60b41 --- /dev/null +++ b/src/global.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { notification, Button, message } from 'antd'; +import { formatMessage } from 'umi/locale'; +import defaultSettings from './defaultSettings'; + +const { pwa } = defaultSettings; +// if pwa is true +if (pwa) { + // Notify user if offline now + window.addEventListener('sw.offline', () => { + message.warning(formatMessage({ id: 'app.pwa.offline' })); + }); + + // Pop up a prompt on the page asking the user if they want to use the latest version + window.addEventListener('sw.updated', e => { + const reloadSW = async () => { + // Check if there is sw whose state is waiting in ServiceWorkerRegistration + // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration + const worker = e.detail && e.detail.waiting; + if (!worker) { + return Promise.resolve(); + } + // Send skip-waiting event to waiting SW with MessageChannel + await new Promise((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = event => { + if (event.data.error) { + reject(event.data.error); + } else { + resolve(event.data); + } + }; + worker.postMessage({ type: 'skip-waiting' }, [channel.port2]); + }); + // Refresh current page to use the updated HTML and other assets after SW has skiped waiting + window.location.reload(true); + return true; + }; + const key = `open${Date.now()}`; + const btn = ( + + ); + notification.open({ + message: formatMessage({ id: 'app.pwa.serviceworker.updated' }), + description: formatMessage({ id: 'app.pwa.serviceworker.updated.hint' }), + btn, + key, + onClose: async () => {}, + }); + }); +} diff --git a/src/global.less b/src/global.less new file mode 100644 index 0000000..1450ba4 --- /dev/null +++ b/src/global.less @@ -0,0 +1,52 @@ +@import '~antd/lib/style/themes/default.less'; + +html, +body, +#root { + height: 100%; +} + +.colorWeak { + filter: invert(80%); +} + +.ant-layout { + min-height: 100vh; +} + +canvas { + display: block; +} + +body { + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.globalSpin { + width: 100%; + margin: 40px 0 !important; +} + +ul, +ol { + list-style: none; +} + +@media (max-width: @screen-xs) { + .ant-table { + width: 100%; + overflow-x: auto; + &-thead > tr, + &-tbody > tr { + > th, + > td { + white-space: pre; + > span { + display: block; + } + } + } + } +} diff --git a/src/layouts/BasicLayout.js b/src/layouts/BasicLayout.js new file mode 100644 index 0000000..dc93402 --- /dev/null +++ b/src/layouts/BasicLayout.js @@ -0,0 +1,237 @@ +import React, { Suspense } from 'react'; +import { Layout } from 'antd'; +import 'moment/locale/zh-cn'; +import DocumentTitle from 'react-document-title'; +import isEqual from 'lodash/isEqual'; +import memoizeOne from 'memoize-one'; +import { connect } from 'dva'; +import { ContainerQuery } from 'react-container-query'; +import classNames from 'classnames'; +import pathToRegexp from 'path-to-regexp'; +import Media from 'react-media'; +import { formatMessage } from 'umi/locale'; +import Authorized from '@/utils/Authorized'; +import logo from '../assets/logo.svg'; +import Footer from './Footer'; +import Header from './Header'; +import Context from './MenuContext'; +import Exception403 from '../pages/Exception/403'; +import PageLoading from '@/components/PageLoading'; +import SiderMenu from '@/components/SiderMenu'; + +import { menu, title } from '../defaultSettings'; +import styles from './BasicLayout.less'; + +// lazy load SettingDrawer +const SettingDrawer = React.lazy(() => import('@/components/SettingDrawer')); + +const { Content } = Layout; + +const query = { + 'screen-xs': { + maxWidth: 575, + }, + 'screen-sm': { + minWidth: 576, + maxWidth: 767, + }, + 'screen-md': { + minWidth: 768, + maxWidth: 991, + }, + 'screen-lg': { + minWidth: 992, + maxWidth: 1199, + }, + 'screen-xl': { + minWidth: 1200, + maxWidth: 1599, + }, + 'screen-xxl': { + minWidth: 1600, + }, +}; + +class BasicLayout extends React.Component { + constructor(props) { + super(props); + this.getPageTitle = memoizeOne(this.getPageTitle); + this.matchParamsPath = memoizeOne(this.matchParamsPath, isEqual); + } + + componentDidMount() { + const { + dispatch, + route: { routes, authority }, + } = this.props; + dispatch({ + type: 'user/fetchCurrent', + }); + dispatch({ + type: 'setting/getSetting', + }); + dispatch({ + type: 'menu/fetchMenuData', + payload: { routes, authority }, + }); + } + + getContext() { + const { location, breadcrumbNameMap } = this.props; + return { + location, + breadcrumbNameMap, + }; + } + + matchParamsPath = (pathname, breadcrumbNameMap) => { + const pathKey = Object.keys(breadcrumbNameMap).find(key => pathToRegexp(key).test(pathname)); + return breadcrumbNameMap[pathKey]; + }; + + getRouteAuthority = (pathname, routeData) => { + const routes = routeData.slice(); // clone + let authorities; + + while (routes.length > 0) { + const route = routes.shift(); + // check partial route + if (pathToRegexp(`${route.path}(.*)`).test(pathname)) { + if (route.authority) { + authorities = route.authority; + } + // is exact route? + if (pathToRegexp(route.path).test(pathname)) { + break; + } + + if (route.routes) { + route.routes.forEach(r => routes.push(r)); + } + } + } + return authorities; + }; + + getPageTitle = (pathname, breadcrumbNameMap) => { + const currRouterData = this.matchParamsPath(pathname, breadcrumbNameMap); + + if (!currRouterData) { + return title; + } + + const pageName = menu.disableLocal + ? currRouterData.name + : formatMessage({ + id: currRouterData.locale || currRouterData.name, + defaultMessage: currRouterData.name, + }); + + return `${pageName} - ${title}`; + }; + + getLayoutStyle = () => { + const { fixSiderbar, isMobile, collapsed, layout } = this.props; + if (fixSiderbar && layout !== 'topmenu' && !isMobile) { + return { + paddingLeft: collapsed ? '80px' : '256px', + }; + } + return null; + }; + + handleMenuCollapse = collapsed => { + const { dispatch } = this.props; + dispatch({ + type: 'global/changeLayoutCollapsed', + payload: collapsed, + }); + }; + + renderSettingDrawer = () => { + // Do not render SettingDrawer in production + // unless it is deployed in preview.pro.ant.design as demo + if (process.env.NODE_ENV === 'production' && process.env.APP_TYPE !== 'site') { + return null; + } + return ; + }; + + render() { + const { + navTheme, + layout: PropsLayout, + children, + location: { pathname }, + isMobile, + menuData, + breadcrumbNameMap, + route: { routes }, + fixedHeader, + } = this.props; + + const isTop = PropsLayout === 'topmenu'; + const routerConfig = this.getRouteAuthority(pathname, routes); + const contentStyle = !fixedHeader ? { paddingTop: 0 } : {}; + const layout = ( + + {isTop && !isMobile ? null : ( + + )} + +
    + + }> + {children} + + +
    + + + ); + return ( + + + + {params => ( + +
    {layout}
    +
    + )} +
    +
    + }>{this.renderSettingDrawer()} +
    + ); + } +} + +export default connect(({ global, setting, menu: menuModel }) => ({ + collapsed: global.collapsed, + layout: setting.layout, + menuData: menuModel.menuData, + breadcrumbNameMap: menuModel.breadcrumbNameMap, + ...setting, +}))(props => ( + + {isMobile => } + +)); diff --git a/src/layouts/BasicLayout.less b/src/layouts/BasicLayout.less new file mode 100644 index 0000000..60beb60 --- /dev/null +++ b/src/layouts/BasicLayout.less @@ -0,0 +1,6 @@ +@import '~antd/lib/style/themes/default.less'; + +.content { + margin: 24px; + padding-top: @layout-header-height; +} diff --git a/src/layouts/BlankLayout.js b/src/layouts/BlankLayout.js new file mode 100644 index 0000000..505270f --- /dev/null +++ b/src/layouts/BlankLayout.js @@ -0,0 +1,3 @@ +import React from 'react'; + +export default props =>
    ; diff --git a/src/layouts/Footer.js b/src/layouts/Footer.js new file mode 100644 index 0000000..41548e5 --- /dev/null +++ b/src/layouts/Footer.js @@ -0,0 +1,26 @@ +import React, { Fragment } from 'react'; +import { Layout, Icon } from 'antd'; +import GlobalFooter from '@/components/GlobalFooter'; + +const { Footer } = Layout; +const FooterView = () => ( +
    + + Copyright 2019 SpringBlade{' '} + + {' '} + + + } + /> +
    +); +export default FooterView; diff --git a/src/layouts/Header.js b/src/layouts/Header.js new file mode 100644 index 0000000..69571c5 --- /dev/null +++ b/src/layouts/Header.js @@ -0,0 +1,176 @@ +import React from 'react'; +import { formatMessage } from 'umi/locale'; +import { Layout, message, Modal } from 'antd'; +import Animate from 'rc-animate'; +import { connect } from 'dva'; +import router from 'umi/router'; +import GlobalHeader from '@/components/GlobalHeader'; +import TopNavHeader from '@/components/TopNavHeader'; +import styles from './Header.less'; + +const { Header } = Layout; + +class HeaderView extends React.Component { + state = { + visible: true, + }; + + static getDerivedStateFromProps(props, state) { + if (!props.autoHideHeader && !state.visible) { + return { + visible: true, + }; + } + return null; + } + + componentDidMount() { + document.addEventListener('scroll', this.handScroll, { passive: true }); + } + + componentWillUnmount() { + document.removeEventListener('scroll', this.handScroll); + } + + getHeadWidth = () => { + const { isMobile, collapsed, setting } = this.props; + const { fixedHeader, layout } = setting; + if (isMobile || !fixedHeader || layout === 'topmenu') { + return '100%'; + } + return collapsed ? 'calc(100% - 80px)' : 'calc(100% - 256px)'; + }; + + handleNoticeClear = type => { + message.success( + `${formatMessage({ id: 'component.noticeIcon.cleared' })} ${formatMessage({ + id: `component.globalHeader.${type}`, + })}` + ); + const { dispatch } = this.props; + dispatch({ + type: 'global/clearNotices', + payload: type, + }); + }; + + handleMenuClick = ({ key }) => { + const { dispatch } = this.props; + if (key === 'userCenter') { + message.success('即将开放'); + // router.push('/account/center'); + return; + } + if (key === 'userinfo') { + router.push('/account/settings/base'); + return; + } + if (key === 'password') { + router.push('/account/settings/password'); + return; + } + if (key === 'triggerError') { + router.push('/exception/trigger'); + return; + } + if (key === 'logout') { + Modal.confirm({ + title: '退出确认', + content: '是否确定退出登录?', + okText: '确定', + okType: 'danger', + cancelText: '取消', + onOk() { + dispatch({ + type: 'login/logout', + }); + }, + onCancel() {}, + }); + } + }; + + handleNoticeVisibleChange = visible => { + if (visible) { + const { dispatch } = this.props; + dispatch({ + type: 'global/fetchNotices', + }); + } + }; + + handScroll = () => { + const { autoHideHeader } = this.props; + const { visible } = this.state; + if (!autoHideHeader) { + return; + } + const scrollTop = document.body.scrollTop + document.documentElement.scrollTop; + if (!this.ticking) { + this.ticking = true; + requestAnimationFrame(() => { + if (this.oldScrollTop > scrollTop) { + this.setState({ + visible: true, + }); + } else if (scrollTop > 300 && visible) { + this.setState({ + visible: false, + }); + } else if (scrollTop < 300 && !visible) { + this.setState({ + visible: true, + }); + } + this.oldScrollTop = scrollTop; + this.ticking = false; + }); + } + }; + + render() { + const { isMobile, handleMenuCollapse, setting } = this.props; + const { navTheme, layout, fixedHeader } = setting; + const { visible } = this.state; + const isTop = layout === 'topmenu'; + const width = this.getHeadWidth(); + const HeaderDom = visible ? ( +
    + {isTop && !isMobile ? ( + + ) : ( + + )} +
    + ) : null; + return ( + + {HeaderDom} + + ); + } +} + +export default connect(({ user, global, setting, loading }) => ({ + currentUser: user.currentUser, + collapsed: global.collapsed, + fetchingMoreNotices: loading.effects['global/fetchMoreNotices'], + fetchingNotices: loading.effects['global/fetchNotices'], + loadedAllNotices: global.loadedAllNotices, + notices: global.notices, + setting, +}))(HeaderView); diff --git a/src/layouts/Header.less b/src/layouts/Header.less new file mode 100644 index 0000000..cc2da96 --- /dev/null +++ b/src/layouts/Header.less @@ -0,0 +1,8 @@ +.fixedHeader { + position: fixed; + top: 0; + right: 0; + z-index: 9; + width: 100%; + transition: width 0.2s; +} diff --git a/src/layouts/MenuContext.js b/src/layouts/MenuContext.js new file mode 100644 index 0000000..860f106 --- /dev/null +++ b/src/layouts/MenuContext.js @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export default createContext(); diff --git a/src/layouts/Sword.less b/src/layouts/Sword.less new file mode 100644 index 0000000..43cd43a --- /dev/null +++ b/src/layouts/Sword.less @@ -0,0 +1,24 @@ +@import '~antd/lib/style/themes/default.less'; + +.inputItem { + :global { + .ant-input-number { + width: 100%; + } + } +} + +.card { + margin-bottom: 24px; +} + +.iconPreview { + font-size: 28px; + text-align: center; + margin-bottom: 20px; + cursor: pointer; +} + +.center { + text-align: center; +} diff --git a/src/layouts/UserLayout.js b/src/layouts/UserLayout.js new file mode 100644 index 0000000..151f09e --- /dev/null +++ b/src/layouts/UserLayout.js @@ -0,0 +1,67 @@ +import React, { Fragment } from 'react'; +import { formatMessage } from 'umi/locale'; +import Link from 'umi/link'; +import { Icon } from 'antd'; +import GlobalFooter from '@/components/GlobalFooter'; +import SelectLang from '@/components/SelectLang'; +import styles from './UserLayout.less'; +import logo from '../assets/logo.svg'; + +const links = [ + { + key: 'help', + title: formatMessage({ id: 'layout.user.link.help' }), + href: '', + }, + { + key: 'privacy', + title: formatMessage({ id: 'layout.user.link.privacy' }), + href: '', + }, + { + key: 'terms', + title: formatMessage({ id: 'layout.user.link.terms' }), + href: '', + }, +]; + +const copyright = ( + + Copyright 2019 SpringBlade{' '} + + {' '} + + +); + +const UserLayout = ({ children }) => ( + // @TODO +
    +
    + +
    +
    +
    +
    + + logo + Sword 企业级开发平台 + +
    +
    + Sword是SpringBlade前端UI项目,基于react 、ant design、umi、dva等流行技术栈。 +
    +
    + {children} +
    + +
    +); + +export default UserLayout; diff --git a/src/layouts/UserLayout.less b/src/layouts/UserLayout.less new file mode 100644 index 0000000..ba3d323 --- /dev/null +++ b/src/layouts/UserLayout.less @@ -0,0 +1,71 @@ +@import '~antd/lib/style/themes/default.less'; + +.container { + display: flex; + flex-direction: column; + height: 100vh; + overflow: auto; + background: @layout-body-background; +} + +.lang { + width: 100%; + height: 40px; + line-height: 44px; + text-align: right; + :global(.ant-dropdown-trigger) { + margin-right: 24px; + } +} + +.content { + flex: 1; + padding: 32px 0; +} + +@media (min-width: @screen-md-min) { + .container { + background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg'); + background-repeat: no-repeat; + background-position: center 110px; + background-size: 100%; + } + + .content { + padding: 32px 0 24px 0; + } +} + +.top { + text-align: center; +} + +.header { + height: 44px; + line-height: 44px; + a { + text-decoration: none; + } +} + +.logo { + height: 44px; + margin-right: 16px; + vertical-align: top; +} + +.title { + position: relative; + top: 2px; + color: @heading-color; + font-weight: 600; + font-size: 33px; + font-family: Avenir, 'Helvetica Neue', Arial, Helvetica, sans-serif; +} + +.desc { + margin-top: 12px; + margin-bottom: 40px; + color: @text-color-secondary; + font-size: @font-size-base; +} diff --git a/src/locales/en-US.js b/src/locales/en-US.js new file mode 100644 index 0000000..d681f2e --- /dev/null +++ b/src/locales/en-US.js @@ -0,0 +1,39 @@ +import analysis from './en-US/analysis'; +import desk from './en-US/desk'; +import exception from './en-US/exception'; +import form from './en-US/form'; +import global from './en-US/global'; +import globalHeader from './en-US/globalHeader'; +import login from './en-US/login'; +import menu from './en-US/menu'; +import monitor from './en-US/monitor'; +import result from './en-US/result'; +import settingDrawer from './en-US/settingDrawer'; +import settings from './en-US/settings'; +import pwa from './en-US/pwa'; +import component from './en-US/component'; + +export default { + 'navBar.lang': 'Languages', + 'layout.user.link.help': 'Help', + 'layout.user.link.privacy': 'Privacy', + 'layout.user.link.terms': 'Terms', + 'app.home.introduce': 'introduce', + 'app.forms.basic.title': 'Basic form', + 'app.forms.basic.description': + 'Form pages are used to collect or verify information to users, and basic forms are common in scenarios where there are fewer data items.', + ...analysis, + ...desk, + ...exception, + ...form, + ...global, + ...globalHeader, + ...login, + ...menu, + ...monitor, + ...result, + ...settingDrawer, + ...settings, + ...pwa, + ...component, +}; diff --git a/src/locales/en-US/analysis.js b/src/locales/en-US/analysis.js new file mode 100644 index 0000000..f3005da --- /dev/null +++ b/src/locales/en-US/analysis.js @@ -0,0 +1,34 @@ +export default { + 'app.analysis.test': 'Gongzhuan No.{no} shop', + 'app.analysis.introduce': 'Introduce', + 'app.analysis.total-sales': 'Total Sales', + 'app.analysis.day-sales': 'Daily Sales', + 'app.analysis.visits': 'Visits', + 'app.analysis.visits-trend': 'Visits Trend', + 'app.analysis.visits-ranking': 'Visits Ranking', + 'app.analysis.day-visits': 'Daily Visits', + 'app.analysis.week': 'WoW Change', + 'app.analysis.day': 'DoD Change', + 'app.analysis.payments': 'Payments', + 'app.analysis.conversion-rate': 'Conversion Rate', + 'app.analysis.operational-effect': 'Operational Effect', + 'app.analysis.sales-trend': 'Stores Sales Trend', + 'app.analysis.sales-ranking': 'Sales Ranking', + 'app.analysis.all-year': 'All Year', + 'app.analysis.all-month': 'All Month', + 'app.analysis.all-week': 'All Week', + 'app.analysis.all-day': 'All day', + 'app.analysis.search-users': 'Search Users', + 'app.analysis.per-capita-search': 'Per Capita Search', + 'app.analysis.online-top-search': 'Online Top Search', + 'app.analysis.the-proportion-of-sales': 'The Proportion Of Sales', + 'app.analysis.channel.all': 'ALL', + 'app.analysis.channel.online': 'Online', + 'app.analysis.channel.stores': 'Stores', + 'app.analysis.sales': 'Sales', + 'app.analysis.traffic': 'Traffic', + 'app.analysis.table.rank': 'Rank', + 'app.analysis.table.search-keyword': 'Keyword', + 'app.analysis.table.users': 'Users', + 'app.analysis.table.weekly-range': 'Weekly Range', +}; diff --git a/src/locales/en-US/component.js b/src/locales/en-US/component.js new file mode 100644 index 0000000..3ba7eed --- /dev/null +++ b/src/locales/en-US/component.js @@ -0,0 +1,5 @@ +export default { + 'component.tagSelect.expand': 'Expand', + 'component.tagSelect.collapse': 'Collapse', + 'component.tagSelect.all': 'All', +}; diff --git a/src/locales/en-US/desk.js b/src/locales/en-US/desk.js new file mode 100644 index 0000000..afc293a --- /dev/null +++ b/src/locales/en-US/desk.js @@ -0,0 +1,16 @@ +export default { + 'desk.notice.title': 'title', + 'desk.notice.title.placeholder': 'please enter a title', + 'desk.notice.title.validation': 'please enter a title', + 'desk.notice.category': 'category', + 'desk.notice.category.placeholder': 'please select a category', + 'desk.notice.category.validation': 'please select a category', + 'desk.notice.date': 'date', + 'desk.notice.date.placeholder': 'please enter a date', + 'desk.notice.date.validation': 'please enter a date', + 'desk.notice.date.start': 'start date', + 'desk.notice.date.end': 'end date', + 'desk.notice.content': 'content', + 'desk.notice.content.placeholder': 'please enter a content', + 'desk.notice.content.validation': 'please enter a content', +}; diff --git a/src/locales/en-US/exception.js b/src/locales/en-US/exception.js new file mode 100644 index 0000000..5035552 --- /dev/null +++ b/src/locales/en-US/exception.js @@ -0,0 +1,6 @@ +export default { + 'app.exception.back': 'Back to home', + 'app.exception.description.403': "Sorry, you don't have access to this page", + 'app.exception.description.404': 'Sorry, the page you visited does not exist', + 'app.exception.description.500': 'Sorry, the server is reporting an error', +}; diff --git a/src/locales/en-US/form.js b/src/locales/en-US/form.js new file mode 100644 index 0000000..36e088d --- /dev/null +++ b/src/locales/en-US/form.js @@ -0,0 +1,38 @@ +export default { + 'form.get-captcha': 'Get Captcha', + 'form.captcha.second': 'sec', + 'form.optional': ' (optional) ', + 'form.submit': 'Submit', + 'form.save': 'Save', + 'form.email.placeholder': 'Email', + 'form.password.placeholder': 'Password', + 'form.confirm-password.placeholder': 'Confirm password', + 'form.phone-number.placeholder': 'Phone number', + 'form.verification-code.placeholder': 'Verification code', + 'form.title.label': 'Title', + 'form.title.placeholder': 'Give the target a name', + 'form.date.label': 'Start and end date', + 'form.date.placeholder.start': 'Start date', + 'form.date.placeholder.end': 'End date', + 'form.goal.label': 'Goal description', + 'form.goal.placeholder': 'Please enter your work goals', + 'form.standard.label': 'Metrics', + 'form.standard.placeholder': 'Please enter a metric', + 'form.client.label': 'Client', + 'form.client.label.tooltip': 'Target service object', + 'form.client.placeholder': + 'Please describe your customer service, internal customers directly @ Name / job number', + 'form.invites.label': 'Inviting critics', + 'form.invites.placeholder': 'Please direct @ Name / job number, you can invite up to 5 people', + 'form.weight.label': 'Weight', + 'form.weight.placeholder': 'Please enter weight', + 'form.public.label': 'Target disclosure', + 'form.public.label.help': 'Customers and invitees are shared by default', + 'form.public.radio.public': 'Public', + 'form.public.radio.partially-public': 'Partially public', + 'form.public.radio.private': 'Private', + 'form.publicUsers.placeholder': 'Open to', + 'form.publicUsers.option.A': 'Colleague A', + 'form.publicUsers.option.B': 'Colleague B', + 'form.publicUsers.option.C': 'Colleague C', +}; diff --git a/src/locales/en-US/global.js b/src/locales/en-US/global.js new file mode 100644 index 0000000..8749539 --- /dev/null +++ b/src/locales/en-US/global.js @@ -0,0 +1,16 @@ +export default { + 'table.columns.action': 'action', + 'button.add.name': 'add', + 'button.submit.name': 'submit', + 'button.view.name': 'view', + 'button.edit.name': 'edit', + 'button.delete.name': 'delete', + 'button.search.name': 'search', + 'button.reset.name': 'reset', + 'button.recycle.name': 'recycle', + 'button.recovery.name': 'recovery', + 'button.expand.name': 'expand', + 'button.role.name': 'grant role', + 'button.reset-password.name': 'reset password', + 'button.back.name': 'back', +}; diff --git a/src/locales/en-US/globalHeader.js b/src/locales/en-US/globalHeader.js new file mode 100644 index 0000000..29f21d7 --- /dev/null +++ b/src/locales/en-US/globalHeader.js @@ -0,0 +1,18 @@ +export default { + 'component.globalHeader.search': 'Search', + 'component.globalHeader.search.example1': 'Search example 1', + 'component.globalHeader.search.example2': 'Search example 2', + 'component.globalHeader.search.example3': 'Search example 3', + 'component.globalHeader.help': 'Help', + 'component.globalHeader.notification': 'Notification', + 'component.globalHeader.notification.empty': 'You have viewed all notifications.', + 'component.globalHeader.message': 'Message', + 'component.globalHeader.message.empty': 'You have viewed all messsages.', + 'component.globalHeader.event': 'Event', + 'component.globalHeader.event.empty': 'You have viewed all events.', + 'component.noticeIcon.clear': 'Clear', + 'component.noticeIcon.cleared': 'Cleared', + 'component.noticeIcon.empty': 'No notifications', + 'component.noticeIcon.loaded': 'Loaded', + 'component.noticeIcon.loading-more': 'Loading more', +}; diff --git a/src/locales/en-US/login.js b/src/locales/en-US/login.js new file mode 100644 index 0000000..d1b6029 --- /dev/null +++ b/src/locales/en-US/login.js @@ -0,0 +1,42 @@ +export default { + 'app.login.tenantId': 'tenantId', + 'app.login.userName': 'userName', + 'app.login.password': 'password', + 'app.login.message-invalid-credentials': 'Invalid username or password(admin/ant.design)', + 'app.login.message-invalid-verification-code': 'Invalid verification code', + 'app.login.tab-login-credentials': 'Credentials', + 'app.login.tab-login-mobile': 'Mobile number', + 'app.login.remember-me': 'Remember me', + 'app.login.forgot-password': 'Forgot your password?', + 'app.login.sign-in-with': 'Sign in with', + 'app.login.signup': 'Sign up', + 'app.login.login': 'Login', + 'app.register.register': 'Register', + 'app.register.get-verification-code': 'Get code', + 'app.register.sign-in': 'Already have an account?', + 'app.register-result.msg': 'Account:registered at {email}', + 'app.register-result.activation-email': + 'The activation email has been sent to your email address and is valid for 24 hours. Please log in to the email in time and click on the link in the email to activate the account.', + 'app.register-result.back-home': 'Back to home', + 'app.register-result.view-mailbox': 'View mailbox', + 'validation.email.required': 'Please enter your email!', + 'validation.email.wrong-format': 'The email address is in the wrong format!', + 'validation.userName.tenantId': 'Please enter your tenantId!', + 'validation.userName.required': 'Please enter your userName!', + 'validation.password.required': 'Please enter your password!', + 'validation.captcha.required': 'Please enter your captcha!', + 'validation.password.twice': 'The passwords entered twice do not match!', + 'validation.password.strength.msg': + "Please enter at least 6 characters and don't use passwords that are easy to guess.", + 'validation.password.strength.strong': 'Strength: strong', + 'validation.password.strength.medium': 'Strength: medium', + 'validation.password.strength.short': 'Strength: too short', + 'validation.confirm-password.required': 'Please confirm your password!', + 'validation.phone-number.required': 'Please enter your phone number!', + 'validation.phone-number.wrong-format': 'Malformed phone number!', + 'validation.verification-code.required': 'Please enter the verification code!', + 'validation.title.required': 'Please enter a title', + 'validation.date.required': 'Please select the start and end date', + 'validation.goal.required': 'Please enter a description of the goal', + 'validation.standard.required': 'Please enter a metric', +}; diff --git a/src/locales/en-US/menu.js b/src/locales/en-US/menu.js new file mode 100644 index 0000000..40e9282 --- /dev/null +++ b/src/locales/en-US/menu.js @@ -0,0 +1,42 @@ +export default { + 'menu.home': 'Home', + 'menu.dashboard': 'Dashboard', + 'menu.dashboard.analysis': 'Analysis', + 'menu.dashboard.monitor': 'Monitor', + 'menu.dashboard.workplace': 'Workplace', + 'menu.desk': 'desktop', + 'menu.desk.notice': 'notice', + 'menu.system': 'system', + 'menu.system.user': 'user', + 'menu.system.dept': 'department', + 'menu.system.dict': 'dictionary', + 'menu.system.menu': 'menu', + 'menu.system.role': 'role', + 'menu.system.param': 'parameter', + 'menu.system.tenant': 'tenant', + 'menu.system.client': 'client', + 'menu.monitor': 'monitor', + 'menu.monitor.log': 'log', + 'menu.monitor.log.log_usual': 'usual log', + 'menu.monitor.log.log_api': 'api log', + 'menu.monitor.log.log_error': 'error log', + 'menu.monitor.admin': 'service admin', + 'menu.monitor.doc': 'api doc', + 'menu.tool': 'develop', + 'menu.tool.code': 'code generate', + 'menu.tool.datasource': 'datasource', + 'menu.result': 'Result', + 'menu.result.success': 'Success', + 'menu.result.fail': 'Fail', + 'menu.exception': 'Exception', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'Trigger', + 'menu.account': 'Account', + 'menu.account.center': 'Account Center', + 'menu.account.password': 'Modify Password', + 'menu.account.settings': 'Account Settings', + 'menu.account.trigger': 'Trigger Error', + 'menu.account.logout': 'Logout', +}; diff --git a/src/locales/en-US/monitor.js b/src/locales/en-US/monitor.js new file mode 100644 index 0000000..dcb5705 --- /dev/null +++ b/src/locales/en-US/monitor.js @@ -0,0 +1,18 @@ +export default { + 'app.monitor.trading-activity': 'Real-Time Trading Activity', + 'app.monitor.total-transactions': 'Total transactions today', + 'app.monitor.sales-target': 'Sales target completion rate', + 'app.monitor.remaining-time': 'Remaining time of activity', + 'app.monitor.total-transactions-per-second': 'Total transactions per second', + 'app.monitor.activity-forecast': 'Activity forecast', + 'app.monitor.efficiency': 'Efficiency', + 'app.monitor.ratio': 'Ratio', + 'app.monitor.proportion-per-category': 'Proportion Per Category', + 'app.monitor.fast-food': 'Fast food', + 'app.monitor.western-food': 'Western food', + 'app.monitor.hot-pot': 'Hot pot', + 'app.monitor.waiting-for-implementation': 'Waiting for implementation', + 'app.monitor.popular-searches': 'Popular Searches', + 'app.monitor.resource-surplus': 'Resource Surplus', + 'app.monitor.fund-surplus': 'Fund Surplus', +}; diff --git a/src/locales/en-US/pwa.js b/src/locales/en-US/pwa.js new file mode 100644 index 0000000..ed8d199 --- /dev/null +++ b/src/locales/en-US/pwa.js @@ -0,0 +1,6 @@ +export default { + 'app.pwa.offline': 'You are offline now', + 'app.pwa.serviceworker.updated': 'New content is available', + 'app.pwa.serviceworker.updated.hint': 'Please press the "Refresh" button to reload current page', + 'app.pwa.serviceworker.updated.ok': 'Refresh', +}; diff --git a/src/locales/en-US/result.js b/src/locales/en-US/result.js new file mode 100644 index 0000000..23de8b7 --- /dev/null +++ b/src/locales/en-US/result.js @@ -0,0 +1,28 @@ +export default { + 'app.result.error.title': 'Submission Failed', + 'app.result.error.description': + 'Please check and modify the following information before resubmitting.', + 'app.result.error.hint-title': 'The content you submitted has the following error:', + 'app.result.error.hint-text1': 'Your account has been frozen', + 'app.result.error.hint-btn1': 'Thaw immediately', + 'app.result.error.hint-text2': 'Your account is not yet eligible to apply', + 'app.result.error.hint-btn2': 'Upgrade immediately', + 'app.result.error.btn-text': 'Return to modify', + 'app.result.success.title': 'Submission Success', + 'app.result.success.description': + 'The submission results page is used to feed back the results of a series of operational tasks. If it is a simple operation, use the Message global prompt feedback. This text area can show a simple supplementary explanation. If there is a similar requirement for displaying “documents”, the following gray area can present more complicated content.', + 'app.result.success.operate-title': 'Project Name', + 'app.result.success.operate-id': 'Project ID:', + 'app.result.success.principal': 'Principal:', + 'app.result.success.operate-time': 'Effective time:', + 'app.result.success.step1-title': 'Create project', + 'app.result.success.step1-operator': 'Qu Lili', + 'app.result.success.step2-title': 'Departmental preliminary review', + 'app.result.success.step2-operator': 'Zhou Maomao', + 'app.result.success.step2-extra': 'Urge', + 'app.result.success.step3-title': 'Financial review', + 'app.result.success.step4-title': 'Finish', + 'app.result.success.btn-return': 'Back to list', + 'app.result.success.btn-project': 'View project', + 'app.result.success.btn-print': 'Print', +}; diff --git a/src/locales/en-US/settingDrawer.js b/src/locales/en-US/settingDrawer.js new file mode 100644 index 0000000..a644905 --- /dev/null +++ b/src/locales/en-US/settingDrawer.js @@ -0,0 +1,31 @@ +export default { + 'app.setting.pagestyle': 'Page style setting', + 'app.setting.pagestyle.dark': 'Dark style', + 'app.setting.pagestyle.light': 'Light style', + 'app.setting.content-width': 'Content Width', + 'app.setting.content-width.fixed': 'Fixed', + 'app.setting.content-width.fluid': 'Fluid', + 'app.setting.themecolor': 'Theme Color', + 'app.setting.themecolor.dust': 'Dust Red', + 'app.setting.themecolor.volcano': 'Volcano', + 'app.setting.themecolor.sunset': 'Sunset Orange', + 'app.setting.themecolor.cyan': 'Cyan', + 'app.setting.themecolor.green': 'Polar Green', + 'app.setting.themecolor.daybreak': 'Daybreak Blue (default)', + 'app.setting.themecolor.geekblue': 'Geek Glue', + 'app.setting.themecolor.purple': 'Golden Purple', + 'app.setting.navigationmode': 'Navigation Mode', + 'app.setting.sidemenu': 'Side Menu Layout', + 'app.setting.topmenu': 'Top Menu Layout', + 'app.setting.fixedheader': 'Fixed Header', + 'app.setting.fixedsidebar': 'Fixed Sidebar', + 'app.setting.fixedsidebar.hint': 'Works on Side Menu Layout', + 'app.setting.hideheader': 'Hidden Header when scrolling', + 'app.setting.hideheader.hint': 'Works when Hidden Header is enabled', + 'app.setting.othersettings': 'Other Settings', + 'app.setting.weakmode': 'Weak Mode', + 'app.setting.copy': 'Copy Setting', + 'app.setting.copyinfo': 'copy success,please replace defaultSettings in src/models/setting.js', + 'app.setting.production.hint': + 'Setting panel shows in development environment only, please manually modify', +}; diff --git a/src/locales/en-US/settings.js b/src/locales/en-US/settings.js new file mode 100644 index 0000000..0e8318b --- /dev/null +++ b/src/locales/en-US/settings.js @@ -0,0 +1,68 @@ +export default { + 'app.settings.menuMap.basic': 'Basic Settings', + 'app.settings.menuMap.security': 'Security Settings', + 'app.settings.menuMap.binding': 'Account Binding', + 'app.settings.menuMap.notification': 'New Message Notification', + 'app.settings.basic.avatar': 'Avatar', + 'app.settings.basic.change-avatar': 'Change avatar', + 'app.settings.basic.email': 'Email', + 'app.settings.basic.email-message': 'Please input your email!', + 'app.settings.basic.nickname': 'Nickname', + 'app.settings.basic.nickname-message': 'Please input your Nickname!', + 'app.settings.basic.realname': 'Realname', + 'app.settings.basic.realname-message': 'Please input your Realname!', + 'app.settings.basic.profile': 'Personal profile', + 'app.settings.basic.profile-message': 'Please input your personal profile!', + 'app.settings.basic.profile-placeholder': 'Brief introduction to yourself', + 'app.settings.basic.country': 'Country/Region', + 'app.settings.basic.country-message': 'Please input your country!', + 'app.settings.basic.geographic': 'Province or city', + 'app.settings.basic.geographic-message': 'Please input your geographic info!', + 'app.settings.basic.address': 'Street Address', + 'app.settings.basic.address-message': 'Please input your address!', + 'app.settings.basic.phone': 'Phone Number', + 'app.settings.basic.phone-message': 'Please input your phone!', + 'app.settings.basic.update': 'Update Information', + 'app.settings.password.old': 'Old Password', + 'app.settings.password.old-message': 'Please input your Old Password!', + 'app.settings.password.new': 'New Password', + 'app.settings.password.new-message': 'Please input your new Password!', + 'app.settings.password.new1': 'New Password1', + 'app.settings.password.new1-message': 'Please comfirm your new Password!', + 'app.settings.security.strong': 'Strong', + 'app.settings.security.medium': 'Medium', + 'app.settings.security.weak': 'Weak', + 'app.settings.security.password': 'Account Password', + 'app.settings.security.password-description': 'Current password strength', + 'app.settings.security.phone': 'Security Phone', + 'app.settings.security.phone-description': 'Bound phone', + 'app.settings.security.question': 'Security Question', + 'app.settings.security.question-description': + 'The security question is not set, and the security policy can effectively protect the account security', + 'app.settings.security.email': 'Backup Email', + 'app.settings.security.email-description': 'Bound Email', + 'app.settings.security.mfa': 'MFA Device', + 'app.settings.security.mfa-description': + 'Unbound MFA device, after binding, can be confirmed twice', + 'app.settings.security.modify': 'Modify', + 'app.settings.security.set': 'Set', + 'app.settings.security.bind': 'Bind', + 'app.settings.binding.taobao': 'Binding Taobao', + 'app.settings.binding.taobao-description': 'Currently unbound Taobao account', + 'app.settings.binding.alipay': 'Binding Alipay', + 'app.settings.binding.alipay-description': 'Currently unbound Alipay account', + 'app.settings.binding.dingding': 'Binding DingTalk', + 'app.settings.binding.dingding-description': 'Currently unbound DingTalk account', + 'app.settings.binding.bind': 'Bind', + 'app.settings.notification.password': 'Account Password', + 'app.settings.notification.password-description': + 'Messages from other users will be notified in the form of a station letter', + 'app.settings.notification.messages': 'System Messages', + 'app.settings.notification.messages-description': + 'System messages will be notified in the form of a station letter', + 'app.settings.notification.todo': 'To-do Notification', + 'app.settings.notification.todo-description': + 'The to-do list will be notified in the form of a letter from the station', + 'app.settings.open': 'Open', + 'app.settings.close': 'Close', +}; diff --git a/src/locales/zh-CN.js b/src/locales/zh-CN.js new file mode 100644 index 0000000..96b7f51 --- /dev/null +++ b/src/locales/zh-CN.js @@ -0,0 +1,39 @@ +import analysis from './zh-CN/analysis'; +import exception from './zh-CN/exception'; +import form from './zh-CN/form'; +import desk from './zh-CN/desk'; +import global from './zh-CN/global'; +import globalHeader from './zh-CN/globalHeader'; +import login from './zh-CN/login'; +import menu from './zh-CN/menu'; +import monitor from './zh-CN/monitor'; +import result from './zh-CN/result'; +import settingDrawer from './zh-CN/settingDrawer'; +import settings from './zh-CN/settings'; +import pwa from './zh-CN/pwa'; +import component from './zh-CN/component'; + +export default { + 'navBar.lang': '语言', + 'layout.user.link.help': '帮助', + 'layout.user.link.privacy': '隐私', + 'layout.user.link.terms': '条款', + 'app.home.introduce': '介绍', + 'app.forms.basic.title': '基础表单', + 'app.forms.basic.description': + '表单页用于向用户收集或验证信息,基础表单常见于数据项较少的表单场景。', + ...analysis, + ...exception, + ...form, + ...desk, + ...global, + ...globalHeader, + ...login, + ...menu, + ...monitor, + ...result, + ...settingDrawer, + ...settings, + ...pwa, + ...component, +}; diff --git a/src/locales/zh-CN/analysis.js b/src/locales/zh-CN/analysis.js new file mode 100644 index 0000000..8ed17ed --- /dev/null +++ b/src/locales/zh-CN/analysis.js @@ -0,0 +1,34 @@ +export default { + 'app.analysis.test': '工专路 {no} 号店', + 'app.analysis.introduce': '指标说明', + 'app.analysis.total-sales': '总销售额', + 'app.analysis.day-sales': '日销售额', + 'app.analysis.visits': '访问量', + 'app.analysis.visits-trend': '访问量趋势', + 'app.analysis.visits-ranking': '门店访问量排名', + 'app.analysis.day-visits': '日访问量', + 'app.analysis.week': '周同比', + 'app.analysis.day': '日同比', + 'app.analysis.payments': '支付笔数', + 'app.analysis.conversion-rate': '转化率', + 'app.analysis.operational-effect': '运营活动效果', + 'app.analysis.sales-trend': '销售趋势', + 'app.analysis.sales-ranking': '门店销售额排名', + 'app.analysis.all-year': '全年', + 'app.analysis.all-month': '本月', + 'app.analysis.all-week': '本周', + 'app.analysis.all-day': '今日', + 'app.analysis.search-users': '搜索用户数', + 'app.analysis.per-capita-search': '人均搜索次数', + 'app.analysis.online-top-search': '线上热门搜索', + 'app.analysis.the-proportion-of-sales': '销售额类别占比', + 'app.analysis.channel.all': '全部渠道', + 'app.analysis.channel.online': '线上', + 'app.analysis.channel.stores': '门店', + 'app.analysis.sales': '销售额', + 'app.analysis.traffic': '客流量', + 'app.analysis.table.rank': '排名', + 'app.analysis.table.search-keyword': '搜索关键词', + 'app.analysis.table.users': '用户数', + 'app.analysis.table.weekly-range': '周涨幅', +}; diff --git a/src/locales/zh-CN/component.js b/src/locales/zh-CN/component.js new file mode 100644 index 0000000..1f1fead --- /dev/null +++ b/src/locales/zh-CN/component.js @@ -0,0 +1,5 @@ +export default { + 'component.tagSelect.expand': '展开', + 'component.tagSelect.collapse': '收起', + 'component.tagSelect.all': '全部', +}; diff --git a/src/locales/zh-CN/desk.js b/src/locales/zh-CN/desk.js new file mode 100644 index 0000000..7a7cd78 --- /dev/null +++ b/src/locales/zh-CN/desk.js @@ -0,0 +1,16 @@ +export default { + 'desk.notice.title': '通知标题', + 'desk.notice.title.placeholder': '请输入通知标题', + 'desk.notice.title.validation': '请输入通知标题', + 'desk.notice.category': '通知类型', + 'desk.notice.category.placeholder': '请选择通知类型', + 'desk.notice.category.validation': '请选择通知类型', + 'desk.notice.date': '通知日期', + 'desk.notice.date.placeholder': '请选择通知日期', + 'desk.notice.date.validation': '请选择通知日期', + 'desk.notice.date.start': '开始日期', + 'desk.notice.date.end': '结束日期', + 'desk.notice.content': '通知内容', + 'desk.notice.content.placeholder': '请输入通知内容', + 'desk.notice.content.validation': '请输入通知内容', +}; diff --git a/src/locales/zh-CN/exception.js b/src/locales/zh-CN/exception.js new file mode 100644 index 0000000..6f645da --- /dev/null +++ b/src/locales/zh-CN/exception.js @@ -0,0 +1,6 @@ +export default { + 'app.exception.back': '返回首页', + 'app.exception.description.403': '抱歉,你无权访问该页面', + 'app.exception.description.404': '抱歉,你访问的页面不存在', + 'app.exception.description.500': '抱歉,服务器出错了', +}; diff --git a/src/locales/zh-CN/form.js b/src/locales/zh-CN/form.js new file mode 100644 index 0000000..7f3bd95 --- /dev/null +++ b/src/locales/zh-CN/form.js @@ -0,0 +1,37 @@ +export default { + 'form.get-captcha': '获取验证码', + 'form.captcha.second': '秒', + 'form.optional': '(选填)', + 'form.submit': '提交', + 'form.save': '保存', + 'form.email.placeholder': '邮箱', + 'form.password.placeholder': '至少6位密码,区分大小写', + 'form.confirm-password.placeholder': '确认密码', + 'form.phone-number.placeholder': '手机号', + 'form.verification-code.placeholder': '验证码', + 'form.title.label': '标题', + 'form.title.placeholder': '给目标起个名字', + 'form.date.label': '起止日期', + 'form.date.placeholder.start': '开始日期', + 'form.date.placeholder.end': '结束日期', + 'form.goal.label': '目标描述', + 'form.goal.placeholder': '请输入你的阶段性工作目标', + 'form.standard.label': '衡量标准', + 'form.standard.placeholder': '请输入衡量标准', + 'form.client.label': '客户', + 'form.client.label.tooltip': '目标的服务对象', + 'form.client.placeholder': '请描述你服务的客户,内部客户直接 @姓名/工号', + 'form.invites.label': '邀评人', + 'form.invites.placeholder': '请直接 @姓名/工号,最多可邀请 5 人', + 'form.weight.label': '权重', + 'form.weight.placeholder': '请输入', + 'form.public.label': '目标公开', + 'form.public.label.help': '客户、邀评人默认被分享', + 'form.public.radio.public': '公开', + 'form.public.radio.partially-public': '部分公开', + 'form.public.radio.private': '不公开', + 'form.publicUsers.placeholder': '公开给', + 'form.publicUsers.option.A': '同事甲', + 'form.publicUsers.option.B': '同事乙', + 'form.publicUsers.option.C': '同事丙', +}; diff --git a/src/locales/zh-CN/global.js b/src/locales/zh-CN/global.js new file mode 100644 index 0000000..d890da8 --- /dev/null +++ b/src/locales/zh-CN/global.js @@ -0,0 +1,16 @@ +export default { + 'table.columns.action': '操作', + 'button.add.name': '新增', + 'button.submit.name': '提交', + 'button.view.name': '查看', + 'button.edit.name': '修改', + 'button.delete.name': '删除', + 'button.search.name': '查询', + 'button.reset.name': '重置', + 'button.recycle.name': '回收站', + 'button.recovery.name': '恢复', + 'button.expand.name': '展开', + 'button.role.name': '角色配置', + 'button.reset-password.name': '密码重置', + 'button.back.name': '返回', +}; diff --git a/src/locales/zh-CN/globalHeader.js b/src/locales/zh-CN/globalHeader.js new file mode 100644 index 0000000..2045382 --- /dev/null +++ b/src/locales/zh-CN/globalHeader.js @@ -0,0 +1,18 @@ +export default { + 'component.globalHeader.search': '站内搜索', + 'component.globalHeader.search.example1': '搜索提示一', + 'component.globalHeader.search.example2': '搜索提示二', + 'component.globalHeader.search.example3': '搜索提示三', + 'component.globalHeader.help': '使用文档', + 'component.globalHeader.notification': '通知', + 'component.globalHeader.notification.empty': '你已查看所有通知', + 'component.globalHeader.message': '消息', + 'component.globalHeader.message.empty': '您已读完所有消息', + 'component.globalHeader.event': '待办', + 'component.globalHeader.event.empty': '你已完成所有待办', + 'component.noticeIcon.clear': '清空', + 'component.noticeIcon.cleared': '清空了', + 'component.noticeIcon.empty': '暂无数据', + 'component.noticeIcon.loaded': '加载完毕', + 'component.noticeIcon.loading-more': '加载更多', +}; diff --git a/src/locales/zh-CN/login.js b/src/locales/zh-CN/login.js new file mode 100644 index 0000000..aae59fe --- /dev/null +++ b/src/locales/zh-CN/login.js @@ -0,0 +1,41 @@ +export default { + 'app.login.tenantId': '租户ID', + 'app.login.userName': '用户名', + 'app.login.password': '密码', + 'app.login.message-invalid-credentials': '账户或密码错误(admin/ant.design)', + 'app.login.message-invalid-verification-code': '验证码错误', + 'app.login.tab-login-credentials': '账户密码登录', + 'app.login.tab-login-mobile': '手机号登录', + 'app.login.remember-me': '自动登录', + 'app.login.forgot-password': '忘记密码', + 'app.login.sign-in-with': '其他登录方式', + 'app.login.signup': '注册账户', + 'app.login.login': '登录', + 'app.register.register': '注册', + 'app.register.get-verification-code': '获取验证码', + 'app.register.sign-in': '使用已有账户登录', + 'app.register-result.msg': '你的账户:{email} 注册成功', + 'app.register-result.activation-email': + '激活邮件已发送到你的邮箱中,邮件有效期为24小时。请及时登录邮箱,点击邮件中的链接激活帐户。', + 'app.register-result.back-home': '返回首页', + 'app.register-result.view-mailbox': '查看邮箱', + 'validation.email.required': '请输入邮箱地址!', + 'validation.email.wrong-format': '邮箱地址格式错误!', + 'validation.tenantId.required': '请输入租户ID!', + 'validation.userName.required': '请输入用户名!', + 'validation.password.required': '请输入密码!', + 'validation.captcha.required': '请输入验证码!', + 'validation.password.twice': '两次输入的密码不匹配!', + 'validation.password.strength.msg': '请至少输入 6 个字符。请不要使用容易被猜到的密码。', + 'validation.password.strength.strong': '强度:强', + 'validation.password.strength.medium': '强度:中', + 'validation.password.strength.short': '强度:太短', + 'validation.confirm-password.required': '请确认密码!', + 'validation.phone-number.required': '请输入手机号!', + 'validation.phone-number.wrong-format': '手机号格式错误!', + 'validation.verification-code.required': '请输入验证码!', + 'validation.title.required': '请输入标题', + 'validation.date.required': '请选择起止日期', + 'validation.goal.required': '请输入目标描述', + 'validation.standard.required': '请输入衡量标准', +}; diff --git a/src/locales/zh-CN/menu.js b/src/locales/zh-CN/menu.js new file mode 100644 index 0000000..f8cee1d --- /dev/null +++ b/src/locales/zh-CN/menu.js @@ -0,0 +1,42 @@ +export default { + 'menu.home': '首页', + 'menu.dashboard': '仪表盘', + 'menu.dashboard.analysis': '分析页', + 'menu.dashboard.monitor': '监控页', + 'menu.dashboard.workplace': '工作台', + 'menu.desk': '工作台', + 'menu.desk.notice': '通知公告', + 'menu.system': '系统管理', + 'menu.system.user': '用户管理', + 'menu.system.dept': '部门管理', + 'menu.system.dict': '字典管理', + 'menu.system.menu': '菜单管理', + 'menu.system.role': '角色管理', + 'menu.system.param': '参数管理', + 'menu.system.tenant': '租户管理', + 'menu.system.client': '应用管理', + 'menu.monitor': '系统监控', + 'menu.monitor.log': '日志管理', + 'menu.monitor.log.log_usual': '通用日志', + 'menu.monitor.log.log_api': '接口日志', + 'menu.monitor.log.log_error': '错误日志', + 'menu.monitor.admin': '服务治理', + 'menu.monitor.doc': '接口文档', + 'menu.tool': '研发工具', + 'menu.tool.code': '代码生成', + 'menu.tool.datasource': '数据源管理', + 'menu.result': '结果页', + 'menu.result.success': '成功页', + 'menu.result.fail': '失败页', + 'menu.exception': '异常页', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': '触发错误', + 'menu.account': '个人页', + 'menu.account.center': '个人中心', + 'menu.account.password': '密码修改', + 'menu.account.settings': '个人设置', + 'menu.account.trigger': '触发报错', + 'menu.account.logout': '退出登录', +}; diff --git a/src/locales/zh-CN/monitor.js b/src/locales/zh-CN/monitor.js new file mode 100644 index 0000000..3a3e3f0 --- /dev/null +++ b/src/locales/zh-CN/monitor.js @@ -0,0 +1,18 @@ +export default { + 'app.monitor.trading-activity': '活动实时交易情况', + 'app.monitor.total-transactions': '今日交易总额', + 'app.monitor.sales-target': '销售目标完成率', + 'app.monitor.remaining-time': '活动剩余时间', + 'app.monitor.total-transactions-per-second': '每秒交易总额', + 'app.monitor.activity-forecast': '活动情况预测', + 'app.monitor.efficiency': '券核效率', + 'app.monitor.ratio': '跳出率', + 'app.monitor.proportion-per-category': '各品类占比', + 'app.monitor.fast-food': '中式快餐', + 'app.monitor.western-food': '西餐', + 'app.monitor.hot-pot': '火锅', + 'app.monitor.waiting-for-implementation': 'Waiting for implementation', + 'app.monitor.popular-searches': '热门搜索', + 'app.monitor.resource-surplus': '资源剩余', + 'app.monitor.fund-surplus': '补贴资金剩余', +}; diff --git a/src/locales/zh-CN/pwa.js b/src/locales/zh-CN/pwa.js new file mode 100644 index 0000000..e950484 --- /dev/null +++ b/src/locales/zh-CN/pwa.js @@ -0,0 +1,6 @@ +export default { + 'app.pwa.offline': '当前处于离线状态', + 'app.pwa.serviceworker.updated': '有新内容', + 'app.pwa.serviceworker.updated.hint': '请点击“刷新”按钮或者手动刷新页面', + 'app.pwa.serviceworker.updated.ok': '刷新', +}; diff --git a/src/locales/zh-CN/result.js b/src/locales/zh-CN/result.js new file mode 100644 index 0000000..cba0e1c --- /dev/null +++ b/src/locales/zh-CN/result.js @@ -0,0 +1,27 @@ +export default { + 'app.result.error.title': '提交失败', + 'app.result.error.description': '请核对并修改以下信息后,再重新提交。', + 'app.result.error.hint-title': '您提交的内容有如下错误:', + 'app.result.error.hint-text1': '您的账户已被冻结', + 'app.result.error.hint-btn1': '立即解冻', + 'app.result.error.hint-text2': '您的账户还不具备申请资格', + 'app.result.error.hint-btn2': '立即升级', + 'app.result.error.btn-text': '返回修改', + 'app.result.success.title': '提交成功', + 'app.result.success.description': + '提交结果页用于反馈一系列操作任务的处理结果, 如果仅是简单操作,使用 Message 全局提示反馈即可。 本文字区域可以展示简单的补充说明,如果有类似展示 “单据”的需求,下面这个灰色区域可以呈现比较复杂的内容。', + 'app.result.success.operate-title': '项目名称', + 'app.result.success.operate-id': '项目 ID:', + 'app.result.success.principal': '负责人:', + 'app.result.success.operate-time': '生效时间:', + 'app.result.success.step1-title': '创建项目', + 'app.result.success.step1-operator': '曲丽丽', + 'app.result.success.step2-title': '部门初审', + 'app.result.success.step2-operator': '周毛毛', + 'app.result.success.step2-extra': '催一下', + 'app.result.success.step3-title': '财务复核', + 'app.result.success.step4-title': '完成', + 'app.result.success.btn-return': '返回列表', + 'app.result.success.btn-project': '查看项目', + 'app.result.success.btn-print': '打印', +}; diff --git a/src/locales/zh-CN/settingDrawer.js b/src/locales/zh-CN/settingDrawer.js new file mode 100644 index 0000000..15685a4 --- /dev/null +++ b/src/locales/zh-CN/settingDrawer.js @@ -0,0 +1,31 @@ +export default { + 'app.setting.pagestyle': '整体风格设置', + 'app.setting.pagestyle.dark': '暗色菜单风格', + 'app.setting.pagestyle.light': '亮色菜单风格', + 'app.setting.content-width': '内容区域宽度', + 'app.setting.content-width.fixed': '定宽', + 'app.setting.content-width.fluid': '流式', + 'app.setting.themecolor': '主题色', + 'app.setting.themecolor.dust': '薄暮', + 'app.setting.themecolor.volcano': '火山', + 'app.setting.themecolor.sunset': '日暮', + 'app.setting.themecolor.cyan': '明青', + 'app.setting.themecolor.green': '极光绿', + 'app.setting.themecolor.daybreak': '拂晓蓝(默认)', + 'app.setting.themecolor.geekblue': '极客蓝', + 'app.setting.themecolor.purple': '酱紫', + 'app.setting.navigationmode': '导航模式', + 'app.setting.sidemenu': '侧边菜单布局', + 'app.setting.topmenu': '顶部菜单布局', + 'app.setting.fixedheader': '固定 Header', + 'app.setting.fixedsidebar': '固定侧边菜单', + 'app.setting.fixedsidebar.hint': '侧边菜单布局时可配置', + 'app.setting.hideheader': '下滑时隐藏 Header', + 'app.setting.hideheader.hint': '固定 Header 时可配置', + 'app.setting.othersettings': '其他设置', + 'app.setting.weakmode': '色弱模式', + 'app.setting.copy': '拷贝设置', + 'app.setting.copyinfo': '拷贝成功,请到 src/defaultSettings.js 中替换默认配置', + 'app.setting.production.hint': + '配置栏只在开发环境用于预览,生产环境不会展现,请拷贝后手动修改配置文件', +}; diff --git a/src/locales/zh-CN/settings.js b/src/locales/zh-CN/settings.js new file mode 100644 index 0000000..be7f3c5 --- /dev/null +++ b/src/locales/zh-CN/settings.js @@ -0,0 +1,63 @@ +export default { + 'app.settings.menuMap.basic': '基本设置', + 'app.settings.menuMap.security': '安全设置', + 'app.settings.menuMap.binding': '账号绑定', + 'app.settings.menuMap.notification': '新消息通知', + 'app.settings.basic.avatar': '头像', + 'app.settings.basic.change-avatar': '更换头像', + 'app.settings.basic.email': '邮箱', + 'app.settings.basic.email-message': '请输入您的邮箱!', + 'app.settings.basic.nickname': '昵称', + 'app.settings.basic.nickname-message': '请输入您的昵称!', + 'app.settings.basic.realname': '姓名', + 'app.settings.basic.realname-message': '请输入您的姓名!', + 'app.settings.basic.profile': '个人简介', + 'app.settings.basic.profile-message': '请输入个人简介!', + 'app.settings.basic.profile-placeholder': '个人简介', + 'app.settings.basic.country': '国家/地区', + 'app.settings.basic.country-message': '请输入您的国家或地区!', + 'app.settings.basic.geographic': '所在省市', + 'app.settings.basic.geographic-message': '请输入您的所在省市!', + 'app.settings.basic.address': '街道地址', + 'app.settings.basic.address-message': '请输入您的街道地址!', + 'app.settings.basic.phone': '联系电话', + 'app.settings.basic.phone-message': '请输入您的联系电话!', + 'app.settings.basic.update': '更新基本信息', + 'app.settings.password.old': '旧密码', + 'app.settings.password.old-message': '请输入你的旧密码!', + 'app.settings.password.new': '新密码', + 'app.settings.password.new-message': '请输入你的新密码!', + 'app.settings.password.new1': '确认密码', + 'app.settings.password.new1-message': '请输入你的确认密码!', + 'app.settings.security.strong': '强', + 'app.settings.security.medium': '中', + 'app.settings.security.weak': '弱', + 'app.settings.security.password': '账户密码', + 'app.settings.security.password-description': '当前密码强度', + 'app.settings.security.phone': '密保手机', + 'app.settings.security.phone-description': '已绑定手机', + 'app.settings.security.question': '密保问题', + 'app.settings.security.question-description': '未设置密保问题,密保问题可有效保护账户安全', + 'app.settings.security.email': '备用邮箱', + 'app.settings.security.email-description': '已绑定邮箱', + 'app.settings.security.mfa': 'MFA 设备', + 'app.settings.security.mfa-description': '未绑定 MFA 设备,绑定后,可以进行二次确认', + 'app.settings.security.modify': '修改', + 'app.settings.security.set': '设置', + 'app.settings.security.bind': '绑定', + 'app.settings.binding.taobao': '绑定淘宝', + 'app.settings.binding.taobao-description': '当前未绑定淘宝账号', + 'app.settings.binding.alipay': '绑定支付宝', + 'app.settings.binding.alipay-description': '当前未绑定支付宝账号', + 'app.settings.binding.dingding': '绑定钉钉', + 'app.settings.binding.dingding-description': '当前未绑定钉钉账号', + 'app.settings.binding.bind': '绑定', + 'app.settings.notification.password': '账户密码', + 'app.settings.notification.password-description': '其他用户的消息将以站内信的形式通知', + 'app.settings.notification.messages': '系统消息', + 'app.settings.notification.messages-description': '系统消息将以站内信的形式通知', + 'app.settings.notification.todo': '待办任务', + 'app.settings.notification.todo-description': '待办任务将以站内信的形式通知', + 'app.settings.open': '开', + 'app.settings.close': '关', +}; diff --git a/src/locales/zh-TW.js b/src/locales/zh-TW.js new file mode 100644 index 0000000..6f290d6 --- /dev/null +++ b/src/locales/zh-TW.js @@ -0,0 +1,39 @@ +import analysis from './zh-TW/analysis'; +import exception from './zh-TW/exception'; +import form from './zh-TW/form'; +import desk from './zh-TW/desk'; +import global from './zh-TW/global'; +import globalHeader from './zh-TW/globalHeader'; +import login from './zh-TW/login'; +import menu from './zh-TW/menu'; +import monitor from './zh-TW/monitor'; +import result from './zh-TW/result'; +import settingDrawer from './zh-TW/settingDrawer'; +import settings from './zh-TW/settings'; +import pwa from './zh-TW/pwa'; +import component from './zh-TW/component'; + +export default { + 'navBar.lang': '語言', + 'layout.user.link.help': '幫助', + 'layout.user.link.privacy': '隱私', + 'layout.user.link.terms': '條款', + 'app.home.introduce': '介紹', + 'app.forms.basic.title': '基礎表單', + 'app.forms.basic.description': + '表單頁用於向用戶收集或驗證信息,基礎表單常見於數據項較少的表單場景。', + ...analysis, + ...exception, + ...form, + ...desk, + ...global, + ...globalHeader, + ...login, + ...menu, + ...monitor, + ...result, + ...settingDrawer, + ...settings, + ...pwa, + ...component, +}; diff --git a/src/locales/zh-TW/analysis.js b/src/locales/zh-TW/analysis.js new file mode 100644 index 0000000..7b2e37c --- /dev/null +++ b/src/locales/zh-TW/analysis.js @@ -0,0 +1,34 @@ +export default { + 'app.analysis.test': '工專路 {no} 號店', + 'app.analysis.introduce': '指標說明', + 'app.analysis.total-sales': '總銷售額', + 'app.analysis.day-sales': '日銷售額', + 'app.analysis.visits': '訪問量', + 'app.analysis.visits-trend': '訪問量趨勢', + 'app.analysis.visits-ranking': '門店訪問量排名', + 'app.analysis.day-visits': '日訪問量', + 'app.analysis.week': '周同比', + 'app.analysis.day': '日同比', + 'app.analysis.payments': '支付筆數', + 'app.analysis.conversion-rate': '轉化率', + 'app.analysis.operational-effect': '運營活動效果', + 'app.analysis.sales-trend': '銷售趨勢', + 'app.analysis.sales-ranking': '門店銷售額排名', + 'app.analysis.all-year': '全年', + 'app.analysis.all-month': '本月', + 'app.analysis.all-week': '本周', + 'app.analysis.all-day': '今日', + 'app.analysis.search-users': '搜索用戶數', + 'app.analysis.per-capita-search': '人均搜索次數', + 'app.analysis.online-top-search': '線上熱門搜索', + 'app.analysis.the-proportion-of-sales': '銷售額類別占比', + 'app.analysis.channel.all': '全部渠道', + 'app.analysis.channel.online': '線上', + 'app.analysis.channel.stores': '門店', + 'app.analysis.sales': '銷售額', + 'app.analysis.traffic': '客流量', + 'app.analysis.table.rank': '排名', + 'app.analysis.table.search-keyword': '搜索關鍵詞', + 'app.analysis.table.users': '用戶數', + 'app.analysis.table.weekly-range': '周漲幅', +}; diff --git a/src/locales/zh-TW/component.js b/src/locales/zh-TW/component.js new file mode 100644 index 0000000..ba48e29 --- /dev/null +++ b/src/locales/zh-TW/component.js @@ -0,0 +1,5 @@ +export default { + 'component.tagSelect.expand': '展開', + 'component.tagSelect.collapse': '收起', + 'component.tagSelect.all': '全部', +}; diff --git a/src/locales/zh-TW/desk.js b/src/locales/zh-TW/desk.js new file mode 100644 index 0000000..12c9369 --- /dev/null +++ b/src/locales/zh-TW/desk.js @@ -0,0 +1,16 @@ +export default { + 'desk.notice.title': '通知標題', + 'desk.notice.title.placeholder': '請輸入通知標題', + 'desk.notice.title.validation': '請輸入通知標題', + 'desk.notice.category': '通知類型', + 'desk.notice.category.placeholder': '請選擇通知類型', + 'desk.notice.category.validation': '請選擇通知類型', + 'desk.notice.date': '通知日期', + 'desk.notice.date.placeholder': '請選擇通知日期', + 'desk.notice.date.validation': '請選擇通知日期', + 'desk.notice.date.start': '開始日期', + 'desk.notice.date.end': '結束日期', + 'desk.notice.content': '通知內容', + 'desk.notice.content.placeholder': '請輸入通知內容', + 'desk.notice.content.validation': '請輸入通知內容', +}; diff --git a/src/locales/zh-TW/exception.js b/src/locales/zh-TW/exception.js new file mode 100644 index 0000000..24a2661 --- /dev/null +++ b/src/locales/zh-TW/exception.js @@ -0,0 +1,6 @@ +export default { + 'app.exception.back': '返回首頁', + 'app.exception.description.403': '抱歉,妳無權訪問該頁面', + 'app.exception.description.404': '抱歉,妳訪問的頁面不存在', + 'app.exception.description.500': '抱歉,服務器出錯了', +}; diff --git a/src/locales/zh-TW/form.js b/src/locales/zh-TW/form.js new file mode 100644 index 0000000..cf1adf5 --- /dev/null +++ b/src/locales/zh-TW/form.js @@ -0,0 +1,37 @@ +export default { + 'form.get-captcha': '獲取驗證碼', + 'form.captcha.second': '秒', + 'form.optional': '(選填)', + 'form.submit': '提交', + 'form.save': '保存', + 'form.email.placeholder': '郵箱', + 'form.password.placeholder': '至少6位密碼,區分大小寫', + 'form.confirm-password.placeholder': '確認密碼', + 'form.phone-number.placeholder': '手機號', + 'form.verification-code.placeholder': '驗證碼', + 'form.title.label': '標題', + 'form.title.placeholder': '給目標起個名字', + 'form.date.label': '起止日期', + 'form.date.placeholder.start': '開始日期', + 'form.date.placeholder.end': '結束日期', + 'form.goal.label': '目標描述', + 'form.goal.placeholder': '請輸入妳的階段性工作目標', + 'form.standard.label': '衡量標淮', + 'form.standard.placeholder': '請輸入衡量標淮', + 'form.client.label': '客戶', + 'form.client.label.tooltip': '目標的服務對象', + 'form.client.placeholder': '請描述妳服務的客戶,內部客戶直接 @姓名/工號', + 'form.invites.label': '邀評人', + 'form.invites.placeholder': '請直接 @姓名/工號,最多可邀請 5 人', + 'form.weight.label': '權重', + 'form.weight.placeholder': '請輸入', + 'form.public.label': '目標公開', + 'form.public.label.help': '客戶、邀評人默認被分享', + 'form.public.radio.public': '公開', + 'form.public.radio.partially-public': '部分公開', + 'form.public.radio.private': '不公開', + 'form.publicUsers.placeholder': '公開給', + 'form.publicUsers.option.A': '同事甲', + 'form.publicUsers.option.B': '同事乙', + 'form.publicUsers.option.C': '同事丙', +}; diff --git a/src/locales/zh-TW/global.js b/src/locales/zh-TW/global.js new file mode 100644 index 0000000..7df1416 --- /dev/null +++ b/src/locales/zh-TW/global.js @@ -0,0 +1,16 @@ +export default { + 'table.columns.action': '操作', + 'button.add.name': '新增', + 'button.submit.name': '提交', + 'button.view.name': '查看', + 'button.edit.name': '修改', + 'button.delete.name': '刪除', + 'button.search.name': '查詢', + 'button.reset.name': '重置', + 'button.recycle.name': '回收站', + 'button.recovery.name': '恢複', + 'button.expand.name': '展開', + 'button.role.name': '角色配置', + 'button.reset-password.name': '密碼重置', + 'button.back.name': '返回', +}; diff --git a/src/locales/zh-TW/globalHeader.js b/src/locales/zh-TW/globalHeader.js new file mode 100644 index 0000000..c7b4e6f --- /dev/null +++ b/src/locales/zh-TW/globalHeader.js @@ -0,0 +1,18 @@ +export default { + 'component.globalHeader.search': '站內搜索', + 'component.globalHeader.search.example1': '搜索提示壹', + 'component.globalHeader.search.example2': '搜索提示二', + 'component.globalHeader.search.example3': '搜索提示三', + 'component.globalHeader.help': '使用手冊', + 'component.globalHeader.notification': '通知', + 'component.globalHeader.notification.empty': '妳已查看所有通知', + 'component.globalHeader.message': '消息', + 'component.globalHeader.message.empty': '您已讀完所有消息', + 'component.globalHeader.event': '待辦', + 'component.globalHeader.event.empty': '妳已完成所有待辦', + 'component.noticeIcon.clear': '清空', + 'component.noticeIcon.cleared': '清空了', + 'component.noticeIcon.empty': '暫無資料', + 'component.noticeIcon.loaded': '加載完畢', + 'component.noticeIcon.loading-more': '加載更多', +}; diff --git a/src/locales/zh-TW/login.js b/src/locales/zh-TW/login.js new file mode 100644 index 0000000..1ccf9ca --- /dev/null +++ b/src/locales/zh-TW/login.js @@ -0,0 +1,41 @@ +export default { + 'app.login.tenantId': '租戶編號', + 'app.login.userName': '賬戶', + 'app.login.password': '密碼', + 'app.login.message-invalid-credentials': '賬戶或密碼錯誤(admin/ant.design)', + 'app.login.message-invalid-verification-code': '驗證碼錯誤', + 'app.login.tab-login-credentials': '賬戶密碼登錄', + 'app.login.tab-login-mobile': '手機號登錄', + 'app.login.remember-me': '自動登錄', + 'app.login.forgot-password': '忘記密碼', + 'app.login.sign-in-with': '其他登錄方式', + 'app.login.signup': '註冊賬戶', + 'app.login.login': '登錄', + 'app.register.register': '註冊', + 'app.register.get-verification-code': '獲取驗證碼', + 'app.register.sign-in': '使用已有賬戶登錄', + 'app.register-result.msg': '妳的賬戶:{email} 註冊成功', + 'app.register-result.activation-email': + '激活郵件已發送到妳的郵箱中,郵件有效期為24小時。請及時登錄郵箱,點擊郵件中的鏈接激活帳戶。', + 'app.register-result.back-home': '返回首頁', + 'app.register-result.view-mailbox': '查看郵箱', + 'validation.email.required': '請輸入郵箱地址!', + 'validation.email.wrong-format': '郵箱地址格式錯誤!', + 'validation.tenantId.required': '請輸入租戶編號!', + 'validation.userName.required': '請輸入賬戶!', + 'validation.password.required': '請輸入密碼!', + 'validation.captcha.required': '請輸入驗證碼!', + 'validation.password.twice': '兩次輸入的密碼不匹配!', + 'validation.password.strength.msg': '請至少輸入 6 個字符。請不要使用容易被猜到的密碼。', + 'validation.password.strength.strong': '強度:強', + 'validation.password.strength.medium': '強度:中', + 'validation.password.strength.short': '強度:太短', + 'validation.confirm-password.required': '請確認密碼!', + 'validation.phone-number.required': '請輸入手機號!', + 'validation.phone-number.wrong-format': '手機號格式錯誤!', + 'validation.verification-code.required': '請輸入驗證碼!', + 'validation.title.required': '請輸入標題', + 'validation.date.required': '請選擇起止日期', + 'validation.goal.required': '請輸入目標描述', + 'validation.standard.required': '請輸入衡量標淮', +}; diff --git a/src/locales/zh-TW/menu.js b/src/locales/zh-TW/menu.js new file mode 100644 index 0000000..68ed137 --- /dev/null +++ b/src/locales/zh-TW/menu.js @@ -0,0 +1,42 @@ +export default { + 'menu.home': '首頁', + 'menu.dashboard': '儀表盤', + 'menu.dashboard.analysis': '分析頁', + 'menu.dashboard.monitor': '監控頁', + 'menu.dashboard.workplace': '工作台', + 'menu.desk': '工作台', + 'menu.desk.notice': '通知公告', + 'menu.system': '系統管理', + 'menu.system.user': '用戶管理', + 'menu.system.dept': '部門管理', + 'menu.system.dict': '字典管理', + 'menu.system.menu': '菜單管理', + 'menu.system.role': '角色管理', + 'menu.system.param': '參數管理', + 'menu.system.tenant': '租戶管理', + 'menu.system.client': '應用管理', + 'menu.monitor': '系統監控', + 'menu.monitor.log': '日志管理', + 'menu.monitor.log.log_usual': '通用日志', + 'menu.monitor.log.log_api': '接口日志', + 'menu.monitor.log.log_error': '錯誤日志', + 'menu.monitor.admin': '服務治理', + 'menu.monitor.doc': '接口文檔', + 'menu.tool': '研發工具', + 'menu.tool.code': '代碼生成', + 'menu.tool.datasource': '數據源管理', + 'menu.result': '結果頁', + 'menu.result.success': '成功頁', + 'menu.result.fail': '失敗頁', + 'menu.exception': '異常頁', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': '觸發錯誤', + 'menu.account': '個人頁', + 'menu.account.center': '個人中心', + 'menu.account.password': '密碼修改', + 'menu.account.settings': '個人設置', + 'menu.account.trigger': '觸發報錯', + 'menu.account.logout': '退出登錄', +}; diff --git a/src/locales/zh-TW/monitor.js b/src/locales/zh-TW/monitor.js new file mode 100644 index 0000000..d19ac05 --- /dev/null +++ b/src/locales/zh-TW/monitor.js @@ -0,0 +1,18 @@ +export default { + 'app.monitor.trading-activity': '活動實時交易情況', + 'app.monitor.total-transactions': '今日交易總額', + 'app.monitor.sales-target': '銷售目標完成率', + 'app.monitor.remaining-time': '活動剩余時間', + 'app.monitor.total-transactions-per-second': '每秒交易總額', + 'app.monitor.activity-forecast': '活動情況預測', + 'app.monitor.efficiency': '券核效率', + 'app.monitor.ratio': '跳出率', + 'app.monitor.proportion-per-category': '各品類占比', + 'app.monitor.fast-food': '中式快餐', + 'app.monitor.western-food': '西餐', + 'app.monitor.hot-pot': '火鍋', + 'app.monitor.waiting-for-implementation': 'Waiting for implementation', + 'app.monitor.popular-searches': '熱門搜索', + 'app.monitor.resource-surplus': '資源剩余', + 'app.monitor.fund-surplus': '補貼資金剩余', +}; diff --git a/src/locales/zh-TW/pwa.js b/src/locales/zh-TW/pwa.js new file mode 100644 index 0000000..108a6e4 --- /dev/null +++ b/src/locales/zh-TW/pwa.js @@ -0,0 +1,6 @@ +export default { + 'app.pwa.offline': '當前處於離線狀態', + 'app.pwa.serviceworker.updated': '有新內容', + 'app.pwa.serviceworker.updated.hint': '請點擊“刷新”按鈕或者手動刷新頁面', + 'app.pwa.serviceworker.updated.ok': '刷新', +}; diff --git a/src/locales/zh-TW/result.js b/src/locales/zh-TW/result.js new file mode 100644 index 0000000..a87b96e --- /dev/null +++ b/src/locales/zh-TW/result.js @@ -0,0 +1,27 @@ +export default { + 'app.result.error.title': '提交失敗', + 'app.result.error.description': '請核對並修改以下信息後,再重新提交。', + 'app.result.error.hint-title': '您提交的內容有如下錯誤:', + 'app.result.error.hint-text1': '您的賬戶已被凍結', + 'app.result.error.hint-btn1': '立即解凍', + 'app.result.error.hint-text2': '您的賬戶還不具備申請資格', + 'app.result.error.hint-btn2': '立即升級', + 'app.result.error.btn-text': '返回修改', + 'app.result.success.title': '提交成功', + 'app.result.success.description': + '提交結果頁用於反饋壹系列操作任務的處理結果, 如果僅是簡單操作,使用 Message 全局提示反饋即可。 本文字區域可以展示簡單的補充說明,如果有類似展示 “單據”的需求,下面這個灰色區域可以呈現比較復雜的內容。', + 'app.result.success.operate-title': '項目名稱', + 'app.result.success.operate-id': '項目 ID:', + 'app.result.success.principal': '負責人:', + 'app.result.success.operate-time': '生效時間:', + 'app.result.success.step1-title': '創建項目', + 'app.result.success.step1-operator': '曲麗麗', + 'app.result.success.step2-title': '部門初審', + 'app.result.success.step2-operator': '周毛毛', + 'app.result.success.step2-extra': '催壹下', + 'app.result.success.step3-title': '財務復核', + 'app.result.success.step4-title': '完成', + 'app.result.success.btn-return': '返回列表', + 'app.result.success.btn-project': '查看項目', + 'app.result.success.btn-print': '打印', +}; diff --git a/src/locales/zh-TW/settingDrawer.js b/src/locales/zh-TW/settingDrawer.js new file mode 100644 index 0000000..24dc281 --- /dev/null +++ b/src/locales/zh-TW/settingDrawer.js @@ -0,0 +1,31 @@ +export default { + 'app.setting.pagestyle': '整體風格設置', + 'app.setting.pagestyle.dark': '暗色菜單風格', + 'app.setting.pagestyle.light': '亮色菜單風格', + 'app.setting.content-width': '內容區域寬度', + 'app.setting.content-width.fixed': '定寬', + 'app.setting.content-width.fluid': '流式', + 'app.setting.themecolor': '主題色', + 'app.setting.themecolor.dust': '薄暮', + 'app.setting.themecolor.volcano': '火山', + 'app.setting.themecolor.sunset': '日暮', + 'app.setting.themecolor.cyan': '明青', + 'app.setting.themecolor.green': '極光綠', + 'app.setting.themecolor.daybreak': '拂曉藍(默認)', + 'app.setting.themecolor.geekblue': '極客藍', + 'app.setting.themecolor.purple': '醬紫', + 'app.setting.navigationmode': '導航模式', + 'app.setting.sidemenu': '側邊菜單布局', + 'app.setting.topmenu': '頂部菜單布局', + 'app.setting.fixedheader': '固定 Header', + 'app.setting.fixedsidebar': '固定側邊菜單', + 'app.setting.fixedsidebar.hint': '側邊菜單布局時可配置', + 'app.setting.hideheader': '下滑時隱藏 Header', + 'app.setting.hideheader.hint': '固定 Header 時可配置', + 'app.setting.othersettings': '其他設置', + 'app.setting.weakmode': '色弱模式', + 'app.setting.copy': '拷貝設置', + 'app.setting.copyinfo': '拷貝成功,請到 src/defaultSettings.js 中替換默認配置', + 'app.setting.production.hint': + '配置欄只在開發環境用於預覽,生產環境不會展現,請拷貝後手動修改配置文件', +}; diff --git a/src/locales/zh-TW/settings.js b/src/locales/zh-TW/settings.js new file mode 100644 index 0000000..66acea6 --- /dev/null +++ b/src/locales/zh-TW/settings.js @@ -0,0 +1,63 @@ +export default { + 'app.settings.menuMap.basic': '基本設置', + 'app.settings.menuMap.security': '安全設置', + 'app.settings.menuMap.binding': '賬號綁定', + 'app.settings.menuMap.notification': '新消息通知', + 'app.settings.basic.avatar': '頭像', + 'app.settings.basic.change-avatar': '更換頭像', + 'app.settings.basic.email': '郵箱', + 'app.settings.basic.email-message': '請輸入您的郵箱!', + 'app.settings.basic.nickname': '昵稱', + 'app.settings.basic.nickname-message': '請輸入您的昵稱!', + 'app.settings.basic.realname': '姓名', + 'app.settings.basic.realname-message': '請輸入您的姓名!', + 'app.settings.basic.profile': '個人簡介', + 'app.settings.basic.profile-message': '請輸入個人簡介!', + 'app.settings.basic.profile-placeholder': '個人簡介', + 'app.settings.basic.country': '國家/地區', + 'app.settings.basic.country-message': '請輸入您的國家或地區!', + 'app.settings.basic.geographic': '所在省市', + 'app.settings.basic.geographic-message': '請輸入您的所在省市!', + 'app.settings.basic.address': '街道地址', + 'app.settings.basic.address-message': '請輸入您的街道地址!', + 'app.settings.basic.phone': '聯系電話', + 'app.settings.basic.phone-message': '請輸入您的聯系電話!', + 'app.settings.basic.update': '更新基本信息', + 'app.settings.password.old': '舊密碼', + 'app.settings.password.old-message': '請輸入妳的舊密碼!', + 'app.settings.password.new': '新密碼', + 'app.settings.password.new-message': '請輸入妳的新密碼!', + 'app.settings.password.new1': '確認密碼', + 'app.settings.password.new1-message': '請輸入妳的確認密碼!', + 'app.settings.security.strong': '強', + 'app.settings.security.medium': '中', + 'app.settings.security.weak': '弱', + 'app.settings.security.password': '賬戶密碼', + 'app.settings.security.password-description': '當前密碼強度', + 'app.settings.security.phone': '密保手機', + 'app.settings.security.phone-description': '已綁定手機', + 'app.settings.security.question': '密保問題', + 'app.settings.security.question-description': '未設置密保問題,密保問題可有效保護賬戶安全', + 'app.settings.security.email': '備用郵箱', + 'app.settings.security.email-description': '已綁定郵箱', + 'app.settings.security.mfa': 'MFA 設備', + 'app.settings.security.mfa-description': '未綁定 MFA 設備,綁定後,可以進行二次確認', + 'app.settings.security.modify': '修改', + 'app.settings.security.set': '設置', + 'app.settings.security.bind': '綁定', + 'app.settings.binding.taobao': '綁定淘寶', + 'app.settings.binding.taobao-description': '當前未綁定淘寶賬號', + 'app.settings.binding.alipay': '綁定支付寶', + 'app.settings.binding.alipay-description': '當前未綁定支付寶賬號', + 'app.settings.binding.dingding': '綁定釘釘', + 'app.settings.binding.dingding-description': '當前未綁定釘釘賬號', + 'app.settings.binding.bind': '綁定', + 'app.settings.notification.password': '賬戶密碼', + 'app.settings.notification.password-description': '其他用戶的消息將以站內信的形式通知', + 'app.settings.notification.messages': '系統消息', + 'app.settings.notification.messages-description': '系統消息將以站內信的形式通知', + 'app.settings.notification.todo': '待辦任務', + 'app.settings.notification.todo-description': '待辦任務將以站內信的形式通知', + 'app.settings.open': '開', + 'app.settings.close': '關', +}; diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..839bc5b --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Ant Design Pro", + "short_name": "Ant Design Pro", + "display": "standalone", + "start_url": "./?utm_source=homescreen", + "theme_color": "#002140", + "background_color": "#001529", + "icons": [ + { + "src": "icons/icon-192x192.png", + "sizes": "192x192" + }, + { + "src": "icons/icon-128x128.png", + "sizes": "128x128" + }, + { + "src": "icons/icon-512x512.png", + "sizes": "512x512" + } + ] +} diff --git a/src/models/client.js b/src/models/client.js new file mode 100644 index 0000000..e81ccbb --- /dev/null +++ b/src/models/client.js @@ -0,0 +1,87 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { CLIENT_NAMESPACE } from '../actions/client'; +import { list, submit, detail, remove } from '../services/client'; + +export default { + namespace: CLIENT_NAMESPACE, + state: { + data: { + list: [], + pagination: false, + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/client'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/code.js b/src/models/code.js new file mode 100644 index 0000000..6e2da3f --- /dev/null +++ b/src/models/code.js @@ -0,0 +1,112 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { CODE_NAMESPACE } from '../actions/code'; +import { list, submit, detail, remove } from '../services/code'; +import { select } from '../services/datasource'; +import { dict } from '../services/dict'; + +export default { + namespace: CODE_NAMESPACE, + state: { + data: { + list: [], + pagination: {}, + }, + init: { + source: [], + category: [], + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchInit({ payload }, { call, put }) { + const responseS = yield call(select, payload); + const responseC = yield call(dict, payload); + if (responseS.success && responseC.success) { + yield put({ + type: 'saveInit', + payload: { + source: responseS.data, + category: responseC.data, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/tool/code'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveInit(state, action) { + return { + ...state, + init: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/datasource.js b/src/models/datasource.js new file mode 100644 index 0000000..e55dde7 --- /dev/null +++ b/src/models/datasource.js @@ -0,0 +1,87 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { DATASOURCE_NAMESPACE } from '../actions/datasource'; +import { list, submit, detail, remove } from '../services/datasource'; + +export default { + namespace: DATASOURCE_NAMESPACE, + state: { + data: { + list: [], + pagination: false, + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/tool/datasource'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/dept.js b/src/models/dept.js new file mode 100644 index 0000000..4e02fae --- /dev/null +++ b/src/models/dept.js @@ -0,0 +1,103 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { DEPT_NAMESPACE } from '../actions/dept'; +import { list, submit, detail, remove, tree } from '../services/dept'; + +export default { + namespace: DEPT_NAMESPACE, + state: { + data: { + list: [], + pagination: false, + }, + init: { + tree: [], + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data, + pagination: false, + }, + }); + } + }, + *fetchInit({ payload }, { call, put }) { + const response = yield call(tree, payload); + if (response.success) { + yield put({ + type: 'saveInit', + payload: { + tree: response.data, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/dept'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveInit(state, action) { + return { + ...state, + init: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/dict.js b/src/models/dict.js new file mode 100644 index 0000000..7df4ac3 --- /dev/null +++ b/src/models/dict.js @@ -0,0 +1,103 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { DICT_NAMESPACE } from '../actions/dict'; +import { list, submit, detail, remove, tree } from '../services/dict'; + +export default { + namespace: DICT_NAMESPACE, + state: { + data: { + list: [], + pagination: false, + }, + init: { + tree: [], + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data, + pagination: false, + }, + }); + } + }, + *fetchInit({ payload }, { call, put }) { + const response = yield call(tree, payload); + if (response.success) { + yield put({ + type: 'saveInit', + payload: { + tree: response.data, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/dict'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveInit(state, action) { + return { + ...state, + init: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/global.js b/src/models/global.js new file mode 100644 index 0000000..7856652 --- /dev/null +++ b/src/models/global.js @@ -0,0 +1,143 @@ +import { queryNotices } from '@/services/api'; + +export default { + namespace: 'global', + + state: { + collapsed: false, + notices: [], + loadedAllNotices: false, + }, + + effects: { + *fetchNotices(_, { call, put, select }) { + const response = yield call(queryNotices); + const { data } = response; + const loadedAllNotices = data && data.length && data[data.length - 1] === null; + yield put({ + type: 'setLoadedStatus', + payload: loadedAllNotices, + }); + yield put({ + type: 'saveNotices', + payload: data.filter(item => item), + }); + const unreadCount = yield select( + state => state.global.notices.filter(item => !item.read).length + ); + yield put({ + type: 'user/changeNotifyCount', + payload: { + totalCount: data.length, + unreadCount, + }, + }); + }, + *fetchMoreNotices({ payload }, { call, put, select }) { + const response = yield call(queryNotices, payload); + const { data } = response; + const loadedAllNotices = data && data.length && data[data.length - 1] === null; + yield put({ + type: 'setLoadedStatus', + payload: loadedAllNotices, + }); + yield put({ + type: 'pushNotices', + payload: data.filter(item => item), + }); + const unreadCount = yield select( + state => state.global.notices.filter(item => !item.read).length + ); + yield put({ + type: 'user/changeNotifyCount', + payload: { + totalCount: data.length, + unreadCount, + }, + }); + }, + *clearNotices({ payload }, { put, select }) { + yield put({ + type: 'saveClearedNotices', + payload, + }); + const count = yield select(state => state.global.notices.length); + const unreadCount = yield select( + state => state.global.notices.filter(item => !item.read).length + ); + yield put({ + type: 'user/changeNotifyCount', + payload: { + totalCount: count, + unreadCount, + }, + }); + }, + *changeNoticeReadState({ payload }, { put, select }) { + const notices = yield select(state => + state.global.notices.map(item => { + const notice = { ...item }; + if (notice.id === payload) { + notice.read = true; + } + return notice; + }) + ); + yield put({ + type: 'saveNotices', + payload: notices, + }); + yield put({ + type: 'user/changeNotifyCount', + payload: { + totalCount: notices.length, + unreadCount: notices.filter(item => !item.read).length, + }, + }); + }, + }, + + reducers: { + changeLayoutCollapsed(state, { payload }) { + return { + ...state, + collapsed: payload, + }; + }, + saveNotices(state, { payload }) { + return { + ...state, + notices: payload, + }; + }, + saveClearedNotices(state, { payload }) { + return { + ...state, + notices: state.notices.filter(item => item.type !== payload), + }; + }, + pushNotices(state, { payload }) { + return { + ...state, + notices: [...state.notices, ...payload], + }; + }, + setLoadedStatus(state, { payload }) { + return { + ...state, + loadedAllNotices: payload, + }; + }, + }, + + subscriptions: { + setup({ history }) { + // Subscribe history(url) change, trigger `load` action if pathname is `/` + return history.listen(({ pathname, search }) => { + if (typeof window.ga !== 'undefined') { + window.ga('send', 'pageview', pathname + search); + } + }); + }, + }, +}; diff --git a/src/models/log.js b/src/models/log.js new file mode 100644 index 0000000..5278130 --- /dev/null +++ b/src/models/log.js @@ -0,0 +1,151 @@ +import { LOG_NAMESPACE } from '../actions/log'; +import { + usualList, + usualDetail, + apiList, + apiDetail, + errorList, + errorDetail, +} from '../services/log'; + +export default { + namespace: LOG_NAMESPACE, + state: { + usualData: { + list: [], + pagination: {}, + }, + usualDetail: {}, + apiData: { + list: [], + pagination: {}, + }, + apiDetail: {}, + errorData: { + list: [], + pagination: {}, + }, + errorDetail: {}, + }, + effects: { + *fetchUsualList({ payload }, { call, put }) { + const response = yield call(usualList, payload); + if (response.success) { + yield put({ + type: 'saveUsualList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchApiList({ payload }, { call, put }) { + const response = yield call(apiList, payload); + if (response.success) { + yield put({ + type: 'saveApiList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchErrorList({ payload }, { call, put }) { + const response = yield call(errorList, payload); + if (response.success) { + yield put({ + type: 'saveErrorList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchUsualDetail({ payload }, { call, put }) { + const response = yield call(usualDetail, payload); + if (response.success) { + yield put({ + type: 'saveUsualDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *fetchApiDetail({ payload }, { call, put }) { + const response = yield call(apiDetail, payload); + if (response.success) { + yield put({ + type: 'saveApiDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *fetchErrorDetail({ payload }, { call, put }) { + const response = yield call(errorDetail, payload); + if (response.success) { + yield put({ + type: 'saveErrorDetail', + payload: { + detail: response.data, + }, + }); + } + }, + }, + reducers: { + saveUsualList(state, action) { + return { + ...state, + usualData: action.payload, + }; + }, + saveApiList(state, action) { + return { + ...state, + apiData: action.payload, + }; + }, + saveErrorList(state, action) { + return { + ...state, + errorData: action.payload, + }; + }, + saveUsualDetail(state, action) { + return { + ...state, + usualDetail: action.payload.detail, + }; + }, + saveApiDetail(state, action) { + return { + ...state, + apiDetail: action.payload.detail, + }; + }, + saveErrorDetail(state, action) { + return { + ...state, + errorDetail: action.payload.detail, + }; + }, + }, +}; diff --git a/src/models/login.js b/src/models/login.js new file mode 100644 index 0000000..e07edcb --- /dev/null +++ b/src/models/login.js @@ -0,0 +1,125 @@ +import { routerRedux } from 'dva/router'; +import { stringify } from 'qs'; +import { getFakeCaptcha } from '../services/api'; +import { accountLogin } from '../services/user'; +import { dynamicRoutes, dynamicButtons } from '../services/menu'; +import { + setAuthority, + setToken, + setCurrentUser, + setRoutes, + setButtons, + removeAll, +} from '../utils/authority'; +import { getPageQuery, formatRoutes, formatButtons } from '../utils/utils'; +import { reloadAuthorized } from '../utils/Authorized'; + +export default { + namespace: 'login', + + state: { + status: undefined, + }, + + effects: { + *login({ payload }, { call, put }) { + const response = yield call(accountLogin, payload); + if (response.success) { + const { success, data } = response; + yield put({ + type: 'changeLoginStatus', + payload: { + status: success, + type: 'login', + data: { ...data }, + }, + }); + const responseRoutes = yield call(dynamicRoutes); + const responseButtons = yield call(dynamicButtons); + yield put({ + type: 'saveMenuData', + payload: { + routes: responseRoutes.data, + buttons: responseButtons.data, + }, + }); + reloadAuthorized(); + const urlParams = new URL(window.location.href); + const params = getPageQuery(); + let { redirect } = params; + if (redirect) { + const redirectUrlParams = new URL(redirect); + if (redirectUrlParams.origin === urlParams.origin) { + redirect = redirect.substr(urlParams.origin.length); + if (redirect.match(/^\/.*#/)) { + redirect = redirect.substr(redirect.indexOf('#') + 1); + } + } else { + window.location.href = redirect; + return; + } + } + yield put(routerRedux.replace(redirect || '/')); + } + }, + + *getCaptcha({ payload }, { call }) { + yield call(getFakeCaptcha, payload); + }, + + *logout(_, { put }) { + yield put({ + type: 'changeLoginStatus', + payload: { + status: false, + type: 'logout', + data: { + authority: 'guest', + logout: true, + }, + }, + }); + reloadAuthorized(); + yield put( + routerRedux.push({ + pathname: '/user/login', + search: stringify({ + redirect: window.location.href, + }), + }) + ); + }, + }, + + reducers: { + changeLoginStatus(state, { payload }) { + const { status, type } = payload; + + if (status) { + const { + data: { tokenType, accessToken, authority, account, userName, avatar }, + } = payload; + const token = `${tokenType} ${accessToken}`; + setToken(token); + setAuthority(authority); + setCurrentUser({ avatar, account, name: userName, authority }); + } else { + removeAll(); + } + + return { + ...state, + status: type === 'login' ? (status ? 'ok' : 'error') : '', + type: payload.type, + }; + }, + saveMenuData(state, { payload }) { + const { routes, buttons } = payload; + setRoutes(formatRoutes(routes)); + setButtons(formatButtons(buttons)); + return { + ...state, + }; + }, + }, +}; diff --git a/src/models/menu.js b/src/models/menu.js new file mode 100644 index 0000000..5de8afb --- /dev/null +++ b/src/models/menu.js @@ -0,0 +1,255 @@ +import memoizeOne from 'memoize-one'; +import isEqual from 'lodash/isEqual'; +import { message } from 'antd'; +import router from 'umi/router'; +import { formatMessage } from 'umi/locale'; +import Authorized from '../utils/Authorized'; +import { menu } from '../defaultSettings'; +import { + dynamicRoutes, + dynamicButtons, + list, + submit, + detail, + remove, + tree, +} from '../services/menu'; +import { getRoutes, setRoutes, getButtons, setButtons } from '../utils/authority'; +import { MENU_NAMESPACE } from '../actions/menu'; +import { formatRoutes, formatButtons } from '../utils/utils'; + +const { check } = Authorized; + +// Conversion router to menu. +function formatter(data, parentAuthority, parentName) { + return data + .map(item => { + if (!item.name || !item.path) { + return null; + } + + let locale = 'menu'; + if (parentName) { + locale = `${parentName}.${item.name}`; + } else { + locale = `menu.${item.name}`; + } + // if enableMenuLocale use item.name, + // close menu international + const name = menu.disableLocal + ? item.name + : formatMessage({ id: locale, defaultMessage: item.name }); + const result = { + ...item, + name, + locale, + authority: item.authority || parentAuthority, + }; + if (item.routes) { + const children = formatter(item.routes, item.authority, locale); + // Reduce memory usage + result.children = children; + } + delete result.routes; + return result; + }) + .filter(item => item); +} + +const memoizeOneFormatter = memoizeOne(formatter, isEqual); + +/** + * get SubMenu or Item + */ +const getSubMenu = item => { + // doc: add hideChildrenInMenu + if (item.children && !item.hideChildrenInMenu && item.children.some(child => child.name)) { + return { + ...item, + children: filterMenuData(item.children), // eslint-disable-line + }; + } + return item; +}; + +/** + * filter menuData + */ +const filterMenuData = menuData => { + if (!menuData) { + return []; + } + return menuData + .filter(item => item.name && !item.hideInMenu) + .map(item => check(item.authority, getSubMenu(item))) + .filter(item => item); +}; +/** + * 获取面包屑映射 + * @param {Object} menuData 菜单配置 + */ +const getBreadcrumbNameMap = menuData => { + const routerMap = {}; + + const flattenMenuData = data => { + data.forEach(menuItem => { + if (menuItem.children) { + flattenMenuData(menuItem.children); + } + // Reduce memory usage + routerMap[menuItem.path] = menuItem; + }); + }; + flattenMenuData(menuData); + return routerMap; +}; + +const memoizeOneGetBreadcrumbNameMap = memoizeOne(getBreadcrumbNameMap, isEqual); + +export default { + namespace: MENU_NAMESPACE, + + state: { + menuData: [], + breadcrumbNameMap: {}, + data: { + list: [], + pagination: false, + }, + init: { + tree: [], + }, + detail: {}, + }, + + effects: { + *fetchMenuData({ payload }, { call, put }) { + const { authority } = payload; + // 设置菜单数据 + let routes = getRoutes(); + if (routes.length === 0) { + const response = yield call(dynamicRoutes); + routes = formatRoutes(response.data); + setRoutes(routes); + } + // 设置按钮数据 + let buttons = getButtons(); + if (buttons.length === 0) { + const response = yield call(dynamicButtons); + buttons = formatButtons(response.data); + setButtons(buttons); + } + const menuData = filterMenuData(memoizeOneFormatter(routes, authority)); + const breadcrumbNameMap = memoizeOneGetBreadcrumbNameMap(menuData); + yield put({ + type: 'save', + payload: { menuData, breadcrumbNameMap }, + }); + }, + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data, + pagination: false, + }, + }); + } + }, + *fetchInit({ payload }, { call, put }) { + const response = yield call(tree, payload); + if (response.success) { + yield put({ + type: 'saveInit', + payload: { + tree: response.data, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *selectIcon({ payload }, { put }) { + yield put({ + type: 'saveIcon', + payload: { + detail: payload, + }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/menu'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + + reducers: { + save(state, action) { + return { + ...state, + ...action.payload, + }; + }, + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveInit(state, action) { + return { + ...state, + init: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + saveIcon(state, action) { + const newState = state; + newState.detail.source = action.payload.detail.source; + return { + ...newState, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/notice.js b/src/models/notice.js new file mode 100644 index 0000000..b09e630 --- /dev/null +++ b/src/models/notice.js @@ -0,0 +1,96 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { NOTICE_NAMESPACE } from '../actions/notice'; +import { dict } from '../services/dict'; +import { list, submit, detail, remove } from '../services/notice'; + +export default { + namespace: NOTICE_NAMESPACE, + state: { + data: { + list: [], + pagination: {}, + }, + init: { + category: [], + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchInit({ payload }, { call, put }) { + const response = yield call(dict, payload); + if (response.success) { + yield put({ + type: 'saveInit', + payload: { + category: response.data, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/desk/notice'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveInit(state, action) { + return { + ...state, + init: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + }, +}; diff --git a/src/models/param.js b/src/models/param.js new file mode 100644 index 0000000..04f647b --- /dev/null +++ b/src/models/param.js @@ -0,0 +1,87 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { PARAM_NAMESPACE } from '../actions/param'; +import { list, submit, detail, remove } from '../services/param'; + +export default { + namespace: PARAM_NAMESPACE, + state: { + data: { + list: [], + pagination: {}, + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/param'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/project.js b/src/models/project.js new file mode 100644 index 0000000..e522e70 --- /dev/null +++ b/src/models/project.js @@ -0,0 +1,28 @@ +import { queryProjectNotice } from '../services/notice'; + +export default { + namespace: 'project', + + state: { + notice: [], + }, + + effects: { + *fetchNotice(_, { call, put }) { + const response = yield call(queryProjectNotice); + yield put({ + type: 'saveNotice', + payload: Array.isArray(response.data) ? response.data : [], + }); + }, + }, + + reducers: { + saveNotice(state, action) { + return { + ...state, + notice: action.payload, + }; + }, + }, +}; diff --git a/src/models/role.js b/src/models/role.js new file mode 100644 index 0000000..2fc4ba5 --- /dev/null +++ b/src/models/role.js @@ -0,0 +1,152 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { ROLE_NAMESPACE } from '../actions/role'; +import { list, submit, detail, remove, tree, grant } from '../services/role'; +import { grantTree, roleTreeKeys, dynamicRoutes, dynamicButtons } from '../services/menu'; +import { setButtons, setRoutes } from '../utils/authority'; +import { formatButtons, formatRoutes } from '../utils/utils'; + +export default { + namespace: ROLE_NAMESPACE, + state: { + data: { + list: [], + pagination: false, + }, + init: { + tree: [], + }, + detail: {}, + grantTree: [], + roleCheckedTreeKeys: [], + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data, + pagination: false, + }, + }); + } + }, + *fetchInit({ payload }, { call, put }) { + const response = yield call(tree, payload); + if (response.success) { + yield put({ + type: 'saveInit', + payload: { + tree: response.data, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *grantTree({ payload }, { call, put }) { + const response = yield call(grantTree, payload); + yield put({ + type: 'save', + payload: { + grantTree: response.data, + }, + }); + }, + *roleTreeKeys({ payload }, { call, put }) { + const response = yield call(roleTreeKeys, payload); + yield put({ + type: 'save', + payload: { + roleCheckedTreeKeys: response.data, + }, + }); + }, + *setRoleTreeKeys({ payload }, { put }) { + yield put({ + type: 'save', + payload: { + roleCheckedTreeKeys: payload.roleCheckedTreeKeys, + }, + }); + }, + *grant({ payload, callback }, { call }) { + const response = yield call(grant, payload); + if (response.success) { + const routes = yield call(dynamicRoutes); + const buttons = yield call(dynamicButtons); + setRoutes(formatRoutes(routes.data)); + setButtons(formatButtons(buttons.data)); + if (callback) { + callback(); + } + } + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/role'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + save(state, action) { + return { + ...state, + ...action.payload, + }; + }, + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveInit(state, action) { + return { + ...state, + init: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/setting.js b/src/models/setting.js new file mode 100644 index 0000000..171da48 --- /dev/null +++ b/src/models/setting.js @@ -0,0 +1,123 @@ +import { message } from 'antd'; +import defaultSettings from '../defaultSettings'; + +let lessNodesAppended; +const updateTheme = primaryColor => { + // Don't compile less in production! + if (APP_TYPE !== 'site') { + return; + } + // Determine if the component is remounted + if (!primaryColor) { + return; + } + const hideMessage = message.loading('正在编译主题!', 0); + function buildIt() { + if (!window.less) { + return; + } + setTimeout(() => { + window.less + .modifyVars({ + '@primary-color': primaryColor, + }) + .then(() => { + hideMessage(); + }) + .catch(() => { + message.error('Failed to update theme'); + hideMessage(); + }); + }, 200); + } + if (!lessNodesAppended) { + // insert less.js and color.less + const lessStyleNode = document.createElement('link'); + const lessConfigNode = document.createElement('script'); + const lessScriptNode = document.createElement('script'); + lessStyleNode.setAttribute('rel', 'stylesheet/less'); + lessStyleNode.setAttribute('href', '/color.less'); + lessConfigNode.innerHTML = ` + window.less = { + async: true, + env: 'production', + javascriptEnabled: true + }; + `; + lessScriptNode.src = 'https://gw.alipayobjects.com/os/lib/less.js/3.8.1/less.min.js'; + lessScriptNode.async = true; + lessScriptNode.onload = () => { + buildIt(); + lessScriptNode.onload = null; + }; + document.body.appendChild(lessStyleNode); + document.body.appendChild(lessConfigNode); + document.body.appendChild(lessScriptNode); + lessNodesAppended = true; + } else { + buildIt(); + } +}; + +const updateColorWeak = colorWeak => { + document.body.className = colorWeak ? 'colorWeak' : ''; +}; + +export default { + namespace: 'setting', + state: defaultSettings, + reducers: { + getSetting(state) { + const setting = {}; + const urlParams = new URL(window.location.href); + Object.keys(state).forEach(key => { + if (urlParams.searchParams.has(key)) { + const value = urlParams.searchParams.get(key); + setting[key] = value === '1' ? true : value; + } + }); + const { primaryColor, colorWeak } = setting; + if (state.primaryColor !== primaryColor) { + updateTheme(primaryColor); + } + updateColorWeak(colorWeak); + return { + ...state, + ...setting, + }; + }, + changeSetting(state, { payload }) { + const urlParams = new URL(window.location.href); + Object.keys(defaultSettings).forEach(key => { + if (urlParams.searchParams.has(key)) { + urlParams.searchParams.delete(key); + } + }); + Object.keys(payload).forEach(key => { + if (key === 'collapse') { + return; + } + let value = payload[key]; + if (value === true) { + value = 1; + } + if (defaultSettings[key] !== value) { + urlParams.searchParams.set(key, value); + } + }); + const { primaryColor, colorWeak, contentWidth } = payload; + if (state.primaryColor !== primaryColor) { + updateTheme(primaryColor); + } + if (state.contentWidth !== contentWidth && window.dispatchEvent) { + window.dispatchEvent(new Event('resize')); + } + updateColorWeak(colorWeak); + window.history.replaceState(null, 'setting', urlParams.href); + return { + ...state, + ...payload, + }; + }, + }, +}; diff --git a/src/models/tenant.js b/src/models/tenant.js new file mode 100644 index 0000000..a49e163 --- /dev/null +++ b/src/models/tenant.js @@ -0,0 +1,87 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { TENANT_NAMESPACE } from '../actions/tenant'; +import { list, submit, detail, remove } from '../services/tenant'; + +export default { + namespace: TENANT_NAMESPACE, + state: { + data: { + list: [], + pagination: false, + }, + detail: {}, + }, + effects: { + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/tenant'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + reducers: { + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/models/user.js b/src/models/user.js new file mode 100644 index 0000000..1fda883 --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,194 @@ +import { message } from 'antd'; +import router from 'umi/router'; +import { USER_NAMESPACE } from '../actions/user'; +import { query as queryUsers, list, submit, update, detail, remove, grant } from '../services/user'; +import { select as tenants } from '../services/tenant'; +import { tree as roles } from '../services/role'; +import { tree as depts } from '../services/dept'; +import { getCurrentUser } from '../utils/authority'; + +export default { + namespace: USER_NAMESPACE, + + state: { + list: [], + currentUser: {}, + data: { + list: [], + pagination: {}, + }, + init: { + roleTree: [], + deptTree: [], + tenantList: [], + }, + detail: {}, + }, + + effects: { + *fetch(_, { call, put }) { + const response = yield call(queryUsers); + yield put({ + type: 'save', + payload: response, + }); + }, + *fetchCurrent(_, { put }) { + const currentUser = getCurrentUser(); + yield put({ + type: 'saveCurrentUser', + payload: currentUser, + }); + }, + *fetchList({ payload }, { call, put }) { + const response = yield call(list, payload); + if (response.success) { + yield put({ + type: 'saveList', + payload: { + list: response.data.records, + pagination: { + total: response.data.total, + current: response.data.current, + pageSize: response.data.size, + }, + }, + }); + } + }, + *fetchInit({ payload }, { call, put }) { + const responseRole = yield call(roles, payload); + const responseDept = yield call(depts, payload); + const responseTenant = yield call(tenants, payload); + if (responseRole.success && responseDept.success && responseTenant.success) { + yield put({ + type: 'saveInit', + payload: { + roleTree: responseRole.data, + deptTree: responseDept.data, + tenantList: responseTenant.data, + }, + }); + } + }, + *fetchChangeInit({ payload }, { call, put }) { + const responseRole = yield call(roles, payload); + const responseDept = yield call(depts, payload); + if (responseRole.success && responseDept.success) { + yield put({ + type: 'saveChangeInit', + payload: { + roleTree: responseRole.data, + deptTree: responseDept.data, + }, + }); + } + }, + *fetchDetail({ payload }, { call, put }) { + const response = yield call(detail, payload); + if (response.success) { + yield put({ + type: 'saveDetail', + payload: { + detail: response.data, + }, + }); + } + }, + *clearDetail({ payload }, { put }) { + yield put({ + type: 'removeDetail', + payload: { payload }, + }); + }, + *grant({ payload, callback }, { call }) { + const response = yield call(grant, payload); + if (response.success) { + if (callback) { + callback(); + } + } + }, + *submit({ payload }, { call }) { + const response = yield call(submit, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/user'); + } + }, + *update({ payload }, { call }) { + const response = yield call(update, payload); + if (response.success) { + message.success('提交成功'); + router.push('/system/user'); + } + }, + *remove({ payload }, { call }) { + const { + data: { keys }, + success, + } = payload; + const response = yield call(remove, { ids: keys }); + if (response.success) { + success(); + } + }, + }, + + reducers: { + save(state, action) { + return { + ...state, + list: action.payload, + }; + }, + saveCurrentUser(state, action) { + return { + ...state, + currentUser: action.payload || {}, + }; + }, + changeNotifyCount(state, action) { + return { + ...state, + currentUser: { + ...state.currentUser, + notifyCount: action.payload.totalCount, + unreadCount: action.payload.unreadCount, + }, + }; + }, + saveList(state, action) { + return { + ...state, + data: action.payload, + }; + }, + saveInit(state, action) { + return { + ...state, + init: action.payload, + }; + }, + saveChangeInit(state, action) { + const newState = state; + newState.init.roleTree = action.payload.roleTree; + newState.init.deptTree = action.payload.deptTree; + return { + ...newState, + }; + }, + saveDetail(state, action) { + return { + ...state, + detail: action.payload.detail, + }; + }, + removeDetail(state) { + return { + ...state, + detail: {}, + }; + }, + }, +}; diff --git a/src/pages/404.js b/src/pages/404.js new file mode 100644 index 0000000..34921c0 --- /dev/null +++ b/src/pages/404.js @@ -0,0 +1,13 @@ +import React from 'react'; +import Link from 'umi/link'; +import { formatMessage } from 'umi/locale'; +import Exception from '@/components/Exception'; + +export default () => ( + +); diff --git a/src/pages/Account/Center/Applications.js b/src/pages/Account/Center/Applications.js new file mode 100644 index 0000000..1c81572 --- /dev/null +++ b/src/pages/Account/Center/Applications.js @@ -0,0 +1,88 @@ +import React, { PureComponent } from 'react'; +import { List, Card, Icon, Dropdown, Menu, Avatar, Tooltip } from 'antd'; +import numeral from 'numeral'; +import { connect } from 'dva'; +import { formatWan } from '@/utils/utils'; +import stylesApplications from './Applications.less'; + +@connect(({ list }) => ({ + list, +})) +class Center extends PureComponent { + render() { + const { + list: { list }, + } = this.props; + const itemMenu = ( + + + + 1st menu item + + + + + 2nd menu item + + + + + 3d menu item + + + + ); + const CardInfo = ({ activeUser, newUser }) => ( +
    +
    +

    活跃用户

    +

    {activeUser}

    +
    +
    +

    新增用户

    +

    {newUser}

    +
    +
    + ); + return ( + ( + + + + , + + + , + + + , + + + , + ]} + > + } title={item.title} /> +
    + +
    +
    +
    + )} + /> + ); + } +} + +export default Center; diff --git a/src/pages/Account/Center/Applications.less b/src/pages/Account/Center/Applications.less new file mode 100644 index 0000000..8f5cb36 --- /dev/null +++ b/src/pages/Account/Center/Applications.less @@ -0,0 +1,43 @@ +@import '~antd/lib/style/themes/default.less'; +@import '~@/utils/utils.less'; + +.filterCardList { + margin-bottom: -24px; + :global { + .ant-card-meta-content { + margin-top: 0; + } + // disabled white space + .ant-card-meta-avatar { + font-size: 0; + } + .ant-card-actions { + background: #f7f9fa; + } + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } + .cardInfo { + .clearfix(); + margin-top: 16px; + margin-left: 40px; + & > div { + position: relative; + text-align: left; + float: left; + width: 50%; + p { + line-height: 32px; + font-size: 24px; + margin: 0; + } + p:first-child { + color: @text-color-secondary; + font-size: 12px; + line-height: 20px; + margin-bottom: 4px; + } + } + } +} diff --git a/src/pages/Account/Center/Articles.js b/src/pages/Account/Center/Articles.js new file mode 100644 index 0000000..9bb5ac3 --- /dev/null +++ b/src/pages/Account/Center/Articles.js @@ -0,0 +1,59 @@ +import React, { PureComponent } from 'react'; +import { List, Icon, Tag } from 'antd'; +import { connect } from 'dva'; +import ArticleListContent from '@/components/ArticleListContent'; +import styles from './Articles.less'; + +@connect(({ list }) => ({ + list, +})) +class Center extends PureComponent { + render() { + const { + list: { list }, + } = this.props; + const IconText = ({ type, text }) => ( + + + {text} + + ); + return ( + ( + , + , + , + ]} + > + + {item.title} + + } + description={ + + Ant Design + 设计语言 + 蚂蚁金服 + + } + /> + + + )} + /> + ); + } +} + +export default Center; diff --git a/src/pages/Account/Center/Articles.less b/src/pages/Account/Center/Articles.less new file mode 100644 index 0000000..2e51509 --- /dev/null +++ b/src/pages/Account/Center/Articles.less @@ -0,0 +1,12 @@ +@import '~antd/lib/style/themes/default.less'; + +.articleList { + :global { + .ant-list-item:first-child { + padding-top: 0; + } + } +} +a.listItemMetaTitle { + color: @heading-color; +} diff --git a/src/pages/Account/Center/Center.js b/src/pages/Account/Center/Center.js new file mode 100644 index 0000000..d2512f9 --- /dev/null +++ b/src/pages/Account/Center/Center.js @@ -0,0 +1,216 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'dva'; +import Link from 'umi/link'; +import router from 'umi/router'; +import { Card, Row, Col, Icon, Avatar, Tag, Divider, Spin, Input } from 'antd'; +import GridContent from '@/components/PageHeaderWrapper/GridContent'; +import styles from './Center.less'; + +@connect(({ loading, user, project }) => ({ + listLoading: loading.effects['list/fetch'], + currentUser: user.currentUser, + currentUserLoading: loading.effects['user/fetchCurrent'], + project, + projectLoading: loading.effects['project/fetchNotice'], +})) +class Center extends PureComponent { + state = { + newTags: [], + inputVisible: false, + inputValue: '', + }; + + componentDidMount() { + const { dispatch } = this.props; + dispatch({ + type: 'user/fetchCurrent', + }); + dispatch({ + type: 'list/fetch', + payload: { + count: 8, + }, + }); + dispatch({ + type: 'project/fetchNotice', + }); + } + + onTabChange = key => { + const { match } = this.props; + switch (key) { + case 'articles': + router.push(`${match.url}/articles`); + break; + case 'applications': + router.push(`${match.url}/applications`); + break; + case 'projects': + router.push(`${match.url}/projects`); + break; + default: + break; + } + }; + + showInput = () => { + this.setState({ inputVisible: true }, () => this.input.focus()); + }; + + saveInputRef = input => { + this.input = input; + }; + + handleInputChange = e => { + 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: '', + }); + }; + + render() { + const { newTags, inputVisible, inputValue } = this.state; + const { + listLoading, + currentUser, + currentUserLoading, + project: { notice }, + projectLoading, + match, + location, + children, + } = this.props; + + const operationTabList = [ + { + key: 'articles', + tab: ( + + 文章 (8) + + ), + }, + { + key: 'applications', + tab: ( + + 应用 (8) + + ), + }, + { + key: 'projects', + tab: ( + + 项目 (8) + + ), + }, + ]; + + return ( + + +
    + + {currentUser && Object.keys(currentUser).length ? ( +
    +
    + +
    {currentUser.name}
    +
    {currentUser.signature}
    +
    +
    +

    + + {currentUser.title} +

    +

    + + {currentUser.group} +

    +

    + + {currentUser.geographic.province.label} + {currentUser.geographic.city.label} +

    +
    + +
    +
    标签
    + {currentUser.tags.concat(newTags).map(item => ( + {item.label} + ))} + {inputVisible && ( + + )} + {!inputVisible && ( + + + + )} +
    + +
    +
    团队
    + + + {notice.map(item => ( +
    + + + {item.member} + + + ))} + + + + + ) : ( + 'loading...' + )} + + + + + {children} + + + + + ); + } +} + +export default Center; diff --git a/src/pages/Account/Center/Center.less b/src/pages/Account/Center/Center.less new file mode 100644 index 0000000..f6434fa --- /dev/null +++ b/src/pages/Account/Center/Center.less @@ -0,0 +1,97 @@ +@import '~antd/lib/style/themes/default.less'; +@import '~@/utils/utils.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; + color: @text-color; + transition: color 0.3s; + .textOverflow(); + + &:hover { + color: @primary-color; + } + } +} + +.tabsCard { + :global { + .ant-card-head { + padding: 0 16px; + } + } +} diff --git a/src/pages/Account/Center/Projects.js b/src/pages/Account/Center/Projects.js new file mode 100644 index 0000000..538a9e1 --- /dev/null +++ b/src/pages/Account/Center/Projects.js @@ -0,0 +1,52 @@ +import React, { PureComponent } from 'react'; +import { List, Card } from 'antd'; +import moment from 'moment'; +import { connect } from 'dva'; +import AvatarList from '@/components/AvatarList'; +import stylesProjects from './Projects.less'; + +@connect(({ list }) => ({ + list, +})) +class Center extends PureComponent { + render() { + const { + list: { list }, + } = this.props; + return ( + ( + + } + > + {item.title}} description={item.subDescription} /> +
    + {moment(item.updatedAt).fromNow()} +
    + + {item.members.map(member => ( + + ))} + +
    +
    +
    +
    + )} + /> + ); + } +} + +export default Center; diff --git a/src/pages/Account/Center/Projects.less b/src/pages/Account/Center/Projects.less new file mode 100644 index 0000000..43b8d72 --- /dev/null +++ b/src/pages/Account/Center/Projects.less @@ -0,0 +1,57 @@ +@import '~antd/lib/style/themes/default.less'; +@import '~@/utils/utils.less'; + +.coverCardList { + margin-bottom: -24px; + + .card { + :global { + .ant-card-meta-title { + margin-bottom: 4px; + & > a { + color: @heading-color; + display: inline-block; + max-width: 100%; + } + } + .ant-card-meta-description { + height: 44px; + line-height: 22px; + overflow: hidden; + } + } + + &:hover { + :global { + .ant-card-meta-title > a { + color: @primary-color; + } + } + } + } + + .cardItemContent { + display: flex; + margin-top: 16px; + margin-bottom: -4px; + line-height: 20px; + height: 20px; + & > span { + color: @text-color-secondary; + flex: 1; + font-size: 12px; + } + .avatarList { + flex: 0 1 auto; + } + } + .cardList { + margin-top: 24px; + } + + :global { + .ant-list .ant-list-item-content-single { + max-width: 100%; + } + } +} diff --git a/src/pages/Account/Center/models/list.js b/src/pages/Account/Center/models/list.js new file mode 100644 index 0000000..4758eda --- /dev/null +++ b/src/pages/Account/Center/models/list.js @@ -0,0 +1,54 @@ +import { queryFakeList, removeFakeList, addFakeList, updateFakeList } from '@/services/api'; + +export default { + namespace: 'list', + + state: { + list: [], + }, + + effects: { + *fetch({ payload }, { call, put }) { + const response = yield call(queryFakeList, payload); + yield put({ + type: 'queryList', + payload: Array.isArray(response) ? response : [], + }); + }, + *appendFetch({ payload }, { call, put }) { + const response = yield call(queryFakeList, payload); + yield put({ + type: 'appendList', + payload: Array.isArray(response) ? response : [], + }); + }, + *submit({ payload }, { call, put }) { + let callback; + if (payload.id) { + callback = Object.keys(payload).length === 1 ? removeFakeList : updateFakeList; + } else { + callback = addFakeList; + } + const response = yield call(callback, payload); // post + yield put({ + type: 'queryList', + payload: response, + }); + }, + }, + + reducers: { + queryList(state, action) { + return { + ...state, + list: action.payload, + }; + }, + appendList(state, action) { + return { + ...state, + list: state.list.concat(action.payload), + }; + }, + }, +}; diff --git a/src/pages/Account/Settings/BaseView.js b/src/pages/Account/Settings/BaseView.js new file mode 100644 index 0000000..045948d --- /dev/null +++ b/src/pages/Account/Settings/BaseView.js @@ -0,0 +1,208 @@ +import React, { Component } from 'react'; +import { formatMessage } from 'umi/locale'; +import { Form, Input, Upload, Button, message, Icon, Card } from 'antd'; +import Panel from '../../../components/Panel'; +import { getUserInfo, update } from '../../../services/user'; +import { getToken } from '../../../utils/authority'; + +const FormItem = Form.Item; + +@Form.create() +class BaseView extends Component { + state = { + userId: '', + avatar: '', + loading: false, + }; + + componentDidMount() { + this.setBaseInfo(); + } + + setBaseInfo = () => { + const { form } = this.props; + getUserInfo().then(resp => { + if (resp.success) { + const userInfo = resp.data; + Object.keys(form.getFieldsValue()).forEach(key => { + const obj = {}; + obj[key] = userInfo[key] || null; + form.setFieldsValue(obj); + }); + this.setState({ userId: userInfo.id, avatar: userInfo.avatar }); + } else { + message.error(resp.msg || '获取数据失败'); + } + }); + }; + + beforeUpload = file => { + const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'; + if (!isJpgOrPng) { + message.error('You can only upload JPG/PNG file!'); + } + const isLt2M = file.size / 1024 / 1024 < 2; + if (!isLt2M) { + message.error('Image must smaller than 2MB!'); + } + return isJpgOrPng && isLt2M; + }; + + handleChange = info => { + if (info.file.status === 'uploading') { + this.setState({ loading: true }); + return; + } + if (info.file.status === 'done') { + this.setState({ loading: false, avatar: info.file.response.data.link }); + } + }; + + handleSubmit = e => { + e.preventDefault(); + const { form } = this.props; + const { userId, avatar } = this.state; + form.validateFieldsAndScroll((err, values) => { + if (!err) { + const params = { + id: userId, + ...values, + avatar, + }; + update(params).then(resp => { + if (resp.success) { + message.success(resp.msg); + } else { + message.error(resp.msg || '提交失败'); + } + }); + } + }); + }; + + render() { + const { + form: { getFieldDecorator }, + } = this.props; + + const { avatar, loading } = this.state; + + const formItemLayout = { + labelCol: { + xs: { span: 24 }, + sm: { span: 7 }, + }, + wrapperCol: { + xs: { span: 24 }, + sm: { span: 12 }, + md: { span: 10 }, + }, + }; + + const uploadProp = { + action: '/api/blade-resource/oss/endpoint/put-file', + headers: { + 'Blade-Auth': getToken(), + }, + }; + + const uploadButton = ( +
    + +
    上传头像
    +
    + ); + + const action = ( + + ); + + return ( + +
    + + + {getFieldDecorator('avatar', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.basic.avatar' }, {}), + }, + ], + })( + + {avatar ? ( + avatar + ) : ( + uploadButton + )} + + )} + + + {getFieldDecorator('name', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.basic.nickname-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('realName', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.basic.realname-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('phone', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.basic.phone-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('email', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.basic.email-message' }, {}), + }, + ], + })()} + + + +
    + ); + } +} + +export default BaseView; diff --git a/src/pages/Account/Settings/BaseView.less b/src/pages/Account/Settings/BaseView.less new file mode 100644 index 0000000..e1b09e9 --- /dev/null +++ b/src/pages/Account/Settings/BaseView.less @@ -0,0 +1,52 @@ +@import '~antd/lib/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; + } + } + } +} diff --git a/src/pages/Account/Settings/BindingView.js b/src/pages/Account/Settings/BindingView.js new file mode 100644 index 0000000..29a2989 --- /dev/null +++ b/src/pages/Account/Settings/BindingView.js @@ -0,0 +1,60 @@ +import React, { Component, Fragment } from 'react'; +import { formatMessage, FormattedMessage } from 'umi/locale'; +import { Icon, List } from 'antd'; + +class BindingView extends Component { + getData = () => [ + { + title: formatMessage({ id: 'app.settings.binding.taobao' }, {}), + description: formatMessage({ id: 'app.settings.binding.taobao-description' }, {}), + actions: [ + + + , + ], + avatar: , + }, + { + title: formatMessage({ id: 'app.settings.binding.alipay' }, {}), + description: formatMessage({ id: 'app.settings.binding.alipay-description' }, {}), + actions: [ + + + , + ], + avatar: , + }, + { + title: formatMessage({ id: 'app.settings.binding.dingding' }, {}), + description: formatMessage({ id: 'app.settings.binding.dingding-description' }, {}), + actions: [ + + + , + ], + avatar: , + }, + ]; + + render() { + return ( + + ( + + + + )} + /> + + ); + } +} + +export default BindingView; diff --git a/src/pages/Account/Settings/GeographicView.js b/src/pages/Account/Settings/GeographicView.js new file mode 100644 index 0000000..d33cb13 --- /dev/null +++ b/src/pages/Account/Settings/GeographicView.js @@ -0,0 +1,128 @@ +import React, { PureComponent } from 'react'; +import { Select, Spin } from 'antd'; +import { connect } from 'dva'; +import styles from './GeographicView.less'; + +const { Option } = Select; + +const nullSlectItem = { + label: '', + key: '', +}; + +@connect(({ geographic }) => { + const { province, isLoading, city } = geographic; + return { + province, + city, + isLoading, + }; +}) +class GeographicView extends PureComponent { + componentDidMount = () => { + const { dispatch } = this.props; + dispatch({ + type: 'geographic/fetchProvince', + }); + }; + + componentDidUpdate(props) { + const { dispatch, value } = this.props; + + if (!props.value && !!value && !!value.province) { + dispatch({ + type: 'geographic/fetchCity', + payload: value.province.key, + }); + } + } + + getProvinceOption() { + const { province } = this.props; + return this.getOption(province); + } + + getCityOption = () => { + const { city } = this.props; + return this.getOption(city); + }; + + getOption = list => { + if (!list || list.length < 1) { + return ( + + ); + } + return list.map(item => ( + + )); + }; + + selectProvinceItem = item => { + const { dispatch, onChange } = this.props; + dispatch({ + type: 'geographic/fetchCity', + payload: item.key, + }); + onChange({ + province: item, + city: nullSlectItem, + }); + }; + + selectCityItem = item => { + const { value, onChange } = this.props; + onChange({ + province: value.province, + city: item, + }); + }; + + conversionObject() { + const { value } = this.props; + if (!value) { + return { + province: nullSlectItem, + city: nullSlectItem, + }; + } + const { province, city } = value; + return { + province: province || nullSlectItem, + city: city || nullSlectItem, + }; + } + + render() { + const { province, city } = this.conversionObject(); + const { isLoading } = this.props; + return ( + + + + + ); + } +} + +export default GeographicView; diff --git a/src/pages/Account/Settings/GeographicView.less b/src/pages/Account/Settings/GeographicView.less new file mode 100644 index 0000000..fdc9750 --- /dev/null +++ b/src/pages/Account/Settings/GeographicView.less @@ -0,0 +1,19 @@ +@import '~antd/lib/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; + } +} diff --git a/src/pages/Account/Settings/Info.js b/src/pages/Account/Settings/Info.js new file mode 100644 index 0000000..ceee506 --- /dev/null +++ b/src/pages/Account/Settings/Info.js @@ -0,0 +1,125 @@ +import React, { Component } from 'react'; +import { connect } from 'dva'; +import router from 'umi/router'; +import { FormattedMessage } from 'umi/locale'; +import { Menu } from 'antd'; +import GridContent from '@/components/PageHeaderWrapper/GridContent'; +import styles from './Info.less'; + +const { Item } = Menu; + +@connect(({ user }) => ({ + currentUser: user.currentUser, +})) +class Info extends Component { + constructor(props) { + super(props); + const { match, location } = props; + const menuMap = { + base: , + security: ( + + ), + binding: ( + + ), + notification: ( + + ), + }; + const key = location.pathname.replace(`${match.path}/`, ''); + this.state = { + mode: 'inline', + menuMap, + selectKey: menuMap[key] ? key : 'base', + }; + } + + static getDerivedStateFromProps(props, state) { + const { match, location } = props; + let selectKey = location.pathname.replace(`${match.path}/`, ''); + selectKey = state.menuMap[selectKey] ? selectKey : 'base'; + if (selectKey !== state.selectKey) { + return { selectKey }; + } + return null; + } + + componentDidMount() { + window.addEventListener('resize', this.resize); + this.resize(); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resize); + } + + getmenu = () => { + const { menuMap } = this.state; + return Object.keys(menuMap).map(item => {menuMap[item]}); + }; + + getRightTitle = () => { + const { selectKey, menuMap } = this.state; + return menuMap[selectKey]; + }; + + selectKey = ({ key }) => { + router.push(`/account/settings/${key}`); + this.setState({ + selectKey: key, + }); + }; + + resize = () => { + if (!this.main) { + return; + } + requestAnimationFrame(() => { + let mode = '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, + }); + }); + }; + + render() { + const { children, currentUser } = this.props; + if (!currentUser.userid) { + return ''; + } + const { mode, selectKey } = this.state; + return ( + +
    { + this.main = ref; + }} + > +
    + + {this.getmenu()} + +
    +
    +
    {this.getRightTitle()}
    + {children} +
    +
    +
    + ); + } +} + +export default Info; diff --git a/src/pages/Account/Settings/Info.less b/src/pages/Account/Settings/Info.less new file mode 100644 index 0000000..b391ad5 --- /dev/null +++ b/src/pages/Account/Settings/Info.less @@ -0,0 +1,97 @@ +@import '~antd/lib/style/themes/default.less'; + +.main { + display: flex; + width: 100%; + height: 100%; + padding-top: 16px; + padding-bottom: 16px; + overflow: auto; + background-color: @body-background; + .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 #e8e8e8; + } + .ant-list-item { + padding-top: 14px; + padding-bottom: 14px; + } + } +} +:global { + .ant-list-item-meta { + // 账号绑定图标 + .taobao { + display: block; + color: #ff4000; + font-size: 48px; + line-height: 48px; + border-radius: @border-radius-base; + } + .dingding { + margin: 2px; + padding: 6px; + color: #fff; + font-size: 32px; + line-height: 32px; + background-color: #2eabff; + border-radius: @border-radius-base; + } + .alipay { + color: #2eabff; + font-size: 48px; + line-height: 48px; + border-radius: @border-radius-base; + } + } + + // 密码强度 + font.strong { + color: @success-color; + } + font.medium { + color: @warning-color; + } + font.weak { + color: @error-color; + } +} + +@media screen and (max-width: @screen-md) { + .main { + flex-direction: column; + .leftmenu { + width: 100%; + border: none; + } + .right { + padding: 40px; + } + } +} diff --git a/src/pages/Account/Settings/NotificationView.js b/src/pages/Account/Settings/NotificationView.js new file mode 100644 index 0000000..96677bb --- /dev/null +++ b/src/pages/Account/Settings/NotificationView.js @@ -0,0 +1,50 @@ +import React, { Component, Fragment } from 'react'; +import { formatMessage } from 'umi/locale'; +import { Switch, List } from 'antd'; + +class NotificationView extends Component { + getData = () => { + const Action = ( + + ); + return [ + { + title: formatMessage({ id: 'app.settings.notification.password' }, {}), + description: formatMessage({ id: 'app.settings.notification.password-description' }, {}), + actions: [Action], + }, + { + title: formatMessage({ id: 'app.settings.notification.messages' }, {}), + description: formatMessage({ id: 'app.settings.notification.messages-description' }, {}), + actions: [Action], + }, + { + title: formatMessage({ id: 'app.settings.notification.todo' }, {}), + description: formatMessage({ id: 'app.settings.notification.todo-description' }, {}), + actions: [Action], + }, + ]; + }; + + render() { + return ( + + ( + + + + )} + /> + + ); + } +} + +export default NotificationView; diff --git a/src/pages/Account/Settings/PasswordView.js b/src/pages/Account/Settings/PasswordView.js new file mode 100644 index 0000000..1e0e23d --- /dev/null +++ b/src/pages/Account/Settings/PasswordView.js @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; +import { formatMessage } from 'umi/locale'; +import { Form, Input, Button, Card, message } from 'antd'; +import Panel from '../../../components/Panel'; +import { updatePassword } from '../../../services/user'; + +const FormItem = Form.Item; + +@Form.create() +class PasswordView extends Component { + handleSubmit = e => { + e.preventDefault(); + const { form } = this.props; + form.validateFieldsAndScroll((err, values) => { + if (!err) { + updatePassword(values).then(resp => { + if (resp.success) { + message.success(resp.msg); + } + }); + } + }); + }; + + render() { + const { + form: { getFieldDecorator }, + } = this.props; + + const formItemLayout = { + labelCol: { + xs: { span: 24 }, + sm: { span: 7 }, + }, + wrapperCol: { + xs: { span: 24 }, + sm: { span: 12 }, + md: { span: 10 }, + }, + }; + + const action = ( + + ); + + return ( + +
    + + + {getFieldDecorator('oldPassword', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.password.old-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('newPassword', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.password.new-message' }, {}), + }, + ], + })()} + + + {getFieldDecorator('newPassword1', { + rules: [ + { + required: true, + message: formatMessage({ id: 'app.settings.password.new1-message' }, {}), + }, + ], + })()} + + + +
    + ); + } +} + +export default PasswordView; diff --git a/src/pages/Account/Settings/PhoneView.js b/src/pages/Account/Settings/PhoneView.js new file mode 100644 index 0000000..2665527 --- /dev/null +++ b/src/pages/Account/Settings/PhoneView.js @@ -0,0 +1,33 @@ +import React, { Fragment, PureComponent } from 'react'; +import { Input } from 'antd'; +import styles from './PhoneView.less'; + +class PhoneView extends PureComponent { + render() { + const { value, onChange } = this.props; + let values = ['', '']; + if (value) { + values = value.split('-'); + } + return ( + + { + onChange(`${e.target.value}-${values[1]}`); + }} + /> + { + onChange(`${values[0]}-${e.target.value}`); + }} + value={values[1]} + /> + + ); + } +} + +export default PhoneView; diff --git a/src/pages/Account/Settings/PhoneView.less b/src/pages/Account/Settings/PhoneView.less new file mode 100644 index 0000000..ee4328e --- /dev/null +++ b/src/pages/Account/Settings/PhoneView.less @@ -0,0 +1,11 @@ +@import '~antd/lib/style/themes/default.less'; + +.area_code { + width: 30%; + max-width: 128px; + margin-right: 8px; +} +.phone_number { + width: ~'calc(70% - 8px)'; + max-width: 312px; +} diff --git a/src/pages/Account/Settings/SecurityView.js b/src/pages/Account/Settings/SecurityView.js new file mode 100644 index 0000000..0706bd0 --- /dev/null +++ b/src/pages/Account/Settings/SecurityView.js @@ -0,0 +1,102 @@ +import React, { Component, Fragment } from 'react'; +import { formatMessage, FormattedMessage } from 'umi/locale'; +import { List } from 'antd'; +// import { getTimeDistance } from '@/utils/utils'; + +const passwordStrength = { + strong: ( + + + + ), + medium: ( + + + + ), + weak: ( + + + Weak + + ), +}; + +class SecurityView extends Component { + getData = () => [ + { + title: formatMessage({ id: 'app.settings.security.password' }, {}), + description: ( + + {formatMessage({ id: 'app.settings.security.password-description' })}: + {passwordStrength.strong} + + ), + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'app.settings.security.phone' }, {}), + description: `${formatMessage( + { id: 'app.settings.security.phone-description' }, + {} + )}:138****8293`, + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'app.settings.security.question' }, {}), + description: formatMessage({ id: 'app.settings.security.question-description' }, {}), + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'app.settings.security.email' }, {}), + description: `${formatMessage( + { id: 'app.settings.security.email-description' }, + {} + )}:ant***sign.com`, + actions: [ + + + , + ], + }, + { + title: formatMessage({ id: 'app.settings.security.mfa' }, {}), + description: formatMessage({ id: 'app.settings.security.mfa-description' }, {}), + actions: [ + + + , + ], + }, + ]; + + render() { + return ( + + ( + + + + )} + /> + + ); + } +} + +export default SecurityView; diff --git a/src/pages/Account/Settings/models/geographic.js b/src/pages/Account/Settings/models/geographic.js new file mode 100644 index 0000000..3a9ac63 --- /dev/null +++ b/src/pages/Account/Settings/models/geographic.js @@ -0,0 +1,65 @@ +import { queryProvince, queryCity } from '@/services/api'; + +export default { + namespace: 'geographic', + + state: { + province: [], + city: [], + isLoading: false, + }, + + effects: { + *fetchProvince(_, { call, put }) { + yield put({ + type: 'changeLoading', + payload: true, + }); + const response = yield call(queryProvince); + yield put({ + type: 'setProvince', + payload: response, + }); + yield put({ + type: 'changeLoading', + payload: false, + }); + }, + *fetchCity({ payload }, { call, put }) { + yield put({ + type: 'changeLoading', + payload: true, + }); + const response = yield call(queryCity, payload); + yield put({ + type: 'setCity', + payload: response, + }); + yield put({ + type: 'changeLoading', + payload: false, + }); + }, + }, + + reducers: { + setProvince(state, action) { + return { + ...state, + province: action.payload, + }; + }, + setCity(state, action) { + return { + ...state, + city: action.payload, + }; + }, + changeLoading(state, action) { + return { + ...state, + isLoading: action.payload, + }; + }, + }, +}; diff --git a/src/pages/Authorized.js b/src/pages/Authorized.js new file mode 100644 index 0000000..c29d961 --- /dev/null +++ b/src/pages/Authorized.js @@ -0,0 +1,13 @@ +import React from 'react'; +import RenderAuthorized from '@/components/Authorized'; +import { getAuthority } from '@/utils/authority'; +import Redirect from 'umi/redirect'; + +const Authority = getAuthority(); +const Authorized = RenderAuthorized(Authority); + +export default ({ children }) => ( + }> + {children} + +); diff --git a/src/pages/Dashboard/Analysis.js b/src/pages/Dashboard/Analysis.js new file mode 100644 index 0000000..0b85db7 --- /dev/null +++ b/src/pages/Dashboard/Analysis.js @@ -0,0 +1,187 @@ +import React, { Component, Suspense } from 'react'; +import { connect } from 'dva'; +import { Row, Col, Icon, Menu, Dropdown } from 'antd'; + +import GridContent from '@/components/PageHeaderWrapper/GridContent'; +import { getTimeDistance } from '@/utils/utils'; + +import styles from './Analysis.less'; +import PageLoading from '@/components/PageLoading'; + +const IntroduceRow = React.lazy(() => import('./IntroduceRow')); +const SalesCard = React.lazy(() => import('./SalesCard')); +const TopSearch = React.lazy(() => import('./TopSearch')); +const ProportionSales = React.lazy(() => import('./ProportionSales')); +const OfflineData = React.lazy(() => import('./OfflineData')); + +@connect(({ chart, loading }) => ({ + chart, + loading: loading.effects['chart/fetch'], +})) +class Analysis extends Component { + state = { + salesType: 'all', + currentTabKey: '', + rangePickerValue: getTimeDistance('year'), + }; + + componentDidMount() { + const { dispatch } = this.props; + this.reqRef = requestAnimationFrame(() => { + dispatch({ + type: 'chart/fetch', + }); + }); + } + + componentWillUnmount() { + const { dispatch } = this.props; + dispatch({ + type: 'chart/clear', + }); + cancelAnimationFrame(this.reqRef); + clearTimeout(this.timeoutId); + } + + handleChangeSalesType = e => { + this.setState({ + salesType: e.target.value, + }); + }; + + handleTabChange = key => { + this.setState({ + currentTabKey: key, + }); + }; + + handleRangePickerChange = rangePickerValue => { + const { dispatch } = this.props; + this.setState({ + rangePickerValue, + }); + + dispatch({ + type: 'chart/fetchSalesData', + }); + }; + + selectDate = type => { + const { dispatch } = this.props; + this.setState({ + rangePickerValue: getTimeDistance(type), + }); + + dispatch({ + type: 'chart/fetchSalesData', + }); + }; + + isActive = type => { + const { rangePickerValue } = this.state; + const value = getTimeDistance(type); + if (!rangePickerValue[0] || !rangePickerValue[1]) { + return ''; + } + if ( + rangePickerValue[0].isSame(value[0], 'day') && + rangePickerValue[1].isSame(value[1], 'day') + ) { + return styles.currentDate; + } + return ''; + }; + + render() { + const { rangePickerValue, salesType, currentTabKey } = this.state; + const { chart, loading } = this.props; + const { + visitData, + visitData2, + salesData, + searchData, + offlineData, + offlineChartData, + salesTypeData, + salesTypeDataOnline, + salesTypeDataOffline, + } = chart; + let salesPieData; + if (salesType === 'all') { + salesPieData = salesTypeData; + } else { + salesPieData = salesType === 'online' ? salesTypeDataOnline : salesTypeDataOffline; + } + const menu = ( + + 操作一 + 操作二 + + ); + + const dropdownGroup = ( + + + + + + ); + + const activeKey = currentTabKey || (offlineData[0] && offlineData[0].name); + + return ( + + }> + + + + + +
    + +
    + + + + + + + + + + + + + + + + ); + } +} + +export default Analysis; diff --git a/src/pages/Dashboard/Analysis.less b/src/pages/Dashboard/Analysis.less new file mode 100644 index 0000000..f71bd34 --- /dev/null +++ b/src/pages/Dashboard/Analysis.less @@ -0,0 +1,198 @@ +@import '~antd/lib/style/themes/default.less'; +@import '~@/utils/utils.less'; + +.iconGroup { + i { + margin-left: 16px; + color: @text-color-secondary; + cursor: pointer; + transition: color 0.32s; + &:hover { + color: @text-color; + } + } +} + +.rankingList { + margin: 25px 0 0; + padding: 0; + list-style: none; + li { + .clearfix(); + + display: flex; + align-items: center; + margin-top: 16px; + span { + color: @text-color; + font-size: 14px; + line-height: 22px; + } + .rankingItemNumber { + display: inline-block; + width: 20px; + height: 20px; + margin-top: 1.5px; + margin-right: 16px; + font-weight: 600; + font-size: 12px; + line-height: 20px; + text-align: center; + background-color: @background-color-base; + border-radius: 20px; + &.active { + color: #fff; + background-color: #314659; + } + } + .rankingItemTitle { + flex: 1; + margin-right: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} + +.salesExtra { + display: inline-block; + margin-right: 24px; + a { + margin-left: 24px; + color: @text-color; + &:hover { + color: @primary-color; + } + &.currentDate { + color: @primary-color; + } + } +} + +.salesCard { + .salesBar { + padding: 0 0 32px 32px; + } + .salesRank { + padding: 0 32px 32px 72px; + } + :global { + .ant-tabs-bar { + padding-left: 16px; + .ant-tabs-nav .ant-tabs-tab { + padding-top: 16px; + padding-bottom: 14px; + line-height: 24px; + } + } + .ant-tabs-extra-content { + padding-right: 24px; + line-height: 55px; + } + .ant-card-head { + position: relative; + } + .ant-card-head-title { + align-items: normal; + } + } +} + +.salesCardExtra { + height: inherit; +} + +.salesTypeRadio { + position: absolute; + right: 54px; + bottom: 12px; +} + +.offlineCard { + :global { + .ant-tabs-ink-bar { + bottom: auto; + } + .ant-tabs-bar { + border-bottom: none; + } + .ant-tabs-nav-container-scrolling { + padding-right: 40px; + padding-left: 40px; + } + .ant-tabs-tab-prev-icon::before { + position: relative; + left: 6px; + } + .ant-tabs-tab-next-icon::before { + position: relative; + right: 6px; + } + .ant-tabs-tab-active h4 { + color: @primary-color; + } + } +} + +.twoColLayout { + .salesCard { + height: calc(100% - 24px); + } + div[class^='ant-col']:last-child { + position: absolute\9; + right: 0\9; + height: 100%\9; + } + :global { + .ant-row { + position: relative\9; + display: flex; + display: block\9; + flex-flow: row wrap; + } + } +} + +.trendText { + margin-left: 8px; + color: @heading-color; +} + +@media screen and (max-width: @screen-lg) { + .salesExtra { + display: none; + } + + .rankingList { + li { + span:first-child { + margin-right: 8px; + } + } + } +} + +@media screen and (max-width: @screen-md) { + .rankingTitle { + margin-top: 16px; + } + + .salesCard .salesBar { + padding: 16px; + } +} + +@media screen and (max-width: @screen-sm) { + .salesExtraWrap { + display: none; + } + + .salesCard { + :global { + .ant-tabs-content { + padding-top: 30px; + } + } + } +} diff --git a/src/pages/Dashboard/IntroduceRow.js b/src/pages/Dashboard/IntroduceRow.js new file mode 100644 index 0000000..7262826 --- /dev/null +++ b/src/pages/Dashboard/IntroduceRow.js @@ -0,0 +1,144 @@ +import React, { memo } from 'react'; +import { Row, Col, Icon, Tooltip } from 'antd'; +import { FormattedMessage } from 'umi/locale'; +import styles from './Analysis.less'; +import { ChartCard, MiniArea, MiniBar, MiniProgress, Field } from '@/components/Charts'; +import Trend from '@/components/Trend'; +import numeral from 'numeral'; +import Yuan from '@/utils/Yuan'; + +const topColResponsiveProps = { + xs: 24, + sm: 12, + md: 12, + lg: 12, + xl: 6, + style: { marginBottom: 24 }, +}; + +const IntroduceRow = memo(({ loading, visitData }) => ( + + + } + action={ + } + > + + + } + loading={loading} + total={() => 126560} + footer={ + } + value={`¥${numeral(12423).format('0,0')}`} + /> + } + contentHeight={46} + > + + + 12% + + + + 11% + + + + + + } + action={ + } + > + + + } + total={numeral(8846).format('0,0')} + footer={ + } + value={numeral(1234).format('0,0')} + /> + } + contentHeight={46} + > + + + + + } + action={ + } + > + + + } + total={numeral(6560).format('0,0')} + footer={ + + } + value="60%" + /> + } + contentHeight={46} + > + + + + + + } + action={ + } + > + + + } + total="78%" + footer={ +
    + + + 12% + + + + 11% + +
    + } + contentHeight={46} + > + +
    + + +)); + +export default IntroduceRow; diff --git a/src/pages/Dashboard/Monitor.js b/src/pages/Dashboard/Monitor.js new file mode 100644 index 0000000..6290917 --- /dev/null +++ b/src/pages/Dashboard/Monitor.js @@ -0,0 +1,245 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'dva'; +import { formatMessage, FormattedMessage } from 'umi/locale'; +import { Row, Col, Card, Tooltip } from 'antd'; +import { Pie, WaterWave, Gauge, TagCloud } from '@/components/Charts'; +import NumberInfo from '@/components/NumberInfo'; +import CountDown from '@/components/CountDown'; +import ActiveChart from '@/components/ActiveChart'; +import numeral from 'numeral'; +import GridContent from '@/components/PageHeaderWrapper/GridContent'; + +import Authorized from '@/utils/Authorized'; +import styles from './Monitor.less'; + +const { Secured } = Authorized; + +const targetTime = new Date().getTime() + 3900000; + +// use permission as a parameter +const havePermissionAsync = new Promise(resolve => { + // Call resolve on behalf of passed + setTimeout(() => resolve(), 300); +}); + +@Secured(havePermissionAsync) +@connect(({ monitor, loading }) => ({ + monitor, + loading: loading.models.monitor, +})) +class Monitor extends PureComponent { + componentDidMount() { + const { dispatch } = this.props; + dispatch({ + type: 'monitor/fetchTags', + }); + } + + render() { + const { monitor, loading } = this.props; + const { tags } = monitor; + + return ( + + +
    + + } + bordered={false} + > + + + + } + suffix="元" + total={numeral(124543233).format('0,0')} + /> + + + + } + total="92%" + /> + + + + } + total={} + /> + + + + } + suffix="元" + total={numeral(234).format('0,0')} + /> + + +
    + + } + > + map + +
    + + +
    + + } + style={{ marginBottom: 24 }} + bordered={false} + > + + + } + style={{ marginBottom: 24 }} + bodyStyle={{ textAlign: 'center' }} + bordered={false} + > + + + + + + + + } + bordered={false} + className={styles.pieCard} + > + + + + } + total="28%" + height={128} + lineWidth={2} + /> + + + + } + total="22%" + height={128} + lineWidth={2} + /> + + + + } + total="32%" + height={128} + lineWidth={2} + /> + + + + + + + } + loading={loading} + bordered={false} + bodyStyle={{ overflow: 'hidden' }} + > + + + + + + } + bodyStyle={{ textAlign: 'center', fontSize: 0 }} + bordered={false} + > + + } + percent={34} + /> + + + + + ); + } +} + +export default Monitor; diff --git a/src/pages/Dashboard/Monitor.less b/src/pages/Dashboard/Monitor.less new file mode 100644 index 0000000..82d7272 --- /dev/null +++ b/src/pages/Dashboard/Monitor.less @@ -0,0 +1,23 @@ +@import '~antd/lib/style/themes/default.less'; +@import '~@/utils/utils.less'; + +.mapChart { + height: 452px; + padding-top: 24px; + text-align: center; + img { + display: inline-block; + max-width: 100%; + max-height: 437px; + } +} + +.pieCard :global(.pie-stat) { + font-size: 24px !important; +} + +@media screen and (max-width: @screen-lg) { + .mapChart { + height: auto; + } +} diff --git a/src/pages/Dashboard/OfflineData.js b/src/pages/Dashboard/OfflineData.js new file mode 100644 index 0000000..f7d06ef --- /dev/null +++ b/src/pages/Dashboard/OfflineData.js @@ -0,0 +1,65 @@ +import React, { memo } from 'react'; +import { Card, Tabs, Row, Col } from 'antd'; +import { formatMessage, FormattedMessage } from 'umi/locale'; +import styles from './Analysis.less'; +import { TimelineChart, Pie } from '@/components/Charts'; +import NumberInfo from '@/components/NumberInfo'; + +const CustomTab = ({ data, currentTabKey: currentKey }) => ( + + + + } + gap={2} + total={`${data.cvr * 100}%`} + theme={currentKey !== data.name && 'light'} + /> + + + + + +); + +const { TabPane } = Tabs; + +const OfflineData = memo( + ({ activeKey, loading, offlineData, offlineChartData, handleTabChange }) => ( + + + {offlineData.map(shop => ( + } key={shop.name}> +
    + +
    +
    + ))} +
    +
    + ) +); + +export default OfflineData; diff --git a/src/pages/Dashboard/ProportionSales.js b/src/pages/Dashboard/ProportionSales.js new file mode 100644 index 0000000..ff16a7d --- /dev/null +++ b/src/pages/Dashboard/ProportionSales.js @@ -0,0 +1,58 @@ +import React, { memo } from 'react'; +import { Card, Radio } from 'antd'; +import { FormattedMessage } from 'umi/locale'; +import styles from './Analysis.less'; +import { Pie } from '@/components/Charts'; +import Yuan from '@/utils/Yuan'; + +const ProportionSales = memo( + ({ dropdownGroup, salesType, loading, salesPieData, handleChangeSalesType }) => ( + + } + bodyStyle={{ padding: 24 }} + extra={ +
    + {dropdownGroup} +
    + + + + + + + + + + + +
    +
    + } + style={{ marginTop: 24 }} + > +

    + +

    + } + total={() => {salesPieData.reduce((pre, now) => now.y + pre, 0)}} + data={salesPieData} + valueFormat={value => {value}} + height={270} + lineWidth={4} + style={{ padding: '8px 0' }} + /> +
    + ) +); + +export default ProportionSales; diff --git a/src/pages/Dashboard/SalesCard.js b/src/pages/Dashboard/SalesCard.js new file mode 100644 index 0000000..3ab5777 --- /dev/null +++ b/src/pages/Dashboard/SalesCard.js @@ -0,0 +1,150 @@ +import React, { memo } from 'react'; +import { Row, Col, Card, Tabs, DatePicker } from 'antd'; +import { FormattedMessage, formatMessage } from 'umi/locale'; +import numeral from 'numeral'; +import styles from './Analysis.less'; +import { Bar } from '@/components/Charts'; + +const { RangePicker } = DatePicker; +const { TabPane } = Tabs; + +const rankingListData = []; +for (let i = 0; i < 7; i += 1) { + rankingListData.push({ + title: formatMessage({ id: 'app.analysis.test' }, { no: i }), + total: 323234, + }); +} + +const SalesCard = memo( + ({ rangePickerValue, salesData, isActive, handleRangePickerChange, loading, selectDate }) => ( + + + } + size="large" + tabBarStyle={{ marginBottom: 24 }} + > + } + key="sales" + > + +
    +
    + + } + data={salesData} + /> +
    + +
    +
    +

    + +

    +
      + {rankingListData.map((item, i) => ( +
    • + + {i + 1} + + + {item.title} + + + {numeral(item.total).format('0,0')} + +
    • + ))} +
    +
    + + + + } + key="views" + > + +
    +
    + + } + data={salesData} + /> +
    + +
    +
    +

    + +

    +
      + {rankingListData.map((item, i) => ( +
    • + + {i + 1} + + + {item.title} + + {numeral(item.total).format('0,0')} +
    • + ))} +
    +
    + + + + + + + ) +); + +export default SalesCard; diff --git a/src/pages/Dashboard/TopSearch.js b/src/pages/Dashboard/TopSearch.js new file mode 100644 index 0000000..4e75ea7 --- /dev/null +++ b/src/pages/Dashboard/TopSearch.js @@ -0,0 +1,111 @@ +import React, { memo } from 'react'; +import { Row, Col, Table, Tooltip, Card, Icon } from 'antd'; +import { FormattedMessage } from 'umi/locale'; +import Trend from '@/components/Trend'; +import numeral from 'numeral'; +import styles from './Analysis.less'; +import NumberInfo from '@/components/NumberInfo'; +import { MiniArea } from '@/components/Charts'; + +const columns = [ + { + title: , + dataIndex: 'index', + key: 'index', + }, + { + title: ( + + ), + dataIndex: 'keyword', + key: 'keyword', + render: text => {text}, + }, + { + title: , + dataIndex: 'count', + key: 'count', + sorter: (a, b) => a.count - b.count, + className: styles.alignRight, + }, + { + title: , + dataIndex: 'range', + key: 'range', + sorter: (a, b) => a.range - b.range, + render: (text, record) => ( + + {text}% + + ), + align: 'right', + }, +]; + +const TopSearch = memo(({ loading, visitData2, searchData, dropdownGroup }) => ( + + } + extra={dropdownGroup} + style={{ marginTop: 24 }} + > + +
    + + + } + > + + + + } + gap={8} + total={numeral(12321).format('0,0')} + status="up" + subTotal={17.1} + /> + + + + + + } + > + + + + } + total={2.7} + status="down" + subTotal={26.2} + gap={8} + /> + + + +
    record.index} + size="small" + columns={columns} + dataSource={searchData} + pagination={{ + style: { marginBottom: 0 }, + pageSize: 5, + }} + /> + +)); + +export default TopSearch; diff --git a/src/pages/Dashboard/Workplace.js b/src/pages/Dashboard/Workplace.js new file mode 100644 index 0000000..3b87443 --- /dev/null +++ b/src/pages/Dashboard/Workplace.js @@ -0,0 +1,364 @@ +import React, { PureComponent } from 'react'; +import { Card, Col, Collapse, Row, Divider, Tag } from 'antd'; +import styles from '../../layouts/Sword.less'; + +import PageHeaderWrapper from '@/components/PageHeaderWrapper'; + +const { Panel } = Collapse; + +class Workplace extends PureComponent { + render() { + return ( + + + + +
    + Downloads + Build Status + Coverage Status + Downloads + + Downloads + + + Downloads + +
    + + + + +
    + + + +
    1.Sword是SpringBlade前端UI系统
    +
    2.对现有的ant design pro库进行二次封装
    +
    3.100%兼容原生ant design pro库
    +
    4.配合后端代码生成系统可以快速搭建完整的功能模块
    +
    5.使用Sword可以大幅度提升开发效率,不再为重复工作发愁
    +
    + +
    1.BladeX是一款精心设计的微服务架构,提供 SpringCloud 全套解决方案
    +
    2.开源中国首批完美集成 SpringCloud Alibaba 系列组件的微服务架构
    +
    3.基于稳定生产的商业项目升级优化而来,更加贴近企业级的需求
    +
    4.追求企业开发更加高效,部署更加方便,生产更加稳定
    +
    5.GVP-码云最有价值开源项目
    +
    + 6.BladeX授权地址:点击授权 +
    +
    + +
    1.经历过较长的线上生产,积累了很多企业痛点的解决方案。
    +
    2.一套代码兼容MySql、Oracle、PostgreSQL,适应企业各种不同场景的需求。
    +
    + 3.集成了很多企业急切所需的例如多租户、Oauth2授权认证、工作流、分布式事务等等功能。 +
    +
    + 4.深度定制了Flowable工作流,完美支持SpringCloud分布式服务的场景,以远程调用的方式进行操作。 +
    +
    + 5.升级了核心驱动,新功能完全可以开箱即用,而开源版需要自己再花时间进行集成,需要花掉更多的时间成本。 +
    +
    + 6.拥抱微服务时代,很多企业由于项目转型或升级,传统的技术已然不能满足,反而会花更多成本,而BladeX就是为此而生。 +
    +
    + 7.同时提供SpringCloud版本和SpringBoot版本,两个版本的api可以与Sword和Saber无缝对接,为小型项目至大型项目保驾护航。 +
    +
    + 8.授权购买即永久,源码没有混淆,后续升级完全免费。企业只需花很少的钱即可获得一整套成熟的解决方案,你还在等什么? +
    +
    + +
    + 1.前后端分离-采用前后端分离模式,前端提供两套架构,Sword基于React,Saber基于Vue +
    +
    + 2. + 分布式单体式后端架构-提供两套后端架构,基于SpringCloud的分布式架构以及基于SpringBoot的单体式架构 +
    +
    + 3.API完全兼容-两套后端架构与两套前端架构,共四套架构可以任意组合,所有API完全兼容 +
    +
    + 4.前后端代码生成-定制针对两套前端与后端的代码生成模板,轻松生成整个模块的前后端代码,减少重复工作量 +
    +
    + 5.组件化、插件化架构-针对功能深度定制各个starter,引入开箱即用,为整个架构解耦,提升效率 +
    +
    6.Nacos-集成阿里巴巴的Nacos完成统一的服务注册与配置
    +
    + 7.Sentinel-集成Sentinel从流量控制、熔断降级、系统负载等多个维度保护服务的稳定性 +
    +
    8.Dubbo-完美集成Dubbo最新版,支持远程RPC调用
    +
    9.多租户系统-完整的SaaS多租户架构
    +
    10.Oauth2-集成Oauth2协议,完美支持多终端的接入与认证授权
    +
    + 11.工作流-深度定制SpringCloud分布式场景的Flowable工作流,为复杂流程保驾护航。同时提供SpringBoot集成版本 +
    +
    12.独立流程设计器-提供独立的完全汉化的流程设计器,轻松定制流程模型
    +
    13.动态网关-集成基于Nacos的轻量级、高拓展性动态网关
    +
    14.动态聚合文档-实现基于Nacos的Swagger SpringCloud聚合文档
    +
    + 15.分布式文件服务-集成minio、qiniu、alioss等优秀的第三方,提供便捷的文件上传与管理 +
    +
    + 16.多租户对象存储系统-在SaaS系统中,各租户可自行配置文件上传至自己的私有OSS +
    +
    17.权限管理-精心设计的权限管理方案,角色权限精确到按钮
    +
    + 18.动态数据权限-高度灵活的动态数据权限,提供注解+Web可视化两种配置方式,Web配置无需重启直接生效 +
    +
    + 19.动态接口权限-高度灵活的动态接口权限,提供注解+Web可视化两种配置方式,Web配置无需重启直接生效 +
    +
    + 20.多租户顶部菜单配置-提供给每个租户独立的顶部菜单配置模块,可以自定义顶部菜单切换 +
    +
    + 21.主流数据库兼容-一套代码完全兼容Mysql、Postgresql、Oracle三大主流数据库 +
    +
    22.动态网关鉴权-基于Nacos的动态网关鉴权,可在线配置,实时生效
    +
    + 23.全能代码生成器-支持自定义模型、模版 + 、业务建模,支持多种模板引擎,在线配置。大幅度提升开发效率,不再为重复工作发愁。 +
    +
    + 24.Seata分布式事务-定制集成Seata,支持分布式事务,无代码侵入,不失灵活与简洁 +
    +
    25.未完待续...
    +
    + +
    1.接BladeX系列架构的定制服务
    +
    + 2.接3个月以内工期的react、vue、springboot、springcloud、app、小程序等软件定制服务 +
    +
    3.有意向请联系唯一指定QQ:85088620
    +
    +
    +
    + +
    + + + 产品名称 + + SpringBlade企业级微服务开发平台 + + 账号密码 + + 管理员(admin) + + 官网地址 + + + https://bladex.vip + + + 社区地址 + + + https://sns.bladex.vip + + + 获取文档 + + + + 免费版 + + + + + + 收费版 + + + + 获取源码 + + + + 开源版 + + + + + + 商业版 + + + + + + + + +
    1.升级SpringBoot 2.2.4.RELEASE
    +
    2.升级Alibaba Cloud 2.2.0.RELEASE
    +
    3.升级Mybatis-Plus 3.3.1
    +
    4.增加登陆验证码功能
    +
    5.增加验证码对应的CaptchaTokenGranter
    +
    6.增加RedisUtil,方便业务操作
    +
    7.增加Condition类getQueryWrapper自定义排除参数的入口
    +
    8.优化Seata封装,完美支持1.0.0版本
    +
    + +
    1.升级SpringCloud Hoxton.SR1
    +
    2.升级SpringBoot 2.2.2.RELEASE
    +
    3.升级Alibaba Cloud 2.1.1.RELEASE
    +
    4.升级Seata 1.0.0
    +
    5.升级Swagger-Bootstrap-UI为最新的Knife4j 2.0.1
    +
    6.升级Xss过滤机制以适配最新架构
    +
    7.升级前端请求机制以适配最新架构
    +
    8.修复blade-resource无法进行docker打包的问题
    +
    9.修复blade-demo没有配置LauncherService导致启动失败的问题
    +
    + +
    1.增加示例工程,增加多种常见场景的解决方案
    +
    2.增加不同包名运行的示例
    +
    3.增加多数据源调用运行的示例
    +
    4.增加自定义加载Naocs配置文件的示例
    +
    5.增加根据Nacos命名空间读取配置、注册服务的示例
    +
    6.修复Condition类没有过滤分页字段的问题
    +
    7.拆分CommonConstant出LauncherConstant
    +
    + +
    1.封装集成zipkin,支持分布式链路追踪
    +
    2.seata升级至0.9.0,解决部分分布式事务遇到的bug
    +
    3.springboot版本升级至2.1.9
    +
    + +
    1.增加个人中心,支持用户信息自定义修改
    +
    2.增加网关鉴权配置示例
    +
    3.token的SIGN_KEY修改为一致
    +
    4.admin模块增加对seata服务的过滤
    +
    5.blade-tool增加部分工具类方法
    +
    + +
    1.增加网关动态鉴权
    +
    2.secure安全模块token校验默认关闭,交由网关处理
    +
    3.boot版本开启secure token校验功能
    +
    4.优化blade-gateway代码逻辑
    +
    5.修复blade-resource无法启动的问题
    +
    + +
    1.封装集成seata,支持分布式事务
    +
    2.重写blade-core-cloud模块,增强cloud场景支持
    +
    3.增加hystrix自动fallback功能
    +
    4.升级springboot至2.1.8.RELEASE
    +
    5.升级springcloud至Greenwich.SR3
    +
    + +
    1.升级SpringBoot至2.1.7
    +
    2.代码生成增加多数据源配置
    +
    3.增强代码生成功能,支持可选基础业务、包装器配置
    +
    4.优化代码生成模板
    +
    + +
    1.升级AlibabaCloud毕业版本
    +
    2.升级支持Naocs 1.1.0、Sentinel 1.6.3
    +
    3.租户系统的tenantCode统一更改为tenantId
    +
    4.优化代码生成模板
    +
    5.优化mybatis-plus新版配置
    +
    6.修复排序字段sql注入问题
    +
    + +
    1.重构令牌发放逻辑,可自定义令牌类型,增强可拓展性
    +
    2.增加动态配置token过期时间、令牌续期功能
    +
    3.增加GateWay动态聚合文档功能,简化配置
    +
    4.优化Wrapper定义,代码更加简洁
    +
    5.Swagger增加多包扫描
    +
    6.使用 Swagger-Bootstrap-UI 最新版排序注解
    +
    7.升级 SpringBoot 2.1.6,SpringCloud Greenwich.SR2
    +
    8.升级 Mybatis-Plus 3.1.2
    +
    9.修复排序字段可能导致的sql注入问题
    +
    10.修复部分缓存清除失效的问题
    +
    + +
    1.增加七牛云oss-starter
    +
    2.增加blade-resource模块,对外提供服务,支持分布式下的oss场景
    +
    3.LauncherService增加排序功能
    +
    4.增加单元测试starter,可在启动过程中便捷地指定profile以及启动参数
    +
    5.增加指定启动参数的单元测试demo
    +
    6.优化docker脚本配置
    +
    + +
    1.升级 SpringBoot 2.1.5
    +
    2.前端框架Saber升级 element-ui 2.8.2
    +
    3.Saber业务代码升级
    +
    4.优化Saber代码生成模板
    +
    5.统一日志业务表基础字段
    +
    6.优化租户过滤逻辑
    +
    7.BaseEntity放开主键限制,子类可自定义主键类型
    +
    8.XssFilter增加放行配置,可配置放行微信api接口
    +
    + +
    1.升级 SpringCloud Greenwich
    +
    + 2.升级 SpringCloud Alibaba + 组件版本为0.9.0.RELEASE,支持最新版本的nacos与sentinel +
    +
    3.升级 SpringBoot 2.1.4
    +
    4.升级 mysql 驱动版本
    +
    5.优化 LauncherService 关于环境的判断逻辑
    +
    6.修复 blade-core-log 在部分情况下获取request为空的问题
    +
    7.修复多租户插件判断租户过滤的逻辑
    +
    8.修复请求日志打印插件部分格式空指针的问题
    +
    9.降低nacos心跳日志等级,关闭心跳日志显示
    +
    + +
    1.增加多终端令牌认证系统
    +
    2.增加多租户开关
    +
    3.修复部分模块没有筛选已删除的问题
    +
    4.调整角色分配会越权的问题
    +
    5.优化部署脚本
    +
    + +
    1.升级为SaaS多租户系统
    +
    2.优化代码生成逻辑
    +
    3.代码生成增加菜单sql
    +
    4.增加SysClient,提供系统信息远程调用
    +
    5.优化部署脚本,增加前端部署实例
    +
    6.增加父子角色过滤,使得角色无法越权配置
    +
    + +
    + 1.SpringBlade 2.0 + 是由一个商业级项目升级优化而来的SpringCloud微服务架构,采用Java8 + API重构了业务代码,完全遵循阿里巴巴编码规范 +
    +
    + 2.采用Spring Boot 2 、Spring Cloud Greenwich 、Mybatis + 等核心技术,用于快速搭建企业级的微服务系统平台 +
    +
    + 3.SpringBlade + 致力于创造新颖的开发模式,将开发中遇到的痛点、生产中所踩的坑整理归纳,并将解决方案都融合到框架中 +
    +
    +
    +
    +
    + + + + ); + } +} + +export default Workplace; diff --git a/src/pages/Dashboard/Workplace.less b/src/pages/Dashboard/Workplace.less new file mode 100644 index 0000000..b67e0b6 --- /dev/null +++ b/src/pages/Dashboard/Workplace.less @@ -0,0 +1,228 @@ +@import '~antd/lib/style/themes/default.less'; +@import '~@/utils/utils.less'; + +.activitiesList { + padding: 0 24px 8px 24px; + .username { + color: @text-color; + } + .event { + font-weight: normal; + } +} + +.pageHeaderContent { + display: flex; + .avatar { + flex: 0 1 72px; + margin-bottom: 8px; + & > span { + display: block; + width: 72px; + height: 72px; + border-radius: 72px; + } + } + .content { + position: relative; + top: 4px; + flex: 1 1 auto; + margin-left: 24px; + color: @text-color-secondary; + line-height: 22px; + .contentTitle { + margin-bottom: 12px; + color: @heading-color; + font-weight: 500; + font-size: 20px; + line-height: 28px; + } + } +} + +.extraContent { + .clearfix(); + + float: right; + white-space: nowrap; + .statItem { + position: relative; + display: inline-block; + padding: 0 32px; + > p:first-child { + margin-bottom: 4px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + > p { + margin: 0; + color: @heading-color; + font-size: 30px; + line-height: 38px; + > span { + color: @text-color-secondary; + font-size: 20px; + } + } + &::after { + position: absolute; + top: 8px; + right: 0; + width: 1px; + height: 40px; + background-color: @border-color-split; + content: ''; + } + &:last-child { + padding-right: 0; + &::after { + display: none; + } + } + } +} + +.members { + a { + display: block; + height: 24px; + margin: 12px 0; + color: @text-color; + transition: all 0.3s; + .textOverflow(); + .member { + margin-left: 12px; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + } + &:hover { + color: @primary-color; + } + } +} + +.projectList { + :global { + .ant-card-meta-description { + height: 44px; + overflow: hidden; + color: @text-color-secondary; + line-height: 22px; + } + } + .cardTitle { + font-size: 0; + a { + display: inline-block; + height: 24px; + margin-left: 12px; + color: @heading-color; + font-size: @font-size-base; + line-height: 24px; + vertical-align: top; + &:hover { + color: @primary-color; + } + } + } + .projectGrid { + width: 33.33%; + } + .projectItemContent { + display: flex; + height: 20px; + margin-top: 8px; + overflow: hidden; + font-size: 12px; + line-height: 20px; + .textOverflow(); + a { + display: inline-block; + flex: 1 1 0; + color: @text-color-secondary; + .textOverflow(); + &:hover { + color: @primary-color; + } + } + .datetime { + flex: 0 0 auto; + float: right; + color: @disabled-color; + } + } +} + +.datetime { + color: @disabled-color; +} + +@media screen and (max-width: @screen-xl) and (min-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + margin-left: -44px; + .statItem { + padding: 0 16px; + } + } +} + +@media screen and (max-width: @screen-lg) { + .activeCard { + margin-bottom: 24px; + } + .members { + margin-bottom: 0; + } + .extraContent { + float: none; + margin-right: 0; + .statItem { + padding: 0 16px; + text-align: left; + &::after { + display: none; + } + } + } +} + +@media screen and (max-width: @screen-md) { + .extraContent { + margin-left: -16px; + } + .projectList { + .projectGrid { + width: 50%; + } + } +} + +@media screen and (max-width: @screen-sm) { + .pageHeaderContent { + display: block; + .content { + margin-left: 0; + } + } + .extraContent { + .statItem { + float: none; + } + } +} + +@media screen and (max-width: @screen-xs) { + .projectList { + .projectGrid { + width: 100%; + } + } +} diff --git a/src/pages/Dashboard/models/activities.js b/src/pages/Dashboard/models/activities.js new file mode 100644 index 0000000..4e0a11e --- /dev/null +++ b/src/pages/Dashboard/models/activities.js @@ -0,0 +1,28 @@ +import { queryActivities } from '@/services/api'; + +export default { + namespace: 'activities', + + state: { + list: [], + }, + + effects: { + *fetchList(_, { call, put }) { + const response = yield call(queryActivities); + yield put({ + type: 'saveList', + payload: Array.isArray(response) ? response : [], + }); + }, + }, + + reducers: { + saveList(state, action) { + return { + ...state, + list: action.payload, + }; + }, + }, +}; diff --git a/src/pages/Dashboard/models/chart.js b/src/pages/Dashboard/models/chart.js new file mode 100644 index 0000000..8dfe4a9 --- /dev/null +++ b/src/pages/Dashboard/models/chart.js @@ -0,0 +1,61 @@ +import { fakeChartData } from '@/services/api'; + +export default { + namespace: 'chart', + + state: { + visitData: [], + visitData2: [], + salesData: [], + searchData: [], + offlineData: [], + offlineChartData: [], + salesTypeData: [], + salesTypeDataOnline: [], + salesTypeDataOffline: [], + radarData: [], + loading: false, + }, + + effects: { + *fetch(_, { call, put }) { + const response = yield call(fakeChartData); + yield put({ + type: 'save', + payload: response, + }); + }, + *fetchSalesData(_, { call, put }) { + const response = yield call(fakeChartData); + yield put({ + type: 'save', + payload: { + salesData: response.salesData, + }, + }); + }, + }, + + reducers: { + save(state, { payload }) { + return { + ...state, + ...payload, + }; + }, + clear() { + return { + visitData: [], + visitData2: [], + salesData: [], + searchData: [], + offlineData: [], + offlineChartData: [], + salesTypeData: [], + salesTypeDataOnline: [], + salesTypeDataOffline: [], + radarData: [], + }; + }, + }, +}; diff --git a/src/pages/Dashboard/models/monitor.js b/src/pages/Dashboard/models/monitor.js new file mode 100644 index 0000000..e3e832f --- /dev/null +++ b/src/pages/Dashboard/models/monitor.js @@ -0,0 +1,28 @@ +import { queryTags } from '@/services/api'; + +export default { + namespace: 'monitor', + + state: { + tags: [], + }, + + effects: { + *fetchTags(_, { call, put }) { + const response = yield call(queryTags); + yield put({ + type: 'saveTags', + payload: response.list, + }); + }, + }, + + reducers: { + saveTags(state, action) { + return { + ...state, + tags: action.payload, + }; + }, + }, +}; diff --git a/src/pages/Desk/Notice/Notice.js b/src/pages/Desk/Notice/Notice.js new file mode 100644 index 0000000..7bf7f04 --- /dev/null +++ b/src/pages/Desk/Notice/Notice.js @@ -0,0 +1,144 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'dva'; +import { Button, Col, Form, Input, Row, Select, DatePicker } from 'antd'; +import { formatMessage, FormattedMessage } from 'umi/locale'; +import Panel from '../../../components/Panel'; +import Grid from '../../../components/Sword/Grid'; +import { NOTICE_INIT, NOTICE_LIST } from '../../../actions/notice'; +import func from '../../../utils/Func'; + +const FormItem = Form.Item; +const { RangePicker } = DatePicker; + +@connect(({ notice, loading }) => ({ + notice, + loading: loading.models.notice, +})) +@Form.create() +class Notice extends PureComponent { + // ============ 初始化数据 =============== + componentWillMount() { + const { dispatch } = this.props; + dispatch(NOTICE_INIT()); + } + + // ============ 查询 =============== + handleSearch = params => { + const { dispatch } = this.props; + + const { dateRange } = params; + + const payload = { + ...params, + begin_date: dateRange ? func.format(dateRange[0], 'YYYY-MM-DD') : null, + end_date: dateRange ? func.format(dateRange[1], 'YYYY-MM-DD') : null, + }; + + payload.dateRange = null; + + dispatch(NOTICE_LIST(payload)); + }; + + // ============ 查询表单 =============== + renderSearchForm = onReset => { + const { + form, + notice: { + init: { category }, + }, + } = this.props; + const { getFieldDecorator } = form; + + return ( + +
    + }> + {getFieldDecorator('category')( + + )} + + + + }> + {getFieldDecorator('title')( + + )} + + + + }> + {getFieldDecorator('dateRange')( + + )} + + + +
    + + +
    + + + ); + }; + + render() { + const code = 'notice'; + + const { + form, + loading, + notice: { data }, + } = this.props; + + const columns = [ + { + title: formatMessage({ id: 'desk.notice.title' }), + dataIndex: 'title', + }, + { + title: formatMessage({ id: 'desk.notice.category' }), + dataIndex: 'categoryName', + }, + { + title: formatMessage({ id: 'desk.notice.content' }), + dataIndex: 'content', + }, + { + title: formatMessage({ id: 'desk.notice.date' }), + dataIndex: 'releaseTime', + }, + ]; + + return ( + + + + ); + } +} +export default Notice; diff --git a/src/pages/Desk/Notice/NoticeAdd.js b/src/pages/Desk/Notice/NoticeAdd.js new file mode 100644 index 0000000..34e3892 --- /dev/null +++ b/src/pages/Desk/Notice/NoticeAdd.js @@ -0,0 +1,142 @@ +import React, { PureComponent } from 'react'; +import { Form, Input, Card, Select, Button, DatePicker } from 'antd'; +import { formatMessage, FormattedMessage } from 'umi/locale'; +import { connect } from 'dva'; +import moment from 'moment'; +import Panel from '../../../components/Panel'; +import { NOTICE_INIT, NOTICE_SUBMIT } from '../../../actions/notice'; +import func from '../../../utils/Func'; + +const FormItem = Form.Item; +const { TextArea } = Input; + +@connect(({ notice, loading }) => ({ + notice, + submitting: loading.effects['notice/submit'], +})) +@Form.create() +class NoticeAdd extends PureComponent { + componentWillMount() { + const { dispatch } = this.props; + dispatch(NOTICE_INIT()); + } + + handleSubmit = e => { + e.preventDefault(); + const { dispatch, form } = this.props; + form.validateFieldsAndScroll((err, values) => { + if (err) return; + + const params = { + ...values, + releaseTime: func.format(values.releaseTime), + }; + + dispatch(NOTICE_SUBMIT(params)); + }); + }; + + disabledDate = current => + // Can not select days before today + current && current < moment().endOf('day'); + + render() { + const { + form: { getFieldDecorator }, + notice: { init }, + submitting, + } = this.props; + + const { category } = init; + + const formItemLayout = { + labelCol: { + xs: { span: 24 }, + sm: { span: 7 }, + }, + wrapperCol: { + xs: { span: 24 }, + sm: { span: 12 }, + md: { span: 10 }, + }, + }; + + const action = ( + + ); + + return ( + } back="/desk/notice" action={action}> +
    + + }> + {getFieldDecorator('title', { + rules: [ + { + required: true, + message: formatMessage({ id: 'desk.notice.title.validation' }), + }, + ], + })()} + + }> + {getFieldDecorator('category', { + rules: [ + { + required: true, + message: formatMessage({ id: 'desk.notice.category.validation' }), + }, + ], + })( + + )} + + }> + {getFieldDecorator('releaseTime', { + rules: [ + { + required: true, + message: formatMessage({ id: 'desk.notice.date.validation' }), + }, + ], + })( + + )} + + }> + {getFieldDecorator('content', { + rules: [ + { + required: true, + message: formatMessage({ id: 'desk.notice.content.validation' }), + }, + ], + })( +