(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>