表格支持导出为 Excel (#1077)

This commit is contained in:
吴多益 2020-11-17 16:05:16 +08:00 committed by GitHub
parent 550bebef52
commit 12be84bd61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 392 additions and 37 deletions

View File

@ -1232,39 +1232,17 @@ crud 组件支持通过配置`headerToolbar`和`footerToolbar`属性,实现在
在`headerToolbar`或者`footerToolbar`数组中添加`export-csv`字符串,可以实现点击下载 CSV 的功能,注意这里只包括当前分页的数据,要下载全部数据需要通过后端 API 实现。
```schema:height="600" scope="body"
{
"type": "crud",
"api": "https://houtai.baidu.com/api/sample",
"headerToolbar": ["export-csv"],
"columns": [
{
"name": "id",
"label": "ID"
},
{
"name": "engine",
"label": "Rendering engine"
},
{
"name": "browser",
"label": "Browser"
},
{
"name": "platform",
"label": "Platform(s)"
},
{
"name": "version",
"label": "Engine version"
},
{
"name": "grade",
"label": "CSS grade"
}
]
}
```
可以在[示例](../../examples/crud/export-excel-csv)中进行测试。
### 导出 Excel
在`headerToolbar`或者`footerToolbar`数组中添加`export-excel`字符串,可以实现点击下载 Excel 的功能,和导出 CSV 一样只包括当前分页的数据,但它们有明显区别:
1. 导出 CSV 是将 api 返回数据导出,表头是数据里的 key而 Excel 的表头使用的是 label。
2. 导出 Excel 更重视展现一致支持合并单元格、链接、mapping 映射、图片(需要加[跨域 Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS))。
3. 导出 Excel 只在 `mode``table` 时能用。
可以在[示例](../../examples/crud/export-excel-csv)中进行测试。
### 显隐显示查询条件表单

View File

@ -0,0 +1,142 @@
export default {
$schema: 'https://houtai.baidu.com/v2/schemas/page.json#',
title: 'CSV 导出的是原始数据,而 Excel 是尽可能还原展现效果',
body: {
type: 'crud',
headerToolbar: ['export-excel', 'export-csv'],
data: {
items: [
{
icon: __uri('../../static/ie.png'),
link: 'https://www.microsoft.com/',
engine: 'Trident',
browser: 'Internet Explorer 4.2',
platform: 'Win 95+',
version: '4',
grade: 'A'
},
{
icon: __uri('../../static/ie.png'),
link: 'https://www.microsoft.com/',
engine: 'Trident',
browser: 'Internet Explorer 4.2',
platform: 'Win 95+',
version: '4',
grade: 'B'
},
{
icon: __uri('../../static/ie.png'),
link: 'https://www.microsoft.com/',
engine: 'Trident',
browser: 'AOL browser (AOL desktop)',
platform: 'Win 95+',
version: '4',
grade: 'C'
},
{
icon: __uri('../../static/ie.png'),
link: 'https://www.microsoft.com/',
engine: 'Trident',
browser: 'AOL browser (AOL desktop)',
platform: 'Win 98',
version: '3',
grade: 'A'
},
{
icon: __uri('../../static/ie.png'),
link: 'https://www.microsoft.com/',
engine: 'Trident',
browser: 'AOL browser (AOL desktop)',
platform: 'Win 98',
version: '4',
grade: 'A'
},
{
icon: __uri('../../static/firefox.png'),
link: 'https://www.mozilla.org/',
engine: 'Gecko',
browser: 'Firefox 1.0',
platform: 'Win 98+ / OSX.2+',
version: '4',
grade: 'A'
},
{
icon: __uri('../../static/firefox.png'),
link: 'https://www.mozilla.org/',
engine: 'Gecko',
browser: 'Firefox 1.0',
platform: 'Win 98+ / OSX.2+',
version: '5',
grade: 'A'
},
{
icon: __uri('../../static/firefox.png'),
link: 'https://www.mozilla.org/',
engine: 'Gecko',
browser: 'Firefox 2.0',
platform: 'Win 98+ / OSX.2+',
version: '5',
grade: 'B'
},
{
icon: __uri('../../static/firefox.png'),
link: 'https://www.mozilla.org/',
engine: 'Gecko',
browser: 'Firefox 2.0',
platform: 'Win 98+ / OSX.2+',
version: '5',
grade: 'C'
},
{
icon: __uri('../../static/firefox.png'),
link: 'https://www.mozilla.org/',
engine: 'Gecko',
browser: 'Firefox 2.0',
platform: 'Win 98+ / OSX.2+',
version: '5',
grade: 'D'
}
]
},
combineNum: 3,
columns: [
{
name: 'icon',
label: '图标',
type: 'image'
},
{
name: 'link',
label: '官网',
type: 'link'
},
{
name: 'engine',
label: '引擎'
},
{
name: 'browser',
label: '浏览器'
},
{
name: 'platform',
label: '操作系统'
},
{
name: 'version',
label: '引擎版本'
},
{
name: 'grade',
label: 'CSS等级',
type: 'mapping',
map: {
A: '优',
B: '中',
C: '差',
D: '极差'
}
}
]
}
};

View File

@ -46,6 +46,7 @@ import MergeCellSchema from './CRUD/MergeCell';
import HeaderGroupSchema from './CRUD/HeaderGroup';
import HeaderHideSchema from './CRUD/HeaderHide';
import LoadOnceTableCrudSchema from './CRUD/LoadOnce';
import ExportCSVExcelSchema from './CRUD/ExportCSVExcel';
import SdkTest from './Sdk/Test';
import JSONSchemaForm from './Form/Schem';
import SimpleDialogSchema from './Dialog/Simple';
@ -344,6 +345,11 @@ export const examples = [
label: '一次性加载',
path: '/examples/crud/load-once',
component: makeSchemaRenderer(LoadOnceTableCrudSchema)
},
{
label: '导出 Excel/CSV',
path: '/examples/crud/export-excel-csv',
component: makeSchemaRenderer(ExportCSVExcelSchema)
}
// {
// label: '测试',

BIN
examples/static/firefox.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
examples/static/ie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -34,6 +34,7 @@ fis.set('project.files', [
'scss/**.scss',
'/examples/*.html',
'/examples/*.tpl',
'/examples/static/*.png',
'/src/**.html',
'mock/**'
]);
@ -388,6 +389,7 @@ if (fis.project.currentMedia() === 'publish') {
'!zrender/**',
'!echarts/**',
'!papaparse/**',
'!exceljs/**',
'!docsearch.js/**',
'!monaco-editor/**.css',
'!src/components/RichText.tsx',
@ -405,6 +407,8 @@ if (fis.project.currentMedia() === 'publish') {
'papaparse.js': ['papaparse/**'],
'exceljs.js': ['exceljs/**'],
'charts.js': ['zrender/**', 'echarts/**'],
'rest.js': [
@ -417,7 +421,8 @@ if (fis.project.currentMedia() === 'publish') {
'!jquery/**',
'!zrender/**',
'!echarts/**',
'!papaparse/**'
'!papaparse/**',
'!exceljs/**'
]
}),
postpackager: [
@ -578,7 +583,8 @@ if (fis.project.currentMedia() === 'publish') {
'!jquery/**',
'!zrender/**',
'!echarts/**',
'!papaparse/**'
'!papaparse/**',
'!exceljs/**'
],
'pkg/rich-text.js': [
'src/components/RichText.js',
@ -588,6 +594,7 @@ if (fis.project.currentMedia() === 'publish') {
'pkg/tinymce.js': ['src/components/Tinymce.tsx', 'tinymce/**'],
'pkg/charts.js': ['zrender/**', 'echarts/**'],
'pkg/papaparse.js': ['papaparse/**'],
'pkg/exceljs.js': ['exceljs/**'],
'pkg/api-mock.js': ['mock/*.ts'],
'pkg/app.js': [
'/examples/components/App.tsx',
@ -605,7 +612,8 @@ if (fis.project.currentMedia() === 'publish') {
'!src/components/RichText.js',
'!zrender/**',
'!echarts/**',
'!papaparse/**'
'!papaparse/**',
'!exceljs/**'
],
'pkg/npm.css': ['node_modules/*/**.css', '!monaco-editor/**'],

View File

@ -46,6 +46,7 @@
"dom-helpers": "^3.3.1",
"downshift": "3.1.4",
"echarts": "^4.1.0",
"exceljs": "^4.2.0",
"file-saver": "^2.0.2",
"flv.js": "1.5.0",
"froala-editor": "2.9.6",

View File

@ -65,7 +65,8 @@ export type CRUDBultinToolbarType =
| 'switch-per-page'
| 'load-more'
| 'filter-toggler'
| 'export-csv';
| 'export-csv'
| 'export-excel';
export interface CRUDBultinToolbar extends Omit<BaseSchema, 'type'> {
type: CRUDBultinToolbarType;

View File

@ -39,6 +39,7 @@ import {SchemaPopOver} from '../PopOver';
import {SchemaQuickEdit} from '../QuickEdit';
import {SchemaCopyable} from '../Copyable';
import {SchemaRemark} from '../Remark';
import {toDataURL, getImageDimensions} from '../../utils/image';
/**
*
@ -271,6 +272,17 @@ export interface TableProps extends RendererProps {
popOverContainer?: any;
canAccessSuperData?: boolean;
}
/**
* url
*/
const getAbsoluteUrl = (function () {
let link: HTMLAnchorElement;
return function (url: string) {
if (!link) link = document.createElement('a');
link.href = url;
return link.href;
};
})();
export default class Table extends React.Component<TableProps, object> {
static propsList: Array<string> = [
@ -1642,6 +1654,8 @@ export default class Table extends React.Component<TableProps, object> {
} else if (type === 'drag-toggler') {
this.renderedToolbars.push(type);
return this.renderDragToggler();
} else if (type === 'export-excel') {
return this.renderExportExcel();
}
return void 0;
@ -1723,6 +1737,169 @@ export default class Table extends React.Component<TableProps, object> {
);
}
renderExportExcel() {
const {
store,
classPrefix: ns,
classnames: cx,
translate: __,
columns
} = this.props;
if (!columns) {
return null;
}
// 按名字快速查找列信息
const columnNameMap: {[key: string]: any} = {};
for (const column of columns) {
if (column.name) {
columnNameMap[column.name] = column;
}
}
return (
<Button
classPrefix={ns}
onClick={() => {
(require as any)(['exceljs'], async (ExcelJS: any) => {
if (!store.data.items || store.data.items.length === 0) {
return;
}
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('sheet');
worksheet.views = [{state: 'frozen', xSplit: 0, ySplit: 1}];
const items = store.data.items;
// 基于第一行数据来生成列名,所以必须保证第一行数据是完整的
const firstRowKeys = Object.keys(items[0]);
const firstRowLabels = firstRowKeys.map(key => {
if (key in columnNameMap) {
return columnNameMap[key].label || key;
}
return key;
});
const firstRow = worksheet.getRow(1);
firstRow.values = firstRowLabels;
worksheet.autoFilter = {
from: {
row: 1,
column: 1
},
to: {
row: 1,
column: firstRowKeys.length
}
};
// 数据从第二行开始
let rowIndex = 1;
for (const row of store.rows) {
rowIndex += 1;
const sheetRow = worksheet.getRow(rowIndex);
let columIndex = 0;
for (const key of firstRowKeys) {
columIndex += 1;
if (!(key in row.data)) {
continue;
}
// 处理合并单元格
if (key in row.rowSpans) {
if (row.rowSpans[key] === 0) {
continue;
} else {
// start row, start column, end row, end column
worksheet.mergeCells(
rowIndex,
columIndex,
rowIndex + row.rowSpans[key] - 1,
columIndex
);
}
}
const value = row.data[key];
const type = columnNameMap[key]?.type || 'plain';
if (type === 'image') {
const imageData = await toDataURL(value);
const imageDimensions = await getImageDimensions(imageData);
const imageMatch = imageData.match(/data:image\/(.*);/);
let imageExt = 'png';
if (imageMatch) {
imageExt = imageMatch[1];
}
// 目前 excel 只支持这些格式,所以其它格式直接输出 url
if (
imageExt != 'png' &&
imageExt != 'jpeg' &&
imageExt != 'gif'
) {
sheetRow.getCell(columIndex).value = value;
continue;
}
const imageId = workbook.addImage({
base64: imageData,
extension: imageExt
});
const linkURL = getAbsoluteUrl(value);
worksheet.addImage(imageId, {
// 这里坐标位置是从 0 开始的,所以要减一
tl: {col: columIndex - 1, row: rowIndex - 1},
ext: {
width: imageDimensions.width,
height: imageDimensions.height
},
hyperlinks: {
tooltip: linkURL
}
});
} else if (type == 'link') {
const linkURL = getAbsoluteUrl(value);
sheetRow.getCell(columIndex).value = {
text: value,
hyperlink: linkURL
};
} else if (type === 'mapping') {
// 拷贝自 Mapping.tsx
const map = columnNameMap[key].map;
if (
typeof value !== 'undefined' &&
map &&
(map[value] ?? map['*'])
) {
const viewValue =
map[value] ??
(value === true && map['1']
? map['1']
: value === false && map['0']
? map['0']
: map['*']); // 兼容平台旧用法:即 value 为 true 时映射 1 ,为 false 时映射 0
sheetRow.getCell(columIndex).value = viewValue;
} else {
sheetRow.getCell(columIndex).value = value;
}
} else {
sheetRow.getCell(columIndex).value = value;
}
}
}
const buffer = await workbook.xlsx.writeBuffer();
if (buffer) {
var blob = new Blob([buffer], {
type:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
saveAs(blob, 'data.xlsx');
}
});
}}
size="sm"
>
{__('导出 Excel')}
</Button>
);
}
renderActions(region: string) {
let {actions, render, store, classnames: cx, data} = this.props;

42
src/utils/image.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* @file image
* @param url
*/
/**
* url dataurl
* @param url
*/
export const toDataURL = (url: string) => {
return new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = function () {
const reader = new FileReader();
reader.onloadend = function () {
resolve(reader.result as string);
};
reader.readAsDataURL(xhr.response);
};
xhr.onerror = reject;
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.send();
});
};
/**
* url
* @param url
*/
export const getImageDimensions = (url: string) => {
return new Promise<{width: number; height: number}>(function (
resolved,
rejected
) {
const i = new Image();
i.onload = function () {
resolved({width: i.width, height: i.height});
};
i.src = url;
});
};