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:
parent
6081ee29af
commit
cb8edc7b75
27
docs/plugins/packages/export.md
Normal file
27
docs/plugins/packages/export.md
Normal 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);
|
||||
```
|
@ -16,6 +16,7 @@
|
||||
"@nocobase/plugin-automations": "^0.4.0-alpha.7",
|
||||
"@nocobase/plugin-china-region": "^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-pages": "^0.4.0-alpha.7",
|
||||
"@nocobase/plugin-permissions": "^0.4.0-alpha.7",
|
||||
|
@ -46,6 +46,7 @@ const plugins = [
|
||||
'@nocobase/plugin-permissions',
|
||||
'@nocobase/plugin-automations',
|
||||
'@nocobase/plugin-china-region',
|
||||
'@nocobase/plugin-export',
|
||||
];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
|
@ -14,12 +14,12 @@ interface ActionParams {
|
||||
}
|
||||
|
||||
interface Resource {
|
||||
get: (params?: ActionParams) => Promise<any>;
|
||||
list: (params?: ActionParams) => Promise<any>;
|
||||
create: (params?: ActionParams) => Promise<any>;
|
||||
update: (params?: ActionParams) => Promise<any>;
|
||||
destroy: (params?: ActionParams) => Promise<any>;
|
||||
[name: string]: (params?: ActionParams) => Promise<any>;
|
||||
get: (params?: ActionParams, options?: any) => Promise<any>;
|
||||
list: (params?: ActionParams, options?: any) => Promise<any>;
|
||||
create: (params?: ActionParams, options?: any) => Promise<any>;
|
||||
update: (params?: ActionParams, options?: any) => Promise<any>;
|
||||
destroy: (params?: ActionParams, options?: any) => Promise<any>;
|
||||
[name: string]: (params?: ActionParams, options?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
// TODO 待改进,提供一个封装度更完整的 SDK,request 可以自由替换
|
||||
@ -29,7 +29,7 @@ class ApiClient {
|
||||
{},
|
||||
{
|
||||
get(target, method, receiver) {
|
||||
return (params: ActionParams = {}) => {
|
||||
return (params: ActionParams = {}, options: any = {}) => {
|
||||
let {
|
||||
associatedKey,
|
||||
resourceKey,
|
||||
@ -41,15 +41,11 @@ class ApiClient {
|
||||
} = params;
|
||||
let url = `/${name}`;
|
||||
sort = sort || [];
|
||||
let options: any = {
|
||||
params: {},
|
||||
};
|
||||
if (['list', 'get'].indexOf(method as string) !== -1) {
|
||||
options.method = 'get';
|
||||
options.params = restParams;
|
||||
if (['list', 'get', 'export'].indexOf(method as string) !== -1) {
|
||||
options.method = 'get';
|
||||
} else {
|
||||
options.method = 'post';
|
||||
options.params = restParams;
|
||||
options.data = values;
|
||||
}
|
||||
if (associatedKey) {
|
||||
|
@ -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(
|
||||
data && data.list,
|
||||
item => item[rowKey] == currentRowId,
|
||||
@ -433,6 +479,14 @@ export function Table(props: any) {
|
||||
refresh();
|
||||
await reloadMenu();
|
||||
},
|
||||
async export() {
|
||||
exportRequest.run({
|
||||
...params[0],
|
||||
filter: selectedRowKeys.length ? {
|
||||
[`${rowKey}.in`]: selectedRowKeys,
|
||||
} : {},
|
||||
});
|
||||
}
|
||||
}}
|
||||
actions={actions}
|
||||
style={{ marginBottom: 14 }}
|
||||
|
7
packages/plugin-export/.npmignore
Normal file
7
packages/plugin-export/.npmignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
*.log
|
||||
docs
|
||||
__tests__
|
||||
tsconfig.json
|
||||
src
|
||||
.fatherrc.ts
|
19
packages/plugin-export/package.json
Normal file
19
packages/plugin-export/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
50
packages/plugin-export/src/actions/export.ts
Normal file
50
packages/plugin-export/src/actions/export.ts
Normal 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;
|
162
packages/plugin-export/src/renders/index.ts
Normal file
162
packages/plugin-export/src/renders/index.ts
Normal 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
|
||||
};
|
||||
}
|
60
packages/plugin-export/src/renders/renders.ts
Normal file
60
packages/plugin-export/src/renders/renders.ts
Normal 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('/');
|
||||
}
|
28
packages/plugin-export/src/server.ts
Normal file
28
packages/plugin-export/src/server.ts
Normal 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();
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user