diff --git a/docs/plugins/packages/export.md b/docs/plugins/packages/export.md
new file mode 100644
index 000000000..e93072f06
--- /dev/null
+++ b/docs/plugins/packages/export.md
@@ -0,0 +1,27 @@
+---
+title: '@nocobase/plugin-export'
+---
+
+# @nocobase/plugin-export
+
+提供导出功能
+
+
+暂时只支持 excel 导出
+
+
+## 安装
+
+```bash
+yarn nocobase pull export --start
+```
+
+## Action API
+
+### export
+
+参数和 list 一致,暂时只支持 excel 导出
+
+```ts
+api.resource(resourceName).export(params);
+```
diff --git a/packages/api/package.json b/packages/api/package.json
index 504f48d4c..fd56eba67 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -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",
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index e56d5f4d6..687e0f468 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -46,6 +46,7 @@ const plugins = [
'@nocobase/plugin-permissions',
'@nocobase/plugin-automations',
'@nocobase/plugin-china-region',
+ '@nocobase/plugin-export',
];
for (const plugin of plugins) {
diff --git a/packages/app/src/api-client.ts b/packages/app/src/api-client.ts
index f8e6bc9a3..b3e190dcd 100644
--- a/packages/app/src/api-client.ts
+++ b/packages/app/src/api-client.ts
@@ -14,12 +14,12 @@ interface ActionParams {
}
interface Resource {
- get: (params?: ActionParams) => Promise;
- list: (params?: ActionParams) => Promise;
- create: (params?: ActionParams) => Promise;
- update: (params?: ActionParams) => Promise;
- destroy: (params?: ActionParams) => Promise;
- [name: string]: (params?: ActionParams) => Promise;
+ get: (params?: ActionParams, options?: any) => Promise;
+ list: (params?: ActionParams, options?: any) => Promise;
+ create: (params?: ActionParams, options?: any) => Promise;
+ update: (params?: ActionParams, options?: any) => Promise;
+ destroy: (params?: ActionParams, options?: any) => Promise;
+ [name: string]: (params?: ActionParams, options?: any) => Promise;
}
// 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.params = restParams;
+ if (['list', 'get', 'export'].indexOf(method as string) !== -1) {
options.method = 'get';
- options.params = restParams;
} else {
options.method = 'post';
- options.params = restParams;
options.data = values;
}
if (associatedKey) {
diff --git a/packages/app/src/components/views/Table.tsx b/packages/app/src/components/views/Table.tsx
index 92e82c17c..b18223386 100644
--- a/packages/app/src/components/views/Table.tsx
+++ b/packages/app/src/components/views/Table.tsx
@@ -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 }}
diff --git a/packages/plugin-export/.npmignore b/packages/plugin-export/.npmignore
new file mode 100644
index 000000000..461574b2f
--- /dev/null
+++ b/packages/plugin-export/.npmignore
@@ -0,0 +1,7 @@
+node_modules
+*.log
+docs
+__tests__
+tsconfig.json
+src
+.fatherrc.ts
\ No newline at end of file
diff --git a/packages/plugin-export/package.json b/packages/plugin-export/package.json
new file mode 100644
index 000000000..c737c1623
--- /dev/null
+++ b/packages/plugin-export/package.json
@@ -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": "*"
+ }
+}
diff --git a/packages/plugin-export/src/actions/export.ts b/packages/plugin-export/src/actions/export.ts
new file mode 100644
index 000000000..ae4509757
--- /dev/null
+++ b/packages/plugin-export/src/actions/export.ts
@@ -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;
diff --git a/packages/plugin-export/src/renders/index.ts b/packages/plugin-export/src/renders/index.ts
new file mode 100644
index 000000000..e5e104faf
--- /dev/null
+++ b/packages/plugin-export/src/renders/index.ts
@@ -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
+ };
+}
diff --git a/packages/plugin-export/src/renders/renders.ts b/packages/plugin-export/src/renders/renders.ts
new file mode 100644
index 000000000..a8da3a0e3
--- /dev/null
+++ b/packages/plugin-export/src/renders/renders.ts
@@ -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('/');
+}
diff --git a/packages/plugin-export/src/server.ts b/packages/plugin-export/src/server.ts
new file mode 100644
index 000000000..42c4db145
--- /dev/null
+++ b/packages/plugin-export/src/server.ts
@@ -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();
+ });
+}