Skip to content
On this page

用vue3 写了个上传组件列表,支持取消上传,展示进度条

最近写了个上传附件组件,回想起来之前单位有个功能模块,也是差不多文件上传功能(支持取消、支持删除、支持显示进度条),然后被同事写的是乱七八糟。维护起来非常费劲,简直就是我的眼中钉,肉中刺,一直想把这段代码给拔掉。这次刚好遇到了相似的功能,咱们就从组件设计以及实现一起来看看,怎么实现一个文件上传附件的功能!

示意图

    1. 支持文件上传 (这个肯定要支持某些文件根据自己的业务需求限制)
    1. 支持文件列表删除
    1. 支持显示附件上传进度条
    1. 支持上传中的附件取消

组件设计

咱们先来划分组件设计,以及职责划分。如下图咱们共划分为3个模块:交互层、操作层、数据层。

模块划分示意图:

示意图

模块交互示意图:

示意图

其实这么划分组件已经能很清晰看明白每个组件要做什么了!那么咱们的文件目录划分如下:

bash
├── index.vue 
├── list.vue
├── operator.vue
├── options.js
└── running
    ├── index.vue
    ├── item.vue
    └── progress.vue
  • index.vue负责载各个组件
  • list.vue 负责展示上传的文件列表
  • operator.vue 负责展示操作按钮(上传、删除)
  • options.js 就放了个uuidconvertFileSize转换大小的函数
  • running 文件夹中主要负责上传文件,取消文件,展示进度条

相关实现

咱们就来分析下具体实现细节:

  1. 显示进度条以及支持取消相关功能具体如下:

进度条以及支持取消 running下item.vue代码

js
import { ref } from 'vue'
import axios from 'axios'

const percent = ref(0) // 记录百分比
const CancelToken = axios.CancelToken // 取消上传
const axiosCancel = ref(null)


const uploadFile = () => {
  const formData = new FormData()
  formData.append('file', props.file)

  axios
    .post(`你自己的API`, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      cancelToken: new CancelToken(function executor(c) {
        axiosCancel.value = c
      }),
      onUploadProgress: progressEvent => { // 这里能计算出来上传的百分比
        if (progressEvent.lengthComputable) {
          percent.value = Math.round((progressEvent.loaded / progressEvent.total) * 100)
        }
      }
    })
    .then(() => {
      percent.value = 100
      setTimeout(() => { // 完成
        emits('success', props.uuid)
      }, 300)
    })
    .catch(error => {
      if (axios.isCancel(error)) {
        console.log('Request canceled', error.message)
      } else {
        console.log(error)
      }
    })
}


const oncancel = () => {
  axiosCancel.value?.()
  isCancel.value = true

  setTimeout(() => { // 取消
    emits('cancel', props.uuid)
  }, 300)
}
  1. 上传中数据删除、新增逻辑 runningUploadFiles
  • 上传完成后删除
  • 取消后删除
  • 新选文件后新增

新增 index.vue代码

vue
<template>
  <div class="work-flow-attachment">
    <WorkFlowAttachmentRunning v-model="runningUploadFiles" :formId="formId" @complate="onComplate" />
    <WorkFlowAttachmentOperator :uploadFiles="uploadFiles" :deleteFiles="deleteFiles" />
    <WorkFlowAttachmentList ref="listRef" :formId="formId" v-model="checkData" />
  </div>
</template>

<script setup>
import { defineProps, ref } from 'vue'
import { Confirm } from '@/components/pro-confirm/index.js'
import WorkFlowAttachmentOperator from './operator.vue'
import WorkFlowAttachmentRunning from './running/index.vue'
import WorkFlowAttachmentList from './list.vue'

import { deleteAttachmentApi } from '@/api/attachment.js'
import { generateUUID } from './options'

const ATTACHMENT_TYPE = {
  VIEW: 'view',
  EDIT: 'edit'
}

defineProps({
  formId: {
    type: String,
    default: ''
  },
  size: {
    type: String,
    default: 'middle' // middle、small
  },
  type: {
    type: String,
    default: 'edit' // edit、view
  }
})

const listRef = ref(null)
const runningUploadFiles = ref([])
const checkData = ref([])

const uploadFiles = files => {
  files.forEach(file => {
    runningUploadFiles.value.push({ // 新增任务
      file,
      uuid: generateUUID()
    })
  })
}

const deleteFiles = () => {
  if (!checkData.value.length) {
    Confirm.error('请至少选中一项数据')
    return
  }

  Confirm.warning({
    content: '是否删除选中数据?',
    async confirm() {
      await deleteAttachmentApi({
        ids: checkData.value.join()
      })
      onComplate()
    }
  })
}

const onComplate = () => {
  listRef.value?.reload?.()
}
</script>

<style lang="less" scoped>
.work-flow-attachment {
  padding: 12px 24px;
}
</style>

上传完成和上传取消(利用v-model=""语法糖更改runningUploadFiles数据):

running 下 index.vue代码

vue
<template>
  <div class="work-flow-attachment-running">
    <AttachmentRunningItem
      v-for="item in modelValue"
      :key="item.uuid"
      v-bind="item"
      :formId="formId"
      @cancel="onCancel"
      @success="onSuccess"
    />
  </div>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'
import AttachmentRunningItem from './item.vue'

const emit = defineEmits(['update:modelValue', 'complate'])

const props = defineProps({
  modelValue: {
    type: Array,
    default: () => []
  },
  formId: {
    type: String,
    default: ''
  }
})

// 清除数据
const onCancel = uuid => {
  emit(
    'update:modelValue',
    props.modelValue.filter(item => item.uuid !== uuid)
  )
}

// 完成
const onSuccess = uuid => {
  onCancel(uuid)
  emit('complate')
}
</script>

<style lang="less" scoped>
.work-flow-attachment-running {
  > div:last-child {
    margin-bottom: 16px;
  }
}
</style>
  1. 删除列表相关实现也是利用**v-model="" 语法糖更新checkData数据:**

list.vue代码:

vue
<template>
  <div class="work-flow-attachment-list">
    <div v-for="(item, idx) in listData" :key="idx" class="attachment-list__item">
      <ProCheckbox v-model="listData[idx].check" @change="onCheckboxChange">
        <span class="attachment-list__item--content" @click.stop="onDownload(item.id)">
          <span class="attachment-list__item--title">
            <span>{{ item.name }}</span>
            <span>[{{ item.size }}]</span>
          </span>

          <span class="attachment-list__item--operator" @click.stop="onDownload(item.id)">
            <a-tooltip placement="top">
              <template #title>
                <span>下载</span>
              </template>
              <download-outlined />
            </a-tooltip>
          </span>

          <span
            class="attachment-list__item--operator"
            v-if="isVerifySupportPreview(item.name)"
            @click.stop="onPreview(item.id)"
          >
            <a-tooltip placement="top">
              <template #title>
                <span>预览</span>
              </template>
              <folder-view-outlined />
            </a-tooltip>
          </span>
        </span>
      </ProCheckbox>
    </div>
  </div>
</template>

<script setup>
import { defineProps, onMounted, ref, defineEmits, defineExpose, watch } from 'vue'
import ProCheckbox from '@/components/pro-checkbox/index.vue'
import { getAttachmentListApi } from '@/api/attachment.js'
import { convertFileSize } from './options'

const isSupportPreview = ['.png', '.ofd', '.xls', '.xlsx', '.jpg', '.doc', '.docx', '.pdf']

const emit = defineEmits(['update:modelValue', 'complate'])
const props = defineProps({
  formId: {
    type: String,
    default: ''
  },
  modelValue: {
    type: Array,
    default: () => []
  }
})

const listData = ref([])

const isVerifySupportPreview = (name = '') => {
  return isSupportPreview.some(item => name.endsWith(item))
}

watch(
  () => props.formId,
  () => {
    getData()
  }
)

const getData = async () => {
  if (!props.formId) return
  const response = await getAttachmentListApi({ formId: props.formId })

  listData.value = (response?.result || []).map(item => {
    return {
      name: item.fileName,
      suffix: item.fileSuffix,
      id: item.id,
      size: convertFileSize(item.totalSpace),
      check: false
    }
  })
}

const onDownload = id => {}

const onPreview = () => {}

const reload = getData

const onCheckboxChange = () => {
  emit(
    'update:modelValue',
    listData.value.filter(item => item.check).map(item => item.id)
  )
}

onMounted(getData)

defineExpose({
  reload
})
</script>

<style lang="less" scoped>
.work-flow-attachment-list {
  .attachment-list__item {
    margin-top: 8px;
  }

  .attachment-list__item--content {
    .attachment-list__item--title {
      &:hover {
        color: #3080f8;
      }
    }

    .attachment-list__item--operator {
      font-size: 16px;
      &:hover {
        color: #3080f8;
      }
    }

    span {
      margin-right: 4px;
    }
  }
}
</style>

总结

组件设计好,职责划分明确后在进行编码能使代码更好维护,更利于迭代。 咱们在划分完成后只关心数据runningUploadFiles以及checkData处理就行了。希望这个文章对你来说能学习到:

  • 文件进度条获取
  • 文件上传取消
  • 利用v-model语法糖使代码数据流转更便捷清晰
  • 数据模块的设计划分

源码地址链接

Released under the MIT License.