茅日盛 3 лет назад
Родитель
Сommit
6624d5347f

+ 2 - 0
package.json

@@ -9,9 +9,11 @@
   "dependencies": {
     "antd": "^3.26.18",
     "axios": "^0.19.0",
+    "downloadjs": "^1.4.7",
     "dva": "^2.4.1",
     "echarts": "^5.1.2",
     "echarts-for-react": "^3.0.1",
+    "html2canvas": "^1.3.2",
     "lodash": "^4.17.15",
     "lodash.clonedeep": "^4.5.0",
     "lodash.throttle": "^4.1.1",

+ 148 - 1
src/pages/halberd/components/addTask/index.js

@@ -1,6 +1,8 @@
 import React, { Component } from 'react'
-import { Modal, Button, Input, message } from 'antd'
+import { Modal, Button, Input, message, Upload } from 'antd'
 import { FormItem } from 'wptpc-design'
+import { yc } from '@/conf/config'
+
 class Index extends Component {
   state = {
     data: null,
@@ -14,10 +16,12 @@ class Index extends Component {
     let params = null
     if (props.params) {
       params = props.params
+      params.header = props.params.header || []
     }
     this.state = {
       params: {
         detail: [],
+        header: [],
         needNew: 0,
         name: '',
         getTokenUrl: '',
@@ -31,6 +35,44 @@ class Index extends Component {
     }
   }
 
+  // 设置 header 属性字段
+  setHeader (index, value, key) {
+    const { params } = this.state
+    if (params.header[index]) {
+      params.header[index][key] = value
+    }
+    this.setState({
+      params: params
+    })
+  }
+
+  // 新增属性字段
+  addHeader = () => {
+    const { params } = this.state
+
+    this.setState({
+      params: {
+        ...params,
+        header: [
+          ...params.header,
+          {
+            key: '',
+            value: ''
+          }
+        ]
+      }
+    })
+  };
+
+  // 删除属性字段
+  delHeader= (index) =>
+    this.setState((prevState) => ({
+      params: {
+        ...prevState.params,
+        header: prevState.params.header.filter((_, i) => i !== index)
+      }
+    }));
+
   // 设置属性字段
   setDetail (i, v, k) {
     const { params } = this.state
@@ -110,7 +152,31 @@ class Index extends Component {
     const { onCancel, showModal, update } = this.props
 
     const detail = params.detail || []
+    const header = params.header || []
     const settingProps = []
+
+    const uploadProps = {
+      showUploadList: false,
+      name: 'file',
+      action: `${yc}/schedule/task/import`,
+      onChange: (info) => {
+        if (info.file.status === 'done') {
+          if (info.file.response.code !== 0) {
+            return message.error(info.file.response.msg)
+          }
+          if (info.file.response.data.length > 0) {
+            const { params } = this.state
+            params.detail = info.file.response.data.map((url) => ({ url }))
+            this.setState({
+              params: params
+            })
+            message.success(`${info.file.name} 文件上传成功`)
+          }
+        } else if (info.file.status === 'error') {
+          message.error(`${info.file.name} 文件上传失败`)
+        }
+      }
+    }
     if (update) {
       settingProps.push({
         label: '需要新压测数据',
@@ -138,6 +204,79 @@ class Index extends Component {
         isRequired: true,
         type: 'input'
       },
+      {
+        label: '自定义 header',
+        key: 'header',
+        isRequired: false,
+        render: () => {
+          return (
+            <div>
+              {header.map((d, i) => (
+                <div
+                  key={i}
+                  style={{
+                    display: 'flex',
+                    height: '40px',
+                    alignItems: 'center',
+                    flexWrap: 'wrap'
+                  }}
+                >
+                  <Input
+                    value={d.key}
+                    style={{ width: 220, marginRight: 10 }}
+                    placeholder="键"
+                    isRequired
+                    onChange={(e) =>
+                      this.setHeader(i, e.target.value, 'key')
+                    }
+                  />
+                  <Input
+                    value={d.value}
+                    style={{ width: 220, marginRight: 10 }}
+                    placeholder="值"
+                    isRequired
+                    onChange={(e) =>
+                      this.setHeader(i, e.target.value, 'value')
+                    }
+                  />
+                  <Button
+                    type="dashed"
+                    icon="minus"
+                    style={{ marginRight: '2px' }}
+                    onClick={() => this.delHeader(i)}
+                  />
+                  {i === header.length - 1 ? (
+                    <>
+                      <Button
+                        type="dashed"
+                        icon="plus"
+                        onClick={() => this.addHeader()}
+                      />
+                    </>
+                  ) : (
+                    <>
+                      <Button
+                        type="dashed"
+                        icon="plus"
+                        style={{
+                          opacity: '0'
+                        }}
+                      />
+                    </>
+                  )}
+                </div>
+              ))}
+              {!header.length && (
+                <Button
+                  type="dashed"
+                  icon="plus"
+                  onClick={() => this.addHeader()}
+                />
+              )}
+            </div>
+          )
+        }
+      },
       {
         label: '压测接口',
         key: 'detail',
@@ -145,6 +284,14 @@ class Index extends Component {
         render: () => {
           return (
             <div>
+              <div>
+                <Button key="back" style={{ marginRight: 10 }}>
+                  <a href={`${yc}/schedule/task/export`}>下载</a>
+                </Button>
+                <Upload {...uploadProps}>
+                  <Button>导入</Button>
+                </Upload>
+              </div>
               {detail.map((d, i) => (
                 <div
                   key={i}

+ 135 - 0
src/pages/halberd/components/changeSource/index.js

@@ -0,0 +1,135 @@
+import React, { Component, cloneElement } from 'react'
+import { Modal, message } from 'antd'
+import { FormItem } from 'wptpc-design'
+class Index extends Component {
+  state = {
+    data: null,
+    params: null
+  };
+
+  constructor (props) {
+    super(props)
+
+    let params = null
+    if (props.params) {
+      params = props.params
+    }
+    this.state = {
+      params: {
+        ...params
+      }
+    }
+  }
+
+  // 统一change
+  onParamsChange = (k, v) => {
+    const { params } = this.state
+    const newParams = { ...params }
+    this.setState(
+      {
+        params: { ...newParams, [k]: v }
+      }
+    )
+  };
+
+  onOk = async (cb) => {
+    if (!this.getCheck()) {
+      return
+    }
+    const { params = {} } = this.state
+    if (Object.values(params).some(item => item === '0')) {
+      message.warning('内容数字不可以为 0!')
+      return
+    }
+    if (typeof this.props.onOk === 'function') {
+      const success = await this.props.onOk({ ...params })
+      if (success) {
+        this.setState({ showModal: false })
+      }
+    }
+    if (typeof cb === 'function') {
+      // eslint-disable-next-line standard/no-callback-literal
+      cb({ ...params })
+    }
+  };
+
+  // 设置属性字段
+  setDetail (v, k) {
+    const { params } = this.state
+    if (params) {
+      params[k] = v
+    }
+    this.setState({
+      params: params
+    })
+  }
+
+  onChange = (e, key) => {
+    const { value } = e.target
+    const reg = /^-?[0-9]*(\.[0-9]*)?$/
+    if ((!isNaN(value) && reg.test(value)) || value === '' || value === '-') {
+      this.setDetail(value, key)
+    }
+  };
+
+  render () {
+    const {
+      params = {},
+      showModal = false
+    } = this.state
+
+    this.formSetting = [
+      {
+        label: '内网地址',
+        key: 'fileInUrl',
+        value: params.fileInUrl,
+        placeholder: '请输入内网地址',
+        isRequired: true,
+        type: 'input'
+      },
+      {
+        label: '外网地址',
+        key: 'fileOutUrl',
+        value: params.fileOutUrl,
+        placeholder: '请输入外网地址',
+        isRequired: true,
+        type: 'input'
+      }
+    ]
+    return (
+      <>
+        {cloneElement(this.props.trigger, {
+          onClick: () => {
+            this.setState({
+              showModal: true
+            })
+          }
+        })}
+        <Modal
+          title={'更换数据源'}
+          visible={showModal}
+          width={900}
+          onOk={this.onOk}
+          onCancel={() => {
+            this.setState({
+              showModal: false
+            })
+          }}
+          destroyOnClose
+        >
+          <FormItem
+            getCheck={(cb) => {
+              this.getCheck = cb
+            }}
+            onChange={this.onParamsChange}
+            formSetting={this.formSetting}
+            params={params}
+          />
+        </Modal>
+
+      </>
+
+    )
+  }
+}
+export default Index

+ 257 - 58
src/pages/halberd/components/psReport/index.js

@@ -1,73 +1,272 @@
 import React from 'react'
 import ReactECharts from 'echarts-for-react'
 import * as echarts from 'echarts'
-const Page = (props) => {
-  const [cross, setCross] = React.useState([])
-  const [vertical, setVertical] = React.useState([])
+import { get } from 'lodash'
+import s from './index.less'
+import { Descriptions, Divider } from 'antd'
+const Page = props => {
+  const [data, setData] = React.useState({})
 
   React.useEffect(() => {
-    const { pressureData = {} } = props
-    setCross(pressureData.cross)
-    setVertical(pressureData.vertical)
+    const { data = {} } = props
+    setData(data)
   }, [])
 
-  const options = {
-    tooltip: {
-      trigger: 'axis',
-      position: function (pt) {
-        return [pt[0], '10%']
-      }
-    },
-    title: {
-      left: 'center',
-      text: '压测报告界面折线图'
-    },
-    toolbox: {
-      feature: {
-        saveAsImage: {}
-      }
-    },
-    xAxis: {
-      type: 'category',
-      boundaryGap: false,
-      data: cross
-    },
-    yAxis: {
-      type: 'value',
-      boundaryGap: [0, '100%']
-    },
-    dataZoom: [{
-      type: 'inside',
-      start: 0,
-      end: 100
-    }, {
-      start: 0,
-      end: 100
-    }],
-    series: [
-      {
-        name: '数据',
-        type: 'line',
-        symbol: 'none',
-        sampling: 'lttb',
-        itemStyle: {
-          color: 'rgb(255, 70, 131)'
+  const {
+    // saveAsImage = {
+    //   icon:
+    //     'image://https://cdn.weipaitang.com/static/public/20210824acf87486-499f-7486499f-1d32-40d793b1fd05.svg'
+    // }
+    saveAsImage = false,
+    showTitle = true,
+    showLine = false
+  } = props
+
+  function getOptions (title, xData = [], yData = [], {
+    yFormat = '{value}',
+    tooltipFormat,
+    yMax,
+    showAreaStyle
+  } = {}) {
+    return {
+      tooltip: {
+        trigger: 'axis',
+        position: function (pt) {
+          return [pt[0], '10%']
         },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{
-            offset: 0,
-            color: 'rgb(255, 158, 68)'
-          }, {
-            offset: 1,
-            color: 'rgb(255, 70, 131)'
-          }])
+        formatter: function (params) {
+          let rez = `<div>${params[0].axisValue}</div>`
+          params.forEach(item => {
+            const strItem = `<div style="display: flex; justify-content: space-between;">
+            <div style="padding-right: 10px">
+            ${item.marker} ${item.seriesName}
+            </div>
+            <div style="color: #666;">
+              ${tooltipFormat ? tooltipFormat(item.value) : item.value}
+            </div>
+            </div>`
+            rez += strItem
+          })
+          return rez
+        }
+      },
+      grid: {
+        left: '60px',
+        right: '28px',
+        containLabel: true
+      },
+      title: {
+        left: 'center',
+        text: title,
+        textStyle: {
+          fontSize: 16,
+          fontWeight: 'normal',
+          color: '#666'
+        }
+      },
+      toolbox: {
+        feature: {
+          saveAsImage: saveAsImage
+        }
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: xData
+      },
+      yAxis: {
+        type: 'value',
+        boundaryGap: [0, '100%'],
+        axisLabel: {
+          show: true,
+          interval: 'auto',
+          formatter: yFormat
         },
-        data: vertical
+        max: yMax
+      },
+      dataZoom: [
+        {
+          type: 'inside',
+          start: 0,
+          end: 100
+        },
+        {
+          start: 0,
+          end: 100
+        }
+      ],
+      series: yData.map((yItem, index) => {
+        const h = ((yData.length - index) * (255 / yData.length)) >> 0
+        return {
+          name: yItem.name,
+          type: 'line',
+          symbol: 'none',
+          sampling: 'lttb',
+          itemStyle: {
+            color: yData.length === 1 ? 'rgb(255, 70, 131)' : `hsl(${h}, 100%, 64%)`
+          },
+          ...(
+            showAreaStyle ? {
+              areaStyle: {
+                color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                  {
+                    offset: 0,
+                    color: 'rgb(255, 158, 68)'
+                  },
+                  {
+                    offset: 1,
+                    color: 'rgb(255, 70, 131)'
+                  }
+                ])
+              }
+            } : {}
+          ),
+          data: yItem.data
+        }
+      })
+    }
+  }
+
+  function getSeriesYData (data, format) {
+    const _data = data
+    return Object.keys(_data).map(key => {
+      return {
+        name: key,
+        data: _data[key].vertical.map(item => {
+          if (format) {
+            return format(item)
+          } else {
+            return item
+          }
+        })
       }
-    ]
+    })
   }
 
-  return <ReactECharts option={options} />
+  function getSeriesXData (data) {
+    return Object.keys(data).reduce((acc, key) => {
+      return data[key].cross
+    }, {})
+  }
+
+  return (
+    <div>
+      {
+        showLine && (
+          <Divider />
+        )
+      }
+      {
+
+        showTitle && (
+          <div className={s.title}>
+            {data.taskName}-压测报告
+          </div>
+        )
+      }
+      <div className={s.descriptionWrap}>
+        <Descriptions bordered>
+          <Descriptions.Item label="压测参数" span={24}>
+            -t {data.threads} -c {data.connects} -d {data.duration} -timeout {data.timeout}</Descriptions.Item>
+          <Descriptions.Item label="服务器配置" span={8}>{get(data, 'monitor.infoResult')}</Descriptions.Item>
+          <Descriptions.Item label="部署类型" span={8}>{get(data, 'monitor.deployType')}</Descriptions.Item>
+          <Descriptions.Item label="QPS">{get(data, 'report.summary_req_sec')}</Descriptions.Item>
+          <Descriptions.Item label="平均延时">{get(data, 'report.latency_mean')}</Descriptions.Item>
+          <Descriptions.Item label="99% 延时">{get(data, 'report.latency_p99')}</Descriptions.Item>
+        </Descriptions>
+      </div>
+      <ReactECharts
+        option={getOptions('压力持续图', get(data, 'pressure_data.cross'), [
+          { name: '数据', data: get(data, 'pressure_data.vertical') }
+        ], { showAreaStyle: true })}
+      />
+      <div className={s.chartWrap}>
+        <div className={s.chartItem}>
+          <ReactECharts
+            option={getOptions(
+              'CPU 使用率',
+              getSeriesXData(get(data, 'monitor.metrics.node_cpu_seconds_total') || {}),
+              getSeriesYData(get(data, 'monitor.metrics.node_cpu_seconds_total') || {}),
+              {
+                yFormat: function (value) {
+                  return value + '%'
+                },
+                tooltipFormat: function (value) {
+                  return value + '%'
+                },
+                yMax: 100
+              }
+            )}
+          />
+        </div>
+        <div className={s.chartItem}>
+          <ReactECharts
+            option={getOptions(
+              '内存使用率',
+              getSeriesXData(get(data, 'monitor.metrics.memUsage') || {}),
+              getSeriesYData(get(data, 'monitor.metrics.memUsage') || {}),
+              {
+                yFormat: function (value) {
+                  return value + '%'
+                },
+                tooltipFormat: function (value) {
+                  return value + '%'
+                },
+                yMax: 100
+              }
+            )}
+          />
+        </div>
+      </div>
+      <div className={s.chartWrap}>
+        <div className={s.chartItem}>
+          <ReactECharts
+            option={getOptions(
+              '磁盘读取',
+              getSeriesXData(get(data, 'monitor.metrics.node_disk_read_bytes_total') || {}),
+              getSeriesYData(get(data, 'monitor.metrics.node_disk_read_bytes_total') || {}, value => (value / 1024).toFixed(2)),
+              {
+                yFormat: function (value) {
+                  return value + 'kb/s'
+                },
+                tooltipFormat: function (value) {
+                  return value + 'kb/s'
+                }
+              }
+            )}
+          />
+        </div>
+        <div className={s.chartItem}>
+          <ReactECharts
+            option={getOptions(
+              '磁盘写入',
+              getSeriesXData(get(data, 'monitor.metrics.node_disk_written_bytes_total') || {}),
+              getSeriesYData(get(data, 'monitor.metrics.node_disk_written_bytes_total') || {}, value => (value / 1024).toFixed(2)),
+              {
+                yFormat: function (value) {
+                  return value + 'kb/s'
+                },
+                tooltipFormat: function (value) {
+                  return value + 'kb/s'
+                }
+              }
+            )}
+          />
+        </div>
+      </div>
+      <div className={s.chartWrap}>
+        <div className={s.chartItem}>
+          <ReactECharts
+            option={getOptions(
+              'FPM',
+              getSeriesXData(get(data, 'monitor.metrics.phpfpm_processes_total') || {}),
+              getSeriesYData(get(data, 'monitor.metrics.phpfpm_processes_total') || {})
+            )}
+          />
+        </div>
+      </div>
+    </div>
+  )
 }
 
 export default Page

+ 22 - 0
src/pages/halberd/components/psReport/index.less

@@ -0,0 +1,22 @@
+.title {
+    font-size: 24px;
+    text-align: center;
+    padding: 20px 0;
+    font-weight: bold;
+}
+.chartWrap {
+    display: flex;
+    margin-top: 20px;
+}
+.chartItem {
+    width: 50%;
+}
+
+.description {
+    color: #999;
+    text-align: center;
+}
+
+.descriptionWrap {
+    padding: 20px 40px;
+}

+ 40 - 9
src/pages/halberd/index.js

@@ -1,12 +1,13 @@
 import React from 'react'
-import { Button, Table, Badge, Popconfirm, Checkbox, Switch } from 'antd'
+import { Button, Table, Badge, Popconfirm, Checkbox, Switch, message } from 'antd'
 import AddTaskModal from './components/addTask'
 import StartTaskModal from './components/startTask'
+import ChangeSource from './components/changeSource'
 import router from 'umi/router'
 import { FilterTable } from 'wptpc-design'
 import { yc } from '@/conf/config'
 import Style from './index.less'
-import { taskBegin, taskStop, taskUpdate, taskAdd, taskCopy, caseAdd, caseDel } from './service'
+import { taskBegin, taskStop, taskUpdate, taskAdd, taskCopy, caseAdd, caseDel, updateSource } from './service'
 const apiUrl = `${yc}/schedule/task/list`
 
 const scheduleState = {
@@ -99,6 +100,19 @@ class Halberd extends React.PureComponent {
         )
         return (
           <div>
+            <ChangeSource
+              onOk={this.handleChangeSource}
+              params={value}
+              trigger={
+                <Button
+                  type="primary"
+                  size={'small'}
+                  style={{ margin: '0 5px' }}
+                >
+                  更换数据源
+                </Button>
+              }
+            />
             <Popconfirm
               title={titleNode}
               onConfirm={() => {
@@ -160,6 +174,20 @@ class Halberd extends React.PureComponent {
     }
   ];
 
+  async handleChangeSource (data) {
+    const { _id: id, fileInUrl, fileOutUrl } = data
+    const res = await updateSource({
+      id,
+      fileInUrl,
+      fileOutUrl
+    })
+    const { code } = res
+    if (code === 0) {
+      message.success('修改成功')
+      return true
+    }
+  }
+
   /**
    * 复制任务
    * @param {*} id
@@ -206,7 +234,8 @@ class Halberd extends React.PureComponent {
         id,
         name: name,
         getTokenUrl: get_token_url,
-        detail: JSON.parse(value.detail)
+        detail: JSON.parse(value.detail),
+        header: JSON.parse(value.header)
       }
     })
   };
@@ -257,7 +286,7 @@ class Halberd extends React.PureComponent {
    * @param {*} data
    */
   addTask = data => {
-    const { name = '', getTokenUrl, detail = [] } = data
+    const { name = '', getTokenUrl, detail = [], header = [] } = data
     taskAdd({
       name,
       getTokenUrl: getTokenUrl,
@@ -268,7 +297,8 @@ class Halberd extends React.PureComponent {
             rate: Number(item.rate)
           }
         })
-      )
+      ),
+      header: JSON.stringify(header)
     }).then(res => {
       const { code } = res
       if (code === 0) {
@@ -283,13 +313,14 @@ class Halberd extends React.PureComponent {
    * @param {*} data
    */
   updateTask = data => {
-    const { name = '', getTokenUrl, detail = [] } = data
+    const { name = '', getTokenUrl, detail = [], header = []  } = data
     taskUpdate({
       id: data.id,
       name,
       getTokenUrl: getTokenUrl,
       needNew: data.needNew,
-      detail: JSON.stringify(detail)
+      detail: JSON.stringify(detail),
+      header: JSON.stringify(header)
     }).then(res => {
       const { code } = res
       if (code === 0) {
@@ -461,7 +492,7 @@ class Halberd extends React.PureComponent {
     return <div>当前任务没有 case</div>
   };
 
-  render () {
+  render() {
     const { addFormVisible, updateFormVisible, startTaskVisible, params, refreshChecked } = this.state
     // filtertable的搜索项配置
     const filterSetting = {
@@ -509,7 +540,7 @@ class Halberd extends React.PureComponent {
             this.setState({
               refreshChecked: checked
             })
-          }}/></span>
+          }} /></span>
         </div>
         <FilterTable filterSetting={filterSetting} tableSetting={tableSetting} apiUrl={apiUrl} />
 

+ 5 - 0
src/pages/halberd/service.js

@@ -62,3 +62,8 @@ export async function taskList (params) {
   const url = `${yc}/schedule/task/list`
   return fetchApi(url, params)
 }
+
+export async function updateSource (params) {
+  const url = `${yc}/schedule/task/update-source`
+  return fetchApi(url, params)
+}

+ 92 - 4
src/pages/halberd/viewReport/index.js

@@ -1,11 +1,14 @@
 import React, { Component } from 'react'
-import { Table, PageHeader, Modal, Pagination, Icon } from 'antd'
+import { Table, PageHeader, Modal, Pagination, Icon, Button } from 'antd'
 import get from 'lodash/get'
 import router from 'umi/router'
 import moment from 'moment'
 import { viewReport, eptResponse } from '../service'
 import Styles from './index.less'
 import PsReport from '../components/psReport'
+import html2canvas from 'html2canvas'
+import downloadJS from 'downloadjs'
+
 class Index extends Component {
   state = {
     data: null,
@@ -25,6 +28,8 @@ class Index extends Component {
       params = props.params
     }
     this.state = {
+      selectedRowKeys: [],
+      showMultiPsReportModal: false,
       params: {
         ...params
       }
@@ -44,7 +49,7 @@ class Index extends Component {
         return (<div>
           {!!(pressure_data.cross && pressure_data.cross.length) && <a onClick={() => {
             this.setState({
-              pressureData: pressure_data,
+              viewData: data,
               showPsReportModal: true
             })
           }}><Icon type="line-chart" />&nbsp;</a>}
@@ -254,6 +259,7 @@ class Index extends Component {
         this.setState({
           dataSource: data.list.map(item => {
             return {
+              taskName: data.task.name,
               ...item.report,
               ...item,
               finish_time:
@@ -300,7 +306,7 @@ class Index extends Component {
   }
 
   render () {
-    const { dataSource, modalDataSource = {}, task = {}, showModal, showPsReportModal, pressureData } = this.state
+    const { dataSource, modalDataSource = {}, task = {}, showModal, showPsReportModal, viewData, selectedRowKeys, showMultiPsReportModal } = this.state
     const modalColumns = [
       {
         title: '时间',
@@ -341,8 +347,36 @@ class Index extends Component {
         key: 'response'
       }
     ]
+
+    const rowSelection = {
+      selectedRowKeys,
+      onChange: (selectedRowKeys, selectedRows) => {
+        this.setState({ selectedRowKeys })
+      },
+      getCheckboxProps: record => ({
+        disabled: !record.monitor && !(Array.isArray(record.pressure_data.cross) && record.pressure_data.cross.length > 0)
+      })
+    }
+    const hasSelected = selectedRowKeys.length > 0
+    const dataSourceMap = (dataSource || []).reduce((acc, dataItem) => {
+      return {
+        ...acc,
+        [dataItem._id]: dataItem
+      }
+    }, {})
+
     return (
       <div>
+        <div style={{ marginBottom: 16 }}>
+          <Button type="primary" onClick={() => {
+            this.setState({ showMultiPsReportModal: true })
+          }} disabled={!hasSelected}>
+            批量查看报告
+          </Button>
+          <span style={{ marginLeft: 8 }}>
+            {hasSelected ? `选中了 ${selectedRowKeys.length} 项` : ''}
+          </span>
+        </div>
         {dataSource && <React.Fragment>
           <PageHeader
             style={{
@@ -355,6 +389,7 @@ class Index extends Component {
           />
           <Table
             columns={this.columns}
+            rowSelection={rowSelection}
             className={Styles.head}
             bordered
             rowKey="_id"
@@ -376,7 +411,60 @@ class Index extends Component {
             this.hiddenPsReport()
           }}
         >
-          <PsReport pressureData={pressureData}/>
+          <>
+            <div style={{ textAlign: 'right', padding: '20px 80px 20px 0' }}>
+              <img
+                onClick={() => {
+                  html2canvas(document.querySelector('.singlePsReportModal')).then((canvas) => {
+                    downloadJS(canvas.toDataURL('image/png'), '单条图表.png')
+                  })
+                }}
+                style={{ width: 16, cursor: 'pointer' }}
+                src="https://cdn.weipaitang.com/static/public/20210824acf87486-499f-7486499f-1d32-40d793b1fd05.svg"
+                alt="" />
+            </div>
+            <div className="singlePsReportModal">
+              <PsReport data={viewData} />
+            </div>
+          </>
+        </Modal>
+        <Modal
+          visible={showMultiPsReportModal}
+          width={900}
+          destroyOnClose
+          closable={false}
+          onCancel={() => {
+            this.setState({
+              showMultiPsReportModal: false
+            })
+          }}
+          onOk={() => {
+            this.setState({
+              showMultiPsReportModal: false
+            })
+          }}
+        >
+
+          <>
+            <div style={{ textAlign: 'right', padding: '20px 80px 20px 0' }}>
+              <img
+                onClick={() => {
+                  html2canvas(document.querySelector('.multiPsReportModal')).then((canvas) => {
+                    downloadJS(canvas.toDataURL('image/png'), '批量图表.png')
+                  })
+                }}
+                style={{ width: 16, cursor: 'pointer' }}
+                src="https://cdn.weipaitang.com/static/public/20210824acf87486-499f-7486499f-1d32-40d793b1fd05.svg"
+                alt="" />
+            </div>
+            <div className="multiPsReportModal">
+              {
+                selectedRowKeys.map((selectedRowKey, index) => (
+                  <PsReport data={dataSourceMap[selectedRowKey]} saveAsImage={false} showTitle={index === 0} showLine={index !== 0} />
+                ))
+              }
+            </div>
+          </>
         </Modal>
         <Modal
           visible={showModal}

+ 8 - 0
wgit.config.js

@@ -0,0 +1,8 @@
+module.exports = {
+  releaseConfig: [
+    {
+      reg: /t.*/, // 分支匹配,支持正则或字符串
+      releaseUrl: 'http://ci.wpt.la/job/multienv-test/job/test-front-dc/' // 发布链接
+    }
+  ]
+}

+ 44 - 0
yarn.lock

@@ -3648,6 +3648,16 @@ base16@^1.0.0:
   resolved "http://npm.wpt.la/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70"
   integrity sha1-4pf2DX7BAUp6lxo568ipjAtoHnA=
 
+base64-arraybuffer@^0.2.0:
+  version "0.2.0"
+  resolved "http://npm.wpt.la/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45"
+  integrity sha1-S5RPrAGRqlkHr+LYyZnMxXzoD0U=
+
+base64-arraybuffer@^1.0.1:
+  version "1.0.1"
+  resolved "http://npm.wpt.la/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz#87bd13525626db4a9838e00a508c2b73efcf348c"
+  integrity sha1-h70TUlYm20qYOOAKUIwrc+/PNIw=
+
 base64-js@^1.0.2:
   version "1.3.1"
   resolved "http://npm.wpt.la/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
@@ -5050,6 +5060,13 @@ css-has-pseudo@^0.10.0:
     postcss "^7.0.6"
     postcss-selector-parser "^5.0.0-rc.4"
 
+css-line-break@2.0.1:
+  version "2.0.1"
+  resolved "http://npm.wpt.la/css-line-break/-/css-line-break-2.0.1.tgz#3dc74c2ed5eb64211480281932475790243e7338"
+  integrity sha1-PcdMLtXrZCEUgCgZMkdXkCQ+czg=
+  dependencies:
+    base64-arraybuffer "^0.2.0"
+
 css-loader-1@2.0.0:
   version "2.0.0"
   resolved "http://npm.wpt.la/css-loader-1/-/css-loader-1-2.0.0.tgz#4dec481134dc6df3c95a769fbec440138c418fc2"
@@ -5835,6 +5852,11 @@ dotenv@^8.2.0:
   resolved "http://npm.wpt.la/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
   integrity sha1-l+YZJZradQ7qPk6j4mvO6lQksWo=
 
+downloadjs@^1.4.7:
+  version "1.4.7"
+  resolved "http://npm.wpt.la/downloadjs/-/downloadjs-1.4.7.tgz#f69f96f940e0d0553dac291139865a3cd0101e3c"
+  integrity sha1-9p+W+UDg0FU9rCkROYZaPNAQHjw=
+
 draft-js@^0.10.0, draft-js@~0.10.0:
   version "0.10.5"
   resolved "http://npm.wpt.la/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742"
@@ -8271,6 +8293,14 @@ html-webpack-plugin@4.0.0-beta.5:
     tapable "^1.1.0"
     util.promisify "1.0.0"
 
+html2canvas@^1.3.2:
+  version "1.3.2"
+  resolved "http://npm.wpt.la/html2canvas/-/html2canvas-1.3.2.tgz#951cc8388a3ce939fdac02131007ee28124afc27"
+  integrity sha1-lRzIOIo86Tn9rAITEAfuKBJK/Cc=
+  dependencies:
+    css-line-break "2.0.1"
+    text-segmentation "^1.0.2"
+
 htmlparser2@^3.10.0, htmlparser2@^3.3.0, htmlparser2@^3.9.1:
   version "3.10.1"
   resolved "http://npm.wpt.la/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
@@ -16751,6 +16781,13 @@ test-exclude@^5.2.3:
     read-pkg-up "^4.0.0"
     require-main-filename "^2.0.0"
 
+text-segmentation@^1.0.2:
+  version "1.0.2"
+  resolved "http://npm.wpt.la/text-segmentation/-/text-segmentation-1.0.2.tgz#1f828fa14aa101c114ded1bda35ba7dcc17c9858"
+  integrity sha1-H4KPoUqhAcEU3tG9o1un3MF8mFg=
+  dependencies:
+    utrie "^1.0.1"
+
 text-table@0.2.0, text-table@^0.2.0:
   version "0.2.0"
   resolved "http://npm.wpt.la/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@@ -17926,6 +17963,13 @@ utils-merge@1.0.1:
   resolved "http://npm.wpt.la/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
 
+utrie@^1.0.1:
+  version "1.0.1"
+  resolved "http://npm.wpt.la/utrie/-/utrie-1.0.1.tgz#e155235ebcbddc89ae09261ab6e773ce61401b2f"
+  integrity sha1-4VUjXry93ImuCSYatudzzmFAGy8=
+  dependencies:
+    base64-arraybuffer "^1.0.1"
+
 uuid@^3.0.1, uuid@^3.3.2:
   version "3.3.3"
   resolved "http://npm.wpt.la/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"