File UI 更新

This commit is contained in:
liaoxuezhi 2019-10-23 23:01:45 +08:00
parent eef30b2346
commit 52d87776a6
16 changed files with 787 additions and 303 deletions

View File

@ -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",

View File

@ -0,0 +1,142 @@
.#{$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);
}
}
&-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;
>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;
}
}

View File

@ -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;
@ -210,4 +219,20 @@
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;
}
}

View File

@ -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";

View File

@ -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';

View File

@ -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";

View File

@ -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,

7
src/icons/fail.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 34 34" version="1.1">
<g transform="translate(1.000000, 1.000000)">
<circle stroke="#EA2E2E" cx="16" cy="16" r="16" fill="none"></circle>
<polygon fill="#EA2E2E" fill-rule="nonzero" points="24 10.1052632 21.8947368 8 16 14.0350877 10.1052632 8 8 10.1052632 14.0350877 16 8 21.8947368 10.1052632 24 16 17.9649123 21.8947368 24 24 21.8947368 17.9649123 16"></polygon>
</g>
</svg>

After

Width:  |  Height:  |  Size: 502 B

8
src/icons/file.svg Normal file
View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 14 16" version="1.1">
<g>
<path d="M0,0 L0,16 L14,16 L14,4.001 L9.939,0 L0,0 Z M1,1 L9,1 L9,4.001 L9,5 L10,5 L13,5 L13,15 L1,15 L1,1 Z M10,1.464 L12.575,4.001 L10,4.001 L10,1.464 Z" id="Fill-1"></path>
<polygon points="4 12.0002 10 12.0002 10 10.9992 4 10.9992"></polygon>
<polygon points="4 9.0002 10 9.0002 10 8.0002 4 8.0002"></polygon>
</g>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024" version="1.1" p-id="1463">
<path d="M852.727563 392.447107C956.997809 458.473635 956.941389 565.559517 852.727563 631.55032L281.888889 993.019655C177.618644 1059.046186 93.090909 1016.054114 93.090909 897.137364L93.090909 126.860063C93.090909 7.879206 177.675064-35.013033 281.888889 30.977769L852.727563 392.447107 852.727563 392.447107Z" p-id="4494" fill="#606670" />
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 14 16" version="1.1" p-id="1463">
<path d="M13.5722,7.254 L1.2838,0.115 C1.019,-0.038 0.6926,-0.038 0.4278,0.115 C0.163,0.269 -1.83725092e-07,0.554 -1.83725092e-07,0.861 L-1.83725092e-07,15.139 C-0.0002,15.446 0.1629,15.731 0.4278,15.885 C0.6927,16.039 1.019,16.038 1.2838,15.884 L13.5721,8.746 C13.8368,8.592 13.9999998,8.308 13.9999998,8 C13.9999998,7.692 13.837,7.408 13.5722,7.254 Z" id="path-1"></path>
</svg>

Before

Width:  |  Height:  |  Size: 492 B

After

Width:  |  Height:  |  Size: 519 B

View File

@ -1,4 +1,8 @@
<svg viewBox="0 0 1024 1024" version="1.1"
<svg viewBox="0 0 15 17" version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path d="M972.8 102.4c-30.72 0-51.2 20.48-51.2 51.2v51.2c-51.2-71.68-122.88-128-204.8-158.72C460.8-66.56 158.72 51.2 46.08 307.2S51.2 865.28 307.2 977.92 865.28 972.8 977.92 716.8H972.8c0-30.72-20.48-51.2-51.2-51.2s-51.2 20.48-51.2 51.2h-5.12c-46.08 76.8-112.64 138.24-199.68 174.08-209.92 87.04-445.44-15.36-532.48-225.28S148.48 215.04 358.4 133.12c189.44-81.92 404.48 0 506.88 174.08H768c-30.72 0-51.2 20.48-51.2 51.2s20.48 51.2 51.2 51.2h204.8c30.72 0 51.2-20.48 51.2-51.2V153.6c0-30.72-20.48-51.2-51.2-51.2z"></path>
</svg>
<g transform="translate(1.000000, 0.000000)">
<polygon id="Fill-1" fill="#666666" points="5.0003 0.0003 5.0003 7.0703 9.5353 3.5353"></polygon>
<path fill="none" d="M13,9.5355 C13,13.1255 10.09,16.0355 6.5,16.0355 C2.91,16.0355 0,13.1255 0,9.5355 C0,5.9455 2.91,3.0355 6.5,3.0355" stroke="#666666" stroke-width="2"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 615 B

After

Width:  |  Height:  |  Size: 440 B

9
src/icons/success.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32" version="1.1">
<g id="Group-5">
<circle stroke="#5FB333" fill="#FFFFFF" cx="16" cy="16" r="15.5"></circle>
<g transform="translate(5.647059, 7.529412)" fill="#5FB333" fill-rule="nonzero">
<polygon id="Shape" points="21.1764706 2.76408669 18.7058824 0.26749226 7.41176471 11.6804954 2.47058824 6.50897833 0 9.18390093 4.94117647 14.1770898 4.94117647 14.1770898 7.41176471 16.6736842 9.88235294 14.1770898 9.88235294 14.1770898"></polygon>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 608 B

11
src/icons/upload.svg Normal file
View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16" version="1.1" p-id="1463">
<g stroke="#666666" stroke-width="2" fill="none" fill-rule="evenodd">
<path d="M8,12.2426 L8,1.2426"></path>
<path d="M4.4648,4.9496 L8.7068,0.7076"></path>
<path d="M11.5352,4.9496 L7.2932,0.7076"></path>
<path d="M0,14.2426 L16,14.2426"></path>
<path d="M1,9.2426 L1,15.2426"></path>
<path d="M15,9.2426 L15,15.2426"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@ -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<FileProps, FileState> {
static defaultProps: Partial<FileProps> = {
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<FileProps, FileState> {
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<FileProps, FileState> {
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<FileProps, FileState> {
: undefined;
}
dropzone = React.createRef<any>();
constructor(props: FileProps) {
super(props);
@ -163,6 +176,7 @@ export default class FileControl extends React.Component<FileProps, FileState> {
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<FileProps, FileState> {
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<FileProps, FileState> {
) {
obj = {
...org,
...obj
...obj,
id: obj.id || org!.id
};
}
@ -214,30 +230,29 @@ export default class FileControl extends React.Component<FileProps, FileState> {
}
}
handleDrop(e: React.ChangeEvent<any>) {
const files = e.currentTarget.files;
handleDrop(files: Array<FileX>) {
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<FileX> = [];
[].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<FileProps, FileState> {
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<FileProps, FileState> {
);
}
handleDropRejected(rejectedFiles: any, evt: React.DragEvent<any>) {
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<FileProps, FileState> {
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<FileProps, FileState> {
}
}
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<FileProps, FileState> {
chunkApi,
finishChunkApi,
asBase64,
asBlob
asBlob,
data
} = this.props;
if (asBase64) {
@ -379,7 +446,8 @@ export default class FileControl extends React.Component<FileProps, FileState> {
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<FileProps, FileState> {
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<FileProps, FileState> {
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<FileProps, FileState> {
: 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<FileProps, FileState> {
onChange(value);
}
uploadFile(file: FileX, reciever: string, params: object, config: Partial<FileProps> = {}): Promise<Payload> {
uploadFile(
file: FileX,
reciever: string,
params: object,
config: Partial<FileProps> = {},
onProgress: (progress: number) => void
): Promise<Payload> {
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<FileProps> = {}): Promise<Payload> {
uploadBigFile(
file: FileX,
reciever: string,
params: object,
config: Partial<FileProps> = {},
onProgress: (progress: number) => void
): Promise<Payload> {
const chunkSize = config.chunkSize || 5 * 1024 * 1024;
const self = this;
let startProgress = 0.2;
let endProgress = 0.9;
let progressArr: Array<number>;
interface ObjectState {
key: string;
@ -527,13 +610,26 @@ export default class FileControl extends React.Component<FileProps, FileState> {
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<FileProps, FileState> {
});
}
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<any> | 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<FileProps>) {
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<FileProps, FileState> {
fd.append(config.fieldName || 'file', blob, file.name);
return self
._send(reciever, fd, {
withCredentials: true
})
._send(reciever, fd, {}, progress => updateProgress(task.partNumber, progress))
.then(ret => {
state.loaded++;
callback(null, {
@ -622,17 +744,25 @@ export default class FileControl extends React.Component<FileProps, FileState> {
});
}
_send(reciever: string, data: any, options?: object): Promise<Payload> {
_send(
api: ApiObject | ApiString,
data?: any,
options?: object,
onProgress?: (progress: number) => void
): Promise<Payload> {
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,135 @@ export default class FileControl extends React.Component<FileProps, FileState> {
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 (
<div className={cx('amis-file-control', className)}>
{error ? (
<Alert level="danger" showCloseButton onClose={this.clearError}>
{error}
</Alert>
) : null}
{files && files.length ? (
<ul className="list-group no-bg m-b-sm">
{files.map((file, key) => (
<li key={key} className="list-group-item clearfix">
<a
className="text-danger pull-right"
onClick={() => this.removeFile(file, key)}
href="javascript:void 0"
data-tooltip="移除"
>
<i className="fa fa-times" />
</a>
<span className="pull-right text-muted text-xs m-r-sm">
{(stateTextMap && stateTextMap[file.state as string]) || ''}
</span>
<i className="fa fa-file fa-fw m-r-xs" />
{(file as FileValue).url ? (
<a href={(file as FileValue).url} target="_blank">
{file.name || (file as FileValue).filename || (file as FileValue).value}
</a>
) : (
<span>{file.name || (file as FileValue).filename}</span>
)}
</li>
))}
</ul>
) : null}
<div className="clear">
{(multiple && (!maxLength || files.length < maxLength)) || (!multiple && !files.length) ? (
<label className={cx('btn m-r-xs', btnClassName, {disabled})}>
<input
type="file"
accept={accept}
disabled={disabled}
multiple={multiple}
className="invisible"
onChange={this.handleDrop}
/>
{btnLabel}
</label>
) : null}
{!autoUpload && !hideUploadButton && files.length ? (
<button
type="button"
className={cx('btn m-r-xs', btnUploadClassName)}
disabled={!hasPending}
onClick={this.toggleUpload}
<div className={cx('FileControl', className)}>
<DropZone
key="drop-zone"
ref={this.dropzone}
onDrop={this.handleDrop}
onDropRejected={this.handleDropRejected}
accept={accept}
multiple={multiple}
>
{({getRootProps, getInputProps, isDragActive}) => (
<div
{...getRootProps({
onClick: preventEvent
})}
className={cx('FileControl-dropzone', {
disabled,
'is-empty': !files.length,
'is-active': isDragActive
})}
>
{uploading ? '暂停上传' : '开始上传'}
</button>
) : null}
<input {...getInputProps()} />
{this.state.uploading ? <i className="fa fa-spinner fa-spin fa-2x fa-fw" /> : null}
</div>
{isDragActive ? (
<div className={cx('FileControl-acceptTip')}></div>
) : (
<>
{(multiple && (!maxLength || files.length < maxLength)) || !multiple ? (
<Button
level="default"
className={cx('FileControl-selectBtn')}
onClick={this.handleSelect}
>
<Icon icon="upload" className="icon" />
{!multiple && files.length
? '重新上传'
: multiple && files.length
? '继续添加'
: '上传文件'}
</Button>
) : null}
{description
? render('desc', description!, {
className: cx('FileControl-description')
})
: null}
{Array.isArray(files) ? (
<ul className={cx('FileControl-list')}>
{files.map((file, index) => (
<li key={file.id}>
<div
className={cx('FileControl-itemInfo', {
'is-invalid':
file.state === 'invalid' || file.state === 'error'
})}
>
<Icon icon="file" className="icon" />
{file.name || (file as FileValue).filename}
{file.state === 'invalid' || file.state === 'error' ? (
<Icon icon="fail" className="icon" />
) : null}
{file.state !== 'uploading' ? (
<a
data-tooltip="移除"
className={cx('FileControl-clear')}
onClick={() => this.removeFile(file, index)}
>
<Icon icon="close" className="icon" />
</a>
) : null}
</div>
{file.state === 'uploading' || file.state === 'uploaded' ? (
<div className={cx('FileControl-progressInfo')}>
<div className={cx('FileControl-progress')}>
<span
style={{
width: `${
file.state === 'uploaded'
? 100
: file.progress * 100
}%`
}}
/>
</div>
{file.state === 'uploaded' ? (
<Icon icon="success" className="icon" />
) : (
<span>{Math.round((file.progress || 0) * 100)}%</span>
)}
</div>
) : null}
</li>
))}
</ul>
) : null}
</>
)}
</div>
)}
</DropZone>
{error ? <div className={cx('FileControl-errorMsg')}>{error}</div> : null}
{!autoUpload && !hideUploadButton && files.length ? (
<Button
level="default"
disabled={!hasPending}
className={cx('FileControl-uploadBtn')}
onClick={this.handleSelect}
>
{uploading ? '暂停上传' : '开始上传'}
</Button>
) : null}
</div>
);
}
@ -738,6 +918,7 @@ export default class FileControl extends React.Component<FileProps, FileState> {
@FormItem({
type: 'file',
sizeMutable: false
sizeMutable: false,
renderDescription: false
})
export class FileControlRenderer extends FileControl {}

View File

@ -11,11 +11,13 @@ import {buildApi} from '../../utils/api';
import {createObject, qsstringify} from '../../utils/helper';
import {Icon} from '../../components/icons';
import Button from '../../components/Button';
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;
@ -134,6 +136,8 @@ export default class ImageControl extends React.Component<ImageProps, ImageState
files: []
};
cropper = React.createRef<Cropper>();
dropzone = React.createRef<DropZone>();
current: FileValue | FileX | null = null;
resolve?: (value?: any) => void;
@ -257,7 +261,29 @@ export default class ImageControl extends React.Component<ImageProps, ImageState
}
handleDropRejected(rejectedFiles: any, evt: React.DragEvent<any>) {
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() {
@ -437,7 +463,7 @@ export default class ImageControl extends React.Component<ImageProps, ImageState
}
handleSelect() {
this.refs.dropzone && (this.refs.dropzone as any).open();
this.dropzone.current && this.dropzone.current.open();
}
handleDrop(files: Array<FileX>) {
@ -458,15 +484,15 @@ export default class ImageControl extends React.Component<ImageProps, ImageState
const event = e.nativeEvent as any;
const files: Array<FileX> = [];
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);
});
@ -475,7 +501,7 @@ export default class ImageControl extends React.Component<ImageProps, ImageState
}
handleCrop() {
(this.refs.cropper as any).getCroppedCanvas().toBlob((file: File) => {
this.cropper.current!.getCroppedCanvas().toBlob((file: File) => {
this.addFiles([file]);
this.setState({
cropFile: undefined,
@ -722,12 +748,11 @@ export default class ImageControl extends React.Component<ImageProps, ImageState
const {files, error, crop, uploading, cropFile} = this.state;
const hasPending = files.some(file => file.state == 'pending');
return (
<div className={cx(`ImageControl`, className)} tabIndex={-1} onPaste={this.handlePaste}>
<div className={cx(`ImageControl`, className)}>
{cropFile ? (
<div className={cx('ImageControl-cropperWrapper')}>
<Cropper {...crop} ref="cropper" src={cropFile.preview} />
<Cropper {...crop} ref={this.cropper} src={cropFile.preview} />
<div className={cx('ImageControl-croperToolbar')}>
<a
className={cx('ImageControl-cropCancel')}
@ -750,141 +775,190 @@ export default class ImageControl extends React.Component<ImageProps, ImageState
) : (
<DropZone
key="drop-zone"
className={cx('ImageControl-dropzone', {
disabled,
'is-empty': !files.length
})}
activeClassName="is-active"
ref="dropzone"
ref={this.dropzone}
onDrop={this.handleDrop}
onDropRejected={this.handleDropRejected}
disableClick
accept={accept}
multiple={multiple}
>
{files && files.length
? files.map((file, key) => (
<div
key={file.id || key}
className={cx('ImageControl-item', {
'is-uploaded': file.state !== 'uploading',
'is-invalid': file.state === 'error' || file.state === 'invalid'
})}
>
{file.state === 'invalid' || file.state === 'error' ? (
<a
className={cx('ImageControl-retryBtn', {'is-disabled': disabled})}
onClick={this.handleSelect}
>
<Icon icon="retry" className="icon" />
<p className="ImageControl-itemInfoError"></p>
</a>
) : file.state === 'uploading' ? (
<>
<a
onClick={this.removeFile.bind(this, file, key)}
key="clear"
className={cx('ImageControl-itemClear')}
data-tooltip="移除"
>
<Icon icon="close" className="icon" />
</a>
<div key="info" className={cx('ImageControl-itemInfo')}>
<p></p>
<div className={cx('ImageControl-progress')}>
<span
style={{width: `${Math.round(file.progress * 100)}%`}}
className={cx('ImageControl-progressValue')}
/>
</div>
</div>
</>
) : (
<>
<div key="image" className={cx('ImageControl-itemImageWrap')}>
<img
onLoad={this.handleImageLoaded.bind(this, key)}
src={file.url || file.preview}
alt={file.name}
/>
</div>
<div key="overlay" className={cx('ImageControl-itemOverlay')}>
{file.info ? (
[
<div key="1">
{file.info.width} x {file.info.height}
</div>,
file.info.len ? (
<div key="2">
{ImageControl.formatFileSize(file.info.len)}
</div>
) : null
]
) : (
<div>...</div>
)}
{!disabled ? (
<a
data-tooltip="查看大图"
data-position="bottom"
target="_blank"
href={file.url || file.preview}
>
<Icon icon="view" className="icon" />
</a>
) : null}
{!!crop && !disabled ? (
<a
data-tooltip="裁剪图片"
data-position="bottom"
onClick={this.editImage.bind(this, key)}
>
<Icon icon="pencil" className="icon" />
</a>
) : null}
{!disabled ? (
<a
data-tooltip="移除"
data-position="bottom"
onClick={this.removeFile.bind(this, file, key)}
>
<Icon icon="remove" className="icon" />
</a>
) : null}
</div>
</>
)}
</div>
))
: null}
{(multiple && (!maxLength || files.length < maxLength)) || (!multiple && !files.length) ? (
<label
className={cx('ImageControl-addBtn', {'is-disabled': disabled})}
onClick={this.handleSelect}
data-tooltip={placeholder}
data-position="right"
{({getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject, isFocused}) => (
<div
{...getRootProps({
onClick: preventEvent,
onPaste: this.handlePaste,
className: cx('ImageControl-dropzone', {
disabled,
'is-empty': !files.length,
'is-active': isDragActive
})
})}
>
<Icon icon="plus" className="icon" />
</label>
) : null}
<input {...getInputProps()} />
{isDragActive || isDragAccept || isDragReject ? (
<div
className={cx('ImageControl-acceptTip', {
'is-accept': isDragAccept,
'is-reject': isDragReject
})}
>
</div>
) : (
<>
{files && files.length
? files.map((file, key) => (
<div
key={file.id || key}
className={cx('ImageControl-item', {
'is-uploaded': file.state !== 'uploading',
'is-invalid':
file.state === 'error' || file.state === 'invalid'
})}
>
{file.state === 'invalid' || file.state === 'error' ? (
<a
className={cx('ImageControl-retryBtn', {
'is-disabled': disabled
})}
onClick={this.handleSelect}
>
<Icon icon="retry" className="icon" />
<p className="ImageControl-itemInfoError"></p>
</a>
) : file.state === 'uploading' ? (
<>
<a
onClick={this.removeFile.bind(this, file, key)}
key="clear"
className={cx('ImageControl-itemClear')}
data-tooltip="移除"
>
<Icon icon="close" className="icon" />
</a>
<div key="info" className={cx('ImageControl-itemInfo')}>
<p></p>
<div className={cx('ImageControl-progress')}>
<span
style={{
width: `${Math.round(
file.progress * 100
)}%`
}}
className={cx('ImageControl-progressValue')}
/>
</div>
</div>
</>
) : (
<>
<div
key="image"
className={cx('ImageControl-itemImageWrap')}
>
<img
onLoad={this.handleImageLoaded.bind(this, key)}
src={file.url || file.preview}
alt={file.name}
/>
</div>
<div
key="overlay"
className={cx('ImageControl-itemOverlay')}
>
{file.info ? (
[
<div key="1">
{file.info.width} x {file.info.height}
</div>,
file.info.len ? (
<div key="2">
{ImageControl.formatFileSize(
file.info.len
)}
</div>
) : null
]
) : (
<div>...</div>
)}
{!disabled ? (
<a
data-tooltip="查看大图"
data-position="bottom"
target="_blank"
href={file.url || file.preview}
>
<Icon icon="view" className="icon" />
</a>
) : null}
{!!crop && !disabled ? (
<a
data-tooltip="裁剪图片"
data-position="bottom"
onClick={this.editImage.bind(this, key)}
>
<Icon icon="pencil" className="icon" />
</a>
) : null}
{!disabled ? (
<a
data-tooltip="移除"
data-position="bottom"
onClick={this.removeFile.bind(
this,
file,
key
)}
>
<Icon icon="remove" className="icon" />
</a>
) : null}
</div>
</>
)}
</div>
))
: null}
{(multiple && (!maxLength || files.length < maxLength)) ||
(!multiple && !files.length) ? (
<label
className={cx('ImageControl-addBtn', {'is-disabled': disabled})}
onClick={this.handleSelect}
data-tooltip={placeholder}
data-position="right"
>
<Icon icon="plus" className="icon" />
</label>
) : null}
{isFocused ? (
<span className={cx('ImageControl-pasteTip')}>
</span>
) : null}
{!autoUpload && !hideUploadButton && files.length ? (
<Button
level="default"
className={cx('ImageControl-uploadBtn')}
disabled={!hasPending}
onClick={this.toggleUpload}
>
{uploading ? '暂停上传' : '开始上传'}
</Button>
) : null}
{error ? <div className={cx('ImageControl-errorMsg')}>{error}</div> : null}
</>
)}
</div>
)}
</DropZone>
)}
{!autoUpload && !hideUploadButton && files.length ? (
<Button
level="default"
className={cx('ImageControl-uploadBtn')}
disabled={!hasPending}
onClick={this.toggleUpload}
>
{uploading ? '暂停上传' : '开始上传'}
</Button>
) : null}
{error ? <div className={cx('ImageControl-errorMsg')}>{error}</div> : null}
</div>
);
}

View File

@ -11,6 +11,7 @@ export interface FormItemBasicConfig extends Partial<RendererConfig> {
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<FormControlProps, FormControlS
env,
formItem: model,
renderLabel,
renderDescription,
hint
} = this.props;
@ -250,7 +253,7 @@ export class FormItemWrap extends React.Component<FormControlProps, FormControlS
</ul>
) : null}
{description
{renderDescription !== false && description
? render('description', description, {
className: cx(`Form-description`, descriptionClassName)
})
@ -278,6 +281,7 @@ export class FormItemWrap extends React.Component<FormControlProps, FormControlS
captionClassName,
formItem: model,
renderLabel,
renderDescription,
hint,
formMode
} = this.props;
@ -339,7 +343,7 @@ export class FormItemWrap extends React.Component<FormControlProps, FormControlS
</ul>
) : null}
{description
{renderDescription !== false && description
? render('description', description, {
className: cx(`Form-description`, descriptionClassName)
})
@ -366,7 +370,8 @@ export class FormItemWrap extends React.Component<FormControlProps, FormControlS
labelRemark,
env,
hint,
renderLabel
renderLabel,
renderDescription
} = this.props;
description = description || desc;
@ -427,7 +432,7 @@ export class FormItemWrap extends React.Component<FormControlProps, FormControlS
</ul>
) : null}
{description
{renderDescription !== false && description
? render('description', description, {
className: cx(`Form-description`, descriptionClassName)
})
@ -455,6 +460,7 @@ export class FormItemWrap extends React.Component<FormControlProps, FormControlS
captionClassName,
formItem: model,
renderLabel,
renderDescription,
hint,
formMode
} = this.props;
@ -518,7 +524,7 @@ export class FormItemWrap extends React.Component<FormControlProps, FormControlS
</ul>
) : 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,