mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 04:05:45 +00:00
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:
parent
f5079af61e
commit
1dc7a39780
1
.github/workflows/deploy-client-docs.yml
vendored
1
.github/workflows/deploy-client-docs.yml
vendored
@ -16,6 +16,7 @@ on:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'packages/core/client/**'
|
||||
- 'packages/core/client/docs/**'
|
||||
- '.github/workflows/deploy-client-docs.yml'
|
||||
|
||||
|
@ -398,10 +398,6 @@ export default defineConfig({
|
||||
"title": "Pagination",
|
||||
"link": "/components/pagination"
|
||||
},
|
||||
{
|
||||
"title": "Preview",
|
||||
"link": "/components/preview"
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
@ -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",
|
||||
|
@ -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": "选择一条已有的数据作为表单的初始化数据",
|
||||
|
@ -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 };
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
@ -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>
|
@ -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) {
|
||||
|
@ -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>
|
||||
));
|
||||
};
|
@ -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';
|
||||
|
||||
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(
|
||||
(props: UploadProps) => {
|
||||
return <AntdUpload {...useUploadProps(props)} />;
|
||||
},
|
||||
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([]);
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPreview?.(file);
|
||||
},
|
||||
[file, onPreview],
|
||||
);
|
||||
const onDelete = useCallback(() => {
|
||||
propsOnDelete?.(file);
|
||||
}, [file, propsOnDelete]);
|
||||
const onDownload = useCallback(() => {
|
||||
saveAs(file.url, `${file.title}${file.extname}`);
|
||||
}, [file]);
|
||||
|
||||
function closeIFrameModal() {
|
||||
setVisible(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (sync) {
|
||||
const fileList = toFileList(value);
|
||||
setFileList(fileList);
|
||||
internalFileList.current = fileList;
|
||||
}
|
||||
}, [value, sync]);
|
||||
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>
|
||||
);
|
||||
|
||||
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) => {
|
||||
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 {
|
||||
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={() => {
|
||||
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];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</span>
|
||||
{file.status === 'uploading' && (
|
||||
<div className={`${prefixCls}-list-item-progress`}>
|
||||
<Progress strokeWidth={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>
|
||||
const content = (
|
||||
<div
|
||||
className={cls(
|
||||
`${prefixCls}-list-item`,
|
||||
`${prefixCls}-list-item-${file.status ?? 'done'}`,
|
||||
`${prefixCls}-list-item-list-type-picture-card`,
|
||||
)}
|
||||
>
|
||||
<div className={`${prefixCls}-list-item-info`}>{wrappedItem}</div>
|
||||
<span className={`${prefixCls}-list-item-actions`}>
|
||||
<Space size={3}>
|
||||
{!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 size={2} type={'line'} showInfo={false} percent={file.percent} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 预览图片的弹框 */}
|
||||
{visible && fileType === 'image' && (
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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,37 +477,47 @@ 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');
|
||||
|
||||
// 新版 UISchema(1.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 { onChange } = extraProps;
|
||||
onChange?.(fileList);
|
||||
const beforeUpload = useBeforeUpload(rules);
|
||||
const { size, mimetype: accept } = rules ?? {};
|
||||
const sizeHint = useSizeHint(size);
|
||||
const handleChange = useCallback(
|
||||
({ fileList }) => {
|
||||
const { onChange } = extraProps;
|
||||
onChange?.(fileList);
|
||||
|
||||
if (fileList.some((file) => file.status === 'uploading')) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (fileList.some((file) => file.status === 'uploading')) {
|
||||
setLoading(true);
|
||||
} 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;
|
||||
}
|
||||
|
@ -244,22 +244,24 @@ describe('Upload', () => {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'string',
|
||||
default: {
|
||||
id: 1,
|
||||
title: '微信图片_20240131154451',
|
||||
filename: '234ead512e44bf944689069ce2b41a95.png',
|
||||
extname: '.png',
|
||||
path: '',
|
||||
size: 841380,
|
||||
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
|
||||
mimetype: 'image/png',
|
||||
meta: {},
|
||||
storageId: 1,
|
||||
updatedAt: '2024-04-21T01:26:02.961Z',
|
||||
createdAt: '2024-04-21T01:26:02.961Z',
|
||||
createdById: 1,
|
||||
updatedById: 1,
|
||||
},
|
||||
default: [
|
||||
{
|
||||
id: 1,
|
||||
title: '微信图片_20240131154451',
|
||||
filename: '234ead512e44bf944689069ce2b41a95.png',
|
||||
extname: '.png',
|
||||
path: '',
|
||||
size: 841380,
|
||||
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
|
||||
mimetype: 'image/png',
|
||||
meta: {},
|
||||
storageId: 1,
|
||||
updatedAt: '2024-04-21T01:26:02.961Z',
|
||||
createdAt: '2024-04-21T01:26:02.961Z',
|
||||
createdById: 1,
|
||||
updatedById: 1,
|
||||
},
|
||||
],
|
||||
'x-component': 'CollectionField',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-read-pretty': false,
|
||||
|
@ -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;
|
||||
|
@ -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}>
|
||||
|
@ -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}>
|
||||
|
@ -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,41 +29,42 @@ 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': {
|
||||
data: {
|
||||
id: 1,
|
||||
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,
|
||||
'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();
|
||||
|
||||
|
||||
|
@ -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,41 +30,42 @@ 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': {
|
||||
data: {
|
||||
id: 1,
|
||||
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,
|
||||
'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();
|
||||
|
||||
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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';
|
||||
|
@ -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 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],
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -105,6 +105,7 @@ const schema: ISchema = {
|
||||
multiple: false,
|
||||
// accept: 'jpg,png'
|
||||
},
|
||||
'x-use-component-props': 'useCollectionFieldStorageRules',
|
||||
},
|
||||
enabledLanguages: {
|
||||
type: 'array',
|
||||
|
@ -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) {
|
||||
|
@ -131,7 +131,7 @@ test.describe('form item & view form', () => {
|
||||
try {
|
||||
await testSmall(26);
|
||||
} catch (err) {
|
||||
await testSmall(24);
|
||||
await testSmall(28);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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();
|
||||
|
@ -8,3 +8,4 @@
|
||||
*/
|
||||
|
||||
export * from './useUploadFiles';
|
||||
export * from './useStorageRules';
|
||||
|
@ -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'));
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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) {
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
@ -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}" })}}`,
|
||||
},
|
||||
};
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
@ -100,7 +100,7 @@ export class FileCollectionTemplate extends CollectionTemplate {
|
||||
},
|
||||
// 文件的可访问地址
|
||||
{
|
||||
interface: 'input',
|
||||
interface: 'url',
|
||||
type: 'string',
|
||||
name: 'url',
|
||||
deletable: false,
|
||||
|
@ -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';
|
@ -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\"。"
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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) => {
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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]);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
}
|
@ -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';
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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}`)),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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];
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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}`)),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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}`)),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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}`);
|
||||
});
|
||||
};
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user