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;
}