* `chunk-enabled` prop added to enable chunk upload * `chunk` prop added to modify chunk upload parameters * `ChunkUploadHandler` class created to handler chunk upload process * Example added to the docs * Chunk documentation added to the docsmaster
parent
7d88edc716
commit
961cc35f23
@ -0,0 +1,38 @@
|
||||
// http://eslint.org/docs/user-guide/configuring
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: 'babel-eslint',
|
||||
parserOptions: {
|
||||
sourceType: 'module'
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
jest: true
|
||||
},
|
||||
// https://github.com/standard/standard/blob/master/docs/RULES-en.md
|
||||
extends: "standard",
|
||||
|
||||
// required to lint *.vue files
|
||||
plugins: [
|
||||
'html'
|
||||
],
|
||||
// add your custom rules here
|
||||
'rules': {
|
||||
// allow paren-less arrow functions
|
||||
'arrow-parens': 0,
|
||||
// allow async-await
|
||||
'generator-star-spacing': 0,
|
||||
// allow debugger during development
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
|
||||
'no-useless-escape': 0,
|
||||
'comma-dangle': 0,
|
||||
'space-before-function-paren': 0,
|
||||
'no-multiple-empty-lines': 0,
|
||||
'no-multi-spaces': 0,
|
||||
'padded-blocks': 0,
|
||||
'prefer-promise-reject-errors': 0,
|
||||
'operator-linebreak': 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="example-simple">
|
||||
<h1 id="example-title" class="example-title">Chunk Upload Example</h1>
|
||||
|
||||
<p>When using chunk uploads, the file will be uploaded in different parts (or chunks). All the files with a size higher than the set in the input will be uploaded using this method.</p>
|
||||
<p>You will be able to see the different parts being uploaded individually. Some parts might fail, and the package is prepared to <em>retry</em> up to a certain amount of times.</p>
|
||||
<p>You can also pause / resume the upload process.</p>
|
||||
|
||||
<div class="upload">
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input v-model="chunkEnabled" type="checkbox"> Use chunk upload
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputMinSize" class="col-sm-2 control-label">Min Size</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<input id="inputMinSize" v-model="chunkMinSize" type="number" class="form-control">
|
||||
<span class="input-group-addon">MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputMaxActive" class="col-sm-2 control-label">Max Active Chunks</label>
|
||||
<div class="col-sm-10">
|
||||
<input id="inputMaxActive" v-model="chunkMaxActive" type="number" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputMaxRetries" class="col-sm-2 control-label">Max Chunk Retries</label>
|
||||
<div class="col-sm-10">
|
||||
<input id="inputMaxRetries" v-model="chunkMaxRetries" type="number" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-condensed">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="text-right">Size</th>
|
||||
<th class="text-right">Progress</th>
|
||||
<th>Status</th>
|
||||
<th>Pause</th>
|
||||
<th colspan="3" class="text-center">Chunks</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="5"></th>
|
||||
<th class="text-right">Total</th>
|
||||
<th class="text-right">Active</th>
|
||||
<th class="text-right">Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="file in files">
|
||||
<tr :key="file.id + '-info'">
|
||||
<td>{{ file.name }}</td>
|
||||
<td class="text-right">{{ file.size | formatSize }}</td>
|
||||
<td class="text-right">{{ file.progress }}%</td>
|
||||
|
||||
<td v-if="file.error">{{ file.error }}</td>
|
||||
<td v-else-if="file.success">Success</td>
|
||||
<td v-else-if="file.active">Active</td>
|
||||
<td v-else> - </td>
|
||||
|
||||
<td>
|
||||
<template v-if="file.chunk">
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
v-if="file.active"
|
||||
@click="file.chunk.pause()"
|
||||
>
|
||||
<i class="fa fa-pause"/>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
v-if="!file.active && file.chunk.hasChunksToUpload"
|
||||
@click="file.chunk.resume()"
|
||||
>
|
||||
<i class="fa fa-play"/>
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<template v-if="file.chunk">
|
||||
<td class="text-right">{{ file.chunk.chunks.length }}</td>
|
||||
<td class="text-right">{{ file.chunk.chunksUploading.length }}</td>
|
||||
<td class="text-right">{{ file.chunk.chunksUploaded.length }}</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="text-right"> - </td>
|
||||
<td class="text-right"> - </td>
|
||||
<td class="text-right"> - </td>
|
||||
</template>
|
||||
</tr>
|
||||
|
||||
<tr :key="file.id + '-loading'">
|
||||
<td colspan="8">
|
||||
<div class="chunk-loading" v-if="file.chunk">
|
||||
<span
|
||||
v-for="(chunk, index) in file.chunk.chunks"
|
||||
:key="index"
|
||||
class="chunk-loading-part"
|
||||
:class="{'chunk-loading-part__uploaded': chunk.uploaded}"
|
||||
>
|
||||
<template v-if="chunk.retries != file.chunk.maxRetries">
|
||||
{{ file.chunk.maxRetries - chunk.retries }} error(s)
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="example-btn">
|
||||
<file-upload
|
||||
class="btn btn-primary"
|
||||
post-action="/upload/post"
|
||||
|
||||
:chunk-enabled="chunkEnabled"
|
||||
:chunk="{
|
||||
action: '/upload/chunk',
|
||||
minSize: chunkMinSize * 1048576,
|
||||
maxActive: chunkMaxActive,
|
||||
maxRetries: chunkMaxRetries
|
||||
}"
|
||||
|
||||
extensions="gif,jpg,jpeg,png,webp"
|
||||
accept="image/png,image/gif,image/jpeg,image/webp"
|
||||
:multiple="true"
|
||||
:size="1024 * 1024 * 10"
|
||||
v-model="files"
|
||||
@input-filter="inputFilter"
|
||||
@input-file="inputFile"
|
||||
ref="upload">
|
||||
<i class="fa fa-plus"></i>
|
||||
Select files
|
||||
</file-upload>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-5">
|
||||
Source code: <a href="https://github.com/lian-yue/vue-upload-component/blob/master/docs/views/examples/Chunk.vue">/docs/views/examples/Chunk.vue</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
.example-simple label.btn {
|
||||
margin-bottom: 0;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import FileUpload from 'vue-upload-component'
|
||||
export default {
|
||||
components: {
|
||||
FileUpload,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
|
||||
chunkEnabled: true,
|
||||
|
||||
// 1MB by default
|
||||
chunkMinSize: 1,
|
||||
chunkMaxActive: 3,
|
||||
chunkMaxRetries: 5
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
inputFilter(newFile, oldFile, prevent) {
|
||||
if (newFile && !oldFile) {
|
||||
// Before adding a file
|
||||
// 添加文件前
|
||||
|
||||
// Filter system files or hide files
|
||||
// 过滤系统文件 和隐藏文件
|
||||
if (/(\/|^)(Thumbs\.db|desktop\.ini|\..+)$/.test(newFile.name)) {
|
||||
return prevent()
|
||||
}
|
||||
|
||||
// Filter php html js file
|
||||
// 过滤 php html js 文件
|
||||
if (/\.(php5?|html?|jsx?)$/i.test(newFile.name)) {
|
||||
return prevent()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
inputFile(newFile, oldFile) {
|
||||
if (newFile && !oldFile) {
|
||||
// add
|
||||
console.log('add', newFile)
|
||||
this.$refs.upload.active = true
|
||||
}
|
||||
if (newFile && oldFile) {
|
||||
// update
|
||||
console.log('update', newFile)
|
||||
}
|
||||
|
||||
if (!newFile && oldFile) {
|
||||
// remove
|
||||
console.log('remove', oldFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chunk-loading {
|
||||
margin: -12px;
|
||||
display: flex;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
.chunk-loading .chunk-loading-part {
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
flex: 1;
|
||||
background: #ccc;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chunk-loading .chunk-loading-part.chunk-loading-part__uploaded {
|
||||
background: #28A745;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,329 @@
|
||||
import { default as request, createRequest, sendRequest } from '../utils/request'
|
||||
|
||||
export default class ChunkUploadHandler {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {File} file
|
||||
* @param {Object} options
|
||||
*/
|
||||
constructor (file, options) {
|
||||
this.file = file
|
||||
this.options = options
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the max retries from options
|
||||
*/
|
||||
get maxRetries () {
|
||||
return parseInt(this.options.maxRetries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the max number of active chunks being uploaded at once from options
|
||||
*/
|
||||
get maxActiveChunks () {
|
||||
return parseInt(this.options.maxActive)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file type
|
||||
*/
|
||||
get fileType () {
|
||||
return this.file.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file size
|
||||
*/
|
||||
get fileSize () {
|
||||
return this.file.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets action (url) to upload the file
|
||||
*/
|
||||
get action () {
|
||||
return this.options.action || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the body to be merged when sending the request in start phase
|
||||
*/
|
||||
get startBody () {
|
||||
return this.options.startBody || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the body to be merged when sending the request in upload phase
|
||||
*/
|
||||
get uploadBody () {
|
||||
return this.options.uploadBody || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the body to be merged when sending the request in finish phase
|
||||
*/
|
||||
get finishBody () {
|
||||
return this.options.finishBody || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the headers of the requests from options
|
||||
*/
|
||||
get headers () {
|
||||
return this.options.headers || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether it's ready to upload files or not
|
||||
*/
|
||||
get readyToUpload () {
|
||||
return !!this.chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the progress of the chunk upload
|
||||
* - Gets all the completed chunks
|
||||
* - Gets the progress of all the chunks that are being uploaded
|
||||
*/
|
||||
get progress () {
|
||||
const completedProgress = (this.chunksUploaded.length / this.chunks.length) * 100
|
||||
const uploadingProgress = this.chunksUploading.reduce((progress, chunk) => {
|
||||
return progress + ((chunk.progress | 0) / this.chunks.length)
|
||||
}, 0)
|
||||
|
||||
return Math.min(completedProgress + uploadingProgress, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all the chunks that are pending to be uploaded
|
||||
*/
|
||||
get chunksToUpload () {
|
||||
return this.chunks.filter(chunk => {
|
||||
return !chunk.active && !chunk.uploaded
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there are chunks to upload or not
|
||||
*/
|
||||
get hasChunksToUpload () {
|
||||
return this.chunksToUpload.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all the chunks that are uploading
|
||||
*/
|
||||
get chunksUploading () {
|
||||
return this.chunks.filter(chunk => {
|
||||
return !!chunk.xhr && !!chunk.active
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all the chunks that have finished uploading
|
||||
*/
|
||||
get chunksUploaded () {
|
||||
return this.chunks.filter(chunk => {
|
||||
return !!chunk.uploaded
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all the chunks in the initial state
|
||||
*/
|
||||
createChunks () {
|
||||
this.chunks = []
|
||||
|
||||
let start = 0
|
||||
let end = this.chunkSize
|
||||
while (start < this.fileSize) {
|
||||
this.chunks.push({
|
||||
blob: this.file.file.slice(start, end),
|
||||
startOffset: start,
|
||||
active: false,
|
||||
retries: this.maxRetries
|
||||
})
|
||||
start = end
|
||||
end = start + this.chunkSize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the progress of the file with the handler's progress
|
||||
*/
|
||||
updateFileProgress () {
|
||||
this.file.progress = this.progress
|
||||
}
|
||||
|
||||
/**
|
||||
* Paues the upload process
|
||||
* - Stops all active requests
|
||||
* - Sets the file not active
|
||||
*/
|
||||
pause () {
|
||||
this.file.active = false
|
||||
this.chunksUploading.forEach(chunk => {
|
||||
chunk.xhr.abort()
|
||||
chunk.active = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the file upload
|
||||
* - Sets the file active
|
||||
* - Starts the following chunks
|
||||
*/
|
||||
resume () {
|
||||
this.file.active = true
|
||||
this.startChunking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the file upload
|
||||
*
|
||||
* @returns Promise
|
||||
* - resolve The file was uploaded
|
||||
* - reject The file upload failed
|
||||
*/
|
||||
upload () {
|
||||
this.promise = new Promise((resolve, reject) => {
|
||||
this.resolve = resolve
|
||||
this.reject = reject
|
||||
})
|
||||
this.start()
|
||||
|
||||
return this.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Start phase
|
||||
* Sends a request to the backend to initialise the chunks
|
||||
*/
|
||||
start () {
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: this.headers,
|
||||
url: this.action,
|
||||
body: Object.assign(this.startBody, {
|
||||
phase: 'start',
|
||||
mime_type: this.fileType,
|
||||
size: this.fileSize
|
||||
})
|
||||
}).then(res => {
|
||||
if (res.status !== 'success') {
|
||||
return this.reject(res.message)
|
||||
}
|
||||
|
||||
this.sessionId = res.data.session_id
|
||||
this.chunkSize = res.data.end_offset
|
||||
|
||||
this.createChunks()
|
||||
this.startChunking()
|
||||
}).catch(error => this.reject(error))
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts to upload chunks
|
||||
*/
|
||||
startChunking () {
|
||||
for (let i = 0; i < this.maxActiveChunks; i++) {
|
||||
this.uploadNextChunk()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the next chunk
|
||||
* - Won't do anything if the process is paused
|
||||
* - Will start finish phase if there are no more chunks to upload
|
||||
*/
|
||||
uploadNextChunk () {
|
||||
if (this.file.active) {
|
||||
if (this.hasChunksToUpload) {
|
||||
return this.uploadChunk(this.chunksToUpload[0])
|
||||
}
|
||||
|
||||
if (this.chunksUploading.length === 0) {
|
||||
return this.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a chunk
|
||||
* - Sends the chunk to the backend
|
||||
* - Sets the chunk as uploaded if everything went well
|
||||
* - Decreases the number of retries if anything went wrong
|
||||
* - Fails if there are no more retries
|
||||
*
|
||||
* @param {Object} chunk
|
||||
*/
|
||||
uploadChunk (chunk) {
|
||||
chunk.progress = 0
|
||||
chunk.active = true
|
||||
this.updateFileProgress()
|
||||
chunk.xhr = createRequest({
|
||||
method: 'POST',
|
||||
headers: this.headers,
|
||||
url: this.action
|
||||
})
|
||||
|
||||
chunk.xhr.upload.addEventListener('progress', function(evt) {
|
||||
if (evt.lengthComputable) {
|
||||
chunk.progress = Math.round(evt.loaded / evt.total * 100)
|
||||
}
|
||||
}, false)
|
||||
|
||||
sendRequest(chunk.xhr, Object.assign(this.uploadBody, {
|
||||
phase: 'upload',
|
||||
session_id: this.sessionId,
|
||||
start_offset: chunk.startOffset,
|
||||
chunk: chunk.blob
|
||||
})).then(res => {
|
||||
chunk.active = false
|
||||
if (res.status === 'success') {
|
||||
chunk.uploaded = true
|
||||
} else {
|
||||
if (chunk.retries-- <= 0) {
|
||||
this.pause()
|
||||
return this.reject('File upload failed')
|
||||
}
|
||||
}
|
||||
|
||||
this.uploadNextChunk()
|
||||
}).catch(() => {
|
||||
chunk.active = false
|
||||
if (chunk.retries-- <= 0) {
|
||||
this.pause()
|
||||
return this.reject('File upload failed')
|
||||
}
|
||||
|
||||
this.uploadNextChunk()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish phase
|
||||
* Sends a request to the backend to finish the process
|
||||
*/
|
||||
finish () {
|
||||
this.updateFileProgress()
|
||||
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: this.headers,
|
||||
url: this.action,
|
||||
body: Object.assign(this.finishBody, {
|
||||
phase: 'finish',
|
||||
session_id: this.sessionId
|
||||
})
|
||||
}).then(res => {
|
||||
if (res.status !== 'success') {
|
||||
return this.reject(res.message)
|
||||
}
|
||||
|
||||
this.resolve(res)
|
||||
}).catch(error => this.reject(error))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Creates a XHR request
|
||||
*
|
||||
* @param {Object} options
|
||||
*/
|
||||
export const createRequest = (options) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.responseType = 'json'
|
||||
xhr.open(options.method || 'GET', options.url)
|
||||
if (options.headers) {
|
||||
Object.keys(options.headers).forEach(key => {
|
||||
xhr.setRequestHeader(key, options.headers[key])
|
||||
})
|
||||
}
|
||||
|
||||
return xhr
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a XHR request with certain body
|
||||
*
|
||||
* @param {XMLHttpRequest} xhr
|
||||
* @param {Object} body
|
||||
*/
|
||||
export const sendRequest = (xhr, body) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response)
|
||||
} else {
|
||||
reject(xhr.statusText)
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(xhr.statusText)
|
||||
xhr.send(JSON.stringify(body))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and sends XHR request
|
||||
*
|
||||
* @param {Object} options
|
||||
*
|
||||
* @returns Promise
|
||||
*/
|
||||
export default function (options) {
|
||||
const xhr = createRequest(options)
|
||||
|
||||
return sendRequest(xhr, options.body)
|
||||
}
|
||||
Loading…
Reference in new issue