UNPKG

veui

Version:

Baidu Enterprise UI for Vue.js.

640 lines (557 loc) 16.6 kB
import { parse as parseByte } from 'bytes' import { keys, some, compact, last, pick, pickBy, includes, endsWith, find, uniqueId, entries, identity, omit, isEmpty, forEach, isUndefined, isPlainObject } from 'lodash' import { createFileList } from '../../utils/file' export const STATUS = { EMPTY: 'empty', PENDING: 'pending', UPLOADING: 'uploading', SUCCESS: 'success', FAILURE: 'failure' } export const ORDERS = { PREPEND: 'prepend', APPEND: 'append' } export const PickerPosition = { BEFORE: 'before', AFTER: 'after', NONE: 'none', TOP: 'top' } export const HelpPosition = { SIDE: 'side', BOTTOM: 'bottom' } export const PUBLIC_FILE_PROPS = [ 'name', 'type', 'poster', 'src', 'alt', 'size' ] export const ERRORS = { TYPE_INVALID: 'type', SIZE_INVALID: 'size', TOO_MANY_FILES: 'count', CUSTOM_INVALID: 'custom' } export function pickerOrderMatch (pickerPos, order) { return ( (pickerPos === PickerPosition.BEFORE && order === ORDERS.PREPEND) || (pickerPos === PickerPosition.AFTER && order === ORDERS.APPEND) ) } export function getFileMediaType (file, mediaExtensions) { return find(keys(mediaExtensions), function (type) { return some(mediaExtensions[type], (ext) => endsWith(file.name, `.${ext}`)) }) } function getMediaExtensionsByType (type, mediaExtensions) { switch (type) { case 'image': case 'video': return mediaExtensions[type] case 'media': return mediaExtensions.image.concat(mediaExtensions.video) } return [] } function getValidateFileType ({ accept, extensions, type }) { const mediaExtensions = getMediaExtensionsByType(type, extensions) return function validateFileType (file) { if (!accept) { return true } let ext = last(file.name.split('.')).toLowerCase() return accept.split(/,\s*/).some((item) => { let acceptExtention = last(item.split(/[./]/)).toLowerCase() if ( acceptExtention === ext || // 对于类似'application/msword'这样的mimetype与扩展名对不上的情形跳过校验 (acceptExtention !== '*' && includes(item, '/')) ) { return true } return ( acceptExtention === '*' && includes(item, '/') && (includes(mediaExtensions, ext) || !mediaExtensions.length) ) }) } } function getValidateFileSize ({ maxSize }) { maxSize = maxSize && parseByte(maxSize) return function validateFileSize (file) { return !maxSize || file.size <= maxSize } } function getCustomValidate ({ validator }) { return function customValidate (file) { return validator ? validator(file) : { valid: true } } } export function getValidateFile (options, ctx) { const validators = [ [ getValidateFileType(options), ERRORS.TYPE_INVALID, (file) => file.name, identity, () => ctx.t('fileTypeInvalid') ], [ getValidateFileSize(options), ERRORS.SIZE_INVALID, (file) => file.size, identity, () => ctx.t('fileSizeInvalid') ], [ getCustomValidate(options), ERRORS.CUSTOM_INVALID, identity, (result) => result.valid, (result) => result.message ] ].map(function ([validate, type, getValue, getValid, getMessage]) { return function (file) { return Promise.resolve(validate(file)).then(function (result) { if (getValid(result)) { return } return { type, preview: isPlainObject(result) ? result.preview : false, value: getValue(file), message: getMessage(result) } }) } }) return function validateFile (file) { return Promise.all(validators.map((validate) => validate(file))).then( function (errors) { errors = errors.filter(identity) return errors.length ? errors : null } ) } } export class UploaderFile { constructor (file, { keyField, extensions }) { this.keyField = keyField this.key = uniqueId('veuiUploaderFile') this.status = STATUS.PENDING this.message = undefined this._preview = false this._file = file this.meta = file ? { ...pick(file, ['name', 'size']), type: getFileMediaType(file, extensions) } : {} this.loaded = undefined this.total = undefined } get isPending () { return this.status === STATUS.PENDING } get isFailure () { return this.status === STATUS.FAILURE } set isFailure (val) { if (val) { if (this.isUploading) { this.cancel() } this.status = STATUS.FAILURE } } get isUploading () { return this.status === STATUS.UPLOADING } set isUploading (val) { if (val) { this.status = STATUS.UPLOADING } } get isSuccess () { return this.status === STATUS.SUCCESS } set isSuccess (val) { if (val) { if (this.isUploading) { this.cancel() } this.status = STATUS.SUCCESS } } get name () { // TODO: eslint 该升级了! // return this.result?.name ?? this.meta.name return (this.result && this.result.name) || this.meta.name } get size () { return (this.result && this.result.size) || this.meta.size } get src () { return (this.result && this.result.src) || this.meta.src } get type () { return (this.result && this.result.type) || this.meta.type } set type (val) { this.meta.type = val } get poster () { return (this.result && this.result.poster) || this.meta.poster } get alt () { return (this.result && this.result.alt) || this.meta.alt } get native () { return this._file } get preview () { return (this.result && this.result.preview) || this._preview } set preview (val) { this._preview = val } getValue () { return pickBy( { ...omit(this.result, PUBLIC_FILE_PROPS), ...this.extra, ...pick(this, PUBLIC_FILE_PROPS) }, (val) => !isUndefined(val) ) } get value () { const value = this.getValue() // 如果用户没有设置 keyField 那么自动加上初始的 // 如果用户设置了,那么保留用户设置的 return value[this.keyField] != null ? value : { ...value, [this.keyField]: this.key } } // key 是用来渲染的,v-for 里面保持稳定, 对于新上传的文件,始终是自动生成的 // valueKey 是 value[keyField] get valueKey () { return typeof this._valueKey === 'undefined' ? this.key : this._valueKey } set value (val) { this.isSuccess = true const nextValueKey = val[this.keyField] // 如果设置的 val 中 keyField 和之前的不一样,那么也不知道是否是同一个,直接保留一个 key 吧。 if (nextValueKey && nextValueKey !== this.valueKey) { this._valueKey = undefined this.key = nextValueKey } this.result = pick(val, PUBLIC_FILE_PROPS) this.extra = omit(val, [this.keyField, 'status', ...PUBLIC_FILE_PROPS]) } static fromValue (val, options) { let file = new UploaderFile(undefined, options) file.value = val return file } validate (options, context) { const validateFile = getValidateFile(options, context) return validateFile(this._file) } upload (context, callbacks) { // 因为 validate 是异步的,可能存在在 validate 过程中组件 destroy 调用 cancel 的情况,这里简单拦截下 if (this.isCancelled) { throw createCancelError('upload cancelled') } let local const promise = new Promise(function (resolve, reject) { local = { onload: resolve, onerror: reject } }) const listeners = ['onload', 'onerror', 'onprogress', 'oncancel'].reduce( (ret, key) => { ret[key] = (...args) => forEach( compact([this[key].bind(this), callbacks[key], local[key]]), (execute) => { try { execute(...args) } catch (err) { if (key === 'onload') { // 在 onload 内抛异常就认为出错了,中止onload执行,执行 onerror 逻辑 listeners.onerror(err) return false } } } ) return ret }, {} ) // uploadRequest is provided on vm to reduce repetitive creations since it only relates to props // otherwise getUploadRequest(options) every upload is called here this._cancel = context.uploadRequest(this._file, listeners) return promise } onload (data) { // 即使返回失败也将信息保留 this.result = omit(data, ['success', 'message']) if (!data.success) { this._valueKey = undefined throw new Error(data.message) } // 成功了,如果发现 value 中有 keyField 且和 this.key 不同,那么记录下 valueKey 和 key 不同 const valueKey = this.getValue()[this.keyField] if (valueKey != null && this.key !== valueKey) { this._valueKey = valueKey } // 上传完成,有了 src 的话,不需要再 hold 文件对象 if (this.src) { this._file = undefined } } onerror (err) { this.message = err.message } onprogress (evt) { this.loaded = evt.loaded this.total = evt.total } oncancel () { this.loaded = 0 } cancel () { this.isCancelled = true if (this.status === STATUS.UPLOADING && this._cancel) { this._cancel() this._cancel = undefined } } } export function getUploadRequest (options) { const { requestMode, action, upload } = options if (requestMode === 'xhr' && action) { return getXhrUploadRequest(options) } else if (requestMode === 'iframe' && action) { return getIframeUploadRequest(options) } else if (requestMode === 'custom' && upload) { return getCustomUploadRequest(options) } throw new Error( '[veui-uploader] `action` is required for `xhr` or `iframe` mode and `upload` is requried for `custom` mode.' ) } function getIframeUploadRequest (options) { const parseResponse = getResonpseParse(options) const { name, action, iframeMode, callbackNamespace, payload } = options function getForm () { const iframeId = uniqueId('veui-uploader-iframe') let form = document.createElement('form') form.method = 'POST' form.action = action form.target = iframeId form.enctype = 'multipart/form-data' let iframe = document.createElement('iframe') iframe.name = iframeId iframe.id = iframeId iframe.hidden = true document.body.appendChild(form) document.body.appendChild(iframe) function clean () { document.body.removeChild(form) document.body.removeChild(iframe) } return [form, iframe, clean] } function attachFileToForm (file, form) { let fileInput = document.createElement('input') fileInput.type = 'file' fileInput.hidden = true fileInput.name = name // 解耦了视图和上传逻辑,但是提交到 iframe 需要把 pickFile 选到的文件塞回 input // input.files setter支持 FileList,但是 FileList 没有 slice 或者构造函数来实现从多文件 FileList 得到单文件 FileList // (可以通过 DataTransferItemList.add() 来间接构造 FileList 但是 IE 11 不支持) // 所以上面 pickFile 逻辑里保证了 iframe 情况下只能选单文件,并把 FileList 关联到 File._rawFileList 上 // 这样这里就能从这个字段拿到原始的 FileList // 如果是调用 addFiles 插入的 File,requestMode=iframe 上传要兼容 IE 11的话,也要带上 _rawFileList fileInput.files = file._rawFileList || createFileList(file) form.appendChild(fileInput) } function attachObjectToForm (obj, form) { entries(obj).forEach(function ([key, val]) { let input = document.createElement('input') input.type = 'hidden' input.name = key input.value = val form.appendChild(input) }) } function bindPostMessageCallback (iframeId, ondata) { function callback (event) { if ( !event.source || !event.source.frameElement || event.source.frameElement.id !== iframeId ) { return } // 支持action为绝对路径或相对路径,ie9里的location没有origin let actionOrigin = /^https?:\/\//.test(action) ? action.match(/^https?:\/\/[^/]*/)[0] : location.origin || location.protocol + '//' + location.host if (actionOrigin === event.origin) { ondata(event.data) } } function cancel () { window.removeEventListener('message', callback) } window.addEventListener('message', callback) return cancel } function bindJsonpCallback (callbackFuncName, ondata) { if (!window[callbackNamespace]) { window[callbackNamespace] = {} } window[callbackNamespace][callbackFuncName] = ondata return function cancel () { delete window[callbackNamespace][callbackFuncName] if (isEmpty(window[callbackNamespace])) { delete window[callbackNamespace] } } } return function iframeUploadRequest (file, { onload, onerror, onprogress }) { const [form, iframe, cleanDom] = getForm() attachFileToForm(file, form) attachObjectToForm(payload, form) let removeBind if (iframeMode === 'postmessage') { removeBind = bindPostMessageCallback(iframe.id, ondata) } else if (iframeMode === 'callback') { const callbackFuncName = uniqueId('veuiUploaderCallback') removeBind = bindJsonpCallback(callbackFuncName, ondata) attachObjectToForm( { callback: `parent.${callbackNamespace}['${callbackFuncName}']` }, form ) } else { throw new Error('[veui-uploader] `iframe-mode` is invalid.') } // TODO: timeout ? function ondata (data) { clean() onprogress({ loaded: file.size, total: file.size }) onload(parseResponse(data)) } function clean () { removeBind() cleanDom() } onprogress({ loaded: -1, total: file.size }) // IE 的 ProgressEvent 不支持 constructor,所以这里只能抛个 plain object form.submit() return function () { clean() onerror(createCancelError('upload cancelled')) } } } function getXhrUploadRequest (options) { const parseResponse = getResonpseParse(options) const { name, action, headers, payload, withCredentials } = options return function xhrUploadRequest (file, { onload, onerror, onprogress }) { let cancelled let formData = new FormData() formData.append(name, file) entries(payload).forEach(function ([key, val]) { formData.append(key, val) }) let xhr = new XMLHttpRequest() xhr.upload.onprogress = onprogress xhr.onload = () => onload(parseResponse(xhr.responseText)) xhr.onerror = (...args) => !cancelled && onerror(...args) xhr.open('POST', action, true) entries(headers).forEach(function ([key, val]) { xhr.setRequestHeader(key, val) }) xhr.withCredentials = withCredentials xhr.send(formData) return function cancel () { cancelled = true xhr.abort() // cancel 了也需要回调,否则外面的 promise 没法 settle 就一直占用内存 onerror(createCancelError('upload cancelled')) } } } function getCustomUploadRequest ({ upload, convertResponse }) { // 之前的实现里自定义上传函数的 context 是 null,这里继续保留 return function (file, callbacks) { // 自定义上传也调用 convertResponse 处理结果 let original = callbacks.onload callbacks = { ...callbacks, onload: (data, ...args) => original(convertResponse(data), ...args) } let cancel = upload(file, callbacks) return function () { if (cancel) { cancel() } callbacks.onerror(createCancelError('upload cancelled')) } } } function getResonpseParse ({ convertResponse, dataType }) { return function (data) { if (typeof data === 'string' && dataType === 'json') { try { data = JSON.parse(data) } catch (err) { return convertResponse(undefined, err) } } return convertResponse(data) } } function createCancelError (msg) { let err = new Error(msg) err.__CANCEL__ = true return err } export function isCancelError (err) { return !!err.__CANCEL__ }