diff --git a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx index 702d34009..60cddd08a 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx @@ -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(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 ( @@ -205,6 +214,7 @@ export const AddCollectionAction = (props) => { }} /> + ); }; diff --git a/packages/core/client/src/collection-manager/Configuration/ImportCollectionMetaAction.tsx b/packages/core/client/src/collection-manager/Configuration/ImportCollectionMetaAction.tsx new file mode 100644 index 000000000..be37cd611 --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/ImportCollectionMetaAction.tsx @@ -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 ( + +

+ +

+

{t('Click or drag file to this area to upload')}

+
+ ); +}; + +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(['required']); + const [isModalOpen, setIsModalOpen] = useState(false); + // const [restoreData, setRestoreData] = useState(null); + const [loading, setLoading] = useState(false); + + const showModal = () => { + setIsModalOpen(true); + }; + + useImperativeHandle(ref, () => ({ + showModal, + })); + + const handleCancel = () => { + setIsModalOpen(false); + // setRestoreData(null); + setDataTypes(['required']); + }; + return ( + <> + {/* {t('Import')} */} + + + + + + + ); +}); diff --git a/packages/core/client/src/collection-manager/Configuration/components/index.tsx b/packages/core/client/src/collection-manager/Configuration/components/index.tsx index 54a9429e1..7b4c4b825 100644 --- a/packages/core/client/src/collection-manager/Configuration/components/index.tsx +++ b/packages/core/client/src/collection-manager/Configuration/components/index.tsx @@ -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) => { diff --git a/packages/core/client/src/collection-manager/Configuration/index.tsx b/packages/core/client/src/collection-manager/Configuration/index.tsx index 834489e50..5acc6511b 100644 --- a/packages/core/client/src/collection-manager/Configuration/index.tsx +++ b/packages/core/client/src/collection-manager/Configuration/index.tsx @@ -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_-]*$/, diff --git a/packages/core/client/src/collection-manager/collectionPlugin.ts b/packages/core/client/src/collection-manager/collectionPlugin.ts index 241609330..f1a6d6c55 100644 --- a/packages/core/client/src/collection-manager/collectionPlugin.ts +++ b/packages/core/client/src/collection-manager/collectionPlugin.ts @@ -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, ]); } } diff --git a/packages/core/client/src/collection-manager/templates/import.tsx b/packages/core/client/src/collection-manager/templates/import.tsx new file mode 100644 index 000000000..d485c3dfd --- /dev/null +++ b/packages/core/client/src/collection-manager/templates/import.tsx @@ -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: [], + }; +} diff --git a/packages/core/client/src/collection-manager/templates/index.tsx b/packages/core/client/src/collection-manager/templates/index.tsx index 0daa8dcfa..b27c9d4ac 100644 --- a/packages/core/client/src/collection-manager/templates/index.tsx +++ b/packages/core/client/src/collection-manager/templates/index.tsx @@ -3,3 +3,4 @@ export * from './tree'; export * from './expression'; export * from './view'; export * from './sql'; +export * from './import'; diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index 2210692e4..0545ef5c4 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -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": "演示文本" } diff --git a/packages/plugins/@tachybase/plugin-collection-manager/src/server/actions/collectionImportExportMeta.ts b/packages/plugins/@tachybase/plugin-collection-manager/src/server/actions/collectionImportExportMeta.ts new file mode 100644 index 000000000..4c6b51ec2 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-collection-manager/src/server/actions/collectionImportExportMeta.ts @@ -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(); + }, + }, +}; diff --git a/packages/plugins/@tachybase/plugin-collection-manager/src/server/server.ts b/packages/plugins/@tachybase/plugin-collection-manager/src/server/server.ts index b33c8f0a1..2eab3766b 100644 --- a/packages/plugins/@tachybase/plugin-collection-manager/src/server/server.ts +++ b/packages/plugins/@tachybase/plugin-collection-manager/src/server/server.ts @@ -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() { diff --git a/packages/plugins/@tachybase/plugin-data-source-manager/package.json b/packages/plugins/@tachybase/plugin-data-source-manager/package.json index cc58e87eb..cc1dd525f 100644 --- a/packages/plugins/@tachybase/plugin-data-source-manager/package.json +++ b/packages/plugins/@tachybase/plugin-data-source-manager/package.json @@ -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", diff --git a/packages/plugins/@tachybase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/schemas/collections.ts b/packages/plugins/@tachybase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/schemas/collections.ts index eff5d8a73..d4a1f6714 100644 --- a/packages/plugins/@tachybase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/schemas/collections.ts +++ b/packages/plugins/@tachybase/plugin-data-source-manager/src/client/component/MainDataSourceManager/Configuration/schemas/collections.ts @@ -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") }}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61bb4faba..4c525a5a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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