refactor(plugin-fm): change api and allow to select storage (#1250)

* refactor(plugin-fm): change api and allow to select storage

* fix(plugin-fm): fix lint errors and demo actions

* refactor(plugin-fm): refactor action codes

* fix(plugin-fm): fix api in test

* fix(plugin-fm): fix build

* fix(plugin-fm): fix locale

* refactor(plugin-fm): hide storage from api and use sourceField param

* fix(plugin-fm): fix storage select load

* fix: improve code

* fix(plugin-fm): change to attachmentField

* refactor(plugin-fm): change middleware name

* fix(plugin-fm): fix params in test cases

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2023-06-06 18:30:42 +07:00 committed by GitHub
parent b33c00be8f
commit 36d16bc015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 437 additions and 475 deletions

View File

@ -1,4 +1,3 @@
export * from './attachment';
export * from './checkbox';
export * from './checkboxGroup';
export * from './chinaRegion';

View File

@ -209,7 +209,6 @@ export default {
"Field source":"Field source",
"Preview":"Preview",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.",
"Storage type": "Storage type",
"Edit": "Edit",
"Edit collection": "Edit collection",
"Configure fields": "Configure fields",
@ -245,7 +244,6 @@ export default {
"Radio group": "Radio group",
"Checkbox group": "Checkbox group",
"China region": "China region",
"Attachment": "Attachment",
"Date & Time": "Date & Time",
"Datetime": "Datetime",
"Relation": "Relation",
@ -523,7 +521,6 @@ export default {
"Redirect to": "Redirect to",
"Save action": "Save action",
"Exists": "Exists",
"Filename": "Filename",
"Add condition": "Add condition",
"Add condition group": "Add condition group",
"exists": "exists",
@ -540,7 +537,6 @@ export default {
"Expression": "Expression",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.",
"Formula error.": "Formula error.",
"Accept": "Accept",
"Rich Text": "Rich Text",
"Junction collection": "Junction collection",
"Leave it blank, unless you need a custom intermediate table": "Leave it blank, unless you need a custom intermediate table",
@ -593,22 +589,6 @@ export default {
"Action permission": "Action permission",
"Field permission": "Field permission",
"Scope name": "Scope name",
"File storages": "File storages",
"Storage display name": "Storage display name",
"Storage name": "Storage name",
"Default storage": "Default storage",
"Add storage": "Add storage",
"Edit storage": "Edit storage",
"Storage base URL": "Storage base URL",
"Destination": "Destination",
"Use the built-in static file server": "Use the built-in static file server",
"Local storage": "Local storage",
"Aliyun OSS": "Aliyun OSS",
"Amazon S3": "Amazon S3",
"Tencent COS": "Tencent COS",
"Region": "Region",
"Bucket": "Bucket",
"Path": "Path",
"Unsaved changes": "Unsaved changes",
"Are you sure you don't want to save?": "Are you sure you don't want to save?",
"Dragging": "Dragging",

View File

@ -189,7 +189,6 @@ export default {
"Field source": "ソースフィールド",
"Preview": "プレビュー",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "ランダムに生成され、変更可能です。 アルファベット、数字、アンダースコアをサポートし、アルファベットから始まる必要があります。",
"Storage type": "ストレージタイプ",
"Edit": "編集",
"Edit collection": "コレクションの編集",
"Configure fields": "フィールドの設定",
@ -217,7 +216,6 @@ export default {
"Radio group": "ラジオボタングループ",
"Checkbox group": "チェックボックスグループ",
"China region": "中国地域",
"Attachment": "添付ファイル",
"Date & Time": "日付と時間",
"Datetime": "日付",
"Relation": "関連づけ",
@ -330,7 +328,6 @@ export default {
"Add option": "オプションを追加",
"Related collection": "関連付けコレクション",
"Allow linking to multiple records": "複数のレコードの関連付けを許可する",
"Allow uploading multiple files": "複数ファイルのアップロードを許可する",
"Configure calendar": "カレンダーの設定",
"Title field": "タイトルフィールド",
"Start date field": "開始日フィールド",
@ -429,7 +426,6 @@ export default {
"Redirect to": "リダイレクトする",
"Save action": "操作を保存",
"Exists": "存在する",
"Filename": "ファイル名",
"Add condition": "条件の追加",
"Add condition group": "条件グループの追加",
"exists": "が存在する",
@ -446,7 +442,6 @@ export default {
"Expression": "表达式",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "+、-、*、/、( ) で算術演算、@でフィールド変数を開くことができます。",
"Formula error.": "式の検証エラーです。",
"Accept": "ファイル形式",
"Rich Text": "リッチテキスト",
"Junction collection": "中間コレクション",
"Leave it blank, unless you need a custom intermediate table": "カスタム中間テーブルが必要でない限り、デフォルトで空白のままにします",
@ -495,19 +490,6 @@ export default {
"Action permission": "操作権限",
"Field permission": "フィールド権限",
"Scope name": "スコープ名",
"File storages": "ファイルストレージ",
"Storage display name": "ファイルストレージ名",
"Storage name": "ファイルストレージ識別子",
"Default storage": "デフォルトストレージ",
"Add storage": "ファイルストレージを追加",
"Edit storage": "ファイルストレージを編集",
"Storage base URL": "Storage base URL",
"Destination": "ファイルパス",
"Use the built-in static file server": "組み込みの静的ファイル サービスを使用する",
"Local storage": "ローカルストレージ",
"Aliyun OSS": "Aliyun OSS",
"Tencent COS": "Tencent COS",
"Amazon S3": "Amazon S3",
"Unsaved changes": "変更が保存されていません",
"Are you sure you don't want to save?": "変更を保存しなくてもよいですか?",
"Dragging": "ドラッグ",

View File

@ -133,7 +133,6 @@ export default {
"Collection name": "Имя Коллекции",
"Categories":"Категории таблиц данных",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Случайно сгенерированный и может быть изменен. Поддерживает буквы, цифры и подчеркивания, должно начинаться с буквы.",
"Storage type": "Тип Хранилища",
"Edit": "Изменить",
"Edit collection": "Изменить Коллекцию",
"Configure fields": "Конфигурировать поля",
@ -159,7 +158,6 @@ export default {
"Radio group": "Радио группа",
"Checkbox group": "Чекбокс группа",
"China region": "Китай регион",
"Attachment": "Вложение",
"Date & Time": "Дата & Время",
"Datetime": "Датавремя",
"Relation": "Связь",
@ -272,7 +270,6 @@ export default {
"Add option": "Добавить опцию",
"Related collection": "Связанная коллекция",
"Allow linking to multiple records": "Позволить прилинковать много записей",
"Allow uploading multiple files": "Позволить загружать много файлов",
"Configure calendar": "Настроить календарь",
"Title field": "Поле заголовка",
"Start date field": "Поле даты начала",
@ -371,7 +368,6 @@ export default {
"Redirect to": "Перенаправить на",
"Save action": "Сохранить действие",
"Exists": "Существуют",
"Filename": "Имя файла",
"Add condition": "Добавить правило",
"Add condition group": "Добавить группу правил",
"exists": "существуют",
@ -388,7 +384,6 @@ export default {
"Expression": "Переменная",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Введите +, -, *, /, ( ) для вычисления, введите @ чтобы открыть переменные поля.",
"Formula error.": "Ошибка формулы.",
"Accept": "Подтвердить",
"Rich Text": "Rich Text",
"Junction collection": "Коллекция Узлов",
"Leave it blank, unless you need a custom intermediate table": "Оставьте это поле пустым, если вам не нужна пользовательская промежуточная таблица.",
@ -433,19 +428,6 @@ export default {
"Action permission": "Разрешения на действия",
"Field permission": "Разрешения на поля",
"Scope name": "Имя области",
"File storages": "Файловые хранилища",
"Storage display name": "Имя храшилища на экране",
"Storage name": "Имя хранилища",
"Default storage": "Хранилище по умолчанию",
"Add storage": "Добавить хранилище",
"Edit storage": "Изменить хранилище",
"Storage base URL": "Базовый URL хранилища",
"Destination": "Назначение",
"Use the built-in static file server": "Использовать встроенный статический файл-сервер",
"Local storage": "Локальное хранилище",
"Aliyun OSS": "Aliyun OSS",
"Tencent COS": "Tencent COS",
"Amazon S3": "Amazon S3",
"Unsaved changes": "Несохраненные изменения",
"Are you sure you don't want to save?": "Вы уверены, что не хотите сохранить?",
"Dragging": "Перетаскивание",

View File

@ -132,7 +132,6 @@ export default {
"Collection display name": "Koleksiyon görünen adı",
"Collection name": "Koleksiyon adı",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Rastgele oluşturulur ve değiştirilebilir. Desteklenen içerik; harfler, sayılar ve alt çizgiler. Bir harfle başlamalıdır.",
"Storage type": "Depolama türü",
"Edit": "Düzenle",
"Edit collection": "Koleksiyon düzenle",
"Configure fields": "Alanları düzenle",
@ -158,7 +157,6 @@ export default {
"Radio group": "Radio Seçim grup",
"Checkbox group": "Checkbox grup",
"China region": "Çin bölgesi",
"Attachment": "Dosya eki",
"Date & Time": "Tarih & Saat",
"Datetime": "Datetime",
"Relation": "Relation",
@ -272,7 +270,6 @@ export default {
"Add option": "Seçenek ekle",
"Related collection": "Bağlantılı koleksiyon",
"Allow linking to multiple records": "Birden çok kayda bağlanmaya izin ver",
"Allow uploading multiple files": "Birden çok dosya yüklemeye izin ver",
"Configure calendar": "Takvimi yapılandır",
"Start date field": "Başlangıç tarihi alanı",
"End date field": "Bitiş tarihi alanı",
@ -370,7 +367,6 @@ export default {
"Redirect to": "Yönlendirilecek yer",
"Save action": "Kaydet işlemi",
"Exists": "Var olanlar",
"Filename": "Dosya adı",
"Add condition": "Koşul ekle",
"Add condition group": "Koşul grubu ekle",
"exists": "var olanlar",
@ -387,7 +383,6 @@ export default {
"Expression": "Expression",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.",
"Formula error.": "Formül hatalı.",
"Accept": "Kabul et",
"Rich Text": "Zengin Metin",
"Junction collection": "Bağlantı koleksiyonu",
"Leave it blank, unless you need a custom intermediate table": "Özel bir ara tabloya ihtiyacınız yoksa boş bırakın",
@ -432,18 +427,6 @@ export default {
"Action permission": "İşlem yetkisi",
"Field permission": "Alan yetkisi",
"Scope name": "Kapsam adı",
"File storages": "Dosya depoları",
"Storage display name": "Depo görünen adı",
"Storage name": "Depo adı",
"Default storage": "Varsayılan depo",
"Add storage": "Depo ekle",
"Edit storage": "Depo düzenle",
"Storage base URL": "Depolama temel URLsi",
"Destination": "Hedef",
"Use the built-in static file server": "Yerleşik statik dosya sunucusunu kullanın",
"Local storage": "Lokal depolama",
"Aliyun OSS": "Aliyun OSS",
"Amazon S3": "Amazon S3",
"Unsaved changes": "Değişiklikler kaydedilmedi",
"Are you sure you don't want to save?": "kaydetmek istemediğinizden emin misiniz??",
"Dragging": "Sürükleme",

View File

@ -223,8 +223,6 @@ export default {
"Field source":"来源字段",
"Preview":"预览",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。",
"Storage type": "存储类型",
"Types will be used in database": "数据库使用的类型",
"Edit": "编辑",
"Edit collection": "编辑数据表",
"Configure fields": "配置字段",
@ -267,7 +265,6 @@ export default {
"Radio group": "单选框",
"Checkbox group": "复选框",
"China region": "中国行政区",
"Attachment": "附件",
"Date & Time": "日期 & 时间",
"Datetime": "日期",
"Relation": "关系类型",
@ -437,7 +434,6 @@ export default {
"Add option": "添加选项",
"Related collection": "关系表",
"Allow linking to multiple records": "允许关联多条记录",
"Allow uploading multiple files": "允许上传多个文件",
"Daily": "每天",
"Weekly": "每周",
@ -564,7 +560,6 @@ export default {
'Save action': '保存操作',
'Exists': '存在',
'Filename': '文件名',
'Add condition': '添加条件',
'Add condition group': '添加条件分组',
'exists': '存在',
@ -578,7 +573,6 @@ export default {
'Role UID': '角色标识',
'Precision': '精确度',
'Accept': '文件格式',
'Rich Text': '富文本',
'Junction collection': '中间表',
'Leave it blank, unless you need a custom intermediate table': '默认留空,除非你需要一个自定义的中间表',
@ -637,23 +631,6 @@ export default {
'Field permission': '字段权限',
'Scope name': '数据范围名称',
'File storages': '文件存储',
'Storage display name': '文件存储名称',
'Storage name': '文件存储标识',
'Default storage': '默认存储',
'Add storage': '添加文件存储',
'Edit storage': '编辑文件存储',
'Storage base URL': 'Base URL',
'Destination': '存储路径',
'Use the built-in static file server': '使用内置静态文件服务',
'Local storage': '本地存储',
'Aliyun OSS': '阿里云 OSS',
'Amazon S3': '亚马逊 S3',
'Tencent COS': '腾讯云 COS',
'Region': '区域',
'Bucket': '存储桶',
'Path': '路径(相对)',
'Unsaved changes': '未保存修改',
'Are you sure you don\'t want to save?': '你确定不保存修改吗?',
'Dragging': '拖拽中',
@ -708,7 +685,6 @@ export default {
"Print": "打印",
"Done": "完成",
'Sign up successfully, and automatically jump to the sign in page': '注册成功,即将跳转到登录页面',
'File manager': '文件管理器',
'ACL': '访问控制',
'Collection manager': '数据表管理',
'Plugin manager': '插件管理器',

View File

@ -270,7 +270,7 @@ export default {
uiSchema: {
'x-uid': '5xf7izxjny5',
name: '9az9003ijcm',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',
@ -660,7 +660,7 @@ export default {
uiSchema: {
'x-uid': 'wosew16td91',
name: 'p82ihvtkxtf',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',

View File

@ -270,7 +270,7 @@ export default {
uiSchema: {
'x-uid': '5xf7izxjny5',
name: '9az9003ijcm',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',
@ -660,7 +660,7 @@ export default {
uiSchema: {
'x-uid': 'wosew16td91',
name: 'p82ihvtkxtf',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',

View File

@ -270,7 +270,7 @@ export default {
uiSchema: {
'x-uid': '5xf7izxjny5',
name: '9az9003ijcm',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',
@ -660,7 +660,7 @@ export default {
uiSchema: {
'x-uid': 'wosew16td91',
name: 'p82ihvtkxtf',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',

View File

@ -9,7 +9,7 @@ const mock = new MockAdapter(apiClient.axios);
const sleep = (value: number) => new Promise((resolve) => setTimeout(resolve, value));
mock.onPost('/attachments:upload').reply(async (config) => {
mock.onPost('/attachments:create').reply(async (config) => {
const total = 1024; // mocked file size
for (const progress of [0, 0.2, 0.4, 0.6, 0.8, 1]) {
await sleep(500);

View File

@ -16,7 +16,7 @@ const schema = {
'x-decorator': 'FormItem',
'x-component': 'Preview',
'x-component-props': {
action: 'attachments:upload',
action: 'attachments:create',
// multiple: true,
},
'x-reactions': {

View File

@ -59,7 +59,7 @@ const schema = {
'x-decorator': 'FormItem',
'x-component': 'Preview',
'x-component-props': {
action: 'attachments:upload',
action: 'attachments:create',
multiple: true,
},
'x-reactions': [

View File

@ -180,9 +180,9 @@ const InternalRemoteSelect = connect(
const options = useMemo(() => {
if (!data?.data?.length) {
return value !== undefined && value !== null ? (Array.isArray(value) ? value : [value]) : [];
return value != null ? (Array.isArray(value) ? value : [value]) : [];
}
const valueOptions = (value !== undefined && value !== null && (Array.isArray(value) ? value : [value])) || [];
const valueOptions = (value != null && (Array.isArray(value) ? value : [value])) || [];
return uniqBy(data?.data?.concat(valueOptions) || [], fieldNames.value);
}, [data?.data, getOptionsByFieldNames, normalizeOptions, value]);
const onDropdownVisibleChange = () => {

View File

@ -270,7 +270,7 @@ export default {
uiSchema: {
'x-uid': '5xf7izxjny5',
name: '9az9003ijcm',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',
@ -660,7 +660,7 @@ export default {
uiSchema: {
'x-uid': 'wosew16td91',
name: 'p82ihvtkxtf',
'x-component-props': { multiple: true, action: 'attachments:upload' },
'x-component-props': { multiple: true, action: 'attachments:create' },
type: 'array',
'x-component': 'Upload.Attachment',
title: '附件',

View File

@ -9,7 +9,7 @@ const mock = new MockAdapter(apiClient.axios);
const sleep = (value: number) => new Promise((resolve) => setTimeout(resolve, value));
mock.onPost('/attachments:upload').reply(async (config) => {
mock.onPost('/attachments:create').reply(async (config) => {
const total = 1024; // mocked file size
for (const progress of [0, 0.2, 0.4, 0.6, 0.8, 1]) {
await sleep(500);

View File

@ -15,7 +15,7 @@ const schema = {
'x-decorator': 'FormItem',
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:upload',
action: 'attachments:create',
// multiple: true,
},
'x-reactions': {

View File

@ -58,7 +58,7 @@ const schema = {
'x-decorator': 'FormItem',
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:upload',
action: 'attachments:create',
multiple: true,
},
'x-reactions': [

View File

@ -94,7 +94,7 @@ const schema: ISchema = {
'x-decorator': 'FormItem',
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:upload',
action: 'attachments:create',
multiple: false,
// accept: 'jpg,png'
},
@ -187,7 +187,7 @@ const schema2: ISchema = {
'x-decorator': 'FormItem',
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:upload',
action: 'attachments:create',
multiple: false,
// accept: 'jpg,png'
},

View File

@ -113,15 +113,19 @@ export class MockServer extends Application {
const queryString = qs.stringify(restParams, { arrayFormat: 'brackets' });
let request;
switch (method) {
case 'upload':
return agent.post(`${url}?${queryString}`).attach('file', file).field(values);
case 'list':
case 'get':
return agent.get(`${url}?${queryString}`);
request = agent.get(`${url}?${queryString}`);
break;
default:
return agent.post(`${url}?${queryString}`).send(values);
request = agent.post(`${url}?${queryString}`);
break;
}
return file ? request.attach('file', file).field(values) : request.send(values);
};
},
},

View File

@ -1,6 +1,8 @@
import { SchemaComponent } from '@nocobase/client';
import { Card } from 'antd';
import React from 'react';
import { Card } from 'antd';
import { SchemaComponent } from '@nocobase/client';
import { StorageOptions } from './StorageOptions';
import { storageSchema } from './schemas/storage';

View File

@ -12,7 +12,7 @@ const schema = {
[uid()]: {
'x-component': 'Action.Drawer',
type: 'void',
title: '{{t("File storages")}}',
title: '{{t("File manager")}}',
properties: {
storageSchema,
},
@ -31,7 +31,7 @@ export const FileStorageShortcut = () => {
setVisible(true);
}}
icon={<FileOutlined />}
title={t('File storages')}
title={t('File manager')}
/>
<SchemaComponent components={{ StorageOptions }} schema={schema} />
</ActionContext.Provider>

View File

@ -2,22 +2,23 @@ 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';
import { NAMESPACE } from './locale';
const schema = {
local: {
properties: {
documentRoot: {
title: '{{t("Destination")}}',
title: `{{t("Destination", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
default: 'uploads',
},
serve: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': '{{t("Use the built-in static file server")}}',
'x-content': `{{t("Use the built-in static file server", { ns: "${NAMESPACE}" })}}`,
default: true,
},
},
@ -25,28 +26,28 @@ const schema = {
'ali-oss': {
properties: {
region: {
title: '{{t("Region")}}',
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
accessKeyId: {
title: '{{t("AccessKey ID")}}',
title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
accessKeySecret: {
title: '{{t("AccessKey Secret")}}',
title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Password',
required: true,
},
bucket: {
title: '{{t("Bucket")}}',
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
@ -57,28 +58,28 @@ const schema = {
'tx-cos': {
properties: {
Region: {
title: '{{t("Region")}}',
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
SecretId: {
title: '{{t("SecretId")}}',
title: `{{t("SecretId", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
SecretKey: {
title: '{{t("SecretKey")}}',
title: `{{t("SecretKey", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Password',
required: true,
},
Bucket: {
title: '{{t("Bucket")}}',
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
@ -89,39 +90,33 @@ const schema = {
s3: {
properties: {
region: {
title: '{{t("Region")}}',
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
accessKeyId: {
title: '{{t("AccessKey ID")}}',
title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
secretAccessKey: {
title: '{{t("AccessKey Secret")}}',
title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Password',
required: true,
},
bucket: {
title: '{{t("Bucket")}}',
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
endpoint: {
title: '{{t("Endpoint")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
},
},
};

View File

@ -1,9 +1,11 @@
import {
CollectionManagerProvider,
PluginManagerContext,
SchemaComponentOptions,
SchemaInitializerContext,
SchemaInitializerProvider,
SettingsCenterProvider,
registerField,
registerTemplate,
useCollection,
} from '@nocobase/client';
@ -14,12 +16,16 @@ import { FileStorageShortcut } from './FileStorageShortcut';
import * as hooks from './hooks';
import * as initializers from './initializers';
import * as templates from './templates';
import { NAMESPACE } from './locale';
import { attachment } from './interfaces/attachment';
// 注册之后就可以在 Crete collection 按钮中选择创建了
forEach(templates, (template, key: string) => {
registerTemplate(key, template);
});
registerField(attachment.group, 'attachment', attachment);
export default function (props) {
const initializes = useContext(SchemaInitializerContext);
const hasUploadAction = initializes.TableActionInitializers.items[0].children.some(
@ -38,6 +44,7 @@ export default function (props) {
},
},
visible: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const collection = useCollection();
return collection.template === 'file';
},
@ -49,11 +56,11 @@ export default function (props) {
<SettingsCenterProvider
settings={{
'file-manager': {
title: '{{t("File manager")}}',
title: `{{t("File manager", { ns: "${NAMESPACE}" })}}`,
icon: 'FileOutlined',
tabs: {
storages: {
title: '{{t("File storages")}}',
title: `{{t("File storage", { ns: "${NAMESPACE}" })}}`,
component: FileStoragePane,
},
},
@ -68,9 +75,11 @@ export default function (props) {
},
}}
>
<SchemaComponentOptions scope={hooks}>
<SchemaInitializerProvider components={initializers}>{props.children}</SchemaInitializerProvider>
</SchemaComponentOptions>
<CollectionManagerProvider interfaces={{ attachment }}>
<SchemaComponentOptions scope={hooks}>
<SchemaInitializerProvider components={initializers}>{props.children}</SchemaInitializerProvider>
</SchemaComponentOptions>
</CollectionManagerProvider>
</PluginManagerContext.Provider>
</SettingsCenterProvider>
);

View File

@ -17,7 +17,7 @@ export const UploadActionInitializer = (props) => {
icon: 'UploadOutlined',
},
properties: {
modal: {
drawer: {
type: 'void',
title: '{{ t("Upload files") }}',
'x-component': 'Action.Container',

View File

@ -1,13 +1,13 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { defaultProps, operators } from './properties';
import { IField } from './types';
import { IField, interfacesProperties } from '@nocobase/client';
import { NAMESPACE } from '../locale';
export const attachment: IField = {
name: 'attachment',
type: 'object',
group: 'media',
title: '{{t("Attachment")}}',
title: `{{t("Attachment", { ns: "${NAMESPACE}" })}}`,
isAssociation: true,
default: {
type: 'belongsToMany',
@ -17,17 +17,19 @@ export const attachment: IField = {
type: 'array',
// title,
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:upload',
},
'x-component-props': {},
},
},
availableTypes: ['belongsToMany'],
schemaInitialize(schema: ISchema, { block }) {
schemaInitialize(schema: ISchema, { block, field }) {
if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {};
schema['x-component-props']['size'] = 'small';
}
schema['x-component-props']['action'] = `${field.target}:create${
field.storage ? `?attachementField=${field.collectionName}.${field.name}` : ''
}`;
},
initialize: (values: any) => {
if (!values.through) {
@ -47,21 +49,42 @@ export const attachment: IField = {
}
},
properties: {
...defaultProps,
...interfacesProperties.defaultProps,
'uiSchema.x-component-props.accept': {
type: 'string',
title: '{{t("Accept")}}',
title: `{{t("MIME type", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input',
'x-decorator': 'FormItem',
description: 'Example: .doc,.docx',
description: 'Example: image/png',
default: 'image/*',
},
'uiSchema.x-component-props.multiple': {
type: 'boolean',
'x-content': "{{t('Allow uploading multiple files')}}",
'x-content': `{{t('Allow uploading multiple files', { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
default: true,
},
storage: {
type: 'string',
title: `{{t("Storage", { ns: "${NAMESPACE}" })}}`,
description: `{{t('Default storage will be used when not selected', { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RemoteSelect',
'x-component-props': {
service: {
resource: 'storages',
params: {
// pageSize: -1
},
},
manual: false,
fieldNames: {
label: 'title',
value: 'name',
},
},
},
},
filterable: {
children: [
@ -80,10 +103,10 @@ export const attachment: IField = {
},
{
name: 'filename',
title: '{{t("Filename")}}',
operators: operators.string,
title: `{{t("Filename", { ns: "${NAMESPACE}" })}}`,
operators: interfacesProperties.operators.string,
schema: {
title: '{{t("Filename")}}',
title: `{{t("Filename", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-component': 'Input',
},

View File

@ -0,0 +1,21 @@
export default {
'File manager': 'File manager',
'Attachment': 'Attachment',
'MIME type': 'MIME type',
'Storage display name': 'Storage display name',
'Storage name': 'Storage name',
'Storage type': 'Storage type',
'Default storage': 'Default storage',
'Storage base URL': 'Storage base URL',
'Destination': 'Destination',
'Use the built-in static file server': 'Use the built-in static file server',
'Local storage': 'Local storage',
'Aliyun OSS': 'Aliyun OSS',
'Tencent COS': 'Tencent COS',
'Amazon S3': 'Amazon S3',
'Region': 'Region',
'Bucket': 'Bucket',
'Path': 'Path',
'Filename': 'Filename',
'Will be used for API': 'Will be used for API',
};

View File

@ -1,11 +1,7 @@
import { i18n } from '@nocobase/client';
import { useTranslation } from 'react-i18next';
import zhCN from './zh-CN';
export const NAMESPACE = 'file-manager';
i18n.addResources('zh-CN', NAMESPACE, zhCN);
export function useFmTranslation() {
return useTranslation(NAMESPACE);
}

View File

@ -0,0 +1,18 @@
export default {
'File manager': 'ファイルストレージ',
'Attachment': '添付ファイル',
'MIME type': 'ファイル形式',
'Allow uploading multiple files': '複数ファイルのアップロードを許可する',
'Storage display name': 'ファイルストレージ名',
'Storage name': 'ファイルストレージ識別子',
'Storage type': 'ストレージタイプ',
'Default storage': 'デフォルトストレージ',
'Storage base URL': 'Storage base URL',
'Destination': 'ファイルパス',
'Use the built-in static file server': '組み込みの静的ファイル サービスを使用する',
'Local storage': 'ローカルストレージ',
'Aliyun OSS': 'Aliyun OSS',
'Tencent COS': 'Tencent COS',
'Amazon S3': 'Amazon S3',
'Filename': 'ファイル名',
};

View File

@ -0,0 +1,18 @@
export default {
'File manager': 'Файловые хранилища',
'Attachment': 'Вложение',
'MIME type': 'Подтвердить',
'Allow uploading multiple files': 'Позволить загружать много файлов',
'Storage display name': 'Имя храшилища на экране',
'Storage name': 'Имя хранилища',
'Storage type': 'Тип Хранилища',
'Default storage': 'Хранилище по умолчанию',
'Storage base URL': 'Базовый URL хранилища',
'Destination': 'Назначение',
'Use the built-in static file server': 'Использовать встроенный статический файл-сервер',
'Local storage': 'Локальное хранилище',
'Aliyun OSS': 'Aliyun OSS',
'Amazon S3': 'Amazon S3',
'Tencent COS': 'Tencent COS',
'Filename': 'Имя файла',
}

View File

@ -0,0 +1,17 @@
export default {
'File manager': 'Dosya depoları',
'Attachment': 'Dosya eki',
'MIME type': 'Kabul et',
'Allow uploading multiple files': 'Birden çok dosya yüklemeye izin ver',
'Storage name': 'Depo adı',
'Storage type': 'Depolama türü',
'Default storage': 'Varsayılan depo',
'Storage base URL': 'Depolama temel URLsi',
'Destination': 'Hedef',
'Use the built-in static file server': 'Yerleşik statik dosya sunucusunu kullanın',
'Local storage': 'Lokal depolama',
'Aliyun OSS': 'Aliyun OSS',
'Amazon S3': 'Amazon S3',
'Tencent COS': 'Tencent COS',
'Filename': 'Dosya adı',
};

View File

@ -1,11 +1,30 @@
const locale = {
export default {
'File collection': '文件数据表',
'File name': '文件名',
'Extension name': '扩展名',
Size: '文件大小',
'Mime Type': 'Mime 类型',
'MIME type': 'MIME 类型',
URL: 'URL',
'File storage': '文件存储',
'File manager': '文件管理器',
Attachment: '附件',
'Allow uploading multiple files': '允许上传多个文件',
Storage: '存储空间',
Storages: '存储空间',
'Storage name': '存储空间标识',
'Storage type': '存储类型',
'Default storage': '默认存储空间',
'Storage base URL': '访问 URL 基础',
Destination: '上传目标文件夹',
'Use the built-in static file server': '使用内置静态文件服务',
'Local storage': '本地存储',
'Aliyun OSS': '阿里云 OSS',
'Amazon S3': '亚马逊 S3',
'Tencent COS': '腾讯云 COS',
Region: '区域',
Bucket: '存储桶',
Path: '相对路径',
Filename: '文件名',
'Will be used for API': '将用于 API',
'Default storage will be used when not selected': '留空将使用默认存储空间',
};
export default locale;

View File

@ -1,6 +1,7 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { useActionContext, useRequest } from '@nocobase/client';
import { NAMESPACE } from '../locale';
const collection = {
name: 'storages',
@ -10,7 +11,7 @@ const collection = {
name: 'title',
interface: 'input',
uiSchema: {
title: '{{t("Storage display name")}}',
title: '{{t("Title")}}',
type: 'string',
'x-component': 'Input',
required: true,
@ -21,7 +22,8 @@ const collection = {
name: 'name',
interface: 'input',
uiSchema: {
title: '{{t("Storage name")}}',
title: `{{t("Storage name", { ns: "${NAMESPACE}" })}}`,
descriptions: `{{t("Will be used for API", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-component': 'Input',
} as ISchema,
@ -31,15 +33,15 @@ const collection = {
name: 'type',
interface: 'select',
uiSchema: {
title: '{{t("Storage type")}}',
title: `{{t("Storage type", { ns: "${NAMESPACE}" })}}`,
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' },
{ label: '{{t("Tencent COS")}}', value: 'tx-cos' },
{ label: `{{t("Local storage", { ns: "${NAMESPACE}" })}}`, value: 'local' },
{ label: `{{t("Aliyun OSS", { ns: "${NAMESPACE}" })}}`, value: 'ali-oss' },
{ label: `{{t("Amazon S3", { ns: "${NAMESPACE}" })}}`, value: 's3' },
{ label: `{{t("Tencent COS", { ns: "${NAMESPACE}" })}}`, value: 'tx-cos' },
],
} as ISchema,
},
@ -48,7 +50,7 @@ const collection = {
name: 'baseUrl',
interface: 'input',
uiSchema: {
title: '{{t("Storage base URL")}}',
title: `{{t("Storage base URL", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-component': 'Input',
required: true,
@ -59,7 +61,7 @@ const collection = {
name: 'path',
interface: 'input',
uiSchema: {
title: '{{t("Path")}}',
title: `{{t("Path", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-component': 'Input',
} as ISchema,
@ -69,7 +71,7 @@ const collection = {
name: 'default',
interface: 'boolean',
uiSchema: {
title: '{{t("Default storage")}}',
title: `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
type: 'boolean',
'x-component': 'Checkbox',
} as ISchema,
@ -117,14 +119,14 @@ export const storageSchema: ISchema = {
'x-component-props': {
useAction: '{{ cm.useBulkDestroyAction }}',
confirm: {
title: "{{t('Delete storage')}}",
title: "{{t('Delete')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
create: {
type: 'void',
title: '{{t("Add storage")}}',
title: '{{t("Add new")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
@ -148,7 +150,7 @@ export const storageSchema: ISchema = {
);
},
},
title: '{{t("Add storage")}}',
title: '{{t("Add new")}}',
properties: {
title: {
'x-component': 'CollectionField',
@ -180,7 +182,7 @@ export const storageSchema: ISchema = {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
title: '',
'x-content': '{{t("Default storage")}}',
'x-content': `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
},
footer: {
type: 'void',
@ -284,7 +286,7 @@ export const storageSchema: ISchema = {
'x-decorator-props': {
useValues: '{{ cm.useValuesFromRecord }}',
},
title: '{{t("Edit storage")}}',
title: '{{t("Edit")}}',
properties: {
title: {
'x-component': 'CollectionField',
@ -349,8 +351,8 @@ export const storageSchema: ISchema = {
'x-component': 'Action.Link',
'x-component-props': {
confirm: {
title: "{{t('Delete role')}}",
content: "{{t('Are you sure you want to delete it?')}}",
title: '{{t("Delete")}}',
content: '{{t("Are you sure you want to delete it?")}}',
},
useAction: '{{cm.useDestroyAction}}',
},

View File

@ -72,7 +72,7 @@ export const file = {
deletable: false,
uiSchema: {
type: 'string',
title: `{{t("Mime type", { ns: "${NAMESPACE}" })}}`,
title: `{{t("MIME type", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input',
'x-read-pretty': true,
},

View File

@ -3,14 +3,15 @@ import path from 'path';
import { getApp } from '.';
import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants';
const { LOCAL_STORAGE_BASE_URL, APP_PORT = '13000' } = process.env;
const { LOCAL_STORAGE_BASE_URL, LOCAL_STORAGE_DEST = 'storage/uploads', APP_PORT = '13000' } = process.env;
const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `http://localhost:${APP_PORT}/uploads`;
const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `http://localhost:${APP_PORT}/storage/uploads`;
describe('action', () => {
let app;
let agent;
let db;
let StorageModel;
beforeEach(async () => {
app = await getApp({
@ -19,12 +20,14 @@ describe('action', () => {
agent = app.agent();
db = app.db;
const Storage = db.getCollection('storages').model;
await Storage.create({
name: `local1_${db.getTablePrefix()}`,
StorageModel = db.getCollection('storages').model;
await StorageModel.create({
name: 'local1',
type: STORAGE_TYPE_LOCAL,
baseUrl: DEFAULT_LOCAL_BASE_URL,
default: true,
rules: {
size: 1024,
},
});
});
@ -32,9 +35,9 @@ describe('action', () => {
await db.close();
});
describe('direct attachment', () => {
describe('default storage', () => {
it('upload file should be ok', async () => {
const { body } = await agent.resource('attachments').upload({
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
});
@ -45,6 +48,7 @@ describe('action', () => {
size: 13,
mimetype: 'text/plain',
meta: {},
storageId: 1,
};
// 文件上传和解析是否正常
@ -52,25 +56,26 @@ describe('action', () => {
// 文件的 url 是否正常生成
expect(body.data.url).toBe(`${DEFAULT_LOCAL_BASE_URL}${body.data.path}/${body.data.filename}`);
const Attachment = db.getCollection('attachments').model;
const Attachment = db.getModel('attachments');
const attachment = await Attachment.findOne({
where: { id: body.data.id },
include: ['storage'],
});
const storage = attachment.get('storage');
// 文件的数据是否正常保存
expect(attachment).toMatchObject(matcher);
// 关联的存储引擎是否正确
const storage = await attachment.getStorage();
expect(storage).toMatchObject({
type: 'local',
options: {},
options: { documentRoot: LOCAL_STORAGE_DEST },
rules: {},
path: '',
baseUrl: DEFAULT_LOCAL_BASE_URL,
default: true,
});
const { documentRoot = 'uploads' } = storage.options || {};
const { documentRoot = 'storage/uploads' } = storage.options || {};
const destPath = path.resolve(
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
storage.path,
@ -86,59 +91,70 @@ describe('action', () => {
});
});
describe.skip('belongsTo attachment', () => {
it('upload with associatedIndex, fail as 400 because file mimetype does not match', async () => {
const User = db.getCollection('users').model;
const user = await User.create();
const response = await agent.resource('users.avatar').upload({
associatedIndex: user.id,
file: path.resolve(__dirname, './files/text.txt'),
describe('specific storage', () => {
it('fail as 400 because file size greater than rules', async () => {
db.collection({
name: 'customers',
fields: [
{
name: 'avatar',
type: 'belongsTo',
target: 'attachments',
storage: 'local1',
},
],
});
const response = await agent.resource('attachments').create({
attachmentField: 'customers.avatar',
file: path.resolve(__dirname, './files/image.jpg'),
});
expect(response.status).toBe(400);
});
it.skip('upload with associatedIndex', async () => {
const User = db.getCollection('users').model;
const user = await User.create();
const { body } = await agent.resource('users.avatar').upload({
associatedIndex: user.id,
file: path.resolve(__dirname, './files/image.png'),
values: { width: 100, height: 100 },
it('fail as 400 because file mimetype does not match', async () => {
const textStorage = await StorageModel.create({
name: 'local2',
type: STORAGE_TYPE_LOCAL,
baseUrl: DEFAULT_LOCAL_BASE_URL,
rules: {
mimetype: ['text/*'],
},
});
const matcher = {
title: 'image',
extname: '.png',
path: '',
size: 255,
mimetype: 'image/png',
// TODO(optimize): 可以考虑使用 qs 的 decoder 来进行类型解析
// see: https://github.com/ljharb/qs/issues/91
// 或考虑使用 query-string 库的 parseNumbers 等配置项
meta: { width: '100', height: '100' },
};
// 上传正常返回
expect(body.data).toMatchObject(matcher);
// 由于初始没有外键,无法获取
// await user.getAvatar()
const updatedUser = await User.findByPk(user.id, {
include: ['avatar'],
db.collection({
name: 'customers',
fields: [
{
name: 'avatar',
type: 'belongsTo',
target: 'attachments',
storage: textStorage.name,
},
],
});
// 外键更新正常
expect(updatedUser.get('avatar').id).toBe(body.data.id);
// await db.sync();
const response = await agent.resource('attachments').create({
attachmentField: 'customers.avatar',
file: path.resolve(__dirname, './files/image.jpg'),
});
expect(response.status).toBe(400);
});
it.skip('upload to assoiciated field and storage with full base url should be ok', async () => {
it('upload to storage which is not default', async () => {
const BASE_URL = `http://localhost:${APP_PORT}/another-uploads`;
const storageName = 'local_private';
const urlPath = 'test/path';
const Storage = db.getCollection('storages').model;
// 动态添加 storage
await Storage.create({
name: storageName,
const storage = await StorageModel.create({
name: 'local_private',
type: STORAGE_TYPE_LOCAL,
rules: {
mimetype: ['text/*'],
},
path: urlPath,
baseUrl: BASE_URL,
options: {
@ -146,12 +162,21 @@ describe('action', () => {
},
});
const User = db.getCollection('users').model;
const user = await User.create();
const { body } = await agent.resource('users.pubkeys').upload({
associatedIndex: user.id,
db.collection({
name: 'customers',
fields: [
{
name: 'file',
type: 'belongsTo',
target: 'attachments',
storage: storage.name,
},
],
});
const { body } = await agent.resource('attachments').create({
attachmentField: 'customers.file',
file: path.resolve(__dirname, './files/text.txt'),
values: {},
});
// 文件的 url 是否正常生成
@ -161,27 +186,5 @@ describe('action', () => {
const content = await agent.get(url);
expect(content.text).toBe('Hello world!\n');
});
// TODO(bug): 没有 associatedIndex 时路径解析资源名称不对,无法进入 action
it.skip('upload without associatedIndex', async () => {
const { body } = await agent.resource('users.avatar').upload({
file: path.resolve(__dirname, './files/image.png'),
values: { width: 100, height: 100 },
});
const matcher = {
title: 'image',
extname: '.png',
path: '',
size: 255,
mimetype: 'image/png',
// TODO(optimize): 可以考虑使用 qs 的 decoder 来进行类型解析
// @see: https://github.com/ljharb/qs/issues/91
// 或考虑使用 query-string 库的 parseNumbers 等配置项
meta: { width: '100', height: '100' },
storage_id: 1,
};
// 上传返回正常
expect(body.data).toMatchObject(matcher);
});
});
});

View File

@ -21,7 +21,7 @@ describe('storage:ali-oss', () => {
const Storage = db.getCollection('storages').model;
storage = await Storage.create({
...aliossStorage.defaults(),
name: `ali-oss_${db.getTablePrefix()}`,
name: 'ali-oss',
default: true,
path: 'test/path',
});
@ -33,7 +33,7 @@ describe('storage:ali-oss', () => {
describe('direct attachment', () => {
itif('upload file should be ok', async () => {
const { body } = await agent.resource('attachments').upload({
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, '../files/text.txt'),
});

View File

@ -21,7 +21,7 @@ describe('storage:s3', () => {
const Storage = db.getCollection('storages').model;
storage = await Storage.create({
...s3Storage.defaults(),
name: `s3_${db.getTablePrefix()}`,
name: 's3',
default: true,
path: 'test/path',
});
@ -33,7 +33,7 @@ describe('storage:s3', () => {
describe('direct attachment', () => {
itif('upload file should be ok', async () => {
const { body } = await agent.resource('attachments').upload({
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, '../files/text.txt'),
});

View File

@ -21,7 +21,7 @@ describe('storage:tx-cos', () => {
const Storage = db.getCollection('storages').model;
storage = await Storage.create({
...txStorage.defaults(),
name: `tx-cos_${db.getTablePrefix()}`,
name: 'tx-cos',
default: true,
path: 'test/path',
});
@ -33,7 +33,7 @@ describe('storage:tx-cos', () => {
describe('direct attachment', () => {
itif('upload file should be ok', async () => {
const { body } = await agent.resource('attachments').upload({
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, '../files/text.txt'),
});

View File

@ -11,35 +11,16 @@ export default {
type: 'belongsTo',
name: 'avatar',
target: 'attachments',
attachment: {
// storage 为配置的默认引擎
rules: {
size: 1024 * 10,
mimetype: ['image/png'],
},
},
},
{
type: 'belongsToMany',
name: 'pubkeys',
target: 'attachments',
attachment: {
storage: 'local_private',
rules: {
mimetype: ['text/*'],
},
},
},
{
type: 'belongsToMany',
name: 'photos',
target: 'attachments',
attachment: {
rules: {
size: 1024 * 100,
mimetype: ['image/*'],
},
},
},
],
} as CollectionOptions;

View File

@ -0,0 +1,111 @@
import multer from '@koa/multer';
import { Context, Next } from '@nocobase/actions';
import path from 'path';
import { DEFAULT_MAX_FILE_SIZE, FILE_FIELD_NAME, LIMIT_FILES } from '../constants';
import * as Rules from '../rules';
import { getStorageConfig } from '../storages';
// TODO(optimize): 需要优化错误处理,计算失败后需要抛出对应错误,以便程序处理
function getFileFilter(storage) {
return (req, file, cb) => {
// size 交给 limits 处理
const { size, ...rules } = storage.rules;
const ruleKeys = Object.keys(rules);
const result =
!ruleKeys.length || !ruleKeys.some((key) => typeof Rules[key] !== 'function' || !Rules[key](file, rules[key]));
cb(null, result);
};
}
function getFileData(ctx: Context) {
const { [FILE_FIELD_NAME]: file, storage } = ctx;
if (!file) {
return ctx.throw(400, 'file validation failed');
}
const storageConfig = getStorageConfig(storage.type);
const { [storageConfig.filenameKey || 'filename']: name } = file;
// make compatible filename across cloud service (with path)
const filename = path.basename(name);
const extname = path.extname(filename);
const urlPath = storage.path ? storage.path.replace(/^([^/])/, '/$1') : '';
return {
title: file.originalname.replace(extname, ''),
filename,
extname,
// TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path
path: storage.path,
size: file.size,
// 直接缓存起来
url: `${storage.baseUrl}${urlPath}/${filename}`,
mimetype: file.mimetype,
// @ts-ignore
meta: ctx.request.body,
storageId: storage.id,
...(storageConfig.getFileData ? storageConfig.getFileData(file) : {}),
};
}
export async function middleware(ctx: Context, next: Next) {
const { resourceName, actionName } = ctx.action;
const { attachmentField } = ctx.action.params;
const collection = ctx.db.getCollection(resourceName);
if (collection?.options?.template !== 'file' || !['upload', 'create'].includes(actionName)) {
return next();
}
const storageName = ctx.db.getFieldByPath(attachmentField)?.options?.storage || collection.options.storage;
const StorageRepo = ctx.db.getRepository('storages');
const storage = await StorageRepo.findOne({ filter: storageName ? { name: storageName } : { default: true } });
ctx.storage = storage;
await multipart(ctx, async () => {
const values = getFileData(ctx);
ctx.action.mergeParams({
values,
});
await next();
});
}
async function multipart(ctx: Context, next: Next) {
const { storage } = ctx;
if (!storage) {
console.error('[file-manager] no linked or default storage provided');
return ctx.throw(500);
}
const storageConfig = getStorageConfig(storage.type);
if (!storageConfig) {
console.error(`[file-manager] storage type "${storage.type}" is not defined`);
return ctx.throw(500);
}
const multerOptions = {
fileFilter: getFileFilter(storage),
limits: {
fileSize: storage.rules.size ?? DEFAULT_MAX_FILE_SIZE,
// 每次只允许提交一个文件
files: LIMIT_FILES,
},
storage: storageConfig.make(storage),
};
const upload = multer(multerOptions).single(FILE_FIELD_NAME);
try {
// NOTE: empty next and invoke after success
await upload(ctx, () => {});
} catch (err) {
if (err.name === 'MulterError') {
return ctx.throw(400, err);
}
return ctx.throw(500);
}
await next();
}

View File

@ -0,0 +1,7 @@
import actions from '@nocobase/actions';
import { middleware } from './attachments';
export default function ({ app }) {
app.resourcer.use(middleware);
app.resourcer.registerActionHandler('upload', actions.create);
}

View File

@ -1,169 +0,0 @@
import multer from '@koa/multer';
import { Context, Next } from '@nocobase/actions';
import path from 'path';
import { FILE_FIELD_NAME, LIMIT_FILES, LIMIT_MAX_FILE_SIZE } from '../constants';
import * as Rules from '../rules';
import { getStorageConfig } from '../storages';
function getRules(ctx: Context) {
const { resourceField } = ctx;
if (!resourceField) {
return ctx.storage.rules;
}
const { rules = {} } = resourceField.options.attachment || {};
return Object.assign({}, ctx.storage.rules, rules);
}
// TODO(optimize): 需要优化错误处理,计算失败后需要抛出对应错误,以便程序处理
function getFileFilter(ctx: Context) {
return (req, file, cb) => {
// size 交给 limits 处理
const { size, ...rules } = getRules(ctx);
const ruleKeys = Object.keys(rules);
const result =
!ruleKeys.length ||
!ruleKeys.some((key) => typeof Rules[key] !== 'function' || !Rules[key](file, rules[key], ctx));
cb(null, result);
};
}
const isUploadAction = (ctx: Context) => {
const { resourceName, actionName } = ctx.action;
if (actionName === 'upload' && resourceName === 'attachments') {
return true;
}
const collection = ctx.db.getCollection(resourceName);
if (collection?.options?.template === 'file' && ['upload', 'create'].includes(actionName)) {
return true;
}
};
export async function middleware(ctx: Context, next: Next) {
const { resourceName } = ctx.action;
const collection = ctx.db.getCollection(resourceName);
if (!isUploadAction(ctx)) {
return next();
}
const Storage = ctx.db.getCollection('storages');
let storage;
if (collection.options.storage) {
storage = await Storage.repository.findOne({ filter: { name: collection.options.storage } });
} else {
storage = await Storage.repository.findOne({ filter: { default: true } });
}
if (!storage) {
console.error('[file-manager] no default or linked storage provided');
return ctx.throw(500);
}
// 传递已取得的存储引擎,避免重查
ctx.storage = storage;
const storageConfig = getStorageConfig(storage.type);
if (!storageConfig) {
console.error(`[file-manager] storage type "${storage.type}" is not defined`);
return ctx.throw(500);
}
const multerOptions = {
fileFilter: getFileFilter(ctx),
limits: {
fileSize: Math.min(getRules(ctx).size || LIMIT_MAX_FILE_SIZE, LIMIT_MAX_FILE_SIZE),
// 每次只允许提交一个文件
files: LIMIT_FILES,
},
storage: storageConfig.make(storage),
};
const upload = multer(multerOptions).single(FILE_FIELD_NAME);
return upload(ctx, next);
}
export async function createAction(ctx: Context, next: Next) {
if (!isUploadAction(ctx)) {
return next();
}
const { [FILE_FIELD_NAME]: file, storage } = ctx;
if (!file) {
return ctx.throw(400, 'file validation failed');
}
const storageConfig = getStorageConfig(storage.type);
const { [storageConfig.filenameKey || 'filename']: name } = file;
// make compatible filename across cloud service (with path)
const filename = path.basename(name);
const extname = path.extname(filename);
const urlPath = storage.path ? storage.path.replace(/^([^\/])/, '/$1') : '';
const values = {
title: file.originalname.replace(extname, ''),
filename,
extname,
// TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path
path: storage.path,
size: file.size,
// 直接缓存起来
url: `${storage.baseUrl}${urlPath}/${filename}`,
mimetype: file.mimetype,
storageId: storage.id,
// @ts-ignore
meta: ctx.request.body,
...(storageConfig.getFileData ? storageConfig.getFileData(file) : {}),
};
ctx.action.mergeParams({
values,
});
await next();
}
export async function uploadAction(ctx: Context, next: Next) {
const { [FILE_FIELD_NAME]: file, storage } = ctx;
if (!file) {
return ctx.throw(400, 'file validation failed');
}
const storageConfig = getStorageConfig(storage.type);
const { [storageConfig.filenameKey || 'filename']: name } = file;
// make compatible filename across cloud service (with path)
const filename = path.basename(name);
const extname = path.extname(filename);
const urlPath = storage.path ? storage.path.replace(/^([^\/])/, '/$1') : '';
const data = {
title: file.originalname.replace(extname, ''),
filename,
extname,
// TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path
path: storage.path,
size: file.size,
// 直接缓存起来
url: `${storage.baseUrl}${urlPath}/${filename}`,
mimetype: file.mimetype,
storageId: storage.id,
// @ts-ignore
meta: ctx.request.body,
...(storageConfig.getFileData ? storageConfig.getFileData(file) : {}),
};
const fileData = await ctx.db.sequelize.transaction(async (transaction) => {
const { resourceName } = ctx.action;
const repository = ctx.db.getRepository(resourceName);
const result = await repository.create({
values: {
...data,
},
transaction,
});
return result;
});
ctx.body = fileData;
await next();
}

View File

@ -7,6 +7,7 @@ export default {
title: '文件管理器',
createdBy: true,
updatedBy: true,
template: 'file',
fields: [
{
comment: '用户文件名(不含扩展名)',

View File

@ -1,6 +1,6 @@
export const FILE_FIELD_NAME = 'file';
export const LIMIT_FILES = 1;
export const LIMIT_MAX_FILE_SIZE = 1024 * 1024 * 1024;
export const DEFAULT_MAX_FILE_SIZE = 1024 * 1024 * 1024;
export const STORAGE_TYPE_LOCAL = 'local';
export const STORAGE_TYPE_ALI_OSS = 'ali-oss';

View File

@ -1,5 +1,5 @@
import match from 'mime-match';
export default function (file, options: string | string[] = '*', ctx): boolean {
export default function (file, options: string | string[] = '*'): boolean {
return options.toString().split(',').some(match(file.mimetype));
}

View File

@ -1,12 +1,12 @@
import { Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { createAction, uploadAction, middleware as uploadMiddleware } from './actions/upload';
import initActions from './actions';
import { STORAGE_TYPE_LOCAL } from './constants';
import { getStorageConfig } from './storages';
export default class PluginFileManager extends Plugin {
storageType() {
return process.env.DEFAULT_STORAGE_TYPE;
return process.env.DEFAULT_STORAGE_TYPE ?? 'local';
}
async install() {
@ -41,14 +41,16 @@ export default class PluginFileManager extends Plugin {
actions: ['storages:*'],
});
this.app.acl.allow('attachments', 'upload', 'loggedIn');
initActions(this);
this.app.resourcer.use(uploadMiddleware);
this.app.resourcer.use(createAction);
this.app.resourcer.registerActionHandler('upload', uploadAction);
// this.app.acl.allow('attachments', 'upload', 'loggedIn');
// this.app.resourcer.use(uploadMiddleware);
// this.app.resourcer.use(createAction);
// this.app.resourcer.registerActionHandler('upload', uploadAction);
if (process.env.APP_ENV !== 'production') {
await getStorageConfig(STORAGE_TYPE_LOCAL).middleware(this.app);
await getStorageConfig(STORAGE_TYPE_LOCAL).middleware!(this.app);
}
const defaultStorageName = getStorageConfig(this.storageType()).defaults().name;

View File

@ -49,7 +49,7 @@ function createLocalServerUpdateHook(app, storages) {
}
function getDocumentRoot(storage): string {
const { documentRoot = 'uploads' } = storage.options || {};
const { documentRoot = 'storage/uploads' } = storage.options || {};
// TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹
return path.resolve(path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot));
}
@ -126,7 +126,7 @@ export default {
},
defaults() {
const { LOCAL_STORAGE_DEST, LOCAL_STORAGE_BASE_URL, APP_PORT } = process.env;
const documentRoot = LOCAL_STORAGE_DEST || 'uploads';
const documentRoot = LOCAL_STORAGE_DEST || 'storage/uploads';
return {
title: '本地存储',
type: STORAGE_TYPE_LOCAL,