From cb8edc7b75036de015415b484a9568347b89c91d Mon Sep 17 00:00:00 2001 From: Junyi Date: Mon, 17 May 2021 11:11:49 +0800 Subject: [PATCH] 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 --- docs/plugins/packages/export.md | 27 +++ packages/api/package.json | 1 + packages/api/src/app.ts | 1 + packages/app/src/api-client.ts | 22 +-- packages/app/src/components/views/Table.tsx | 54 ++++++ packages/plugin-export/.npmignore | 7 + packages/plugin-export/package.json | 19 ++ packages/plugin-export/src/actions/export.ts | 50 ++++++ packages/plugin-export/src/renders/index.ts | 162 ++++++++++++++++++ packages/plugin-export/src/renders/renders.ts | 60 +++++++ packages/plugin-export/src/server.ts | 28 +++ 11 files changed, 418 insertions(+), 13 deletions(-) create mode 100644 docs/plugins/packages/export.md create mode 100644 packages/plugin-export/.npmignore create mode 100644 packages/plugin-export/package.json create mode 100644 packages/plugin-export/src/actions/export.ts create mode 100644 packages/plugin-export/src/renders/index.ts create mode 100644 packages/plugin-export/src/renders/renders.ts create mode 100644 packages/plugin-export/src/server.ts 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(); + }); +}