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-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",
|
||||||
|
@ -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) {
|
||||||
|
@ -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 待改进,提供一个封装度更完整的 SDK,request 可以自由替换
|
// TODO 待改进,提供一个封装度更完整的 SDK,request 可以自由替换
|
||||||
@ -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 = {
|
|
||||||
params: {},
|
|
||||||
};
|
|
||||||
if (['list', 'get'].indexOf(method as string) !== -1) {
|
|
||||||
options.method = 'get';
|
|
||||||
options.params = restParams;
|
options.params = restParams;
|
||||||
|
if (['list', 'get', 'export'].indexOf(method as string) !== -1) {
|
||||||
|
options.method = 'get';
|
||||||
} else {
|
} else {
|
||||||
options.method = 'post';
|
options.method = 'post';
|
||||||
options.params = restParams;
|
|
||||||
options.data = values;
|
options.data = values;
|
||||||
}
|
}
|
||||||
if (associatedKey) {
|
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(
|
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 }}
|
||||||
|
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