refactor: export plugin (#1460)

* refactor: export plugin

* fix: improve code
This commit is contained in:
chenos 2023-02-16 16:48:00 +08:00 committed by GitHub
parent 2f8954b70f
commit a6aec25343
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 575 additions and 168 deletions

View File

@ -216,7 +216,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
}); });
// register database field types // register database field types
for (const [name, field] of Object.entries(FieldTypes)) { for (const [name, field] of Object.entries<any>(FieldTypes)) {
if (['Field', 'RelationField'].includes(name)) { if (['Field', 'RelationField'].includes(name)) {
continue; continue;
} }

View File

@ -1,5 +1,5 @@
import { BaseColumnFieldOptions, Field } from './field';
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field, ValueParser } from './field';
export class ArrayField extends Field { export class ArrayField extends Field {
get dataType() { get dataType() {
@ -28,6 +28,51 @@ export class ArrayField extends Field {
super.unbind(); super.unbind();
this.off('beforeSave', this.sortValue); this.off('beforeSave', this.sortValue);
} }
buildValueParser(ctx: any) {
return new ArrayValueParser(this, ctx);
}
}
export class ArrayValueParser extends ValueParser {
async setValue(value: any) {
const { map, set } = this.getOptions();
const values = this.toArr(value);
if (set.size > 0) {
const filtered = values.map((v) => (map.has(v) ? map.get(v) : v)).filter((v) => set.has(v));
if (values.length === filtered.length) {
this.value = filtered;
} else {
this.errors.push('No matching option found');
}
} else {
this.value = values;
}
}
getOptions() {
const options = this.field.options?.['uiSchema']?.enum || [];
const map = new Map();
const set = new Set();
for (const option of options) {
set.add(option.value);
set.add(option.label);
map.set(option.label, option.value);
}
return { map, set };
}
toArr(value) {
let values = [];
if (!value) {
values = [];
} else if (typeof value === 'string') {
values = value.split(',');
} else if (Array.isArray(value)) {
values = value;
}
return values;
}
} }
export interface ArrayFieldOptions extends BaseColumnFieldOptions { export interface ArrayFieldOptions extends BaseColumnFieldOptions {

View File

@ -1,7 +1,8 @@
import lodash, { omit } from 'lodash'; import { omit } from 'lodash';
import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize';
import { Reference } from '../features/ReferencesMap'; import { Reference } from '../features/ReferencesMap';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { ToOneValueParser } from './has-one-field';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
export class BelongsToField extends RelationField { export class BelongsToField extends RelationField {
@ -115,6 +116,10 @@ export class BelongsToField extends RelationField {
collection.model.refreshAttributes(); collection.model.refreshAttributes();
// this.collection.removeIndex([this.options.foreignKey]); // this.collection.removeIndex([this.options.foreignKey]);
} }
buildValueParser(ctx: any) {
return new ToOneValueParser(this, ctx);
}
} }
export interface BelongsToFieldOptions extends BaseRelationFieldOptions, SequelizeBelongsToOptions { export interface BelongsToFieldOptions extends BaseRelationFieldOptions, SequelizeBelongsToOptions {

View File

@ -2,8 +2,10 @@ import { omit } from 'lodash';
import { BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize'; import { BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Reference } from '../features/ReferencesMap'; import { Reference } from '../features/ReferencesMap';
import { Repository } from '../repository';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { BelongsToField } from './belongs-to-field'; import { BelongsToField } from './belongs-to-field';
import { ValueParser } from './field';
import { MultipleRelationFieldOptions, RelationField } from './relation-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field';
export class BelongsToManyField extends RelationField { export class BelongsToManyField extends RelationField {
@ -123,6 +125,48 @@ export class BelongsToManyField extends RelationField {
this.clearAccessors(); this.clearAccessors();
delete collection.model.associations[this.name]; delete collection.model.associations[this.name];
} }
buildValueParser(ctx: any) {
return new ToManyValueParser(this, ctx);
}
}
export class ToManyValueParser extends ValueParser {
async setValue(value: any) {
const fieldNames = this.getFileNames();
console.log('fieldNames', fieldNames, this.ctx.column);
if (this.isInterface('chinaRegion')) {
const repository = this.field.database.getRepository(this.field.target) as Repository;
this.value = await Promise.all(
value.split('/').map(async (v) => {
const instance = await repository.findOne({ filter: { [fieldNames.label]: v.trim() } });
return instance ? instance.get(fieldNames.value) : v;
}),
);
} else {
const dataIndex = this.ctx?.column?.dataIndex || [];
if (Array.isArray(dataIndex) && dataIndex.length < 2) {
return;
}
const field = this.ctx.column.dataIndex[1];
const repository = this.field.database.getRepository(this.field.target) as Repository;
this.value = await Promise.all(
value.split(',').map(async (v) => {
const instance = await repository.findOne({ filter: { [field]: v.trim() } });
return instance ? instance.get(fieldNames.value) : v;
}),
);
}
}
getFileNames() {
const fieldNames = this.field.options?.uiSchema?.['x-component-props']?.['fieldNames'] || {};
return { label: 'id', value: 'id', ...fieldNames };
}
isInterface(name) {
return this.field.options.interface === name;
}
} }
export interface BelongsToManyFieldOptions export interface BelongsToManyFieldOptions

View File

@ -1,10 +1,42 @@
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field'; import { BaseColumnFieldOptions, Field, ValueParser } from './field';
export class BooleanField extends Field { export class BooleanField extends Field {
get dataType() { get dataType() {
return DataTypes.BOOLEAN; return DataTypes.BOOLEAN;
} }
buildValueParser(ctx: any) {
return new BooleanValueParser(this, ctx);
}
}
export class BooleanValueParser extends ValueParser {
async setValue(value: any) {
// Boolean
if (typeof value === 'boolean') {
this.value = value;
}
// Number
else if (typeof value === 'number' && [0, 1].includes(value)) {
this.value = value === 1;
}
// String
else if (typeof value === 'string') {
if (!value) {
this.value = null;
}
if (['1', 'y', 'yes', 'true', '是'].includes(value.toLowerCase())) {
this.value = true;
} else if (['0', 'n', 'no', 'false', '否'].includes(value.toLowerCase())) {
this.value = false;
} else {
this.errors.push(`${JSON.stringify(this.value)} value invalid`);
}
} else {
this.errors.push(`${JSON.stringify(this.value)} value invalid`);
}
}
} }
export interface BooleanFieldOptions extends BaseColumnFieldOptions { export interface BooleanFieldOptions extends BaseColumnFieldOptions {

View File

@ -1,10 +1,38 @@
import { moment2str } from '@nocobase/utils';
import moment, { isDate, isMoment } from 'moment';
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field'; import { BaseColumnFieldOptions, Field, ValueParser } from './field';
export class DateField extends Field { export class DateField extends Field {
get dataType() { get dataType() {
return DataTypes.DATE(3); return DataTypes.DATE(3);
} }
buildValueParser(ctx: any) {
return new DateValueParser(this, ctx);
}
}
export class DateValueParser extends ValueParser {
async setValue(value: any) {
if (isMoment(value)) {
this.value = value;
} else if (isDate(value)) {
this.value = value;
} else if (typeof value === 'string') {
const props = this.getProps();
const m = moment(value);
if (m.isValid()) {
this.value = moment2str(m, props);
} else {
this.errors.push('Invalid date');
}
}
}
getProps() {
return this.field.options?.uiSchema?.['x-component-props'] || {};
}
} }
export interface DateFieldOptions extends BaseColumnFieldOptions { export interface DateFieldOptions extends BaseColumnFieldOptions {

View File

@ -40,9 +40,9 @@ export abstract class Field {
[key: string]: any; [key: string]: any;
constructor(options?: any, context?: FieldContext) { constructor(options?: any, context?: FieldContext) {
this.context = context; this.context = context as any;
this.database = context.database; this.database = this.context.database;
this.collection = context.collection; this.collection = this.context.collection;
this.options = options || {}; this.options = options || {};
this.init(); this.init();
} }
@ -138,7 +138,7 @@ export abstract class Field {
// 排序字段通过 sortable 控制 // 排序字段通过 sortable 控制
const sortable = this.collection.options.sortable; const sortable = this.collection.options.sortable;
if (sortable) { if (sortable) {
let sortField: string; let sortField: any;
if (sortable === true) { if (sortable === true) {
sortField = 'sort'; sortField = 'sort';
} else if (typeof sortable === 'string') { } else if (typeof sortable === 'string') {
@ -238,4 +238,33 @@ export abstract class Field {
typeToString() { typeToString() {
return this.dataType.toString(); return this.dataType.toString();
} }
buildValueParser(ctx: any) {
return new ValueParser(this, ctx);
}
}
export class ValueParser {
ctx: any;
field: any;
value: any;
errors = [];
constructor(field: any, ctx: any) {
this.field = field;
this.ctx = ctx;
}
toString() {
return this.value;
}
getValue() {
return this.value;
}
async setValue(value: any) {
console.log(this.field.name, value);
this.value = value;
}
} }

View File

@ -5,11 +5,12 @@ import {
ForeignKeyOptions, ForeignKeyOptions,
HasManyOptions, HasManyOptions,
HasManyOptions as SequelizeHasManyOptions, HasManyOptions as SequelizeHasManyOptions,
Utils, Utils
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Reference } from '../features/ReferencesMap'; import { Reference } from '../features/ReferencesMap';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { ToManyValueParser } from './belongs-to-many-field';
import { MultipleRelationFieldOptions, RelationField } from './relation-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field';
export interface HasManyFieldOptions extends HasManyOptions { export interface HasManyFieldOptions extends HasManyOptions {
@ -185,6 +186,10 @@ export class HasManyField extends RelationField {
// @ts-ignore // @ts-ignore
collection.model.refreshAttributes(); collection.model.refreshAttributes();
} }
buildValueParser(ctx: any) {
return new ToManyValueParser(this, ctx);
}
} }
export interface HasManyFieldOptions extends MultipleRelationFieldOptions, SequelizeHasManyOptions { export interface HasManyFieldOptions extends MultipleRelationFieldOptions, SequelizeHasManyOptions {

View File

@ -1,16 +1,18 @@
import lodash, { omit } from 'lodash'; import { omit } from 'lodash';
import { import {
AssociationScope, AssociationScope,
DataType, DataType,
ForeignKeyOptions, ForeignKeyOptions,
HasOneOptions, HasOneOptions,
HasOneOptions as SequelizeHasOneOptions, HasOneOptions as SequelizeHasOneOptions,
Utils, Utils
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { checkIdentifier, snakeCase } from '../utils';
import { BaseRelationFieldOptions, RelationField } from './relation-field';
import { Reference } from '../features/ReferencesMap'; import { Reference } from '../features/ReferencesMap';
import { Repository } from '../repository';
import { checkIdentifier } from '../utils';
import { ValueParser } from './field';
import { BaseRelationFieldOptions, RelationField } from './relation-field';
export interface HasOneFieldOptions extends HasOneOptions { export interface HasOneFieldOptions extends HasOneOptions {
/** /**
@ -191,6 +193,36 @@ export class HasOneField extends RelationField {
// @ts-ignore // @ts-ignore
collection.model.refreshAttributes(); collection.model.refreshAttributes();
} }
buildValueParser(ctx: any) {
return new ToOneValueParser(this, ctx);
}
}
export class ToOneValueParser extends ValueParser {
async setValue(value: any) {
const fieldNames = this.getFileNames();
console.log('fieldNames', fieldNames, this.ctx.column);
const dataIndex = this.ctx?.column?.dataIndex || [];
if (Array.isArray(dataIndex) && dataIndex.length < 2) {
return;
}
const field = this.ctx.column.dataIndex[1];
const repository = this.field.database.getRepository(this.field.target) as Repository;
const instance = await repository.findOne({ filter: { [field]: value.trim() } });
if (instance) {
this.value = instance.get(fieldNames.value);
}
}
getFileNames() {
const fieldNames = this.field.options?.uiSchema?.['x-component-props']?.['fieldNames'] || {};
return { label: 'id', value: 'id', ...fieldNames };
}
isInterface(name) {
return this.field.options.interface === name;
}
} }
export interface HasOneFieldOptions extends BaseRelationFieldOptions, SequelizeHasOneOptions { export interface HasOneFieldOptions extends BaseRelationFieldOptions, SequelizeHasOneOptions {

View File

@ -1,7 +1,44 @@
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field'; import { BaseColumnFieldOptions, Field, ValueParser } from './field';
export class IntegerField extends Field { abstract class NumberField extends Field {
buildValueParser(ctx: any) {
return new NumberValueParser(this, ctx);
}
}
function percent2float(value: string) {
const index = value.indexOf('.');
if (index === -1) {
return parseFloat(value) / 100;
}
const repeat = value.length - index - 2;
const v = parseInt('1' + '0'.repeat(repeat));
return (parseFloat(value) * v) / (100 * v);
}
export class NumberValueParser extends ValueParser {
async setValue(value: any) {
if (value === null || value === undefined || typeof value === 'number') {
this.value = value;
}
if (typeof value === 'string') {
if (!value) {
this.value = null;
} else if (['n/a', '-'].includes(value.toLowerCase())) {
this.value = null;
} else if (value.endsWith('%')) {
this.value = percent2float(value);
console.log(value, this.value);
} else {
const val = +value;
this.value = isNaN(val) ? null : val;
}
}
}
}
export class IntegerField extends NumberField {
get dataType() { get dataType() {
return DataTypes.INTEGER; return DataTypes.INTEGER;
} }
@ -11,7 +48,7 @@ export interface IntegerFieldOptions extends BaseColumnFieldOptions {
type: 'integer'; type: 'integer';
} }
export class BigIntField extends Field { export class BigIntField extends NumberField {
get dataType() { get dataType() {
return DataTypes.BIGINT; return DataTypes.BIGINT;
} }
@ -21,7 +58,7 @@ export interface BigIntFieldOptions extends BaseColumnFieldOptions {
type: 'bigInt'; type: 'bigInt';
} }
export class FloatField extends Field { export class FloatField extends NumberField {
get dataType() { get dataType() {
return DataTypes.FLOAT; return DataTypes.FLOAT;
} }
@ -31,7 +68,7 @@ export interface FloatFieldOptions extends BaseColumnFieldOptions {
type: 'float'; type: 'float';
} }
export class DoubleField extends Field { export class DoubleField extends NumberField {
get dataType() { get dataType() {
return DataTypes.DOUBLE; return DataTypes.DOUBLE;
} }
@ -41,7 +78,7 @@ export interface DoubleFieldOptions extends BaseColumnFieldOptions {
type: 'double'; type: 'double';
} }
export class RealField extends Field { export class RealField extends NumberField {
get dataType() { get dataType() {
return DataTypes.REAL; return DataTypes.REAL;
} }
@ -51,7 +88,7 @@ export interface RealFieldOptions extends BaseColumnFieldOptions {
type: 'real'; type: 'real';
} }
export class DecimalField extends Field { export class DecimalField extends NumberField {
get dataType() { get dataType() {
return DataTypes.DECIMAL; return DataTypes.DECIMAL;
} }

View File

@ -1,10 +1,44 @@
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field'; import { BaseColumnFieldOptions, Field, ValueParser } from './field';
export class StringField extends Field { export class StringField extends Field {
get dataType() { get dataType() {
return DataTypes.STRING; return DataTypes.STRING;
} }
buildValueParser(ctx: any) {
return new StringValueParser(this, ctx);
}
}
export class StringValueParser extends ValueParser {
async setValue(value: any) {
const { map, set } = this.getOptions();
if (set.size > 0) {
if (map.has(value)) {
value = map.get(value);
}
if (set.has(value)) {
this.value = value;
} else {
this.errors.push('No matching option found');
}
} else {
this.value = value;
}
}
getOptions() {
const options = this.field.options?.['uiSchema']?.enum || [];
const map = new Map();
const set = new Set();
for (const option of options) {
set.add(option.value);
set.add(option.label);
map.set(option.label, option.value);
}
return { map, set };
}
} }
export interface StringFieldOptions extends BaseColumnFieldOptions { export interface StringFieldOptions extends BaseColumnFieldOptions {

View File

@ -76,3 +76,65 @@ export const str2moment = (value?: string | string[], options: Str2momentOptions
? toMoment(value, options) ? toMoment(value, options)
: value; : value;
}; };
const toStringByPicker = (value, picker) => {
if (picker === 'year') {
return value.format('YYYY') + '-01-01T00:00:00.000Z';
}
if (picker === 'month') {
return value.format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'quarter') {
return value.format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'week') {
return value.format('YYYY-MM-DD') + 'T00:00:00.000Z';
}
return value.format('YYYY-MM-DD') + 'T00:00:00.000Z';
};
const toGmtByPicker = (value: moment.Moment | moment.Moment[], picker?: any) => {
if (!value) {
return value;
}
if (Array.isArray(value)) {
return value.map((val) => toStringByPicker(val, picker));
}
if (moment.isMoment(value)) {
return toStringByPicker(value, picker);
}
};
export interface Moment2strOptions {
showTime?: boolean;
gmt?: boolean;
picker?: 'year' | 'month' | 'week' | 'quarter';
}
export const moment2str = (value?: moment.Moment | moment.Moment[], options: Moment2strOptions = {}) => {
const { showTime, gmt, picker } = options;
if (!value) {
return value;
}
if (showTime) {
return gmt ? toGmt(value) : toLocal(value);
}
return toGmtByPicker(value, picker);
};
export const mapDateFormat = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: moment.Moment | moment.Moment[]) => {
if (onChange) {
onChange(moment2str(value, props));
}
},
};
};
};

View File

@ -5,6 +5,9 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": {
"xlsx": "^0.18.5"
},
"devDependencies": { "devDependencies": {
"@nocobase/client": "0.9.0-alpha.2", "@nocobase/client": "0.9.0-alpha.2",
"@nocobase/test": "0.9.0-alpha.2" "@nocobase/test": "0.9.0-alpha.2"

View File

@ -1,14 +1,12 @@
import { Schema, useFieldSchema, useForm } from '@formily/react'; import { Schema, useFieldSchema, useForm } from '@formily/react';
import { isEmpty } from '@formily/shared';
import { import {
useActionContext, useActionContext,
useAPIClient, useAPIClient,
useBlockRequestContext, useBlockRequestContext,
useCollection, useCollection,
useCollectionManager, useCollectionManager,
useCompile, useCompile
} from '@nocobase/client'; } from '@nocobase/client';
import { message } from 'antd';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -29,45 +27,43 @@ export const useDownloadXlsxTemplateAction = () => {
const apiClient = useAPIClient(); const apiClient = useAPIClient();
const actionSchema = useFieldSchema(); const actionSchema = useFieldSchema();
const compile = useCompile(); const compile = useCompile();
const { getCollectionJoinField } = useCollectionManager(); const { getCollectionJoinField, getCollectionField } = useCollectionManager();
const { name, title, getField } = useCollection(); const { name, title, getField } = useCollection();
const { t } = useTranslation(NAMESPACE); const { t } = useTranslation(NAMESPACE);
const { schema: importSchema } = useImportSchema(actionSchema); const { schema: importSchema } = useImportSchema(actionSchema);
return { return {
async run() { async run() {
const { importColumns, explain } = cloneDeep(importSchema?.['x-action-settings']?.['importSettings'] ?? {}); const { importColumns, explain } = cloneDeep(importSchema?.['x-action-settings']?.['importSettings'] ?? {});
try { const columns = importColumns
importColumns.forEach((es) => { .map((column) => {
const { uiSchema, interface: fieldInterface } = const field = getCollectionField(`${name}.${column.dataIndex[0]}`);
getCollectionJoinField(`${name}.${es.dataIndex.join('.')}`) ?? {}; if (!field) {
if (isEmpty(uiSchema) && isEmpty(fieldInterface)) {
throw new Error(t('Field {{fieldName}} does not exist', { fieldName: es.dataIndex.join('.') }));
}
es.enum = uiSchema?.enum?.map((e) => ({ value: e.value, label: e.label }));
if (!es.enum && uiSchema.type === 'boolean') {
es.enum = [
{ value: true, label: t('Yes') },
{ value: false, label: t('No') },
];
}
es.defaultTitle = compile(uiSchema?.title);
if (fieldInterface === 'chinaRegion') {
es.dataIndex.push('name');
}
});
} catch (error) {
message.error(error.message);
return; return;
} }
if (field.interface === 'chinaRegion') {
column.dataIndex.push('name');
}
column.defaultTitle = compile(field?.uiSchema?.title) || field.name;
if (column.dataIndex.length > 1) {
const subField = getCollectionJoinField(`${name}.${column.dataIndex.join('.')}`);
if (!subField) {
return;
}
column.defaultTitle = column.defaultTitle + '/' + compile(subField?.uiSchema?.title) || subField.name;
}
return column;
})
.filter(Boolean);
const { data } = await resource.downloadXlsxTemplate( const { data } = await resource.downloadXlsxTemplate(
{ {
values: {
title: compile(title), title: compile(title),
explain, explain,
columns: JSON.stringify(compile(importColumns)), columns: compile(columns),
},
}, },
{ {
method: 'get', method: 'post',
responseType: 'blob', responseType: 'blob',
}, },
); );
@ -82,7 +78,7 @@ export const useImportStartAction = () => {
const apiClient = useAPIClient(); const apiClient = useAPIClient();
const actionSchema = useFieldSchema(); const actionSchema = useFieldSchema();
const compile = useCompile(); const compile = useCompile();
const { getCollectionJoinField } = useCollectionManager(); const { getCollectionJoinField, getCollectionField } = useCollectionManager();
const { name, title, getField } = useCollection(); const { name, title, getField } = useCollection();
const { t } = useTranslation(NAMESPACE); const { t } = useTranslation(NAMESPACE);
const { schema: importSchema } = useImportSchema(actionSchema); const { schema: importSchema } = useImportSchema(actionSchema);
@ -92,47 +88,47 @@ export const useImportStartAction = () => {
return { return {
async run() { async run() {
const { importColumns, explain } = cloneDeep(importSchema?.['x-action-settings']?.['importSettings'] ?? {}); const { importColumns, explain } = cloneDeep(importSchema?.['x-action-settings']?.['importSettings'] ?? {});
try { const columns = importColumns
importColumns.forEach((es) => { .map((column) => {
const { uiSchema, interface: fieldInterface } = const field = getCollectionField(`${name}.${column.dataIndex[0]}`);
getCollectionJoinField(`${name}.${es.dataIndex.join('.')}`) ?? {}; if (!field) {
if (isEmpty(uiSchema) && isEmpty(fieldInterface)) {
throw new Error(t('Field {{fieldName}} does not exist', { fieldName: es.dataIndex.join('.') }));
}
es.enum = uiSchema?.enum?.map((e) => ({ value: e.value, label: e.label }));
if (!es.enum && uiSchema.type === 'boolean') {
es.enum = [
{ value: true, label: t('Yes') },
{ value: false, label: t('No') },
];
}
es.defaultTitle = compile(uiSchema?.title);
if (fieldInterface === 'chinaRegion') {
es.dataIndex.push('name');
}
});
} catch (error) {
message.error(error.message);
return; return;
} }
if (field.interface === 'chinaRegion') {
column.dataIndex.push('name');
}
column.defaultTitle = compile(field?.uiSchema?.title) || field.name;
if (column.dataIndex.length > 1) {
const subField = getCollectionJoinField(`${name}.${column.dataIndex.join('.')}`);
if (!subField) {
return;
}
column.defaultTitle = column.defaultTitle + '/' + compile(subField?.uiSchema?.title) || subField.name;
}
return column;
})
.filter(Boolean);
let formData = new FormData(); let formData = new FormData();
const uploadFiles = form.values.upload.map((f) => f.originFileObj); const uploadFiles = form.values.upload.map((f) => f.originFileObj);
console.log(form, uploadFiles); console.log(form, uploadFiles);
formData.append('file', uploadFiles[0]); formData.append('file', uploadFiles[0]);
formData.append('columns', JSON.stringify(importColumns)); formData.append('columns', JSON.stringify(columns));
formData.append('explain', explain); formData.append('explain', explain);
setVisible(false); setVisible(false);
setImportModalVisible(true); setImportModalVisible(true);
setImportStatus(ImportStatus.IMPORTING); setImportStatus(ImportStatus.IMPORTING);
const { data }: any = await apiClient.axios try {
.post(`${name}:importXlsx`, formData, { const { data }: any = await apiClient.axios.post(`${name}:importXlsx`, formData, {
timeout: 10 * 60 * 1000, timeout: 10 * 60 * 1000,
}) });
.catch((err) => {});
setImportResult(data); setImportResult(data);
form.reset(); form.reset();
await service?.refresh?.(); await service?.refresh?.();
setImportStatus(ImportStatus.IMPORTED); setImportStatus(ImportStatus.IMPORTED);
} catch (error) {
setImportModalVisible(false);
setVisible(true);
}
}, },
}; };
}; };

View File

@ -2,14 +2,14 @@ import { Context, Next } from '@nocobase/actions';
import xlsx from 'node-xlsx'; import xlsx from 'node-xlsx';
export async function downloadXlsxTemplate(ctx: Context, next: Next) { export async function downloadXlsxTemplate(ctx: Context, next: Next) {
let { columns, explain, title } = ctx.action.params; let { columns, explain, title } = ctx.request.body;
if (typeof columns === 'string') { if (typeof columns === 'string') {
columns = JSON.parse(columns); columns = JSON.parse(columns);
} }
const header = columns?.map((column) => column.defaultTitle); const header = columns?.map((column) => column.defaultTitle);
const data = [header]; const data = [header];
if (explain?.trim() !== '') { if (explain?.trim() !== '') {
data.push([explain]); data.unshift([explain]);
} }
ctx.body = xlsx.build([ ctx.body = xlsx.build([

View File

@ -1,109 +1,163 @@
import { Context, Next } from '@nocobase/actions'; import { Context, Next } from '@nocobase/actions';
import { Repository } from '@nocobase/database'; import { Collection, Repository } from '@nocobase/database';
import { cloneDeep } from 'lodash';
import xlsx from 'node-xlsx'; import xlsx from 'node-xlsx';
import { transform } from '../utils'; import XLSX from 'xlsx';
import { namespace } from '../../';
const IMPORT_LIMIT_COUNT = 10000; const IMPORT_LIMIT_COUNT = 10000;
export async function importXlsx(ctx: Context, next: Next) { class Importer {
let { columns } = ctx.request.body as any; repository: Repository;
const { ['file']: file } = ctx; collection: Collection;
columns: any[];
items: any[][] = [];
headerRow;
context: Context;
constructor(ctx: Context) {
const { resourceName, resourceOf } = ctx.action; const { resourceName, resourceOf } = ctx.action;
this.context = ctx;
this.repository = ctx.db.getRepository<any>(resourceName, resourceOf);
this.collection = this.repository.collection;
this.parseXlsx();
}
getRows() {
const workbook = XLSX.read(this.context.file.buffer, {
type: 'buffer',
// cellDates: true,
// raw: false,
});
const r = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<any>(r, { header: 1, defval: null, raw: false });
return rows;
}
parseXlsx() {
const rows = this.getRows();
let columns = this.context.request.body.columns as any[];
if (typeof columns === 'string') { if (typeof columns === 'string') {
columns = JSON.parse(columns); columns = JSON.parse(columns);
} }
const repository = ctx.db.getRepository<any>(resourceName, resourceOf) as Repository; this.columns = columns.map((column) => {
const collection = repository.collection; return {
...column,
field: this.collection.fields.get(column.dataIndex[0]),
};
});
const str = this.columns.map((column) => column.defaultTitle).join('||');
for (const row of rows) {
if (this.hasHeaderRow()) {
if (row && row.join('').trim()) {
this.items.push(row);
}
}
if (str === row.filter((r) => r).join('||')) {
this.headerRow = row;
}
}
}
columns = columns?.filter((col) => col?.dataIndex?.length > 0); getFieldByIndex(index) {
const collectionFields = columns.map((col) => collection.fields.get(col.dataIndex[0])); return this.columns[index].field;
const {
0: { data: originalList },
} = xlsx.parse(file.buffer);
const failureData = originalList.splice(IMPORT_LIMIT_COUNT + 1);
const titles = originalList.shift();
const legalList: any[] = [];
if (originalList.length > 0 && titles?.length === columns.length) {
// const results = (
// await Promise.allSettled<any>(
// originalList.map(async (item) => {
// try {
// const transformResult = await transform({ ctx, record: item, columns, fields: collectionFields });
// legalList.push(cloneDeep(item));
// return transformResult;
// } catch (error) {
// failureData.unshift([...item, error.message]);
// }
// }),
// )
// ).filter((item) => 'value' in item && item.value !== undefined);
const values: any[] = [];
for (const item of originalList) {
try {
const transformResult = await transform({ ctx, record: item, columns, fields: collectionFields });
values.push(transformResult);
legalList.push(cloneDeep<any>(item));
} catch (error) {
failureData.unshift([...item, error.message]);
} }
}
//@ts-ignore async getItems() {
// const values = results.map((r) => r.value); const items: any[] = [];
const result = await ctx.db.sequelize.transaction(async (transaction) => { for (const row of this.items) {
let sort: number = 0; const values = {};
if (collection.options.sortable) { const errors = [];
sort = await repository.model.max<number, any>('sort', { transaction }); for (let index = 0; index < row.length; index++) {
} if (!this.columns[index]) {
for (const [index, val] of values.entries()) {
if (val === undefined || val === null) {
continue; continue;
} }
try { const column = this.columns[index];
let values = { ...val }; const { field, defaultTitle } = column;
if (collection.options.sortable) { let value = row[index];
sort += 1; if (value === undefined || value === null) {
values['sort'] = sort; continue;
} }
await repository.create({ const parser = field.buildValueParser({ ...this.context, column });
await parser.setValue(typeof value === 'string' ? value.trim() : value);
value = parser.getValue();
if (parser.errors.length > 0) {
errors.push(`${defaultTitle}: ${parser.errors.join(';')}`);
}
if (value === undefined) {
continue;
}
values[field.name] = value;
}
items.push({
row,
values,
errors,
});
}
return items;
}
hasSortField() {
return !!this.collection.options.sortable;
}
async run() {
return await this.context.db.sequelize.transaction(async (transaction) => {
let sort: number = 0;
if (this.hasSortField()) {
sort = await this.repository.model.max<number, any>('sort', { transaction });
}
const result: any = [[], []];
for (const { row, values, errors } of await this.getItems()) {
if (errors.length > 0) {
row.push(errors.join(';'));
result[1].push(row);
continue;
}
if (this.hasSortField()) {
values['sort'] = ++sort;
}
try {
const instance = await this.repository.create({
values, values,
transaction, transaction,
logging: false, logging: false,
context: this.context,
}); });
result[0].push(instance);
} catch (error) { } catch (error) {
const failData = legalList[index]; this.context.log.error(error, row);
failData.push(error?.original?.message ?? error.message); result[1].push(row);
failureData.unshift(failData);
} }
} }
return { return result;
successCount: originalList.length - failureData.length,
failureCount: failureData.length,
};
}); });
const header = columns?.map((column) => column.defaultTitle); }
hasHeaderRow() {
return !!this.headerRow;
}
}
export async function importXlsx(ctx: Context, next: Next) {
const importer = new Importer(ctx);
if (!importer.hasHeaderRow()) {
ctx.throw(400, ctx.t('Imported template does not match, please download again.', { ns: namespace }));
}
const [success, failure] = await importer.run();
ctx.body = { ctx.body = {
rows: xlsx.build([ rows: xlsx.build([
{ {
name: file.originalname, name: ctx.file.originalname,
data: [header].concat(failureData), data: [importer.headerRow].concat(failure),
}, },
]), ]),
...result, successCount: success.length,
failureCount: failure.length,
}; };
} else {
ctx.body = {
rows: file.buffer.toJSON(),
successCount: 0,
failureCount: originalList?.length ?? 0,
};
}
ctx.set({
'Content-Type': 'application/octet-stream',
// to avoid "invalid character" error in header (RFC)
'Content-Disposition': `attachment; filename=${encodeURI('testTitle')}.xlsx`,
});
await next(); await next();
} }

View File

@ -7,4 +7,5 @@ export default {
'Incorrect date format': '日期格式不正确', 'Incorrect date format': '日期格式不正确',
'Incorrect email format': '邮箱格式不正确', 'Incorrect email format': '邮箱格式不正确',
'Illegal percentage format': '百分比格式有误', 'Illegal percentage format': '百分比格式有误',
'Imported template does not match, please download again.': '导入模板不匹配,请检查导入文件标题行或重新下载导入模板'
}; };