diff --git a/packages/app/client/src/pages/index.tsx b/packages/app/client/src/pages/index.tsx index a4dceee9e..8e3631246 100644 --- a/packages/app/client/src/pages/index.tsx +++ b/packages/app/client/src/pages/index.tsx @@ -14,6 +14,7 @@ import { compose, DesignableSwitch, DocumentTitleProvider, + FileStorageShortcut, i18n, MenuItemInitializers, PluginManagerProvider, @@ -85,6 +86,7 @@ const providers = [ WorkflowShortcut, SystemSettingsShortcut, SchemaTemplateShortcut, + FileStorageShortcut, }, }, ], diff --git a/packages/core/client/src/file-manager/FileStorageShortcut.tsx b/packages/core/client/src/file-manager/FileStorageShortcut.tsx new file mode 100644 index 000000000..5099ef1a0 --- /dev/null +++ b/packages/core/client/src/file-manager/FileStorageShortcut.tsx @@ -0,0 +1,40 @@ +import { SettingOutlined } from '@ant-design/icons'; +import { uid } from '@formily/shared'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PluginManager } from '..'; +import { ActionContext, SchemaComponent } from '../schema-component'; +import { storageSchema } from './schemas/storage'; +import { StorageOptions } from './StorageOptions'; + +const schema = { + type: 'object', + properties: { + [uid()]: { + 'x-component': 'Action.Drawer', + type: 'void', + title: '{{t("File storages")}}', + properties: { + storageSchema, + }, + }, + }, +}; + +export const FileStorageShortcut = () => { + const [visible, setVisible] = useState(false); + const { t } = useTranslation(); + return ( + + { + setVisible(true); + }} + icon={} + title={t('File storages')} + /> + + + ); +}; diff --git a/packages/core/client/src/file-manager/StorageOptions.tsx b/packages/core/client/src/file-manager/StorageOptions.tsx new file mode 100644 index 000000000..e84e8e49e --- /dev/null +++ b/packages/core/client/src/file-manager/StorageOptions.tsx @@ -0,0 +1,98 @@ +import { FormLayout } from '@formily/antd'; +import { Field } from '@formily/core'; +import { observer, RecursionField, Schema, useField, useForm } from '@formily/react'; +import React, { useEffect, useState } from 'react'; + +const schema = { + local: { + properties: { + documentRoot: { + title: '{{t("Destination")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + }, + }, + 'ali-oss': { + properties: { + region: { + title: '{{t("Region")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + accessKeyId: { + title: '{{t("AccessKey ID")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + accessKeySecret: { + title: '{{t("AccessKey Secret")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + bucket: { + title: '{{t("Bucket")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + }, + }, + s3: { + properties: { + region: { + title: '{{t("Region")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + accessKeyId: { + title: '{{t("AccessKey ID")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + secretAccessKey: { + title: '{{t("AccessKey Secret")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + bucket: { + title: '{{t("Bucket")}}', + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + required: true, + }, + }, + }, +}; + +export const StorageOptions = observer((props) => { + const form = useForm(); + const field = useField(); + const [s, setSchema] = useState(new Schema({})); + useEffect(() => { + form.clearFormGraph('options.*'); + setSchema(new Schema(schema[form.values.type] || {})); + }, [form.values.type]); + return ( +
+ + + +
+ ); +}); diff --git a/packages/core/client/src/file-manager/index.ts b/packages/core/client/src/file-manager/index.ts new file mode 100644 index 000000000..658bae5cb --- /dev/null +++ b/packages/core/client/src/file-manager/index.ts @@ -0,0 +1 @@ +export * from './FileStorageShortcut'; diff --git a/packages/core/client/src/file-manager/schemas/storage.ts b/packages/core/client/src/file-manager/schemas/storage.ts new file mode 100644 index 000000000..f7fe776b2 --- /dev/null +++ b/packages/core/client/src/file-manager/schemas/storage.ts @@ -0,0 +1,350 @@ +import { ISchema } from '@formily/react'; +import { uid } from '@formily/shared'; +import { useRequest } from '../../api-client'; +import { useActionContext } from '../../schema-component'; + +const collection = { + name: 'storages', + fields: [ + { + type: 'integer', + name: 'title', + interface: 'input', + uiSchema: { + title: '{{t("Storage display name")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'name', + interface: 'input', + uiSchema: { + title: '{{t("Storage name")}}', + type: 'string', + 'x-component': 'Input', + } as ISchema, + }, + { + type: 'string', + name: 'type', + interface: 'select', + uiSchema: { + title: '{{t("Storage type")}}', + type: 'string', + 'x-component': 'Select', + required: true, + enum: [ + { label: '{{t("Local storage")}}', value: 'local' }, + { label: '{{t("Aliyun OSS")}}', value: 'ali-oss' }, + { label: '{{t("Amazon S3")}}', value: 's3' }, + ], + } as ISchema, + }, + { + type: 'string', + name: 'baseUrl', + interface: 'input', + uiSchema: { + title: '{{t("Storage base URL")}}', + type: 'string', + 'x-component': 'Input', + } as ISchema, + }, + { + type: 'boolean', + name: 'default', + interface: 'boolean', + uiSchema: { + title: '{{t("Default storage")}}', + type: 'boolean', + 'x-component': 'Checkbox', + } as ISchema, + }, + ], +}; + +export const storageSchema: ISchema = { + type: 'object', + properties: { + block1: { + type: 'void', + 'x-decorator': 'ResourceActionProvider', + 'x-decorator-props': { + collection, + resourceName: 'storages', + request: { + resource: 'storages', + action: 'list', + params: { + pageSize: 50, + sort: ['id'], + appends: [], + }, + }, + }, + 'x-component': 'CollectionProvider', + 'x-component-props': { + collection, + }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useBulkDestroyAction }}', + confirm: { + title: "{{t('Delete storage')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + }, + }, + create: { + type: 'void', + title: '{{t("Add storage")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues(options) { + const ctx = useActionContext(); + return useRequest( + () => + Promise.resolve({ + data: { + name: `s_${uid()}`, + }, + }), + { ...options, refreshDeps: [ctx.visible] }, + ); + }, + }, + title: '{{t("Add storage")}}', + properties: { + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + description: + '{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}', + }, + baseUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + type: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + options: { + type: 'object', + 'x-component': 'StorageOptions', + 'x-decorator': 'FormItem', + }, + default: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + title: '', + 'x-content': '{{t("Default storage")}}', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useCreateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + table: { + type: 'void', + 'x-uid': 'input', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useDataSource: '{{ cm.useDataSourceFromRAC }}', + }, + properties: { + column1: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + title: { + type: 'number', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column2: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + name: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column3: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + default: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column4: { + type: 'void', + title: '{{t("Actions")}}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + update: { + type: 'void', + title: '{{t("Edit")}}', + 'x-component': 'Action.Link', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + title: '{{t("Edit storage")}}', + properties: { + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-disabled': true, + }, + baseUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + type: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-disabled': true, + }, + options: { + type: 'object', + 'x-component': 'StorageOptions', + 'x-decorator': 'FormItem', + }, + default: { + title: '', + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-content': '{{t("Default storage")}}', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useUpdateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete role')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{cm.useDestroyAction}}', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/core/client/src/index.tsx b/packages/core/client/src/index.tsx index 4727a7247..4a95666ab 100644 --- a/packages/core/client/src/index.tsx +++ b/packages/core/client/src/index.tsx @@ -11,6 +11,7 @@ export * from './board'; export * from './china-region'; export * from './collection-manager'; export * from './document-title'; +export * from './file-manager'; export * from './i18n'; export * from './icon'; export * from './plugin-manager'; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 54ca373b2..9aaa7d482 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -382,4 +382,12 @@ export default { 'Action permission': '操作权限', 'Field permission': '字段权限', 'Scope name': '数据范围名称', + + 'File storages': '文件存储', + 'Storage display name': '文件存储名称', + 'Storage name': '文件存储标识', + 'Default storage': '默认存储', + 'Add storage': '添加文件存储', + 'Edit storage': '编辑文件存储', + 'Storage base URL': 'Base URL', } diff --git a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx index d1843fbc1..5c86674f3 100644 --- a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx +++ b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx @@ -134,6 +134,7 @@ const InternalAdminLayout = (props: any) => { { component: 'WorkflowShortcut', pin: true }, { component: 'SchemaTemplateShortcut', pin: true }, { component: 'SystemSettingsShortcut' }, + { component: 'FileStorageShortcut' }, ]} /> diff --git a/packages/core/database/src/fields/index.ts b/packages/core/database/src/fields/index.ts index 04ae3200f..1ee87cbe4 100644 --- a/packages/core/database/src/fields/index.ts +++ b/packages/core/database/src/fields/index.ts @@ -16,6 +16,7 @@ import { RealFieldOptions } from './number-field'; import { PasswordFieldOptions } from './password-field'; +import { RadioFieldOptions } from './radio-field'; import { SortFieldOptions } from './sort-field'; import { StringFieldOptions } from './string-field'; import { TextFieldOptions } from './text-field'; @@ -35,6 +36,7 @@ export * from './has-one-field'; export * from './json-field'; export * from './number-field'; export * from './password-field'; +export * from './radio-field'; export * from './relation-field'; export * from './sort-field'; export * from './string-field'; @@ -54,6 +56,7 @@ export type FieldOptions = | JsonFieldOptions | JsonbFieldOptions | BooleanFieldOptions + | RadioFieldOptions | SortFieldOptions | TextFieldOptions | VirtualFieldOptions diff --git a/packages/core/database/src/fields/radio-field.ts b/packages/core/database/src/fields/radio-field.ts new file mode 100644 index 000000000..417928e8c --- /dev/null +++ b/packages/core/database/src/fields/radio-field.ts @@ -0,0 +1,50 @@ +import { DataTypes } from 'sequelize'; +import { BaseColumnFieldOptions, Field } from './field'; + +export interface RadioFieldOptions extends BaseColumnFieldOptions { + type: 'radio'; +} + +/** + * 暂时只支持全局,不支持批量 + */ +export class RadioField extends Field { + get dataType() { + return DataTypes.BOOLEAN; + } + + init() { + const { name } = this.options; + this.listener = async (model, { transaction }) => { + if (!model.changed(name as any)) { + return; + } + const value = model.get(name) as boolean; + if (value) { + const M = this.collection.model; + await M.update( + { [name]: false }, + { + where: { + [name]: true, + }, + transaction, + hooks: false, + }, + ); + } + }; + } + + bind() { + super.bind(); + this.on('beforeCreate', this.listener.bind(this)); + this.on('beforeUpdate', this.listener.bind(this)); + } + + unbind() { + super.unbind(); + this.off('beforeCreate', this.listener.bind(this)); + this.off('beforeUpdate', this.listener.bind(this)); + } +} diff --git a/packages/plugins/file-manager/src/collections/storages.ts b/packages/plugins/file-manager/src/collections/storages.ts index 8a776e72c..979c8a242 100644 --- a/packages/plugins/file-manager/src/collections/storages.ts +++ b/packages/plugins/file-manager/src/collections/storages.ts @@ -49,7 +49,7 @@ export default { // TODO(feature): 需要使用一个实现了可设置默认值的字段 { comment: '默认引擎', - type: 'boolean', + type: 'radio', name: 'default', defaultValue: false, }, diff --git a/packages/plugins/file-manager/src/storages/local.ts b/packages/plugins/file-manager/src/storages/local.ts index 7f0b1ebe2..089cdfa98 100644 --- a/packages/plugins/file-manager/src/storages/local.ts +++ b/packages/plugins/file-manager/src/storages/local.ts @@ -82,11 +82,12 @@ async function middleware(app: Application, options?) { // 以下情况才认为当前进程所应该提供静态服务 // 否则都忽略,交给其他 server 来提供(如 nginx/cdn 等) - if (url.origin && url.origin !== LOCALHOST) { + if (url.origin && storage?.options?.serve === false) { continue; } const basePath = url.pathname.startsWith('/') ? url.pathname : `/${url.pathname}`; + if (!match(basePath, ctx.path)) { continue; }