feat: export plugin (#73)

* feat: add data export action in table view

* feat: add data render based on interface type for exporting

* fix: use rewrite action name for permissions

* feat: add support for subTable type field

* add datetime render

* export filter support selectedRowKeys

* docs

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2021-05-17 11:11:49 +08:00 committed by GitHub
parent 6081ee29af
commit cb8edc7b75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 418 additions and 13 deletions

View File

@ -0,0 +1,27 @@
---
title: '@nocobase/plugin-export'
---
# @nocobase/plugin-export
提供导出功能
<Alert title="注意" type="warning">
暂时只支持 excel 导出
</Alert>
## 安装
```bash
yarn nocobase pull export --start
```
## Action API
### export
参数和 list 一致,暂时只支持 excel 导出
```ts
api.resource(resourceName).export(params);
```

View File

@ -16,6 +16,7 @@
"@nocobase/plugin-automations": "^0.4.0-alpha.7", "@nocobase/plugin-automations": "^0.4.0-alpha.7",
"@nocobase/plugin-china-region": "^0.4.0-alpha.7", "@nocobase/plugin-china-region": "^0.4.0-alpha.7",
"@nocobase/plugin-collections": "^0.4.0-alpha.7", "@nocobase/plugin-collections": "^0.4.0-alpha.7",
"@nocobase/plugin-export": "^0.4.0-alpha.7",
"@nocobase/plugin-file-manager": "^0.4.0-alpha.7", "@nocobase/plugin-file-manager": "^0.4.0-alpha.7",
"@nocobase/plugin-pages": "^0.4.0-alpha.7", "@nocobase/plugin-pages": "^0.4.0-alpha.7",
"@nocobase/plugin-permissions": "^0.4.0-alpha.7", "@nocobase/plugin-permissions": "^0.4.0-alpha.7",

View File

@ -46,6 +46,7 @@ const plugins = [
'@nocobase/plugin-permissions', '@nocobase/plugin-permissions',
'@nocobase/plugin-automations', '@nocobase/plugin-automations',
'@nocobase/plugin-china-region', '@nocobase/plugin-china-region',
'@nocobase/plugin-export',
]; ];
for (const plugin of plugins) { for (const plugin of plugins) {

View File

@ -14,12 +14,12 @@ interface ActionParams {
} }
interface Resource { interface Resource {
get: (params?: ActionParams) => Promise<any>; get: (params?: ActionParams, options?: any) => Promise<any>;
list: (params?: ActionParams) => Promise<any>; list: (params?: ActionParams, options?: any) => Promise<any>;
create: (params?: ActionParams) => Promise<any>; create: (params?: ActionParams, options?: any) => Promise<any>;
update: (params?: ActionParams) => Promise<any>; update: (params?: ActionParams, options?: any) => Promise<any>;
destroy: (params?: ActionParams) => Promise<any>; destroy: (params?: ActionParams, options?: any) => Promise<any>;
[name: string]: (params?: ActionParams) => Promise<any>; [name: string]: (params?: ActionParams, options?: any) => Promise<any>;
} }
// TODO 待改进,提供一个封装度更完整的 SDKrequest 可以自由替换 // TODO 待改进,提供一个封装度更完整的 SDKrequest 可以自由替换
@ -29,7 +29,7 @@ class ApiClient {
{}, {},
{ {
get(target, method, receiver) { get(target, method, receiver) {
return (params: ActionParams = {}) => { return (params: ActionParams = {}, options: any = {}) => {
let { let {
associatedKey, associatedKey,
resourceKey, resourceKey,
@ -41,15 +41,11 @@ class ApiClient {
} = params; } = params;
let url = `/${name}`; let url = `/${name}`;
sort = sort || []; sort = sort || [];
let options: any = { options.params = restParams;
params: {}, if (['list', 'get', 'export'].indexOf(method as string) !== -1) {
};
if (['list', 'get'].indexOf(method as string) !== -1) {
options.method = 'get'; options.method = 'get';
options.params = restParams;
} else { } else {
options.method = 'post'; options.method = 'post';
options.params = restParams;
options.data = values; options.data = values;
} }
if (associatedKey) { if (associatedKey) {

View File

@ -258,6 +258,52 @@ export function Table(props: any) {
}, },
); );
const exportRequest = useRequest(({ sorter, filter } = {}) => {
return api
.resource(resourceName)
.export({
associatedKey,
perPage: -1,
sorter,
sort,
'fields[appends]': appends,
filter: {
and: [
defaultFilter,
schemaFilter,
filter,
].filter(obj => obj && Object.keys(obj).length),
},
}, {
parseResponse: false,
responseType: 'blob'
})
.then(async response => {
// decodeURI() for encoded filename in server side
const filename = decodeURI(response.headers.get('Content-Disposition').replace('attachment; filename=', ''));
// ReadableStream
let res = new Response(response.body);
let blob = await res.blob();
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
// cleanup
URL.revokeObjectURL(url);
document.body.removeChild(a);
a = null;
blob = null;
url = null;
res = null;
});
}, {
manual: true,
paginated: false
});
const currentRow = find( const currentRow = find(
data && data.list, data && data.list,
item => item[rowKey] == currentRowId, item => item[rowKey] == currentRowId,
@ -433,6 +479,14 @@ export function Table(props: any) {
refresh(); refresh();
await reloadMenu(); await reloadMenu();
}, },
async export() {
exportRequest.run({
...params[0],
filter: selectedRowKeys.length ? {
[`${rowKey}.in`]: selectedRowKeys,
} : {},
});
}
}} }}
actions={actions} actions={actions}
style={{ marginBottom: 14 }} style={{ marginBottom: 14 }}

View File

@ -0,0 +1,7 @@
node_modules
*.log
docs
__tests__
tsconfig.json
src
.fatherrc.ts

View File

@ -0,0 +1,19 @@
{
"name": "@nocobase/plugin-export",
"version": "0.4.0-alpha.7",
"main": "lib/index.js",
"license": "MIT",
"devDependencies": {
"@types/node-xlsx": "^0.15.1"
},
"dependencies": {
"@nocobase/actions": "^0.4.0-alpha.7",
"@nocobase/database": "^0.4.0-alpha.7",
"@nocobase/resourcer": "^0.4.0-alpha.7",
"node-xlsx": "^0.16.1"
},
"peerDependencies": {
"@nocobase/plugin-collections": "*",
"@nocobase/plugin-permissions": "*"
}
}

View File

@ -0,0 +1,50 @@
import xlsx from 'node-xlsx';
import { actions } from '@nocobase/actions';
import render from '../renders';
async function _export(ctx, next) {
await actions.common.list(ctx, async () => {
const {
db,
action: {
params: {
resourceName
}
},
body,
} = ctx;
const table = db.getTable(resourceName);
const tableOptions = table.getOptions();
const fields = body.rows.length
? Object.keys(body.rows[0].get())
.map(key => tableOptions.fields.find(({ name }) => name === key))
.filter(item => item && !item.developerMode)
.sort((a, b) => a.sort - b.sort)
: [];
const { rows, ranges } = render({
fields,
data: body.rows
}, ctx);
ctx.body = xlsx.build([{
name: tableOptions.title,
data: rows,
options: {
'!merges': ranges
}
}]);
ctx.set({
'Content-Type': 'application/octet-stream',
// to avoid "invalid character" error in header (RFC)
'Content-Disposition': `attachment; filename=${encodeURI(tableOptions.title)}.xlsx`
});
});
await next();
}
export default _export;

View File

@ -0,0 +1,162 @@
import * as renders from './renders';
function getInterfaceRender(name: string): Function {
return renders[name] || renders._;
}
function renderHeader(params, ctx) {
const {
fields,
headers = [],
rowIndex = 0
} = params;
let { colIndex = 0 } = params;
if (!headers[rowIndex]) {
headers.push([]);
}
const row = headers[rowIndex];
fields.forEach((field, i) => {
const nextColIndex = colIndex + i;
row.push({
field,
rowIndex,
colIndex: nextColIndex
});
if (field.interface === 'subTable') {
const subTable = ctx.db.getTable(field.target);
const subFields = subTable.getOptions().fields.filter(field => Boolean(field.__index));
renderHeader({
fields: subFields,
headers,
rowIndex: rowIndex + 1,
colIndex: nextColIndex
}, ctx);
colIndex += subFields.length;
}
});
Object.assign(params, { headers });
}
function renderRows({ fields, data }, ctx) {
return data.reduce((result, row) => {
const thisRow = [];
const rowIndex = 0;
let colOffset = 0;
fields.forEach((field, i) => {
if (!thisRow[rowIndex]) {
thisRow.push([]);
}
const cells = thisRow[rowIndex];
if (field.interface !== 'subTable') {
const render = getInterfaceRender(field.interface);
cells.push({
value: render(field, row),
rowIndex: result.length + rowIndex,
colIndex: i + colOffset
});
return;
}
const subTable = ctx.db.getTable(field.target);
const subFields = subTable.getOptions().fields.filter(item => Boolean(item.__index));
const subRows = renderRows({ fields: subFields, data: row.get(field.name) || [] }, ctx);
// const { rows: subRowGroups } = subTableRows;
subRows.forEach((cells, j) => {
const subRowIndex = rowIndex + j;
if (!thisRow[subRowIndex]) {
thisRow.push([]);
}
const subCells = thisRow[subRowIndex];
subCells.push(...cells.map(cell => ({
...cell,
rowIndex: result.length + subRowIndex,
colIndex: cell.colIndex + i
})));
});
colOffset += subFields.length;
});
thisRow.forEach((cells) => {
cells.forEach(cell => {
const relRowIndex = cell.rowIndex - result.length;
Object.assign(cell, {
rowSpan: relRowIndex >= thisRow.length - 1
|| thisRow[relRowIndex + 1].find(item => item.colIndex === cell.colIndex)
? 1
: thisRow.length - relRowIndex
});
});
});
return result.concat(thisRow);
}, []);
}
export default function({ fields, data }, ctx) {
const headers = [];
renderHeader({
fields,
headers
}, ctx);
const ranges = [];
// 计算全表最大的列索引(由于无论如何最大列都是单个单元格,所以等价于长度)
const maxColIndex = Math.max(...headers.map(row => row[row.length - 1].colIndex));
// 遍历所有单元格,计算需要合并的坐标范围
headers.forEach((row, rowIndex) => {
row.forEach((cell, cellIndex) => {
// 跨行合并的行数为
cell.rowSpan = cell.rowIndex >= headers.length - 1
|| headers[cell.rowIndex + 1].find(item => item.colIndex === cell.colIndex)
? 1
: headers.length - cell.rowIndex;
const nextCell = headers.slice(0, rowIndex + 1)
.map(r => r.find(item => item.colIndex > cell.colIndex))
.filter(c => Boolean(c))
.reduce((min, c) => min && Math.min(min.colIndex, c.colIndex) === min.colIndex ? min : c, null);
cell.colSpan = nextCell
? nextCell.colIndex - cell.colIndex
: maxColIndex - cell.colIndex + 1;
if (cell.rowSpan > 1 || cell.colSpan > 1) {
ranges.push({
s: { c: cell.colIndex, r: cell.rowIndex },
e: { c: cell.colIndex + cell.colSpan - 1, r: cell.rowIndex + cell.rowSpan - 1 }
});
}
});
});
const rows = renderRows({ fields, data }, ctx)
.map(row => {
const cells = Array(maxColIndex).fill(null);
row.forEach(cell => {
cells.splice(cell.colIndex, 1, cell.value);
if (cell.rowSpan > 1) {
ranges.push({
s: { c: cell.colIndex, r: cell.rowIndex + headers.length },
e: { c: cell.colIndex, r: cell.rowIndex + cell.rowSpan - 1 + headers.length }
});
}
});
return cells;
});
return {
rows: [
...headers.map(row => {
// 补齐无数据单元格,以供合并
const cells = Array(maxColIndex).fill(null);
row.forEach(cell => cells.splice(cell.colIndex, 1, cell.field.title))
return cells;
}),
...rows
],
ranges
};
}

View File

@ -0,0 +1,60 @@
import moment from 'moment';
export function _(field, row) {
return row.get(field.name);
}
export function datetime(field, row) {
const value = row.get(field.name);
return moment(value).format(field.showTime ? `${field.dateFormat} ${field.timeFormat}` : field.dateFormat);
}
export function percent(field, row) {
const value = row.get(field.name);
return value && `${value}%`;
}
export function boolean(field, row) {
const value = row.get(field.name);
// TODO(feature): i18n
return value ? '是' : '否';
}
export function select(field, row) {
const value = row.get(field.name);
const option = field.dataSource.find(item => item.value === value);
return option && option.label;
}
export function multipleSelect(field, row) {
const values = row.get(field.name);
return values && values.map(value => {
const option = field.dataSource.find(item => item.value === value);
return option && option.label;
});
}
export const radio = select;
export const checkboxes = multipleSelect;
export function subTable(field, row) {
// TODO: need title field to be defined
return (row.get(field.name) || []).map(item => item[field.sourceKey]);
}
export function linkTo(field, row) {
return (row.get(field.name) || []).map(item => item[field.labelField]);
}
export function attachment(field, row) {
return (row.get(field.name) || []).map(item => item[field.url]).join(' ');
}
export function chinaRegion(field, row) {
const value = row.get(field.name);
const values = (Array.isArray(value) ? value : [value]).sort((a, b) =>
a.level !== b.level ? a.level - b.level : a.sort - b.sort,
);
return values.map(item => item.name).join('/');
}

View File

@ -0,0 +1,28 @@
import Resourcer from '@nocobase/resourcer';
import _export from './actions/export';
export const ACTION_NAME_EXPORT = 'export';
export default async function (options = {}) {
const resourcer: Resourcer = this.resourcer;
resourcer.registerActionHandler(ACTION_NAME_EXPORT, _export);
// TODO(temp): 继承 list 权限的临时写法
resourcer.use(async (ctx, next) => {
if (ctx.action.params.actionName === ACTION_NAME_EXPORT) {
ctx.action.mergeParams({
actionName: 'list'
});
console.log('action name in export has been rewritten to:', ctx.action.params.actionName);
const permissionPlugin = ctx.app.getPluginInstance('@nocobase/plugin-permissions');
if (permissionPlugin) {
return permissionPlugin.middleware(ctx, next);
}
}
await next();
});
}