如何在你的项目中使用在线excel(葡萄城Spread)

1,010 阅读4分钟

如何在你的项目中接入在线excel,这一篇文章就够了~

最近公司项目中遇到了需要使用在线excel的场景,于是商讨方案后决定使用葡萄城的spread作为技术开发栈,用过之后发现还是很强大的(据说实现了excel90%的功能),就是有点小贵,适合企业使用。这里将介绍完整的使用方法,便于需要使用的小伙伴~

使用总结

  • 数据加载快,万条数据无压力,页面不卡顿
  • 相比传统table适合更复杂数据交互
  • 节省了原本的导入,导出excel的资源
  • ... eg图片:

901b81b0a66feb2564e62b0008857c3.png

起步

在拿到测试开发授权的excel后,首先将其全局引入我们的项目中,在main.js中进行全局注册。 未购买的小伙伴可以有30天的试用期进行测试使用,但是只能使用loaclhost,无线进行线上测试哦

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

// 注册葡萄城spread
/* eslint-disable */
import '@grapecity/spread-sheets-resources-zh'
import GC from '@grapecity/spread-sheets'
GC.Spread.Common.CultureManager.culture('zh-cn')
import '@grapecity/spread-sheets-vue'
import '@grapecity/spread-sheets-charts'
import * as Excel from '@grapecity/spread-excelio'
GC.Spread.Sheets.LicenseKey = Excel.LicenseKey = '此处为授权密匙,未购买部署授权则不需填写'
/* eslint-ensable */

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

结构

在我们的项目中使用时,将spread的引入主要拆为了3大块

  • spread.mixin.js
  • page.vue(使用页面)
  • header.vue(表头页面) spread.mixin.js : 通过mixin混入用来集合spread的方法,便于文件内的调用
    page.vue: spread的使用页面,也是业务逻辑的呈现页面。
    header.vue: 表头渲染页面,包括按钮,搜索,选择等组件的封装。

page.vue

  • dom模块
<template>
  <div v-loading="loading" class="spread-box">
    <!-- header组件 -->
    <gd-spread-header :buttons="buttons" :percentage="percentage" />
    <!-- spread渲染模块 -->
    <gc-spread-sheets class="spread-content" @workbookInitialized="initSpread">
      <gc-worksheet :data-source="dataSource" :auto-generate-columns="autoGenerateColumns">
        <gc-column
          v-for="(item, index) in sheets[activeSheetIndex].columns"
          :key="index"
          :data-field="item.dataField"
          :header-text="item.displayName"
          :formatter="item.formatter"
          :width="item.width"
          :cell-type="item.cellType && item.cellType()"
        />
      </gc-worksheet>
    </gc-spread-sheets>
  </div>
</template>
  • js模块 首先进行需要模块的引入与注册
import GC from '@grapecity/spread-sheets'
// spread混入引入
import { spreadMixins } from '@/mixins'
// 项目内字典表(可自行设计)
import { MEASURE_UNIT_DICT } from '@/common/dict.const'
// 头部组件引入
import { GdSpreadHeader } from '@common/components/index'
// api 引入
import { getDataApi } from '@api/index'
export default {
  components: {
    GdSpreadHeader
  },
  mixins: [spreadMixins]
}

数据渲染,组件渲染

  data: function () {
    return {
      // 表头按钮配置
      buttons: [
        {
          id: 'import',
          icon: 'el-icon-download',
          text: '导出excel',
          click: () => {
            this.handleExportExcel()
          }
        },
        {
          id: 'save',
          text: '保存',
          click: () => {
            this.handleSaveData()
          }
        },
        {
          id: 'insertRow',
          text: '添加行',
          nativeType: 'insertRow',
          click: val => {
            this.handleInsertRow({ type: 'row', val: val })
          }
        }
      ],
      // 表单配置
      sheets: [
        {
          // 数据源
          dataSource: [],
          name: 'ALLData',
          // header 多级表头,数组深度为表头层级,用来配置多级表头
          header: [
            [
              // addSpan 合并位置(单元格起始行,单元格起始列,向下合并的行数,向右合并的列数 )
              // setValue 设置文本位置(单元格起始行,单元格起始列,文本值)
              { addSpan: [0, 0, 2, 1], setValue: [0, 0, '编码'] },
              { addSpan: [0, 1, 2, 1], setValue: [0, 1, '层级码'] },
              { addSpan: [0, 2, 2, 1], setValue: [0, 2, '名称'] },
              { addSpan: [0, 3, 2, 1], setValue: [0, 3, '计量单位'] },
              {
                addSpan: [0, 4, 1, 3], 
                setValue: [0, 4, '工程量1'] 
              },
              { addSpan: [0, 7, 1, 3], setValue: [0, 7, '工程量2'] },
              { addSpan: [0, 10, 1, 2], setValue: [0, 10, '变化量'] }
            ]
          ],
          // 表头配置
          columns: [
            {
              dataField: 'code', // 编码
              displayName: '编码', // 对应值
              width: '120', // 宽度 *|自定义  *为自适应宽度
              align: 'left', // 对齐方式left|center|right 
              formatter: '0.00' // 数据格式化
            },
            {
              dataField: 'detailCode',
              displayName: '层级码',
              formatter: '0000',
              width: '100',
              align: 'left'
            },
            {
              dataField: 'name',
              displayName: '名称',
              width: '120',
              align: 'left'
            },
            {
              dataField: 'unit',
              displayName: '计量单位',
              width: '100',
              align: 'center',
              cellType: () => {
                // 此处为项目内部封装字典表转化
                let cellType = new GC.Spread.Sheets.CellTypes.ComboBox()
                cellType.items(this.lookUp[MEASURE_UNIT_DICT])
                return cellType
              }
            },
            {
              dataField: 'amount',
              displayName: '数量',
              formatter: '0.00',
              width: '100',
              align: 'right'
            },
            {
              dataField: 'unitMoney',
              displayName: '单价(元)',
              width: '120',
              align: 'right'
            },
            {
              dataField: 'sumMoney',
              displayName: '合价(元)',
              width: '100',
              align: 'right'
            },
            {
              dataField: 'amount',
              displayName: '数量',
              width: '100',
              align: 'right'
            },
            {
              dataField: 'unitMoney',
              displayName: '单价',
              width: '120',
              align: 'right'
            },
            {
              dataField: 'sumMoney',
              displayName: '合价',
              width: '100',
              align: 'right'
            },
            {
              dataField: 'finalAmount',
              displayName: '数量',
              width: '100',
              align: 'right'
            },
            {
              dataField: 'finalPrice',
              displayName: '合价(元)',
              width: '100',
              align: 'right'
            }
          ]
        }
      ]
    }
  },
  • function 执行
<script>
  methods: {
    // spread组件初次加载
    initSpread: function (spread) {
      // 定义spread
      this.spread = spread
      // 限制滚动区域(true/false)
      spread.options.scrollbarMaxAlign = true
      // 屏蔽sheet增加按钮
      spread.options.newTabVisible = this.newTabVisible
      // 配置多级表头
      this.handleSetTableHeader()
      // 配置表单保护
      this.handleSetProtectedArea()
      // 格式化数据
      this.handleFormatterData({ startLen: 4, endLen: 12, unit: 3 })
      // 获取字典
      this.getLookUp()
      // 设置对齐方式
      this.handleSetAlign()
      // 配置层级码格式校验
      this.handleDetailCodeCheck({ position: 1 })
      // ... 其他集成方法
      // 请求数据
      this.loadData('init')
    },
    // 获取初始数据
    loadData(type) {
      this.loading = true
      const params = {
        currentPage: this.currentPage,
        pageSize: this.pageSize
      }
      getDataApi(params)
        .then(res => {
          // 总页数
          this.totalPages = res.pages
          // 总数据条数
          this.total = res.total
          // 执行获取数据后的内容
          this.handleExitOption({ type: type, data: res.records })
          // 无数据时进度条加载为100%
          if (res.records.length == 0) {
            this.percentage = 100
          } else {
            this.percentage = parseInt((this.currentPage / this.totalPages) * 100)
          }
        })
        .catch(() => {
          this.loading = false
        })
    },
    // 配置执行
    handleExitOption({ type, data }) {
      // 配置数据源
      let sheet = this.spread.getSheet(this.activeSheetIndex)
      this.dataSource = data
      // suspendPaint 挂起绘制,加速进程,避免卡顿
      sheet.suspendPaint()
      // 拼接新数据
      this.activeRow = sheet.getRowCount()
      sheet.addRows(this.activeRow, data.length)
      let datasource = sheet.getDataSource()
      data.map((item, index) => {
        datasource[this.activeRow + index] = item
      })
      // 设置边线
      this.handleSetBorder()
      // 配置树形结构
      this.handleSetTreeData(data)
      // 结束绘制,加速进程,避免卡顿
      sheet.resumePaint()
      // 关闭loading
      this.loading = false
      // 开启加载
      this.isAddData = true
      // 清除变化
      sheet.clearPendingChanges()
      // 大于1页时开启加载,加载后续数据
      if (type === 'init' && this.totalPages > 1) {
        this.addAll()
      }
    },
    // 提交变更数据(增删改脏数据)
    submitDirtyData() {
      const params = this.dirtyData
      Object.assign(params, { sectionId: this.id })
      replydoExcelData(params).then(() => {
        this.$message.success('保存成功')
      })
    },
    // 获取字典表数据(项目内部方法)
    getLookUp() {
      let lookUpData = JSON.parse(sessionStorage.getItem('LOOKUPS'))[MEASURE_UNIT_DICT]
      lookUpData.map(item => {
        item.text = item.name
        item.value = item.code
      })
      this.lookUp[MEASURE_UNIT_DICT] = lookUpData
    },
    // 配置表单保护
    handleSetProtectedArea() {
      this.handleGetSheet().options.protectionOptions = this.protectedOption
      this.handleGetSheet().options.isProtected = this.isProtected

      // 设置默认style
      let defaultStyle = new GC.Spread.Sheets.Style()
      defaultStyle.locked = true
      defaultStyle.foreColor = this.protectedTextColor
      this.handleGetSheet().setDefaultStyle(defaultStyle, GC.Spread.Sheets.SheetArea.viewport)

      // 可编辑列-style
      let styleNo = new GC.Spread.Sheets.Style()
      styleNo.foreColor = this.defaultTextColor
      styleNo.locked = false
      // 要保护的行列
      for (let i = 7; i < 10; i++) {
        this.handleGetSheet().setStyle(-1, i, styleNo)
      }
    },
    // 循环加载所有数据
    addAll() {
      this.currentPage++
      if (this.isGetNextData && this.currentPage <= this.totalPages) {
        const params = {
          currentPage: this.currentPage,
          pageSize: this.pageSize
        }
        this.isGetNextData = false
        getDataApi(params).then(res => {
          this.totalPages = res.pages
          this.total = res.total
          this.percentage = parseInt((this.currentPage / this.totalPages) * 100)
          this.allData = this.allData.concat(res.records)
          this.handleExitOption({ type: 'add', data: res.records })
          this.isGetNextData = true
          this.addAll()
        })
      }
    }
  }
}
</script>

header.vue (头部渲染组件)

这里二次包装了,按钮,选择,搜索,删上传等常用组件(element-ui),便于项目快速开发。

<template>
  <div>
    <div class="spread-header">
      <!-- left -->
      <div class="spread-header-left">
        <div v-for="(item, index) in buttons" :key="index">
          <!-- 选择器 -->
          <div v-if="item.nativeType && item.nativeType === 'select'" class="spread-el-button">
            <el-select
              v-model="item.contractId"
              size="small"
              :placeholder="item.placeholder"
              @change="selectChange"
            >
              <el-option
                v-for="(childItem, childIndex) in item.list"
                :key="'select' + childIndex"
                :label="childItem.contractName"
                :value="childItem.id"
              />
            </el-select>
          </div>
          <!-- 普通按钮 -->
          <el-button
            v-if="!item.nativeType"
            :id="item.id"
            class="spread-el-button"
            size="small"
            :icon="item.icon"
            :type="item.type && item.type"
            :loading="item.loading"
            @click="item.click && item.click()"
          >
            {{ item.text }}
          </el-button>
          <!-- 上传按钮 -->
          <el-upload
            v-if="item.nativeType && item.nativeType === 'upload'"
            ref="upload"
            class="spread-el-button"
            action=""
            :on-change="item.beforeUpload"
            :file-list="fileList"
            :auto-upload="false"
            :show-file-list="false"
          >
            <el-button slot="trigger" size="small" type="primary">{{ item.text }}</el-button>
          </el-upload>
          <!-- 插入行 -->
          <div class="spread-el-button">
            <el-input
              v-if="item.nativeType && item.nativeType === 'insertRow'"
              v-model="insertRowVal"
              placeholder="请输入"
              size="small"
              style="width: 160px"
            >
              <el-button slot="append" @click="item.click && item.click(insertRowVal)">
                {{ item.text }}
              </el-button>
            </el-input>
          </div>
        </div>
      </div>
      <!-- right -->
      <div class="spread-header-right">
        <div v-for="(item, index) in queryFields" :key="'fileds' + index">
          <el-date-picker
            v-model="item.value"
            size="small"
            type="date"
            :placeholder="item.placeholder"
          />
          <i class="el-icon-search spread-el-icon" @click="handleQuery(item.value)"></i>
        </div>
      </div>
    </div>
    <div class="spread-progress">
      <el-progress :percentage="percentage" />
    </div>
  </div>
</template>
<script>
export default {
  props: {
    buttons: {
      type: Array,
      default: () => []
    },
    queryFields: {
      type: Array,
      default: () => []
    },
    fileList: {
      type: Array,
      default: () => []
    },
    percentage: {
      type: Number,
      default: () => 0
    }
  },
  data() {
    return {
      insertRowVal: '' // 插入行数
    }
  },
  watch: {
    // 行数规则
    insertRowVal(val) {
      val = parseInt(val)
      this.insertRowVal = val
      let reg = /^[1-9]\d*$/
      if (!reg.test(val)) {
        this.insertRowVal = ''
      } else if (val > 999) {
        this.insertRowVal = val.toString().substring(0, 3)
      }
    }
  },
  methods: {
    selectChange(e) {
      this.$emit('selectChange', e)
    },
    handleQuery(e) {
      this.$emit('handleQuery', e)
    }
  }
}
</script>
<style lang="scss" scoped>
.spread-header {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  margin-bottom: 15px;
  .spread-header-left {
    display: flex;
    flex-direction: row;
  }
  .spread-header-right {
    display: flex;
    flex-direction: row;
    .spread-header-right_search {
      display: flex;
      align-items: center;
    }
  }
}
.spread-el-button {
  margin-right: 10px;
}
.spread-el-icon {
  margin-left: 5px;
  // 全局换肤主题色
  @include color('header-bg-color');
}
</style>

spread.mixin.js

这里进行常用sprad方法的包装与介绍

配置多级表头

可进行多级表头的配置,合并等操作,只需设置表头合并位置

handleSetTableHeader() {
  let spreadNS = GC.Spread.Sheets
  let header = this.sheets[this.activeSheetIndex].header
  this.handleGetSheet().setRowCount(header.length + 1, spreadNS.SheetArea.colHeader)
  this.handleGetSheet().setColumnCount(1, spreadNS.SheetArea.rowHeader)
  header.map(item => {
    item.map(items => {
      this.handleGetSheet().addSpan(
        items.addSpan[0],
        items.addSpan[1],
        items.addSpan[2],
        items.addSpan[3],
        GC.Spread.Sheets.SheetArea.colHeader
      )
      this.handleGetSheet().setValue(
        items.setValue[0],
        items.setValue[1],
        items.setValue[2],
        GC.Spread.Sheets.SheetArea.colHeader
      )
    })
  })
}

配置树形结构

将数据进行树形层级结构配置,可进行收缩与展开,你可以使用分组列来展示有分层结构的数据,使数据呈现树形结构。
内部集成了字典转化,与数据状态添加等操作。

handleSetTreeData(data) {
  this.handleGetSheet().suspendPaint()
  let d = data
  let style = new GC.Spread.Sheets.Style();
  style.backColor = 'yellow';
  style.foreColor = 'red';
  for (let r = 0; r < d.length; r++) {
    let level = d[r].level
    this.handleGetSheet().getCell(r + this.activeRow, 0).textIndent(level)
    // 转化字典
    d[r].measureUnit = this.handleBook(d[r].measureUnit)
    // 添加新增数据状态
    if (d[r].detailStatus == '1') {
      this.handleGetSheet().setStyle(r + this.activeRow, -1, style, GC.Spread.Sheets.SheetArea.viewport);
    }
  }
  this.handleGetSheet().outlineColumn.options({ columnIndex: 0 })
  // 隐藏左侧状态栏
  this.handleGetSheet().showRowOutline(false)
  this.handleGetSheet().outlineColumn.options({
    columnIndex: 0,
    expandIndicator: require('@/assets/spread/add-circle.png'),
    collapseIndicator: require('@/assets/spread/del-circle.png')
  })
  this.handleGetSheet().resumePaint()
}

获取表单json数据结构

进行json数据的获取可让我们进行复制,导出等一系列操作

handleSerialization() {
  const serializationOption = {
    includeBindingSource: this.includeBindingSource, // 在将工作簿转换为json时包含绑定源,默认值为false
    saveAsView: this.saveAsView, // 将工作簿转换为json时,包含格式化字符串的结果,默认值为false
    includeAutoMergedCells: this.includeAutoMergedCells, // 将工作簿转换为json时,将自动合并的单元格包含为实际合并的单元格
    ignoreFormula: this.ignoreFormula, // 忽略公式,默认为true
    ignoreStyle: this.ignoreStyle, // 将工作簿转换为json时忽略样式,默认值为false
    rowHeadersAsFrozenColumns: this.rowHeadersAsFrozenColumns, // 将工作簿转换为json时,将行标头视为冻结列,默认值为false
    columnHeadersAsFrozenRows: this.columnHeadersAsFrozenRows // 将工作簿转换为json时,将列标头视为冻结行,默认值为false
  }

  let jsonStr = this.spread.toJSON(serializationOption)
  this.$message.success('导出json成功')

  console.log('导出json数据-表头', jsonStr.sheets.Sheet1.columns)
  console.log('导出json数据-数据源', jsonStr.sheets.Sheet1.data.dataTable)
}

导出excel

将页面内excel导出到本地

handleExportExcel() {
  // 导出解开表单保护
  this.handleGetSheet().options.isProtected = false;
  const serializationOption = {
    includeBindingSource: this.includeBindingSource, // 在将工作簿转换为json时包含绑定源,默认值为false
    saveAsView: this.saveAsView, // 将工作簿转换为json时,包含格式化字符串的结果,默认值为false
    includeAutoMergedCells: this.includeAutoMergedCells, // 将工作簿转换为json时,将自动合并的单元格包含为实际合并的单元格
    ignoreFormula: this.ignoreFormula, // 忽略公式,默认为true
    ignoreStyle: this.ignoreStyle, // 将工作簿转换为json时忽略样式,默认值为false
    rowHeadersAsFrozenColumns: this.rowHeadersAsFrozenColumns, // 将工作簿转换为json时,将行标头视为冻结列,默认值为false
    columnHeadersAsFrozenRows: this.columnHeadersAsFrozenRows // 将工作簿转换为json时,将列标头视为冻结行,默认值为false
  }
  const excelIo = new IO()

  let fileName = this.fileName
  if (fileName === undefined) {
    fileName = (new Date()).getTime() + '.xlsx'
  }
  const password = this.password
  if (fileName.substr(-5, 5) !== '.xlsx') {
    fileName += '.xlsx'
  }

  let json = this.spread.toJSON(serializationOption)
  // 导出后重新保护表单
  this.handleGetSheet().options.isProtected = true
  // here is excel IO API
  excelIo.save(
    json,
    function (blob) {
      FaverSaver.saveAs(blob, fileName)
    },
    function (e) {
      // process error
      console.log(e)
    },
    {
      password: password
    }
  )
}

导入excel

将本地excel导入页面

handleImportExcel(excelFile) {
  const excelIo = new IO()
  const password = this.password
  // here is excel IO API
  excelIo.open(
    excelFile,
    function (json) {
      let workbookObj = json
      this.spread.fromJSON(json)
      this.spread.fromJSON(workbookObj)
    },
    function (e) {
      // process error
      console.log(e.errorMessage)
    },
    {
      password: password
    }
  )
}

获取变更数据

此方法可获取删除,修改,新增数据,便于后台交互
此处将脏数据放于数组中,add-新增数据,update-变更数据,delete-删除数据

handleSaveData() {
  this.dirtyData = {
    add: [],
    update: [],
    delete: []
  }
  let editRows = this.handleGetSheet().getDirtyRows()
  let insertRows = this.handleGetSheet().getInsertRows()
  let deleteRows = this.handleGetSheet().getDeletedRows()

  // 处理数据
  editRows.map(item => {
    this.dirtyData.update.push(item.item)
  })
  insertRows.map(item => {
    this.dirtyData.add.push(item.item)
  })
  deleteRows.map(item => {
    // 与后端确认,目前所有页面的spreadJS删除均传入id
    this.dirtyData.delete.push(item.originalItem.id)
  })

  // 转化单位字典
  for (let itemArr in this.dirtyData) {
    if (itemArr !== 'delete') {
      this.dirtyData[itemArr].map(item => {
        // 更改sectionId
        item.contractSectionId = this.id || this.contractSectionId
        // 单位匹配
        this.lookUp[MEASURE_UNIT_DICT].map(items => {
          if (items.name === item.measureUnit) {
            item.measureUnit = items.value
            return item.measureUnit
          }
        })
      })
    }
  }
  // 提交数据
  // ...
}

监听返回底部

监听excel到达最后一行数据,便于进行数据懒加载、拼接数据等操作

handleWatchGetBottom() {
  const _this = this
  function bottomF() {
    let row = _this.handleGetSheet().getViewportBottomRow(1)
    let rowCount = _this.handleGetSheet().getRowCount()
    if ((row === rowCount - 1) && _this.isAddData === true) {
      console.log('滚动到底部了', row, _this.currentPage, _this.pageSize)
      if (_this.currentPage >= _this.pageSize) {
        // 判断是否到达底部
        _this.$message.warning('已无更多数据')
      } else if (_this.currentPage < _this.pageSize) {
        _this.isAddData = false
        // 定位行
        _this.activeRow = row
        // 当前页数自增
        _this.currentPage++
        // 执行新数据获取,拼接更多数据
        _this.loadData('add')
      }
    }
  }
  // debounce 为节流函数
  this.handleGetSheet().bind(GC.Spread.Sheets.Events.TopRowChanged, debounce(bottomF, 500))
},

配置公式

进行自动计算等操作,此处为使用示例

/**
 *
 * @param {*} data        数据
 * @param {*} activeRow   追加数据最后一行索引
 * @param {*} toArr       要配置公式的列
 * @param {*} fromArr     要配置公式的数据源
 */
handleSetFormula({ data, activeRow, toArr, fromArr }) {
  this.handleGetSheet().suspendPaint()
  data.map((_, index) => {
    const indexs = activeRow + index + 1
    this.handleGetSheet().setFormula(
      activeRow + index,
      toArr[0],
      `=SUM(${fromArr[0]}${indexs}-${fromArr[1]}${indexs})`
    )
    this.handleGetSheet().setFormula(
      activeRow + index,
      toArr[1],
      `=SUM(${fromArr[2]}${indexs}-${fromArr[3]}${indexs})`
    )
  })
  this.handleGetSheet().resumePaint()
  // 清除公式变化数据
  this.handleGetSheet().clearPendingChanges({
    clearType: 1,
    row: -1,
    rowCount: -1,
    col: -1,
    colCount: -1
  })
}

设置边线

用来给表单,单元格设置自定义边线(示例)
all:true 上下左右都添加
-1,-1 代表全表添加

  let lineStyle = GC.Spread.Sheets.LineStyle.thin
  let lineBorder = new GC.Spread.Sheets.LineBorder('#cccccc', lineStyle)
  let sheetArea = GC.Spread.Sheets.SheetArea.viewport
  this.handleGetSheet().getRange(-1, -1, 0, 0).setBorder(lineBorder, { all: true }, sheetArea)

对齐方式

设置单元格内文本对齐方式
left|center|right

this.handleGetSheet().getCell(-1, 0).hAlign(GC.Spread.Sheets.HorizontalAlign.left)

刷新spread

在页面视口大小发生改变时,可调取进行页面适配

this.spread.refresh()

最后

本篇对常用的spread功能进行了总结,后续还会进行更多使用场景的更新迭代~
coding加油~