feat(plugin-fm): make rules configurable (#4118)

* feat(plugin-fm): make rules configurable

* fix(client): fix upload test cases

* refactor(client): remove dulicated code

* refactor(client): upload component

* refactor(client): remove a lot of duplicated code

* fix(client): fix upload in system settings

* fix(client): fix test case

* fix(client): fix test case

* fix(client): fix test case

* chore: update yarn.lock

* fix(client): fix test case

* fix: api mock

* refactor(client): refactor hooks

* docs(client): add demo code

* fix: ci

* fix(client): fix import package

* fix: filesize

* fix(client): fix upload component

* fix(client): deprecate preview component and move to file-manager

* fix(plugin-fm): fix storage changes in attachment field and locales

* refactor(plugin-fm): add migration for attachment field storage

* test(plugin-fm): add test case

* feat(plugin-fm): add storage size component

* fix(plugin-fm): fix component

* refactor(plugin-fm): adjust constant

* fix(plugin-fm): fix default local size limit

* fix(plugin-fm): fix test cases

* fix(plugin-fm): fix test case

* fix(plugin-fm): fix rule hook

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2024-05-29 09:33:23 +08:00 committed by GitHub
parent f5079af61e
commit 1dc7a39780
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 1418 additions and 1370 deletions

View File

@ -16,6 +16,7 @@ on:
branches:
- '**'
paths:
- 'packages/core/client/**'
- 'packages/core/client/docs/**'
- '.github/workflows/deploy-client-docs.yml'

View File

@ -398,10 +398,6 @@ export default defineConfig({
"title": "Pagination",
"link": "/components/pagination"
},
{
"title": "Preview",
"link": "/components/preview"
},
]
},
]

View File

@ -37,6 +37,7 @@
"classnames": "^2.3.1",
"cronstrue": "^2.11.0",
"file-saver": "^2.0.5",
"filesize": "9.0.11",
"flat": "^5.0.2",
"i18next": "^22.4.9",
"i18next-http-backend": "^2.1.1",

View File

@ -795,8 +795,13 @@
"Render Failed": "渲染失败",
"Feedback": "反馈问题",
"Try again": "重试一下",
"Download": "下载",
"Click or drag file to this area to upload": "点击或拖拽文件到此区域上传",
"Support for a single or bulk upload, file size should not exceed": "支持单个或批量上传,文件大小不能超过",
"Support for a single or bulk upload.": "支持单个或批量上传",
"File size should not exceed {{size}}.": "文件大小不能超过 {{size}}",
"File size exceeds the limit": "文件大小超过限制",
"File type is not allowed": "文件类型不允许",
"Incomplete uploading files need to be resolved": "未完成上传的文件需要处理",
"Default title for each record": "用作数据的默认标题",
"If collection inherits, choose inherited collections as templates": "当前表有继承关系时,可选择继承链路上的表作为模板来源",
"Select an existing piece of data as the initialization data for the form": "选择一条已有的数据作为表单的初始化数据",

View File

@ -7,15 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { RecursionField, connect, useField, useFieldSchema } from '@formily/react';
import { RecursionField, connect, useExpressionScope, useField, useFieldSchema } from '@formily/react';
import { differenceBy, unionBy } from 'lodash';
import cls from 'classnames';
import React, { useContext, useEffect, useState } from 'react';
import { Upload as AntdUpload } from 'antd';
import {
AttachmentList,
FormProvider,
RecordPickerContext,
RecordPickerProvider,
SchemaComponentOptions,
Uploader,
useActionContext,
useSchemaOptionsContext,
} from '../..';
import {
TableSelectorParamsProvider,
@ -29,11 +34,13 @@ import {
import { useCompile } from '../../hooks';
import { ActionContextProvider } from '../action';
import { EllipsisWithTooltip } from '../input';
import { FileSelector, Preview } from '../preview';
import { ReadPrettyInternalViewer } from './InternalViewer';
import { Upload } from '../upload';
import { useFieldNames, useInsertSchema } from './hooks';
import schema from './schema';
import { flatData, getLabelFormatValue, isShowFilePicker, useLabelUiSchema } from './util';
import { flatData, getLabelFormatValue, useLabelUiSchema } from './util';
import { useTranslation } from 'react-i18next';
import { PlusOutlined } from '@ant-design/icons';
import { useStyles } from '../upload/style';
const useTableSelectorProps = () => {
const field: any = useField();
@ -73,6 +80,75 @@ const useTableSelectorProps = () => {
},
};
};
function FileSelector(props) {
const { disabled, multiple, value, onChange, action, onSelect, quickUpload, selectFile } = props;
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
const { useFileCollectionStorageRules } = useExpressionScope();
const { t } = useTranslation();
const rules = useFileCollectionStorageRules();
// 兼容旧版本
const showSelectButton = selectFile === undefined && quickUpload === undefined;
return wrapSSR(
<div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>
<AttachmentList disabled={disabled} multiple={multiple} value={value} onChange={onChange} />
{showSelectButton ? (
<div className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}>
<AntdUpload disabled={disabled} multiple={multiple} listType={'picture-card'} showUploadList={false}>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onSelect}
>
<PlusOutlined />
{t('Select')}
</div>
</AntdUpload>
</div>
) : null}
{quickUpload ? (
<Uploader
value={value}
multiple={multiple}
// onRemove={handleRemove}
onChange={onChange}
action={action}
rules={rules}
/>
) : null}
{selectFile && (multiple || !value) ? (
<div className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}>
<AntdUpload disabled={disabled} multiple={multiple} listType={'picture-card'} showUploadList={false}>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onSelect}
>
<PlusOutlined />
{t('Select')}
</div>
</AntdUpload>
</div>
) : null}
</div>
</div>,
);
}
const InternalFileManager = (props) => {
const { value, multiple, onChange, ...others } = props;
const fieldSchema = useFieldSchema();
@ -87,13 +163,9 @@ const InternalFileManager = (props) => {
const labelUiSchema = useLabelUiSchema(collectionField?.target, fieldNames?.label || 'label');
const compile = useCompile();
const { modalProps } = useActionContext();
const getFilter = () => {
const targetKey = collectionField?.targetKey || 'id';
const list = options.map((option) => option[targetKey]).filter(Boolean);
const filter = list.length ? { $and: [{ [`${targetKey}.$ne`]: list }] } : {};
return filter;
};
const handleSelect = () => {
const handleSelect = (ev) => {
ev.stopPropagation();
ev.preventDefault();
insertSelector(schema.Selector);
setVisibleSelector(true);
setSelectedRows([]);
@ -114,14 +186,6 @@ const InternalFileManager = (props) => {
}
}, [value, fieldNames?.label]);
const handleRemove = (file) => {
const newOptions = options.filter((option) => option.id !== file.id);
setOptions(newOptions);
if (newOptions.length === 0) {
return onChange(null);
}
onChange(newOptions);
};
const pickerProps = {
size: 'small',
fieldNames,
@ -152,23 +216,13 @@ const InternalFileManager = (props) => {
return (
<div style={{ width: '100%', overflow: 'auto' }}>
<FileSelector
value={options}
value={multiple ? options : options?.[0]}
multiple={multiple}
quickUpload={fieldSchema['x-component-props']?.quickUpload !== false}
selectFile={fieldSchema['x-component-props']?.selectFile !== false}
action={`${collectionField?.target}:create`}
onSelect={handleSelect}
onRemove={handleRemove}
onChange={(changed) => {
if (changed.every((file) => file.status !== 'uploading')) {
changed = changed.filter((file) => file.status === 'done').map((file) => file.response.data);
if (multiple) {
onChange([...options, ...changed]);
} else {
onChange(changed[0]);
}
}
}}
onChange={onChange}
/>
<ActionContextProvider
value={{
@ -184,7 +238,7 @@ const InternalFileManager = (props) => {
<RecordPickerProvider {...pickerProps}>
<CollectionProvider_deprecated name={collectionField?.target}>
<FormProvider>
<TableSelectorParamsProvider params={{ filter: getFilter() }}>
<TableSelectorParamsProvider params={{}}>
<SchemaComponentOptions scope={{ usePickActionProps, useTableSelectorProps }}>
<RecursionField
onlyRenderProperties
@ -209,7 +263,9 @@ const FileManageReadPretty = connect((props) => {
const { getField } = useCollection_deprecated();
const { getCollectionJoinField } = useCollectionManager_deprecated();
const collectionField = getField(fieldSchema.name) || getCollectionJoinField(fieldSchema['x-collection-field']);
return <EllipsisWithTooltip ellipsis>{collectionField ? <Preview {...props} /> : null}</EllipsisWithTooltip>;
return (
<EllipsisWithTooltip ellipsis>{collectionField ? <Upload.ReadPretty {...props} /> : null}</EllipsisWithTooltip>
);
});
export { FileManageReadPretty, InternalFileManager };

View File

@ -6,286 +6,20 @@
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { connect } from '@formily/react';
import { DeleteOutlined, DownloadOutlined, PlusOutlined } from '@ant-design/icons';
import { connect, mapReadPretty } from '@formily/react';
import { Upload as AntdUpload, Button, Progress, Space, UploadFile } from 'antd';
import cls from 'classnames';
import { saveAs } from 'file-saver';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Lightbox from 'react-image-lightbox';
import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app
import { ReadPretty } from '../upload/ReadPretty';
import { isImage, toFileList, useUploadProps } from '../upload/shared';
import { useStyles } from '../upload/style';
import { UploadProps } from '../upload/type';
type Props = UploadProps & {
/** 是否显示 Upload 按钮 */
quickUpload: boolean;
/** 是否显示 Select 按钮 */
selectFile: boolean;
onRemove?: (file) => void;
onSelect?: () => void;
};
import { useCollectionRecordData } from '../../../data-source';
import { Upload } from '../upload/Upload';
/**
* @deprecated
* Only used for file collection preview field.
* For file object preview, please use `Upload.ReadPretty` instead.
*/
export const Preview = connect((props) => {
return <ReadPretty.File {...props} />;
}, mapReadPretty(ReadPretty.File));
export const FileSelector = (props: Props) => {
const { disabled, multiple, value, quickUpload, selectFile, onRemove, onSelect } = props;
const uploadProps = useUploadProps({ ...props });
const [fileList, setFileList] = useState([]);
const [photoIndex, setPhotoIndex] = useState(0);
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
const internalFileList = useRef([]);
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
// 兼容旧版本
const showSelectButton = selectFile === undefined && quickUpload === undefined;
useEffect(() => {
const fileList = toFileList(value);
setFileList(fileList);
internalFileList.current = fileList;
}, [value]);
const handleRemove = (file) => {
onRemove?.(file);
return true;
};
const handleSelect = (e) => {
e.preventDefault();
e.stopPropagation();
onSelect?.();
};
const list = fileList.length ? (multiple ? fileList : [fileList[fileList.length - 1]]) : [];
return wrapSSR(
<div>
<div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>
{list.map((file) => {
const handleClick = (e) => {
e.preventDefault();
e.stopPropagation();
const index = fileList.indexOf(file);
if (isImage(file.extname)) {
setVisible(true);
setPhotoIndex(index);
} else {
saveAs(file.url, `${file.title}${file.extname}`);
}
};
return (
<div
key={file.uid || file.id}
className={`${prefixCls}-list-picture-card-container ${prefixCls}-list-item-container`}
>
<div
className={cls(
`${prefixCls}-list-item`,
`${prefixCls}-list-item-done`,
`${prefixCls}-list-item-list-type-picture-card`,
)}
>
<div className={`${prefixCls}-list-item-info`}>
<span className={`${prefixCls}-span`}>
<a
className={`${prefixCls}-list-item-thumbnail`}
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
>
{file.imageUrl && (
<img src={file.imageUrl} alt={file.title} className={`${prefixCls}-list-item-image`} />
)}
</a>
<a
target="_blank"
rel="noopener noreferrer"
className={`${prefixCls}-list-item-name`}
title={file.title}
href={file.url}
onClick={handleClick}
>
{file.status === 'uploading' ? t('Uploading') : file.title}
</a>
</span>
</div>
<span className={`${prefixCls}-list-item-actions`}>
<Space size={3}>
<Button
size={'small'}
type={'text'}
icon={<DownloadOutlined />}
onClick={() => {
saveAs(file.url, `${file.title}${file.extname}`);
}}
/>
{!disabled && (
<Button
size={'small'}
type={'text'}
icon={<DeleteOutlined />}
onClick={() => {
handleRemove(file);
internalFileList.current = internalFileList.current.filter((item) => item.uid !== file.uid);
}}
/>
)}
</Space>
</span>
{file.status === 'uploading' && (
<div className={`${prefixCls}-list-item-progress`}>
<Progress strokeWidth={2} type={'line'} showInfo={false} percent={file.percent} />
</div>
)}
</div>
</div>
);
})}
<>
{showSelectButton ? (
<div className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}>
<AntdUpload
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
showUploadList={false}
onRemove={handleRemove}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={handleSelect}
>
<PlusOutlined />
{t('Select')}
</div>
</AntdUpload>
</div>
) : null}
{quickUpload ? (
<div className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}>
<AntdUpload
{...uploadProps}
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
fileList={fileList}
showUploadList={false}
onRemove={handleRemove}
onChange={(info) => {
// info.fileList 有 BUG会导致上传状态一直是 uploading
// 所以这里仿照 antd 源码,自己维护一个 fileList
const list = updateFileList(info.file, internalFileList.current);
internalFileList.current = list;
// 如果不在这里 setFileList 的话,会导致 onChange 只会执行一次
setFileList(toFileList(list));
uploadProps.onChange?.({ fileList: list });
}}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PlusOutlined />
{t('Upload')}
</div>
</AntdUpload>
</div>
) : null}
{selectFile ? (
<div className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}>
<AntdUpload
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
showUploadList={false}
onRemove={handleRemove}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={handleSelect}
>
<PlusOutlined />
{t('Select')}
</div>
</AntdUpload>
</div>
) : null}
</>
</div>
</div>
{/* 预览图片的弹框 */}
{visible && (
<Lightbox
// discourageDownloads={true}
mainSrc={fileList[photoIndex]?.imageUrl}
nextSrc={fileList[(photoIndex + 1) % fileList.length]?.imageUrl}
prevSrc={fileList[(photoIndex + fileList.length - 1) % fileList.length]?.imageUrl}
onCloseRequest={() => setVisible(false)}
onMovePrevRequest={() => setPhotoIndex((photoIndex + fileList.length - 1) % fileList.length)}
onMoveNextRequest={() => setPhotoIndex((photoIndex + 1) % fileList.length)}
imageTitle={fileList[photoIndex]?.title}
toolbarButtons={[
<button
key={'preview-img'}
style={{ fontSize: 22, background: 'none', lineHeight: 1 }}
type="button"
aria-label="Zoom in"
title="Zoom in"
className="ril-zoom-in ril__toolbarItemChild ril__builtinButton"
onClick={(e) => {
e.preventDefault();
const file = fileList[photoIndex];
saveAs(file.url, `${file.title}${file.extname}`);
}}
>
<DownloadOutlined />
</button>,
]}
/>
)}
</div>,
);
};
const data = useCollectionRecordData();
return <Upload.ReadPretty {...props} value={data} />;
});
export default Preview;
function updateFileList(file: UploadFile, fileList: (UploadFile | Readonly<UploadFile>)[]) {
const nextFileList = [...fileList];
const fileIndex = nextFileList.findIndex(({ uid }) => uid === file.uid);
if (fileIndex === -1) {
nextFileList.push(file);
} else {
nextFileList[fileIndex] = file;
}
return nextFileList;
}

View File

@ -1,21 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { render } from '@nocobase/test/client';
import React from 'react';
import App1 from '../demos/demo1';
describe('Preview', () => {
it('should render correctly', () => {
const { queryAllByText } = render(<App1 />);
expect(queryAllByText('s33766399').length).toBe(2);
expect(queryAllByText('简历').length).toBe(2);
});
});

View File

@ -1,85 +0,0 @@
/**
* title: Preview
*/
import { FormItem } from '@formily/antd-v5';
import { SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
import { Preview } from '@nocobase/client';
const defaultValue = [
{
id: 45,
title: 's33766399',
name: 's33766399',
filename: 'cd48dc833ab01aa3959ac39309fc39de.jpg',
extname: '.jpg',
size: null,
mimetype: 'image/jpeg',
path: '',
meta: {},
status: 'uploading',
percent: 60,
url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/cd48dc833ab01aa3959ac39309fc39de.jpg',
created_at: '2021-08-13T15:00:17.423Z',
updated_at: '2021-08-13T15:00:17.423Z',
created_by_id: null,
updated_by_id: null,
storage_id: 2,
},
{
id: 7,
title: '简历',
filename: 'd9f6ad6669902a9a8a1229d9f362235a.docx',
extname: '.docx',
size: null,
mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
path: '',
meta: {},
url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/d9f6ad6669902a9a8a1229d9f362235a.docx',
created_at: '2021-09-12T01:22:06.229Z',
updated_at: '2021-09-12T01:22:06.229Z',
created_by_id: null,
updated_by_id: 1,
storage_id: 2,
t_jh7a28dsfzi: {
createdAt: '2021-09-12T01:22:07.886Z',
updatedAt: '2021-09-12T01:22:07.886Z',
f_xg3mysbjfra: 1,
f_gc7ppj0b7n1: 7,
},
},
];
const schema = {
type: 'object',
properties: {
read: {
type: 'object',
title: `阅读模式`,
'x-decorator': 'FormItem',
'x-component': 'Preview',
default: defaultValue,
},
read2: {
type: 'object',
title: `小图预览`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Preview',
'x-component-props': {
size: 'small',
},
default: defaultValue,
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Preview, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -1,14 +0,0 @@
# Preview
Used for previewing uploaded files.
```ts
type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
serviceErrorMessage?: string;
value?: any;
size?: string;
}
```
<code src="./demos/demo1.tsx"></code>

View File

@ -1,14 +0,0 @@
# Preview
用于预览上传的文件。
```ts
type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
serviceErrorMessage?: string;
value?: any;
size?: string;
}
```
<code src="./demos/demo1.tsx"></code>

View File

@ -20,9 +20,10 @@ import { CollectionProvider_deprecated, useCollection_deprecated } from '../../.
import { FormProvider, SchemaComponentOptions } from '../../core';
import { useCompile } from '../../hooks';
import { ActionContextProvider, useActionContext } from '../action';
import { FileSelector } from '../preview';
import { useFieldNames } from './useFieldNames';
import { getLabelFormatValue, useLabelUiSchema } from './util';
import { Upload } from '../upload';
import { toArr } from '@formily/shared';
export const RecordPickerContext = createContext(null);
RecordPickerContext.displayName = 'RecordPickerContext';
@ -152,27 +153,24 @@ export const InputRecordPicker: React.FC<any> = (props: IRecordPickerProps) => {
setSelectedRows([]);
};
const handleRemove = (file) => {
const newOptions = options.filter((option) => option.id !== file.id);
setOptions(newOptions);
if (newOptions.length === 0) {
return onChange(null);
}
onChange(newOptions);
};
// const handleRemove = (file) => {
// const newOptions = options.filter((option) => option.id !== file.id);
// setOptions(newOptions);
// if (newOptions.length === 0) {
// return onChange(null);
// }
// onChange(newOptions);
// };
return (
<div>
{showFilePicker ? (
<FileSelector
<Upload.Attachment
value={options}
multiple={multiple}
quickUpload={quickUpload}
selectFile={selectFile}
action={`${collectionField?.target}:create`}
onSelect={handleSelect}
onRemove={handleRemove}
onChange={(changed) => {
onChange={(files) => {
let changed = toArr(files);
if (changed.every((file) => file.status !== 'uploading')) {
changed = changed.filter((file) => file.status === 'done').map((file) => file.response.data);
if (multiple) {

View File

@ -1,252 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { DownloadOutlined } from '@ant-design/icons';
import { Field } from '@formily/core';
import { useField } from '@formily/react';
import { isString } from '@nocobase/utils/client';
import { Button, Modal, Space } from 'antd';
import useUploadStyle from 'antd/es/upload/style';
import cls from 'classnames';
import { saveAs } from 'file-saver';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Lightbox from 'react-image-lightbox';
import { useRecord } from '../../../record-provider';
import { isImage, isPdf, toArr, toImages } from './shared';
import { useStyles } from './style';
import type { UploadProps } from './type';
type Composed = React.FC<UploadProps> & {
Upload?: React.FC<UploadProps>;
File?: React.FC<UploadProps>;
};
export const ReadPretty: Composed = () => null;
ReadPretty.File = function File(props: UploadProps) {
const { t } = useTranslation();
const record = useRecord();
const field = useField<Field>();
const value = isString(field.value) ? record : field.value;
const images = toImages(toArr(value));
const [fileIndex, setFileIndex] = useState(0);
const [visible, setVisible] = useState(false);
const [fileType, setFileType] = useState<'image' | 'pdf'>();
const { size } = props;
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
const useUploadStyleVal = (useUploadStyle as any).default ? (useUploadStyle as any).default : useUploadStyle;
// 加载 antd 的样式
useUploadStyleVal(prefixCls);
function closeIFrameModal() {
setVisible(false);
}
return wrapSSR(
<div>
<div
className={cls(
`${prefixCls}-wrapper`,
`${prefixCls}-picture-card-wrapper`,
`nb-upload`,
size ? `nb-upload-${size}` : null,
hashId,
)}
>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>
{images.map((file) => {
const handleClick = (e) => {
const index = images.indexOf(file);
if (isImage(file.extname)) {
e.preventDefault();
e.stopPropagation();
setVisible(true);
setFileIndex(index);
setFileType('image');
} else if (isPdf(file.extname)) {
e.preventDefault();
e.stopPropagation();
setVisible(true);
setFileIndex(index);
setFileType('pdf');
}
// else {
// saveAs(file.url, `${file.title}${file.extname}`);
// }
};
return (
<div
key={file.name}
className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}
>
<div
className={cls(
`${prefixCls}-list-item`,
`${prefixCls}-list-item-done`,
`${prefixCls}-list-item-list-type-picture-card`,
)}
>
<div className={`${prefixCls}-list-item-info`}>
<span className={`${prefixCls}-span`}>
<a
className={`${prefixCls}-list-item-thumbnail`}
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
>
{file.imageUrl && (
<img
src={`${file.imageUrl}${file.thumbnailRule || ''}`}
alt={file.title}
className={`${prefixCls}-list-item-image`}
/>
)}
</a>
<a
target="_blank"
rel="noopener noreferrer"
className={`${prefixCls}-list-item-name`}
title={file.title}
href={file.url}
onClick={handleClick}
>
{file.title}
</a>
</span>
</div>
{size !== 'small' && (
<span className={`${prefixCls}-list-item-actions`}>
<Space size={3}>
<Button
size={'small'}
type={'text'}
icon={<DownloadOutlined />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
saveAs(file.url, `${file.title}${file.extname}`);
}}
/>
</Space>
</span>
)}
</div>
</div>
);
})}
</div>
</div>
{visible && fileType === 'image' && (
<Lightbox
reactModalStyle={{ overlay: { zIndex: 9999 } }}
// discourageDownloads={true}
mainSrc={images[fileIndex]?.imageUrl}
nextSrc={images[(fileIndex + 1) % images.length]?.imageUrl}
prevSrc={images[(fileIndex + images.length - 1) % images.length]?.imageUrl}
// @ts-ignore
onCloseRequest={(e) => {
e.preventDefault();
e.stopPropagation();
setVisible(false);
}}
onMovePrevRequest={() => setFileIndex((fileIndex + images.length - 1) % images.length)}
onMoveNextRequest={() => setFileIndex((fileIndex + 1) % images.length)}
imageTitle={images[fileIndex]?.title}
toolbarButtons={[
<button
key={'download'}
style={{ fontSize: 22, background: 'none', lineHeight: 1 }}
type="button"
aria-label="Zoom in"
title="Zoom in"
className="ril-zoom-in ril__toolbarItemChild ril__builtinButton"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const file = images[fileIndex];
saveAs(file.url, `${file.title}${file.extname}`);
}}
>
<DownloadOutlined />
</button>,
]}
/>
)}
{visible && fileType === 'pdf' && (
<Modal
open={visible}
title={'PDF - ' + images[fileIndex].title}
onCancel={closeIFrameModal}
footer={[
<Button
key={'download'}
style={{
textTransform: 'capitalize',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const file = images[fileIndex];
saveAs(file.url, `${file.title}${file.extname}`);
}}
>
{t('download')}
</Button>,
<Button key={'close'} onClick={closeIFrameModal} style={{ textTransform: 'capitalize' }}>
{t('close')}
</Button>,
]}
width={'85vw'}
centered={true}
>
<div
style={{
padding: '8px',
maxWidth: '100%',
maxHeight: 'calc(100vh - 256px)',
height: '90vh',
width: '100%',
background: 'white',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflowY: 'auto',
}}
>
<iframe
src={images[fileIndex].url}
style={{
width: '100%',
maxHeight: '90vh',
flex: '1 1 auto',
}}
></iframe>
</div>
</Modal>
)}
</div>,
);
};
ReadPretty.Upload = function Upload() {
const field = useField<Field>();
return (field.value || []).map((item) => (
<div key={item.name}>
{item.url ? (
<a target={'_blank'} href={item.url} rel="noreferrer">
{item.name}
</a>
) : (
<span>{item.name}</span>
)}
</div>
));
};

View File

@ -8,256 +8,230 @@
*/
import { DeleteOutlined, DownloadOutlined, InboxOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { Upload as AntdUpload, Button, Modal, Progress, Space, UploadFile } from 'antd';
import { Field } from '@formily/core';
import { connect, mapProps, mapReadPretty, useField } from '@formily/react';
import { Upload as AntdUpload, Button, Modal, Progress, Space, Tooltip } from 'antd';
import useUploadStyle from 'antd/es/upload/style';
import cls from 'classnames';
import { saveAs } from 'file-saver';
import React, { useEffect, useRef, useState } from 'react';
import filesize from 'filesize';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LightBox from 'react-image-lightbox';
import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { useProps } from '../../hooks/useProps';
import { ReadPretty } from './ReadPretty';
import { isImage, isPdf, toArr, toFileList, toItem, toValue, useUploadProps } from './shared';
import {
FILE_SIZE_LIMI_MAX,
isImage,
isPdf,
normalizeFile,
toFileList,
toValueItem,
useBeforeUpload,
useUploadProps,
} from './shared';
import { useStyles } from './style';
import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from './type';
export const Upload: ComposedUpload = connect(
(props: UploadProps) => {
return <AntdUpload {...useUploadProps(props)} />;
function InternalUpload(props: UploadProps) {
const { onChange, ...rest } = props;
const onFileChange = useCallback(
(info) => {
onChange?.(toFileList(info.fileList));
},
[onChange],
);
return <AntdUpload {...useUploadProps(rest)} onChange={onFileChange} />;
}
function ReadPretty({ value, onChange, disabled, multiple, size }: UploadProps) {
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
const useUploadStyleVal = (useUploadStyle as any).default ? (useUploadStyle as any).default : useUploadStyle;
// 加载 antd 的样式
useUploadStyleVal(prefixCls);
return wrapSSR(
<div
className={cls(
`${prefixCls}-wrapper`,
`${prefixCls}-picture-card-wrapper`,
`nb-upload`,
size ? `nb-upload-${size}` : null,
hashId,
)}
>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>
<AttachmentList disabled={disabled} readPretty multiple={multiple} value={value} onChange={onChange} />
</div>
</div>,
);
}
export const Upload: ComposedUpload = connect(
InternalUpload,
mapProps({
value: 'fileList',
}),
mapReadPretty(ReadPretty.Upload),
mapReadPretty(ReadPretty),
);
Upload.Attachment = connect((props: UploadProps) => {
const { disabled, multiple, value, onChange } = props;
const [fileList, setFileList] = useState<any[]>([]);
const [sync, setSync] = useState(true);
const images = fileList;
const [fileIndex, setFileIndex] = useState(0);
const [visible, setVisible] = useState(false);
const [fileType, setFileType] = useState<'image' | 'pdf'>();
Upload.ReadPretty = ReadPretty;
function useSizeHint(size: number) {
const s = size ?? FILE_SIZE_LIMI_MAX;
const { t, i18n } = useTranslation();
const sizeString = filesize(s, { base: 2, standard: 'jedec', locale: i18n.language });
return s !== 0 ? t('File size should not exceed {{size}}.', { size: sizeString }) : '';
}
function AttachmentListItem(props) {
const { file, disabled, onPreview, onDelete: propsOnDelete, readPretty } = props;
const { componentCls: prefixCls } = useStyles();
const { t } = useTranslation();
const uploadProps = useUploadProps({ ...props });
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
const internalFileList = useRef([]);
function closeIFrameModal() {
setVisible(false);
}
useEffect(() => {
if (sync) {
const fileList = toFileList(value);
setFileList(fileList);
internalFileList.current = fileList;
}
}, [value, sync]);
return wrapSSR(
<div>
<div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>
{fileList.map((file) => {
const handleClick = (e) => {
const handleClick = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
const index = fileList.indexOf(file);
if (isImage(file.extname)) {
setFileType('image');
setVisible(true);
setFileIndex(index);
} else if (isPdf(file.extname)) {
setVisible(true);
setFileIndex(index);
setFileType('pdf');
} else {
onPreview?.(file);
},
[file, onPreview],
);
const onDelete = useCallback(() => {
propsOnDelete?.(file);
}, [file, propsOnDelete]);
const onDownload = useCallback(() => {
saveAs(file.url, `${file.title}${file.extname}`);
}
};
return (
<div
key={file.uid || file.id}
className={`${prefixCls}-list-picture-card-container ${prefixCls}-list-item-container`}
>
}, [file]);
const item = [
<span key="thumbnail" className={`${prefixCls}-list-item-thumbnail`}>
{file.imageUrl && <img src={file.imageUrl} alt={file.title} className={`${prefixCls}-list-item-image`} />}
</span>,
<span key="title" className={`${prefixCls}-list-item-name`} title={file.title}>
{file.status === 'uploading' ? t('Uploading') : file.title}
</span>,
];
const wrappedItem = file.id ? (
<a target="_blank" rel="noopener noreferrer" href={file.url} onClick={handleClick}>
{item}
</a>
) : (
<span className={`${prefixCls}-span`}>{item}</span>
);
const content = (
<div
className={cls(
`${prefixCls}-list-item`,
`${prefixCls}-list-item-done`,
`${prefixCls}-list-item-${file.status ?? 'done'}`,
`${prefixCls}-list-item-list-type-picture-card`,
)}
>
<div className={`${prefixCls}-list-item-info`}>
<span className={`${prefixCls}-span`}>
<a
className={`${prefixCls}-list-item-thumbnail`}
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
>
{file.imageUrl && (
<img src={file.imageUrl} alt={file.title} className={`${prefixCls}-list-item-image`} />
)}
</a>
<a
target="_blank"
rel="noopener noreferrer"
className={`${prefixCls}-list-item-name`}
title={file.title}
href={file.url}
onClick={handleClick}
>
{file.status === 'uploading' ? t('Uploading') : file.title}
</a>
</span>
</div>
<div className={`${prefixCls}-list-item-info`}>{wrappedItem}</div>
<span className={`${prefixCls}-list-item-actions`}>
<Space size={3}>
<Button
size={'small'}
type={'text'}
icon={<DownloadOutlined />}
onClick={() => {
saveAs(file.url, `${file.title}${file.extname}`);
}}
/>
{!disabled && (
<Button
size={'small'}
type={'text'}
icon={<DeleteOutlined />}
onClick={() => {
setSync(false);
setFileList((prevFileList) => {
if (!multiple) {
onChange?.(null as any);
setSync(true);
return [];
}
const index = prevFileList.indexOf(file);
prevFileList.splice(index, 1);
internalFileList.current = internalFileList.current.filter(
(item) => item.uid !== file.uid,
);
onChange?.(toValue([...prevFileList]));
return [...prevFileList];
});
}}
/>
{!readPretty && file.id && (
<Button size={'small'} type={'text'} icon={<DownloadOutlined />} onClick={onDownload} />
)}
{!readPretty && !disabled && (
<Button size={'small'} type={'text'} icon={<DeleteOutlined />} onClick={onDelete} />
)}
</Space>
</span>
{file.status === 'uploading' && (
<div className={`${prefixCls}-list-item-progress`}>
<Progress strokeWidth={2} type={'line'} showInfo={false} percent={file.percent} />
<Progress size={2} type={'line'} showInfo={false} percent={file.percent} />
</div>
)}
</div>
</div>
);
})}
{!disabled && (multiple || toArr(value).length < 1) && (
<div className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}>
<AntdUpload
{...uploadProps}
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
fileList={fileList}
onChange={(info) => {
// info.fileList 有 BUG会导致上传状态一直是 uploading
// 所以这里仿照 antd 源码,自己维护一个 fileList
const list = updateFileList(info.file, internalFileList.current);
internalFileList.current = list;
setSync(false);
if (multiple) {
if (info.file.status === 'done') {
onChange?.(toValue(list));
}
onChange?.(toValue(list));
setFileList(list.map(toItem));
setSync(true);
} else {
if (info.file.status === 'done') {
// TODO(BUG): object 的联动有问题,不响应,折中的办法先置空再赋值
onChange?.(null as any);
onChange?.(info.file?.response?.data);
}
setFileList([toItem(info.file)]);
setSync(true);
}
}}
showUploadList={false}
>
{!disabled && (multiple || toArr(value).length < 1) && (
<span>
<PlusOutlined />
<br /> {t('Upload')}
</span>
)}
</AntdUpload>
</div>
return (
<div className={`${prefixCls}-list-picture-card-container ${prefixCls}-list-item-container`}>
{file.status === 'error' ? (
<Tooltip title={file.response} getPopupContainer={(node) => node.parentNode as HTMLElement}>
{content}
</Tooltip>
) : (
content
)}
</div>
</div>
{/* 预览图片的弹框 */}
{visible && fileType === 'image' && (
);
}
const PreviewerTypes = [
{
matcher: isImage,
Component({ index, list, onSwitchIndex }) {
const onDownload = useCallback(
(e) => {
e.preventDefault();
const file = list[index];
saveAs(file.url, `${file.title}${file.extname}`);
},
[index, list],
);
return (
<LightBox
// discourageDownloads={true}
mainSrc={images[fileIndex]?.imageUrl}
nextSrc={images[(fileIndex + 1) % images.length]?.imageUrl}
prevSrc={images[(fileIndex + images.length - 1) % images.length]?.imageUrl}
onCloseRequest={() => setVisible(false)}
onMovePrevRequest={() => setFileIndex((fileIndex + images.length - 1) % images.length)}
onMoveNextRequest={() => setFileIndex((fileIndex + 1) % images.length)}
imageTitle={images[fileIndex]?.title}
mainSrc={list[index]?.imageUrl}
nextSrc={list[(index + 1) % list.length]?.imageUrl}
prevSrc={list[(index + list.length - 1) % list.length]?.imageUrl}
onCloseRequest={() => onSwitchIndex(null)}
onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)}
onMoveNextRequest={() => onSwitchIndex((index + 1) % list.length)}
imageTitle={list[index]?.title}
toolbarButtons={[
<button
key={'preview-img'}
style={{ fontSize: 22, background: 'none', lineHeight: 1 }}
type="button"
aria-label="Zoom in"
title="Zoom in"
aria-label="Download"
title="Download"
className="ril-zoom-in ril__toolbarItemChild ril__builtinButton"
onClick={(e) => {
e.preventDefault();
const file = images[fileIndex];
saveAs(file.url, `${file.title}${file.extname}`);
}}
onClick={onDownload}
>
<DownloadOutlined />
</button>,
]}
/>
)}
{visible && fileType === 'pdf' && (
);
},
},
{
matcher: isPdf,
Component({ index, list, onSwitchIndex }) {
const { t } = useTranslation();
const onDownload = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
const file = list[index];
saveAs(file.url, `${file.title}${file.extname}`);
},
[index, list],
);
const onClose = useCallback(() => {
onSwitchIndex(null);
}, [onSwitchIndex]);
return (
<Modal
open={visible}
title={'PDF - ' + images[fileIndex].title}
onCancel={closeIFrameModal}
open={index != null}
title={'PDF - ' + list[index].title}
onCancel={onClose}
footer={[
<Button
key="download"
style={{
textTransform: 'capitalize',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const file = images[fileIndex];
saveAs(file.url, `${file.title}${file.extname}`);
}}
onClick={onDownload}
>
{t('download')}
{t('Download')}
</Button>,
<Button key="close" onClick={closeIFrameModal} style={{ textTransform: 'capitalize' }}>
{t('close')}
<Button key="close" onClick={onClose} style={{ textTransform: 'capitalize' }}>
{t('Close')}
</Button>,
]}
width={'85vw'}
@ -278,27 +252,218 @@ Upload.Attachment = connect((props: UploadProps) => {
}}
>
<iframe
src={images[fileIndex].url}
src={list[index].url}
style={{
width: '100%',
maxHeight: '90vh',
flex: '1 1 auto',
}}
></iframe>
/>
</div>
</Modal>
)}
);
},
},
];
function Previewer({ index, onSwitchIndex, list }) {
if (index == null) {
return null;
}
const file = list[index];
const { Component } = PreviewerTypes.find((type) => type.matcher(file)) ?? {};
if (!Component) {
return null;
}
return <Component index={index} list={list} onSwitchIndex={onSwitchIndex} />;
}
export function AttachmentList(props) {
const { disabled, multiple, value, onChange, readPretty } = props;
const [fileList, setFileList] = useState<any[]>([]);
const [preview, setPreview] = useState<number>(null);
useEffect(() => {
const list = toFileList(value);
setFileList(list);
}, [value]);
const onPreview = useCallback(
(file) => {
const index = fileList.findIndex((item) => item.id === file.id);
const previewType = PreviewerTypes.find((type) => type.matcher(file));
if (previewType) {
setPreview(index);
} else {
if (file.id) {
saveAs(file.url, `${file.title}${file.extname}`);
}
}
},
[fileList],
);
const onDelete = useCallback(
(file) => {
if (multiple) {
onChange(value.filter((item) => item.id !== file.id));
} else {
onChange(null);
}
},
[multiple, onChange, value],
);
return (
<>
{fileList.map((file, index) => (
<AttachmentListItem
key={file.id}
file={file}
index={index}
disabled={disabled}
onPreview={onPreview}
onDelete={onDelete}
readPretty={readPretty}
/>
))}
<Previewer index={preview} onSwitchIndex={setPreview} list={fileList} />
</>
);
}
export function Uploader({ rules, ...props }: UploadProps) {
const { disabled, multiple, value, onChange } = props;
const [pendingList, setPendingList] = useState<any[]>([]);
const { t } = useTranslation();
const { componentCls: prefixCls } = useStyles();
const field = useField<Field>();
const uploadProps = useUploadProps(props);
const beforeUpload = useBeforeUpload(rules);
useEffect(() => {
const error = pendingList.find((file) => file.status === 'error');
if (error) {
field.setFeedback({
type: 'error',
code: 'UploadError',
messages: [t('Incomplete uploading files need to be resolved')],
});
} else {
field.setFeedback({});
}
}, [field, pendingList]);
const onUploadChange = useCallback(
(info) => {
if (multiple) {
const uploadedList = info.fileList.filter((file) => file.status === 'done');
if (uploadedList.length) {
const valueList = [...(value ?? []), ...uploadedList.map(toValueItem)];
onChange?.(valueList);
}
setPendingList(info.fileList.filter((file) => file.status !== 'done').map(normalizeFile));
} else {
// NOTE: 用 fileList 里的才有附加的验证状态信息file 没有(不清楚为何)
const file = info.fileList.find((f) => f.uid === info.file.uid);
if (file.status === 'done') {
onChange?.(toValueItem(file));
setPendingList([]);
} else {
setPendingList([normalizeFile(file)]);
}
}
},
[value, multiple, onChange],
);
const onDelete = useCallback((file) => {
setPendingList((prevPendingList) => {
const index = prevPendingList.indexOf(file);
prevPendingList.splice(index, 1);
return [...prevPendingList];
});
}, []);
const { mimetype: accept, size } = rules ?? {};
const sizeHint = useSizeHint(size);
const selectable = !disabled && (multiple || !(value || pendingList.length));
return (
<>
{pendingList.map((file, index) => (
<AttachmentListItem key={file.uid} file={file} index={index} disabled={disabled} onDelete={onDelete} />
))}
<div
className={cls(`${prefixCls}-list-picture-card-container`, `${prefixCls}-list-item-container`)}
style={
selectable
? {}
: {
display: 'none',
}
}
>
<Tooltip title={sizeHint}>
<AntdUpload
accept={accept}
{...uploadProps}
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
fileList={pendingList}
beforeUpload={beforeUpload}
onChange={onUploadChange}
showUploadList={false}
>
{selectable ? (
<span>
<PlusOutlined />
<br /> {t('Upload')}
</span>
) : null}
</AntdUpload>
</Tooltip>
</div>
</>
);
}
function Attachment(props: UploadProps) {
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
return wrapSSR(
<div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>
<AttachmentList {...props} />
<Uploader {...props} />
</div>
</div>,
);
}, mapReadPretty(ReadPretty.File));
}
Attachment.ReadPretty = ReadPretty;
Upload.Attachment = withDynamicSchemaProps(connect(Attachment, mapReadPretty(Attachment.ReadPretty)), {
displayName: 'Upload.Attachment',
});
Upload.Dragger = connect(
(props: DraggerProps) => {
const { tipContent } = props;
const { tipContent, onChange, ...rest } = props;
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
const onFileChange = useCallback(
(info) => {
onChange?.(toFileList(info.fileList));
},
[onChange],
);
return wrapSSR(
<div className={cls(`${prefixCls}-dragger`, hashId)}>
<AntdUpload.Dragger {...useUploadProps(props)}>
<AntdUpload.Dragger {...useUploadProps(rest)} onChange={onFileChange}>
{tipContent}
{props.children}
</AntdUpload.Dragger>
@ -312,18 +477,21 @@ Upload.Dragger = connect(
Upload.DraggerV2 = withDynamicSchemaProps(
connect(
(props: DraggerV2Props) => {
({ rules, ...props }: DraggerV2Props) => {
const { t } = useTranslation();
const defaultTitle = t('Click or drag file to this area to upload');
const defaultSubTitle = t('Support for a single or bulk upload');
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { title = defaultTitle, subTitle = defaultSubTitle, ...extraProps } = useProps(props);
const { title = defaultTitle, ...extraProps } = useProps(props);
const [loading, setLoading] = useState(false);
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
const handleChange = (fileList: any[] = []) => {
const beforeUpload = useBeforeUpload(rules);
const { size, mimetype: accept } = rules ?? {};
const sizeHint = useSizeHint(size);
const handleChange = useCallback(
({ fileList }) => {
const { onChange } = extraProps;
onChange?.(fileList);
@ -332,17 +500,24 @@ Upload.DraggerV2 = withDynamicSchemaProps(
} else {
setLoading(false);
}
};
},
[extraProps],
);
return wrapSSR(
<div className={cls(`${prefixCls}-dragger`, hashId)}>
{/* @ts-ignore */}
<AntdUpload.Dragger {...useUploadProps({ ...props, ...extraProps, onChange: handleChange })}>
<AntdUpload.Dragger
{...useUploadProps({ ...props, ...extraProps, accept, onChange: handleChange, beforeUpload })}
>
<p className={`${prefixCls}-drag-icon`}>
{loading ? <LoadingOutlined style={{ fontSize: 36 }} spin /> : <InboxOutlined />}
</p>
<p className={`${prefixCls}-text`}>{title}</p>
<p className={`${prefixCls}-hint`}>{subTitle}</p>
<ul>
<li className={`${prefixCls}-hint`}>{t('Support for a single or bulk upload.')}</li>
<li className={`${prefixCls}-hint`}>{sizeHint}</li>
</ul>
</AntdUpload.Dragger>
</div>,
);
@ -355,14 +530,3 @@ Upload.DraggerV2 = withDynamicSchemaProps(
);
export default Upload;
function updateFileList(file: UploadFile, fileList: (UploadFile | Readonly<UploadFile>)[]) {
const nextFileList = [...fileList];
const fileIndex = nextFileList.findIndex(({ uid }) => uid === file.uid);
if (fileIndex === -1) {
nextFileList.push(file);
} else {
nextFileList[fileIndex] = file;
}
return nextFileList;
}

View File

@ -244,7 +244,8 @@ describe('Upload', () => {
_isJSONSchemaObject: true,
version: '2.0',
type: 'string',
default: {
default: [
{
id: 1,
title: '微信图片_20240131154451',
filename: '234ead512e44bf944689069ce2b41a95.png',
@ -260,6 +261,7 @@ describe('Upload', () => {
createdById: 1,
updatedById: 1,
},
],
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-read-pretty': false,

View File

@ -36,4 +36,25 @@ mockRequest.onPost('/attachments:create').reply(async (config) => {
];
});
mockRequest.onGet('/storages:getRules/').reply(async (config) => {
return [
200,
{
data: {},
},
];
});
mockRequest.onGet('/storages:getRules/limited').reply(async (config) => {
return [
200,
{
data: {
size: 1024,
mimetype: 'text/plain',
},
},
];
});
export default apiClient;

View File

@ -42,6 +42,20 @@ const schema = {
},
};
const collection = {
name: 'posts',
fields: [
{
name: 'input',
type: 'attachment',
},
{
name: 'read',
type: 'attachment',
},
],
};
export default () => {
return (
<APIClientProvider apiClient={apiClient}>

View File

@ -106,6 +106,24 @@ const schema = {
},
};
const collection = {
name: 'posts',
fields: [
{
name: 'input',
type: 'attachment',
},
{
name: 'read',
type: 'attachment',
},
{
name: 'read2',
type: 'attachment',
},
],
};
export default () => {
return (
<APIClientProvider apiClient={apiClient}>

View File

@ -1,3 +1,13 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { uid } from '@formily/shared';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils';
@ -19,23 +29,25 @@ const schema: ISchema = {
},
},
},
}
};
const Demo = () => {
return <SchemaComponent schema={schema} />;
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo })
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({
plugins: [DemoPlugin],
apis: {
'attachments:create': {
'attachments:create': () => [
200,
{
data: {
id: 1,
id: uid(),
title: '20240131154451',
filename: '99726f173d5329f056c083f2ee0ccc08.png',
extname: '.png',
@ -51,9 +63,8 @@ const app = mockApp({
updatedById: 1,
},
},
],
},
});
export default app.getRootComponent();

View File

@ -1,3 +1,13 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { uid } from '@formily/shared';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils';
@ -20,23 +30,25 @@ const schema: ISchema = {
},
},
},
}
};
const Demo = () => {
return <SchemaComponent schema={schema} />;
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo })
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({
plugins: [DemoPlugin],
apis: {
'attachments:create': {
'attachments:create': () => [
200,
{
data: {
id: 1,
id: uid(),
title: '20240131154451',
filename: '99726f173d5329f056c083f2ee0ccc08.png',
extname: '.png',
@ -52,9 +64,8 @@ const app = mockApp({
updatedById: 1,
},
},
],
},
});
export default app.getRootComponent();

View File

@ -1,4 +1,3 @@
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin, ISchema } from '@nocobase/client';
@ -24,8 +23,6 @@ const schema: ISchema = {
mimetype: 'image/jpeg',
path: '',
meta: {},
status: 'uploading',
percent: 60,
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
created_at: '2021-08-13T15:00:17.423Z',
updated_at: '2021-08-13T15:00:17.423Z',
@ -64,14 +61,14 @@ const schema: ISchema = {
},
},
},
}
};
const Demo = () => {
return <SchemaComponent schema={schema} />;
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo })
this.app.router.add('root', { path: '/', Component: Demo });
}
}

View File

@ -0,0 +1,65 @@
import { uid } from '@formily/shared';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin, ISchema } from '@nocobase/client';
const schema: ISchema = {
type: 'void',
name: 'root',
'x-decorator': 'FormV2',
'x-component': 'ShowFormData',
properties: {
test: {
type: 'boolean',
title: 'Test',
'x-decorator': 'FormItem',
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:create',
rules: {
size: 10240,
mimetype: 'image/png',
},
},
},
},
};
const Demo = () => {
return <SchemaComponent schema={schema} />;
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({
plugins: [DemoPlugin],
apis: {
'attachments:create': () => [
200,
{
data: {
id: uid(),
title: '20240131154451',
filename: '99726f173d5329f056c083f2ee0ccc08.png',
extname: '.png',
path: '',
size: 841380,
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
mimetype: 'image/png',
meta: {},
storageId: 1,
updatedAt: '2024-04-29T09:49:28.769Z',
createdAt: '2024-04-29T09:49:28.769Z',
createdById: 1,
updatedById: 1,
},
},
],
},
});
export default app.getRootComponent();

View File

@ -19,6 +19,10 @@ type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
<code src="./demos/new-demos/multiple.tsx"></code>
## Rules
<code src="./demos/new-demos/rules.tsx"></code>
## Read Pretty
```ts

View File

@ -19,6 +19,10 @@ type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
<code src="./demos/new-demos/multiple.tsx"></code>
## Rules
<code src="./demos/new-demos/rules.tsx"></code>
## Read Pretty
```ts

View File

@ -64,8 +64,6 @@ export const UPLOAD_PLACEHOLDER = [
ext: /\.(zip|rar|arj|z|gz|iso|jar|ace|tar|uue|dmg|pkg|lzh|cab)$/i,
icon: '//img.alicdn.com/tfs/TB10jmfr29TBuNjy0FcXXbeiFXa-200-200.png',
},
{
ext: /\.[^.]+$/i,
icon: '//img.alicdn.com/tfs/TB10.R4r3mTBuNjy1XbXXaMrVXa-200-200.png',
},
];
export const UNKNOWN_FILE_ICON = '//img.alicdn.com/tfs/TB10.R4r3mTBuNjy1XbXXaMrVXa-200-200.png';

View File

@ -7,23 +7,23 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Field } from '@formily/core';
import { useField } from '@formily/react';
import { reaction } from '@formily/reactive';
import { isArr, isValid, toArr as toArray } from '@formily/shared';
import { UploadFile } from 'antd/es/upload/interface';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import match from 'mime-match';
import { useCallback } from 'react';
import { useAPIClient } from '../../../api-client';
import { UPLOAD_PLACEHOLDER } from './placeholder';
import { UNKNOWN_FILE_ICON, UPLOAD_PLACEHOLDER } from './placeholder';
import type { IUploadProps, UploadProps } from './type';
export const isImage = (extName: string) => {
const reg = /\.(png|jpg|gif|jpeg|webp)$/;
return reg.test(extName);
export const FILE_SIZE_LIMI_MAX = 1024 * 1024 * 1024;
export const isImage = (file) => {
return match(file.mimetype || file.type, 'image/*');
};
export const isPdf = (extName: string) => {
return extName.toLowerCase().endsWith('.pdf');
export const isPdf = (file) => {
return match(file.mimetype || file.type, 'application/pdf');
};
export const toMap = (fileList: any) => {
@ -92,93 +92,65 @@ export const testOpts = (ext: RegExp, options: { exclude?: string[]; include?: s
return true;
};
export const getImageByUrl = (url: string, options: any) => {
export const getImageByUrl = (url: string, options: any = {}) => {
for (let i = 0; i < UPLOAD_PLACEHOLDER.length; i++) {
if (UPLOAD_PLACEHOLDER[i].ext.test(url) && testOpts(UPLOAD_PLACEHOLDER[i].ext, options)) {
return UPLOAD_PLACEHOLDER[i].icon || url;
}
}
// console.log(UPLOAD_PLACEHOLDER[i].ext, testOpts(UPLOAD_PLACEHOLDER[i].ext, options));
if (UPLOAD_PLACEHOLDER[i].ext.test(url)) {
if (testOpts(UPLOAD_PLACEHOLDER[i].ext, options)) {
return UPLOAD_PLACEHOLDER[i].icon || UNKNOWN_FILE_ICON;
} else {
return url;
}
}
}
return UNKNOWN_FILE_ICON;
};
export const getURL = (target: any) => {
return target?.['url'] || target?.['downloadURL'] || target?.['imgURL'];
return target?.['url'] || target?.['downloadURL'] || target?.['imgURL'] || target?.['name'];
};
export const getThumbURL = (target: any) => {
return target?.['thumbUrl'] || target?.['url'] || target?.['downloadURL'] || target?.['imgURL'];
return target?.['thumbUrl'] || target?.['url'] || target?.['downloadURL'] || target?.['imgURL'] || target?.['name'];
};
export const getErrorMessage = (target: any) => {
return target?.errorMessage ||
target?.errMsg ||
target?.errorMsg ||
target?.message ||
typeof target?.error === 'string'
? target.error
: '';
};
export function getResponseMessage({ error, response }: UploadFile<any>) {
if (error instanceof Error && 'isAxiosError' in error) {
// @ts-ignore
return error.response.data?.errors?.map?.((item) => item?.message).join(', ');
}
if (!response) {
return '';
}
if (typeof response === 'string') {
return response;
}
const { errors } = response.data ?? {};
if (!errors?.length) {
return '';
}
return errors.map((item) => item?.message).join(', ');
}
export const getState = (target: any) => {
if (target?.success === false) return 'error';
if (target?.failed === true) return 'error';
if (target?.error) return 'error';
return target?.state || target?.status;
export function normalizeFile(file: UploadFile & Record<string, any>) {
const imageUrl = isImage(file) ? URL.createObjectURL(file.originFileObj) : getImageByUrl(file.name);
const response = getResponseMessage(file);
return {
...file,
title: file.name,
thumbUrl: imageUrl,
imageUrl,
response,
};
}
export const normalizeFileList = (fileList: UploadFile[]) => {
if (fileList && fileList.length) {
return fileList.map((file, index) => {
return {
...file,
uid: file.uid || `${index}`,
status: getState(file.response) || getState(file),
url: getURL(file) || getURL(file?.response),
thumbUrl: getImageByUrl(getThumbURL(file) || getThumbURL(file?.response), {
exclude: ['.png', '.jpg', '.jpeg', '.gif'],
}),
};
});
return fileList.map(normalizeFile);
}
return [];
};
export const useValidator = (validator: (value: any) => string) => {
const field = useField<Field>();
useEffect(() => {
const dispose = reaction(
() => field.value,
(value) => {
const message = validator(value);
field.setFeedback({
type: 'error',
code: 'UploadError',
messages: message ? [message] : [],
});
},
);
return () => {
dispose();
};
}, []);
};
export const useUploadValidator = (serviceErrorMessage = 'Upload Service Error') => {
useValidator((value) => {
const list = toArr(value);
for (let i = 0; i < list.length; i++) {
if (list[i]?.status === 'error') {
return getErrorMessage(list[i]?.response) || getErrorMessage(list[i]) || serviceErrorMessage;
}
}
});
};
export function useUploadProps<T extends IUploadProps = UploadProps>({ serviceErrorMessage, ...props }: T) {
useUploadValidator(serviceErrorMessage);
const onChange = (param: { fileList: any[] }) => {
props.onChange?.(normalizeFileList([...param.fileList]));
};
export function useUploadProps<T extends IUploadProps = UploadProps>(props: T) {
const api = useAPIClient();
return {
@ -215,10 +187,13 @@ export function useUploadProps<T extends IUploadProps = UploadProps>({ serviceEr
},
};
},
onChange,
};
}
export function toValueItem(file) {
return file.response?.data;
}
export const toItem = (file) => {
if (file?.response?.data) {
file = {
@ -245,3 +220,61 @@ export const toValue = (fileList: any) => {
.filter((file) => !file.response || file.status === 'done')
.map((file) => file?.response?.data || file);
};
const Rules: Record<string, RuleFunction> = {
size(file, options: number): null | string {
const size = options ?? FILE_SIZE_LIMI_MAX;
if (size === 0) {
return null;
}
return file.size <= size ? null : 'File size exceeds the limit';
},
mimetype(file, options: string | string[] = '*'): null | string {
const pattern = options.toString().trim();
if (!pattern || pattern === '*') {
return null;
}
return pattern.split(',').filter(Boolean).some(match(file.type)) ? null : 'File type is not allowed';
},
};
type RuleFunction = (file: UploadFile, options: any) => string | null;
function validate(file, rules: Record<string, any>) {
if (!rules) {
return null;
}
const ruleKeys = Object.keys(rules);
if (!ruleKeys.length) {
return null;
}
for (const key of ruleKeys) {
const error = Rules[key](file, rules[key]);
if (error) {
return error;
}
}
return null;
}
export function useBeforeUpload(rules) {
const { t } = useTranslation();
return useCallback(
(file) => {
const error = validate(file, rules);
if (error) {
file.status = 'error';
file.response = t(error);
} else {
if (file.status === 'error') {
delete file.status;
delete file.response;
}
}
return !error;
},
[rules],
);
}

View File

@ -14,8 +14,8 @@ export const useStyles = genStyleHook('upload', (token) => {
return {
[`${componentCls}-wrapper`]: {
'&.nb-upload-small': {
[`${componentCls}-list-picture-card-container${componentCls}-list-picture-card-container`]: {
'&.nb-upload.nb-upload-small': {
[`${componentCls}-list-picture-card-container`]: {
margin: '0 3px 3px 0 !important',
height: '32px !important',
width: '32px !important',
@ -26,11 +26,15 @@ export const useStyles = genStyleHook('upload', (token) => {
},
[`${componentCls}-list-picture ${componentCls}-list-item, ${componentCls}-list-picture-card ${componentCls}-list-item`]:
{
borderRadius: '4px',
padding: '1px !important',
[`${componentCls}-list-item-image`]: {
borderRadius: '2px',
},
},
},
'&.nb-upload-large': {
[`${componentCls}-list-picture-card-container${componentCls}-list-picture-card-container`]: {
[`${componentCls}-list-picture-card-container`]: {
margin: '0 3px 3px 0 !important',
height: '160px !important',
width: '160px !important',
@ -38,7 +42,7 @@ export const useStyles = genStyleHook('upload', (token) => {
},
},
'&.nb-upload': {
[`${componentCls}-list-item${componentCls}-list-item-list-type-picture-card`]: {
[`${componentCls}-list-item, ${componentCls}-list-item-list-type-picture-card`]: {
padding: '3px !important',
},
[`${componentCls}-list-item-thumbnail`]: {
@ -82,6 +86,29 @@ export const useStyles = genStyleHook('upload', (token) => {
marginBlock: '0 28px !important',
},
},
[`${componentCls}-list-item-error`]: {
[`${componentCls}-list-item-info img`]: {
opacity: 0.6,
},
},
[`${componentCls}-drag`]: {
[`${componentCls}-drag-container`]: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
ul: {
color: token.colorTextSecondary,
[`${componentCls}-hint`]: {
textAlign: 'left',
},
},
},
},
},
} as any;
});

View File

@ -11,25 +11,28 @@ import type { DraggerProps as AntdDraggerProps, UploadProps as AntdUploadProps }
import { UploadFile } from 'antd/es/upload/interface';
import React from 'react';
export type PropsRules = Record<string, any>;
export type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
onChange?: (fileList: UploadFile | UploadFile[]) => void;
serviceErrorMessage?: string;
value?: any;
size?: string;
rules?: PropsRules;
};
export type DraggerProps = Omit<AntdDraggerProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
serviceErrorMessage?: string;
onChange?: (fileList: UploadFile | UploadFile[]) => void;
// serviceErrorMessage?: string;
tipContent?: string | React.ReactNode;
children?: React.ReactNode;
};
export type DraggerV2Props = Omit<AntdDraggerProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
onChange?: (fileList: UploadFile | UploadFile[]) => void;
serviceErrorMessage?: string;
title?: string;
subTitle?: string;
rules?: PropsRules;
children?: React.ReactNode;
/** @deprecated */
useProps?: () => any;
@ -41,9 +44,10 @@ export type ComposedUpload = React.FC<UploadProps> & {
File?: React.FC<UploadProps>;
Attachment?: React.FC<UploadProps>;
Selector?: React.FC<any>;
ReadPretty?: React.FC<any>;
};
export type IUploadProps = {
serviceErrorMessage?: string;
onChange?: (...args: any) => void;
// serviceErrorMessage?: string;
// onChange?: (...args: any) => void;
};

View File

@ -105,6 +105,7 @@ const schema: ISchema = {
multiple: false,
// accept: 'jpg,png'
},
'x-use-component-props': 'useCollectionFieldStorageRules',
},
enabledLanguages: {
type: 'array',

View File

@ -7,11 +7,11 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { ComponentType } from 'react';
import MockAdapter from 'axios-mock-adapter';
import { observer, useFieldSchema, useForm } from '@formily/react';
import { AxiosInstance, AxiosRequestConfig } from 'axios';
import { set, get, pick } from 'lodash';
import { useFieldSchema, observer, useForm } from '@formily/react';
import MockAdapter from 'axios-mock-adapter';
import { get, pick, set } from 'lodash';
import React, { ComponentType } from 'react';
import {
AntdSchemaComponentPlugin,
@ -23,13 +23,12 @@ import {
SchemaComponent,
SchemaSettings,
SchemaSettingsPlugin,
// @ts-ignore
} from '@nocobase/client';
import dataSourceMainCollections from './dataSourceMainCollections.json';
import dataSource2 from './dataSource2.json';
import dataSourceMainData from './dataSourceMainData.json';
import _ from 'lodash';
import dataSource2 from './dataSource2.json';
import dataSourceMainCollections from './dataSourceMainCollections.json';
import dataSourceMainData from './dataSourceMainData.json';
const defaultApis = {
'uiSchemas:patch': { data: { result: 'ok' } },
@ -49,6 +48,9 @@ type MockApis = Record<URL, ResponseData>;
type AppOrOptions = Application | ApplicationOptions;
function getProcessMockData(apis: Record<string, any>, key: string) {
if (typeof apis[key] === 'function') {
return apis[key];
}
return (config: AxiosRequestConfig) => {
if (!apis[key]) return [404, { data: { message: 'mock data not found' } }];
if (config?.params?.pageSize || config?.params?.page) {

View File

@ -131,7 +131,7 @@ test.describe('form item & view form', () => {
try {
await testSmall(26);
} catch (err) {
await testSmall(24);
await testSmall(28);
}
}
});

View File

@ -0,0 +1,72 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { InputNumber, Select, Space } from 'antd';
import React, { useCallback } from 'react';
import { FILE_SIZE_LIMIT_DEFAULT, FILE_SIZE_LIMIT_MAX, FILE_SIZE_LIMIT_MIN } from '../constants';
const UnitOptions = [
{ value: 1, label: 'Byte' },
{ value: 1024, label: 'KB' },
{ value: 1024 * 1024, label: 'MB' },
{ value: 1024 * 1024 * 1024, label: 'GB' },
];
function getUnitOption(v, defaultUnit = 1024 * 1024) {
const value = v || defaultUnit;
for (let i = UnitOptions.length - 1; i >= 0; i--) {
const option = UnitOptions[i];
if (value % option.value === 0) {
return option;
}
}
return UnitOptions[0];
}
function limitSize(value, min, max) {
return Math.min(Math.max(min, value), max);
}
export function FileSizeField(props) {
const {
value,
defaultUnit = 1024 * 1024,
min = FILE_SIZE_LIMIT_MIN,
max = FILE_SIZE_LIMIT_MAX,
step = 1,
onChange,
} = props;
const defaultValue = props.defaultValue ?? FILE_SIZE_LIMIT_DEFAULT;
const dvOption = getUnitOption(defaultValue, defaultUnit);
const dv = defaultValue / dvOption.value;
const vOption = getUnitOption(value ?? defaultValue, defaultUnit);
const v = value == null ? dv : value / vOption.value;
const onNumberChange = useCallback(
(val) => {
onChange?.(limitSize(val == null ? val : val * vOption.value, min, max));
},
[vOption.value],
);
const onUnitChange = useCallback(
(val) => {
onChange?.(limitSize(v * val, min, max));
},
[v],
);
return (
<Space.Compact>
<InputNumber value={v} onChange={onNumberChange} defaultValue={`${dv}`} step={step} />
<Select options={UnitOptions} value={vOption.value} onChange={onUnitChange} className="auto-width" />
</Space.Compact>
);
}

View File

@ -63,7 +63,7 @@ test.describe('file collection block', () => {
await expect(page.getByRole('menuitem', { name: 'Size' })).not.toBeVisible();
const imgBox = await page.getByLabel('block-item-CardItem-').locator('.ant-upload-list-item-image').boundingBox();
expect(imgBox.width).toBeLessThanOrEqual(24);
expect(imgBox.width).toBeLessThanOrEqual(28);
// 2. 弹窗中应该可以配置图片尺寸
await page.getByLabel('action-Action.Link-View').click();

View File

@ -8,3 +8,4 @@
*/
export * from './useUploadFiles';
export * from './useStorageRules';

View File

@ -0,0 +1,40 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useCollectionField, useCollectionManager, useRequest } from '@nocobase/client';
export function useStorageRules(storage) {
const name = storage ?? '';
const { loading, data } = useRequest<any>(
{
url: `storages:getRules/${name}`,
},
{
refreshDeps: [name],
},
);
return (!loading && data?.data) || null;
}
export function useAttachmentFieldProps() {
const field = useCollectionField();
const rules = useStorageRules(field?.storage);
return {
rules,
action: `${field.target}:create${field.storage ? `?attachmentField=${field.collectionName}.${field.name}` : ''}`,
};
}
export function useFileCollectionStorageRules() {
const field = useCollectionField();
const collectionManager = useCollectionManager();
const collection = collectionManager.getCollection(field?.target);
return useStorageRules(collection?.getOption('storage'));
}

View File

@ -14,20 +14,16 @@ import {
useCollection,
useSourceIdFromParentRecord,
} from '@nocobase/client';
import { notification } from 'antd';
import { useContext, useMemo } from 'react';
import { useFmTranslation } from '../locale';
// 限制上传文件大小为 10M
export const FILE_LIMIT_SIZE = 1024 * 1024 * 1024;
import { useStorageRules } from './useStorageRules';
export const useUploadFiles = () => {
const { service } = useBlockRequestContext();
const { t } = useFmTranslation();
const { setVisible } = useActionContext();
const { props: blockProps } = useBlockRequestContext();
const collection = useCollection();
const sourceId = useSourceIdFromParentRecord();
const rules = useStorageRules(collection?.getOption('storage'));
const action = useMemo(() => {
let action = `${collection.name}:create`;
if (blockProps?.association) {
@ -43,19 +39,6 @@ export const useUploadFiles = () => {
return {
action,
/**
* false true
*/
beforeUpload(file) {
if (file.size > FILE_LIMIT_SIZE) {
notification.error({
message: `${t('File size cannot exceed')} ${FILE_LIMIT_SIZE / 1024 / 1024}M`,
});
file.status = 'error';
return false;
}
return true;
},
onChange(fileList) {
fileList.forEach((file) => {
if (file.status === 'uploading' && !uploadingFiles[file.uid]) {
@ -78,5 +61,6 @@ export const useUploadFiles = () => {
setVisible(false);
}
},
rules,
};
};

View File

@ -7,13 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin, useCollection_deprecated } from '@nocobase/client';
import { Plugin, useCollection } from '@nocobase/client';
import { FileManagerProvider } from './FileManagerProvider';
import { FileStoragePane } from './FileStorage';
import { NAMESPACE } from './locale';
import { storageTypes } from './schemas/storageTypes';
import { AttachmentFieldInterface } from './interfaces/attachment';
import { FileCollectionTemplate } from './templates';
import { useAttachmentFieldProps, useFileCollectionStorageRules } from './hooks';
import { FileSizeField } from './FileSizeField';
export class PluginFileManagerClient extends Plugin {
storageTypes = new Map();
@ -45,10 +47,19 @@ export class PluginFileManagerClient extends Plugin {
},
},
useVisible() {
const collection = useCollection_deprecated();
const collection = useCollection();
return collection.template === 'file';
},
});
this.app.addScopes({
useAttachmentFieldProps,
useFileCollectionStorageRules,
});
this.app.addComponents({
FileSizeField,
});
}
registerStorageType(name: string, options) {

View File

@ -26,22 +26,20 @@ export class AttachmentFieldInterface extends CollectionFieldInterface {
type: 'array',
// title,
'x-component': 'Upload.Attachment',
'x-component-props': {},
'x-use-component-props': 'useAttachmentFieldProps',
},
};
availableTypes = ['belongsToMany'];
schemaInitialize(schema: ISchema, { block, field }) {
if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {};
schema['x-component-props']['size'] = 'small';
}
if (!schema['x-component-props']) {
schema['x-component-props'] = {};
}
schema['x-component-props']['action'] = `${field.target}:create${
field.storage ? `?attachmentField=${field.collectionName}.${field.name}` : ''
}`;
if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props']['size'] = 'small';
}
schema['x-use-component-props'] = 'useAttachmentFieldProps';
}
initialize(values: any) {
if (!values.through) {
@ -66,9 +64,11 @@ export class AttachmentFieldInterface extends CollectionFieldInterface {
type: 'string',
title: `{{t("MIME type", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input',
'x-component-props': {
placeholder: 'image/*',
},
'x-decorator': 'FormItem',
description: 'Example: image/png',
default: 'image/*',
},
'uiSchema.x-component-props.multiple': {
type: 'boolean',

View File

@ -53,7 +53,7 @@ const collection = {
name: 'baseUrl',
interface: 'input',
uiSchema: {
title: `{{t("Storage base URL", { ns: "${NAMESPACE}" })}}`,
title: `{{t("Access base URL", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-component': 'Input',
required: true,

View File

@ -8,37 +8,25 @@
*/
import { NAMESPACE } from '../../locale';
import common from './common';
export default {
title: `{{t("Aliyun OSS", { ns: "${NAMESPACE}" })}}`,
name: 'ali-oss',
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': '{{ !createOnly }}',
required: true,
default: '{{ useNewId("s_") }}',
description:
'{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}',
},
baseUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
title: common.title,
name: common.name,
baseUrl: common.baseUrl,
options: {
type: 'object',
'x-component': 'div',
'x-component': 'fieldset',
properties: {
region: {
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
description: `{{t('Aliyun OSS region part of the bucket. For example: "oss-cn-beijing".', { ns: "${NAMESPACE}" })}}`,
required: true,
},
accessKeyId: {
@ -70,24 +58,13 @@ export default {
'x-component-props': {
placeholder: '?x-oss-process=image/auto-orient,1/resize,m_fill,w_94,h_94/quality,q_90',
},
default: '?x-oss-process=image/auto-orient,1/resize,m_fill,w_94,h_94/quality,q_90',
description: '{{ xStyleProcessDesc }}',
},
},
},
path: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
default: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
},
paranoid: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Keep file in storage when destroy record", { ns: "${NAMESPACE}" })}}`,
},
path: common.path,
rules: common.rules,
default: common.default,
paranoid: common.paranoid,
},
};

View File

@ -0,0 +1,72 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FILE_SIZE_LIMIT_DEFAULT } from '../../../constants';
import { NAMESPACE } from '../../locale';
export default {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': '{{ !createOnly }}',
required: true,
default: '{{ useNewId("s_") }}',
description:
'{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}',
},
baseUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
description: `{{t('Base URL for file access, could be your CDN base URL. For example: "https://cdn.nocobase.com".', { ns: "${NAMESPACE}" })}}`,
},
path: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
description: `{{t('Relative path the file will be saved to. Left blank as root path. The leading and trailing slashes "/" will be ignored. For example: "user/avatar".', { ns: "${NAMESPACE}" })}}`,
},
rules: {
type: 'object',
'x-component': 'fieldset',
properties: {
size: {
type: 'number',
title: `{{t("File size limit", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Minimum from 1 byte, maximum up to 1GB.", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'FileSizeField',
required: true,
default: FILE_SIZE_LIMIT_DEFAULT,
},
mimetype: {
type: 'string',
title: `{{t("File type (in MIME type format)", { ns: "${NAMESPACE}" })}}`,
description: `{{t('Multi-types seperated with comma, for example: "image/*", "image/png", "image/*, application/pdf" etc.', { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: '*',
},
},
},
},
default: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
},
paranoid: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Keep file in storage when destroy record", { ns: "${NAMESPACE}" })}}`,
},
};

View File

@ -8,24 +8,14 @@
*/
import { NAMESPACE } from '../../locale';
import common from './common';
export default {
title: `{{t("Local storage", { ns: "${NAMESPACE}" })}}`,
name: 'local',
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': '{{ !createOnly }}',
required: true,
default: '{{ useNewId("s_") }}',
description:
'{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}',
},
title: common.title,
name: common.name,
baseUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
@ -53,15 +43,8 @@ export default {
addonBefore: 'storage/uploads/',
},
},
default: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
},
paranoid: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Keep file in storage when destroy record", { ns: "${NAMESPACE}" })}}`,
},
rules: common.rules,
default: common.default,
paranoid: common.paranoid,
},
};

View File

@ -8,31 +8,18 @@
*/
import { NAMESPACE } from '../../locale';
import common from './common';
export default {
title: `{{t("Amazon S3", { ns: "${NAMESPACE}" })}}`,
name: 's3',
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': '{{ !createOnly }}',
required: true,
default: '{{ useNewId("s_") }}',
description:
'{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}',
},
baseUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
title: common.title,
name: common.name,
baseUrl: common.baseUrl,
options: {
type: 'object',
'x-component': 'div',
'x-component': 'fieldset',
properties: {
region: {
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
@ -70,19 +57,9 @@ export default {
},
},
},
path: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
default: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
},
paranoid: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Keep file in storage when destroy record", { ns: "${NAMESPACE}" })}}`,
},
path: common.path,
rules: common.rules,
default: common.default,
paranoid: common.paranoid,
},
};

View File

@ -8,31 +8,18 @@
*/
import { NAMESPACE } from '../../locale';
import common from './common';
export default {
title: `{{t("Tencent COS", { ns: "${NAMESPACE}" })}}`,
name: 'tx-cos',
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
name: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-disabled': '{{ !createOnly }}',
required: true,
default: '{{ useNewId("s_") }}',
description:
'{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}',
},
baseUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
title: common.title,
name: common.name,
baseUrl: common.baseUrl,
options: {
type: 'object',
'x-component': 'div',
'x-component': 'fieldset',
properties: {
Region: {
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
@ -64,19 +51,9 @@ export default {
},
},
},
path: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
default: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Default storage", { ns: "${NAMESPACE}" })}}`,
},
paranoid: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-content': `{{t("Keep file in storage when destroy record", { ns: "${NAMESPACE}" })}}`,
},
path: common.path,
rules: common.rules,
default: common.default,
paranoid: common.paranoid,
},
};

View File

@ -100,7 +100,7 @@ export class FileCollectionTemplate extends CollectionTemplate {
},
// 文件的可访问地址
{
interface: 'input',
interface: 'url',
type: 'string',
name: 'url',
deletable: false,

View File

@ -9,7 +9,9 @@
export const FILE_FIELD_NAME = 'file';
export const LIMIT_FILES = 1;
export const DEFAULT_MAX_FILE_SIZE = 1024 * 1024 * 1024;
export const FILE_SIZE_LIMIT_MIN = 1;
export const FILE_SIZE_LIMIT_MAX = 1024 * 1024 * 1024;
export const FILE_SIZE_LIMIT_DEFAULT = 1024 * 1024 * 20;
export const STORAGE_TYPE_LOCAL = 'local';
export const STORAGE_TYPE_ALI_OSS = 'ali-oss';

View File

@ -3,7 +3,11 @@
"File name": "文件名",
"Extension name": "扩展名",
"Size": "文件大小",
"File size limit": "文件大小限制",
"Minimum from 1 byte, maximum up to 1GB.": "最小为 1 字节,最大为 1GB。",
"MIME type": "MIME 类型",
"File type (in MIME type format)": "文件类型MIME 格式)",
"Multi-types seperated with comma, for example: \"image/*\", \"image/png\", \"image/*, application/pdf\" etc.": "多个类型用逗号分隔,例如:\"image/*\", \"image/png\", \"image/*, application/pdf\" 等。",
"URL": "URL",
"File storage": "文件存储",
"File manager": "文件管理器",
@ -14,7 +18,8 @@
"Storage name": "存储空间标识",
"Storage type": "存储类型",
"Default storage": "默认存储空间",
"Storage base URL": "访问 URL 基础",
"Access base URL": "访问 URL 基础",
"Base URL for file access, could be your CDN base URL. For example: \"https://cdn.nocobase.com\".": "文件访问的基础 URL可以是你的 CDN 基础 URL。例如\"https://cdn.nocobase.com\"。",
"Destination": "上传目标文件夹",
"Use the built-in static file server": "使用内置静态文件服务",
"Local storage": "本地存储",
@ -24,9 +29,12 @@
"Region": "区域",
"Bucket": "存储桶",
"Path": "路径",
"Relative path the file will be saved to. Left blank as root path. The leading and trailing slashes \"/\" will be ignored. For example: \"user/avatar\".": "文件保存的相对路径。留空则为根路径。开始和结尾的斜杠“/”会被忽略。例如:\"user/avatar\"。",
"Filename": "文件名",
"Will be used for API": "将用于 API",
"Default storage will be used when not selected": "留空将使用默认存储空间",
"Keep file in storage when destroy record": "删除记录时保留文件",
"See more": "更多请查阅"
"See more": "更多请查阅",
"Aliyun OSS region part of the bucket. For example: \"oss-cn-beijing\".": "阿里云 OSS 存储桶所在区域。例如:\"oss-cn-beijing\"。"
}

View File

@ -15,10 +15,6 @@ export class FileModel extends Model {
const fileStorages = this.constructor['database']?.['_fileStorages'];
if (json.storageId && fileStorages && fileStorages.has(json.storageId)) {
const storage = fileStorages.get(json.storageId);
json['thumbnailRule'] = storage?.options?.thumbnailRule;
if (!json['thumbnailRule'] && storage?.type === 'ali-oss') {
json['thumbnailRule'] = '?x-oss-process=image/auto-orient,1/resize,m_fill,w_94,h_94/quality,q_90';
}
if (storage?.type === 'local' && process.env.APP_PUBLIC_PATH) {
json['url'] = process.env.APP_PUBLIC_PATH.replace(/\/$/g, '') + json.url;
}

View File

@ -10,7 +10,7 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getApp } from '.';
import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants';
import { FILE_FIELD_NAME, FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from '../../constants';
const { LOCAL_STORAGE_BASE_URL, LOCAL_STORAGE_DEST = 'storage/uploads', APP_PORT = '13000' } = process.env;
@ -22,17 +22,16 @@ describe('action', () => {
let db;
let StorageRepo;
let AttachmentRepo;
let local1;
beforeEach(async () => {
app = await getApp({
database: {},
});
app = await getApp();
agent = app.agent();
db = app.db;
AttachmentRepo = db.getCollection('attachments').repository;
StorageRepo = db.getCollection('storages').repository;
await StorageRepo.create({
local1 = await StorageRepo.create({
values: {
name: 'local1',
type: STORAGE_TYPE_LOCAL,
@ -200,7 +199,6 @@ describe('action', () => {
// 文件的 url 是否正常生成
expect(body.data.url).toBe(`${BASE_URL}/${urlPath}/${body.data.filename}`);
console.log(body.data.url);
const url = body.data.url.replace(`http://localhost:${APP_PORT}`, '');
const content = await agent.get(url);
expect(content.text.includes('Hello world!')).toBe(true);
@ -208,6 +206,43 @@ describe('action', () => {
});
});
describe('rules', () => {
it.skip('file size smaller than limit', async () => {
const storage = await StorageRepo.create({
values: {
name: 'local_private',
type: STORAGE_TYPE_LOCAL,
rules: {
size: 13,
},
baseUrl: '/storage/uploads',
options: {
documentRoot: 'storage/uploads',
},
},
});
db.collection({
name: 'customers',
fields: [
{
name: 'file',
type: 'belongsTo',
target: 'attachments',
storage: storage.name,
},
],
});
const res1 = await agent.resource('attachments').create({
attachmentField: 'customers.file',
file: path.resolve(__dirname, './files/text.txt'),
});
// console.log('-------', res1);
expect(res1.status).toBe(200);
});
});
describe('destroy', () => {
it('destroy one existing file with `paranoid`', async () => {
db.collection({
@ -338,4 +373,37 @@ describe('action', () => {
expect(attachmentExists).toBeNull();
});
});
describe('storage actions', () => {
describe('getRules', () => {
it('get rules without key as default storage', async () => {
const { body, status } = await agent.resource('storages').getRules();
expect(status).toBe(200);
expect(body.data).toEqual({ size: FILE_SIZE_LIMIT_DEFAULT });
});
it('get rules by storage id as default rules', async () => {
const { body, status } = await agent.resource('storages').getRules({ filterByTk: 1 });
expect(status).toBe(200);
expect(body.data).toEqual({ size: FILE_SIZE_LIMIT_DEFAULT });
});
it('get rules by unexisted id as 404', async () => {
const { body, status } = await agent.resource('storages').getRules({ filterByTk: -1 });
expect(status).toBe(404);
});
it('get rules by storage id', async () => {
const { body, status } = await agent.resource('storages').getRules({ filterByTk: local1.id });
expect(status).toBe(200);
expect(body.data).toMatchObject({ size: 1024 });
});
it('get rules by storage name', async () => {
const { body, status } = await agent.resource('storages').getRules({ filterByTk: local1.name });
expect(status).toBe(200);
expect(body.data).toMatchObject({ size: 1024 });
});
});
});
});

View File

@ -18,8 +18,7 @@ export async function getApp(options = {}): Promise<MockServer> {
cors: {
origin: '*',
},
plugins: ['file-manager'],
acl: false,
plugins: ['users', 'auth', 'file-manager'],
});
app.use(async (ctx, next) => {

View File

@ -10,7 +10,7 @@
import path from 'path';
import { MockServer } from '@nocobase/test';
import aliossStorage from '../../storages/ali-oss';
import { FILE_FIELD_NAME } from '../../constants';
import { FILE_FIELD_NAME } from '../../../constants';
import { getApp, requestFile } from '..';
import { Database } from '@nocobase/database';

View File

@ -10,7 +10,7 @@
import path from 'path';
import { MockServer } from '@nocobase/test';
import s3Storage from '../../storages/s3';
import { FILE_FIELD_NAME } from '../../constants';
import { FILE_FIELD_NAME } from '../../../constants';
import { getApp, requestFile } from '..';
import Database from '@nocobase/database';

View File

@ -10,7 +10,7 @@
import path from 'path';
import { MockServer } from '@nocobase/test';
import txStorage from '../../storages/tx-cos';
import { FILE_FIELD_NAME } from '../../constants';
import { FILE_FIELD_NAME } from '../../../constants';
import { getApp, requestFile } from '..';
import { Database } from '@nocobase/database';

View File

@ -11,9 +11,15 @@ import { Context, Next } from '@nocobase/actions';
import { koaMulter as multer } from '@nocobase/utils';
import path from 'path';
import { DEFAULT_MAX_FILE_SIZE, FILE_FIELD_NAME, LIMIT_FILES } from '../constants';
import {
FILE_SIZE_LIMIT_DEFAULT,
FILE_SIZE_LIMIT_MAX,
FILE_FIELD_NAME,
LIMIT_FILES,
FILE_SIZE_LIMIT_MIN,
} from '../../constants';
import * as Rules from '../rules';
import { getStorageConfig } from '../storages';
import Plugin from '..';
// TODO(optimize): 需要优化错误处理,计算失败后需要抛出对应错误,以便程序处理
function getFileFilter(storage) {
@ -33,7 +39,7 @@ function getFileData(ctx: Context) {
return ctx.throw(400, 'file validation failed');
}
const storageConfig = getStorageConfig(storage.type);
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
const { [storageConfig.filenameKey || 'filename']: name } = file;
// make compatible filename across cloud service (with path)
const filename = path.basename(name);
@ -64,7 +70,7 @@ async function multipart(ctx: Context, next: Next) {
return ctx.throw(500);
}
const storageConfig = getStorageConfig(storage.type);
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
if (!storageConfig) {
ctx.logger.error(`[file-manager] storage type "${storage.type}" is not defined`);
return ctx.throw(500);
@ -73,12 +79,16 @@ async function multipart(ctx: Context, next: Next) {
const multerOptions = {
fileFilter: getFileFilter(storage),
limits: {
fileSize: storage.rules.size ?? DEFAULT_MAX_FILE_SIZE,
// 每次只允许提交一个文件
files: LIMIT_FILES,
},
storage: storageConfig.make(storage),
};
multerOptions.limits['fileSize'] = Math.min(
Math.max(FILE_SIZE_LIMIT_MIN, storage.rules.size ?? FILE_SIZE_LIMIT_DEFAULT),
FILE_SIZE_LIMIT_MAX,
);
const upload = multer(multerOptions).single(FILE_FIELD_NAME);
try {
// NOTE: empty next and invoke after success
@ -160,7 +170,7 @@ export async function destroyMiddleware(ctx: Context, next: Next) {
await storages.reduce(
(promise, storage) =>
promise.then(async () => {
const storageConfig = getStorageConfig(storage.type);
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
const result = await storageConfig.delete(storage, storageGroupedRecords[storage.id]);
count += result[0];
undeleted.push(...result[1]);

View File

@ -9,8 +9,13 @@
import actions from '@nocobase/actions';
import { createMiddleware, destroyMiddleware } from './attachments';
import * as storageActions from './storages';
export default function ({ app }) {
app.resourcer.define({
name: 'storages',
actions: storageActions,
});
app.resourcer.use(createMiddleware, { tag: 'createMiddleware', after: 'auth' });
app.resourcer.registerActionHandler('upload', actions.create);

View File

@ -0,0 +1,21 @@
import Plugin from '..';
export async function getRules(context, next) {
const { storagesCache } = context.app.pm.get(Plugin) as Plugin;
let result;
const { filterByTk } = context.action.params;
if (!filterByTk) {
result = Array.from(storagesCache.values()).find((item) => item.default);
} else {
const isNumber = /^[1-9]\d*$/.test(filterByTk);
result = isNumber
? storagesCache.get(Number.parseInt(filterByTk, 10))
: Array.from(storagesCache.values()).find((item) => item.name === filterByTk);
}
if (!result) {
return context.throw(404);
}
context.body = result.rules;
next();
}

View File

@ -7,5 +7,5 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './constants';
export * from '../constants';
export { default } from './server';

View File

@ -0,0 +1,45 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Migration } from '@nocobase/server';
export default class extends Migration {
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
appVersion = '<1.0.0-alpha.16';
async up() {
const r = this.db.getRepository('uiSchemas');
await this.db.sequelize.transaction(async (transaction) => {
const items = await r.find({
filter: {
'schema.x-component': 'CollectionField',
},
transaction,
});
let count = 0;
for (const item of items) {
if (item.schema['x-component-props']?.action?.match(/^.+:create(\?attachmentField=.+)?/)) {
count++;
// console.log(item.schema['x-component-props']);
const {
schema: { action, ...schema },
} = item;
item.set('schema', {
...schema,
'x-use-component-props': 'useAttachmentFieldProps',
});
item.changed('schema');
await item.save({ transaction });
}
}
console.log('item updated:', count);
});
}
}

View File

@ -10,5 +10,9 @@
import match from 'mime-match';
export default function (file, options: string | string[] = '*'): boolean {
return options.toString().split(',').some(match(file.mimetype));
const pattern = options.toString().trim();
if (!pattern || pattern === '*') {
return true;
}
return pattern.split(',').some(match(file.mimetype));
}

View File

@ -7,33 +7,42 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils';
import { FileModel } from './FileModel';
import initActions from './actions';
import { getStorageConfig } from './storages';
import { IStorage, StorageModel } from './storages';
import { STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_LOCAL, STORAGE_TYPE_S3, STORAGE_TYPE_TX_COS } from '../constants';
import StorageTypeLocal from './storages/local';
import StorageTypeAliOss from './storages/ali-oss';
import StorageTypeS3 from './storages/s3';
import StorageTypeTxCos from './storages/tx-cos';
export { default as storageTypes } from './storages';
export type * from './storages';
const DEFAULT_STORAGE_TYPE = STORAGE_TYPE_LOCAL;
export default class PluginFileManagerServer extends Plugin {
storageType() {
return 'local';
}
storageTypes = new Registry<IStorage>();
storagesCache = new Map<number, StorageModel>();
async loadStorages(options?: { transaction: any }) {
const repository = this.db.getRepository('storages');
const storages = await repository.find({
transaction: options?.transaction,
});
const map = new Map();
this.storagesCache = new Map();
for (const storage of storages) {
map.set(storage.get('id'), storage.toJSON());
this.storagesCache.set(storage.get('id'), storage.toJSON());
}
this.db['_fileStorages'] = map;
this.db['_fileStorages'] = this.storagesCache;
}
async install() {
const defaultStorageConfig = getStorageConfig(this.storageType());
const defaultStorageConfig = this.storageTypes.get(DEFAULT_STORAGE_TYPE);
if (defaultStorageConfig) {
const Storage = this.db.getCollection('storages');
@ -49,7 +58,7 @@ export default class PluginFileManagerServer extends Plugin {
await Storage.repository.create({
values: {
...defaultStorageConfig.defaults(),
type: this.storageType(),
type: DEFAULT_STORAGE_TYPE,
default: true,
},
});
@ -69,14 +78,21 @@ export default class PluginFileManagerServer extends Plugin {
}
async load() {
await this.importCollections(resolve(__dirname, './collections'));
this.storageTypes.register(STORAGE_TYPE_LOCAL, new StorageTypeLocal());
this.storageTypes.register(STORAGE_TYPE_ALI_OSS, new StorageTypeAliOss());
this.storageTypes.register(STORAGE_TYPE_S3, new StorageTypeS3());
this.storageTypes.register(STORAGE_TYPE_TX_COS, new StorageTypeTxCos());
await this.db.import({
directory: resolve(__dirname, './collections'),
});
const Storage = this.db.getModel('storages');
Storage.afterSave(async (m, { transaction }) => {
await this.loadStorages({ transaction });
Storage.afterSave((m) => {
this.storagesCache.set(m.id, m.toJSON());
});
Storage.afterDestroy(async (m, { transaction }) => {
await this.loadStorages({ transaction });
Storage.afterDestroy((m) => {
this.storagesCache.delete(m.id);
});
this.app.acl.registerSnippet({
@ -96,12 +112,13 @@ export default class PluginFileManagerServer extends Plugin {
this.app.acl.allow('attachments', 'upload', 'loggedIn');
this.app.acl.allow('attachments', 'create', 'loggedIn');
this.app.acl.allow('storages', 'getRules', 'loggedIn');
// this.app.resourcer.use(uploadMiddleware);
// this.app.resourcer.use(createAction);
// this.app.resourcer.registerActionHandler('upload', uploadAction);
const defaultStorageName = getStorageConfig(this.storageType()).defaults().name;
const defaultStorageName = this.storageTypes.get(DEFAULT_STORAGE_TYPE).defaults().name;
this.app.acl.addFixedParams('storages', 'destroy', () => {
return {

View File

@ -7,18 +7,18 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { AttachmentModel } from '.';
import { STORAGE_TYPE_ALI_OSS } from '../constants';
import { AttachmentModel, StorageType } from '.';
import { STORAGE_TYPE_ALI_OSS } from '../../constants';
import { cloudFilenameGetter } from '../utils';
export default {
export default class extends StorageType {
make(storage) {
const createAliOssStorage = require('multer-aliyun-oss');
return new createAliOssStorage({
config: storage.options,
filename: cloudFilenameGetter(storage),
});
},
}
defaults() {
return {
title: '阿里云对象存储',
@ -32,7 +32,7 @@ export default {
bucket: process.env.ALI_OSS_BUCKET,
},
};
},
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { client } = this.make(storage);
const { deleted } = await client.deleteMulti(records.map((record) => `${record.path}/${record.filename}`));
@ -40,5 +40,5 @@ export default {
deleted.length,
records.filter((record) => !deleted.find((item) => item.Key === `${record.path}/${record.filename}`)),
];
},
};
}
}

View File

@ -9,22 +9,18 @@
import { StorageEngine } from 'multer';
import Application from '@nocobase/server';
import { Registry } from '@nocobase/utils';
import local from './local';
import oss from './ali-oss';
import s3 from './s3';
import cos from './tx-cos';
import { STORAGE_TYPE_LOCAL, STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_S3, STORAGE_TYPE_TX_COS } from '../constants';
export interface StorageModel {
id?: number;
title: string;
type: string;
name: string;
baseUrl: string;
options: { [key: string]: string };
deleteFileOnDestroy?: boolean;
options: Record<string, any>;
rules?: Record<string, any>;
path?: string;
default?: boolean;
paranoid?: boolean;
}
export interface AttachmentModel {
@ -42,14 +38,8 @@ export interface IStorage {
delete(storage: StorageModel, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]>;
}
const storageTypes = new Registry<IStorage>();
storageTypes.register(STORAGE_TYPE_LOCAL, local);
storageTypes.register(STORAGE_TYPE_ALI_OSS, oss);
storageTypes.register(STORAGE_TYPE_S3, s3);
storageTypes.register(STORAGE_TYPE_TX_COS, cos);
export function getStorageConfig(key: string): IStorage {
return storageTypes.get(key);
export abstract class StorageType implements IStorage {
abstract make(storage: StorageModel): StorageEngine;
abstract defaults(): StorageModel;
abstract delete(storage: StorageModel, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]>;
}
export default storageTypes;

View File

@ -11,8 +11,8 @@ import fs from 'fs/promises';
import mkdirp from 'mkdirp';
import multer from 'multer';
import path from 'path';
import { AttachmentModel } from '.';
import { STORAGE_TYPE_LOCAL } from '../constants';
import { AttachmentModel, StorageType } from '.';
import { FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from '../../constants';
import { getFilename } from '../utils';
function getDocumentRoot(storage): string {
@ -21,7 +21,7 @@ function getDocumentRoot(storage): string {
return path.resolve(path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot));
}
export default {
export default class extends StorageType {
make(storage) {
return multer.diskStorage({
destination: function (req, file, cb) {
@ -30,7 +30,7 @@ export default {
},
filename: getFilename,
});
},
}
defaults() {
return {
title: 'Local storage',
@ -40,8 +40,11 @@ export default {
options: {
documentRoot: 'storage/uploads',
},
};
rules: {
size: FILE_SIZE_LIMIT_DEFAULT,
},
};
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const documentRoot = getDocumentRoot(storage);
let count = 0;
@ -66,5 +69,5 @@ export default {
);
return [count, undeleted];
},
};
}
}

View File

@ -7,12 +7,12 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { AttachmentModel } from '.';
import { STORAGE_TYPE_S3 } from '../constants';
import { AttachmentModel, StorageType } from '.';
import { STORAGE_TYPE_S3 } from '../../constants';
import { cloudFilenameGetter } from '../utils';
export default {
filenameKey: 'key',
export default class extends StorageType {
filenameKey = 'key';
make(storage) {
const { S3Client } = require('@aws-sdk/client-s3');
const multerS3 = require('multer-s3');
@ -39,7 +39,7 @@ export default {
},
key: cloudFilenameGetter(storage),
});
},
}
defaults() {
return {
title: 'AWS S3',
@ -53,7 +53,7 @@ export default {
bucket: process.env.AWS_S3_BUCKET,
},
};
},
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const { s3 } = this.make(storage);
@ -70,5 +70,5 @@ export default {
Deleted.length,
records.filter((record) => !Deleted.find((item) => item.Key === `${record.path}/${record.filename}`)),
];
},
};
}
}

View File

@ -9,19 +9,19 @@
import { promisify } from 'util';
import { AttachmentModel } from '.';
import { STORAGE_TYPE_TX_COS } from '../constants';
import { AttachmentModel, StorageType } from '.';
import { STORAGE_TYPE_TX_COS } from '../../constants';
import { cloudFilenameGetter } from '../utils';
export default {
filenameKey: 'url',
export default class extends StorageType {
filenameKey = 'url';
make(storage) {
const createTxCosStorage = require('multer-cos');
return new createTxCosStorage({
cos: storage.options,
filename: cloudFilenameGetter(storage),
});
},
}
defaults() {
return {
title: '腾讯云对象存储',
@ -35,7 +35,7 @@ export default {
Bucket: process.env.TX_COS_BUCKET,
},
};
},
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { cos } = this.make(storage);
const { Deleted } = await promisify(cos.deleteMultipleObject)({
@ -47,5 +47,5 @@ export default {
Deleted.length,
records.filter((record) => !Deleted.find((item) => item.Key === `${record.path}/${record.filename}`)),
];
},
};
}
}

View File

@ -21,6 +21,6 @@ export const cloudFilenameGetter = (storage) => (req, file, cb) => {
if (err) {
return cb(err);
}
cb(null, `${storage.path ? `${storage.path}/` : ''}${filename}`);
cb(null, `${storage.path ? `${storage.path.replace(/\/+$/, '')}/` : ''}${filename}`);
});
};

View File

@ -13486,6 +13486,11 @@ file-uri-to-path@1.0.0:
resolved "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
filesize@^10.1.1:
version "10.1.1"
resolved "https://registry.npmmirror.com/filesize/-/filesize-10.1.1.tgz#eb98ce885aa73741199748e70e5b7339cc22c5ff"
integrity sha512-L0cdwZrKlwZQkMSFnCflJ6J2Y+5egO/p3vgRSDQGxQt++QbUZe5gMbRO6kg6gzwQDPvq2Fk9AmoxUNfZ5gdqaQ==
filesize@^3.6.1:
version "3.6.1"
resolved "https://registry.npmmirror.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317"