From 536e2097d8b8a0c92b72e2ecb2839041243491e6 Mon Sep 17 00:00:00 2001 From: yehuozhili <673632758@qq.com> Date: Mon, 22 Jun 2020 17:38:49 +0800 Subject: [PATCH] feat(upload): complete upload component --- .storybook/preview.js | 1 + package.json | 3 +- src/components/Icon/icon.stories.mdx | 2 + src/components/Icon/icon.tsx | 6 +- src/components/Upload/_style.scss | 37 +++ src/components/Upload/index.tsx | 3 + src/components/Upload/upload.stories.mdx | 130 +++++++++ src/components/Upload/upload.tsx | 352 +++++++++++++++++++++++ src/components/Upload/uploadlist.tsx | 97 +++++++ src/index.tsx | 1 + src/styles/index.scss | 5 +- yarn.lock | 21 ++ 12 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 src/components/Upload/_style.scss create mode 100644 src/components/Upload/index.tsx create mode 100644 src/components/Upload/upload.stories.mdx create mode 100644 src/components/Upload/upload.tsx create mode 100644 src/components/Upload/uploadlist.tsx diff --git a/.storybook/preview.js b/.storybook/preview.js index 2bb556f..e74ff2b 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -39,6 +39,7 @@ const loaderFn = () => { require("../src/components/MultiSelect/multiselect.stories.mdx"), require("../src/components/AutoComplete/autocomplete.stories.mdx"), require("../src/components/Form/form.stories.mdx"), + require("../src/components/Upload/upload.stories.mdx"), require("../src/components/List/list.stories.mdx"), require("../src/components/VirtualList/virtuallist.stories.mdx"), require("../src/components/Icon/icon.stories.mdx"), diff --git a/package.json b/package.json index 745fae2..a413201 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bigbear-ui", - "version": "0.1.28", + "version": "0.1.29", "description": "Neumorphic component library for React;基于React制作的拟物风格组件库", "keywords": [ "component", @@ -29,6 +29,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.28", "@fortawesome/free-solid-svg-icons": "^5.13.0", "@fortawesome/react-fontawesome": "^0.1.9", + "axios": "^0.19.2", "classnames": "^2.2.6", "react-transition-group": "^4.4.1" }, diff --git a/src/components/Icon/icon.stories.mdx b/src/components/Icon/icon.stories.mdx index 545de2f..b6c9b89 100644 --- a/src/components/Icon/icon.stories.mdx +++ b/src/components/Icon/icon.stories.mdx @@ -103,6 +103,8 @@ library.add(faTimes); + + diff --git a/src/components/Icon/icon.tsx b/src/components/Icon/icon.tsx index 0e3a4ba..27df9f3 100644 --- a/src/components/Icon/icon.tsx +++ b/src/components/Icon/icon.tsx @@ -30,6 +30,8 @@ import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle"; import { faMapMarkerAlt } from "@fortawesome/free-solid-svg-icons/faMapMarkerAlt"; import { faAtom } from "@fortawesome/free-solid-svg-icons/faAtom"; import { faFilter } from "@fortawesome/free-solid-svg-icons/faFilter"; +import { faFileAlt } from "@fortawesome/free-solid-svg-icons/faFileAlt"; +import { faImage } from "@fortawesome/free-solid-svg-icons/faImage"; library.add( faCoffee, @@ -59,7 +61,9 @@ library.add( faCheckCircle, faMapMarkerAlt, faAtom, - faFilter + faFilter, + faFileAlt, + faImage ); export type ThemeProps = diff --git a/src/components/Upload/_style.scss b/src/components/Upload/_style.scss new file mode 100644 index 0000000..628cd81 --- /dev/null +++ b/src/components/Upload/_style.scss @@ -0,0 +1,37 @@ +.bigbear-upload-list{ + padding: 10px; + .bigbear-upload-li{ + list-style-type: none; + .bigbear-alert > span:nth-child(1){ + font-size: $font-size-sm; + } + .bigbear-progress-wrapper{ + @include neufactory-noactive($white,$neu-whiteshadow1,$neu-whiteshadow2); + border:none; + } + } +} +.bigbear-alert.upload-success{ + color:$success; +} +.bigbear-alert.upload-failed{ + color:$danger; +} +.bigbear-upload-input{ + margin-bottom: 5px; +} +.bigbear-upload-showchoose{ + margin-top: 10px; + margin-bottom: 10px; +} +.bigbear-upload-btn{ + display: inline-block; +} + +.bigbear-upload-imageli{ + display: inline-block; + .bigbear-avatar { + text-align: center; + line-height:$avatar-size-lg - $avatar-padding-lg*2; + } +} \ No newline at end of file diff --git a/src/components/Upload/index.tsx b/src/components/Upload/index.tsx new file mode 100644 index 0000000..36b72a9 --- /dev/null +++ b/src/components/Upload/index.tsx @@ -0,0 +1,3 @@ +import Upload from "./upload"; + +export default Upload; diff --git a/src/components/Upload/upload.stories.mdx b/src/components/Upload/upload.stories.mdx new file mode 100644 index 0000000..4e91110 --- /dev/null +++ b/src/components/Upload/upload.stories.mdx @@ -0,0 +1,130 @@ +import { Meta, Story, Props ,Preview } from '@storybook/addon-docs/blocks'; +import Upload from './upload'; + + + + + +
+ + # Upload 上传 + +
+ +## 基本使用 + + + + console.log(e)} failCallback={(e)=>console.log(e)} > + + + +## 上传进度 + +type='progress'可以下方附加进度信息。 + +注意uid需要唯一 + + + + 'avatar'} +defaultProgressBar={[{ + filename: 'hello.md', + percent: 100, + status: "success", + uid: '111', + size: 1222, + raw: null + },{ + filename: 'hello2.md', + percent: 0, + status: "failed", + uid: '1112', + size: 12222, + raw: null + },{ + filename: 'hello3.md', + percent: 40, + status: "upload", + uid: '1113', + size: 12222, + raw: null + },{ + filename: 'hello4.md', + percent: 80, + status: "upload", + uid: '11132', + size: 12222, + raw: null + } + ]} + type={'progress'} + successCallback={(e)=>console.log(e)} failCallback={(e)=>console.log(e)} > + + + + +## 确认上传 + +confirm 为 true 可进行分段上传 + + + + 'avatar'} confirm={true} successCallback={(e)=>console.log(e)} failCallback={(e)=>console.log(e)} > + + + + +## 图片回显 + +文件验证需要自行添加 + +传入uploadNumber可以控制最大上传数量 + + + + 'avatar'} confirm={true} type='img' + uploadNumber={3} + defaultProgressBar={[{ + filename: 'hello.md', + percent: 100, + status: "success", + uid: '111', + size: 1222, + raw: null + },{ + filename: 'hello2.md', + percent: 0, + status: "failed", + uid: '1112', + size: 12222, + raw: null + },{ + filename: 'hello3.md', + percent: 40, + status: "upload", + uid: '1113', + size: 12222, + raw: null + },{ + filename: 'hello4.md', + percent: 80, + status: "upload", + uid: '11132', + size: 12222, + raw: null + } + ]} + successCallback={(e)=>console.log(e)} failCallback={(e)=>console.log(e)} > + + + + + + 'avatar'} type='img' + successCallback={(e)=>console.log(e)} failCallback={(e)=>console.log(e)} > + + + + + \ No newline at end of file diff --git a/src/components/Upload/upload.tsx b/src/components/Upload/upload.tsx new file mode 100644 index 0000000..d861ce1 --- /dev/null +++ b/src/components/Upload/upload.tsx @@ -0,0 +1,352 @@ +import React, { ChangeEvent, useState, ReactNode, useRef, useCallback } from "react"; +import Button from "../Button"; +import axios, { CancelTokenSource } from "axios"; +import UploadList, { MemoImageList } from "./uploadlist"; +import { BaseButtonProps } from "../Button/button"; +import Badge from "../Badge"; + +interface UploadProps { + /** 类型,默认模式不进行回显和显示进度,img模式进行回显图片,progress显示进度*/ + type?: "default" | "img" | "progress"; + /** 是否选择文件后按提交按钮才上传 */ + confirm?: boolean; + /** 验证逻辑 返回false则不通过*/ + validate?: (e: ChangeEvent) => boolean; + /** 选择文件上传按钮的属性 */ + uploadBtnAtr?: BaseButtonProps; + /** 选择文件上传按钮的文字 */ + uploadBtn?: ReactNode; + /**上传多少张按钮消失,只在type为img或者progress有效 */ + uploadNumber?: number; + /** 提交按钮,只在confirm为true有效 */ + submitBtn?: ReactNode; + /** 自定义提交,不使用内部发送请求进行上传,可自定义上传头*/ + customSubmit?: (file: FileList | null) => void; + /** 使用内部上传所提供的url */ + url?: string; + /** 成功的回调 只在使用内部发送有效,下同*/ + successCallback?: (res: any) => void; + /** 失败的回调 */ + failCallback?: (res: any) => void; + /** 获取上传进度 */ + onProgress?: (percentage: number, file: File, i: number) => void; + /** 是否多个文件同时上传*/ + multiple?: boolean; + /** 上传给服务器的文件名 */ + filename?: (f: File, i: number) => string; + /** 如果返回promise,需要提供file,否则同步需要返回boolean,如果为false,则不发送*/ + beforeUpload?: (f: File, i: number) => boolean | Promise; + /** 默认展示的文件及进度,只有type为progress有效 */ + defaultProgressBar?: ProgressBar[]; + /** 删除文件列表时的回调,只有type为progress或者img有效 */ + onRemoveCallback?: (f: ProgressBar) => void; + /** 自定义删除行为,只有type为progress或者img有效*/ + customRemove?: ( + file: ProgressBar, + setFlist: React.Dispatch> + ) => void; + /** 携带cookie?*/ + withCredentials?: boolean; + /** 设置请求头*/ + headers?: { [key: string]: any }; + /** input的accept属性*/ + accept?: string; + /** 自定义展示用户的选择,只在confirm为true有效*/ + customShowChoose?: (item: File) => ReactNode; +} + +export interface ProgressBar { + filename: string; + percent: number; + status: "ready" | "success" | "failed" | "upload"; + uid: string; + size: number; + raw: File | null; + cancel?: CancelTokenSource; + img?: string | ArrayBuffer | null; +} + +export const UpdateFilist = ( + setFile: React.Dispatch>, + _file: ProgressBar, + uploadPartial: Partial +) => { + setFile((prevList) => { + if (prevList) { + return prevList.map((v) => { + if (v.uid === _file.uid) { + return { ...v, ...uploadPartial }; + } else { + return v; + } + }); + } else { + return prevList; + } + }); +}; + +const PostFile = ( + url: string, + formData: FormData, + onProgress: ((percentage: number, file: File, i: number) => void) | undefined, + f: File, + i: number, + successCallback: ((res: any) => void) | undefined, + failCallback: ((res: any) => void) | undefined, + setFile: React.Dispatch>, + headers: { [key: string]: any } | undefined, + withCredentials: boolean | undefined +) => { + const source = axios.CancelToken.source(); + const _file: ProgressBar = { + filename: f.name, + percent: 0, + status: "ready", + uid: Date.now() + "upload", + size: f.size, + raw: f, + cancel: source + }; + setFile((prev) => { + return [_file, ...prev]; + }); + axios + .post(url, formData, { + headers: { + "Content-Type": "multipart/form-data", + ...headers + }, + withCredentials, + cancelToken: source.token, + onUploadProgress: (e) => { + let percentage = Math.round((e.loaded * 100) / e.total) || 0; + UpdateFilist(setFile, _file, { status: "upload", percent: percentage }); + if (onProgress) { + onProgress(percentage, f, i); + } + } + }) + .then((res) => { + UpdateFilist(setFile, _file, { status: "success", percent: 100 }); + if (successCallback) { + successCallback(res); + } + }) + .catch((r) => { + UpdateFilist(setFile, _file, { status: "failed", percent: 0 }); + if (failCallback) { + failCallback(r); + } + }); +}; + +function Upload(props: UploadProps) { + const { + validate, + submitBtn, + customSubmit, + url, + successCallback, + failCallback, + multiple, + onProgress, + filename, + beforeUpload, + type, + defaultProgressBar, + confirm, + uploadBtnAtr, + uploadBtn, + onRemoveCallback, + headers, + withCredentials, + accept, + customShowChoose, + customRemove, + uploadNumber + } = props; + const [file, setFile] = useState(null); + const [flist, setFlist] = useState(defaultProgressBar || []); + const handleChange = (e: React.ChangeEvent) => { + if (e.target && e.target.files && e.target.files.length > 0) { + if (validate) { + if (validate(e)) { + setFile(e.target.files); + if (!confirm) handleSubmit(e.target.files); + } else { + setFile(null); + } + } else { + setFile(e.target.files); + if (!confirm) handleSubmit(e.target.files); + } + } else { + setFile(null); + } + }; + const handleSubmit = useCallback( + (files: FileList | null) => { + let resFiles; + files ? (resFiles = files) : (resFiles = file); + if (customSubmit) { + customSubmit(resFiles); + } else { + if (resFiles) { + let postFile = Array.from(resFiles); + postFile.forEach((f, i) => { + const formData = new FormData(); + const fname = filename ? filename(f, i) : f.name; + formData.append(fname, f); + if (beforeUpload) { + const res = beforeUpload(f, i); + if (res && res instanceof Promise) { + res.then((fi) => { + PostFile( + url!, + formData, + onProgress, + fi, + i, + successCallback, + failCallback, + setFlist, + headers, + withCredentials + ); + }); + } else { + if (res) { + PostFile( + url!, + formData, + onProgress, + f, + i, + successCallback, + failCallback, + setFlist, + headers, + withCredentials + ); + } + } + } else { + PostFile( + url!, + formData, + onProgress, + f, + i, + successCallback, + failCallback, + setFlist, + headers, + withCredentials + ); + } + }); + } + } + }, + [ + beforeUpload, + customSubmit, + failCallback, + file, + filename, + headers, + onProgress, + successCallback, + url, + withCredentials + ] + ); + const inputRef = useRef(null); + const buttonclick = () => { + if (inputRef.current) { + inputRef.current.click(); + } + }; + const handleRemove = useCallback( + (file: ProgressBar) => { + if (customRemove) { + customRemove(file, setFlist); + } else { + setFlist((prev) => { + return prev.filter((item) => { + if (item.uid === file.uid && item.status === "upload" && item.cancel) { + item.cancel.cancel(); + } + return item.uid !== file.uid; + }); + }); + } + + if (onRemoveCallback) { + onRemoveCallback(file); + } + }, + [customRemove, onRemoveCallback] + ); + return ( +
+ {!(uploadNumber !== 0 && uploadNumber! <= flist.length) && ( +
+ + +
+ )} + {confirm && !(uploadNumber !== 0 && uploadNumber! <= flist.length) && ( +
+
+ {file && + Array.from(file).map((item, index) => { + if (customShowChoose) { + return customShowChoose(item); + } else { + return ( + + ); + } + })} +
+
handleSubmit(null)}> + {submitBtn ? submitBtn : } +
+
+ )} + {type === "img" && ( + + )} + {type === "progress" && } +
+ ); +} + +Upload.defaultProps = { + url: "http://localhost:6699/user/uploadAvatar", + confirm: false, + type: "default", + uploadBtn: "Upload", + uploadNumber: 0 +}; + +export default Upload; diff --git a/src/components/Upload/uploadlist.tsx b/src/components/Upload/uploadlist.tsx new file mode 100644 index 0000000..4724fcb --- /dev/null +++ b/src/components/Upload/uploadlist.tsx @@ -0,0 +1,97 @@ +import React, { useMemo, memo } from "react"; +import Icon from "../Icon"; +import { ProgressBar } from "./upload"; +import Progress from "../Progress"; +import Alert from "../Alert"; +import Avatar from "../Avatar"; +import { UpdateFilist } from "./upload"; + +interface UploadListProps { + flist: ProgressBar[]; + onRemove: (item: ProgressBar) => void; +} + +function UploadList(props: UploadListProps) { + const { flist, onRemove } = props; + return ( +
    + {flist.map((item) => { + return ( +
  • + } + title={item.filename} + close={true} + initiativeCloseCallback={() => onRemove(item)} + > + {(item.status === "upload" || item.status === "ready") && ( + + )} +
  • + ); + })} +
+ ); +} +const MemoUploadList = memo(UploadList); + +export default MemoUploadList; + +interface imageListProps extends UploadListProps { + setFlist: React.Dispatch>; +} + +export function ImageList(props: imageListProps) { + const { flist, onRemove, setFlist } = props; + useMemo(() => { + if (flist) { + flist.forEach((item) => { + if (item.raw && !item.img) { + const reader = new FileReader(); + reader.addEventListener("load", () => { + UpdateFilist(setFlist, item, { + img: reader.result || "error" + }); + }); + reader.readAsDataURL(item.raw); + } + }); + } + }, [flist, setFlist]); + return ( +
+ {flist.map((item) => { + return ( + { + onRemove(item); + }} + > + + {item.status === "success" && ( + + )} + {(item.status === "upload" || item.status === "ready") && ( + + + + )} + {item.status === "failed" && ( + + + + )} + + + ); + })} +
+ ); +} +export const MemoImageList = memo(ImageList); diff --git a/src/index.tsx b/src/index.tsx index 826dc88..01db6e4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -27,6 +27,7 @@ export { default as Layout } from "./components/Layout"; export { default as Divider } from "./components/Divider"; export { default as Row } from "./components/Grid"; export { Col } from "./components/Grid"; +export { default as Upload } from "./components/Upload"; export { default as useClickOutside } from "./hooks/useClickOutside"; export { default as useDebounce } from "./hooks/useDebounce"; diff --git a/src/styles/index.scss b/src/styles/index.scss index 8accaa1..07543d5 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -80,4 +80,7 @@ @import "../components/Divider/style"; //grid -@import "../components/Grid/style"; \ No newline at end of file +@import "../components/Grid/style"; + +//upload +@import "../components/Upload/style"; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index f31b251..ee00ebb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3636,6 +3636,13 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== +axios@^0.19.2: + version "0.19.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" + integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== + dependencies: + follow-redirects "1.5.10" + axobject-query@^2.0.2: version "2.1.2" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.1.2.tgz#2bdffc0371e643e5f03ba99065d5179b9ca79799" @@ -5613,6 +5620,13 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" +debug@=3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + debug@^3.0.0, debug@^3.1.1, debug@^3.2.5: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" @@ -6937,6 +6951,13 @@ focus-lock@^0.6.7: resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-0.6.8.tgz#61985fadfa92f02f2ee1d90bc738efaf7f3c9f46" integrity sha512-vkHTluRCoq9FcsrldC0ulQHiyBYgVJB2CX53I8r0nTC6KnEij7Of0jpBspjt3/CuNb6fyoj3aOh9J2HgQUM0og== +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + follow-redirects@^1.0.0: version "1.11.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.11.0.tgz#afa14f08ba12a52963140fe43212658897bc0ecb"