新增 FaFileUpload 组件,移除 FileUpload 组件

This commit is contained in:
Hooray
2025-07-05 15:41:00 +08:00
parent fb70031109
commit d25dbeffdc
7 changed files with 295 additions and 148 deletions
-143
View File
@@ -1,143 +0,0 @@
<script setup lang="ts">
import type { UploadProps, UploadUserFile } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'FileUpload',
})
const props = withDefaults(
defineProps<{
action: UploadProps['action']
headers?: UploadProps['headers']
data?: UploadProps['data']
name?: UploadProps['name']
size?: number
max?: number
files?: UploadUserFile[]
notip?: boolean
ext?: string[]
httpRequest?: UploadProps['httpRequest']
}>(),
{
name: 'file',
size: 2,
max: 3,
files: () => [],
notip: false,
ext: () => ['zip', 'rar'],
},
)
const emits = defineEmits<{
onSuccess: [
res: any,
file: UploadUserFile,
fileList: UploadUserFile[],
]
onRemove: [
file: UploadUserFile,
fileList: UploadUserFile[],
]
}>()
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const fileName = file.name.split('.')
const fileExt = fileName.at(-1) ?? ''
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`)
}
if (!isSizeOk) {
toast.error(`上传文件大小不能超过 ${props.size}MB!`)
}
return isTypeOk && isSizeOk
}
const onExceed: UploadProps['onExceed'] = () => {
toast.warning('文件上传超过限制')
}
const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
emits('onSuccess', res, file, fileList)
}
const onRemove: UploadProps['onRemove'] = (file, fileList) => {
emits('onRemove', file, fileList)
}
const onPreview: UploadProps['onPreview'] = (e) => {
const getBlob = (url: string) => new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'blob'
xhr.onload = () => {
if (xhr.status === 200) {
resolve(xhr.response)
}
}
xhr.send()
})
getBlob(e.url!).then((blob: any) => {
const a = document.createElement('a')
const url = window.URL.createObjectURL(blob)
const event = new MouseEvent('click')
a.target = '_blank'
a.download = e.name
a.href = url
a.dispatchEvent(event)
})
}
</script>
<template>
<ElUpload
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-exceed="onExceed"
:on-success="onSuccess"
:on-remove="onRemove"
:on-preview="onPreview"
:http-request="httpRequest"
:file-list="files"
:limit="max"
drag
>
<div class="slot">
<FaIcon name="i-ep:upload-filled" class="el-icon--upload" />
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
</div>
<template #tip>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<ElAlert :title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`" type="info" show-icon :closable="false" />
</div>
</div>
</template>
</ElUpload>
</template>
<style scoped>
:deep(.el-upload.is-drag) {
display: inline-block;
.el-upload-dragger {
padding: 0;
}
&.is-dragover {
border-width: 1px;
}
.slot {
width: 300px;
padding: 40px 0;
}
}
</style>
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineFakeRoute } from 'vite-plugin-fake-server/client'
export default defineFakeRoute([
{
url: '/mock/upload/image',
url: '/mock/upload',
method: 'post',
response: () => {
return {
+8
View File
@@ -93,6 +93,14 @@ const routes: RouteRecordRaw = {
title: '下拉菜单',
},
},
{
path: 'fileupload',
name: 'componentExampleBuiltInFileUpload',
component: () => import('@/views/component_built_in_example/fileupload.vue'),
meta: {
title: '文件上传',
},
},
{
path: 'fixedactionbar',
name: 'componentExampleBuiltInFixedactionbar',
+1 -1
View File
@@ -21,6 +21,7 @@ declare module 'vue' {
FaDivider: typeof import('./../ui/components/FaDivider/index.vue')['default']
FaDrawer: typeof import('./../ui/components/FaDrawer/index.vue')['default']
FaDropdown: typeof import('./../ui/components/FaDropdown/index.vue')['default']
FaFileUpload: typeof import('./../ui/components/FaFileUpload/index.vue')['default']
FaFixedActionBar: typeof import('./../ui/components/FaFixedActionBar/index.vue')['default']
FaHoverCard: typeof import('./../ui/components/FaHoverCard/index.vue')['default']
FaIcon: typeof import('./../ui/components/FaIcon/index.vue')['default']
@@ -48,7 +49,6 @@ declare module 'vue' {
FaTabs: typeof import('./../ui/components/FaTabs/index.vue')['default']
FaToast: typeof import('./../ui/components/FaToast/index.vue')['default']
FaTooltip: typeof import('./../ui/components/FaTooltip/index.vue')['default']
FileUpload: typeof import('./../components/FileUpload/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import axios from 'axios'
import { filesize } from 'filesize'
import { toast } from 'vue-sonner'
defineOptions({
name: 'FaFileUpload',
})
const props = withDefaults(defineProps<{
action: string
method?: string
headers?: Headers | Record<string, any>
data?: Record<string, any>
name?: string
afterUpload?: (response: any) => string | Promise<string>
multiple?: boolean
ext?: string[]
size?: number
max?: number
hideTips?: boolean
disabled?: boolean
}>(), {
method: 'post',
headers: () => ({}),
data: () => ({}),
name: 'file',
multiple: false,
ext: () => [],
size: 10 * 1024 * 1024,
max: 0,
hideTips: false,
disabled: false,
})
const emits = defineEmits<{
onSuccess: [response: any, file: File]
onClick: [fileItem: FileItem, index: number]
}>()
const fileList = defineModel<FileItem[]>('modelValue', { required: true })
export interface FileItem {
name: string
size: number
url?: string
status?: 'uploading' | 'success' | 'error'
progress?: number
file?: File
}
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isDragging = ref(false)
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
return
}
isDragging.value = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
isDragging.value = false
}
function handleDrop(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
return
}
isDragging.value = false
if (e.dataTransfer?.files) {
onSelectFile(e.dataTransfer.files)
}
}
function onSelectFile(files: FileList | File[] | null) {
if (!files) {
return
}
const selectedFiles = Array.from(files)
// 数量限制
const remain = props.max > 0 ? props.max - fileList.value.length : selectedFiles.length
const filesToAdd: File[] = []
for (const file of selectedFiles.slice(0, remain)) {
// 类型校验
if (props.ext.length > 0) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!props.ext.map(e => e.toLowerCase()).includes(ext || '')) {
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式`)
continue
}
}
// 大小校验
if (props.size > 0) {
if (file.size > props.size) {
toast.error(`上传文件大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`)
continue
}
}
filesToAdd.push(file)
}
fileInputRef.value!.value = ''
filesToAdd.forEach(file => uploadFile(file))
}
function uploadFile(file: File, index?: number) {
const formData = new FormData()
Object.entries(props.data).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append(props.name, file)
let headersObj: Record<string, any> = {}
if (props.headers instanceof Headers) {
props.headers.forEach((value, key) => {
headersObj[key] = value
})
}
else {
headersObj = { ...props.headers }
}
if (index === undefined) {
fileList.value.push({
name: file.name,
size: file.size,
status: 'uploading',
progress: 0,
file,
})
}
const currentFileIndex = index ?? fileList.value.length - 1
axios({
url: props.action,
method: props.method,
headers: headersObj,
data: formData,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
fileList.value[currentFileIndex].progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
},
})
.then(async (response) => {
const url = await props.afterUpload?.(response.data)
if (url) {
fileList.value[currentFileIndex].url = url
}
emits('onSuccess', response.data, file)
fileList.value[currentFileIndex].status = 'success'
})
.catch(() => {
fileList.value[currentFileIndex].status = 'error'
})
}
function removeFile(idx: number) {
fileList.value.splice(idx, 1)
}
</script>
<template>
<div class="space-y-2">
<button
class="h-40 w-full flex flex-col cursor-pointer items-center justify-center border border-2 rounded-lg border-dashed bg-transparent p-4 transition-all"
:class="{
'border-primary bg-primary/5': isDragging,
'opacity-50 cursor-not-allowed': props.disabled || (props.max > 0 && fileList.length >= props.max),
}"
:disabled="props.disabled || (props.max > 0 && fileList.length >= props.max)"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="fileInputRef?.click()"
>
<FaIcon name="i-icon-park-outline:upload" class="mb-2 text-2xl text-card-foreground/50" />
<div class="text-sm text-card-foreground/70">
将文件拖到此处,或<span class="cursor-pointer text-primary font-bold">点击上传</span>
</div>
<input ref="fileInputRef" type="file" :multiple="props.multiple" :disabled="props.disabled" class="hidden" @change="e => onSelectFile((e.target as HTMLInputElement).files)">
</button>
<div v-if="!props.hideTips && !props.disabled" class="flex flex-wrap gap-1 text-xs text-card-foreground/50 empty:hidden">
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
{{ `支持 ${props.ext.join(' / ')} 格式` }}
</div>
<div v-if="props.size > 0" class="after:content-[';_'] last:after:content-empty">
{{ `大小不超过 ${filesize(props.size, { standard: 'jedec' })}` }}
</div>
<div v-if="props.max > 0" class="after:content-[';_'] last:after:content-empty">
{{ `数量不超过 ${props.max} 个` }}
</div>
</div>
<div v-if="fileList.length > 0" class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
<div
v-for="(item, index) in fileList"
:key="item.name + index"
class="group/file-upload-item relative flex items-center gap-2 border rounded-lg py-2 pe-2 ps-3"
:class="[
item.status === 'error' ? 'border-red-500 bg-red-500/10' : '',
]"
@click="emits('onClick', item, index)"
>
<div class="flex-1 truncate">
<div class="truncate text-sm font-medium">
{{ item.name }}
</div>
<div class="text-xs text-card-foreground/50">
{{ filesize(item.size, { standard: 'jedec' }) }}
</div>
</div>
<div v-if="item.status === 'uploading'" class="pointer-events-none absolute inset-0 z-0 bg-primary/5" :style="{ width: `${item.progress}%` }" />
<div v-else-if="item.status === 'success'" class="size-10 flex-center">
<FaIcon name="i-ix:upload-success" class="text-lg text-green-600" />
</div>
<div v-else-if="item.status === 'error'" class="size-10 flex-center">
<FaIcon name="i-ix:upload-fail" class="text-lg text-red-600" />
</div>
<FaButtonGroup v-if="!props.disabled" class="absolute inset-e-2 top-1/2 opacity-0 transition-opacity -translate-y-1/2 group-hover/file-upload-item:opacity-100">
<FaButton v-if="item.status === 'error'" variant="outline" size="icon" @click.stop="uploadFile(item.file!, index)">
<FaIcon name="i-icon-park-outline:upload" class="cursor-pointer text-lg" />
</FaButton>
<FaButton v-if="item.status !== 'uploading'" variant="outline" size="icon" @click.stop="removeFile(index)">
<FaIcon name="i-icon-park-outline:delete" class="cursor-pointer text-lg text-red-500" />
</FaButton>
</FaButtonGroup>
</div>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<route lang="yaml">
meta:
enabled: false
</route>
<script setup lang="ts">
import type { FileItem } from '@/ui/components/FaFileUpload/index.vue'
import { toast } from 'vue-sonner'
const files1 = ref<FileItem[]>([])
const files2 = ref<FileItem[]>([
{ name: 'logo.svg', size: 1024 * 1024, url: 'https://fantastic-admin.hurui.me/logo.svg' },
])
function handleSuccess() {
toast.success('模拟上传成功')
}
function handleClick(fileItem: FileItem) {
toast.info(fileItem.name, {
description: fileItem.url,
})
}
</script>
<template>
<div>
<FaPageHeader title="文件上传" description="FaFileUpload" />
<FaPageMain>
<FaFileUpload
v-model="files1"
action="/mock/upload"
:after-upload="(response) => response.data.url"
multiple
@on-success="handleSuccess"
/>
</FaPageMain>
<FaPageMain title="限制上传数量、大小、类型、点击事件">
<FaFileUpload
v-model="files2"
action="/mock/upload"
:after-upload="(response) => response.data.url"
multiple
:ext="['png']"
:size="200 * 1024"
:max="5"
@on-success="handleSuccess"
@on-click="handleClick"
/>
</FaPageMain>
</div>
</template>
@@ -21,7 +21,7 @@ function handleSuccess() {
<FaPageMain>
<FaImageUpload
v-model="files1"
action="/mock/upload/image"
action="/mock/upload"
:after-upload="(response) => response.data.url"
@on-success="handleSuccess"
/>
@@ -29,7 +29,7 @@ function handleSuccess() {
<FaPageMain title="多图上传">
<FaImageUpload
v-model="files2"
action="/mock/upload/image"
action="/mock/upload"
:after-upload="(response) => response.data.url"
:max="2"
@on-success="handleSuccess"
@@ -38,7 +38,7 @@ function handleSuccess() {
<FaPageMain title="自定义默认内容、容器大小、建议尺寸、限制类型、不限制数量">
<FaImageUpload
v-model="files3"
action="/mock/upload/image"
action="/mock/upload"
:after-upload="(response) => response.data.url"
:width="200"
:height="130"