diff --git a/example/app.json b/example/app.json index d2af4ef2..7e6a7040 100644 --- a/example/app.json +++ b/example/app.json @@ -37,6 +37,7 @@ "pages/checkbox/index", "pages/goods-action/index", "pages/swipe-cell/index", + "pages/uploader/index", "pages/datetime-picker/index", "pages/rate/index", "pages/collapse/index", @@ -92,6 +93,7 @@ "van-sticky": "./dist/sticky/index", "van-submit-bar": "./dist/submit-bar/index", "van-swipe-cell": "./dist/swipe-cell/index", + "van-uploader": "./dist/uploader/index", "van-switch": "./dist/switch/index", "van-tab": "./dist/tab/index", "van-tabs": "./dist/tabs/index", diff --git a/example/config.js b/example/config.js index e6efe99d..68487ff9 100644 --- a/example/config.js +++ b/example/config.js @@ -76,6 +76,10 @@ export default [ { path: '/switch', title: 'Switch 开关' + }, + { + path: '/uploader', + title: 'Uploader 文件上传' } ] }, diff --git a/example/pages/uploader/index.js b/example/pages/uploader/index.js new file mode 100644 index 00000000..10c015d0 --- /dev/null +++ b/example/pages/uploader/index.js @@ -0,0 +1,44 @@ +import Page from '../../common/page'; + +Page({ + data: { + fileList1: [], + fileList2: [ + { url: 'https://img.yzcdn.cn/vant/cat.jpeg', name: '图片1' }, + { url: 'http://iph.href.lu/60x60?text=default', name: '图片2', isImage: true } + ], + fileList3: [], + fileList4: [], + fileList5: [] + }, + + beforeRead(event) { + const { file, callback = () => {} } = event.detail; + if (file.path.indexOf('jpeg') < 0) { + wx.showToast({ title: '请选择jpg图片上传', icon: 'none' }); + callback(false); + return; + } + callback(true); + }, + + afterRead(event) { + const { file, name } = event.detail; + const fileList = this.data[`fileList${name}`]; + + this.setData({ [`fileList${name}`]: fileList.concat(file) }); + }, + + oversize() { + wx.showToast({ title: '文件超出大小限制', icon: 'none' }); + }, + + delete(event) { + const { index, name } = event.detail; + const fileList = this.data[`fileList${name}`]; + fileList.splice(index, 1); + this.setData({ [`fileList${name}`]: fileList }); + }, + + clickPreview() {} +}); diff --git a/example/pages/uploader/index.json b/example/pages/uploader/index.json new file mode 100644 index 00000000..c142147a --- /dev/null +++ b/example/pages/uploader/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "Uploader 文件上传" +} diff --git a/example/pages/uploader/index.wxml b/example/pages/uploader/index.wxml new file mode 100644 index 00000000..81651c3c --- /dev/null +++ b/example/pages/uploader/index.wxml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + 上传图片 + + + + + + diff --git a/example/pages/uploader/index.wxss b/example/pages/uploader/index.wxss new file mode 100644 index 00000000..10416bfb --- /dev/null +++ b/example/pages/uploader/index.wxss @@ -0,0 +1 @@ +/* pages/tag/index.wxss */ \ No newline at end of file diff --git a/packages/common/style/var.less b/packages/common/style/var.less index edaf39f1..c3f7e8e4 100644 --- a/packages/common/style/var.less +++ b/packages/common/style/var.less @@ -279,6 +279,22 @@ @divider-content-left-width: 10%; @divider-content-right-width: 10%; +// Uploader +@uploader-size: 80px; +@uploader-icon-size: 24px; +@uploader-icon-color: @gray-dark; +@uploader-text-color: @gray-dark; +@uploader-text-font-size: @font-size-sm; +@uploader-upload-border-color: @gray-light; +@uploader-upload-background-color: @white; +@uploader-delete-color: @white; +@uploader-delete-background-color: rgba(0, 0, 0, .45); +@uploader-file-background-color: @background-color; +@uploader-file-icon-size: 20px; +@uploader-file-icon-color: @gray-darker; +@uploader-file-name-font-size: @font-size-sm; +@uploader-file-name-text-color: @gray-darker; + // DropdownMenu @dropdown-menu-height: 50px; @dropdown-menu-background-color: @white; diff --git a/packages/uploader/README.md b/packages/uploader/README.md new file mode 100644 index 00000000..56c5983e --- /dev/null +++ b/packages/uploader/README.md @@ -0,0 +1,152 @@ +# Upload 上传文件 + +### 引入 + +在`app.json`或`index.json`中引入组件,默认为`ES6`版本,`ES5`引入方式参见[快速上手](#/quickstart) + +```json +"usingComponents": { + "van-uploader": "path/to/vant-weapp/dist/uploader/index" +} +``` + +## 代码演示 + +### 基础用法 + +文件上传完毕后会触发`after-read`回调函数,获取到对应的文件的临时地址,然后再使用`wx.uploadFile`将图片上传到远程服务器上 + +#### wxml 示例 + +```html + +``` + +#### js 示例 + +```js +{ + data: { + fileList: [] + }, + methods: { + afterRead(event) { + const { file } = event.detail; + // 当设置 mutiple 为 true 是 file 是一个数组,mutiple 默认为 false,file 是一个对象 + wx.uploadFile({ + url: 'https://example.weixin.qq.com/upload', //仅为示例,非真实的接口地址 + filePath: file.path, + name: 'file', + formData: { 'user': 'test' }, + success (res){ + // 上传完成需要更新fileList + const { fileList = [] } = this.data; + fileList.push({ ...file, url: res.data }); + this.setData({ fileList }); + } + }); + } + } +} +``` + +### 图片预览 + +通过向组件传入`file-list`属性,可以绑定已经上传的图片列表,并展示图片列表的预览图 + +```html + +``` + +```js +{ + data: { + fileList: [ + { url: 'https://img.yzcdn.cn/vant/cat.jpeg', name: '图片1' }, + // Uploader 根据文件后缀来判断是否为图片文件 + // 如果图片 URL 中不包含类型信息,可以添加 isImage 标记来声明 + { url: 'http://iph.href.lu/60x60?text=default', name: '图片2', isImage: true } + ] + } +} +``` + +### 限制上传数量 + +通过`max-count`属性可以限制上传文件的数量,上传数量达到限制后,会自动隐藏上传区域 + +```html + +``` + +### 自定义上传样式 + +将`use-slot`属性设置为`true`,通过插槽可以自定义上传区域的样式 + +```html + + 上传图片 + +``` + +### 上传前校验 + +将`use-before-read`属性设置为`true`,然后绑定 `before-read` 事件可以在上传前进行校验,调用 `callback` 方法传入 `true` 表示校验通过,传入 `false` 表示校验失败。 + +```html + +``` + +```js +{ + data: { + fileList: [] + }, + + methods: { + beforeRead(event) { + const { file, callback = () => {} } = event.detail; + console.log('before上传', file); + if (file.type !== 'image') { + callback(false); + return; + } + callback(true); + } + } +} +``` + +### Props + +| 参数 | 说明 | 类型 | 默认值 | 版本 | +|-----------|-----------|-----------|-----------|-----------| +| name | 标识符,可以在回调函数的第二项参数中获取 | _string \| number_ | - | +| accept | 接受的文件类型, 可选值为`all` `image` `file` | _string_ | `image` | +| preview-size | 预览图和上传区域的尺寸,默认单位为`px` | _string \| number_ | `80px` | +| preview-image | 是否在上传完成后展示预览图 | _boolean_ | `true` | +| preview-full-image | 是否在点击预览图后展示全屏图片预览 | _boolean_ | `true` | +| multiple | 是否开启图片多选,部分安卓机型不支持 | _boolean_ | `false` | +| disabled | 是否禁用文件上传 | _boolean_ | `false` | +| capture | 图片选取模式,当`accept`为`image`类型时设置`capture`可选值为`camera`可以直接调起摄像头 | _string \| Array_ | `['album', 'camera']` | +| disabled | 是否禁用文件上传 | _boolean_ | `false` | +| max-size | 文件大小限制,单位为`byte` | _number_ | - | +| max-count | 文件上传数量限制 | _number_ | - | +| upload-text | 上传区域文字提示 | _string_ | - | +| image-fit | 预览图裁剪模式,可选值参考小程序`image`组件的`mode`属性 | _string_ | `scaleToFill` | + +### Slot + +| 名称 | 说明 | +| ---- | -------------------------------- | +| - | 自定义上传样式,用法见上面的例子 | + +### Event + +| 事件名 | 说明 | 参数 | +| ------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| bind:before-read | 文件读取前的回调函数,返回 `false` 可终止文件读取,绑定事件的同时需要将`use-before-read`属性设置为`true` | `event.detail.file`: 当前读取的文件,`event.detail.callback`: 回调函数,调用`callback(false)`终止文件读取 | +| bind:after-read | 文件读取完成后的回调函数 | `event.detail.file`: 当前读取的文件 | +| bind:oversize | 文件超出大小限制的回调函数 | - | +| bind:click-preview | 点击预览图片的回调函数 | `event.detail.index`: 点击图片的序号值 | +| bind:delete | 删除图片的回调函数 | `event.detail.index`: 删除图片的序号值 | diff --git a/packages/uploader/index.json b/packages/uploader/index.json new file mode 100644 index 00000000..0a336c08 --- /dev/null +++ b/packages/uploader/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "van-icon": "../icon/index" + } +} diff --git a/packages/uploader/index.less b/packages/uploader/index.less new file mode 100644 index 00000000..159ebbcf --- /dev/null +++ b/packages/uploader/index.less @@ -0,0 +1,91 @@ +@import '../common/style/var.less'; + +.van-uploader { + position: relative; + display: inline-block; + + &__wrapper { + display: flex; + flex-wrap: wrap; + } + + &__upload { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: @uploader-size; + height: @uploader-size; + margin: 0 @padding-xs @padding-xs 0; + background-color: @uploader-upload-background-color; + border: 1px dashed @uploader-upload-border-color; + + &-icon { + display: inline-block; + width: 24px; + height: 24px; + color: @uploader-icon-color; + font-size: @uploader-icon-size; + } + + &-text { + margin-top: @padding-xs; + color: @uploader-text-color; + font-size: @uploader-text-font-size; + } + } + + &__preview { + position: relative; + margin: 0 @padding-xs @padding-xs 0; + + &-image { + display: block; + width: @uploader-size; + height: @uploader-size; + } + + &-delete { + position: absolute; + right: 0; + bottom: 0; + display: inline-block; + width: 18px; + height: 18px; + padding: 1px; + color: @uploader-delete-color; + background-color: @uploader-delete-background-color; + text-align: center; + } + } + + &__file { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: @uploader-size; + height: @uploader-size; + background-color: @uploader-file-background-color; + + &-icon { + display: inline-block; + width: 20px; + height: 20px; + color: @uploader-file-icon-color; + font-size: @uploader-file-icon-size; + } + + &-name { + box-sizing: border-box; + width: 100%; + margin-top: @padding-xs; + padding: 0 5px; + color: @uploader-file-name-text-color; + font-size: @uploader-file-name-font-size; + text-align: center; + } + } +} diff --git a/packages/uploader/index.ts b/packages/uploader/index.ts new file mode 100644 index 00000000..e8018507 --- /dev/null +++ b/packages/uploader/index.ts @@ -0,0 +1,186 @@ +import { VantComponent } from '../common/component'; +import { isImageFile } from './utils'; +import { addUnit } from '../common/utils'; + +interface File { + path: string; // 上传临时地址 + url: string; // 上传临时地址 + size: number; // 上传大小 + name: string; // 上传文件名称,accept="image" 不存在 + type: string; // 上传类型,accept="image" 不存在 + time: number; // 上传时间,accept="image" 不存在 + image: boolean; // 是否为图片 +} + +VantComponent({ + props: { + disabled: Boolean, + uploadText: String, + previewSize: { + type: null, + value: 90, + observer: 'setComputedPreviewSize' + }, + name: { + type: [Number, String], + value: '' + }, + accept: { + type: String, + value: 'image' + }, + fileList: { + type: Array, + value: [], + observer: 'formatFileList' + }, + maxSize: { + type: Number, + value: Number.MAX_VALUE + }, + maxCount: { + type: Number, + value: 100 + }, + previewImage: { + type: Boolean, + value: true + }, + previewFullImage: { + type: Boolean, + value: true + }, + imageFit: { + type: String, + value: 'scaleToFill' + }, + useSlot: Boolean, + useBeforeRead: Boolean + }, + + data: { + lists: [], + computedPreviewSize: '', + isInCount: true + }, + + methods: { + formatFileList() { + const { fileList = [], maxCount } = this.data; + const lists = fileList.map(item => ({ + ...item, + isImage: + typeof item.isImage === 'undefined' + ? isImageFile(item) + : item.isImage + })); + this.setData({ lists, isInCount: lists.length < maxCount }); + }, + + setComputedPreviewSize(val) { + this.setData({ + computedPreviewSize: addUnit(val) + }); + }, + + startUpload() { + if (this.data.disabled) return; + const { + name = '', + capture = ['album', 'camera'], + maxCount = 100, + multiple = false, + maxSize, + accept, + lists, + useBeforeRead = false // 是否定义了 beforeRead + } = this.data; + + let chooseFile = null; + const newMaxCount = maxCount - lists.length; + // 设置为只选择图片的时候使用 chooseImage 来实现 + if (accept === 'image') { + chooseFile = new Promise((resolve, reject) => { + wx.chooseImage({ + count: multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1, // 最多可以选择的数量,如果不支持多选则数量为1 + sourceType: capture, // 选择图片的来源,相册还是相机 + success: res => { + resolve(res); + }, + fail: err => { + reject(err); + } + }); + }); + } else { + chooseFile = new Promise((resolve, reject) => { + wx.chooseMessageFile({ + count: multiple ? newMaxCount : 1, // 最多可以选择的数量,如果不支持多选则数量为1 + type: 'file', + success(res) { + resolve(res); + }, + fail: err => { + reject(err); + } + }); + }); + } + + chooseFile.then(res => { + const file: File | File[] = multiple ? res.tempFiles : res.tempFiles[0]; + + // 检查文件大小 + if (file instanceof Array) { + const sizeEnable = file.every(item => item.size <= maxSize); + if (!sizeEnable) { + this.$emit('oversize', { name }); + return; + } + } else if (file.size > maxSize) { + this.$emit('oversize', { name }); + return; + } + + // 触发上传之前的钩子函数 + if (useBeforeRead) { + this.$emit('before-read', { + file, + name, + callback: result => { + if (result) { + // 开始上传 + this.$emit('after-read', { file, name }); + } + } + }); + } else { + this.$emit('after-read', { file, name }); + } + }); + }, + + deleteItem(event) { + const { index } = event.currentTarget.dataset; + this.$emit('delete', { index, name: this.data.name }); + }, + + doPreviewImage(event) { + if (!this.data.previewFullImage) return; + const curUrl = event.currentTarget.dataset.url; + const images = this.data.lists + .filter(item => item.isImage) + .map(item => item.url || item.path); + + this.$emit('click-preview', { url: curUrl, name: this.data.name }); + + wx.previewImage({ + urls: images, + current: curUrl, + fail() { + wx.showToast({ title: '预览图片失败', icon: 'none' }); + } + }); + } + } +}); diff --git a/packages/uploader/index.wxml b/packages/uploader/index.wxml new file mode 100644 index 00000000..a57cfda2 --- /dev/null +++ b/packages/uploader/index.wxml @@ -0,0 +1,60 @@ + + + + + + + + {{ item.name || ('图片' + index) }} + + + {{ item.name || item.url || item.path }} + + + + + + + + + + + + + + + + {{ uploadText }} + + + + + diff --git a/packages/uploader/utils.ts b/packages/uploader/utils.ts new file mode 100644 index 00000000..52776021 --- /dev/null +++ b/packages/uploader/utils.ts @@ -0,0 +1,31 @@ +interface File { + path: string; // 上传临时地址 + url: string; // 上传临时地址 + size: number; // 上传大小 + name: string; // 上传文件名称,accept="image" 不存在 + type: string; // 上传类型,accept="image" 不存在 + time: number; // 上传时间,accept="image" 不存在 + image: boolean; // 是否为图片 +} + +const IMAGE_EXT = ['jpeg', 'jpg', 'gif', 'png', 'svg']; + +export function isImageUrl(url: string): boolean { + return IMAGE_EXT.some(ext => url.indexOf(`.${ext}`) !== -1); +} + +export function isImageFile(item: File): boolean { + if (item.type) { + return item.type.indexOf('image') === 0; + } + + if (item.path) { + return isImageUrl(item.path); + } + + if (item.url) { + return isImageUrl(item.url); + } + + return false; +}