feat: 数据表导入导出 (#1550)
Co-authored-by: Toby <2287769986@qq.com> Reviewed-on: daoyoucloud/tachybase#1550
This commit is contained in:
parent
d9d8105b36
commit
1df02b79da
@ -1,8 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ArrayTable } from '@tachybase/components';
|
||||
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 { cloneDeep } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -15,6 +15,7 @@ import { useCollectionManager_deprecated } from '../hooks';
|
||||
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
|
||||
import * as components from './components';
|
||||
import { TemplateSummary } from './components/TemplateSummary';
|
||||
import { ImportCollectionMetaAction } from './ImportCollectionMetaAction';
|
||||
|
||||
const getSchema = (schema, category, compile): ISchema => {
|
||||
if (!schema) {
|
||||
@ -148,6 +149,8 @@ export const AddCollectionAction = (props) => {
|
||||
const [schema, setSchema] = useState({});
|
||||
const compile = useCompile();
|
||||
const { t } = useTranslation();
|
||||
const importRef = useRef<any>(null);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const result = [];
|
||||
collectionTemplates.forEach((item) => {
|
||||
@ -173,13 +176,19 @@ export const AddCollectionAction = (props) => {
|
||||
overflow: 'auto',
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (info.key === 'import') {
|
||||
console.log('import', importRef.current);
|
||||
// 打开上传文件的弹窗
|
||||
importRef.current?.showModal();
|
||||
return;
|
||||
}
|
||||
const schema = getSchema(getTemplate(info.key), category, compile);
|
||||
setSchema(schema);
|
||||
setVisible(true);
|
||||
},
|
||||
items,
|
||||
};
|
||||
}, [category, items]);
|
||||
}, [category, items, importRef]);
|
||||
|
||||
return (
|
||||
<RecordProvider record={record}>
|
||||
@ -205,6 +214,7 @@ export const AddCollectionAction = (props) => {
|
||||
}}
|
||||
/>
|
||||
</ActionContextProvider>
|
||||
<ImportCollectionMetaAction ref={importRef} />
|
||||
</RecordProvider>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
@ -121,7 +121,7 @@ export const SourceKey = observer(
|
||||
const field: any = useField();
|
||||
const compile = useCompile();
|
||||
const options = getCollection(collectionName || name)
|
||||
.fields?.filter((v) => {
|
||||
?.fields?.filter((v) => {
|
||||
return v.primaryKey || v.unique;
|
||||
})
|
||||
.map((k) => {
|
||||
|
@ -17,6 +17,7 @@ export * from './components/TemplateSummary';
|
||||
export * from './components/CollectionFieldInterfaceTag';
|
||||
export * from './components/CollectionCategory';
|
||||
export * from './components/CollectionTemplateTag';
|
||||
export * from './ImportCollectionMetaAction';
|
||||
|
||||
registerValidateFormats({
|
||||
uid: /^[a-zA-Z][a-zA-Z0-9_-]*$/,
|
||||
|
@ -48,6 +48,7 @@ import { InheritanceCollectionMixin } from './mixins/InheritanceCollectionMixin'
|
||||
import {
|
||||
ExpressionCollectionTemplate,
|
||||
GeneralCollectionTemplate,
|
||||
ImportCollectionTemplate,
|
||||
SqlCollectionTemplate,
|
||||
TreeCollectionTemplate,
|
||||
ViewCollectionTemplate,
|
||||
@ -170,6 +171,7 @@ export class CollectionPlugin extends Plugin {
|
||||
SqlCollectionTemplate,
|
||||
TreeCollectionTemplate,
|
||||
ViewCollectionTemplate,
|
||||
ImportCollectionTemplate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -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: [],
|
||||
};
|
||||
}
|
@ -3,3 +3,4 @@ export * from './tree';
|
||||
export * from './expression';
|
||||
export * from './view';
|
||||
export * from './sql';
|
||||
export * from './import';
|
||||
|
@ -958,7 +958,8 @@
|
||||
"Designer Mode":"设计者模式",
|
||||
"There are no full screen blocks available at the current location":"当前位置没有可全屏区块",
|
||||
"Exit Full Screen":"退出全屏",
|
||||
"User settings":"个人设置",
|
||||
"Import collection": "导入数据表",
|
||||
"Embedded page": "嵌入页面",
|
||||
"Demonstration text": "演示文本",
|
||||
"User settings":"个人设置"
|
||||
"Demonstration text": "演示文本"
|
||||
}
|
||||
|
@ -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();
|
||||
},
|
||||
},
|
||||
};
|
@ -8,6 +8,7 @@ import { Mutex } from 'async-mutex';
|
||||
import lodash from 'lodash';
|
||||
|
||||
import { CollectionRepository } from '.';
|
||||
import { collectionImportExportMeta } from './actions/collectionImportExportMeta';
|
||||
import {
|
||||
afterCreateForForeignKeyField,
|
||||
afterCreateForReverseField,
|
||||
@ -258,6 +259,8 @@ export class CollectionManagerPlugin extends Plugin {
|
||||
|
||||
this.app.acl.allow('collections', 'list', 'loggedIn');
|
||||
this.app.acl.allow('collectionCategories', 'list', 'loggedIn');
|
||||
|
||||
this.app.resourcer.define(collectionImportExportMeta);
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
@ -14,6 +14,7 @@
|
||||
"@tachybase/schema": "workspace:*",
|
||||
"ahooks": "^3.7.2",
|
||||
"antd": "5.19.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash": "4.17.21",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-router-dom": "^6.25.1",
|
||||
|
@ -3,11 +3,14 @@ import {
|
||||
CollectionTemplateTag,
|
||||
i18n,
|
||||
useAPIClient,
|
||||
useRecord,
|
||||
useResourceContext,
|
||||
type CollectionOptions,
|
||||
} from '@tachybase/client';
|
||||
import { ISchema, Schema, uid } from '@tachybase/schema';
|
||||
|
||||
import { message } from 'antd';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const compile = (source) => {
|
||||
@ -305,6 +308,29 @@ export const collectionTableSchema: ISchema = {
|
||||
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: {
|
||||
type: 'void',
|
||||
title: '{{ t("Delete") }}',
|
||||
|
@ -2584,6 +2584,9 @@ importers:
|
||||
antd:
|
||||
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)
|
||||
file-saver:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
lodash:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
|
Loading…
Reference in New Issue
Block a user