diff --git a/config/config.ts b/config/config.ts index c7b5c76b5c14402af430d2097bb4d97421b20ae3..7d55213b386db620f3925feaa175848ce0992862 100644 --- a/config/config.ts +++ b/config/config.ts @@ -93,12 +93,11 @@ export default { Routes: ['src/pages/Authorized'], authority: ['admin', 'user'], routes: [ - // dashboard { - path: '/', - name: 'welcome', - icon: 'smile', - component: './Welcome', + path: '/analysis', + name: 'Analysis', + icon: 'dashboard', + component: './analysis', }, ], }, diff --git a/package.json b/package.json index 5004028e88b7f5dec2f157eb4368e095fd495c34..924a704ad8fc5df58a5262e4107b94928d6c465c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lint-staged": "lint-staged", "lint-staged:js": "eslint --ext .js", "lint-staged:ts": "tslint", - "lint:fix": "eslint --fix --ext .js src mock tests && stylelint --fix 'src/**/*.less' --syntax less", + "lint:fix": "eslint --fix --ext .js src tests && stylelint --fix 'src/**/*.less' --syntax less", "lint:js": "eslint --ext .js src tests", "lint:prettier": "check-prettier lint", "lint:style": "stylelint 'src/**/*.less' --syntax less", @@ -57,6 +57,7 @@ "dependencies": { "@ant-design/pro-layout": "^4.1.0", "@antv/data-set": "^0.10.1", + "ant-design-pro": "^2.1.1", "antd": "^3.16.1", "bizcharts": "^3.4.3", "bizcharts-plugin-slider": "^2.1.1-beta.1", @@ -67,6 +68,7 @@ "lodash-decorators": "^6.0.0", "memoize-one": "^5.0.0", "moment": "^2.22.2", + "numeral": "^2.0.6", "omit.js": "^1.0.0", "path-to-regexp": "^2.4.0", "qs": "^6.7.0", diff --git a/src/pages/analysis/_mock.ts b/src/pages/analysis/_mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..29100ede39df287ae10b52429d7d4b588d5126ef --- /dev/null +++ b/src/pages/analysis/_mock.ts @@ -0,0 +1,197 @@ +import moment from 'moment'; +import { IVisitData, IRadarData, IAnalysisData } from './data'; + +// mock data +const visitData: IVisitData[] = []; +const beginDay = new Date().getTime(); + +const fakeY = [7, 5, 4, 2, 4, 7, 5, 6, 5, 9, 6, 3, 1, 5, 3, 6, 5]; +for (let i = 0; i < fakeY.length; i += 1) { + visitData.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY[i], + }); +} + +const visitData2 = []; +const fakeY2 = [1, 6, 4, 8, 3, 7, 2]; +for (let i = 0; i < fakeY2.length; i += 1) { + visitData2.push({ + x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'), + y: fakeY2[i], + }); +} + +const salesData = []; +for (let i = 0; i < 12; i += 1) { + salesData.push({ + x: `${i + 1}月`, + y: Math.floor(Math.random() * 1000) + 200, + }); +} +const searchData = []; +for (let i = 0; i < 50; i += 1) { + searchData.push({ + index: i + 1, + keyword: `搜索关键词-${i}`, + count: Math.floor(Math.random() * 1000), + range: Math.floor(Math.random() * 100), + status: Math.floor((Math.random() * 10) % 2), + }); +} +const salesTypeData = [ + { + x: '家用电器', + y: 4544, + }, + { + x: '食用酒水', + y: 3321, + }, + { + x: '个护健康', + y: 3113, + }, + { + x: '服饰箱包', + y: 2341, + }, + { + x: '母婴产品', + y: 1231, + }, + { + x: '其他', + y: 1231, + }, +]; + +const salesTypeDataOnline = [ + { + x: '家用电器', + y: 244, + }, + { + x: '食用酒水', + y: 321, + }, + { + x: '个护健康', + y: 311, + }, + { + x: '服饰箱包', + y: 41, + }, + { + x: '母婴产品', + y: 121, + }, + { + x: '其他', + y: 111, + }, +]; + +const salesTypeDataOffline = [ + { + x: '家用电器', + y: 99, + }, + { + x: '食用酒水', + y: 188, + }, + { + x: '个护健康', + y: 344, + }, + { + x: '服饰箱包', + y: 255, + }, + { + x: '其他', + y: 65, + }, +]; + +const offlineData = []; +for (let i = 0; i < 10; i += 1) { + offlineData.push({ + name: `Stores ${i}`, + cvr: Math.ceil(Math.random() * 9) / 10, + }); +} +const offlineChartData = []; +for (let i = 0; i < 20; i += 1) { + offlineChartData.push({ + x: new Date().getTime() + 1000 * 60 * 30 * i, + y1: Math.floor(Math.random() * 100) + 10, + y2: Math.floor(Math.random() * 100) + 10, + }); +} + +const radarOriginData = [ + { + name: '个人', + ref: 10, + koubei: 8, + output: 4, + contribute: 5, + hot: 7, + }, + { + name: '团队', + ref: 3, + koubei: 9, + output: 6, + contribute: 3, + hot: 1, + }, + { + name: '部门', + ref: 4, + koubei: 1, + output: 6, + contribute: 5, + hot: 7, + }, +]; + +const radarData: IRadarData[] = []; +const radarTitleMap = { + ref: '引用', + koubei: '口碑', + output: '产量', + contribute: '贡献', + hot: '热度', +}; +radarOriginData.forEach(item => { + Object.keys(item).forEach(key => { + if (key !== 'name') { + radarData.push({ + name: item.name, + label: radarTitleMap[key], + value: item[key], + }); + } + }); +}); + +const getFakeChartData: IAnalysisData = { + visitData, + visitData2, + salesData, + searchData, + offlineData, + offlineChartData, + salesTypeData, + salesTypeDataOnline, + salesTypeDataOffline, + radarData, +}; + +export default { + 'GET /api/analysis/fake_chart_data': getFakeChartData, +}; diff --git a/src/pages/analysis/components/Charts/Bar/index.tsx b/src/pages/analysis/components/Charts/Bar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1eaa6454a9957d3e3dbb0f00c6db286c654cc098 --- /dev/null +++ b/src/pages/analysis/components/Charts/Bar/index.tsx @@ -0,0 +1,133 @@ +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'; + +export interface IBarProps { + title: React.ReactNode; + color?: string; + padding?: [number, number, number, number]; + height?: number; + data: Array<{ + x: string; + y: number; + }>; + forceFit?: boolean; + autoLabel?: boolean; + style?: React.CSSProperties; +} + +class Bar extends Component< + IBarProps, + { + autoHideXLabels: boolean; + } +> { + root: HTMLDivElement | undefined; + node: HTMLDivElement | undefined; + + state = { + autoHideXLabels: false, + }; + + componentDidMount() { + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.resize); + } + + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + handleRef = (n: HTMLDivElement) => { + this.node = n; + }; + + @Bind() + @Debounce(400) + resize() { + if (!this.node || !this.node.parentNode) { + return; + } + const canvasWidth = (this.node.parentNode as HTMLDivElement).clientWidth; + const { data = [], autoLabel = true } = this.props; + if (!autoLabel) { + return; + } + const minWidth = data.length * 30; + const { autoHideXLabels } = this.state; + + if (canvasWidth <= minWidth) { + if (!autoHideXLabels) { + this.setState({ + autoHideXLabels: true, + }); + } + } else if (autoHideXLabels) { + this.setState({ + autoHideXLabels: false, + }); + } + } + + render() { + const { + height = 1, + title, + forceFit = true, + data, + color = 'rgba(24, 144, 255, 0.85)', + padding, + } = this.props; + + const { autoHideXLabels } = this.state; + + const scale = { + x: { + type: 'cat', + }, + y: { + min: 0, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + return ( +
+
+ {title &&

{title}

} + + + + + + +
+
+ ); + } +} + +export default autoHeight()(Bar); diff --git a/src/pages/analysis/components/Charts/ChartCard/index.less b/src/pages/analysis/components/Charts/ChartCard/index.less new file mode 100644 index 0000000000000000000000000000000000000000..282f17d9cf32af486e1c13d8d55bec1a9e5076f1 --- /dev/null +++ b/src/pages/analysis/components/Charts/ChartCard/index.less @@ -0,0 +1,75 @@ +@import '~antd/lib/style/themes/default.less'; + +.chartCard { + position: relative; + .chartTop { + position: relative; + width: 100%; + overflow: hidden; + } + .chartTopMargin { + margin-bottom: 12px; + } + .chartTopHasMargin { + margin-bottom: 20px; + } + .metaWrap { + float: left; + } + .avatar { + position: relative; + top: 4px; + float: left; + margin-right: 20px; + img { + border-radius: 100%; + } + } + .meta { + height: 22px; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: 22px; + } + .action { + position: absolute; + top: 4px; + right: 0; + line-height: 1; + cursor: pointer; + } + .total { + height: 38px; + margin-top: 4px; + margin-bottom: 0; + overflow: hidden; + color: @heading-color; + font-size: 30px; + line-height: 38px; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + } + .content { + position: relative; + width: 100%; + margin-bottom: 12px; + } + .contentFixed { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + } + .footer { + margin-top: 8px; + padding-top: 9px; + border-top: 1px solid @border-color-split; + & > * { + position: relative; + } + } + .footerMargin { + margin-top: 20px; + } +} diff --git a/src/pages/analysis/components/Charts/ChartCard/index.tsx b/src/pages/analysis/components/Charts/ChartCard/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..11f5f2052c176f10269fb29efd312714c5957c3a --- /dev/null +++ b/src/pages/analysis/components/Charts/ChartCard/index.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Card } from 'antd'; +import classNames from 'classnames'; +import { CardProps } from 'antd/lib/card'; + +import styles from './index.less'; + +type totalType = () => React.ReactNode; + +const renderTotal = (total?: number | totalType | React.ReactNode) => { + if (!total) { + return; + } + let totalDom; + switch (typeof total) { + case 'undefined': + totalDom = null; + break; + case 'function': + totalDom =
{total()}
; + break; + default: + totalDom =
{total}
; + } + return totalDom; +}; + +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; +} + +class ChartCard extends React.Component { + renderContent = () => { + const { contentHeight, title, avatar, action, total, footer, children, loading } = this.props; + if (loading) { + return false; + } + return ( +
+
+
{avatar}
+
+
+ {title} + {action} +
+ {renderTotal(total)} +
+
+ {children && ( +
+
{children}
+
+ )} + {footer && ( +
+ {footer} +
+ )} +
+ ); + }; + + render() { + const { + loading = false, + contentHeight, + title, + avatar, + action, + total, + footer, + children, + ...rest + } = this.props; + return ( + + {this.renderContent()} + + ); + } +} + +export default ChartCard; diff --git a/src/pages/analysis/components/Charts/Field/index.less b/src/pages/analysis/components/Charts/Field/index.less new file mode 100644 index 0000000000000000000000000000000000000000..4124471cb522bf18fb7963675ddeeb3dc217b9e7 --- /dev/null +++ b/src/pages/analysis/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/pages/analysis/components/Charts/Field/index.tsx b/src/pages/analysis/components/Charts/Field/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee3c12454ead9e275a7ed71dba1a20c79b083cf8 --- /dev/null +++ b/src/pages/analysis/components/Charts/Field/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import styles from './index.less'; + +export interface IFieldProps { + label: React.ReactNode; + value: React.ReactNode; + style?: React.CSSProperties; +} + +const Field: React.SFC = ({ label, value, ...rest }) => ( +
+ {label} + {value} +
+); + +export default Field; diff --git a/src/pages/analysis/components/Charts/Gauge/index.tsx b/src/pages/analysis/components/Charts/Gauge/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b5d33c60421e690358310fa418b909dbe2e30aca --- /dev/null +++ b/src/pages/analysis/components/Charts/Gauge/index.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { Chart, Geom, Axis, Coord, Guide, Shape } from 'bizcharts'; +import autoHeight from '../autoHeight'; + +const { Arc, Html, Line } = Guide; + +export interface IGaugeProps { + title: React.ReactNode; + color?: string; + height?: number; + bgColor?: number; + percent: number; + forceFit?: boolean; + style?: React.CSSProperties; + formatter: (value: string) => string; +} + +const defaultFormatter = (val: string): string => { + switch (val) { + case '2': + return '差'; + case '4': + return '中'; + case '6': + return '良'; + case '8': + return '优'; + default: + return ''; + } +}; + +Shape.registerShape!('point', 'pointer', { + drawShape(cfg: any, group: any) { + let point = cfg.points[0]; + point = (this as any).parsePoint(point); + const center = (this as any).parsePoint({ + x: 0, + y: 0, + }); + group.addShape('line', { + attrs: { + x1: center.x, + y1: center.y, + x2: point.x, + y2: point.y, + stroke: cfg.color, + lineWidth: 2, + lineCap: 'round', + }, + }); + return group.addShape('circle', { + attrs: { + x: center.x, + y: center.y, + r: 6, + stroke: cfg.color, + lineWidth: 3, + fill: '#fff', + }, + }); + }, +}); + +class Gauge extends React.Component { + render() { + const { + title, + height = 1, + 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 renderHtml = () => ` +
+

${title}

+

+ ${(data[0].value * 10).toFixed(2)}% +

+
`; + const data = [{ value: percent / 10 }]; + const textStyle: { + fontSize: number; + fill: string; + textAlign: 'center'; + } = { + fontSize: 12, + fill: 'rgba(0, 0, 0, 0.65)', + textAlign: 'center', + }; + return ( + + + + + + + + + + + + + + + ); + } +} + +export default autoHeight()(Gauge); diff --git a/src/pages/analysis/components/Charts/MiniArea/index.tsx b/src/pages/analysis/components/Charts/MiniArea/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5684aa51fb6c8cf3bbe5457c38a2dbe0168ef3c0 --- /dev/null +++ b/src/pages/analysis/components/Charts/MiniArea/index.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { Chart, Axis, Tooltip, Geom } from 'bizcharts'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +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; + forceFit?: boolean; + scale?: { x: any; y: any }; + yAxis?: IAxis; + borderWidth?: number; + data: Array<{ + x: number | string; + y: number; + }>; +} + +class MiniArea extends React.Component { + render() { + const { + height = 1, + data = [], + forceFit = true, + color = 'rgba(24, 144, 255, 0.2)', + borderColor = '#1089ff', + scale = { x: {}, y: {} }, + borderWidth = 2, + line, + xAxis, + yAxis, + animate = true, + } = this.props; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const scaleProps = { + x: { + type: 'cat', + range: [0, 1], + ...scale!.x, + }, + y: { + min: 0, + ...scale!.y, + }, + }; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + const chartHeight = height + 54; + + return ( +
+
+ {height > 0 && ( + + + + + + {line ? ( + + ) : ( + + )} + + )} +
+
+ ); + } +} + +export default autoHeight()(MiniArea); diff --git a/src/pages/analysis/components/Charts/MiniBar/index.tsx b/src/pages/analysis/components/Charts/MiniBar/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0fe9d4c160ddac885cf1246d455f675ba1822be --- /dev/null +++ b/src/pages/analysis/components/Charts/MiniBar/index.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Chart, Tooltip, Geom } from 'bizcharts'; +import autoHeight from '../autoHeight'; +import styles from '../index.less'; + +export interface IMiniBarProps { + color?: string; + height?: number; + data: Array<{ + x: number | string; + y: number; + }>; + forceFit?: boolean; + style?: React.CSSProperties; +} + +class MiniBar extends React.Component { + render() { + const { height = 0, forceFit = true, color = '#1890FF', data = [] } = this.props; + + const scale = { + x: { + type: 'cat', + }, + y: { + min: 0, + }, + }; + + const padding: [number, number, number, number] = [36, 5, 30, 5]; + + const tooltip: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*y', + (x: string, y: string) => ({ + name: x, + value: y, + }), + ]; + + // for tooltip not to be hide + const chartHeight = height + 54; + + return ( +
+
+ + + + +
+
+ ); + } +} +export default autoHeight()(MiniBar); diff --git a/src/pages/analysis/components/Charts/MiniProgress/index.less b/src/pages/analysis/components/Charts/MiniProgress/index.less new file mode 100644 index 0000000000000000000000000000000000000000..e1e0b4fc5169615814efe60821f39dc3e1bc58b9 --- /dev/null +++ b/src/pages/analysis/components/Charts/MiniProgress/index.less @@ -0,0 +1,37 @@ +@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; + z-index: 9; + width: 20px; + span { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 4px; + border-radius: 100px; + } + span:last-child { + top: auto; + bottom: 0; + } + } +} diff --git a/src/pages/analysis/components/Charts/MiniProgress/index.tsx b/src/pages/analysis/components/Charts/MiniProgress/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c0d1507dd4c5ff0d6d36f40897a910910df7a737 --- /dev/null +++ b/src/pages/analysis/components/Charts/MiniProgress/index.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import styles from './index.less'; + +export interface IMiniProgressProps { + target: number; + targetLabel?: string; + color?: string; + strokeWidth?: number; + percent?: number; + style?: React.CSSProperties; +} + +const MiniProgress: React.SFC = ({ + targetLabel, + target, + color = 'rgb(19, 194, 194)', + strokeWidth, + percent, +}) => { + return ( +
+ +
+ + +
+
+
+
+
+
+ ); +}; + +export default MiniProgress; diff --git a/src/pages/analysis/components/Charts/Pie/index.less b/src/pages/analysis/components/Charts/Pie/index.less new file mode 100644 index 0000000000000000000000000000000000000000..fc961b41df8831d2da6e7cf987b89e3624133fbc --- /dev/null +++ b/src/pages/analysis/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/pages/analysis/components/Charts/Pie/index.tsx b/src/pages/analysis/components/Charts/Pie/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9ed8a9a3956c6ea0b9f4590188c0370881f7c32f --- /dev/null +++ b/src/pages/analysis/components/Charts/Pie/index.tsx @@ -0,0 +1,308 @@ +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'; +export interface IPieProps { + animate?: boolean; + color?: string; + colors?: string[]; + selected?: boolean; + height?: number; + margin?: [number, number, number, number]; + hasLegend?: boolean; + padding?: [number, number, number, number]; + percent?: number; + data?: Array<{ + x: string | string; + y: number; + }>; + inner?: number; + lineWidth?: number; + forceFit?: boolean; + style?: React.CSSProperties; + className?: string; + total?: React.ReactNode | number | (() => React.ReactNode | number); + title?: React.ReactNode; + tooltip?: boolean; + valueFormat?: (value: string) => string | React.ReactNode; + subTitle?: React.ReactNode; +} +interface IPieState { + legendData: Array<{ checked: boolean; x: string; color: string; percent: number; y: string }>; + legendBlock: boolean; +} +class Pie extends Component { + state: IPieState = { + legendData: [], + legendBlock: false, + }; + + requestRef: number | undefined; + root!: HTMLDivElement; + chart: G2.Chart | undefined; + + componentDidMount() { + window.addEventListener( + 'resize', + () => { + this.requestRef = requestAnimationFrame(() => this.resize()); + }, + { passive: true }, + ); + } + + componentDidUpdate(preProps: IPieProps) { + const { data } = this.props; + if (data !== preProps.data) { + // because of charts data create when rendered + // so there is a trick for get rendered time + this.getLegendData(); + } + } + + componentWillUnmount() { + if (this.requestRef) { + window.cancelAnimationFrame(this.requestRef); + } + window.removeEventListener('resize', this.resize); + if (this.resize) { + (this.resize as any).cancel(); + } + } + + getG2Instance = (chart: G2.Chart) => { + this.chart = chart; + requestAnimationFrame(() => { + this.getLegendData(); + this.resize(); + }); + }; + + // for custom lengend view + getLegendData = () => { + if (!this.chart) return; + const geom = this.chart.getAllGeoms()[0]; // 获取所有的图形 + if (!geom) return; + const items = geom.get('dataArray') || []; // 获取图形对应的 + + const legendData = items.map((item: { color: any; _origin: any }[]) => { + /* eslint no-underscore-dangle:0 */ + const origin = item[0]._origin; + origin.color = item[0].color; + origin.checked = true; + return origin; + }); + + this.setState({ + legendData, + }); + }; + handleRoot = (n: HTMLDivElement) => { + this.root = n; + }; + + handleLegendClick = (item: any, i: string | number) => { + const newItem = item; + newItem.checked = !newItem.checked; + + const { legendData } = this.state; + legendData[i] = newItem; + + const filteredLegendData = legendData.filter(l => l.checked).map(l => l.x); + + if (this.chart) { + this.chart.filter('x', val => filteredLegendData.indexOf(val + '') > -1); + } + + this.setState({ + legendData, + }); + }; + + // 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 && + this.root.parentNode && + (this.root.parentNode as HTMLElement).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 = 0, + forceFit = true, + percent, + color, + inner = 0.75, + animate = true, + colors, + lineWidth = 1, + } = this.props; + + const { legendData, legendBlock } = this.state; + const pieClassName = classNames(styles.pie, className, { + [styles.hasLegend]: !!hasLegend, + [styles.legendBlock]: legendBlock, + }); + + const { + data: propsData, + selected: propsSelected = true, + tooltip: propsTooltip = true, + } = this.props; + + let data = propsData || []; + let selected = propsSelected; + let tooltip = propsTooltip; + + const defaultColors = colors; + data = data || []; + selected = selected || true; + tooltip = tooltip || true; + let formatColor; + + const scale = { + x: { + type: 'cat', + range: [0, 1], + }, + y: { + min: 0, + }, + }; + + if (percent || percent === 0) { + selected = false; + tooltip = false; + formatColor = (value: string) => { + if (value === '占比') { + return color || 'rgba(24, 144, 255, 0.85)'; + } + return '#F0F2F5'; + }; + + data = [ + { + x: '占比', + y: parseFloat(percent + ''), + }, + { + x: '反比', + y: 100 - parseFloat(percent + ''), + }, + ]; + } + + const tooltipFormat: [string, (...args: any[]) => { name?: string; value: string }] = [ + 'x*percent', + (x: string, p: number) => ({ + name: x, + value: `${(p * 100).toFixed(2)}%`, + }), + ]; + + const padding = [12, 0, 12, 0] as [number, number, number, number]; + + const dv = new DataView(); + dv.source(data).transform({ + type: 'percent', + field: 'y', + dimension: 'x', + as: 'percent', + }); + + return ( +
+ +
+ + {!!tooltip && } + + + + + {(subTitle || total) && ( +
+ {subTitle &&

{subTitle}

} + {/* eslint-disable-next-line */} + {total && ( +
{typeof total === 'function' ? total() : total}
+ )} +
+ )} +
+
+ + {hasLegend && ( +
    + {legendData.map((item, i) => ( +
  • this.handleLegendClick(item, i)}> + + {item.x} + + + {`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`} + + {valueFormat ? valueFormat(item.y) : item.y} +
  • + ))} +
+ )} +
+ ); + } +} + +export default autoHeight()(Pie); diff --git a/src/pages/analysis/components/Charts/TagCloud/index.less b/src/pages/analysis/components/Charts/TagCloud/index.less new file mode 100644 index 0000000000000000000000000000000000000000..db8e4dabfdd3f1fd4566ff22f55962648c369c49 --- /dev/null +++ b/src/pages/analysis/components/Charts/TagCloud/index.less @@ -0,0 +1,6 @@ +.tagCloud { + overflow: hidden; + canvas { + transform-origin: 0 0; + } +} diff --git a/src/pages/analysis/components/Charts/TagCloud/index.tsx b/src/pages/analysis/components/Charts/TagCloud/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fc809826df3e4d1f607ec83bb812d63f41fcbdd3 --- /dev/null +++ b/src/pages/analysis/components/Charts/TagCloud/index.tsx @@ -0,0 +1,206 @@ +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'; + +export interface ITagCloudProps { + data: Array<{ + name: string; + value: number; + }>; + height?: number; + className?: string; + style?: React.CSSProperties; +} + +interface ITagCloudState { + dv: any; + height?: number; + width: number; +} + +class TagCloud extends Component { + state = { + dv: null, + height: 0, + width: 0, + }; + isUnmount!: boolean; + requestRef!: number; + + root: HTMLDivElement | undefined; + imageMask: HTMLImageElement | undefined; + + componentDidMount() { + requestAnimationFrame(() => { + this.initTagCloud(); + this.renderChart(this.props); + }); + window.addEventListener('resize', this.resize, { passive: true }); + } + + componentDidUpdate(preProps?: ITagCloudProps) { + const { data } = this.props; + if (preProps && JSON.stringify(preProps.data) !== JSON.stringify(data)) { + this.renderChart(this.props); + } + } + componentWillUnmount() { + this.isUnmount = true; + window.cancelAnimationFrame(this.requestRef); + window.removeEventListener('resize', this.resize); + } + resize = () => { + this.requestRef = requestAnimationFrame(() => { + this.renderChart(this.props); + }); + }; + saveRootRef = (node: HTMLDivElement) => { + this.root = node; + }; + + initTagCloud = () => { + function getTextAttrs(cfg: { + x?: any; + y?: any; + style?: any; + opacity?: any; + origin?: any; + color?: any; + }) { + return Object.assign({}, cfg.style, { + fillOpacity: cfg.opacity, + fontSize: cfg.origin._origin.size, + rotate: cfg.origin._origin.rotate, + text: cfg.origin._origin.text, + textAlign: 'center', + fontFamily: cfg.origin._origin.font, + fill: cfg.color, + textBaseline: 'Alphabetic', + }); + } + + (Shape as any).registerShape('point', 'cloud', { + drawShape( + cfg: { x: any; y: any }, + container: { addShape: (arg0: string, arg1: { attrs: any }) => void }, + ) { + const attrs = getTextAttrs(cfg); + return container.addShape('text', { + attrs: Object.assign(attrs, { + x: cfg.x, + y: cfg.y, + }), + }); + }, + }); + }; + + @Bind() + @Debounce(500) + renderChart(nextProps: ITagCloudProps) { + // const colors = ['#1890FF', '#41D9C7', '#2FC25B', '#FACC14', '#9AE65C']; + const { data, height } = nextProps || this.props; + + if (data.length < 1 || !this.root) { + return; + } + + const h = height; + const w = this.root.offsetWidth; + + const onload = () => { + const dv = new DataSet.View().source(data); + const range = dv.range('value'); + const [min, max] = range; + dv.transform({ + type: 'tag-cloud', + fields: ['name', 'value'], + imageMask: this.imageMask, + font: 'Verdana', + size: [w, h], // 宽高设置最好根据 imageMask 做调整 + padding: 0, + timeInterval: 5000, // max execute time + rotate() { + return 0; + }, + fontSize(d: { value: number }) { + // eslint-disable-next-line + return Math.pow((d.value - min) / (max - min), 2) * (17.5 - 5) + 5; + }, + }); + + if (this.isUnmount) { + return; + } + + this.setState({ + dv, + width: w, + height: h, + }); + }; + + if (!this.imageMask) { + this.imageMask = new Image(); + this.imageMask.crossOrigin = ''; + this.imageMask.src = imgUrl; + + this.imageMask.onload = onload; + } else { + onload(); + } + } + + render() { + const { className, height } = this.props; + const { dv, width, height: stateHeight } = this.state; + + return ( +
+ {dv && ( + + + + + + )} +
+ ); + } +} + +export default autoHeight()(TagCloud); diff --git a/src/pages/analysis/components/Charts/TimelineChart/index.less b/src/pages/analysis/components/Charts/TimelineChart/index.less new file mode 100644 index 0000000000000000000000000000000000000000..1751975692135769eebdcaf89ffafcf6b3037cb8 --- /dev/null +++ b/src/pages/analysis/components/Charts/TimelineChart/index.less @@ -0,0 +1,3 @@ +.timelineChart { + background: #fff; +} diff --git a/src/pages/analysis/components/Charts/TimelineChart/index.tsx b/src/pages/analysis/components/Charts/TimelineChart/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ccc37283623e27ff1cd48e7c569a683b1199684 --- /dev/null +++ b/src/pages/analysis/components/Charts/TimelineChart/index.tsx @@ -0,0 +1,133 @@ +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'; + +export interface ITimelineChartProps { + data: Array<{ + x: number; + y1: number; + y2: number; + }>; + title?: string; + titleMap: { y1: string; y2: string }; + padding?: [number, number, number, number]; + height?: number; + style?: React.CSSProperties; + borderWidth?: number; +} + +class TimelineChart extends React.Component { + render() { + const { + title, + height = 400, + padding = [60, 20, 40, 40] as [number, number, number, number], + 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: { x: string }) => { + const date = obj.x; + return date <= ds.state.end && date >= ds.state.start; + }, + }) + .transform({ + type: 'map', + callback(row: { y1: string; y2: string }) { + const newRow = { ...row }; + newRow[titleMap.y1] = row.y1; + newRow[titleMap.y2] = row.y2; + return newRow; + }, + }) + .transform({ + type: 'fold', + fields: [titleMap.y1, titleMap.y2], // 展开字段集 + key: 'key', // key字段 + value: 'value', // value字段 + }); + + const timeScale = { + type: 'time', + tickInterval: 60 * 60 * 1000, + mask: 'HH:mm', + range: [0, 1], + }; + + const cols = { + x: timeScale, + value: { + max, + min: 0, + }, + }; + + const SliderGen = () => ( + { + ds.setState('start', startValue); + ds.setState('end', endValue); + }} + /> + ); + + return ( +
+
+ {title &&

{title}

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

{percent}%

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

= + | React.StatelessComponent

+ | React.ComponentClass

+ | React.ClassicComponentClass

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

( + WrappedComponent: React.ComponentClass

| React.SFC

, + ): React.ComponentClass

{ + class AutoHeightComponent extends React.Component

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

+ {h > 0 && } +
+ ); + } + } + return AutoHeightComponent; + }; +} +export default autoHeight; diff --git a/src/pages/analysis/components/Charts/bizcharts.d.ts b/src/pages/analysis/components/Charts/bizcharts.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..0815ffeeffcacd0ac9710977ab3d4419d078491c --- /dev/null +++ b/src/pages/analysis/components/Charts/bizcharts.d.ts @@ -0,0 +1,3 @@ +import * as BizChart from 'bizcharts'; + +export = BizChart; diff --git a/src/pages/analysis/components/Charts/bizcharts.tsx b/src/pages/analysis/components/Charts/bizcharts.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e08db8d6d2dca240451bdf6ab8a30be077a3fd9d --- /dev/null +++ b/src/pages/analysis/components/Charts/bizcharts.tsx @@ -0,0 +1,3 @@ +import * as BizChart from 'bizcharts'; + +export default BizChart; diff --git a/src/pages/analysis/components/Charts/index.less b/src/pages/analysis/components/Charts/index.less new file mode 100644 index 0000000000000000000000000000000000000000..190428bc80d7cd7f6f22d51fd48fa37b2d44eb10 --- /dev/null +++ b/src/pages/analysis/components/Charts/index.less @@ -0,0 +1,19 @@ +.miniChart { + position: relative; + width: 100%; + .chartContent { + position: absolute; + bottom: -28px; + width: 100%; + > div { + margin: 0 -5px; + overflow: hidden; + } + } + .chartLoading { + position: absolute; + top: 16px; + left: 50%; + margin-left: -7px; + } +} diff --git a/src/pages/analysis/components/Charts/index.tsx b/src/pages/analysis/components/Charts/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5e0815f18ef573549dfc4c13de317568d02db80c --- /dev/null +++ b/src/pages/analysis/components/Charts/index.tsx @@ -0,0 +1,45 @@ +import numeral from 'numeral'; +import ChartCard from './ChartCard'; +import Field from './Field'; +import Bar from './Bar'; +import Pie from './Pie'; +import Gauge from './Gauge'; +import MiniArea from './MiniArea'; +import MiniBar from './MiniBar'; +import MiniProgress from './MiniProgress'; +import WaterWave from './WaterWave'; +import TagCloud from './TagCloud'; +import TimelineChart from './TimelineChart'; + +const yuan = (val: number | string) => `¥ ${numeral(val).format('0,0')}`; + +const Charts = { + yuan, + Bar, + Pie, + Gauge, + MiniBar, + MiniArea, + MiniProgress, + ChartCard, + Field, + WaterWave, + TagCloud, + TimelineChart, +}; + +export { + Charts as default, + yuan, + Bar, + Pie, + Gauge, + MiniBar, + MiniArea, + MiniProgress, + ChartCard, + Field, + WaterWave, + TagCloud, + TimelineChart, +}; diff --git a/src/pages/analysis/components/IntroduceRow.tsx b/src/pages/analysis/components/IntroduceRow.tsx new file mode 100755 index 0000000000000000000000000000000000000000..ee2cf44dc016a8e3d89bdcee4b79296cdbd8ee4b --- /dev/null +++ b/src/pages/analysis/components/IntroduceRow.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { Row, Col, Icon, Tooltip } from 'antd'; +import { FormattedMessage } from 'umi-plugin-react/locale'; +import Charts from './Charts'; +import numeral from 'numeral'; +import styles from '../style.less'; +import Yuan from '../utils/Yuan'; +import Trend from './Trend'; +import { IVisitData } from '../data.d'; +const { ChartCard, MiniArea, MiniBar, MiniProgress, Field } = Charts; + +const topColResponsiveProps = { + xs: 24, + sm: 12, + md: 12, + lg: 12, + xl: 6, + style: { marginBottom: 24 }, +}; + +const IntroduceRow = ({ loading, visitData }: { loading: boolean; visitData: IVisitData[] }) => { + return ( + + + + } + 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/analysis/components/NumberInfo/index.less b/src/pages/analysis/components/NumberInfo/index.less new file mode 100644 index 0000000000000000000000000000000000000000..4a77288cc29d4bc24aa9ee660461bd44cb049897 --- /dev/null +++ b/src/pages/analysis/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/pages/analysis/components/NumberInfo/index.tsx b/src/pages/analysis/components/NumberInfo/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a8df6e6d94efbd2dc579d315b28ba64cb4d20859 --- /dev/null +++ b/src/pages/analysis/components/NumberInfo/index.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Icon } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; +export interface NumberInfoProps { + title?: React.ReactNode | string; + subTitle?: React.ReactNode | string; + total?: React.ReactNode | string; + status?: 'up' | 'down'; + theme?: string; + gap?: number; + subTotal?: number; + suffix?: string; + style?: React.CSSProperties; +} +const NumberInfo: React.SFC = ({ + 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/pages/analysis/components/OfflineData.tsx b/src/pages/analysis/components/OfflineData.tsx new file mode 100755 index 0000000000000000000000000000000000000000..64864bdc20955cfb39083cbdccb68c1b532ca613 --- /dev/null +++ b/src/pages/analysis/components/OfflineData.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Card, Tabs, Row, Col } from 'antd'; +import { formatMessage, FormattedMessage } from 'umi-plugin-react/locale'; +import Charts from './Charts'; +import styles from '../style.less'; +import NumberInfo from './NumberInfo'; +import { IOfflineData, IOfflineChartData } from '../data'; +const { TimelineChart, Pie } = Charts; + +const CustomTab = ({ + data, + currentTabKey: currentKey, +}: { + data: IOfflineData; + currentTabKey: string; +}) => { + return ( + + + + } + gap={2} + total={`${data.cvr * 100}%`} + theme={currentKey !== data.name ? 'light' : undefined} + /> + + + + + + ); +}; + +const { TabPane } = Tabs; + +const OfflineData = ({ + activeKey, + loading, + offlineData, + offlineChartData, + handleTabChange, +}: { + activeKey: string; + loading: boolean; + offlineData: IOfflineData[]; + offlineChartData: IOfflineChartData[]; + handleTabChange: (activeKey: string) => void; +}) => ( + + + {offlineData.map(shop => ( + } key={shop.name}> +
+ +
+
+ ))} +
+
+); + +export default OfflineData; diff --git a/src/pages/analysis/components/PageLoading/index.tsx b/src/pages/analysis/components/PageLoading/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77c0f165f9d20b4f974e754efb9cf08606c41a49 --- /dev/null +++ b/src/pages/analysis/components/PageLoading/index.tsx @@ -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/pages/analysis/components/ProportionSales.tsx b/src/pages/analysis/components/ProportionSales.tsx new file mode 100755 index 0000000000000000000000000000000000000000..b82888ef23fe2a1b29eafc6302bb25af9c436412 --- /dev/null +++ b/src/pages/analysis/components/ProportionSales.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Card, Radio } from 'antd'; +import Charts from './Charts'; +import { FormattedMessage } from 'umi-plugin-react/locale'; +import styles from '../style.less'; +import Yuan from '../utils/Yuan'; +import { RadioChangeEvent } from 'antd/lib/radio'; +import { ISalesData } from '../data'; + +const { Pie } = Charts; + +const ProportionSales = ({ + dropdownGroup, + salesType, + loading, + salesPieData, + handleChangeSalesType, +}: { + loading: boolean; + dropdownGroup: React.ReactNode; + salesType: 'all' | 'online' | 'stores'; + salesPieData: ISalesData[]; + handleChangeSalesType?: (e: RadioChangeEvent) => void; +}) => { + return ( + + } + bodyStyle={{ padding: 24 }} + extra={ +
+ {dropdownGroup} +
+ + + + + + + + + + + +
+
+ } + style={{ marginTop: 24 }} + > +
+

+ +

+ } + total={() => {salesPieData.reduce((pre, now) => now.y + pre, 0)}} + data={salesPieData} + valueFormat={value => {value}} + height={248} + lineWidth={4} + /> +
+
+ ); +}; + +export default ProportionSales; diff --git a/src/pages/analysis/components/SalesCard.tsx b/src/pages/analysis/components/SalesCard.tsx new file mode 100755 index 0000000000000000000000000000000000000000..05be982318ecf6df1b8483617ed881f7772712f8 --- /dev/null +++ b/src/pages/analysis/components/SalesCard.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { Row, Col, Card, Tabs, DatePicker } from 'antd'; +import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale'; +import numeral from 'numeral'; +import Charts from './Charts'; +import { RangePickerValue } from 'antd/lib/date-picker/interface'; +import { ISalesData } from '../data'; +import styles from '../style.less'; + +const { Bar } = Charts; + +const { RangePicker } = DatePicker; +const { TabPane } = Tabs; + +const rankingListData: { title: string; total: number }[] = []; +for (let i = 0; i < 7; i += 1) { + rankingListData.push({ + title: formatMessage({ id: 'analysis.analysis.test' }, { no: i }), + total: 323234, + }); +} + +const SalesCard = ({ + rangePickerValue, + salesData, + isActive, + handleRangePickerChange, + loading, + selectDate, +}: { + rangePickerValue: RangePickerValue; + isActive: (key: 'today' | 'week' | 'month' | 'year') => string; + salesData: ISalesData[]; + loading: boolean; + handleRangePickerChange: (dates: RangePickerValue, dateStrings: [string, string]) => void; + selectDate: (key: 'today' | 'week' | 'month' | 'year') => void; +}) => ( + + + } + 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/analysis/components/TopSearch.tsx b/src/pages/analysis/components/TopSearch.tsx new file mode 100755 index 0000000000000000000000000000000000000000..717b6e35345a63eb8eb757272480135c3091a00f --- /dev/null +++ b/src/pages/analysis/components/TopSearch.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { Row, Col, Table, Tooltip, Card, Icon } from 'antd'; +import { FormattedMessage } from 'umi-plugin-react/locale'; +import Charts from './Charts'; +import Trend from './Trend'; +import NumberInfo from './NumberInfo'; +import numeral from 'numeral'; +import styles from '../style.less'; +import { ISearchData, IVisitData2 } from '../data'; + +const { MiniArea } = Charts; + +const columns = [ + { + title: , + dataIndex: 'index', + key: 'index', + }, + { + title: , + dataIndex: 'keyword', + key: 'keyword', + render: (text: React.ReactNode) => {text}, + }, + { + title: , + dataIndex: 'count', + key: 'count', + sorter: (a: { count: number }, b: { count: number }) => a.count - b.count, + className: styles.alignRight, + }, + { + title: , + dataIndex: 'range', + key: 'range', + sorter: (a: { range: number }, b: { range: number }) => a.range - b.range, + render: (text: React.ReactNode, record: { status: number }) => ( + + {text}% + + ), + }, +]; + +const TopSearch = ({ + loading, + visitData2, + searchData, + dropdownGroup, +}: { + loading: boolean; + visitData2: IVisitData2[]; + dropdownGroup: React.ReactNode; + searchData: ISearchData[]; +}) => ( + + } + 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} + /> + + + + + rowKey={record => record.index} + size="small" + columns={columns} + dataSource={searchData} + pagination={{ + style: { marginBottom: 0 }, + pageSize: 5, + }} + /> + +); + +export default TopSearch; diff --git a/src/pages/analysis/components/Trend/index.less b/src/pages/analysis/components/Trend/index.less new file mode 100644 index 0000000000000000000000000000000000000000..13618838afcd46f1fc0e724097a0af938ca6f7b3 --- /dev/null +++ b/src/pages/analysis/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/pages/analysis/components/Trend/index.tsx b/src/pages/analysis/components/Trend/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee8df20ea51db0e14fe78c12ecf622a6f2772b96 --- /dev/null +++ b/src/pages/analysis/components/Trend/index.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Icon } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +export interface ITrendProps { + colorful?: boolean; + flag: 'up' | 'down'; + style?: React.CSSProperties; + reverseColor?: boolean; + className?: string; +} + +const Trend: React.SFC = ({ + 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/pages/analysis/data.d.ts b/src/pages/analysis/data.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..20df2e20dc6eeeff67414b3f6f50d8bfaeb7d62e --- /dev/null +++ b/src/pages/analysis/data.d.ts @@ -0,0 +1,67 @@ +export interface IVisitData { + x: string; + y: number; +} + +export interface IVisitData2 { + x: string; + y: number; +} + +export interface ISalesData { + x: string; + y: number; +} + +export interface ISearchData { + index: number; + keyword: string; + count: number; + range: number; + status: number; +} + +export interface IOfflineData { + name: string; + cvr: number; +} + +export interface IOfflineChartData { + x: any; + y1: number; + y2: number; +} + +export interface ISalesTypeData { + x: string; + y: number; +} + +export interface ISalesTypeDataOnline { + x: string; + y: number; +} + +export interface ISalesTypeDataOffline { + x: string; + y: number; +} + +export interface IRadarData { + name: string; + label: string; + value: number; +} + +export interface IAnalysisData { + visitData: IVisitData[]; + visitData2: IVisitData2[]; + salesData: ISalesData[]; + searchData: ISearchData[]; + offlineData: IOfflineData[]; + offlineChartData: IOfflineChartData[]; + salesTypeData: ISalesTypeData[]; + salesTypeDataOnline: ISalesTypeDataOnline[]; + salesTypeDataOffline: ISalesTypeDataOffline[]; + radarData: IRadarData[]; +} diff --git a/src/pages/analysis/index.tsx b/src/pages/analysis/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa6716d5af0ec752e0c54969ccf835312db72868 --- /dev/null +++ b/src/pages/analysis/index.tsx @@ -0,0 +1,210 @@ +import React, { Component, Suspense } from 'react'; +import { connect } from 'dva'; +import { Row, Col, Icon, Menu, Dropdown } from 'antd'; +import { RangePickerValue } from 'antd/lib/date-picker/interface'; +import { getTimeDistance } from './utils/utils'; +import styles from './style.less'; +import PageLoading from './components/PageLoading'; +import { Dispatch } from 'redux'; +import { IAnalysisData } from './data.d'; +import { RadioChangeEvent } from 'antd/lib/radio'; +import { GridContent } from '@ant-design/pro-layout'; + +const IntroduceRow = React.lazy(() => import('./components/IntroduceRow')); +const SalesCard = React.lazy(() => import('./components/SalesCard')); +const TopSearch = React.lazy(() => import('./components/TopSearch')); +const ProportionSales = React.lazy(() => import('./components/ProportionSales')); +const OfflineData = React.lazy(() => import('./components/OfflineData')); + +interface AnalysisProps { + analysis: IAnalysisData; + dispatch: Dispatch; + loading: boolean; +} + +interface AnalysisState { + salesType: 'all' | 'online' | 'stores'; + currentTabKey: string; + rangePickerValue: RangePickerValue; +} + +@connect( + ({ + analysis, + loading, + }: { + analysis: any; + loading: { + effects: { [key: string]: boolean }; + }; + }) => ({ + analysis, + loading: loading.effects['analysis/fetch'], + }), +) +class Analysis extends Component { + state: AnalysisState = { + salesType: 'all', + currentTabKey: '', + rangePickerValue: getTimeDistance('year'), + }; + reqRef!: number; + timeoutId!: number; + componentDidMount() { + const { dispatch } = this.props; + this.reqRef = requestAnimationFrame(() => { + dispatch({ + type: 'analysis/fetch', + }); + }); + } + + componentWillUnmount() { + const { dispatch } = this.props; + dispatch({ + type: 'analysis/clear', + }); + cancelAnimationFrame(this.reqRef); + clearTimeout(this.timeoutId); + } + + handleChangeSalesType = (e: RadioChangeEvent) => { + this.setState({ + salesType: e.target.value, + }); + }; + + handleTabChange = (key: string) => { + this.setState({ + currentTabKey: key, + }); + }; + + handleRangePickerChange = (rangePickerValue: RangePickerValue) => { + const { dispatch } = this.props; + this.setState({ + rangePickerValue, + }); + + dispatch({ + type: 'analysis/fetchSalesData', + }); + }; + + selectDate = (type: 'today' | 'week' | 'month' | 'year') => { + const { dispatch } = this.props; + this.setState({ + rangePickerValue: getTimeDistance(type), + }); + + dispatch({ + type: 'analysis/fetchSalesData', + }); + }; + + isActive = (type: 'today' | 'week' | 'month' | 'year') => { + 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 { analysis, loading } = this.props; + const { + visitData, + visitData2, + salesData, + searchData, + offlineData, + offlineChartData, + salesTypeData, + salesTypeDataOnline, + salesTypeDataOffline, + } = analysis; + 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/analysis/locales/en-US.ts b/src/pages/analysis/locales/en-US.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0a03f22105a642ceea239c470fc72bd7b634687 --- /dev/null +++ b/src/pages/analysis/locales/en-US.ts @@ -0,0 +1,34 @@ +export default { + 'analysis.analysis.test': 'Gongzhuan No.{no} shop', + 'analysis.analysis.introduce': 'Introduce', + 'analysis.analysis.total-sales': 'Total Sales', + 'analysis.analysis.day-sales': 'Daily Sales', + 'analysis.analysis.visits': 'Visits', + 'analysis.analysis.visits-trend': 'Visits Trend', + 'analysis.analysis.visits-ranking': 'Visits Ranking', + 'analysis.analysis.day-visits': 'Daily Visits', + 'analysis.analysis.week': 'WoW Change', + 'analysis.analysis.day': 'DoD Change', + 'analysis.analysis.payments': 'Payments', + 'analysis.analysis.conversion-rate': 'Conversion Rate', + 'analysis.analysis.operational-effect': 'Operational Effect', + 'analysis.analysis.sales-trend': 'Stores Sales Trend', + 'analysis.analysis.sales-ranking': 'Sales Ranking', + 'analysis.analysis.all-year': 'All Year', + 'analysis.analysis.all-month': 'All Month', + 'analysis.analysis.all-week': 'All Week', + 'analysis.analysis.all-day': 'All day', + 'analysis.analysis.search-users': 'Search Users', + 'analysis.analysis.per-capita-search': 'Per Capita Search', + 'analysis.analysis.online-top-search': 'Online Top Search', + 'analysis.analysis.the-proportion-of-sales': 'The Proportion Of Sales', + 'analysis.channel.all': 'ALL', + 'analysis.channel.online': 'Online', + 'analysis.channel.stores': 'Stores', + 'analysis.analysis.sales': 'Sales', + 'analysis.analysis.traffic': 'Traffic', + 'analysis.table.rank': 'Rank', + 'analysis.table.search-keyword': 'Keyword', + 'analysis.table.users': 'Users', + 'analysis.table.weekly-range': 'Weekly Range', +}; diff --git a/src/pages/analysis/locales/pt-BR.ts b/src/pages/analysis/locales/pt-BR.ts new file mode 100644 index 0000000000000000000000000000000000000000..7be015713ba9fc34fc103a641ca5d5d8c89785ea --- /dev/null +++ b/src/pages/analysis/locales/pt-BR.ts @@ -0,0 +1,34 @@ +export default { + 'analysis.analysis.test': 'Gongzhuan No.{no} shop', + 'analysis.analysis.introduce': 'Introduzir', + 'analysis.analysis.total-sales': 'Vendas Totais', + 'analysis.analysis.day-sales': 'Vendas do Dia', + 'analysis.analysis.visits': 'Visitas', + 'analysis.analysis.visits-trend': 'Tendência de Visitas', + 'analysis.analysis.visits-ranking': 'Ranking de Visitas', + 'analysis.analysis.day-visits': 'Visitas do Dia', + 'analysis.analysis.week': 'Taxa Semanal', + 'analysis.analysis.day': 'Taxa Diária', + 'analysis.analysis.payments': 'Pagamentos', + 'analysis.analysis.conversion-rate': 'Taxa de Conversão', + 'analysis.analysis.operational-effect': 'Efeito Operacional', + 'analysis.analysis.sales-trend': 'Tendência de Vendas das Lojas', + 'analysis.analysis.sales-ranking': 'Ranking de Vendas', + 'analysis.$2': 'Todo ano', + 'analysis.analysis.all-month': 'Todo mês', + 'analysis.analysis.all-week': 'Toda semana', + 'analysis.analysis.all-day': 'Todo dia', + 'analysis.analysis.search-users': 'Pesquisa de Usuários', + 'analysis.analysis.per-capita-search': 'Busca Per Capta', + 'analysis.analysis.online-top-search': 'Mais Buscadas Online', + 'analysis.analysis.the-proportion-of-sales': 'The Proportion Of Sales', + 'analysis.channel.all': 'Tudo', + 'analysis.channel.online': 'Online', + 'analysis.channel.stores': 'Lojas', + 'analysis.analysis.sales': 'Vendas', + 'analysis.analysis.traffic': 'Tráfego', + 'analysis.table.rank': 'Rank', + 'analysis.table.search-keyword': 'Palavra chave', + 'analysis.table.users': 'Usuários', + 'analysis.table.weekly-range': 'Faixa Semanal', +}; diff --git a/src/pages/analysis/locales/zh-CN.ts b/src/pages/analysis/locales/zh-CN.ts new file mode 100644 index 0000000000000000000000000000000000000000..9308f9d5564fb1e5e2a717e5d507e468a1ae62e1 --- /dev/null +++ b/src/pages/analysis/locales/zh-CN.ts @@ -0,0 +1,34 @@ +export default { + 'analysis.analysis.test': '工专路 {no} 号店', + 'analysis.analysis.introduce': '指标说明', + 'analysis.analysis.total-sales': '总销售额', + 'analysis.analysis.day-sales': '日销售额', + 'analysis.analysis.visits': '访问量', + 'analysis.analysis.visits-trend': '访问量趋势', + 'analysis.analysis.visits-ranking': '门店访问量排名', + 'analysis.analysis.day-visits': '日访问量', + 'analysis.analysis.week': '周同比', + 'analysis.analysis.day': '日同比', + 'analysis.analysis.payments': '支付笔数', + 'analysis.analysis.conversion-rate': '转化率', + 'analysis.analysis.operational-effect': '运营活动效果', + 'analysis.analysis.sales-trend': '销售趋势', + 'analysis.analysis.sales-ranking': '门店销售额排名', + 'analysis.analysis.all-year': '全年', + 'analysis.analysis.all-month': '本月', + 'analysis.analysis.all-week': '本周', + 'analysis.analysis.all-day': '今日', + 'analysis.analysis.search-users': '搜索用户数', + 'analysis.analysis.per-capita-search': '人均搜索次数', + 'analysis.analysis.online-top-search': '线上热门搜索', + 'analysis.analysis.the-proportion-of-sales': '销售额类别占比', + 'analysis.channel.all': '全部渠道', + 'analysis.channel.online': '线上', + 'analysis.channel.stores': '门店', + 'analysis.analysis.sales': '销售额', + 'analysis.analysis.traffic': '客流量', + 'analysis.table.rank': '排名', + 'analysis.table.search-keyword': '搜索关键词', + 'analysis.table.users': '用户数', + 'analysis.table.weekly-range': '周涨幅', +}; diff --git a/src/pages/analysis/locales/zh-TW.ts b/src/pages/analysis/locales/zh-TW.ts new file mode 100644 index 0000000000000000000000000000000000000000..82a4d428924199404fa5622aac2b674c89404707 --- /dev/null +++ b/src/pages/analysis/locales/zh-TW.ts @@ -0,0 +1,34 @@ +export default { + 'analysis.analysis.test': '工專路 {no} 號店', + 'analysis.analysis.introduce': '指標說明', + 'analysis.analysis.total-sales': '總銷售額', + 'analysis.analysis.day-sales': '日銷售額', + 'analysis.analysis.visits': '訪問量', + 'analysis.analysis.visits-trend': '訪問量趨勢', + 'analysis.analysis.visits-ranking': '門店訪問量排名', + 'analysis.analysis.day-visits': '日訪問量', + 'analysis.analysis.week': '周同比', + 'analysis.analysis.day': '日同比', + 'analysis.analysis.payments': '支付筆數', + 'analysis.analysis.conversion-rate': '轉化率', + 'analysis.analysis.operational-effect': '運營活動效果', + 'analysis.analysis.sales-trend': '銷售趨勢', + 'analysis.analysis.sales-ranking': '門店銷售額排名', + 'analysis.analysis.all-year': '全年', + 'analysis.analysis.all-month': '本月', + 'analysis.analysis.all-week': '本周', + 'analysis.analysis.all-day': '今日', + 'analysis.analysis.search-users': '搜索用戶數', + 'analysis.analysis.per-capita-search': '人均搜索次數', + 'analysis.analysis.online-top-search': '線上熱門搜索', + 'analysis.analysis.the-proportion-of-sales': '銷售額類別占比', + 'analysis.channel.all': '全部渠道', + 'analysis.channel.online': '線上', + 'analysis.channel.stores': '門店', + 'analysis.analysis.sales': '銷售額', + 'analysis.analysis.traffic': '客流量', + 'analysis.table.rank': '排名', + 'analysis.table.search-keyword': '搜索關鍵詞', + 'analysis.table.users': '用戶數', + 'analysis.table.weekly-range': '周漲幅', +}; diff --git a/src/pages/analysis/model.tsx b/src/pages/analysis/model.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5706013c7e35aa8f685befcbed806e83f97e7dcc --- /dev/null +++ b/src/pages/analysis/model.tsx @@ -0,0 +1,84 @@ +import { fakeChartData } from './service'; +import { IAnalysisData } from './data'; +import { Reducer } from 'redux'; +import { EffectsCommandMap } from 'dva'; +import { AnyAction } from 'redux'; + +export type Effect = ( + action: AnyAction, + effects: EffectsCommandMap & { select: (func: (state: IAnalysisData) => T) => T }, +) => void; + +export interface ModelType { + namespace: string; + state: IAnalysisData; + effects: { + fetch: Effect; + fetchSalesData: Effect; + }; + reducers: { + save: Reducer; + clear: Reducer; + }; +} + +const Model: ModelType = { + namespace: 'analysis', + + state: { + visitData: [], + visitData2: [], + salesData: [], + searchData: [], + offlineData: [], + offlineChartData: [], + salesTypeData: [], + salesTypeDataOnline: [], + salesTypeDataOffline: [], + radarData: [], + }, + + 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: [], + }; + }, + }, +}; + +export default Model; diff --git a/src/pages/analysis/service.tsx b/src/pages/analysis/service.tsx new file mode 100644 index 0000000000000000000000000000000000000000..64744b9d05f57b1a81a174157dc7cacfbc0e285f --- /dev/null +++ b/src/pages/analysis/service.tsx @@ -0,0 +1,5 @@ +import request from 'umi-request'; + +export async function fakeChartData() { + return request('/api/analysis/fake_chart_data'); +} diff --git a/src/pages/analysis/style.less b/src/pages/analysis/style.less new file mode 100644 index 0000000000000000000000000000000000000000..e1e898e594abec60264826e6fee472d71dfa68eb --- /dev/null +++ b/src/pages/analysis/style.less @@ -0,0 +1,179 @@ +@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; + } + } +} + +.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/analysis/utils/Yuan.tsx b/src/pages/analysis/utils/Yuan.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eafefd41526f7313d6baea44faebafe2cb27f16b --- /dev/null +++ b/src/pages/analysis/utils/Yuan.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { yuan } from '../components/Charts'; +/** + * 减少使用 dangerouslySetInnerHTML + */ +export default class Yuan extends React.Component<{ + children: React.ReactText; +}> { + main: HTMLSpanElement | undefined | null; + componentDidMount() { + this.renderToHtml(); + } + + componentDidUpdate() { + this.renderToHtml(); + } + renderToHtml = () => { + const { children } = this.props; + if (this.main) { + this.main.innerHTML = yuan(children); + } + }; + + render() { + return ( + { + this.main = ref; + }} + /> + ); + } +} diff --git a/src/pages/analysis/utils/utils.less b/src/pages/analysis/utils/utils.less new file mode 100644 index 0000000000000000000000000000000000000000..de1aa64222b6f14328d3a9e3c262ac5a31cce5af --- /dev/null +++ b/src/pages/analysis/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + margin-right: -1em; + padding-right: 1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: 0.2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &::before, + &::after { + display: table; + content: ' '; + } + &::after { + clear: both; + height: 0; + font-size: 0; + visibility: hidden; + } +} diff --git a/src/pages/analysis/utils/utils.ts b/src/pages/analysis/utils/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cdc266a523fe1bce8592fa49a719dda57ca54a5 --- /dev/null +++ b/src/pages/analysis/utils/utils.ts @@ -0,0 +1,53 @@ +import moment from 'moment'; +import { RangePickerValue } from 'antd/lib/date-picker/interface'; + +export function fixedZero(val: number) { + return val * 1 < 10 ? `0${val}` : val; +} + +export function getTimeDistance(type: 'today' | 'week' | 'month' | 'year'): RangePickerValue { + const now = new Date(); + const oneDay = 1000 * 60 * 60 * 24; + + if (type === 'today') { + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + return [moment(now), moment(now.getTime() + (oneDay - 1000))]; + } + + if (type === 'week') { + let day = now.getDay(); + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + + if (day === 0) { + day = 6; + } else { + day -= 1; + } + + const beginTime = now.getTime() - day * oneDay; + + return [moment(beginTime), moment(beginTime + (7 * oneDay - 1000))]; + } + + if (type === 'month') { + const year = now.getFullYear(); + const month = now.getMonth(); + const nextDate = moment(now).add(1, 'months'); + const nextYear = nextDate.year(); + const nextMonth = nextDate.month(); + + return [ + moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), + moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000), + ]; + } + + return [ + moment(`${now.getFullYear()}-01-01 00:00:00`), + moment(`${now.getFullYear()}-12-31 23:59:59`), + ]; +} diff --git a/src/utils/utils.less b/src/utils/utils.less index 7be54ba58bca8f59650472bf77d1ae599165dda1..de1aa64222b6f14328d3a9e3c262ac5a31cce5af 100644 --- a/src/utils/utils.less +++ b/src/utils/utils.less @@ -38,8 +38,8 @@ zoom: 1; &::before, &::after { - content: ' '; display: table; + content: ' '; } &::after { clear: both;