feat(FaFileUpload): 增加文件夹上传和自定义上传请求功能

This commit is contained in:
Hooray
2026-05-13 09:04:25 +08:00
parent a77416fbbe
commit 5f9a01fd29
7 changed files with 276 additions and 339 deletions
+16 -2
View File
@@ -5,6 +5,21 @@ const files = ref<FileItem[]>([
{ name: 'logo.svg', size: 1024 * 1024, url: 'https://fantastic-admin.hurui.me/logo.svg' },
])
function beforeUpload(file: File) {
const isPng = file.type === 'image/png'
const isLt200K = file.size <= 200 * 1024
if (!isPng) {
faToast.error('只能上传 PNG 文件')
return false
}
if (!isLt200K) {
faToast.error('文件大小不能超过 200KB')
return false
}
return true
}
function handleSuccess() {
faToast.success('模拟上传成功')
}
@@ -21,9 +36,8 @@ function handleClick(fileItem: FileItem) {
v-model="files"
action="/fake/upload"
:after-upload="(response) => response.data.url"
:before-upload="beforeUpload"
multiple
:ext="['png']"
:size="200 * 1024"
:max="5"
@on-success="handleSuccess"
@on-click="handleClick"
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { FileItem, FileUploadRequestOptions } from '@fantastic-admin/components'
const files = ref<FileItem[]>([])
async function httpRequest({ file, onProgress }: FileUploadRequestOptions) {
onProgress(30)
await new Promise(resolve => setTimeout(resolve, 300))
onProgress(100)
return {
url: URL.createObjectURL(file),
name: file.name,
}
}
function handleSuccess() {
faToast.success('自定义上传完成')
}
</script>
<template>
<FaFileUpload
v-model="files"
:http-request="httpRequest"
:after-upload="response => response.url"
:max="3"
@on-success="handleSuccess"
/>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { FileItem, FileUploadRequestOptions } from '@fantastic-admin/components'
const files = ref<FileItem[]>([])
async function httpRequest({ file, onProgress }: FileUploadRequestOptions) {
onProgress(50)
await new Promise(resolve => setTimeout(resolve, 200))
onProgress(100)
return {
url: URL.createObjectURL(file),
name: file.name,
}
}
</script>
<template>
<FaFileUpload
v-model="files"
directory
:max="0"
:http-request="httpRequest"
:after-upload="response => response.url"
/>
</template>
@@ -2,6 +2,8 @@
import Demo1 from './_demo1.vue'
import Demo2 from './_demo2.vue'
import Demo3 from './_demo3.vue'
import Demo4 from './_demo4.vue'
import Demo5 from './_demo5.vue'
</script>
<template>
@@ -16,5 +18,11 @@ import Demo3 from './_demo3.vue'
<FaPageMain title="自定义显示" main-class="p-4">
<Demo3 />
</FaPageMain>
<FaPageMain title="自定义上传请求" main-class="p-4">
<Demo4 />
</FaPageMain>
<FaPageMain title="文件夹上传" main-class="p-4">
<Demo5 />
</FaPageMain>
</div>
</template>
+1 -1
View File
@@ -11,7 +11,7 @@ export * from './src/drawer/index'
export { default as FaDrawer } from './src/drawer/index.vue'
export { default as FaDropdown } from './src/dropdown/index.vue'
export { default as FaFileUpload } from './src/file-upload/index.vue'
export type { FileItem } from './src/file-upload/index.vue'
export type { FileItem, FileUploadRequestOptions } from './src/file-upload/index.vue'
export { default as FaFixedBar } from './src/fixed-bar/index.vue'
export { default as FaHoverCard } from './src/hover-card/index.vue'
export { default as FaIcon } from './src/icon/index.vue'
+109 -216
View File
@@ -1,40 +1,52 @@
# FaFileUpload 文件上传
支持拖拽、点击和粘贴上传的文件上传组件,带进度显示和文件管理功能。
支持拖拽、点击、粘贴、自定义上传请求和文件夹上传的文件上传组件,带进度显示和文件管理功能。
## 使用场景
- 头像/图片上传
- 附件文件上传
- 批量文件上传
- 资料文件上传
- 证据/凭证上传
- 文档上传
- 文件夹批量上传
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `action` | `string` | **必需** | 上传接口地址 |
| `method` | `string` | `'post'` | 请求方法 |
| `headers` | `Headers \| Record<string, any>` | `{}` | 请求头 |
| `data` | `Record<string, any>` | `{}` | 额外表单数据 |
| `name` | `string` | `'file'` | 文件字段名 |
| `afterUpload` | `(response: any) => string \| Promise<string>` | - | 上传成功后的处理函数,返回文件 URL |
| `multiple` | `boolean` | `false` | 是否支持多选 |
| `ext` | `string[]` | `[]` | 允许的文件扩展名 |
| `size` | `number` | `10485760` (10MB) | 单个文件大小限制(字节) |
| `max` | `number` | `0` (无限制) | 最大上传数量 |
| `hideTips` | `boolean` | `false` | 是否隐藏提示信息 |
| `action` | `string` | `''` | 默认上传请求地址,使用 `httpRequest` 时可不传 |
| `method` | `string` | `'post'` | 默认上传请求方法 |
| `headers` | `Headers \| Record<string, any>` | `{}` | 默认上传请求头 |
| `data` | `Record<string, any>` | `{}` | 默认上传附加表单数据 |
| `name` | `string` | `'file'` | 默认上传文件字段名 |
| `afterUpload` | `(response: any) => string \| Promise<string>` | - | 上传成功后从响应中提取文件 URL |
| `beforeUpload` | `(file: File) => boolean \| Promise<boolean>` | - | 上传前钩子,返回 `false` 时跳过该文件 |
| `httpRequest` | `(options: FileUploadRequestOptions) => any \| Promise<any>` | - | 自定义上传请求,返回值会传给 `afterUpload` 和 `onSuccess` |
| `multiple` | `boolean` | `false` | 是否支持多选文件 |
| `max` | `number` | `0` | 最大上传数量,`0` 表示不限制 |
| `directory` | `boolean` | `false` | 是否选择文件夹;启用后只能选择文件夹,文件夹内文件会扁平化上传 |
| `disabled` | `boolean` | `false` | 是否禁用 |
```ts
interface FileUploadRequestOptions {
action: string
method: string
headers: Headers | Record<string, any>
data: Record<string, any>
name: string
file: File
onProgress: (percent: number) => void
}
```
### Model
| 名称 | 类型 | 说明 |
|------|------|------|
| `modelValue` | `FileItem[]` | 已上传的文件列表(必需) |
### FileItem 接口
### FileItem
```ts
interface FileItem {
@@ -62,7 +74,7 @@ interface FileItem {
## 粘贴上传
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的文件。粘贴上传会复用组件现有的数量、格式和大小校验逻辑。
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的文件。启用 `directory` 后,粘贴上传不可用。
## 示例
@@ -70,134 +82,17 @@ interface FileItem {
```vue
<script setup lang="ts">
const fileList = ref([])
</script>
import type { FileItem } from '@fantastic-admin/components'
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
/>
</template>
```
### 基础上传
```vue
<script setup lang="ts">
const fileList = ref([])
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
/>
</template>
```
### 限制文件类型
```vue
<script setup lang="ts">
const fileList = ref([])
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
:ext="['jpg', 'png', 'gif']"
/>
</template>
```
### 限制文件大小
```vue
<script setup lang="ts">
const fileList = ref([])
const maxSize = 5 * 1024 * 1024 // 5MB
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
:size="maxSize"
/>
</template>
```
### 限制上传数量
```vue
<script setup lang="ts">
const fileList = ref([])
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
:max="3"
multiple
/>
</template>
```
### 多选上传
```vue
<script setup lang="ts">
const fileList = ref([])
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
multiple
/>
</template>
```
### 带额外参数
```vue
<script setup lang="ts">
const fileList = ref([])
const token = useToken()
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
method="post"
:headers="{ Authorization: token.value }"
:data="{ userId: 123, type: 'avatar' }"
/>
</template>
```
### 处理上传结果
```vue
<script setup lang="ts">
const fileList = ref([])
const fileList = ref<FileItem[]>([])
function afterUpload(response: any) {
// 假设后端返回 { code: 200, data: { url: '...' } }
if (response.code === 200) {
return response.data.url
}
throw new Error('上传失败')
return response.data.url
}
</script>
<template>
<FaFileUpload
<FaFileUpload
v-model="fileList"
action="/api/upload"
:after-upload="afterUpload"
@@ -205,102 +100,100 @@ function afterUpload(response: any) {
</template>
```
### 监听上传成功
### 上传前校验
组件不再内置文件格式或大小校验。如需校验,请通过 `beforeUpload` 实现。
```vue
<script setup lang="ts">
const fileList = ref([])
import type { FileItem } from '@fantastic-admin/components'
function handleSuccess(response: any, file: File) {
console.log('上传成功:', response)
console.log('文件:', file)
}
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
@on-success="handleSuccess"
/>
</template>
```
### 自定义上传区域
```vue
<script setup lang="ts">
const fileList = ref([])
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
>
<div class="flex flex-col items-center justify-center h-48">
<FaIcon name="i-lucide:cloud-upload" class="text-4xl text-primary mb-4" />
<p class="text-sm text-muted-foreground">点击或拖拽文件到此处上传</p>
</div>
</FaFileUpload>
</template>
```
### 头像上传
```vue
<script setup lang="ts">
const fileList = ref([])
function handleClick(fileItem: FileItem) {
if (fileItem.url) {
// 预览头像
window.open(fileItem.url)
}
}
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
:ext="['jpg', 'png']"
:size="2 * 1024 * 1024"
:max="1"
@on-click="handleClick"
/>
</template>
```
### 附件上传列表
```vue
<script setup lang="ts">
const fileList = ref<FileItem[]>([])
function handleFileClick(fileItem: FileItem) {
if (fileItem.url) {
window.open(fileItem.url, '_blank')
function beforeUpload(file: File) {
const isLt2M = file.size <= 2 * 1024 * 1024
const isPdf = file.type === 'application/pdf'
if (!isPdf) {
faToast.error('只能上传 PDF 文件')
return false
}
if (!isLt2M) {
faToast.error('文件不能超过 2MB')
return false
}
return true
}
</script>
<template>
<FaFileUpload
<FaFileUpload
v-model="fileList"
action="/api/upload"
multiple
:max="10"
@on-click="handleFileClick"
:before-upload="beforeUpload"
/>
</template>
```
### 自定义上传请求
```vue
<script setup lang="ts">
import type { FileItem, FileUploadRequestOptions } from '@fantastic-admin/components'
const fileList = ref<FileItem[]>([])
async function httpRequest({ file, onProgress }: FileUploadRequestOptions) {
onProgress(50)
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/custom-upload', {
method: 'POST',
body: formData,
})
onProgress(100)
return await response.json()
}
</script>
<template>
<FaFileUpload
v-model="fileList"
:http-request="httpRequest"
:after-upload="response => response.url"
/>
</template>
```
### 文件夹上传
启用 `directory` 后,文件选择器只能选择文件夹。选择文件夹后,文件夹内的文件会作为扁平文件列表逐个上传。
```vue
<script setup lang="ts">
import type { FileItem } from '@fantastic-admin/components'
const fileList = ref<FileItem[]>([])
</script>
<template>
<FaFileUpload
v-model="fileList"
action="/api/upload"
directory
:max="0"
/>
</template>
```
## 注意事项
1. **文件列表**:`modelValue` 是必需的双向绑定属性
2. **上传进度**:上传过程中会显示进度条
3. **状态显示**:上传中/成功/失败状态会自动显示
4. **重新上传**:失败的文件可以点击重试
5. **删除文件**:上传成功的文件可以删除
6. **拖拽上传**:支持拖拽文件到上传区域
1. **文件列表**:`modelValue` 是必需的双向绑定属性。
2. **上传进度**:默认上传请求会自动更新进度;自定义 `httpRequest` 可调用 `onProgress` 更新进度。
3. **状态显示**:上传中、成功、失败状态会自动显示。
4. **重新上传**:失败的文件可以点击重试。
5. **删除文件**:上传成功的文件可以删除。
6. **上传校验**:组件不再内置格式、大小校验,请使用 `beforeUpload` 自行处理。
+86 -120
View File
@@ -1,35 +1,33 @@
<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
action?: string
method?: string
headers?: Headers | Record<string, any>
data?: Record<string, any>
name?: string
afterUpload?: (response: any) => string | Promise<string>
beforeUpload?: (file: File) => boolean | Promise<boolean>
httpRequest?: (options: FileUploadRequestOptions) => any | Promise<any>
multiple?: boolean
ext?: string[]
size?: number
max?: number
hideTips?: boolean
directory?: boolean
disabled?: boolean
}>(), {
action: '',
method: 'post',
headers: () => ({}),
data: () => ({}),
name: 'file',
multiple: false,
ext: () => [],
size: 10 * 1024 * 1024,
max: 0,
hideTips: false,
directory: false,
disabled: false,
})
@@ -49,6 +47,16 @@ export interface FileItem {
file?: File
}
export interface FileUploadRequestOptions {
action: string
method: string
headers: Headers | Record<string, any>
data: Record<string, any>
name: string
file: File
onProgress: (percent: number) => void
}
const containerRef = useTemplateRef<HTMLElement>('containerRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isDragging = ref(false)
@@ -58,70 +66,9 @@ const isMaxReached = computed(() => props.max > 0 && fileList.value.length >= pr
const canHandlePaste = computed(() =>
!props.disabled
&& !isMaxReached.value
&& !props.directory
&& (isHoveringContainer.value || isContainerFocused.value),
)
const extAliasMap: Record<string, string[]> = {
'jpeg': ['jpg', 'jpeg'],
'jpg': ['jpg', 'jpeg'],
'plain': ['txt'],
'msword': ['doc'],
'vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
'vnd.ms-excel': ['xls'],
'vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
'vnd.ms-powerpoint': ['ppt'],
'vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
'x-zip-compressed': ['zip'],
'zip': ['zip'],
'x-rar-compressed': ['rar'],
'vnd.rar': ['rar'],
'rar': ['rar'],
'x-7z-compressed': ['7z'],
}
const acceptedExts = computed(() => new Set(props.ext.flatMap(ext => normalizeExtVariants(ext))))
function normalizeExt(ext?: string) {
if (!ext) {
return ''
}
return ext.toLowerCase().trim().replace(/^\./, '').split('+')[0] ?? ''
}
function normalizeExtVariants(ext?: string) {
const normalizedExt = normalizeExt(ext)
if (!normalizedExt) {
return []
}
return extAliasMap[normalizedExt] ?? [normalizedExt]
}
function getFileExtVariants(file: File) {
const extVariants = new Set<string>()
const fileNameExt = file.name.split('.').pop()
normalizeExtVariants(fileNameExt).forEach(ext => extVariants.add(ext))
const mimeExt = file.type.split('/')[1]
normalizeExtVariants(mimeExt).forEach(ext => extVariants.add(ext))
return extVariants
}
function validateFile(file: File) {
if (acceptedExts.value.size > 0) {
const fileExtVariants = getFileExtVariants(file)
const isAccepted = [...fileExtVariants].some(ext => acceptedExts.value.has(ext))
if (!isAccepted) {
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式`, {
description: file.name || file.type,
})
return false
}
}
if (props.size > 0 && file.size > props.size) {
toast.error(`上传文件大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`, {
description: `${file.name || file.type} (${filesize(file.size, { standard: 'jedec' })})`,
})
return false
}
return true
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
@@ -192,7 +139,7 @@ function handleFiles(files: FileList | File[] | null) {
if (!files || props.disabled || isMaxReached.value) {
return false
}
const selectedFiles = [...files]
const selectedFiles = [...files].filter(file => file instanceof File)
const remain = props.max > 0 ? props.max - fileList.value.length : selectedFiles.length
if (remain <= 0) {
return false
@@ -200,29 +147,53 @@ function handleFiles(files: FileList | File[] | null) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
const filesToAdd = selectedFiles.slice(0, remain).filter(validateFile)
const filesToAdd = selectedFiles.slice(0, remain)
if (filesToAdd.length === 0) {
return false
}
filesToAdd.forEach(file => uploadFile(file))
filesToAdd.forEach((file) => {
void uploadFile(file)
})
return true
}
function uploadFile(file: File, index?: number) {
function getHeadersObject(headers: Headers | Record<string, any>) {
if (!(headers instanceof Headers)) {
return { ...headers }
}
const headersObj: Record<string, any> = {}
headers.forEach((value, key) => {
headersObj[key] = value
})
return headersObj
}
async function defaultHttpRequest(options: FileUploadRequestOptions) {
const formData = new FormData()
Object.entries(props.data).forEach(([key, value]) => {
Object.entries(options.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 }
formData.append(options.name, options.file)
const response = await axios({
url: options.action,
method: options.method,
headers: getHeadersObject(options.headers),
data: formData,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
options.onProgress(Math.round((progressEvent.loaded * 100) / progressEvent.total))
}
},
})
return response.data
}
async function uploadFile(file: File, index?: number) {
const canUpload = await props.beforeUpload?.(file)
if (canUpload === false) {
return
}
if (index === undefined) {
fileList.value.push({
name: file.name,
@@ -233,28 +204,28 @@ function uploadFile(file: File, index?: number) {
})
}
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'
try {
const response = await (props.httpRequest ?? defaultHttpRequest)({
action: props.action,
method: props.method,
headers: props.headers,
data: props.data,
name: props.name,
file,
onProgress: (percent) => {
fileList.value[currentFileIndex].progress = percent
},
})
const url = await props.afterUpload?.(response)
if (url) {
fileList.value[currentFileIndex].url = url
}
emits('onSuccess', response, file)
fileList.value[currentFileIndex].status = 'success'
}
catch {
fileList.value[currentFileIndex].status = 'error'
}
}
function removeFile(idx: number) {
@@ -291,22 +262,17 @@ function removeFile(idx: number) {
拖放或点击上传
</div>
</slot>
<input ref="fileInputRef" type="file" :multiple="props.multiple" :disabled="props.disabled" class="hidden" @change="e => onSelectFile((e.target as HTMLInputElement).files)">
<input
ref="fileInputRef"
type="file"
:multiple="props.directory || props.multiple"
:webkitdirectory="props.directory || undefined"
:directory="props.directory || undefined"
:disabled="props.disabled"
class="hidden"
@change="e => onSelectFile((e.target as HTMLInputElement).files)"
>
</button>
<div v-if="!props.hideTips && !props.disabled" class="text-xs text-card-foreground/50 flex flex-wrap gap-1 empty:hidden">
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
支持 {{ props.ext.join(' / ') }} 格式
</div>
<div class="after:content-[';_'] last:after:content-empty">
支持粘贴上传
</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="gap-2 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))]">
<div
v-for="(item, index) in fileList"