feat: 数据表导入导出 (#1550)

Co-authored-by: Toby <2287769986@qq.com>
Reviewed-on: daoyoucloud/tachybase#1550
This commit is contained in:
sealday 2024-09-23 14:49:17 +08:00
parent d9d8105b36
commit 1df02b79da
13 changed files with 377 additions and 6 deletions

View File

@ -1,8 +1,8 @@
import React, { useMemo, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { ArrayTable } from '@tachybase/components'; import { ArrayTable } from '@tachybase/components';
import { ISchema, uid, useField, useForm } from '@tachybase/schema'; import { ISchema, uid, useField, useForm } from '@tachybase/schema';
import { DownOutlined, PlusOutlined } from '@ant-design/icons'; import { DownOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Dropdown, MenuProps } from 'antd'; import { Button, Dropdown, MenuProps } from 'antd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -15,6 +15,7 @@ import { useCollectionManager_deprecated } from '../hooks';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider'; import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import * as components from './components'; import * as components from './components';
import { TemplateSummary } from './components/TemplateSummary'; import { TemplateSummary } from './components/TemplateSummary';
import { ImportCollectionMetaAction } from './ImportCollectionMetaAction';
const getSchema = (schema, category, compile): ISchema => { const getSchema = (schema, category, compile): ISchema => {
if (!schema) { if (!schema) {
@ -148,6 +149,8 @@ export const AddCollectionAction = (props) => {
const [schema, setSchema] = useState({}); const [schema, setSchema] = useState({});
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation(); const { t } = useTranslation();
const importRef = useRef<any>(null);
const items = useMemo(() => { const items = useMemo(() => {
const result = []; const result = [];
collectionTemplates.forEach((item) => { collectionTemplates.forEach((item) => {
@ -173,13 +176,19 @@ export const AddCollectionAction = (props) => {
overflow: 'auto', overflow: 'auto',
}, },
onClick: (info) => { onClick: (info) => {
if (info.key === 'import') {
console.log('import', importRef.current);
// 打开上传文件的弹窗
importRef.current?.showModal();
return;
}
const schema = getSchema(getTemplate(info.key), category, compile); const schema = getSchema(getTemplate(info.key), category, compile);
setSchema(schema); setSchema(schema);
setVisible(true); setVisible(true);
}, },
items, items,
}; };
}, [category, items]); }, [category, items, importRef]);
return ( return (
<RecordProvider record={record}> <RecordProvider record={record}>
@ -205,6 +214,7 @@ export const AddCollectionAction = (props) => {
}} }}
/> />
</ActionContextProvider> </ActionContextProvider>
<ImportCollectionMetaAction ref={importRef} />
</RecordProvider> </RecordProvider>
); );
}; };

View File

@ -0,0 +1,173 @@
import React, { useImperativeHandle, useState } from 'react';
import { useField, useForm } from '@tachybase/schema';
import { InboxOutlined } from '@ant-design/icons';
import { Button, message, Modal, Spin, Upload, UploadProps } from 'antd';
import { cloneDeep } from 'lodash';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../api-client';
import { useRecord } from '../../record-provider';
import { useActionContext } from '../../schema-component';
import { useCollectionManager_deprecated } from '../hooks';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
const { Dragger } = Upload;
function useUploadProps(props: UploadProps): any {
const onChange = (param) => {
props.onChange?.(param);
};
const api = useAPIClient();
return {
...props,
customRequest({ action, data, file, filename, headers, onError, onProgress, onSuccess, withCredentials }) {
const formData = new FormData();
if (data) {
Object.keys(data).forEach((key) => {
formData.append(key, data[key]);
});
}
formData.append(filename, file);
// eslint-disable-next-line promise/catch-or-return
api.axios
.post(action, formData, {
withCredentials,
headers,
onUploadProgress: ({ total, loaded }) => {
onProgress({ percent: Math.round((loaded / total) * 100).toFixed(2) }, file);
},
})
.then(({ data }) => {
onSuccess(data, file);
})
.catch(onError)
.finally(() => {});
return {
abort() {
console.log('upload progress is aborted.');
},
};
},
onChange,
};
}
const ImportUpload = (props: any) => {
const { t } = useTranslation();
const { refreshCM } = useCollectionManager_deprecated();
const { close } = props;
const { refresh } = useResourceActionContext();
const uploadProps: UploadProps = {
multiple: false,
action: '/collections:importMeta',
async onChange(info) {
if (info.fileList.length > 1) {
info.fileList.splice(0, info.fileList.length - 1); // 只保留一个文件
}
const { status } = info.file;
if (status === 'done') {
close();
message.success(`${info.file.name} ` + t('file uploaded successfully'));
refresh();
await refreshCM();
} else if (status === 'error') {
// message.error(`${info.file.name} ` + t('file upload failed'));
}
},
onDrop(e) {
console.log('Dropped files', e.dataTransfer.files);
},
};
return (
<Dragger {...useUploadProps(uploadProps)}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"> {t('Click or drag file to this area to upload')}</p>
</Dragger>
);
};
const useCreateCollection = (schema?: any) => {
const form = useForm();
const { refreshCM } = useCollectionManager_deprecated();
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
const { resource } = useResourceContext();
const field = useField();
return {
async run() {
field.data = field.data || {};
field.data.loading = true;
try {
await form.submit();
const values = cloneDeep(form.values);
if (schema?.events?.beforeSubmit) {
schema.events.beforeSubmit(values);
}
if (!values.autoCreateReverseField) {
delete values.reverseField;
}
delete values.autoCreateReverseField;
await resource.create({
values: {
logging: true,
...values,
},
});
ctx.setVisible(false);
await form.reset();
field.data.loading = false;
refresh();
await refreshCM();
} catch (error) {
field.data.loading = false;
}
},
};
};
export const ImportCollectionMetaAction = React.forwardRef((props, ref) => {
const { t } = useTranslation();
const [dataTypes, setDataTypes] = useState<any[]>(['required']);
const [isModalOpen, setIsModalOpen] = useState(false);
// const [restoreData, setRestoreData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const showModal = () => {
setIsModalOpen(true);
};
useImperativeHandle(ref, () => ({
showModal,
}));
const handleCancel = () => {
setIsModalOpen(false);
// setRestoreData(null);
setDataTypes(['required']);
};
return (
<>
{/* <ButtonComponent onClick={showModal}>{t('Import')}</ButtonComponent> */}
<Modal
title={t('Import')}
width={800}
footer={undefined}
open={isModalOpen}
onOk={handleCancel}
onCancel={handleCancel}
>
<Spin spinning={loading}>
<ImportUpload close={handleCancel} />
</Spin>
</Modal>
</>
);
});

View File

@ -121,7 +121,7 @@ export const SourceKey = observer(
const field: any = useField(); const field: any = useField();
const compile = useCompile(); const compile = useCompile();
const options = getCollection(collectionName || name) const options = getCollection(collectionName || name)
.fields?.filter((v) => { ?.fields?.filter((v) => {
return v.primaryKey || v.unique; return v.primaryKey || v.unique;
}) })
.map((k) => { .map((k) => {

View File

@ -17,6 +17,7 @@ export * from './components/TemplateSummary';
export * from './components/CollectionFieldInterfaceTag'; export * from './components/CollectionFieldInterfaceTag';
export * from './components/CollectionCategory'; export * from './components/CollectionCategory';
export * from './components/CollectionTemplateTag'; export * from './components/CollectionTemplateTag';
export * from './ImportCollectionMetaAction';
registerValidateFormats({ registerValidateFormats({
uid: /^[a-zA-Z][a-zA-Z0-9_-]*$/, uid: /^[a-zA-Z][a-zA-Z0-9_-]*$/,

View File

@ -48,6 +48,7 @@ import { InheritanceCollectionMixin } from './mixins/InheritanceCollectionMixin'
import { import {
ExpressionCollectionTemplate, ExpressionCollectionTemplate,
GeneralCollectionTemplate, GeneralCollectionTemplate,
ImportCollectionTemplate,
SqlCollectionTemplate, SqlCollectionTemplate,
TreeCollectionTemplate, TreeCollectionTemplate,
ViewCollectionTemplate, ViewCollectionTemplate,
@ -170,6 +171,7 @@ export class CollectionPlugin extends Plugin {
SqlCollectionTemplate, SqlCollectionTemplate,
TreeCollectionTemplate, TreeCollectionTemplate,
ViewCollectionTemplate, ViewCollectionTemplate,
ImportCollectionTemplate,
]); ]);
} }
} }

View File

@ -0,0 +1,13 @@
import { CollectionTemplate } from '../../data-source/collection-template/CollectionTemplate';
import { getConfigurableProperties } from './properties';
export class ImportCollectionTemplate extends CollectionTemplate {
name = 'import';
title = '{{t("Import collection")}}';
order = 5;
color = 'blue';
divider = true;
default = {
fields: [],
};
}

View File

@ -3,3 +3,4 @@ export * from './tree';
export * from './expression'; export * from './expression';
export * from './view'; export * from './view';
export * from './sql'; export * from './sql';
export * from './import';

View File

@ -958,7 +958,8 @@
"Designer Mode":"设计者模式", "Designer Mode":"设计者模式",
"There are no full screen blocks available at the current location":"当前位置没有可全屏区块", "There are no full screen blocks available at the current location":"当前位置没有可全屏区块",
"Exit Full Screen":"退出全屏", "Exit Full Screen":"退出全屏",
"User settings":"个人设置",
"Import collection": "导入数据表",
"Embedded page": "嵌入页面", "Embedded page": "嵌入页面",
"Demonstration text": "演示文本", "Demonstration text": "演示文本"
"User settings":"个人设置"
} }

View File

@ -0,0 +1,137 @@
import fsPromises from 'fs/promises';
import os from 'os';
import { Readable } from 'stream';
import { Context } from '@tachybase/actions';
import { ResourceOptions } from '@tachybase/resourcer';
import { koaMulter as multer, uid } from '@tachybase/utils';
import lodash from 'lodash';
export const collectionImportExportMeta: ResourceOptions = {
name: 'collections',
middleware: async (ctx, next) => {
if (ctx.action.actionName === 'importMeta') {
const storage = multer.diskStorage({
destination: os.tmpdir(),
filename: function (req, file, cb) {
const randomName = Date.now().toString() + Math.random().toString().slice(2); // 随机生成文件名
cb(null, randomName);
},
});
const upload = multer({ storage }).single('file');
return upload(ctx, next);
} else {
return next();
}
},
actions: {
async exportMeta(ctx: Context, next) {
// todo: define property name;
const { collectionName } = ctx.action.params;
const Collections = ctx.db.getCollection('collections');
const metaList = [];
async function getAssociationCollection(name: string) {
if (metaList.some((item) => item.name === name)) {
return;
}
const meta = await Collections.repository.findOne({
filterByTk: name,
context: ctx,
appends: ['category', 'fields'],
});
if (!meta) {
return;
}
metaList.push(meta);
const fields = meta.dataValues.fields;
for (const field of fields) {
ctx.logger.info('field.dataValues', field.dataValues);
if (field?.dataValues?.options?.target) {
await getAssociationCollection(field?.dataValues?.options?.target);
}
}
}
await getAssociationCollection(collectionName);
ctx.body = metaList;
await next();
},
async importMeta(ctx: Context, next) {
const { file } = ctx;
if (!file) {
throw new Error('file not found');
}
let metaList = null;
try {
metaList = JSON.parse(await fsPromises.readFile(file.path, 'utf8'));
} catch (e) {
ctx.logger.info(e);
throw new Error('file is not a valid json file');
}
let createCount = 0;
async function importCollection(meta) {
const CollectionRepo = ctx.db.getCollection('collections');
// 检查表是否存在
let collection = await CollectionRepo.repository.findOne({
filterByTk: meta.name,
context: ctx,
});
if (collection) {
return;
throw new Error('collection name already exists, please change the name');
}
collection = await CollectionRepo.repository.findOne({
filter: { key: meta.key },
context: ctx,
});
if (collection) {
return;
throw new Error('collection key already exists, please change the key');
}
// generate unique key
// meta.fields.forEach((field) => {
// field.key = uid();
// });
// 检查field是否已经存在
const fieldRepo = ctx.db.getCollection('fields');
const fieldKeys = meta.fields.map((field) => field.key);
const existFields = await fieldRepo.repository.findOne({
filter: { key: { $in: fieldKeys } },
context: ctx,
});
if (existFields) {
return;
throw new Error('field key already exists, please change the key');
}
await CollectionRepo.repository.create({
values: meta,
context: ctx,
});
createCount++;
}
for (const meta of metaList) {
await importCollection(meta);
}
if (!createCount) {
throw new Error('no collection created, please check the key or name');
}
ctx.body = { count: createCount };
await next();
},
},
};

View File

@ -8,6 +8,7 @@ import { Mutex } from 'async-mutex';
import lodash from 'lodash'; import lodash from 'lodash';
import { CollectionRepository } from '.'; import { CollectionRepository } from '.';
import { collectionImportExportMeta } from './actions/collectionImportExportMeta';
import { import {
afterCreateForForeignKeyField, afterCreateForForeignKeyField,
afterCreateForReverseField, afterCreateForReverseField,
@ -258,6 +259,8 @@ export class CollectionManagerPlugin extends Plugin {
this.app.acl.allow('collections', 'list', 'loggedIn'); this.app.acl.allow('collections', 'list', 'loggedIn');
this.app.acl.allow('collectionCategories', 'list', 'loggedIn'); this.app.acl.allow('collectionCategories', 'list', 'loggedIn');
this.app.resourcer.define(collectionImportExportMeta);
} }
async load() { async load() {

View File

@ -14,6 +14,7 @@
"@tachybase/schema": "workspace:*", "@tachybase/schema": "workspace:*",
"ahooks": "^3.7.2", "ahooks": "^3.7.2",
"antd": "5.19.4", "antd": "5.19.4",
"file-saver": "^2.0.5",
"lodash": "4.17.21", "lodash": "4.17.21",
"react-i18next": "^14.1.2", "react-i18next": "^14.1.2",
"react-router-dom": "^6.25.1", "react-router-dom": "^6.25.1",

View File

@ -3,11 +3,14 @@ import {
CollectionTemplateTag, CollectionTemplateTag,
i18n, i18n,
useAPIClient, useAPIClient,
useRecord,
useResourceContext,
type CollectionOptions, type CollectionOptions,
} from '@tachybase/client'; } from '@tachybase/client';
import { ISchema, Schema, uid } from '@tachybase/schema'; import { ISchema, Schema, uid } from '@tachybase/schema';
import { message } from 'antd'; import { message } from 'antd';
import { saveAs } from 'file-saver';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const compile = (source) => { const compile = (source) => {
@ -305,6 +308,29 @@ export const collectionTableSchema: ISchema = {
type: 'primary', type: 'primary',
}, },
}, },
export: {
type: 'void',
title: '{{ t("Export") }}',
'x-component': 'Action.Link',
'x-component-props': {
useAction() {
const { resource, targetKey } = useResourceContext();
const { [targetKey]: collectionName } = useRecord();
return {
async run() {
const { data } = await resource.exportMeta(
{ collectionName },
{
method: 'get',
},
);
const blob = new Blob([JSON.stringify(data.data, null, 2)], { type: 'application/json' });
saveAs(blob, `${collectionName}.json`);
},
};
},
},
},
delete: { delete: {
type: 'void', type: 'void',
title: '{{ t("Delete") }}', title: '{{ t("Delete") }}',

View File

@ -2584,6 +2584,9 @@ importers:
antd: antd:
specifier: 5.19.4 specifier: 5.19.4
version: 5.19.4(date-fns@3.6.0)(luxon@3.5.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 5.19.4(date-fns@3.6.0)(luxon@3.5.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
file-saver:
specifier: ^2.0.5
version: 2.0.5
lodash: lodash:
specifier: 4.17.21 specifier: 4.17.21
version: 4.17.21 version: 4.17.21