From bfdfeaa65ae93fbd48612e42d5e971f2195db894 Mon Sep 17 00:00:00 2001 From: liuyijun <3476078473@qq.com> Date: Mon, 9 Aug 2021 18:19:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4uploader=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=AC=AC=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.json | 13 +- src/packages/avatar/avatar.scss | 4 + src/packages/avatar/demo.scss | 3 + src/packages/avatar/demo.tsx | 9 +- src/packages/uploader/demo.scss | 3 + src/packages/uploader/demo.tsx | 94 ++++++++ src/packages/uploader/doc.md | 133 +++++++++++ src/packages/uploader/index.ts | 2 + src/packages/uploader/upload.ts | 52 ++++ src/packages/uploader/uploader.scss | 71 ++++++ src/packages/uploader/uploader.tsx | 358 ++++++++++++++++++++++++++++ 11 files changed, 737 insertions(+), 5 deletions(-) create mode 100644 src/packages/avatar/demo.scss create mode 100644 src/packages/uploader/demo.scss create mode 100644 src/packages/uploader/demo.tsx create mode 100644 src/packages/uploader/doc.md create mode 100644 src/packages/uploader/index.ts create mode 100644 src/packages/uploader/upload.ts create mode 100644 src/packages/uploader/uploader.scss create mode 100644 src/packages/uploader/uploader.tsx diff --git a/src/config.json b/src/config.json index 01ba3d0..e36afda 100644 --- a/src/config.json +++ b/src/config.json @@ -170,7 +170,18 @@ }, { "name": "数据录入", - "packages": [] + "packages": [ + { + "version": "1.0.0", + "name": "Uploader", + "type": "component", + "cName": "上传", + "desc": "用于将本地的图片或文件上传至服务器。", + "sort": 1, + "show": true, + "author": "swag~jun" + } + ] }, { "name": "业务组件", diff --git a/src/packages/avatar/avatar.scss b/src/packages/avatar/avatar.scss index 26f4126..0199c64 100644 --- a/src/packages/avatar/avatar.scss +++ b/src/packages/avatar/avatar.scss @@ -13,6 +13,10 @@ left: 50%; transform: translate(-50%, -50%); } + .nut-icon__img { + width: 100%; + height: 100%; + } .text { display: inline-block; width: 100%; diff --git a/src/packages/avatar/demo.scss b/src/packages/avatar/demo.scss new file mode 100644 index 0000000..4f3962c --- /dev/null +++ b/src/packages/avatar/demo.scss @@ -0,0 +1,3 @@ +.demo-avatar { + color: #fff; +} diff --git a/src/packages/avatar/demo.tsx b/src/packages/avatar/demo.tsx index 8157511..cb604b1 100644 --- a/src/packages/avatar/demo.tsx +++ b/src/packages/avatar/demo.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Avatar } from './avatar' import Cell from '@/packages/cell' +import './demo.scss' const AvatarDemo = () => { const AvatarStyle = { @@ -29,16 +30,16 @@ const AvatarDemo = () => {

修改形状

- - + +

修改背景色

- +

修改背景图片

- +

可以修改头像的内容

diff --git a/src/packages/uploader/demo.scss b/src/packages/uploader/demo.scss new file mode 100644 index 0000000..3246a56 --- /dev/null +++ b/src/packages/uploader/demo.scss @@ -0,0 +1,3 @@ +.demo.bg-w { + background: #fff; +} diff --git a/src/packages/uploader/demo.tsx b/src/packages/uploader/demo.tsx new file mode 100644 index 0000000..32af7a6 --- /dev/null +++ b/src/packages/uploader/demo.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { Uploader, FileItem } from './uploader' +import Button from '@/packages/button' + +const UploaderDemo = () => { + const uploadUrl = 'https://my-json-server.typicode.com/linrufeng/demo/posts' + const formData = { + custom: 'test', + } + const fileToDataURL = (file: Blob): Promise => { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = (e) => resolve((e.target as FileReader).result) + reader.readAsDataURL(file) + }) + } + const dataURLToImage = (dataURL: string): Promise => { + return new Promise((resolve) => { + const img = new Image() + img.onload = () => resolve(img) + img.src = dataURL + }) + } + const canvastoFile = ( + canvas: HTMLCanvasElement, + type: string, + quality: number + ): Promise => { + return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), type, quality)) + } + const onOversize = (files: File[]) => { + console.log('oversize 触发 文件大小不能超过 50kb', files) + } + const onStart = () => { + console.log('start 触发') + } + const onDelete = (file: FileItem, fileList: FileItem[]) => { + console.log('delete 事件触发', file, fileList) + } + const beforeUpload = async (files: File[]) => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') as CanvasRenderingContext2D + const base64 = await fileToDataURL(files[0]) + const img = await dataURLToImage(base64) + canvas.width = img.width + canvas.height = img.height + context.clearRect(0, 0, img.width, img.height) + context.drawImage(img, 0, 0, img.width, img.height) + let blob = (await canvastoFile(canvas, 'image/jpeg', 0.5)) as Blob //quality:0.5可根据实际情况计算 + const f = await new File([blob], files[0].name, { type: files[0].type }) + return [f] + } + return ( + <> +
+

基础用法

+ +

自定义上传样式

+ + + +

直接调起摄像头(移动端生效)

+ +

上传状态

+ +

限制上传数量5个

+ +

限制上传大小(每个文件最大不超过 50kb)

+ +

限制上传大小(在beforeupload钩子中处理)

+ +

自定义数据 FormData 、 headers

+ +

禁用状态

+ +
+ + ) +} + +export default UploaderDemo diff --git a/src/packages/uploader/doc.md b/src/packages/uploader/doc.md new file mode 100644 index 0000000..59938e7 --- /dev/null +++ b/src/packages/uploader/doc.md @@ -0,0 +1,133 @@ +# Uploader 上传 + +### 介绍 + +用于将本地的图片或文件上传至服务器。 + +### 安装 + +``` javascript +import { Uploader } from '@nutui/nutui'; +``` + +## 代码示例 + +### 基本用法 + +``` tsx + +``` + + +### 自定义上传样式 + +``` tsx + + + +``` + +### 直接调起摄像头(移动端生效) + +``` tsx + +``` +### 限制上传数量5个 + +``` tsx + +``` +### 限制上传大小(每个文件最大不超过 50kb,也可以在beforeupload中自行处理) + +``` tsx + +``` + +``` javascript +const formData = { + custom: 'test' +}; +const onOversize = (files: File[]) => { + console.log('oversize 触发 文件大小不能超过 50kb', files); +}; +const beforeUpload = (files: File[]) => { + //自定义处理 + return files; +} +``` + +### 自定义 FormData headers + +``` tsx + +``` + +``` javascript +const formData = { + custom: 'test' +}; +const onOversize = (files: File[]) => { + console.log('oversize 触发 文件大小不能超过 50kb', files); +}; +const beforeUpload = (files: File[]) => { + //自定义处理 + return files; +} +``` + +### 禁用状态 + +``` tsx + +``` + +### Prop + +| 字段 | 说明 | 类型 | 默认值 | +|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------|------------------| +| name | `input` 标签 `name` 的名称,发到后台的文件参数名 | String | "file" | +| url | 上传服务器的接口地址 | String | - | +| isPreview | 是否上传成功后展示预览图 | Boolean | true | +| isDeletable | 是否展示删除按钮 | Boolean | true | +| method | 上传请求的 http method | String | "post" | +| capture | 图片[选取模式](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input#htmlattrdefcapture),直接调起摄像头 | String | false | +| maximize | 可以设定最大上传文件的大小(字节) | Number丨String | Number.MAX_VALUE | +| maximum | 文件上传数量限制 | Number丨String | 1 | +| clearInput | 是否需要清空`input`内容,设为`true`支持重复选择上传同一个文件 | Boolean | false | +| accept | 允许上传的文件类型,[详细说明](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/Input/file#%E9%99%90%E5%88%B6%E5%85%81%E8%AE%B8%E7%9A%84%E6%96%87%E4%BB%B6%E7%B1%BB%E5%9E%8B) | String | * | +| headers | 设置上传的请求头部 | Object | {} | +| data | 附加上传的信息 formData | Object | {} | +| uploadIcon | 上传区域[图标名称](#/zh-CN/icon)或图片链接 | String | "photograph" | +| xhrState | 接口响应的成功状态(status)值 | Number | 200 | +| withCredentials | 支持发送 cookie 凭证信息 | Boolean | fasle | +| multiple | 是否支持文件多选 | Boolean | fasle | +| disabled | 是否禁用文件上传 | Boolean | fasle | +| timeout | 超时时间,单位为毫秒 | Number丨String | 1000 * 30 | +| beforeUpload | 上传前的函数需要返回一个`Promise`对象 | Function | null | +| beforeDelete | 除文件时的回调,返回值为 false 时不移除。支持返回一个 `Promise` 对象,`Promise` 对象 resolve(false) 或 reject 时不移除 | Function(file): boolean 丨Promise | - | + + + +### FileItem + +| 名称 | 说明 | 默认值 | +|----------|---------------------------------------------------------|---------------------------------| +| status | 文件状态值,可选'ready,uploading,success,error,removed' | "ready" | +| uid | 文件的唯一标识 | new Date().getTime().toString() | +| name | 文件名称 | "" | +| url | 文件路径 | "" | +| type | 文件类型 | "image/jpeg" | +| formData | 上传所需的data | new FormData() | + +### Event + +| 名称 | 说明 | 回调参数 | +|----------|------------------------|----------------------| +| start | 文件上传开始 | options | +| progress | 文件上传的进度 | event,options | +| oversize | 文件大小超过限制时触发 | files | +| success | 上传成功 | responseText,options | +| failure | 上传失败 | responseText,options | +| change | 上传文件改变时的状态 | fileList,event | +| removeImage | 文件删除之前的状态 | files,fileList | + diff --git a/src/packages/uploader/index.ts b/src/packages/uploader/index.ts new file mode 100644 index 0000000..4885a19 --- /dev/null +++ b/src/packages/uploader/index.ts @@ -0,0 +1,2 @@ +import { Uploader } from './uploader' +export default Uploader diff --git a/src/packages/uploader/upload.ts b/src/packages/uploader/upload.ts new file mode 100644 index 0000000..abba12c --- /dev/null +++ b/src/packages/uploader/upload.ts @@ -0,0 +1,52 @@ +export class UploadOptions { + url = '' + formData?: FormData + method = 'post' + xhrState: string | number = 200 + timeout: number = 30 * 1000 + headers = {} + withCredentials = false + onStart?: Function + onProgress?: Function + onSuccess?: Function + onFailure?: Function +} +export class Upload { + options: UploadOptions + constructor(options: UploadOptions) { + this.options = options + } + upload() { + const options = this.options + const xhr = new XMLHttpRequest() + xhr.timeout = options.timeout + if (xhr.upload) { + xhr.upload.addEventListener( + 'progress', + (e: ProgressEvent) => { + options.onProgress?.(e, options) + }, + false + ) + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + if (xhr.status === options.xhrState) { + options.onSuccess?.(xhr.responseText, options) + } else { + options.onFailure?.(xhr.responseText, options) + } + } + } + xhr.withCredentials = options.withCredentials + xhr.open(options.method, options.url, true) + // headers + for (const [key, value] of Object.entries(options.headers)) { + xhr.setRequestHeader(key, value as string) + } + options.onStart?.(options) + xhr.send(options.formData) + } else { + console.warn('浏览器不支持 XMLHttpRequest') + } + } +} diff --git a/src/packages/uploader/uploader.scss b/src/packages/uploader/uploader.scss new file mode 100644 index 0000000..9aaecd0 --- /dev/null +++ b/src/packages/uploader/uploader.scss @@ -0,0 +1,71 @@ +.nut-uploader { + position: relative; + display: flex; + flex-wrap: wrap; + + &__slot { + position: relative; + } + + &__upload { + position: relative; + background: $uploader-background; + width: $uploader-width; + height: $uploader-height; + display: flex; + align-items: center; + justify-content: center; + } + + &__input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + cursor: pointer; + opacity: 0; + &:disabled { + cursor: not-allowed; + } + } + + &__preview { + width: $uploader-width; + height: $uploader-height; + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; + margin-bottom: 10px; + &-img { + position: relative; + width: 100%; + height: 100%; + .close { + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + } + .tips { + position: absolute; + bottom: 0; + left: 0; + right: 0; + text-align: center; + font-size: 12px; + color: $white; + height: 30px; + line-height: 30px; + text-align: c; + background: rgba(0, 0, 0, 0.54); + } + &__c { + height: 100%; + width: 100%; + } + } + } +} diff --git a/src/packages/uploader/uploader.tsx b/src/packages/uploader/uploader.tsx new file mode 100644 index 0000000..0cf5294 --- /dev/null +++ b/src/packages/uploader/uploader.tsx @@ -0,0 +1,358 @@ +import React, { useState, FunctionComponent } from 'react' +import Icon from '@/packages/icon' +import { Upload, UploadOptions } from './upload' +import bem from '@/utils/bem' +import './uploader.scss' + +export interface UploaderProps { + url: string + maximum: string | number + maximize: number + uploadIcon: string + name: string + accept: string + disabled: boolean + multiple: boolean + timeout: number + data: object + method: string + xhrState: number | string + headers: object + withCredentials: boolean + clearInput: boolean + isPreview: boolean + isDeletable: boolean + capture: boolean + start?: (option: UploadOptions) => void + removeImage?: (file: FileItem, fileList: FileItem[]) => void + success?: (param: { responseText: XMLHttpRequest['responseText']; option: UploadOptions }) => void + progress?: (param: { e: ProgressEvent; option: UploadOptions }) => void + failure?: (param: { responseText: XMLHttpRequest['responseText']; option: UploadOptions }) => void + update?: (fileList: any[]) => void + oversize?: (file: File[]) => void + change?: (param: { fileList: any[]; event: React.ChangeEvent }) => void + beforeUpload?: (file: File[]) => Promise + beforeDelete?: (file: FileItem, files: FileItem[]) => boolean +} +export type FileItemStatus = 'ready' | 'uploading' | 'success' | 'error' | 'removed' + +const defaultProps: UploaderProps = { + url: '', + maximum: 1, + uploadIcon: 'photograph', + name: 'file', + accept: '*', + disabled: false, + multiple: false, + maximize: Number.MAX_VALUE, + data: {}, + headers: {}, + method: 'post', + xhrState: 200, + timeout: 1000 * 30, + withCredentials: false, + clearInput: false, + isPreview: true, + isDeletable: true, + capture: false, + beforeDelete: (file: FileItem, files: FileItem[]) => { + return true + }, +} +export class FileItem { + status: FileItemStatus = 'ready' + uid: string = new Date().getTime().toString() + name?: string + url?: string + type?: string + formData: FormData = new FormData() +} +export const Uploader: FunctionComponent< + Partial & React.HTMLAttributes +> = (props) => { + const { + children, + uploadIcon, + name, + accept, + disabled, + multiple, + url, + headers, + timeout, + method, + xhrState, + withCredentials, + data, + isPreview, + isDeletable, + maximum, + capture, + maximize, + start, + removeImage, + progress, + success, + update, + failure, + beforeDelete, + } = { ...defaultProps, ...props } + const [fileList, setFileList] = useState([]) + const b = bem('uploader') + + const clearInput = (el: HTMLInputElement) => { + el.value = '' + } + + const executeUpload = (fileItem: FileItem) => { + const uploadOption = new UploadOptions() + uploadOption.url = url + for (const [key, value] of Object.entries(data)) { + fileItem.formData.append(key, value) + } + uploadOption.formData = fileItem.formData + uploadOption.timeout = timeout * 1 + uploadOption.method = method + uploadOption.xhrState = xhrState + uploadOption.headers = headers + uploadOption.withCredentials = withCredentials + uploadOption.onStart = (option: UploadOptions) => { + setFileList((fileList: FileItem[]) => { + fileList.map((item) => { + if (item.uid === fileItem.uid) { + item.status = 'ready' + } + }) + return [...fileList] + }) + start && start(option) + } + uploadOption.onProgress = ( + e: ProgressEvent, + option: UploadOptions + ) => { + console.log('progress', e, option) + setFileList((fileList: FileItem[]) => { + fileList.map((item) => { + if (item.uid === fileItem.uid) { + item.status = 'uploading' + } + }) + return [...fileList] + }) + progress && progress({ e, option }) + } + uploadOption.onSuccess = ( + responseText: XMLHttpRequest['responseText'], + option: UploadOptions + ) => { + setFileList((fileList: FileItem[]) => { + update && update(fileList) + fileList.map((item) => { + if (item.uid === fileItem.uid) { + item.status = 'success' + } + }) + return [...fileList] + }) + success && + success({ + responseText, + option, + }) + } + uploadOption.onFailure = ( + responseText: XMLHttpRequest['responseText'], + option: UploadOptions + ) => { + setFileList((fileList: FileItem[]) => { + fileList.map((item) => { + if (item.uid === fileItem.uid) { + item.status = 'error' + } + }) + return [...fileList] + }) + failure && + failure({ + responseText, + option, + }) + } + new Upload(uploadOption).upload() + } + + const readFile = (files: File[]) => { + files.forEach((file: File) => { + const formData = new FormData() + formData.append(name, file) + const fileItem = new FileItem() + fileItem.name = file.name + fileItem.status = 'uploading' + fileItem.type = file.type + fileItem.formData = formData + executeUpload(fileItem) + + if (isPreview && file.type.includes('image')) { + const reader = new FileReader() + reader.onload = (event: ProgressEvent) => { + fileItem.url = (event.target as FileReader).result as string + fileList.push(fileItem) + setFileList([...fileList]) + } + reader.readAsDataURL(file) + } else { + fileList.push(fileItem) + setFileList([...fileList]) + } + }) + } + + const filterFiles = (files: File[]) => { + const maximum = (props.maximum as number) * 1 + const oversizes = new Array() + const filterFile = files.filter((file: File) => { + if (file.size > maximize) { + oversizes.push(file) + return false + } else { + return true + } + }) + if (oversizes.length) { + props.oversize && props.oversize(files) + } + if (filterFile.length > maximum) { + filterFile.splice(maximum - 1, filterFile.length - maximum) + } + return filterFile + } + + const onDelete = (file: FileItem, index: number) => { + if (beforeDelete && beforeDelete(file, fileList)) { + fileList.splice(index, 1) + removeImage && removeImage(file, fileList) + setFileList([...fileList]) + } else { + console.log('用户阻止了删除!') + } + } + + const fileChange = (event: React.ChangeEvent) => { + if (disabled) { + return + } + const $el = event.target + let { files } = $el + + if (props.beforeUpload) { + props.beforeUpload(new Array().slice.call(files)).then((f: Array) => { + const _files: File[] = filterFiles(new Array().slice.call(f)) + readFile(_files) + }) + } else { + const _files = filterFiles(new Array().slice.call(files)) + readFile(_files) + } + + props.change && props.change({ fileList, event }) + + if (props.clearInput) { + clearInput($el) + } + } + + return ( +
+ {children ? ( +
+ { + <> + {children} + {maximum > fileList.length && ( + <> + {capture ? ( + + ) : ( + + )} + + )} + + } +
+ ) : ( + <> + {fileList.length !== 0 && + fileList.map((item: any, index: number) => { + return ( +
+
+ {isDeletable && ( + onDelete(item, index)} + /> + )} + {item.type.includes('image') && item.url && ( + + )} + {item.status !== 'success' &&
{item.status}
} +
+
+ ) + })} + {maximum > fileList.length && ( +
+ + {capture ? ( + + ) : ( + + )} +
+ )} + + )} +
+ ) +} + +Uploader.defaultProps = defaultProps +Uploader.displayName = 'NutUploader' -- GitLab