diff --git a/package.json b/package.json index a2dbde599..326507e6a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "license": "ISC", "dependencies": { "async": "2.6.0", + "attr-accept": "1.1.3", "autobind-decorator": "2.4.0", "blueimp-canvastoblob": "2.1.0", "classnames": "2.2.5", @@ -53,26 +54,26 @@ "rc-input-number": "4.4.5", "react": "^16.8.6", "react-addons-update": "15.6.2", - "react-overlays": "0.8.3", "react-color": "2.13.8", "react-cropper": "1.0.0", "react-date-range": "0.9.4", "react-datetime": "2.16.0", "react-dom": "^16.8.6", - "react-dropzone": "4.2.1", + "react-dropzone": "10.1.10", "react-input-range": "1.2.1", "react-json-tree": "0.11.0", + "react-overlays": "0.8.3", "react-progress-2": "^4.4.2", "react-select": "1.2.1", "react-textarea-autosize": "5.1.0", "react-transition-group": "2.2.1", "react-visibility-sensor": "3.11.0", + "redux": "^3.7.2", "setimmediate": "1.0.5", "sortablejs": "1.10.0", "tslib": "^1.10.0", "uncontrollable": "4.1.0", - "video-react": "0.9.4", - "redux": "^3.7.2" + "video-react": "0.9.4" }, "devDependencies": { "@types/async": "^2.0.45", diff --git a/scss/components/form/_file.scss b/scss/components/form/_file.scss new file mode 100644 index 000000000..da817f3a1 --- /dev/null +++ b/scss/components/form/_file.scss @@ -0,0 +1,155 @@ +.#{$ns}FileControl { + &-dropzone { + outline: none; + + } + + &-selectBtn { + width: px2rem(120px); + + + >svg { + margin-right: 10px; + width: pxrem(16px); + height: pxrem(16px); + } + } + + // &-dropzone:focus { + // .#{$ns}FileControl-selectBtn { + // background: $Button--default-onHover-bg; + // border-color: $Button--default-onHover-border; + // color: $Button--default-onHover-color; + // } + + // &:after { + // content: '当前状态接受从剪切板中粘贴文件。'; + // color: $text--muted-color; + // font-size: 11px; + // margin-top: 10px; + // } + // } + + &-description { + margin-left: 10px; + color: #999; + font-size: 12px; + } + + &-list { + list-style: none; + margin: 10px 0; + padding: 0; + width: 250px; + + >li { + color: #333; + font-size: 12px; + + &:hover { + color: #108cee; + background: #f3f3f3; + } + + + } + } + + + + &-itemInfo { + padding: 0px 6px; + line-height: 26px; + height: 26px; + + &.is-invalid { + color: #999; + } + + >svg:first-child { + margin-right: 10px; + } + + >svg:not(:first-child) { + margin-left: 10px; + width: px2rem(16px); + height: px2rem(16px); + top: px2rem(5px); + } + } + + &-itemInfoText { + white-space: nowrap; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + line-height: 1; + } + + &-clear { + float: right; + color: #999; + display: none; + cursor: pointer; + + &:hover { + color: #333; + } + } + + &-list>li:hover &-clear { + display: block; + } + + &-progressInfo { + display: inline-flex; + height: 20px; + padding: 0 6px; + transform: translateY(-3px); + width: 100%; + align-items: center; + white-space: nowrap; + text-overflow: ellipsis; + + >span { + display: inline-block; + padding: 0 4px 0 10px; + font-size: 12px; + } + + >svg { + display: inline-block; + margin: 0 4px 0 10px; + width: 14px; + height: 14px; + top: 0; + } + } + + + + &-progress { + height: 5px; + flex: 1; + background: #ebebeb; + + >span { + display: block; + background: $info; + height: 100%; + min-width: 10%; + transition: ease-out width 0.3s; + } + } + + &-acceptTip { + height: 120px; + color: #999; + border: 2px dashed $info; + border-radius: $borderRadius; + background: #f3f9fe; + line-height: 120px; + text-align: center; + } +} \ No newline at end of file diff --git a/scss/components/form/_image.scss b/scss/components/form/_image.scss index 905240b2d..c379fa8d1 100644 --- a/scss/components/form/_image.scss +++ b/scss/components/form/_image.scss @@ -1,5 +1,7 @@ .#{$ns}ImageControl { - outline: none; + &-dropzone { + outline: none; + } &-addBtn { margin: 0; @@ -35,7 +37,14 @@ } } - &-dropzone.is-active &-addBtn { + &-pasteTip { + display: block; + color: $text--muted-color; + font-size: 12px; + margin-top: 10px; + } + + &-dropzone:focus &-addBtn { border-color: $ImageControl-addBtn-onHover-border; background: $ImageControl-addBtn-onHover-bg; color: $ImageControl-addBtn-onHover-color; @@ -184,4 +193,46 @@ &-uploadBtn { margin-top: 5px; } + + &-cropperWrapper { + position: relative; + + img { + max-width: 100%; + max-height: 400px; + } + } + + &-croperToolbar { + display: inline-flex; + width: 50px; + position: absolute; + right: 0; + bottom: 0; + flex-direction: column; + align-items: flex-end; + + >a { + color: #fff; + padding: 2px 5px; + cursor: pointer; + font-size: 20px; + } + } + + &-acceptTip { + height: 120px; + color: #999; + border: 2px dashed $borderColor; + + // &.is-accept { + border-color: $info; + background: #f3f9fe; + // } + + border-radius: $borderRadius; + + line-height: 120px; + text-align: center; + } } \ No newline at end of file diff --git a/scss/themes/cxd.scss b/scss/themes/cxd.scss index 499125d76..6704db310 100644 --- a/scss/themes/cxd.scss +++ b/scss/themes/cxd.scss @@ -533,6 +533,7 @@ $Card-actions-onChecked-onHover-bg: $white; @import "../components/form/date"; @import "../components/form/date-range"; @import "../components/form/image"; +@import "../components/form/file"; @import "../components/form/editor"; @import "../components/form/rich-text"; @import "../components/form/range"; diff --git a/scss/themes/dark.scss b/scss/themes/dark.scss index d964b33f8..c4f2a7f28 100644 --- a/scss/themes/dark.scss +++ b/scss/themes/dark.scss @@ -215,6 +215,7 @@ pre { @import '../components/form/date'; @import '../components/form/date-range'; @import '../components/form/image'; +@import "../components/form/file"; @import '../components/form/editor'; @import '../components/form/rich-text'; @import '../components/form/range'; @@ -233,4 +234,4 @@ pre { @import '../components/form/nested-select'; @import '../components/form/icon-picker'; -@import '../utilities'; +@import '../utilities'; \ No newline at end of file diff --git a/scss/themes/default.scss b/scss/themes/default.scss index f2a4cf70e..928d4395a 100644 --- a/scss/themes/default.scss +++ b/scss/themes/default.scss @@ -80,6 +80,7 @@ $Form-input-borderColor: #cfdadd; @import "../components/form/date"; @import "../components/form/date-range"; @import "../components/form/image"; +@import "../components/form/file"; @import "../components/form/editor"; @import "../components/form/rich-text"; @import "../components/form/range"; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 9e73319ff..0dbbf65d3 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -39,6 +39,14 @@ import ViewIcon from '../icons/view.svg'; import RemoveIcon from '../icons/remove.svg'; // @ts-ignore import RetryIcon from '../icons/retry.svg'; +// @ts-ignore +import UploadIcon from '../icons/upload.svg'; +// @ts-ignore +import FileIcon from '../icons/file.svg'; +// @ts-ignore +import SuccessIcon from '../icons/success.svg'; +// @ts-ignore +import FailIcon from '../icons/fail.svg'; // 兼容原来的用法,后续不直接试用。 // @ts-ignore @@ -91,6 +99,10 @@ registerIcon('pencil', PencilIcon); registerIcon('view', ViewIcon); registerIcon('remove', RemoveIcon); registerIcon('retry', RetryIcon); +registerIcon('upload', UploadIcon); +registerIcon('file', FileIcon); +registerIcon('success', SuccessIcon); +registerIcon('fail', FailIcon); export function Icon({ icon, diff --git a/src/icons/fail.svg b/src/icons/fail.svg new file mode 100644 index 000000000..7bff4ff9d --- /dev/null +++ b/src/icons/fail.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/src/icons/file.svg b/src/icons/file.svg new file mode 100644 index 000000000..c47fabc33 --- /dev/null +++ b/src/icons/file.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/src/icons/play.svg b/src/icons/play.svg index 30e0df575..ef03d9ad5 100644 --- a/src/icons/play.svg +++ b/src/icons/play.svg @@ -1,4 +1,4 @@ - + xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 14 16" version="1.1" p-id="1463"> + \ No newline at end of file diff --git a/src/icons/retry.svg b/src/icons/retry.svg index 3971a512c..ac1604d7f 100644 --- a/src/icons/retry.svg +++ b/src/icons/retry.svg @@ -1,4 +1,8 @@ - - - \ No newline at end of file + + + + + + diff --git a/src/icons/success.svg b/src/icons/success.svg new file mode 100644 index 000000000..c4e3b0433 --- /dev/null +++ b/src/icons/success.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/src/icons/upload.svg b/src/icons/upload.svg new file mode 100644 index 000000000..1906b4a41 --- /dev/null +++ b/src/icons/upload.svg @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/renderers/Form/File.tsx b/src/renderers/Form/File.tsx index 9fcb62bb1..c9772d572 100644 --- a/src/renderers/Form/File.tsx +++ b/src/renderers/Form/File.tsx @@ -6,14 +6,16 @@ import find = require('lodash/find'); import isPlainObject = require('lodash/isPlainObject'); import {mapLimit} from 'async'; import ImageControl from './Image'; -import {Payload} from '../../types'; +import {Payload, ApiObject, ApiString} from '../../types'; import {filter} from '../../utils/tpl'; import Alert from '../../components/Alert2'; -import {qsstringify} from '../../utils/helper'; +import {qsstringify, createObject} from '../../utils/helper'; +import {buildApi} from '../../utils/api'; +import Button from '../../components/Button'; +import {Icon} from '../../components/icons'; +import DropZone from 'react-dropzone'; export interface FileProps extends FormControlProps { - btnClassName: string; - btnUploadClassName: string; maxSize: number; maxLength: number; placeholder?: string; @@ -48,6 +50,8 @@ export interface FileProps extends FormControlProps { export interface FileX extends File { state?: 'init' | 'error' | 'pending' | 'uploading' | 'uploaded' | 'invalid' | 'ready'; + progress?: number; + id?: any; } export interface FileValue { @@ -56,6 +60,7 @@ export interface FileValue { name?: string; url?: string; state: 'init' | 'error' | 'pending' | 'uploading' | 'uploaded' | 'invalid' | 'ready'; + id?: any; [propName: string]: any; } @@ -65,14 +70,19 @@ export interface FileState { error?: string | null; } +let id = 1; +function gennerateId() { + return id++; +} + +let preventEvent = (e: any) => e.stopPropagation(); + export default class FileControl extends React.Component { static defaultProps: Partial = { - btnClassName: 'btn-sm btn-info', - btnUploadClassName: 'btn-sm btn-success', maxSize: 0, maxLength: 0, placeholder: '', - btnLabel: '请选择文件', + btnLabel: '文件上传', reciever: '/api/upload/file', fileField: 'file', joinValues: true, @@ -116,7 +126,8 @@ export default class FileControl extends React.Component { state: 'ready', value: value, name: value.name, - url: '' + url: '', + id: gennerateId() } : { ...(typeof value === 'string' @@ -124,6 +135,7 @@ export default class FileControl extends React.Component { state: file && file.state ? file.state : 'init', value, name: /^data:/.test(value) ? (file && file.name) || 'base64数据' : '', + id: gennerateId(), url: typeof props.downloadUrl === 'string' && value && !/^data:/.test(value) ? `${props.downloadUrl}${value}` @@ -134,6 +146,7 @@ export default class FileControl extends React.Component { : undefined; } + dropzone = React.createRef(); constructor(props: FileProps) { super(props); @@ -163,6 +176,7 @@ export default class FileControl extends React.Component { 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.toggleUpload = this.toggleUpload.bind(this); @@ -170,6 +184,7 @@ export default class FileControl extends React.Component { this.onChange = this.onChange.bind(this); this.uploadFile = this.uploadFile.bind(this); this.uploadBigFile = this.uploadBigFile.bind(this); + this.handleSelect = this.handleSelect.bind(this); } componentWillReceiveProps(nextProps: FileProps) { @@ -199,7 +214,8 @@ export default class FileControl extends React.Component { ) { obj = { ...org, - ...obj + ...obj, + id: obj.id || org!.id }; } @@ -214,30 +230,29 @@ export default class FileControl extends React.Component { } } - handleDrop(e: React.ChangeEvent) { - const files = e.currentTarget.files; - + handleDrop(files: Array) { if (!files.length) { return; } const {maxSize, multiple, maxLength} = this.props; - const allowed = - (multiple ? (maxLength ? maxLength : files.length + this.state.files.length) : 1) - this.state.files.length; + 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) { - alert( + this.props.env.alert( `您选择的文件 ${file.name} 大小为 ${ImageControl.formatFileSize( file.size )} 超出了最大为 ${ImageControl.formatFileSize(maxSize)} 的限制,请重新选择` ); - return; + file.state = 'invalid'; + } else { + file.state = 'pending'; } - file.state = 'pending'; + file.id = gennerateId(); inputFiles.push(file); }); @@ -248,7 +263,7 @@ export default class FileControl extends React.Component { this.setState( { error: null, - files: this.state.files.concat(inputFiles) + files: multiple ? this.state.files.concat(inputFiles) : inputFiles }, () => { const {autoUpload} = this.props; @@ -260,6 +275,36 @@ export default class FileControl extends React.Component { ); } + handleDropRejected(rejectedFiles: any, evt: React.DragEvent) { + if (evt.type !== 'change' && evt.type !== 'drop') { + return; + } + const {multiple, env, accept} = this.props; + + const files = rejectedFiles.map((file: any) => ({ + ...file, + state: 'invalid', + id: gennerateId(), + name: file.name + })); + + this.setState({ + files: multiple + ? this.state.files.concat(files) + : this.state.files.length + ? this.state.files + : files.slice(0, 1) + }); + + env.alert( + `您添加的文件${files.map((item: any) => `【${item.name}】`)}不符合类型的\`${accept}\`设定,请仔细检查。` + ); + } + + handleSelect() { + this.dropzone.current && this.dropzone.current.open(); + } + startUpload() { if (this.state.uploading) { return; @@ -311,32 +356,49 @@ export default class FileControl extends React.Component { files: this.state.files.concat() }, () => - this.sendFile(file, (error, file, obj) => { - const files = this.state.files.concat(); - const idx = files.indexOf(file as FileX); + this.sendFile( + file, + (error, file, obj) => { + const files = this.state.files.concat(); + const idx = files.indexOf(file as FileX); - if (!~idx) { - return; + if (!~idx) { + return; + } + + let newFile: FileValue = file as FileValue; + + if (error) { + newFile.state = 'error'; + newFile.error = error; + } else { + newFile = obj as FileValue; + } + 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 + }); } - - let newFile: FileValue = file as FileValue; - - if (error) { - newFile.state = 'error'; - newFile.error = error; - } else { - newFile = obj as FileValue; - } - files.splice(idx, 1, newFile); - this.current = null; - this.setState( - { - error: error ? error : null, - files: files - }, - this.tick - ); - }) + ) ); } else { this.setState( @@ -357,7 +419,11 @@ export default class FileControl extends React.Component { } } - sendFile(file: FileX, cb: (error: null | string, file?: FileX, obj?: FileValue) => void) { + sendFile( + file: FileX, + cb: (error: null | string, file?: FileX, obj?: FileValue) => void, + onProgress: (progress: number) => void + ) { const { reciever, fileField, @@ -368,7 +434,8 @@ export default class FileControl extends React.Component { chunkApi, finishChunkApi, asBase64, - asBlob + asBlob, + data } = this.props; if (asBase64) { @@ -379,7 +446,8 @@ export default class FileControl extends React.Component { value: reader.result as string, name: file.name, url: '', - state: 'ready' + state: 'ready', + id: file.id }); }; reader.onerror = (error: any) => cb(error.message); @@ -391,7 +459,8 @@ export default class FileControl extends React.Component { name: file.name, value: file, url: '', - state: 'ready' + state: 'ready', + id: file.id }), 4 ); @@ -412,14 +481,17 @@ export default class FileControl extends React.Component { chunkSize, startChunkApi, chunkApi, - finishChunkApi - } + finishChunkApi, + data + }, + onProgress ) .then(ret => { if (ret.status || !ret.data) { throw new Error(ret.msg || '上传失败, 请重试'); } + onProgress(1); const value = (ret.data as any).value || ret.data; cb(null, file, { @@ -431,7 +503,8 @@ export default class FileControl extends React.Component { : ret.data ? (ret.data as any).url : null, - state: 'uploaded' + state: 'uploaded', + id: file.id }); }) .catch(error => { @@ -481,32 +554,42 @@ export default class FileControl extends React.Component { onChange(value); } - uploadFile(file: FileX, reciever: string, params: object, config: Partial = {}): Promise { + uploadFile( + file: FileX, + reciever: string, + params: object, + config: Partial = {}, + onProgress: (progress: number) => void + ): Promise { const fd = new FormData(); + const api = buildApi(reciever, createObject(config.data, params), { + method: 'post' + }); + + qsstringify({...api.data, ...params}) + .split('&') + .forEach(item => { + const parts = item.split('='); + fd.append(parts[0], parts[1]); + }); - reciever = filter(reciever, this.props.data); fd.append(config.fieldName || 'file', file); - const idx = reciever.indexOf('?'); - - if (~idx && params) { - params = { - ...qs.parse(reciever.substring(idx + 1)), - ...params - }; - reciever = reciever.substring(0, idx) + '?' + qsstringify(params); - } else if (params) { - reciever += '?' + qsstringify(params); - } - - return this._send(reciever, fd, { - withCredentials: true - }); + return this._send(api, fd, {}, onProgress); } - uploadBigFile(file: FileX, reciever: string, params: object, config: Partial = {}): Promise { + uploadBigFile( + file: FileX, + reciever: 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; interface ObjectState { key: string; @@ -527,13 +610,26 @@ export default class FileControl extends React.Component { return new Promise((resolve, reject) => { let state: ObjectState; + const startApi = buildApi( + config.startChunkApi!, + createObject(config.data, { + ...params, + filename: file.name + }), + { + method: 'post', + autoAppend: true + } + ); - self._send(config.startChunkApi as string, {filename: file.name}) + 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('接口返回错误,请仔细检查'); @@ -555,25 +651,53 @@ export default class FileControl extends React.Component { }); } + 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) { - self._send(config.finishChunkApi as string, { - ...params, - uploadId: state.uploadId, - key: state.key, - filename: file.name, - partList - }) + onProgress(endProgress); + const endApi = buildApi( + config.finishChunkApi!, + createObject(config.data, { + ...params, + uploadId: state.uploadId, + key: state.key, + filename: file.name, + partList + }), + { + method: 'post', + autoAppend: true + } + ); + + self._send(endApi) .then(resolve) .catch(reject); } function uploadPartFile(state: ObjectState, conf: Partial) { - reciever = conf.chunkApi as string; - 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], parts[1]); + }); + fd.append('key', state.key); fd.append('uploadId', state.uploadId); fd.append('partNumber', task.partNumber.toString()); @@ -581,9 +705,7 @@ export default class FileControl extends React.Component { fd.append(config.fieldName || 'file', blob, file.name); return self - ._send(reciever, fd, { - withCredentials: true - }) + ._send(api, fd, {}, progress => updateProgress(task.partNumber, progress)) .then(ret => { state.loaded++; callback(null, { @@ -622,17 +744,25 @@ export default class FileControl extends React.Component { }); } - _send(reciever: string, data: any, options?: object): Promise { + _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'); } - reciever = filter(reciever, this.props.data); - return env.fetcher(reciever, data, { + return env.fetcher(api, data, { method: 'post', - ...options + ...options, + withCredentials: true, + onUploadProgress: onProgress + ? (event: {loaded: number; total: number}) => onProgress(event.loaded / event.total) + : undefined }); } @@ -652,85 +782,137 @@ export default class FileControl extends React.Component { btnLabel, accept, disabled, - btnClassName, - btnUploadClassName, maxLength, multiple, autoUpload, - stateTextMap, + description, hideUploadButton, className, - asBlob, - joinValues + classnames: cx, + render } = this.props; let {files, uploading, error} = this.state; const hasPending = files.some(file => file.state == 'pending'); return ( -
- {error ? ( - - {error} - - ) : null} - - {files && files.length ? ( - - ) : null} - -
- {(multiple && (!maxLength || files.length < maxLength)) || (!multiple && !files.length) ? ( - - ) : null} - - {!autoUpload && !hideUploadButton && files.length ? ( - - ) : null} + - {this.state.uploading ? : null} -
+ {isDragActive ? ( +
把文件拖到这,然后松完成添加!
+ ) : ( + <> + {(multiple && (!maxLength || files.length < maxLength)) || !multiple ? ( + + ) : null} + + {description + ? render('desc', description!, { + className: cx('FileControl-description') + }) + : null} + + {Array.isArray(files) ? ( +
    + {files.map((file, index) => ( +
  • +
    + + + {file.name || (file as FileValue).filename} + + {file.state === 'invalid' || file.state === 'error' ? ( + + ) : null} + {file.state !== 'uploading' ? ( + this.removeFile(file, index)} + > + + + ) : null} +
    + {file.state === 'uploading' || file.state === 'uploaded' ? ( +
    +
    + +
    + + {file.state === 'uploaded' ? ( + + ) : ( + {Math.round((file.progress || 0) * 100)}% + )} +
    + ) : null} +
  • + ))} +
+ ) : null} + + )} +
+ )} + + + {error ?
{error}
: null} + + {!autoUpload && !hideUploadButton && files.length ? ( + + ) : null} ); } @@ -738,6 +920,7 @@ export default class FileControl extends React.Component { @FormItem({ type: 'file', - sizeMutable: false + sizeMutable: false, + renderDescription: false }) export class FileControlRenderer extends FileControl {} diff --git a/src/renderers/Form/Image.tsx b/src/renderers/Form/Image.tsx index a5617eff7..9bc390f84 100644 --- a/src/renderers/Form/Image.tsx +++ b/src/renderers/Form/Image.tsx @@ -7,17 +7,18 @@ import 'blueimp-canvastoblob'; import find = require('lodash/find'); import qs from 'qs'; import {Payload} from '../../types'; -import {filter} from '../../utils/tpl'; -import {Switch} from '../../components'; import {buildApi} from '../../utils/api'; import {createObject, qsstringify} from '../../utils/helper'; import {Icon} from '../../components/icons'; import Button from '../../components/Button'; +// @ts-ignore +import accepts from 'attr-accept'; let id = 1; function gennerateId() { return id++; } +let preventEvent = (e: any) => e.stopPropagation(); export interface ImageProps extends FormControlProps { placeholder?: string; @@ -32,9 +33,14 @@ export interface ImageProps extends FormControlProps { aspectRatio?: number; aspectRatioLabel?: string; }; + crop?: + | boolean + | { + aspectRatio?: number; + [propName: string]: any; + }; accept?: string; - btnUploadClassName?: string; - btnClassName?: string; + hideUploadButton?: boolean; joinValues?: boolean; extractValue?: boolean; @@ -47,13 +53,8 @@ export interface ImageState { uploading: boolean; locked: boolean; lockedReason?: string; - compress: boolean; - compressOptions: { - maxWidth?: number; - maxHeight?: number; - }; files: Array; - crop?: object; + crop?: any; error?: string; cropFile?: FileValue; submitOnChange?: boolean; @@ -85,10 +86,7 @@ export default class ImageControl extends React.Component(); + dropzone = React.createRef(); current: FileValue | FileX | null = null; resolve?: (value?: any) => void; @@ -167,9 +165,7 @@ export default class ImageControl extends React.Component (item as FileValue).value === obj.value))) { obj = { ...org, - ...obj + ...obj, + id: org.id || obj.id }; } @@ -265,7 +262,29 @@ export default class ImageControl extends React.Component) { - evt.type === 'change' && alert('您选择的文件类型不符已被过滤!'); + if (evt.type !== 'change' && evt.type !== 'drop') { + return; + } + const {multiple, env, accept} = this.props; + + const files = rejectedFiles.map((file: any) => ({ + ...file, + state: 'invalid', + id: gennerateId(), + name: file.name + })); + + this.setState({ + files: multiple + ? this.state.files.concat(files) + : this.state.files.length + ? this.state.files + : files.slice(0, 1) + }); + + env.alert( + `您添加的文件${files.map((item: any) => `【${item.name}】`)}不符合类型的\`${accept}\`设定,请仔细检查。` + ); } startUpload() { @@ -445,7 +464,7 @@ export default class ImageControl extends React.Component) { @@ -466,15 +485,15 @@ export default class ImageControl extends React.Component = []; const items = event.clipboardData.items; + const accept = this.props.accept; [].slice.call(items).forEach((item: DataTransferItem) => { let blob: FileX; - if (item.kind !== 'file' || !(blob = item.getAsFile() as File) || !/^image/i.test(blob.type)) { + if (item.kind !== 'file' || !(blob = item.getAsFile() as File) || !accepts(blob, accept)) { return; } - blob.preview = window.URL.createObjectURL(blob); blob.id = gennerateId(); files.push(blob); }); @@ -483,7 +502,7 @@ export default class ImageControl extends React.Component { + this.cropper.current!.getCroppedCanvas().toBlob((file: File) => { this.addFiles([file]); this.setState({ cropFile: undefined, @@ -607,17 +626,7 @@ export default class ImageControl extends React.Component void, onProgress: (progress: number) => void ) { - let compressOptions = this.state.compressOptions; - - if (this.props.showCompressOptions) { - compressOptions = { - maxWidth: 800, - maxHeight: 600, - ...compressOptions - }; - } - - this._send(file, this.props.reciever as string, {compress: this.state.compress, compressOptions}, onProgress) + this._send(file, this.props.reciever as string, {}, onProgress) .then((ret: Payload) => { if (ret.status) { throw new Error(ret.msg || '上传失败, 请重试'); @@ -724,73 +733,6 @@ export default class ImageControl extends React.Component - this.setState({compress: checked})} - disabled={this.props.disabled} - /> - - 开启缩放? - - {this.state.compress && ( -
- - this.setState({ - compressOptions: { - ...this.state.compressOptions, - maxWidth: parseInt(e.currentTarget.value, 10) || 0 - } - }) - } - disabled={this.props.disabled} - /> - - X - - - this.setState({ - compressOptions: { - ...this.state.compressOptions, - maxHeight: parseInt(e.currentTarget.value, 10) || 0 - } - }) - } - disabled={this.props.disabled} - /> -
- )} - - ); - } - render() { const { className, @@ -801,167 +743,223 @@ export default class ImageControl extends React.Component file.state == 'pending'); - return ( -
+
{cropFile ? ( -
- - - +
+ +
) : ( - {files && files.length - ? files.map((file, key) => ( -
- {file.state === 'invalid' || file.state === 'error' ? ( - - -

重新上传

-
- ) : file.state === 'uploading' ? ( - <> - - - -
-

文件上传中

-
- -
-
- - ) : ( - <> -
- {file.name} -
- -
- {file.info ? ( - [ -
- {file.info.width} x {file.info.height} -
, - file.info.len ? ( -
- {ImageControl.formatFileSize(file.info.len)} -
- ) : null - ] - ) : ( -
...
- )} - - {!disabled ? ( - - - - ) : null} - {!!crop && !disabled ? ( - - - - ) : null} - {!disabled ? ( - - - - ) : null} -
- - )} -
- )) - : null} - - {(multiple && (!maxLength || files.length < maxLength)) || (!multiple && !files.length) ? ( -
)} - - {this.renderCompressOptions()} - - {!autoUpload && !hideUploadButton && files.length ? ( - - ) : null} - - {error ?
{error}
: null}
); } diff --git a/src/renderers/Form/Item.tsx b/src/renderers/Form/Item.tsx index afe06070d..395f357d4 100644 --- a/src/renderers/Form/Item.tsx +++ b/src/renderers/Form/Item.tsx @@ -11,6 +11,7 @@ export interface FormItemBasicConfig extends Partial { type?: string; wrap?: boolean; renderLabel?: boolean; + renderDescription?: boolean; test?: RegExp | TestFunc; storeType?: string; validations?: string; @@ -48,6 +49,7 @@ export interface FormControlProps extends RendererProps { renderControl?: (props: RendererProps) => JSX.Element; renderLabel?: boolean; + renderDescription?: boolean; sizeMutable?: boolean; wrap?: boolean; hint?: string; @@ -165,6 +167,7 @@ export class FormItemWrap extends React.Component ) : null} - {description + {renderDescription !== false && description ? render('description', description, { className: cx(`Form-description`, descriptionClassName) }) @@ -278,6 +281,7 @@ export class FormItemWrap extends React.Component ) : null} - {description + {renderDescription !== false && description ? render('description', description, { className: cx(`Form-description`, descriptionClassName) }) @@ -366,7 +370,8 @@ export class FormItemWrap extends React.Component ) : null} - {description + {renderDescription !== false && description ? render('description', description, { className: cx(`Form-description`, descriptionClassName) }) @@ -455,6 +460,7 @@ export class FormItemWrap extends React.Component ) : null} - {description + {description && renderDescription !== false ? render('description', description, { className: cx(`Form-description`, descriptionClassName) }) @@ -577,6 +583,7 @@ export function registerFormItem(config: FormItemConfig): RendererConfig { static defaultProps = { className: '', renderLabel: config.renderLabel, + renderDescription: config.renderDescription, sizeMutable: config.sizeMutable, wrap: config.wrap, strictMode: config.strictMode, diff --git a/src/utils/validations.ts b/src/utils/validations.ts index 2a5907fc6..89ea19c6c 100644 --- a/src/utils/validations.ts +++ b/src/utils/validations.ts @@ -192,10 +192,10 @@ export function validate( const fn = validations[ruleName]; - if (!fn(values, value, ...rules[ruleName])) { + if (!fn(values, value, ...(Array.isArray(rules[ruleName]) ? rules[ruleName] : [rules[ruleName]]))) { errors.push( filter((messages && messages[ruleName]) || validateMessages[ruleName], { - ...['', ...rules[ruleName]] + ...[''].concat(rules[ruleName]) }) ); }