el-upload 企业级二次封装

5 阅读4分钟

(1)类型定义

export type BizUploadStatus =
  | 'ready'
  | 'queue'
  | 'uploading'
  | 'success'
  | 'fail'
  | 'canceled'

export type BizFileSource = 'server' | 'local'

export interface ServerFileRaw {
  fileId: string
  name: string
  url: string
  size?: number
}

export interface UploadSuccessResult {
  fileId: string
  name: string
  url: string
  size?: number
}

export interface FileActionState {
  canDownload: boolean
  canDelete: boolean
}

export interface BizFileItem {
  uid: string

  // 服务端字段
  fileId?: string
  name: string
  url?: string
  size?: number
  ext?: string

  // 上传过程态
  status: BizUploadStatus
  percentage: number
  errorMsg?: string
  source: BizFileSource
  raw?: File

  // 取消上传
  abortController?: AbortController

  // 业务能力
  canDownload: boolean
  canDelete: boolean
}

export interface UploadRequestContext {
  file: File
  signal: AbortSignal
  onProgress: (percent: number) => void
}

export type UploadRequest = (
  ctx: UploadRequestContext
) => Promise<UploadSuccessResult>

(2)工具函数

import type {
  BizFileItem,
  FileActionState,
  ServerFileRaw,
} from './types'

export function createUid() {
  return `${Date.now()}_${Math.random().toString(16).slice(2)}`
}

export function getFileExt(name: string) {
  const index = name.lastIndexOf('.')
  if (index < 0) return ''
  return name.slice(index + 1).toLowerCase()
}

export function formatFileSize(size?: number) {
  if (size == null || Number.isNaN(size)) return ''
  if (size < 1024) return `${size}B`
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)}KB`
  if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)}MB`
  return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`
}

export function normalizeServerFile(
  raw: ServerFileRaw,
  resolver?: (file: ServerFileRaw) => FileActionState
): BizFileItem {
  const action = resolver
    ? resolver(raw)
    : {
        canDownload: true,
        canDelete: true,
      }

  return {
    uid: raw.fileId || createUid(),
    fileId: raw.fileId,
    name: raw.name,
    url: raw.url,
    size: raw.size,
    ext: getFileExt(raw.name),
    status: 'success',
    percentage: 100,
    source: 'server',
    canDownload: action.canDownload,
    canDelete: action.canDelete,
  }
}

(3)核心组件

<template>
  <div class="smart-upload">
    <el-upload
      :show-file-list="false"
      :multiple="multiple"
      :limit="limit"
      :accept="accept"
      :before-upload="handleBeforeUpload"
      :http-request="handleHttpRequest"
      :on-exceed="handleExceed"
      :disabled="disabled || readonly"
    >
      <slot name="trigger">
        <el-button :disabled="disabled || readonly" type="primary">
          选择文件
        </el-button>
      </slot>
    </el-upload>

    <div class="smart-upload__list" v-if="innerList.length">
      <div
        v-for="file in innerList"
        :key="file.uid"
        class="smart-upload__item"
        :class="[
          `is-${file.status}`,
          {
            'is-readonly': readonly,
            'is-download-disabled': !file.canDownload,
            'is-delete-disabled': !file.canDelete,
          },
        ]"
      >
        <div class="smart-upload__left">
          <div class="smart-upload__icon">📄</div>

          <div class="smart-upload__main">
            <div class="smart-upload__name-row">
              <span
                class="smart-upload__name"
                :class="{ clickable: !!file.url && file.canDownload }"
                @click="handleClickName(file)"
              >
                {{ file.name }}
              </span>

              <span class="smart-upload__status">
                {{ getStatusText(file) }}
              </span>
            </div>

            <div class="smart-upload__meta">
              <span v-if="file.size != null">{{ formatFileSize(file.size) }}</span>
              <span v-if="file.ext"> · {{ file.ext }}</span>
              <span> · {{ file.source === 'server' ? '回显文件' : '本地文件' }}</span>
            </div>

            <div
              v-if="file.status === 'uploading'"
              class="smart-upload__progress"
            >
              <el-progress
                :percentage="file.percentage"
                :stroke-width="6"
                :show-text="false"
              />
            </div>

            <div
              v-if="file.status === 'fail' && file.errorMsg"
              class="smart-upload__error"
            >
              {{ file.errorMsg }}
            </div>

            <div
              v-if="file.status === 'canceled'"
              class="smart-upload__cancel-text"
            >
              已取消上传
            </div>
          </div>
        </div>

        <div class="smart-upload__actions">
          <el-button
            v-if="file.status === 'fail' && !readonly && !disabled"
            link
            type="primary"
            @click="handleRetry(file)"
          >
            重试
          </el-button>

          <el-button
            v-if="file.status === 'queue' && !readonly && !disabled"
            link
            type="warning"
            @click="handleCancel(file)"
          >
            移除
          </el-button>

          <el-button
            v-if="file.status === 'uploading' && !readonly && !disabled"
            link
            type="warning"
            @click="handleCancel(file)"
          >
            取消
          </el-button>

          <el-button
            link
            type="primary"
            :disabled="!file.canDownload || !file.url"
            @click="handleDownload(file)"
          >
            下载
          </el-button>

          <el-button
            link
            type="danger"
            :disabled="readonly || disabled || !file.canDelete"
            @click="handleRemove(file)"
          >
            删除
          </el-button>
        </div>
      </div>
    </div>

    <div v-else class="smart-upload__empty">暂无文件</div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type {
  UploadProps,
  UploadRequestOptions,
} from 'element-plus'
import type {
  BizFileItem,
  FileActionState,
  ServerFileRaw,
  UploadRequest,
  UploadSuccessResult,
} from './types'
import {
  createUid,
  formatFileSize,
  getFileExt,
} from './utils'

interface Props {
  modelValue: BizFileItem[]
  uploadRequest: UploadRequest

  disabled?: boolean
  readonly?: boolean
  multiple?: boolean
  limit?: number
  accept?: string
  maxSizeMb?: number
  maxConcurrent?: number

  /**
   * 例如 ['pdf', 'doc', 'docx']
   */
  allowedExts?: string[]

  /**
   * 服务端回显文件的权限解析
   */
  serverFileActionResolver?: (file: ServerFileRaw) => FileActionState

  /**
   * 上传成功后的权限解析
   */
  uploadedFileActionResolver?: (
    file: UploadSuccessResult
  ) => FileActionState

  /**
   * 删除前确认
   */
  beforeRemove?: (file: BizFileItem) => boolean | Promise<boolean>

  /**
   * 下载处理可由父层接管
   */
  customDownload?: (file: BizFileItem) => void | Promise<void>
}

const props = withDefaults(defineProps<Props>(), {
  disabled: false,
  readonly: false,
  multiple: true,
  limit: 20,
  accept: '',
  maxSizeMb: 50,
  maxConcurrent: 2,
  allowedExts: () => [],
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: BizFileItem[]): void
  (e: 'change', value: BizFileItem[]): void
  (e: 'remove', file: BizFileItem, list: BizFileItem[]): void
  (e: 'download', file: BizFileItem): void
  (e: 'upload-success', file: BizFileItem, list: BizFileItem[]): void
  (e: 'upload-fail', file: BizFileItem, list: BizFileItem[]): void
  (e: 'upload-cancel', file: BizFileItem, list: BizFileItem[]): void
}>()

const innerList = ref<BizFileItem[]>([])
const uploadingCount = ref(0)

watch(
  () => props.modelValue,
  (val) => {
    innerList.value = val ? [...val] : []
  },
  { immediate: true, deep: true }
)

function syncOut() {
  emit('update:modelValue', [...innerList.value])
  emit('change', [...innerList.value])
}

function replaceItem(uid: string, patch: Partial<BizFileItem>) {
  innerList.value = innerList.value.map((item) =>
    item.uid === uid ? { ...item, ...patch } : item
  )
  syncOut()
}

function removeByUid(uid: string) {
  innerList.value = innerList.value.filter((item) => item.uid !== uid)
  syncOut()
}

function appendItem(file: BizFileItem) {
  innerList.value = [...innerList.value, file]
  syncOut()
}

function getStatusText(file: BizFileItem) {
  switch (file.status) {
    case 'ready':
      return '待上传'
    case 'queue':
      return '排队中'
    case 'uploading':
      return `上传中 ${file.percentage}%`
    case 'success':
      return '上传成功'
    case 'fail':
      return '上传失败'
    case 'canceled':
      return '已取消'
    default:
      return ''
  }
}

function isExtAllowed(fileName: string) {
  if (!props.allowedExts.length) return true
  const ext = getFileExt(fileName)
  return props.allowedExts.includes(ext)
}

const handleBeforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
  if (props.disabled || props.readonly) return false

  const ext = getFileExt(rawFile.name)
  const sizeMb = rawFile.size / 1024 / 1024

  if (!isExtAllowed(rawFile.name)) {
    ElMessage.warning(`文件类型不支持:.${ext}`)
    return false
  }

  if (sizeMb > props.maxSizeMb) {
    ElMessage.warning(`文件大小不能超过 ${props.maxSizeMb}MB`)
    return false
  }

  if (innerList.value.length >= props.limit) {
    ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
    return false
  }

  const item: BizFileItem = {
    uid: createUid(),
    fileId: '',
    name: rawFile.name,
    url: '',
    size: rawFile.size,
    ext,
    status: 'queue',
    percentage: 0,
    errorMsg: '',
    source: 'local',
    raw: rawFile,
    canDownload: false,
    canDelete: true,
  }

  appendItem(item)
  return true
}

async function handleHttpRequest(options: UploadRequestOptions) {
  const target = innerList.value.find(
    (item) =>
      item.raw === options.file ||
      (item.name === options.file.name &&
        item.size === options.file.size &&
        (item.status === 'queue' || item.status === 'ready'))
  )

  if (!target) {
    ElMessage.error('未匹配到待上传文件')
    return
  }

  scheduleUpload(target.uid)
}

function handleExceed() {
  ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
}

function scheduleUpload(uid: string) {
  tryStartNext(uid)
}

function tryStartNext(preferredUid?: string) {
  while (uploadingCount.value < props.maxConcurrent) {
    let next: BizFileItem | undefined

    if (preferredUid) {
      next = innerList.value.find(
        (item) => item.uid === preferredUid && item.status === 'queue'
      )
      preferredUid = undefined
    }

    if (!next) {
      next = innerList.value.find((item) => item.status === 'queue')
    }

    if (!next) return

    runUpload(next)
  }
}

async function runUpload(fileItem: BizFileItem) {
  if (!fileItem.raw) {
    replaceItem(fileItem.uid, {
      status: 'fail',
      errorMsg: '未找到待上传文件',
    })
    return
  }

  const controller = new AbortController()

  replaceItem(fileItem.uid, {
    status: 'uploading',
    percentage: 0,
    errorMsg: '',
    abortController: controller,
  })

  uploadingCount.value += 1

  try {
    const result = await props.uploadRequest({
      file: fileItem.raw,
      signal: controller.signal,
      onProgress: (percent) => {
        replaceItem(fileItem.uid, {
          percentage: Math.max(0, Math.min(100, percent)),
          status: 'uploading',
        })
      },
    })

    const action = props.uploadedFileActionResolver
      ? props.uploadedFileActionResolver(result)
      : {
          canDownload: true,
          canDelete: true,
        }

    replaceItem(fileItem.uid, {
      fileId: result.fileId,
      name: result.name,
      url: result.url,
      size: result.size ?? fileItem.size,
      status: 'success',
      percentage: 100,
      errorMsg: '',
      canDownload: action.canDownload,
      canDelete: action.canDelete,
      abortController: undefined,
    })

    const current = innerList.value.find((item) => item.uid === fileItem.uid)
    if (current) {
      emit('upload-success', current, [...innerList.value])
    }
  } catch (error) {
    const isAbort =
      error instanceof DOMException && error.name === 'AbortError'

    if (isAbort) {
      replaceItem(fileItem.uid, {
        status: 'canceled',
        percentage: 0,
        errorMsg: '上传已取消',
        abortController: undefined,
      })

      const current = innerList.value.find((item) => item.uid === fileItem.uid)
      if (current) {
        emit('upload-cancel', current, [...innerList.value])
      }
    } else {
      const msg =
        error instanceof Error ? error.message : '上传失败,请稍后重试'

      replaceItem(fileItem.uid, {
        status: 'fail',
        percentage: 0,
        errorMsg: msg,
        abortController: undefined,
        canDownload: false,
      })

      const current = innerList.value.find((item) => item.uid === fileItem.uid)
      if (current) {
        emit('upload-fail', current, [...innerList.value])
      }
    }
  } finally {
    uploadingCount.value = Math.max(0, uploadingCount.value - 1)
    tryStartNext()
  }
}

function handleRetry(file: BizFileItem) {
  if (props.readonly || props.disabled) return
  if (!file.raw) {
    ElMessage.warning('当前文件无法重试')
    return
  }

  replaceItem(file.uid, {
    status: 'queue',
    percentage: 0,
    errorMsg: '',
  })

  tryStartNext(file.uid)
}

function handleCancel(file: BizFileItem) {
  if (props.readonly || props.disabled) return

  if (file.status === 'uploading' && file.abortController) {
    file.abortController.abort()
    return
  }

  if (file.status === 'queue') {
    removeByUid(file.uid)
    emit('upload-cancel', file, [...innerList.value])
  }
}

async function handleRemove(file: BizFileItem) {
  if (props.readonly || props.disabled || !file.canDelete) return

  if (file.status === 'uploading' && file.abortController) {
    file.abortController.abort()
  }

  if (props.beforeRemove) {
    const pass = await props.beforeRemove(file)
    if (!pass) return
  }

  removeByUid(file.uid)
  emit('remove', file, [...innerList.value])
}

function handleClickName(file: BizFileItem) {
  if (!file.canDownload || !file.url) {
    ElMessage.warning('该文件不允许下载')
    return
  }
  handleDownload(file)
}

async function handleDownload(file: BizFileItem) {
  if (!file.canDownload || !file.url) {
    ElMessage.warning('该文件不允许下载')
    return
  }

  emit('download', file)

  if (props.customDownload) {
    await props.customDownload(file)
    return
  }

  window.open(file.url, '_blank')
}

defineExpose({
  retry(file: BizFileItem) {
    handleRetry(file)
  },
  cancel(file: BizFileItem) {
    handleCancel(file)
  },
  remove(file: BizFileItem) {
    handleRemove(file)
  },
  getList() {
    return [...innerList.value]
  },
})
</script>

<style scoped lang="scss">
.smart-upload {
  width: 100%;
}

.smart-upload__list {
  margin-top: 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.smart-upload__item {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
  padding: 12px;
  border: 1px solid var(--el-border-color);
  border-radius: 8px;
  background: var(--el-bg-color);
}

.smart-upload__left {
  flex: 1;
  min-width: 0;
  display: flex;
  gap: 10px;
}

.smart-upload__icon {
  flex-shrink: 0;
  line-height: 20px;
}

.smart-upload__main {
  flex: 1;
  min-width: 0;
}

.smart-upload__name-row {
  display: flex;
  gap: 8px;
  align-items: center;
  min-width: 0;
}

.smart-upload__name {
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--el-text-color-primary);
}

.smart-upload__name.clickable {
  cursor: pointer;
  color: var(--el-color-primary);
}

.smart-upload__status {
  flex-shrink: 0;
  font-size: 12px;
  color: var(--el-text-color-secondary);
}

.smart-upload__meta {
  margin-top: 4px;
  font-size: 12px;
  color: var(--el-text-color-secondary);
}

.smart-upload__progress {
  margin-top: 8px;
}

.smart-upload__error {
  margin-top: 6px;
  font-size: 12px;
  color: var(--el-color-danger);
}

.smart-upload__cancel-text {
  margin-top: 6px;
  font-size: 12px;
  color: var(--el-color-warning);
}

.smart-upload__actions {
  display: flex;
  gap: 8px;
  flex-shrink: 0;
  align-items: center;
}

.smart-upload__empty {
  margin-top: 12px;
  padding: 16px 0;
  color: var(--el-text-color-secondary);
  text-align: center;
}

.smart-upload__item.is-uploading {
  border-color: var(--el-color-primary-light-5);
}

.smart-upload__item.is-fail {
  border-color: var(--el-color-danger-light-5);
  background: var(--el-color-danger-light-9);
}

.smart-upload__item.is-canceled {
  border-color: var(--el-color-warning-light-5);
  background: var(--el-color-warning-light-9);
}
</style>

(4)用法

<script setup lang="ts">
import axios from 'axios'
import { ref } from 'vue'
import SmartUpload from '@/components/SmartUpload/SmartUpload.vue'
import { normalizeServerFile } from '@/components/SmartUpload/utils'
import type {
  BizFileItem,
  UploadRequest,
  UploadSuccessResult,
  ServerFileRaw,
} from '@/components/SmartUpload/types'

const fileList = ref<BizFileItem[]>([
  normalizeServerFile(
    {
      fileId: '1001',
      name: '历史附件.pdf',
      url: 'https://example.com/files/1001',
      size: 1024 * 200,
    },
    () => ({
      canDownload: false,
      canDelete: false,
    })
  ),
])

const uploadRequest: UploadRequest = async ({
  file,
  signal,
  onProgress,
}): Promise<UploadSuccessResult> => {
  const formData = new FormData()
  formData.append('file', file)

  const res = await axios.post('/api/upload', formData, {
    signal,
    headers: {
      'Content-Type': 'multipart/form-data',
    },
    onUploadProgress: (e) => {
      if (!e.total) return
      const percent = Math.round((e.loaded / e.total) * 100)
      onProgress(percent)
    },
  })

  const data = res.data?.data || res.data

  return {
    fileId: data.fileId,
    name: data.name,
    url: data.url,
    size: data.size,
  }
}

function uploadedFileActionResolver() {
  return {
    canDownload: true,
    canDelete: true,
  }
}

async function beforeRemove(file: BizFileItem) {
  console.log('删除前校验', file)
  return true
}

async function customDownload(file: BizFileItem) {
  console.log('父层接管下载', file)
  window.open(file.url, '_blank')
}

function handleSubmit() {
  const payload = fileList.value
    .filter((item) => item.status === 'success')
    .map((item) => ({
      fileId: item.fileId,
      name: item.name,
      url: item.url,
    }))

  console.log('提交 payload:', payload)
}
</script>

<template>
  <div>
    <SmartUpload
      v-model="fileList"
      :upload-request="uploadRequest"
      :uploaded-file-action-resolver="uploadedFileActionResolver"
      :before-remove="beforeRemove"
      :custom-download="customDownload"
      :allowed-exts="['pdf', 'doc', 'docx', 'xls', 'xlsx', 'png', 'jpg']"
      :max-size-mb="20"
      :max-concurrent="2"
      accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg"
    />

    <el-button style="margin-top: 16px" type="primary" @click="handleSubmit">
      提交
    </el-button>
  </div>
</template>