mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 08:21:53 +00:00
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:
parent
b33c00be8f
commit
36d16bc015
@ -1,4 +1,3 @@
|
||||
export * from './attachment';
|
||||
export * from './checkbox';
|
||||
export * from './checkboxGroup';
|
||||
export * from './chinaRegion';
|
||||
|
@ -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",
|
||||
|
@ -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": "ドラッグ",
|
||||
|
@ -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": "Перетаскивание",
|
||||
|
@ -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",
|
||||
|
@ -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': '插件管理器',
|
||||
|
@ -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: '附件',
|
||||
|
@ -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: '附件',
|
||||
|
@ -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: '附件',
|
||||
|
@ -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);
|
||||
|
@ -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': {
|
||||
|
@ -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': [
|
||||
|
@ -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 = () => {
|
||||
|
@ -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: '附件',
|
||||
|
@ -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);
|
||||
|
@ -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': {
|
||||
|
@ -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': [
|
||||
|
@ -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'
|
||||
},
|
||||
|
@ -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);
|
||||
};
|
||||
},
|
||||
},
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -17,7 +17,7 @@ export const UploadActionInitializer = (props) => {
|
||||
icon: 'UploadOutlined',
|
||||
},
|
||||
properties: {
|
||||
modal: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
title: '{{ t("Upload files") }}',
|
||||
'x-component': 'Action.Container',
|
||||
|
@ -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',
|
||||
},
|
21
packages/plugins/file-manager/src/client/locale/en-US.ts
Normal file
21
packages/plugins/file-manager/src/client/locale/en-US.ts
Normal 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',
|
||||
};
|
@ -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);
|
||||
}
|
||||
|
18
packages/plugins/file-manager/src/client/locale/ja-JP.ts
Normal file
18
packages/plugins/file-manager/src/client/locale/ja-JP.ts
Normal 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': 'ファイル名',
|
||||
};
|
18
packages/plugins/file-manager/src/client/locale/ru-RU.ts
Normal file
18
packages/plugins/file-manager/src/client/locale/ru-RU.ts
Normal 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': 'Имя файла',
|
||||
}
|
17
packages/plugins/file-manager/src/client/locale/tr-TR.ts
Normal file
17
packages/plugins/file-manager/src/client/locale/tr-TR.ts
Normal 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ı',
|
||||
};
|
@ -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;
|
||||
|
@ -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}}',
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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'),
|
||||
});
|
||||
|
||||
|
@ -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'),
|
||||
});
|
||||
|
||||
|
@ -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'),
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
|
111
packages/plugins/file-manager/src/server/actions/attachments.ts
Normal file
111
packages/plugins/file-manager/src/server/actions/attachments.ts
Normal 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();
|
||||
}
|
@ -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);
|
||||
}
|
@ -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();
|
||||
}
|
@ -7,6 +7,7 @@ export default {
|
||||
title: '文件管理器',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
template: 'file',
|
||||
fields: [
|
||||
{
|
||||
comment: '用户文件名(不含扩展名)',
|
||||
|
@ -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';
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user