陈帅 6 лет назад
Родитель
Сommit
4ad0e5a1f1
49 измененных файлов с 3798 добавлено и 7 удалено
  1. 4 5
      config/config.ts
  2. 3 1
      package.json
  3. 197 0
      src/pages/analysis/_mock.ts
  4. 133 0
      src/pages/analysis/components/Charts/Bar/index.tsx
  5. 75 0
      src/pages/analysis/components/Charts/ChartCard/index.less
  6. 98 0
      src/pages/analysis/components/Charts/ChartCard/index.tsx
  7. 17 0
      src/pages/analysis/components/Charts/Field/index.less
  8. 18 0
      src/pages/analysis/components/Charts/Field/index.tsx
  9. 177 0
      src/pages/analysis/components/Charts/Gauge/index.tsx
  10. 133 0
      src/pages/analysis/components/Charts/MiniArea/index.tsx
  11. 61 0
      src/pages/analysis/components/Charts/MiniBar/index.tsx
  12. 37 0
      src/pages/analysis/components/Charts/MiniProgress/index.less
  13. 43 0
      src/pages/analysis/components/Charts/MiniProgress/index.tsx
  14. 94 0
      src/pages/analysis/components/Charts/Pie/index.less
  15. 308 0
      src/pages/analysis/components/Charts/Pie/index.tsx
  16. 6 0
      src/pages/analysis/components/Charts/TagCloud/index.less
  17. 206 0
      src/pages/analysis/components/Charts/TagCloud/index.tsx
  18. 3 0
      src/pages/analysis/components/Charts/TimelineChart/index.less
  19. 133 0
      src/pages/analysis/components/Charts/TimelineChart/index.tsx
  20. 28 0
      src/pages/analysis/components/Charts/WaterWave/index.less
  21. 230 0
      src/pages/analysis/components/Charts/WaterWave/index.tsx
  22. 75 0
      src/pages/analysis/components/Charts/autoHeight.tsx
  23. 3 0
      src/pages/analysis/components/Charts/bizcharts.d.ts
  24. 3 0
      src/pages/analysis/components/Charts/bizcharts.tsx
  25. 19 0
      src/pages/analysis/components/Charts/index.less
  26. 45 0
      src/pages/analysis/components/Charts/index.tsx
  27. 162 0
      src/pages/analysis/components/IntroduceRow.tsx
  28. 68 0
      src/pages/analysis/components/NumberInfo/index.less
  29. 61 0
      src/pages/analysis/components/NumberInfo/index.tsx
  30. 82 0
      src/pages/analysis/components/OfflineData.tsx
  31. 10 0
      src/pages/analysis/components/PageLoading/index.tsx
  32. 79 0
      src/pages/analysis/components/ProportionSales.tsx
  33. 162 0
      src/pages/analysis/components/SalesCard.tsx
  34. 128 0
      src/pages/analysis/components/TopSearch.tsx
  35. 37 0
      src/pages/analysis/components/Trend/index.less
  36. 42 0
      src/pages/analysis/components/Trend/index.tsx
  37. 67 0
      src/pages/analysis/data.d.ts
  38. 210 0
      src/pages/analysis/index.tsx
  39. 34 0
      src/pages/analysis/locales/en-US.ts
  40. 34 0
      src/pages/analysis/locales/pt-BR.ts
  41. 34 0
      src/pages/analysis/locales/zh-CN.ts
  42. 34 0
      src/pages/analysis/locales/zh-TW.ts
  43. 84 0
      src/pages/analysis/model.tsx
  44. 5 0
      src/pages/analysis/service.tsx
  45. 179 0
      src/pages/analysis/style.less
  46. 33 0
      src/pages/analysis/utils/Yuan.tsx
  47. 50 0
      src/pages/analysis/utils/utils.less
  48. 53 0
      src/pages/analysis/utils/utils.ts
  49. 1 1
      src/utils/utils.less

+ 4 - 5
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',
         },
       ],
     },

+ 3 - 1
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",

+ 197 - 0
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,
+};

+ 133 - 0
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 (
+      <div className={styles.chart} style={{ height }} ref={this.handleRoot}>
+        <div ref={this.handleRef}>
+          {title && <h4 style={{ marginBottom: 20 }}>{title}</h4>}
+          <Chart
+            scale={scale}
+            height={title ? height - 41 : height}
+            forceFit={forceFit}
+            data={data}
+            padding={padding || 'auto'}
+          >
+            <Axis
+              name="x"
+              title={false}
+              label={autoHideXLabels ? false : {}}
+              tickLine={autoHideXLabels ? false : {}}
+            />
+            <Axis name="y" min={0} />
+            <Tooltip showTitle={false} crosshairs={false} />
+            <Geom type="interval" position="x*y" color={color} tooltip={tooltip} />
+          </Chart>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default autoHeight()(Bar);

+ 75 - 0
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;
+  }
+}

+ 98 - 0
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 = <div className={styles.total}>{total()}</div>;
+      break;
+    default:
+      totalDom = <div className={styles.total}>{total}</div>;
+  }
+  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<IChartCardProps> {
+  renderContent = () => {
+    const { contentHeight, title, avatar, action, total, footer, children, loading } = this.props;
+    if (loading) {
+      return false;
+    }
+    return (
+      <div className={styles.chartCard}>
+        <div
+          className={classNames(styles.chartTop, {
+            [styles.chartTopMargin]: !children && !footer,
+          })}
+        >
+          <div className={styles.avatar}>{avatar}</div>
+          <div className={styles.metaWrap}>
+            <div className={styles.meta}>
+              <span className={styles.title}>{title}</span>
+              <span className={styles.action}>{action}</span>
+            </div>
+            {renderTotal(total)}
+          </div>
+        </div>
+        {children && (
+          <div className={styles.content} style={{ height: contentHeight || 'auto' }}>
+            <div className={contentHeight && styles.contentFixed}>{children}</div>
+          </div>
+        )}
+        {footer && (
+          <div
+            className={classNames(styles.footer, {
+              [styles.footerMargin]: !children,
+            })}
+          >
+            {footer}
+          </div>
+        )}
+      </div>
+    );
+  };
+
+  render() {
+    const {
+      loading = false,
+      contentHeight,
+      title,
+      avatar,
+      action,
+      total,
+      footer,
+      children,
+      ...rest
+    } = this.props;
+    return (
+      <Card loading={loading} bodyStyle={{ padding: '20px 24px 8px 24px' }} {...rest}>
+        {this.renderContent()}
+      </Card>
+    );
+  }
+}
+
+export default ChartCard;

+ 17 - 0
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;
+  }
+}

+ 18 - 0
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<IFieldProps> = ({ label, value, ...rest }) => (
+  <div className={styles.field} {...rest}>
+    <span className={styles.label}>{label}</span>
+    <span className={styles.number}>{value}</span>
+  </div>
+);
+
+export default Field;

+ 177 - 0
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<IGaugeProps> {
+  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 = () => `
+    <div style="width: 300px;text-align: center;font-size: 12px!important;">
+      <p style="font-size: 14px; color: rgba(0,0,0,0.43);margin: 0;">${title}</p>
+      <p style="font-size: 24px;color: rgba(0,0,0,0.85);margin: 0;">
+        ${(data[0].value * 10).toFixed(2)}%
+      </p>
+    </div>`;
+    const data = [{ value: percent / 10 }];
+    const textStyle: {
+      fontSize: number;
+      fill: string;
+      textAlign: 'center';
+    } = {
+      fontSize: 12,
+      fill: 'rgba(0, 0, 0, 0.65)',
+      textAlign: 'center',
+    };
+    return (
+      <Chart height={height} data={data} scale={cols} padding={[-16, 0, 16, 0]} forceFit={forceFit}>
+        <Coord type="polar" startAngle={-1.25 * Math.PI} endAngle={0.25 * Math.PI} radius={0.8} />
+        <Axis name="1" line={undefined} />
+        <Axis
+          line={undefined}
+          tickLine={undefined}
+          subTickLine={undefined}
+          name="value"
+          zIndex={2}
+          label={{
+            offset: -12,
+            formatter,
+            textStyle: textStyle,
+          }}
+        />
+        <Guide>
+          <Line
+            start={[3, 0.905]}
+            end={[3, 0.85]}
+            lineStyle={{
+              stroke: color,
+              lineDash: undefined,
+              lineWidth: 2,
+            }}
+          />
+          <Line
+            start={[5, 0.905]}
+            end={[5, 0.85]}
+            lineStyle={{
+              stroke: color,
+              lineDash: undefined,
+              lineWidth: 3,
+            }}
+          />
+          <Line
+            start={[7, 0.905]}
+            end={[7, 0.85]}
+            lineStyle={{
+              stroke: color,
+              lineDash: undefined,
+              lineWidth: 3,
+            }}
+          />
+          <Arc
+            start={[0, 0.965]}
+            end={[10, 0.965]}
+            style={{
+              stroke: bgColor,
+              lineWidth: 10,
+            }}
+          />
+          <Arc
+            start={[0, 0.965]}
+            end={[data[0].value, 0.965]}
+            style={{
+              stroke: color,
+              lineWidth: 10,
+            }}
+          />
+          <Html position={['50%', '95%']} html={renderHtml()} />
+        </Guide>
+        <Geom
+          line={false}
+          type="point"
+          position="value*1"
+          shape="pointer"
+          color={color}
+          active={false}
+        />
+      </Chart>
+    );
+  }
+}
+
+export default autoHeight()(Gauge);

+ 133 - 0
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<IMiniAreaProps> {
+  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 (
+      <div className={styles.miniChart} style={{ height }}>
+        <div className={styles.chartContent}>
+          {height > 0 && (
+            <Chart
+              animate={animate}
+              scale={scaleProps}
+              height={chartHeight}
+              forceFit={forceFit}
+              data={data}
+              padding={padding}
+            >
+              <Axis
+                key="axis-x"
+                name="x"
+                label={false}
+                line={false}
+                tickLine={false}
+                grid={false}
+                {...xAxis}
+              />
+              <Axis
+                key="axis-y"
+                name="y"
+                label={false}
+                line={false}
+                tickLine={false}
+                grid={false}
+                {...yAxis}
+              />
+              <Tooltip showTitle={false} crosshairs={false} />
+              <Geom
+                type="area"
+                position="x*y"
+                color={color}
+                tooltip={tooltip}
+                shape="smooth"
+                style={{
+                  fillOpacity: 1,
+                }}
+              />
+              {line ? (
+                <Geom
+                  type="line"
+                  position="x*y"
+                  shape="smooth"
+                  color={borderColor}
+                  size={borderWidth}
+                  tooltip={false}
+                />
+              ) : (
+                <span style={{ display: 'none' }} />
+              )}
+            </Chart>
+          )}
+        </div>
+      </div>
+    );
+  }
+}
+
+export default autoHeight()(MiniArea);

+ 61 - 0
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<IMiniBarProps> {
+  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 (
+      <div className={styles.miniChart} style={{ height }}>
+        <div className={styles.chartContent}>
+          <Chart
+            scale={scale}
+            height={chartHeight}
+            forceFit={forceFit}
+            data={data}
+            padding={padding}
+          >
+            <Tooltip showTitle={false} crosshairs={false} />
+            <Geom type="interval" position="x*y" color={color} tooltip={tooltip} />
+          </Chart>
+        </div>
+      </div>
+    );
+  }
+}
+export default autoHeight()(MiniBar);

+ 37 - 0
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;
+    }
+  }
+}

+ 43 - 0
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<IMiniProgressProps> = ({
+  targetLabel,
+  target,
+  color = 'rgb(19, 194, 194)',
+  strokeWidth,
+  percent,
+}) => {
+  return (
+    <div className={styles.miniProgress}>
+      <Tooltip title={targetLabel}>
+        <div className={styles.target} style={{ left: target ? `${target}%` : undefined }}>
+          <span style={{ backgroundColor: color || undefined }} />
+          <span style={{ backgroundColor: color || undefined }} />
+        </div>
+      </Tooltip>
+      <div className={styles.progressWrap}>
+        <div
+          className={styles.progress}
+          style={{
+            backgroundColor: color || undefined,
+            width: percent ? `${percent}%` : undefined,
+            height: strokeWidth || undefined,
+          }}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default MiniProgress;

+ 94 - 0
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;
+  }
+}

+ 308 - 0
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<IPieProps, IPieState> {
+  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 (
+      <div ref={this.handleRoot} className={pieClassName} style={style}>
+        <ReactFitText maxFontSize={25}>
+          <div className={styles.chart}>
+            <Chart
+              scale={scale}
+              height={height}
+              forceFit={forceFit}
+              data={dv}
+              padding={padding}
+              animate={animate}
+              onGetG2Instance={this.getG2Instance}
+            >
+              {!!tooltip && <Tooltip showTitle={false} />}
+              <Coord type="theta" innerRadius={inner} />
+              <Geom
+                style={{ lineWidth, stroke: '#fff' }}
+                tooltip={tooltip ? tooltipFormat : undefined}
+                type="intervalStack"
+                position="percent"
+                color={['x', percent || percent === 0 ? formatColor : defaultColors] as any}
+                selected={selected}
+              />
+            </Chart>
+
+            {(subTitle || total) && (
+              <div className={styles.total}>
+                {subTitle && <h4 className="pie-sub-title">{subTitle}</h4>}
+                {/* eslint-disable-next-line */}
+                {total && (
+                  <div className="pie-stat">{typeof total === 'function' ? total() : total}</div>
+                )}
+              </div>
+            )}
+          </div>
+        </ReactFitText>
+
+        {hasLegend && (
+          <ul className={styles.legend}>
+            {legendData.map((item, i) => (
+              <li key={item.x} onClick={() => this.handleLegendClick(item, i)}>
+                <span
+                  className={styles.dot}
+                  style={{
+                    backgroundColor: !item.checked ? '#aaa' : item.color,
+                  }}
+                />
+                <span className={styles.legendTitle}>{item.x}</span>
+                <Divider type="vertical" />
+                <span className={styles.percent}>
+                  {`${(Number.isNaN(item.percent) ? 0 : item.percent * 100).toFixed(2)}%`}
+                </span>
+                <span className={styles.value}>{valueFormat ? valueFormat(item.y) : item.y}</span>
+              </li>
+            ))}
+          </ul>
+        )}
+      </div>
+    );
+  }
+}
+
+export default autoHeight()(Pie);

+ 6 - 0
src/pages/analysis/components/Charts/TagCloud/index.less

@@ -0,0 +1,6 @@
+.tagCloud {
+  overflow: hidden;
+  canvas {
+    transform-origin: 0 0;
+  }
+}

+ 206 - 0
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<ITagCloudProps, ITagCloudState> {
+  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 (
+      <div
+        className={classNames(styles.tagCloud, className)}
+        style={{ width: '100%', height }}
+        ref={this.saveRootRef}
+      >
+        {dv && (
+          <Chart
+            width={width}
+            height={stateHeight}
+            data={dv}
+            padding={0}
+            scale={{
+              x: { nice: false },
+              y: { nice: false },
+            }}
+          >
+            <Tooltip showTitle={false} />
+            <Coord reflect="y" />
+            <Geom
+              type="point"
+              position="x*y"
+              color="text"
+              shape="cloud"
+              tooltip={[
+                'text*value',
+                function trans(text, value) {
+                  return { name: text, value };
+                },
+              ]}
+            />
+          </Chart>
+        )}
+      </div>
+    );
+  }
+}
+
+export default autoHeight()(TagCloud);

+ 3 - 0
src/pages/analysis/components/Charts/TimelineChart/index.less

@@ -0,0 +1,3 @@
+.timelineChart {
+  background: #fff;
+}

+ 133 - 0
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<ITimelineChartProps> {
+  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 = () => (
+      <Slider
+        padding={[0, padding[1] + 20, 0, padding[3]]}
+        width="auto"
+        height={26}
+        xAxis="x"
+        yAxis="y1"
+        scales={{ x: timeScale }}
+        data={data}
+        start={ds.state.start}
+        end={ds.state.end}
+        backgroundChart={{ type: 'line' }}
+        onChange={({ startValue, endValue }: { startValue: string; endValue: string }) => {
+          ds.setState('start', startValue);
+          ds.setState('end', endValue);
+        }}
+      />
+    );
+
+    return (
+      <div className={styles.timelineChart} style={{ height: height + 30 }}>
+        <div>
+          {title && <h4>{title}</h4>}
+          <Chart height={height} padding={padding} data={dv} scale={cols} forceFit>
+            <Axis name="x" />
+            <Tooltip />
+            <Legend name="key" position="top" />
+            <Geom type="line" position="x*value" size={borderWidth} color="key" />
+          </Chart>
+          <div style={{ marginRight: -20 }}>
+            <SliderGen />
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default autoHeight()(TimelineChart);

+ 28 - 0
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;
+  }
+}

+ 230 - 0
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<IWaterWaveProps> {
+  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 (
+      <div
+        className={styles.waterWave}
+        ref={n => (this.root = n)}
+        style={{ transform: `scale(${radio})` }}
+      >
+        <div style={{ width: height, height, overflow: 'hidden' }}>
+          <canvas
+            className={styles.waterWaveCanvasWrapper}
+            ref={n => (this.node = n)}
+            width={height * 2}
+            height={height * 2}
+          />
+        </div>
+        <div className={styles.text} style={{ width: height }}>
+          {title && <span>{title}</span>}
+          <h4>{percent}%</h4>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default autoHeight()(WaterWave);

+ 75 - 0
src/pages/analysis/components/Charts/autoHeight.tsx

@@ -0,0 +1,75 @@
+import React from 'react';
+
+export type IReactComponent<P = any> =
+  | React.StatelessComponent<P>
+  | React.ComponentClass<P>
+  | React.ClassicComponentClass<P>;
+
+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<P extends IAutoHeightProps>(
+    WrappedComponent: React.ComponentClass<P> | React.SFC<P>,
+  ): React.ComponentClass<P> {
+    class AutoHeightComponent extends React.Component<P & IAutoHeightProps> {
+      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 (
+          <div ref={this.handleRoot}>
+            {h > 0 && <WrappedComponent {...this.props} height={h} />}
+          </div>
+        );
+      }
+    }
+    return AutoHeightComponent;
+  };
+}
+export default autoHeight;

+ 3 - 0
src/pages/analysis/components/Charts/bizcharts.d.ts

@@ -0,0 +1,3 @@
+import * as BizChart from 'bizcharts';
+
+export = BizChart;

+ 3 - 0
src/pages/analysis/components/Charts/bizcharts.tsx

@@ -0,0 +1,3 @@
+import * as BizChart from 'bizcharts';
+
+export default BizChart;

+ 19 - 0
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;
+  }
+}

+ 45 - 0
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,
+};

+ 162 - 0
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 (
+    <Row gutter={24}>
+      <Col {...topColResponsiveProps}>
+        <ChartCard
+          bordered={false}
+          title={
+            <FormattedMessage id="analysis.analysis.total-sales" defaultMessage="Total Sales" />
+          }
+          action={
+            <Tooltip
+              title={
+                <FormattedMessage id="analysis.analysis.introduce" defaultMessage="Introduce" />
+              }
+            >
+              <Icon type="info-circle-o" />
+            </Tooltip>
+          }
+          loading={loading}
+          total={() => <Yuan>126560</Yuan>}
+          footer={
+            <Field
+              label={
+                <FormattedMessage id="analysis.analysis.day-sales" defaultMessage="Daily Sales" />
+              }
+              value={`¥${numeral(12423).format('0,0')}`}
+            />
+          }
+          contentHeight={46}
+        >
+          <Trend flag="up" style={{ marginRight: 16 }}>
+            <FormattedMessage id="analysis.analysis.week" defaultMessage="Weekly Changes" />
+            <span className={styles.trendText}>12%</span>
+          </Trend>
+          <Trend flag="down">
+            <FormattedMessage id="analysis.analysis.day" defaultMessage="Daily Changes" />
+            <span className={styles.trendText}>11%</span>
+          </Trend>
+        </ChartCard>
+      </Col>
+
+      <Col {...topColResponsiveProps}>
+        <ChartCard
+          bordered={false}
+          loading={loading}
+          title={<FormattedMessage id="analysis.analysis.visits" defaultMessage="Visits" />}
+          action={
+            <Tooltip
+              title={
+                <FormattedMessage id="analysis.analysis.introduce" defaultMessage="Introduce" />
+              }
+            >
+              <Icon type="info-circle-o" />
+            </Tooltip>
+          }
+          total={numeral(8846).format('0,0')}
+          footer={
+            <Field
+              label={
+                <FormattedMessage id="analysis.analysis.day-visits" defaultMessage="Daily Visits" />
+              }
+              value={numeral(1234).format('0,0')}
+            />
+          }
+          contentHeight={46}
+        >
+          <MiniArea color="#975FE4" data={visitData} />
+        </ChartCard>
+      </Col>
+      <Col {...topColResponsiveProps}>
+        <ChartCard
+          bordered={false}
+          loading={loading}
+          title={<FormattedMessage id="analysis.analysis.payments" defaultMessage="Payments" />}
+          action={
+            <Tooltip
+              title={
+                <FormattedMessage id="analysis.analysis.introduce" defaultMessage="Introduce" />
+              }
+            >
+              <Icon type="info-circle-o" />
+            </Tooltip>
+          }
+          total={numeral(6560).format('0,0')}
+          footer={
+            <Field
+              label={
+                <FormattedMessage
+                  id="analysis.analysis.conversion-rate"
+                  defaultMessage="Conversion Rate"
+                />
+              }
+              value="60%"
+            />
+          }
+          contentHeight={46}
+        >
+          <MiniBar data={visitData} />
+        </ChartCard>
+      </Col>
+      <Col {...topColResponsiveProps}>
+        <ChartCard
+          loading={loading}
+          bordered={false}
+          title={
+            <FormattedMessage
+              id="analysis.analysis.operational-effect"
+              defaultMessage="Operational Effect"
+            />
+          }
+          action={
+            <Tooltip
+              title={
+                <FormattedMessage id="analysis.analysis.introduce" defaultMessage="Introduce" />
+              }
+            >
+              <Icon type="info-circle-o" />
+            </Tooltip>
+          }
+          total="78%"
+          footer={
+            <div style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
+              <Trend flag="up" style={{ marginRight: 16 }}>
+                <FormattedMessage id="analysis.analysis.week" defaultMessage="Weekly Changes" />
+                <span className={styles.trendText}>12%</span>
+              </Trend>
+              <Trend flag="down">
+                <FormattedMessage id="analysis.analysis.day" defaultMessage="Weekly Changes" />
+                <span className={styles.trendText}>11%</span>
+              </Trend>
+            </div>
+          }
+          contentHeight={46}
+        >
+          <MiniProgress percent={78} strokeWidth={8} target={80} color="#13C2C2" />
+        </ChartCard>
+      </Col>
+    </Row>
+  );
+};
+
+export default IntroduceRow;

+ 68 - 0
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;
+    }
+  }
+}

+ 61 - 0
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<NumberInfoProps> = ({
+  theme,
+  title,
+  subTitle,
+  total,
+  subTotal,
+  status,
+  suffix,
+  gap,
+  ...rest
+}) => (
+  <div
+    className={classNames(styles.numberInfo, {
+      [styles[`numberInfo${theme}`]]: theme,
+    })}
+    {...rest}
+  >
+    {title && (
+      <div className={styles.numberInfoTitle} title={typeof title === 'string' ? title : ''}>
+        {title}
+      </div>
+    )}
+    {subTitle && (
+      <div
+        className={styles.numberInfoSubTitle}
+        title={typeof subTitle === 'string' ? subTitle : ''}
+      >
+        {subTitle}
+      </div>
+    )}
+    <div className={styles.numberInfoValue} style={gap ? { marginTop: gap } : {}}>
+      <span>
+        {total}
+        {suffix && <em className={styles.suffix}>{suffix}</em>}
+      </span>
+      {(status || subTotal) && (
+        <span className={styles.subTotal}>
+          {subTotal}
+          {status && <Icon type={`caret-${status}`} />}
+        </span>
+      )}
+    </div>
+  </div>
+);
+
+export default NumberInfo;

+ 82 - 0
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 (
+    <Row gutter={8} style={{ width: 138, margin: '8px 0' }}>
+      <Col span={12}>
+        <NumberInfo
+          title={data.name}
+          subTitle={
+            <FormattedMessage
+              id="analysis.analysis.conversion-rate"
+              defaultMessage="Conversion Rate"
+            />
+          }
+          gap={2}
+          total={`${data.cvr * 100}%`}
+          theme={currentKey !== data.name ? 'light' : undefined}
+        />
+      </Col>
+      <Col span={12} style={{ paddingTop: 36 }}>
+        <Pie
+          animate={false}
+          inner={0.55}
+          tooltip={false}
+          margin={[0, 0, 0, 0]}
+          percent={data.cvr * 100}
+          height={64}
+        />
+      </Col>
+    </Row>
+  );
+};
+
+const { TabPane } = Tabs;
+
+const OfflineData = ({
+  activeKey,
+  loading,
+  offlineData,
+  offlineChartData,
+  handleTabChange,
+}: {
+  activeKey: string;
+  loading: boolean;
+  offlineData: IOfflineData[];
+  offlineChartData: IOfflineChartData[];
+  handleTabChange: (activeKey: string) => void;
+}) => (
+  <Card loading={loading} className={styles.offlineCard} bordered={false} style={{ marginTop: 32 }}>
+    <Tabs activeKey={activeKey} onChange={handleTabChange}>
+      {offlineData.map(shop => (
+        <TabPane tab={<CustomTab data={shop} currentTabKey={activeKey} />} key={shop.name}>
+          <div style={{ padding: '0 24px' }}>
+            <TimelineChart
+              height={400}
+              data={offlineChartData}
+              titleMap={{
+                y1: formatMessage({ id: 'analysis.analysis.traffic' }),
+                y2: formatMessage({ id: 'analysis.analysis.payments' }),
+              }}
+            />
+          </div>
+        </TabPane>
+      ))}
+    </Tabs>
+  </Card>
+);
+
+export default OfflineData;

+ 10 - 0
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 () => (
+  <div style={{ paddingTop: 100, textAlign: 'center' }}>
+    <Spin size="large" />
+  </div>
+);

+ 79 - 0
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 (
+    <Card
+      loading={loading}
+      className={styles.salesCard}
+      bordered={false}
+      title={
+        <FormattedMessage
+          id="analysis.analysis.the-proportion-of-sales"
+          defaultMessage="The Proportion of Sales"
+        />
+      }
+      bodyStyle={{ padding: 24 }}
+      extra={
+        <div className={styles.salesCardExtra}>
+          {dropdownGroup}
+          <div className={styles.salesTypeRadio}>
+            <Radio.Group value={salesType} onChange={handleChangeSalesType}>
+              <Radio.Button value="all">
+                <FormattedMessage id="analysis.channel.all" defaultMessage="ALL" />
+              </Radio.Button>
+              <Radio.Button value="online">
+                <FormattedMessage id="analysis.channel.online" defaultMessage="Online" />
+              </Radio.Button>
+              <Radio.Button value="stores">
+                <FormattedMessage id="analysis.channel.stores" defaultMessage="Stores" />
+              </Radio.Button>
+            </Radio.Group>
+          </div>
+        </div>
+      }
+      style={{ marginTop: 24 }}
+    >
+      <div
+        style={{
+          minHeight: 380,
+        }}
+      >
+        <h4 style={{ marginTop: 8, marginBottom: 32 }}>
+          <FormattedMessage id="analysis.analysis.sales" defaultMessage="Sales" />
+        </h4>
+        <Pie
+          hasLegend
+          subTitle={<FormattedMessage id="analysis.analysis.sales" defaultMessage="Sales" />}
+          total={() => <Yuan>{salesPieData.reduce((pre, now) => now.y + pre, 0)}</Yuan>}
+          data={salesPieData}
+          valueFormat={value => <Yuan>{value}</Yuan>}
+          height={248}
+          lineWidth={4}
+        />
+      </div>
+    </Card>
+  );
+};
+
+export default ProportionSales;

+ 162 - 0
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;
+}) => (
+  <Card loading={loading} bordered={false} bodyStyle={{ padding: 0 }}>
+    <div className={styles.salesCard}>
+      <Tabs
+        tabBarExtraContent={
+          <div className={styles.salesExtraWrap}>
+            <div className={styles.salesExtra}>
+              <a className={isActive('today')} onClick={() => selectDate('today')}>
+                <FormattedMessage id="analysis.analysis.all-day" defaultMessage="All Day" />
+              </a>
+              <a className={isActive('week')} onClick={() => selectDate('week')}>
+                <FormattedMessage id="analysis.analysis.all-week" defaultMessage="All Week" />
+              </a>
+              <a className={isActive('month')} onClick={() => selectDate('month')}>
+                <FormattedMessage id="analysis.analysis.all-month" defaultMessage="All Month" />
+              </a>
+              <a className={isActive('year')} onClick={() => selectDate('year')}>
+                <FormattedMessage id="analysis.analysis.all-year" defaultMessage="All Year" />
+              </a>
+            </div>
+            <RangePicker
+              value={rangePickerValue}
+              onChange={handleRangePickerChange}
+              style={{ width: 256 }}
+            />
+          </div>
+        }
+        size="large"
+        tabBarStyle={{ marginBottom: 24 }}
+      >
+        <TabPane
+          tab={<FormattedMessage id="analysis.analysis.sales" defaultMessage="Sales" />}
+          key="sales"
+        >
+          <Row>
+            <Col xl={16} lg={12} md={12} sm={24} xs={24}>
+              <div className={styles.salesBar}>
+                <Bar
+                  height={295}
+                  title={
+                    <FormattedMessage
+                      id="analysis.analysis.sales-trend"
+                      defaultMessage="Sales Trend"
+                    />
+                  }
+                  data={salesData}
+                />
+              </div>
+            </Col>
+            <Col xl={8} lg={12} md={12} sm={24} xs={24}>
+              <div className={styles.salesRank}>
+                <h4 className={styles.rankingTitle}>
+                  <FormattedMessage
+                    id="analysis.analysis.sales-ranking"
+                    defaultMessage="Sales Ranking"
+                  />
+                </h4>
+                <ul className={styles.rankingList}>
+                  {rankingListData.map((item, i) => (
+                    <li key={item.title}>
+                      <span className={`${styles.rankingItemNumber} ${i < 3 ? styles.active : ''}`}>
+                        {i + 1}
+                      </span>
+                      <span className={styles.rankingItemTitle} title={item.title}>
+                        {item.title}
+                      </span>
+                      <span className={styles.rankingItemValue}>
+                        {numeral(item.total).format('0,0')}
+                      </span>
+                    </li>
+                  ))}
+                </ul>
+              </div>
+            </Col>
+          </Row>
+        </TabPane>
+        <TabPane
+          tab={<FormattedMessage id="analysis.analysis.visits" defaultMessage="Visits" />}
+          key="views"
+        >
+          <Row>
+            <Col xl={16} lg={12} md={12} sm={24} xs={24}>
+              <div className={styles.salesBar}>
+                <Bar
+                  height={292}
+                  title={
+                    <FormattedMessage
+                      id="analysis.analysis.visits-trend"
+                      defaultMessage="Visits Trend"
+                    />
+                  }
+                  data={salesData}
+                />
+              </div>
+            </Col>
+            <Col xl={8} lg={12} md={12} sm={24} xs={24}>
+              <div className={styles.salesRank}>
+                <h4 className={styles.rankingTitle}>
+                  <FormattedMessage
+                    id="analysis.analysis.visits-ranking"
+                    defaultMessage="Visits Ranking"
+                  />
+                </h4>
+                <ul className={styles.rankingList}>
+                  {rankingListData.map((item, i) => (
+                    <li key={item.title}>
+                      <span className={`${styles.rankingItemNumber} ${i < 3 ? styles.active : ''}`}>
+                        {i + 1}
+                      </span>
+                      <span className={styles.rankingItemTitle} title={item.title}>
+                        {item.title}
+                      </span>
+                      <span>{numeral(item.total).format('0,0')}</span>
+                    </li>
+                  ))}
+                </ul>
+              </div>
+            </Col>
+          </Row>
+        </TabPane>
+      </Tabs>
+    </div>
+  </Card>
+);
+
+export default SalesCard;

+ 128 - 0
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: <FormattedMessage id="analysis.table.rank" defaultMessage="Rank" />,
+    dataIndex: 'index',
+    key: 'index',
+  },
+  {
+    title: <FormattedMessage id="analysis.table.search-keyword" defaultMessage="Search keyword" />,
+    dataIndex: 'keyword',
+    key: 'keyword',
+    render: (text: React.ReactNode) => <a href="/">{text}</a>,
+  },
+  {
+    title: <FormattedMessage id="analysis.table.users" defaultMessage="Users" />,
+    dataIndex: 'count',
+    key: 'count',
+    sorter: (a: { count: number }, b: { count: number }) => a.count - b.count,
+    className: styles.alignRight,
+  },
+  {
+    title: <FormattedMessage id="analysis.table.weekly-range" defaultMessage="Weekly Range" />,
+    dataIndex: 'range',
+    key: 'range',
+    sorter: (a: { range: number }, b: { range: number }) => a.range - b.range,
+    render: (text: React.ReactNode, record: { status: number }) => (
+      <Trend flag={record.status === 1 ? 'down' : 'up'}>
+        <span style={{ marginRight: 4 }}>{text}%</span>
+      </Trend>
+    ),
+  },
+];
+
+const TopSearch = ({
+  loading,
+  visitData2,
+  searchData,
+  dropdownGroup,
+}: {
+  loading: boolean;
+  visitData2: IVisitData2[];
+  dropdownGroup: React.ReactNode;
+  searchData: ISearchData[];
+}) => (
+  <Card
+    loading={loading}
+    bordered={false}
+    title={
+      <FormattedMessage
+        id="analysis.analysis.online-top-search"
+        defaultMessage="Online Top Search"
+      />
+    }
+    extra={dropdownGroup}
+    style={{ marginTop: 24 }}
+  >
+    <Row gutter={68}>
+      <Col sm={12} xs={24} style={{ marginBottom: 24 }}>
+        <NumberInfo
+          subTitle={
+            <span>
+              <FormattedMessage id="analysis.analysis.search-users" defaultMessage="search users" />
+              <Tooltip
+                title={
+                  <FormattedMessage id="analysis.analysis.introduce" defaultMessage="introduce" />
+                }
+              >
+                <Icon style={{ marginLeft: 8 }} type="info-circle-o" />
+              </Tooltip>
+            </span>
+          }
+          gap={8}
+          total={numeral(12321).format('0,0')}
+          status="up"
+          subTotal={17.1}
+        />
+        <MiniArea line height={45} data={visitData2} />
+      </Col>
+      <Col sm={12} xs={24} style={{ marginBottom: 24 }}>
+        <NumberInfo
+          subTitle={
+            <span>
+              <FormattedMessage
+                id="analysis.analysis.per-capita-search"
+                defaultMessage="Per Capita Search"
+              />
+              <Tooltip
+                title={
+                  <FormattedMessage id="analysis.analysis.introduce" defaultMessage="introduce" />
+                }
+              >
+                <Icon style={{ marginLeft: 8 }} type="info-circle-o" />
+              </Tooltip>
+            </span>
+          }
+          total={2.7}
+          status="down"
+          subTotal={26.2}
+          gap={8}
+        />
+        <MiniArea line height={45} data={visitData2} />
+      </Col>
+    </Row>
+    <Table<any>
+      rowKey={record => record.index}
+      size="small"
+      columns={columns}
+      dataSource={searchData}
+      pagination={{
+        style: { marginBottom: 0 },
+        pageSize: 5,
+      }}
+    />
+  </Card>
+);
+
+export default TopSearch;

+ 37 - 0
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;
+  }
+}

+ 42 - 0
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<ITrendProps> = ({
+  colorful = true,
+  reverseColor = false,
+  flag,
+  children,
+  className,
+  ...rest
+}) => {
+  const classString = classNames(
+    styles.trendItem,
+    {
+      [styles.trendItemGrey]: !colorful,
+      [styles.reverseColor]: reverseColor && colorful,
+    },
+    className,
+  );
+  return (
+    <div {...rest} className={classString} title={typeof children === 'string' ? children : ''}>
+      <span>{children}</span>
+      {flag && (
+        <span className={styles[flag]}>
+          <Icon type={`caret-${flag}`} />
+        </span>
+      )}
+    </div>
+  );
+};
+
+export default Trend;

+ 67 - 0
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[];
+}

+ 210 - 0
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<any>;
+  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<AnalysisProps, AnalysisState> {
+  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 = (
+      <Menu>
+        <Menu.Item>操作一</Menu.Item>
+        <Menu.Item>操作二</Menu.Item>
+      </Menu>
+    );
+
+    const dropdownGroup = (
+      <span className={styles.iconGroup}>
+        <Dropdown overlay={menu} placement="bottomRight">
+          <Icon type="ellipsis" />
+        </Dropdown>
+      </span>
+    );
+
+    const activeKey = currentTabKey || (offlineData[0] && offlineData[0].name);
+    return (
+      <GridContent>
+        <React.Fragment>
+          <Suspense fallback={<PageLoading />}>
+            <IntroduceRow loading={loading} visitData={visitData} />
+          </Suspense>
+          <Suspense fallback={null}>
+            <SalesCard
+              rangePickerValue={rangePickerValue}
+              salesData={salesData}
+              isActive={this.isActive}
+              handleRangePickerChange={this.handleRangePickerChange}
+              loading={loading}
+              selectDate={this.selectDate}
+            />
+          </Suspense>
+          <Row gutter={24}>
+            <Col xl={12} lg={24} md={24} sm={24} xs={24}>
+              <Suspense fallback={null}>
+                <TopSearch
+                  loading={loading}
+                  visitData2={visitData2}
+                  searchData={searchData}
+                  dropdownGroup={dropdownGroup}
+                />
+              </Suspense>
+            </Col>
+            <Col xl={12} lg={24} md={24} sm={24} xs={24}>
+              <Suspense fallback={null}>
+                <ProportionSales
+                  dropdownGroup={dropdownGroup}
+                  salesType={salesType}
+                  loading={loading}
+                  salesPieData={salesPieData}
+                  handleChangeSalesType={this.handleChangeSalesType}
+                />
+              </Suspense>
+            </Col>
+          </Row>
+          <Suspense fallback={null}>
+            <OfflineData
+              activeKey={activeKey}
+              loading={loading}
+              offlineData={offlineData}
+              offlineChartData={offlineChartData}
+              handleTabChange={this.handleTabChange}
+            />
+          </Suspense>
+        </React.Fragment>
+      </GridContent>
+    );
+  }
+}
+
+export default Analysis;

+ 34 - 0
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',
+};

+ 34 - 0
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',
+};

+ 34 - 0
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': '周涨幅',
+};

+ 34 - 0
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': '周漲幅',
+};

+ 84 - 0
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: <T>(func: (state: IAnalysisData) => T) => T },
+) => void;
+
+export interface ModelType {
+  namespace: string;
+  state: IAnalysisData;
+  effects: {
+    fetch: Effect;
+    fetchSalesData: Effect;
+  };
+  reducers: {
+    save: Reducer<IAnalysisData>;
+    clear: Reducer<IAnalysisData>;
+  };
+}
+
+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;

+ 5 - 0
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');
+}

+ 179 - 0
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;
+      }
+    }
+  }
+}

+ 33 - 0
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 (
+      <span
+        ref={ref => {
+          this.main = ref;
+        }}
+      />
+    );
+  }
+}

+ 50 - 0
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;
+  }
+}

+ 53 - 0
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`),
+  ];
+}

+ 1 - 1
src/utils/utils.less

@@ -38,8 +38,8 @@
   zoom: 1;
   &::before,
   &::after {
-    content: ' ';
     display: table;
+    content: ' ';
   }
   &::after {
     clear: both;