import React from 'react'; import {FormItem, FormControlProps, FormBaseControl} from './Item'; import cx from 'classnames'; import qs from 'qs'; import find from 'lodash/find'; import isPlainObject from 'lodash/isPlainObject'; // @ts-ignore import mapLimit from 'async/mapLimit'; import ImageControl from './InputImage'; import {Payload, ApiObject, ApiString} from '../../types'; import {filter} from '../../utils/tpl'; import Alert from '../../components/Alert2'; import {qsstringify, createObject, guid, isEmpty} from '../../utils/helper'; import {buildApi, normalizeApi} from '../../utils/api'; import Button from '../../components/Button'; import {Icon} from '../../components/icons'; import DropZone from 'react-dropzone'; import {FileRejection} from 'react-dropzone'; import {dataMapping} from '../../utils/tpl-builtin'; import { SchemaApi, SchemaClassName, SchemaTokenizeableString } from '../../Schema'; /** * File 文件上传控件 * 文档:https://baidu.gitee.io/amis/docs/components/form/file */ export interface FileControlSchema extends FormBaseControl { /** * 指定为文件上传 */ type: 'input-file'; /** * 上传文件按钮说明 * @default 请选择文件 */ btnLabel?: string; /** * 默认只支持纯文本,要支持其他类型,请配置此属性。建议直接填写文件后缀 * 如:.txt,.csv * * 多个类型用逗号隔开。 * * @default text/plain */ accept?: string; /** * 如果上传的文件比较小可以设置此选项来简单的把文件 base64 的值给 form 一起提交,目前不支持多选。 */ asBase64?: boolean; /** * 如果不希望 File 组件上传,可以配置 `asBlob` 或者 `asBase64`,采用这种方式后,组件不再自己上传了,而是直接把文件数据作为表单项的值,文件内容会在 Form 表单提交的接口里面一起带上。 */ asBlob?: boolean; /** * 是否自动开始上传 */ autoUpload?: boolean; /** * 默认 `/api/upload/chunk` 想自己存储时才需要关注。 */ chunkApi?: SchemaApi; /** * 分块大小,默认为 5M. * * @default 5242880 */ chunkSize?: number; /** * 分割符 */ delimiter?: string; /** * 默认显示文件路径的时候会支持直接下载, * 可以支持加前缀如:`http://xx.dom/filename=` , * 如果不希望这样,可以把当前配置项设置为 `false`。 * * 1.1.6 版本开始将支持变量 ${xxx} 来自己拼凑个下载地址,并且支持配置成 post. */ downloadUrl?: SchemaApi; /** * 默认 `file`, 如果你不想自己存储,则可以忽略此属性。 * @default file */ fileField?: string; /** * 默认 `/api/upload/finishChunkApi` 想自己存储时才需要关注。 * * @default /api/upload/finishChunkApi */ finishChunkApi?: SchemaApi; /** * 是否隐藏上传按钮 */ hideUploadButton?: boolean; /** * 最多的个数 */ maxLength?: number; /** * 默认没有限制,当设置后,文件大小大于此值将不允许上传。 */ maxSize?: number; /** * 默认 `/api/upload/file` 如果想自己存储,请设置此选项。 * * @default /api/upload/file */ receiver?: SchemaApi; /** * 默认 `/api/upload/startChunk` 想自己存储时才需要关注。 * * @default /api/upload/startChunk */ startChunkApi?: string; /** * 默认为 'auto' amis 所在服务器,限制了文件上传大小不得超出10M,所以 amis 在用户选择大文件的时候,自动会改成分块上传模式。 */ useChunk?: 'auto' | boolean; /** * 按钮 CSS 类名 */ btnClassName?: SchemaClassName; /** * 上传按钮 CSS 类名 */ btnUploadClassName?: SchemaClassName; /** * 是否为多选 */ multiple?: boolean; /** * 1. 单选模式:当用户选中某个选项时,选项中的 value 将被作为该表单项的值提交, * 否则,整个选项对象都会作为该表单项的值提交。 * 2. 多选模式:选中的多个选项的 `value` 会通过 `delimiter` 连接起来, * 否则直接将以数组的形式提交值。 */ joinValues?: boolean; /** * 开启后将选中的选项 value 的值封装为数组,作为当前表单项的值。 */ extractValue?: boolean; /** * 清除时设置的值 */ resetValue?: any; /** * 上传后把其他字段同步到表单内部。 */ autoFill?: { [propName: string]: SchemaTokenizeableString; }; /** * 接口返回的数据中,哪个用来当做值 */ valueField?: string; /** * 接口返回的数据中,哪个用来展示文件名 */ nameField?: string; /** * 接口返回的数据中哪个用来作为下载地址。 */ urlField?: string; /** * 按钮状态文案配置。 */ stateTextMap?: { init: string; pending: string; uploading: string; error: string; uploaded: string; ready: string; }; } export interface FileProps extends FormControlProps, Omit< FileControlSchema, 'type' | 'className' | 'descriptionClassName' | 'inputClassName' > { stateTextMap: { init: string; pending: string; uploading: string; error: string; uploaded: string; ready: string; }; } export interface FileX extends File { state?: | 'init' | 'error' | 'pending' | 'uploading' | 'uploaded' | 'invalid' | 'ready'; progress?: number; id?: any; } export interface FileValue { filename?: string; value?: any; name?: string; url?: string; state: | 'init' | 'error' | 'pending' | 'uploading' | 'uploaded' | 'invalid' | 'ready'; id?: any; [propName: string]: any; } export interface FileState { uploading: boolean; files: Array; error?: string | null; } let preventEvent = (e: any) => e.stopPropagation(); export function getNameFromUrl(url: string) { if (/(?:\/|^)([^\/]+?)$/.test(url)) { return decodeURIComponent(RegExp.$1); } return url; } export default class FileControl extends React.Component { static defaultProps: Partial = { maxSize: 0, maxLength: 0, placeholder: '', receiver: '/api/upload/file', fileField: 'file', joinValues: true, extractValue: false, delimiter: ',', downloadUrl: '', // '/api/file/' useChunk: 'auto', chunkSize: 5 * 1024 * 1024, // 文件大于5M, 自动分块上传 startChunkApi: '/api/upload/startChunk', chunkApi: '/api/upload/chunk', finishChunkApi: '/api/upload/finishChunk', accept: '', multiple: false, autoUpload: true, hideUploadButton: false, stateTextMap: { init: '', pending: '等待上传', uploading: '上传中', error: '上传出错', uploaded: '已上传', ready: '' }, asBase64: false }; state: FileState; current: FileValue | FileX | null; resolve?: (value?: any) => void; emitValue: any; static valueToFile( value: string | FileValue, props: FileProps, files?: Array ): FileValue | undefined { let file: FileValue | FileX | undefined = files && typeof value === 'string' ? find(files, item => (item as FileValue).value === value) : undefined; const valueField = props.valueField || 'value'; const urlField = props.urlField || 'url'; const nameField = props.nameField || 'name'; return value ? value instanceof File ? { state: 'ready', [valueField]: value, [urlField]: value, [nameField]: value.name, id: guid() } : { ...(typeof value === 'string' ? { state: file && file.state ? file.state : 'init', [valueField]: value, [urlField]: value, [nameField]: (file && file.name) || (/^data:/.test(value) ? 'base64数据' : getNameFromUrl(value)), id: guid() } : (value as FileValue)) } : undefined; } dropzone = React.createRef(); constructor(props: FileProps) { super(props); const value: string | Array | FileValue = props.value; const valueField = props.valueField || 'value'; const joinValues = props.joinValues; const delimiter = props.delimiter as string; let files: Array = []; if (value && value instanceof Blob) { files = [value as any]; } else if (value) { files = (Array.isArray(value) ? value : joinValues ? `${(value as any)[valueField] || value}`.split(delimiter) : [value as any] ) .map(item => FileControl.valueToFile(item, props) as FileValue) .filter(item => item); } this.state = { files, uploading: false }; this.sendFile = this.sendFile.bind(this); this.removeFile = this.removeFile.bind(this); this.clearError = this.clearError.bind(this); this.handleDrop = this.handleDrop.bind(this); this.handleDropRejected = this.handleDropRejected.bind(this); this.startUpload = this.startUpload.bind(this); this.stopUpload = this.stopUpload.bind(this); this.retry = this.retry.bind(this); this.toggleUpload = this.toggleUpload.bind(this); this.tick = this.tick.bind(this); this.onChange = this.onChange.bind(this); this.uploadFile = this.uploadFile.bind(this); this.uploadBigFile = this.uploadBigFile.bind(this); this.handleSelect = this.handleSelect.bind(this); this.syncAutoFill = this.syncAutoFill.bind(this); } componentDidMount() { this.syncAutoFill(); } componentDidUpdate(prevProps: FileProps) { const props = this.props; if (prevProps.value !== props.value && this.emitValue !== props.value) { const value: string | Array | FileValue = props.value; const joinValues = props.joinValues; const delimiter = props.delimiter as string; let files: Array = []; if (value) { files = (Array.isArray(value) ? value : joinValues && typeof value === 'string' ? value.split(delimiter) : [value as any] ) .map(item => { let obj = FileControl.valueToFile( item, props, this.state.files ) as FileValue; let org; if ( obj && ((org = find( this.state.files, (item: FileValue) => item.value === obj.value )) as FileValue) ) { obj = { ...org, ...obj, id: obj.id || org!.id }; } return obj; }) .filter(item => item); } this.setState( { files: files }, this.syncAutoFill ); } } handleDrop(files: Array) { if (!files.length) { return; } const {maxSize, multiple, maxLength, translate: __} = this.props; const nameField = this.props.nameField || 'name'; let allowed = multiple && maxLength ? maxLength - this.state.files.length : files.length; const inputFiles: Array = []; [].slice.call(files, 0, allowed).forEach((file: FileX) => { if (maxSize && file.size > maxSize) { this.props.env.alert( __('File.maxSize', { filename: file[nameField as keyof typeof file] || file.name, actualSize: ImageControl.formatFileSize(file.size), maxSize: ImageControl.formatFileSize(maxSize) }) ); file.state = 'invalid'; } else { file.state = 'pending'; } file.id = guid(); inputFiles.push(file); }); if (!inputFiles.length) { return; } this.setState( { error: null, files: multiple ? this.state.files.concat(inputFiles) : inputFiles }, () => { const {autoUpload} = this.props; if (autoUpload) { this.startUpload(); } } ); } handleDropRejected( rejectedFiles: FileRejection[], evt: React.DragEvent ) { if (evt.type !== 'change' && evt.type !== 'drop') { return; } const {multiple, env, accept, translate: __} = this.props; const nameField = this.props.nameField || 'name'; const files = rejectedFiles.map(fileRejection => ({ ...fileRejection.file, state: 'invalid', id: guid(), [nameField]: fileRejection.file.name })); // this.setState({ // files: multiple // ? this.state.files.concat(files) // : this.state.files.length // ? this.state.files // : files.slice(0, 1) // }); env.alert( __('File.invalidType', { files: files.map((item: any) => `「${item[nameField]}」`).join(' '), accept }) ); } handleClickFile(file: FileX | FileValue, e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); const {data, env, downloadUrl} = this.props; const urlField = this.props.urlField || 'url'; const valueField = this.props.valueField || 'value'; const fileUrl = file[urlField as keyof typeof file] || file[valueField as keyof typeof file]; let api = typeof downloadUrl === 'string' && !~downloadUrl.indexOf('$') ? `${downloadUrl}${fileUrl}` : downloadUrl ? downloadUrl : `${fileUrl}`; if (api) { const ctx = createObject(data, { ...file }); const apiObject = normalizeApi(api); if (apiObject.method?.toLowerCase() === 'get' && !apiObject.data) { window.open(buildApi(apiObject, ctx).url); } else { apiObject.responseType = apiObject.responseType ?? 'blob'; env.fetcher(apiObject, ctx, { responseType: 'blob' }); } } } handleSelect() { this.dropzone.current && this.dropzone.current.open(); } startUpload(retry: boolean = false) { if (this.state.uploading) { return; } this.setState( { uploading: true, files: this.state.files.map(file => { if (retry && file.state === 'error') { file.state = 'pending'; file.progress = 0; } return file; }) }, this.tick ); } toggleUpload(e: React.MouseEvent) { e.preventDefault(); return this.state.uploading ? this.stopUpload() : this.startUpload(); } stopUpload() { if (!this.state.uploading) { return; } this.setState({ uploading: false }); } retry() { this.startUpload(true); } tick() { if (this.current || !this.state.uploading) { return; } const {translate: __} = this.props; const nameField = this.props.nameField || 'name'; const file = find( this.state.files, item => item.state === 'pending' ) as FileX; if (file) { this.current = file; file.state = 'uploading'; this.setState( { files: this.state.files.concat() }, () => this.sendFile( file, (error, file, obj) => { const files = this.state.files.concat(); const idx = files.indexOf(file as FileX); if (!~idx) { return; } let newFile: FileValue = file as FileValue; if (error) { newFile.state = 'error'; newFile.error = error; } else { newFile = obj as FileValue; newFile[nameField] = newFile[nameField] || file!.name; } files.splice(idx, 1, newFile); this.current = null; this.setState( { error: error ? error : null, files: files }, this.tick ); }, progress => { const files = this.state.files.concat(); const idx = files.indexOf(file); if (!~idx) { return; } // file 是个非 File 对象,先不copy了直接改。 file.progress = progress; this.setState({ files }); } ) ); } else { this.setState( { uploading: false }, () => { this.onChange(!!this.resolve); if (this.resolve) { this.resolve( this.state.files.some(file => file.state === 'error') ? __('File.errorRetry') : null ); this.resolve = undefined; } } ); } } sendFile( file: FileX, cb: (error: null | string, file?: FileX, obj?: FileValue) => void, onProgress: (progress: number) => void ) { const { receiver, fileField, useChunk, chunkSize, startChunkApi, chunkApi, finishChunkApi, asBase64, asBlob, data, translate: __ } = this.props; const nameField = this.props.nameField || 'name'; const valueField = this.props.valueField || 'value'; if (asBase64) { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = () => { file.state = 'ready'; cb(null, file, { [valueField]: reader.result as string, [nameField]: file.name, state: 'ready', id: file.id }); }; reader.onerror = (error: any) => cb(error.message); return; } else if (asBlob) { file.state = 'ready'; setTimeout( () => cb(null, file, { [nameField]: file.name, [valueField]: file, state: 'ready', id: file.id }), 4 ); return; } let fn = (useChunk === 'auto' && chunkSize && file.size > chunkSize) || useChunk === true ? this.uploadBigFile : this.uploadFile; fn( file, receiver as string, {}, { fieldName: fileField, chunkSize, startChunkApi, chunkApi, finishChunkApi, data }, onProgress ) .then(ret => { if (ret.status || !ret.data) { throw new Error(ret.msg || __('File.errorRetry')); } onProgress(1); let value = (ret.data as any).value || (ret.data as any).url || ret.data; cb(null, file, { ...(isPlainObject(ret.data) ? ret.data : null), value: value, state: 'uploaded', id: file.id }); }) .catch(error => { cb(error.message || __('File.errorRetry'), file); }); } removeFile(file: FileX | FileValue, index: number) { const files = this.state.files.concat(); files.splice(index, 1); this.setState( { files: files }, this.onChange ); } clearError() { this.setState({ error: null }); } onChange(changeImmediately?: boolean) { const { multiple, onChange, joinValues, extractValue, valueField, delimiter, resetValue, asBlob, autoFill, onBulkChange } = this.props; const files = this.state.files.filter( file => ~['uploaded', 'init', 'ready'].indexOf(file.state as string) ); let value: any = multiple ? files : files[0]; if (value) { if (extractValue || asBlob) { value = Array.isArray(value) ? value.map((item: any) => item[valueField || 'value']) : value[valueField || 'value']; } else if (joinValues) { value = Array.isArray(value) ? value .map((item: any) => item[valueField || 'value']) .join(delimiter || ',') : value[valueField || 'value']; } } else { value = typeof resetValue === 'undefined' ? '' : resetValue; } onChange((this.emitValue = value), undefined, changeImmediately); this.syncAutoFill(); } syncAutoFill() { const {autoFill, multiple, onBulkChange} = this.props; if (!isEmpty(autoFill)) { const files = this.state.files.filter( file => ~['uploaded', 'init', 'ready'].indexOf(file.state as string) ); const toSync = dataMapping( autoFill, multiple ? { items: files } : files[0] ); onBulkChange(toSync); } } uploadFile( file: FileX, receiver: string, params: object, config: Partial = {}, onProgress: (progress: number) => void ): Promise { const fd = new FormData(); const api = buildApi(receiver, createObject(config.data, params), { method: 'post' }); qsstringify({...api.data, ...params}) .split('&') .forEach(item => { const parts = item.split('='); fd.append(parts[0], decodeURIComponent(parts[1])); }); // Note: File类型字段放在后面,可以支持第三方云存储鉴权 fd.append(config.fieldName || 'file', file); return this._send(api, fd, {}, onProgress); } uploadBigFile( file: FileX, receiver: string, params: object, config: Partial = {}, onProgress: (progress: number) => void ): Promise { const chunkSize = config.chunkSize || 5 * 1024 * 1024; const self = this; let startProgress = 0.2; let endProgress = 0.9; let progressArr: Array; const __ = this.props.translate; const nameField = this.props.nameField || 'name'; interface ObjectState { key: string; uploadId: string; loaded: number; total: number; [propName: string]: any; } interface Task { file: File; partNumber: number; partSize: number; start: number; stop: number; [propName: string]: any; } return new Promise((resolve, reject) => { let state: ObjectState; const startApi = buildApi( config.startChunkApi!, createObject(config.data, { ...params, filename: file.name, [nameField]: file.name }), { method: 'post', autoAppend: true } ); self._send(startApi).then(startChunk).catch(reject); function startChunk(ret: Payload) { onProgress(startProgress); const tasks = getTasks(file); progressArr = tasks.map(() => 0); if (!ret.data) { throw new Error(__('File.uploadFailed')); } state = { key: (ret.data as any).key, uploadId: (ret.data as any).uploadId, loaded: 0, total: tasks.length }; mapLimit( tasks, 3, uploadPartFile(state, config), function (err: any, results: any) { if (err) { reject(err); } else { finishChunk(results, state); } } ); } function updateProgress(partNumber: number, progress: number) { progressArr[partNumber - 1] = progress; onProgress( startProgress + (endProgress - startProgress) * (progressArr.reduce((count, progress) => count + progress, 0) / progressArr.length) ); } function finishChunk( partList: Array | undefined, state: ObjectState ) { onProgress(endProgress); const endApi = buildApi( config.finishChunkApi!, createObject(config.data, { ...params, uploadId: state.uploadId, key: state.key, [nameField]: file.name, filename: file.name, partList }), { method: 'post', autoAppend: true } ); self._send(endApi).then(resolve).catch(reject); } function uploadPartFile(state: ObjectState, conf: Partial) { return (task: Task, callback: (error: any, value?: any) => void) => { const api = buildApi( conf.chunkApi!, createObject(config.data, params), { method: 'post' } ); const fd = new FormData(); let blob = task.file.slice(task.start, task.stop + 1); qsstringify({...api.data, ...params}) .split('&') .forEach(item => { const parts = item.split('='); fd.append(parts[0], decodeURIComponent(parts[1])); }); fd.append('key', state.key); fd.append('uploadId', state.uploadId); fd.append('partNumber', task.partNumber.toString()); fd.append('partSize', task.partSize.toString()); // Note: File类型字段放在后面,可以支持第三方云存储鉴权 fd.append(config.fieldName || 'file', blob, file.name); return self ._send(api, fd, {}, progress => updateProgress(task.partNumber, progress) ) .then(ret => { state.loaded++; callback(null, { partNumber: task.partNumber, eTag: (ret.data as any).eTag }); }) .catch(callback); }; } function getTasks(file: FileX): Array { let leftSize = file.size; let offset = 0; let partNumber = 1; let tasks: Array = []; while (leftSize > 0) { let partSize = Math.min(leftSize, chunkSize); tasks.push({ file: file, partNumber: partNumber, partSize: partSize, start: offset, stop: offset + partSize - 1 }); leftSize -= partSize; offset += partSize; partNumber += 1; } return tasks; } }); } _send( api: ApiObject | ApiString, data?: any, options?: object, onProgress?: (progress: number) => void ): Promise { const env = this.props.env; if (!env || !env.fetcher) { throw new Error('fetcher is required'); } return env.fetcher(api, data, { method: 'post', ...options, withCredentials: true, onUploadProgress: onProgress ? (event: {loaded: number; total: number}) => onProgress(event.loaded / event.total) : undefined }); } validate(): any { const __ = this.props.translate; if ( this.state.uploading || this.state.files.some(item => item.state === 'pending') ) { return new Promise(resolve => { this.resolve = resolve; this.startUpload(); }); } else if (this.state.files.some(item => item.state === 'error')) { return __('File.errorRetry'); } } render() { const { btnLabel, accept, disabled, maxLength, multiple, autoUpload, description, hideUploadButton, className, btnClassName, btnUploadClassName, classnames: cx, translate: __, render, downloadUrl } = this.props; let {files, uploading, error} = this.state; const nameField = this.props.nameField || 'name'; const valueField = this.props.valueField || 'value'; const urlField = this.props.urlField || 'url'; const hasPending = files.some(file => file.state == 'pending'); let uploaded = 0; let failed = 0; this.state.uploading || this.state.files.forEach(item => { if (item.state === 'error') { failed++; } else if (item.state === 'uploaded') { uploaded++; } }); return (
{({getRootProps, getInputProps, isDragActive}) => (
{isDragActive ? (
{__('File.dragDrop')}
) : ( <> {(multiple && (!maxLength || files.length < maxLength)) || !multiple ? ( ) : null} {description ? render('desc', description!, { className: cx('FileControl-description') }) : null} {Array.isArray(files) ? (
    {files.map((file, index) => (
  • {(file as FileValue)[urlField] || (file as FileValue)[valueField] || downloadUrl ? ( {file[nameField as keyof typeof file] || (file as FileValue).filename} ) : ( {file[nameField as keyof typeof file] || (file as FileValue).filename} )} {file.state === 'invalid' || file.state === 'error' ? ( <> {(file as FileValue).error || null} ) : null} {file.state !== 'uploading' && !disabled ? ( this.removeFile(file, index)} > ) : null}
    {file.state === 'uploading' || file.state === 'uploaded' ? (
    {file.state === 'uploaded' ? ( ) : ( {Math.round((file.progress || 0) * 100)}% )}
    ) : null}
  • ))}
) : null} )}
)}
{failed ? (
{__('File.result', { uploaded, failed })} {__('File.retry')} {__('File.failed')}
) : null} {!autoUpload && !hideUploadButton && files.length ? ( ) : null}
); } } @FormItem({ type: 'input-file', sizeMutable: false, renderDescription: false }) export class FileControlRenderer extends FileControl {}