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 './checkbox';
export * from './checkboxGroup'; export * from './checkboxGroup';
export * from './chinaRegion'; export * from './chinaRegion';

View File

@ -209,7 +209,6 @@ export default {
"Field source":"Field source", "Field source":"Field source",
"Preview":"Preview", "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.", "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": "Edit",
"Edit collection": "Edit collection", "Edit collection": "Edit collection",
"Configure fields": "Configure fields", "Configure fields": "Configure fields",
@ -245,7 +244,6 @@ export default {
"Radio group": "Radio group", "Radio group": "Radio group",
"Checkbox group": "Checkbox group", "Checkbox group": "Checkbox group",
"China region": "China region", "China region": "China region",
"Attachment": "Attachment",
"Date & Time": "Date & Time", "Date & Time": "Date & Time",
"Datetime": "Datetime", "Datetime": "Datetime",
"Relation": "Relation", "Relation": "Relation",
@ -523,7 +521,6 @@ export default {
"Redirect to": "Redirect to", "Redirect to": "Redirect to",
"Save action": "Save action", "Save action": "Save action",
"Exists": "Exists", "Exists": "Exists",
"Filename": "Filename",
"Add condition": "Add condition", "Add condition": "Add condition",
"Add condition group": "Add condition group", "Add condition group": "Add condition group",
"exists": "exists", "exists": "exists",
@ -540,7 +537,6 @@ export default {
"Expression": "Expression", "Expression": "Expression",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.", "Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.",
"Formula error.": "Formula error.", "Formula error.": "Formula error.",
"Accept": "Accept",
"Rich Text": "Rich Text", "Rich Text": "Rich Text",
"Junction collection": "Junction collection", "Junction collection": "Junction collection",
"Leave it blank, unless you need a custom intermediate table": "Leave it blank, unless you need a custom intermediate table", "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", "Action permission": "Action permission",
"Field permission": "Field permission", "Field permission": "Field permission",
"Scope name": "Scope name", "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", "Unsaved changes": "Unsaved changes",
"Are you sure you don't want to save?": "Are you sure you don't want to save?", "Are you sure you don't want to save?": "Are you sure you don't want to save?",
"Dragging": "Dragging", "Dragging": "Dragging",

View File

@ -189,7 +189,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": "ストレージタイプ",
"Edit": "編集", "Edit": "編集",
"Edit collection": "コレクションの編集", "Edit collection": "コレクションの編集",
"Configure fields": "フィールドの設定", "Configure fields": "フィールドの設定",
@ -217,7 +216,6 @@ export default {
"Radio group": "ラジオボタングループ", "Radio group": "ラジオボタングループ",
"Checkbox group": "チェックボックスグループ", "Checkbox group": "チェックボックスグループ",
"China region": "中国地域", "China region": "中国地域",
"Attachment": "添付ファイル",
"Date & Time": "日付と時間", "Date & Time": "日付と時間",
"Datetime": "日付", "Datetime": "日付",
"Relation": "関連づけ", "Relation": "関連づけ",
@ -330,7 +328,6 @@ export default {
"Add option": "オプションを追加", "Add option": "オプションを追加",
"Related collection": "関連付けコレクション", "Related collection": "関連付けコレクション",
"Allow linking to multiple records": "複数のレコードの関連付けを許可する", "Allow linking to multiple records": "複数のレコードの関連付けを許可する",
"Allow uploading multiple files": "複数ファイルのアップロードを許可する",
"Configure calendar": "カレンダーの設定", "Configure calendar": "カレンダーの設定",
"Title field": "タイトルフィールド", "Title field": "タイトルフィールド",
"Start date field": "開始日フィールド", "Start date field": "開始日フィールド",
@ -429,7 +426,6 @@ export default {
"Redirect to": "リダイレクトする", "Redirect to": "リダイレクトする",
"Save action": "操作を保存", "Save action": "操作を保存",
"Exists": "存在する", "Exists": "存在する",
"Filename": "ファイル名",
"Add condition": "条件の追加", "Add condition": "条件の追加",
"Add condition group": "条件グループの追加", "Add condition group": "条件グループの追加",
"exists": "が存在する", "exists": "が存在する",
@ -446,7 +442,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": "ファイル形式",
"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": "カスタム中間テーブルが必要でない限り、デフォルトで空白のままにします",
@ -495,19 +490,6 @@ export default {
"Action permission": "操作権限", "Action permission": "操作権限",
"Field permission": "フィールド権限", "Field permission": "フィールド権限",
"Scope name": "スコープ名", "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": "変更が保存されていません", "Unsaved changes": "変更が保存されていません",
"Are you sure you don't want to save?": "変更を保存しなくてもよいですか?", "Are you sure you don't want to save?": "変更を保存しなくてもよいですか?",
"Dragging": "ドラッグ", "Dragging": "ドラッグ",

View File

@ -133,7 +133,6 @@ export default {
"Collection name": "Имя Коллекции", "Collection name": "Имя Коллекции",
"Categories":"Категории таблиц данных", "Categories":"Категории таблиц данных",
"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": "Тип Хранилища",
"Edit": "Изменить", "Edit": "Изменить",
"Edit collection": "Изменить Коллекцию", "Edit collection": "Изменить Коллекцию",
"Configure fields": "Конфигурировать поля", "Configure fields": "Конфигурировать поля",
@ -159,7 +158,6 @@ export default {
"Radio group": "Радио группа", "Radio group": "Радио группа",
"Checkbox group": "Чекбокс группа", "Checkbox group": "Чекбокс группа",
"China region": "Китай регион", "China region": "Китай регион",
"Attachment": "Вложение",
"Date & Time": "Дата & Время", "Date & Time": "Дата & Время",
"Datetime": "Датавремя", "Datetime": "Датавремя",
"Relation": "Связь", "Relation": "Связь",
@ -272,7 +270,6 @@ export default {
"Add option": "Добавить опцию", "Add option": "Добавить опцию",
"Related collection": "Связанная коллекция", "Related collection": "Связанная коллекция",
"Allow linking to multiple records": "Позволить прилинковать много записей", "Allow linking to multiple records": "Позволить прилинковать много записей",
"Allow uploading multiple files": "Позволить загружать много файлов",
"Configure calendar": "Настроить календарь", "Configure calendar": "Настроить календарь",
"Title field": "Поле заголовка", "Title field": "Поле заголовка",
"Start date field": "Поле даты начала", "Start date field": "Поле даты начала",
@ -371,7 +368,6 @@ export default {
"Redirect to": "Перенаправить на", "Redirect to": "Перенаправить на",
"Save action": "Сохранить действие", "Save action": "Сохранить действие",
"Exists": "Существуют", "Exists": "Существуют",
"Filename": "Имя файла",
"Add condition": "Добавить правило", "Add condition": "Добавить правило",
"Add condition group": "Добавить группу правил", "Add condition group": "Добавить группу правил",
"exists": "существуют", "exists": "существуют",
@ -388,7 +384,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": "Подтвердить",
"Rich Text": "Rich Text", "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": "Оставьте это поле пустым, если вам не нужна пользовательская промежуточная таблица.",
@ -433,19 +428,6 @@ export default {
"Action permission": "Разрешения на действия", "Action permission": "Разрешения на действия",
"Field permission": "Разрешения на поля", "Field permission": "Разрешения на поля",
"Scope name": "Имя области", "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": "Несохраненные изменения", "Unsaved changes": "Несохраненные изменения",
"Are you sure you don't want to save?": "Вы уверены, что не хотите сохранить?", "Are you sure you don't want to save?": "Вы уверены, что не хотите сохранить?",
"Dragging": "Перетаскивание", "Dragging": "Перетаскивание",

View File

@ -132,7 +132,6 @@ export default {
"Collection display name": "Koleksiyon görünen adı", "Collection display name": "Koleksiyon görünen adı",
"Collection name": "Koleksiyon 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.", "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": "Düzenle",
"Edit collection": "Koleksiyon düzenle", "Edit collection": "Koleksiyon düzenle",
"Configure fields": "Alanları düzenle", "Configure fields": "Alanları düzenle",
@ -158,7 +157,6 @@ export default {
"Radio group": "Radio Seçim grup", "Radio group": "Radio Seçim grup",
"Checkbox group": "Checkbox grup", "Checkbox group": "Checkbox grup",
"China region": "Çin bölgesi", "China region": "Çin bölgesi",
"Attachment": "Dosya eki",
"Date & Time": "Tarih & Saat", "Date & Time": "Tarih & Saat",
"Datetime": "Datetime", "Datetime": "Datetime",
"Relation": "Relation", "Relation": "Relation",
@ -272,7 +270,6 @@ export default {
"Add option": "Seçenek ekle", "Add option": "Seçenek ekle",
"Related collection": "Bağlantılı koleksiyon", "Related collection": "Bağlantılı koleksiyon",
"Allow linking to multiple records": "Birden çok kayda bağlanmaya izin ver", "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", "Configure calendar": "Takvimi yapılandır",
"Start date field": "Başlangıç tarihi alanı", "Start date field": "Başlangıç tarihi alanı",
"End date field": "Bitiş tarihi alanı", "End date field": "Bitiş tarihi alanı",
@ -370,7 +367,6 @@ export default {
"Redirect to": "Yönlendirilecek yer", "Redirect to": "Yönlendirilecek yer",
"Save action": "Kaydet işlemi", "Save action": "Kaydet işlemi",
"Exists": "Var olanlar", "Exists": "Var olanlar",
"Filename": "Dosya adı",
"Add condition": "Koşul ekle", "Add condition": "Koşul ekle",
"Add condition group": "Koşul grubu ekle", "Add condition group": "Koşul grubu ekle",
"exists": "var olanlar", "exists": "var olanlar",
@ -387,7 +383,6 @@ export default {
"Expression": "Expression", "Expression": "Expression",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.", "Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.",
"Formula error.": "Formül hatalı.", "Formula error.": "Formül hatalı.",
"Accept": "Kabul et",
"Rich Text": "Zengin Metin", "Rich Text": "Zengin Metin",
"Junction collection": "Bağlantı koleksiyonu", "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", "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", "Action permission": "İşlem yetkisi",
"Field permission": "Alan yetkisi", "Field permission": "Alan yetkisi",
"Scope name": "Kapsam adı", "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", "Unsaved changes": "Değişiklikler kaydedilmedi",
"Are you sure you don't want to save?": "kaydetmek istemediğinizden emin misiniz??", "Are you sure you don't want to save?": "kaydetmek istemediğinizden emin misiniz??",
"Dragging": "Sürükleme", "Dragging": "Sürükleme",

View File

@ -223,8 +223,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": "存储类型",
"Types will be used in database": "数据库使用的类型",
"Edit": "编辑", "Edit": "编辑",
"Edit collection": "编辑数据表", "Edit collection": "编辑数据表",
"Configure fields": "配置字段", "Configure fields": "配置字段",
@ -267,7 +265,6 @@ export default {
"Radio group": "单选框", "Radio group": "单选框",
"Checkbox group": "复选框", "Checkbox group": "复选框",
"China region": "中国行政区", "China region": "中国行政区",
"Attachment": "附件",
"Date & Time": "日期 & 时间", "Date & Time": "日期 & 时间",
"Datetime": "日期", "Datetime": "日期",
"Relation": "关系类型", "Relation": "关系类型",
@ -437,7 +434,6 @@ export default {
"Add option": "添加选项", "Add option": "添加选项",
"Related collection": "关系表", "Related collection": "关系表",
"Allow linking to multiple records": "允许关联多条记录", "Allow linking to multiple records": "允许关联多条记录",
"Allow uploading multiple files": "允许上传多个文件",
"Daily": "每天", "Daily": "每天",
"Weekly": "每周", "Weekly": "每周",
@ -564,7 +560,6 @@ export default {
'Save action': '保存操作', 'Save action': '保存操作',
'Exists': '存在', 'Exists': '存在',
'Filename': '文件名',
'Add condition': '添加条件', 'Add condition': '添加条件',
'Add condition group': '添加条件分组', 'Add condition group': '添加条件分组',
'exists': '存在', 'exists': '存在',
@ -578,7 +573,6 @@ export default {
'Role UID': '角色标识', 'Role UID': '角色标识',
'Precision': '精确度', 'Precision': '精确度',
'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': '默认留空,除非你需要一个自定义的中间表',
@ -637,23 +631,6 @@ export default {
'Field permission': '字段权限', 'Field permission': '字段权限',
'Scope name': '数据范围名称', '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': '未保存修改', 'Unsaved changes': '未保存修改',
'Are you sure you don\'t want to save?': '你确定不保存修改吗?', 'Are you sure you don\'t want to save?': '你确定不保存修改吗?',
'Dragging': '拖拽中', 'Dragging': '拖拽中',
@ -708,7 +685,6 @@ export default {
"Print": "打印", "Print": "打印",
"Done": "完成", "Done": "完成",
'Sign up successfully, and automatically jump to the sign in page': '注册成功,即将跳转到登录页面', 'Sign up successfully, and automatically jump to the sign in page': '注册成功,即将跳转到登录页面',
'File manager': '文件管理器',
'ACL': '访问控制', 'ACL': '访问控制',
'Collection manager': '数据表管理', 'Collection manager': '数据表管理',
'Plugin manager': '插件管理器', 'Plugin manager': '插件管理器',

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ const mock = new MockAdapter(apiClient.axios);
const sleep = (value: number) => new Promise((resolve) => setTimeout(resolve, value)); 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 const total = 1024; // mocked file size
for (const progress of [0, 0.2, 0.4, 0.6, 0.8, 1]) { for (const progress of [0, 0.2, 0.4, 0.6, 0.8, 1]) {
await sleep(500); await sleep(500);

View File

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

View File

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

View File

@ -180,9 +180,9 @@ const InternalRemoteSelect = connect(
const options = useMemo(() => { const options = useMemo(() => {
if (!data?.data?.length) { 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); return uniqBy(data?.data?.concat(valueOptions) || [], fieldNames.value);
}, [data?.data, getOptionsByFieldNames, normalizeOptions, value]); }, [data?.data, getOptionsByFieldNames, normalizeOptions, value]);
const onDropdownVisibleChange = () => { const onDropdownVisibleChange = () => {

View File

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

View File

@ -9,7 +9,7 @@ const mock = new MockAdapter(apiClient.axios);
const sleep = (value: number) => new Promise((resolve) => setTimeout(resolve, value)); 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 const total = 1024; // mocked file size
for (const progress of [0, 0.2, 0.4, 0.6, 0.8, 1]) { for (const progress of [0, 0.2, 0.4, 0.6, 0.8, 1]) {
await sleep(500); await sleep(500);

View File

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

View File

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

View File

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

View File

@ -113,15 +113,19 @@ export class MockServer extends Application {
const queryString = qs.stringify(restParams, { arrayFormat: 'brackets' }); const queryString = qs.stringify(restParams, { arrayFormat: 'brackets' });
let request;
switch (method) { switch (method) {
case 'upload':
return agent.post(`${url}?${queryString}`).attach('file', file).field(values);
case 'list': case 'list':
case 'get': case 'get':
return agent.get(`${url}?${queryString}`); request = agent.get(`${url}?${queryString}`);
break;
default: 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 React from 'react';
import { Card } from 'antd';
import { SchemaComponent } from '@nocobase/client';
import { StorageOptions } from './StorageOptions'; import { StorageOptions } from './StorageOptions';
import { storageSchema } from './schemas/storage'; import { storageSchema } from './schemas/storage';

View File

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

View File

@ -2,22 +2,23 @@ import { FormLayout } from '@formily/antd';
import { Field } from '@formily/core'; import { Field } from '@formily/core';
import { observer, RecursionField, Schema, useField, useForm } from '@formily/react'; import { observer, RecursionField, Schema, useField, useForm } from '@formily/react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { NAMESPACE } from './locale';
const schema = { const schema = {
local: { local: {
properties: { properties: {
documentRoot: { documentRoot: {
title: '{{t("Destination")}}', title: `{{t("Destination", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, default: 'uploads',
}, },
serve: { serve: {
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Checkbox', '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, default: true,
}, },
}, },
@ -25,28 +26,28 @@ const schema = {
'ali-oss': { 'ali-oss': {
properties: { properties: {
region: { region: {
title: '{{t("Region")}}', title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
}, },
accessKeyId: { accessKeyId: {
title: '{{t("AccessKey ID")}}', title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
}, },
accessKeySecret: { accessKeySecret: {
title: '{{t("AccessKey Secret")}}', title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Password', 'x-component': 'Password',
required: true, required: true,
}, },
bucket: { bucket: {
title: '{{t("Bucket")}}', title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
@ -57,28 +58,28 @@ const schema = {
'tx-cos': { 'tx-cos': {
properties: { properties: {
Region: { Region: {
title: '{{t("Region")}}', title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
}, },
SecretId: { SecretId: {
title: '{{t("SecretId")}}', title: `{{t("SecretId", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
}, },
SecretKey: { SecretKey: {
title: '{{t("SecretKey")}}', title: `{{t("SecretKey", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Password', 'x-component': 'Password',
required: true, required: true,
}, },
Bucket: { Bucket: {
title: '{{t("Bucket")}}', title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
@ -89,39 +90,33 @@ const schema = {
s3: { s3: {
properties: { properties: {
region: { region: {
title: '{{t("Region")}}', title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
}, },
accessKeyId: { accessKeyId: {
title: '{{t("AccessKey ID")}}', title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
}, },
secretAccessKey: { secretAccessKey: {
title: '{{t("AccessKey Secret")}}', title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Password', 'x-component': 'Password',
required: true, required: true,
}, },
bucket: { bucket: {
title: '{{t("Bucket")}}', title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
}, },
endpoint: {
title: '{{t("Endpoint")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
}, },
}, },
}; };

View File

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

View File

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

View File

@ -1,13 +1,13 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { defaultProps, operators } from './properties'; import { IField, interfacesProperties } from '@nocobase/client';
import { IField } from './types'; import { NAMESPACE } from '../locale';
export const attachment: IField = { export const attachment: IField = {
name: 'attachment', name: 'attachment',
type: 'object', type: 'object',
group: 'media', group: 'media',
title: '{{t("Attachment")}}', title: `{{t("Attachment", { ns: "${NAMESPACE}" })}}`,
isAssociation: true, isAssociation: true,
default: { default: {
type: 'belongsToMany', type: 'belongsToMany',
@ -17,17 +17,19 @@ export const attachment: IField = {
type: 'array', type: 'array',
// title, // title,
'x-component': 'Upload.Attachment', 'x-component': 'Upload.Attachment',
'x-component-props': { 'x-component-props': {},
action: 'attachments:upload',
},
}, },
}, },
availableTypes: ['belongsToMany'], availableTypes: ['belongsToMany'],
schemaInitialize(schema: ISchema, { block }) { schemaInitialize(schema: ISchema, { block, field }) {
if (['Table', 'Kanban'].includes(block)) { if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {}; schema['x-component-props'] = schema['x-component-props'] || {};
schema['x-component-props']['size'] = 'small'; schema['x-component-props']['size'] = 'small';
} }
schema['x-component-props']['action'] = `${field.target}:create${
field.storage ? `?attachementField=${field.collectionName}.${field.name}` : ''
}`;
}, },
initialize: (values: any) => { initialize: (values: any) => {
if (!values.through) { if (!values.through) {
@ -47,21 +49,42 @@ export const attachment: IField = {
} }
}, },
properties: { properties: {
...defaultProps, ...interfacesProperties.defaultProps,
'uiSchema.x-component-props.accept': { 'uiSchema.x-component-props.accept': {
type: 'string', type: 'string',
title: '{{t("Accept")}}', title: `{{t("MIME type", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input', 'x-component': 'Input',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
description: 'Example: .doc,.docx', description: 'Example: image/png',
default: 'image/*',
}, },
'uiSchema.x-component-props.multiple': { 'uiSchema.x-component-props.multiple': {
type: 'boolean', type: 'boolean',
'x-content': "{{t('Allow uploading multiple files')}}", 'x-content': `{{t('Allow uploading multiple files', { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Checkbox', 'x-component': 'Checkbox',
default: true, 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: { filterable: {
children: [ children: [
@ -80,10 +103,10 @@ export const attachment: IField = {
}, },
{ {
name: 'filename', name: 'filename',
title: '{{t("Filename")}}', title: `{{t("Filename", { ns: "${NAMESPACE}" })}}`,
operators: operators.string, operators: interfacesProperties.operators.string,
schema: { schema: {
title: '{{t("Filename")}}', title: `{{t("Filename", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-component': 'Input', '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 { useTranslation } from 'react-i18next';
import zhCN from './zh-CN';
export const NAMESPACE = 'file-manager'; export const NAMESPACE = 'file-manager';
i18n.addResources('zh-CN', NAMESPACE, zhCN);
export function useFmTranslation() { export function useFmTranslation() {
return useTranslation(NAMESPACE); 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 collection': '文件数据表',
'File name': '文件名', 'File name': '文件名',
'Extension name': '扩展名', 'Extension name': '扩展名',
Size: '文件大小', Size: '文件大小',
'Mime Type': 'Mime 类型', 'MIME type': 'MIME 类型',
URL: 'URL', URL: 'URL',
'File storage': '文件存储', '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 { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { useActionContext, useRequest } from '@nocobase/client'; import { useActionContext, useRequest } from '@nocobase/client';
import { NAMESPACE } from '../locale';
const collection = { const collection = {
name: 'storages', name: 'storages',
@ -10,7 +11,7 @@ const collection = {
name: 'title', name: 'title',
interface: 'input', interface: 'input',
uiSchema: { uiSchema: {
title: '{{t("Storage display name")}}', title: '{{t("Title")}}',
type: 'string', type: 'string',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
@ -21,7 +22,8 @@ const collection = {
name: 'name', name: 'name',
interface: 'input', interface: 'input',
uiSchema: { uiSchema: {
title: '{{t("Storage name")}}', title: `{{t("Storage name", { ns: "${NAMESPACE}" })}}`,
descriptions: `{{t("Will be used for API", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-component': 'Input', 'x-component': 'Input',
} as ISchema, } as ISchema,
@ -31,15 +33,15 @@ const collection = {
name: 'type', name: 'type',
interface: 'select', interface: 'select',
uiSchema: { uiSchema: {
title: '{{t("Storage type")}}', title: `{{t("Storage type", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-component': 'Select', 'x-component': 'Select',
required: true, required: true,
enum: [ enum: [
{ label: '{{t("Local storage")}}', value: 'local' }, { label: `{{t("Local storage", { ns: "${NAMESPACE}" })}}`, value: 'local' },
{ label: '{{t("Aliyun OSS")}}', value: 'ali-oss' }, { label: `{{t("Aliyun OSS", { ns: "${NAMESPACE}" })}}`, value: 'ali-oss' },
{ label: '{{t("Amazon S3")}}', value: 's3' }, { label: `{{t("Amazon S3", { ns: "${NAMESPACE}" })}}`, value: 's3' },
{ label: '{{t("Tencent COS")}}', value: 'tx-cos' }, { label: `{{t("Tencent COS", { ns: "${NAMESPACE}" })}}`, value: 'tx-cos' },
], ],
} as ISchema, } as ISchema,
}, },
@ -48,7 +50,7 @@ const collection = {
name: 'baseUrl', name: 'baseUrl',
interface: 'input', interface: 'input',
uiSchema: { uiSchema: {
title: '{{t("Storage base URL")}}', title: `{{t("Storage base URL", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
@ -59,7 +61,7 @@ const collection = {
name: 'path', name: 'path',
interface: 'input', interface: 'input',
uiSchema: { uiSchema: {
title: '{{t("Path")}}', title: `{{t("Path", { ns: "${NAMESPACE}" })}}`,
type: 'string', type: 'string',
'x-component': 'Input', 'x-component': 'Input',
} as ISchema, } as ISchema,
@ -69,7 +71,7 @@ const collection = {
name: 'default', name: 'default',
interface: 'boolean', interface: 'boolean',
uiSchema: { uiSchema: {
title: '{{t("Default storage")}}', title: `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
type: 'boolean', type: 'boolean',
'x-component': 'Checkbox', 'x-component': 'Checkbox',
} as ISchema, } as ISchema,
@ -117,14 +119,14 @@ export const storageSchema: ISchema = {
'x-component-props': { 'x-component-props': {
useAction: '{{ cm.useBulkDestroyAction }}', useAction: '{{ cm.useBulkDestroyAction }}',
confirm: { confirm: {
title: "{{t('Delete storage')}}", title: "{{t('Delete')}}",
content: "{{t('Are you sure you want to delete it?')}}", content: "{{t('Are you sure you want to delete it?')}}",
}, },
}, },
}, },
create: { create: {
type: 'void', type: 'void',
title: '{{t("Add storage")}}', title: '{{t("Add new")}}',
'x-component': 'Action', 'x-component': 'Action',
'x-component-props': { 'x-component-props': {
type: 'primary', type: 'primary',
@ -148,7 +150,7 @@ export const storageSchema: ISchema = {
); );
}, },
}, },
title: '{{t("Add storage")}}', title: '{{t("Add new")}}',
properties: { properties: {
title: { title: {
'x-component': 'CollectionField', 'x-component': 'CollectionField',
@ -180,7 +182,7 @@ export const storageSchema: ISchema = {
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
title: '', title: '',
'x-content': '{{t("Default storage")}}', 'x-content': `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
}, },
footer: { footer: {
type: 'void', type: 'void',
@ -284,7 +286,7 @@ export const storageSchema: ISchema = {
'x-decorator-props': { 'x-decorator-props': {
useValues: '{{ cm.useValuesFromRecord }}', useValues: '{{ cm.useValuesFromRecord }}',
}, },
title: '{{t("Edit storage")}}', title: '{{t("Edit")}}',
properties: { properties: {
title: { title: {
'x-component': 'CollectionField', 'x-component': 'CollectionField',
@ -349,8 +351,8 @@ export const storageSchema: ISchema = {
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-component-props': { 'x-component-props': {
confirm: { confirm: {
title: "{{t('Delete role')}}", title: '{{t("Delete")}}',
content: "{{t('Are you sure you want to delete it?')}}", content: '{{t("Are you sure you want to delete it?")}}',
}, },
useAction: '{{cm.useDestroyAction}}', useAction: '{{cm.useDestroyAction}}',
}, },

View File

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

View File

@ -3,14 +3,15 @@ import path from 'path';
import { getApp } from '.'; import { getApp } from '.';
import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants'; 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', () => { describe('action', () => {
let app; let app;
let agent; let agent;
let db; let db;
let StorageModel;
beforeEach(async () => { beforeEach(async () => {
app = await getApp({ app = await getApp({
@ -19,12 +20,14 @@ describe('action', () => {
agent = app.agent(); agent = app.agent();
db = app.db; db = app.db;
const Storage = db.getCollection('storages').model; StorageModel = db.getCollection('storages').model;
await Storage.create({ await StorageModel.create({
name: `local1_${db.getTablePrefix()}`, name: 'local1',
type: STORAGE_TYPE_LOCAL, type: STORAGE_TYPE_LOCAL,
baseUrl: DEFAULT_LOCAL_BASE_URL, baseUrl: DEFAULT_LOCAL_BASE_URL,
default: true, rules: {
size: 1024,
},
}); });
}); });
@ -32,9 +35,9 @@ describe('action', () => {
await db.close(); await db.close();
}); });
describe('direct attachment', () => { describe('default storage', () => {
it('upload file should be ok', async () => { 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'), [FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
}); });
@ -45,6 +48,7 @@ describe('action', () => {
size: 13, size: 13,
mimetype: 'text/plain', mimetype: 'text/plain',
meta: {}, meta: {},
storageId: 1,
}; };
// 文件上传和解析是否正常 // 文件上传和解析是否正常
@ -52,25 +56,26 @@ describe('action', () => {
// 文件的 url 是否正常生成 // 文件的 url 是否正常生成
expect(body.data.url).toBe(`${DEFAULT_LOCAL_BASE_URL}${body.data.path}/${body.data.filename}`); 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({ const attachment = await Attachment.findOne({
where: { id: body.data.id }, where: { id: body.data.id },
include: ['storage'], include: ['storage'],
}); });
const storage = attachment.get('storage');
// 文件的数据是否正常保存 // 文件的数据是否正常保存
expect(attachment).toMatchObject(matcher); expect(attachment).toMatchObject(matcher);
// 关联的存储引擎是否正确 // 关联的存储引擎是否正确
const storage = await attachment.getStorage();
expect(storage).toMatchObject({ expect(storage).toMatchObject({
type: 'local', type: 'local',
options: {}, options: { documentRoot: LOCAL_STORAGE_DEST },
rules: {}, rules: {},
path: '', path: '',
baseUrl: DEFAULT_LOCAL_BASE_URL, baseUrl: DEFAULT_LOCAL_BASE_URL,
default: true, default: true,
}); });
const { documentRoot = 'uploads' } = storage.options || {}; const { documentRoot = 'storage/uploads' } = storage.options || {};
const destPath = path.resolve( const destPath = path.resolve(
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot), path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
storage.path, storage.path,
@ -86,59 +91,70 @@ describe('action', () => {
}); });
}); });
describe.skip('belongsTo attachment', () => { describe('specific storage', () => {
it('upload with associatedIndex, fail as 400 because file mimetype does not match', async () => { it('fail as 400 because file size greater than rules', async () => {
const User = db.getCollection('users').model; db.collection({
const user = await User.create(); name: 'customers',
const response = await agent.resource('users.avatar').upload({ fields: [
associatedIndex: user.id, {
file: path.resolve(__dirname, './files/text.txt'), 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); expect(response.status).toBe(400);
}); });
it.skip('upload with associatedIndex', async () => { it('fail as 400 because file mimetype does not match', async () => {
const User = db.getCollection('users').model; const textStorage = await StorageModel.create({
const user = await User.create(); name: 'local2',
type: STORAGE_TYPE_LOCAL,
const { body } = await agent.resource('users.avatar').upload({ baseUrl: DEFAULT_LOCAL_BASE_URL,
associatedIndex: user.id, rules: {
file: path.resolve(__dirname, './files/image.png'), mimetype: ['text/*'],
values: { width: 100, height: 100 }, },
}); });
const matcher = { db.collection({
title: 'image', name: 'customers',
extname: '.png', fields: [
path: '', {
size: 255, name: 'avatar',
mimetype: 'image/png', type: 'belongsTo',
// TODO(optimize): 可以考虑使用 qs 的 decoder 来进行类型解析 target: 'attachments',
// see: https://github.com/ljharb/qs/issues/91 storage: textStorage.name,
// 或考虑使用 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'],
}); });
// 外键更新正常
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 BASE_URL = `http://localhost:${APP_PORT}/another-uploads`;
const storageName = 'local_private';
const urlPath = 'test/path'; const urlPath = 'test/path';
const Storage = db.getCollection('storages').model;
// 动态添加 storage // 动态添加 storage
await Storage.create({ const storage = await StorageModel.create({
name: storageName, name: 'local_private',
type: STORAGE_TYPE_LOCAL, type: STORAGE_TYPE_LOCAL,
rules: {
mimetype: ['text/*'],
},
path: urlPath, path: urlPath,
baseUrl: BASE_URL, baseUrl: BASE_URL,
options: { options: {
@ -146,12 +162,21 @@ describe('action', () => {
}, },
}); });
const User = db.getCollection('users').model; db.collection({
const user = await User.create(); name: 'customers',
const { body } = await agent.resource('users.pubkeys').upload({ fields: [
associatedIndex: user.id, {
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'), file: path.resolve(__dirname, './files/text.txt'),
values: {},
}); });
// 文件的 url 是否正常生成 // 文件的 url 是否正常生成
@ -161,27 +186,5 @@ describe('action', () => {
const content = await agent.get(url); const content = await agent.get(url);
expect(content.text).toBe('Hello world!\n'); 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; const Storage = db.getCollection('storages').model;
storage = await Storage.create({ storage = await Storage.create({
...aliossStorage.defaults(), ...aliossStorage.defaults(),
name: `ali-oss_${db.getTablePrefix()}`, name: 'ali-oss',
default: true, default: true,
path: 'test/path', path: 'test/path',
}); });
@ -33,7 +33,7 @@ describe('storage:ali-oss', () => {
describe('direct attachment', () => { describe('direct attachment', () => {
itif('upload file should be ok', async () => { 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'), [FILE_FIELD_NAME]: path.resolve(__dirname, '../files/text.txt'),
}); });

View File

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

View File

@ -11,35 +11,16 @@ export default {
type: 'belongsTo', type: 'belongsTo',
name: 'avatar', name: 'avatar',
target: 'attachments', target: 'attachments',
attachment: {
// storage 为配置的默认引擎
rules: {
size: 1024 * 10,
mimetype: ['image/png'],
},
},
}, },
{ {
type: 'belongsToMany', type: 'belongsToMany',
name: 'pubkeys', name: 'pubkeys',
target: 'attachments', target: 'attachments',
attachment: {
storage: 'local_private',
rules: {
mimetype: ['text/*'],
},
},
}, },
{ {
type: 'belongsToMany', type: 'belongsToMany',
name: 'photos', name: 'photos',
target: 'attachments', target: 'attachments',
attachment: {
rules: {
size: 1024 * 100,
mimetype: ['image/*'],
},
},
}, },
], ],
} as CollectionOptions; } 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: '文件管理器', title: '文件管理器',
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
template: 'file',
fields: [ fields: [
{ {
comment: '用户文件名(不含扩展名)', comment: '用户文件名(不含扩展名)',

View File

@ -1,6 +1,6 @@
export const FILE_FIELD_NAME = 'file'; export const FILE_FIELD_NAME = 'file';
export const LIMIT_FILES = 1; 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_LOCAL = 'local';
export const STORAGE_TYPE_ALI_OSS = 'ali-oss'; export const STORAGE_TYPE_ALI_OSS = 'ali-oss';

View File

@ -1,5 +1,5 @@
import match from 'mime-match'; 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)); return options.toString().split(',').some(match(file.mimetype));
} }

View File

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

View File

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