diff --git a/core/frontend/package.json b/core/frontend/package.json index f7a77248e3..ccca20d742 100644 --- a/core/frontend/package.json +++ b/core/frontend/package.json @@ -45,6 +45,7 @@ "echarts": "^5.0.1", "element-resize-detector": "^1.2.3", "element-ui": "2.15.7", + "exceljs": "^4.4.0", "file-saver": "^2.0.5", "fit2cloud-ui": "^1.8.0", "flv.js": "^1.6.2", diff --git a/core/frontend/src/components/canvas/components/editor/EditBar.vue b/core/frontend/src/components/canvas/components/editor/EditBar.vue index 53d50ac9b0..c8285d0485 100644 --- a/core/frontend/src/components/canvas/components/editor/EditBar.vue +++ b/core/frontend/src/components/canvas/components/editor/EditBar.vue @@ -89,6 +89,16 @@ @click.stop="exportExcelDownload()" /> + + + { - cb() - } - } + Button, + { + props: { + type: 'text' }, - this.$t('data_export.export_center') - ), + class: 'btn-text', + on: { + click: () => { + cb() + } + } + }, + this.$t('data_export.export_center') + ), this.$t('data_export.export_info') ]), iconClass, @@ -522,7 +540,7 @@ export default { Button, { props: { - type: 'text', + type: 'text' }, class: 'btn-text', on: { @@ -542,6 +560,13 @@ export default { exportExcelDownload() { exportExcelDownload(this.chart, null, null, null, null, null, this.exportDataCb) }, + exportFormattedExcel() { + const instance = this.$store.state.chart.tableInstance[this.chart.id] + if (!instance) { + return + } + exportPivotExcel(instance, this.chart) + }, auxiliaryMatrixChange() { if (this.curComponent.auxiliaryMatrix) { this.curComponent.auxiliaryMatrix = false diff --git a/core/frontend/src/icons/svg/ds-excel-format.svg b/core/frontend/src/icons/svg/ds-excel-format.svg new file mode 100644 index 0000000000..387f4d7708 --- /dev/null +++ b/core/frontend/src/icons/svg/ds-excel-format.svg @@ -0,0 +1 @@ + diff --git a/core/frontend/src/lang/en.js b/core/frontend/src/lang/en.js index baa7c3ac88..296bf0f629 100644 --- a/core/frontend/src/lang/en.js +++ b/core/frontend/src/lang/en.js @@ -1859,7 +1859,9 @@ export default { polynomial_regression: 'Polynomial regression', show_summary: 'Show summary', summary_label: 'Summary label', - tip: 'Tip' + tip: 'Tip', + pivot_export_empty_fields: 'Can not export without row dimension or quota', + export_formatted_excel: 'Export formatted excel' }, dataset: { scope_edit: 'Effective only when editing', diff --git a/core/frontend/src/lang/tw.js b/core/frontend/src/lang/tw.js index 2914c48904..f05b7ff68e 100644 --- a/core/frontend/src/lang/tw.js +++ b/core/frontend/src/lang/tw.js @@ -1852,7 +1852,9 @@ export default { polynomial_regression: '多項式擬合', show_summary: '顯示總計', summary_label: '總計標籤', - tip: '提示' + tip: '提示', + pivot_export_empty_fields: '行維度或指標維度為空不可導出', + export_formatted_excel: '導出 Excel (帶格式)' }, dataset: { scope_edit: '僅編輯時生效', diff --git a/core/frontend/src/lang/zh.js b/core/frontend/src/lang/zh.js index 7a3ac36706..e4fd1b5b75 100644 --- a/core/frontend/src/lang/zh.js +++ b/core/frontend/src/lang/zh.js @@ -1849,7 +1849,9 @@ export default { polynomial_regression: '多项式拟合', show_summary: '显示总计', summary_label: '总计标签', - tip: '提示' + tip: '提示', + pivot_export_empty_fields: '行维度或指标维度为空不可导出', + export_formatted_excel: '导出 Excel (带格式)' }, dataset: { goto: ', 前往 ', diff --git a/core/frontend/src/store/modules/chart.js b/core/frontend/src/store/modules/chart.js index b68f06f8f0..e3aac1428c 100644 --- a/core/frontend/src/store/modules/chart.js +++ b/core/frontend/src/store/modules/chart.js @@ -5,7 +5,8 @@ const getDefaultState = () => { sceneId: {}, viewId: null, tableId: {}, - chartSceneData: {} + chartSceneData: {}, + tableInstance: {} } } @@ -29,6 +30,9 @@ const mutations = { }, setChartSceneData: (state, chartSceneData) => { state.chartSceneData = chartSceneData + }, + setTableInstance: (state, { viewId, tableInstance }) => { + state.tableInstance[viewId] = tableInstance } } @@ -50,6 +54,9 @@ const actions = { }, setChartSceneData: ({ commit }, chartSceneData) => { commit('setChartSceneData', chartSceneData) + }, + setTableInstance: ({ commit }, { viewId, tableInstance }) => { + commit('setTableInstance', { viewId, tableInstance }) } } diff --git a/core/frontend/src/views/chart/chart/common/common_table.js b/core/frontend/src/views/chart/chart/common/common_table.js index 88f4c03cb0..244cdb1aed 100644 --- a/core/frontend/src/views/chart/chart/common/common_table.js +++ b/core/frontend/src/views/chart/chart/common/common_table.js @@ -1,5 +1,9 @@ import { hexColorToRGBA, resetRgbOpacity } from '@/views/chart/chart/util' import { DEFAULT_COLOR_CASE, DEFAULT_SIZE } from '@/views/chart/chart/chart' +import Exceljs from 'exceljs' +import { saveAs } from 'file-saver' +import i18n from '@/lang' +import {Message} from "element-ui"; export function getCustomTheme(chart) { const headerColor = hexColorToRGBA(DEFAULT_COLOR_CASE.tableHeaderBgColor, DEFAULT_COLOR_CASE.alpha) @@ -292,3 +296,168 @@ export function getSize(chart) { return size } + +export async function exportPivotExcel(instance, chart) { + const { meta, fields } = instance.dataCfg + const rowLength = fields?.rows?.length || 0 + const colLength = fields?.columns?.length || 0 + const valueLength = fields?.values?.length || 0 + if (!(rowLength && valueLength)) { + Message.warning({ + message: i18n.t('chart.pivot_export_empty_fields'), + type: 'warning', + showClose: true, + duration: 5000 + }) + return + } + const workbook = new Exceljs.Workbook() + const worksheet = workbook.addWorksheet(chart.title) + const metaMap = meta?.reduce((p, n) => { + if (n.field) { + p[n.field] = n + } + return p + }, {}) + // 角头 + fields.columns?.forEach((column, index) => { + const cell = worksheet.getCell(index + 1, 1) + cell.value = metaMap[column]?.name ?? column + cell.alignment = { vertical: 'middle', horizontal: 'center' } + if (rowLength >= 2) { + worksheet.mergeCells(index + 1, 1, index + 1, rowLength) + } + }) + fields?.rows?.forEach((row, index) => { + const cell = worksheet.getCell(colLength + 1, index + 1) + cell.value = metaMap[row]?.name ?? row + cell.alignment = { vertical: 'middle', horizontal: 'center' } + }) + const { layoutResult } = instance.facet + // 行头 + const { rowLeafNodes, rowsHierarchy, rowNodes } = layoutResult + const maxColIndex = rowsHierarchy.maxLevel + 1 + const notLeafNodeHeightMap = {} + rowLeafNodes.forEach(node => { + // 行头的高度由子节点相加决定,也就是行头子节点中包含的叶子节点数量 + let curNode = node.parent + while (curNode) { + const height = notLeafNodeHeightMap[curNode.id] ?? 0 + notLeafNodeHeightMap[curNode.id] = height + 1 + curNode = curNode.parent + } + const { rowIndex } = node + const writeRowIndex = rowIndex + 1 + colLength + 1 + const writeColIndex = node.level + 1 + const cell = worksheet.getCell(writeRowIndex, writeColIndex) + cell.value = node.label + cell.alignment = { vertical: 'middle', horizontal: 'center' } + if (writeColIndex < maxColIndex) { + worksheet.mergeCells(writeRowIndex, writeColIndex, writeRowIndex, maxColIndex) + } + }) + + const getNodeStartRowIndex = (node) => { + if (!node.children?.length) { + return node.rowIndex + 1 + } else { + return getNodeStartRowIndex(node.children[0]) + } + } + rowNodes?.forEach(node => { + if (node.isLeaf) { + return + } + const rowIndex = getNodeStartRowIndex(node) + const height = notLeafNodeHeightMap[node.id] + const writeRowIndex = rowIndex + colLength + 1 + const mergeColCount = node.children[0].level - node.level + const value = node.label + const cell = worksheet.getCell(writeRowIndex, node.level + 1) + cell.value = value + cell.alignment = { vertical: 'middle', horizontal: 'center' } + if (mergeColCount > 1 || height > 1) { + worksheet.mergeCells( + writeRowIndex, + node.level + 1, + writeRowIndex + height - 1, + node.level + mergeColCount + ) + } + }) + + // 列头 + const { colLeafNodes, colNodes, colsHierarchy } = layoutResult + const maxColHeight = colsHierarchy.maxLevel + 1 + const notLeafNodeWidthMap = {} + colLeafNodes.forEach(node => { + // 列头的宽度由子节点相加决定,也就是列头子节点中包含的叶子节点数量 + let curNode = node.parent + while (curNode) { + const width = notLeafNodeWidthMap[curNode.id] ?? 0 + notLeafNodeWidthMap[curNode.id] = width + 1 + curNode = curNode.parent + } + const { colIndex } = node + const writeRowIndex = node.level + 1 + const writeColIndex = colIndex + 1 + rowLength + const cell = worksheet.getCell(writeRowIndex, writeColIndex) + let value = node.label + if (node.field === '$$extra$$' && metaMap[value]?.name) { + value = metaMap[value].name + } + cell.value = value + cell.alignment = { vertical: 'middle', horizontal: 'center' } + if (writeRowIndex < maxColHeight) { + worksheet.mergeCells(writeRowIndex, writeColIndex, maxColHeight, writeColIndex) + } + }) + const getNodeStartColIndex = (node) => { + if (!node.children?.length) { + return node.colIndex + 1 + } else { + return getNodeStartColIndex(node.children[0]) + } + } + colNodes.forEach(node => { + if (node.isLeaf) { + return + } + const colIndex = getNodeStartColIndex(node) + const width = notLeafNodeWidthMap[node.id] + const writeRowIndex = node.level + 1 + const mergeRowCount = node.children[0].level - node.level + const value = node.label + const writeColIndex = colIndex + rowLength + const cell = worksheet.getCell(writeRowIndex, writeColIndex) + cell.value = value + cell.alignment = { vertical: 'middle', horizontal: 'center' } + if (mergeRowCount > 1 || width > 1) { + worksheet.mergeCells( + writeRowIndex, + writeColIndex, + writeRowIndex + mergeRowCount - 1, + writeColIndex + width - 1 + ) + } + }) + // 单元格数据 + for (let rowIndex = 0; rowIndex < rowLeafNodes.length; rowIndex++) { + for (let colIndex = 0; colIndex < colLeafNodes.length; colIndex++) { + const dataCellMeta = layoutResult.getCellMeta(rowIndex, colIndex) + const { fieldValue } = dataCellMeta + if (fieldValue) { + const meta = metaMap[dataCellMeta.valueField] + const cell = worksheet.getCell(rowIndex + maxColHeight + 1, rowLength + colIndex + 1) + const value = meta?.formatter?.(fieldValue) || fieldValue.toString() + cell.alignment = { vertical: 'middle', horizontal: 'center' } + cell.value = value + } + } + } + const buffer = await workbook.xlsx.writeBuffer() + const dataBlob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8' + }) + saveAs(dataBlob, `${chart.title ?? '透视表'}.xlsx`) +} diff --git a/core/frontend/src/views/chart/components/ChartComponentS2.vue b/core/frontend/src/views/chart/components/ChartComponentS2.vue index f872da4437..8f5eed6b92 100644 --- a/core/frontend/src/views/chart/components/ChartComponentS2.vue +++ b/core/frontend/src/views/chart/components/ChartComponentS2.vue @@ -308,6 +308,7 @@ export default { this.myChart = baseTableNormal(this.chartId, chart, this.antVAction, this.tableData, this, this.columnResize) } else if (chart.type === 'table-pivot') { this.myChart = baseTablePivot(this.chartId, chart, this.antVAction, this.tableHeaderClick, this.tableData) + this.$store.dispatch('chart/setTableInstance', { viewId: this.chart.id, tableInstance: this.myChart }) } if (this.myChart && this.searchCount > 0) {