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

This commit is contained in:
Hooray
2026-05-13 09:00:14 +08:00
parent 2302186258
commit a77416fbbe
7 changed files with 289 additions and 319 deletions
+13 -3
View File
@@ -1,6 +1,18 @@
<script setup lang="ts">
const files = ref<string[]>([])
function beforeUpload(file: File) {
if (!file.type.startsWith('image/')) {
faToast.error('请选择图片文件')
return false
}
if (file.size > 200 * 1024) {
faToast.error('图片大小不能超过 200KB')
return false
}
return true
}
function handleSuccess() {
faToast.success('模拟上传成功')
}
@@ -11,11 +23,9 @@ function handleSuccess() {
v-model="files"
action="/fake/upload"
:after-upload="(response) => `${response.data.url}?fake=${Math.random()}`"
:before-upload="beforeUpload"
:width="200"
:height="130"
:dimension="{ width: 400, height: 260 }"
:ext="['png']"
:size="200 * 1024"
:max="0"
@on-success="handleSuccess"
>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { UploadRequestOptions } from '@fantastic-admin/components'
const files = ref<string[]>([])
async function httpRequest({ file, onProgress }: UploadRequestOptions) {
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>
<FaImageUpload
v-model="files"
:http-request="httpRequest"
:after-upload="response => response.url"
:max="3"
@on-success="handleSuccess"
/>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
const files = ref<string[]>([])
function beforeUpload(file: File) {
return file.type.startsWith('image/')
}
async function httpRequest({ file, onProgress }: {
file: File
onProgress: (percent: number) => void
}) {
onProgress(50)
await new Promise(resolve => setTimeout(resolve, 200))
onProgress(100)
return {
url: URL.createObjectURL(file),
name: file.name,
}
}
</script>
<template>
<FaImageUpload
v-model="files"
directory
:max="0"
:http-request="httpRequest"
:before-upload="beforeUpload"
: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>
@@ -13,8 +15,14 @@ import Demo3 from './_demo3.vue'
<FaPageMain title="多图上传" main-class="p-4">
<Demo2 />
</FaPageMain>
<FaPageMain title="自定义默认内容、容器大小、建议尺寸、限制类型、不限制数量" main-class="p-4">
<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
View File
@@ -17,6 +17,7 @@ export { default as FaHoverCard } from './src/hover-card/index.vue'
export { default as FaIcon } from './src/icon/index.vue'
export * from './src/image-preview/index'
export { default as FaImagePreview } from './src/image-preview/index.vue'
export type { UploadRequestOptions } from './src/image-upload/index.vue'
export { default as FaImageUpload } from './src/image-upload/index.vue'
export { default as FaInputOTP } from './src/input-otp/index.vue'
export { default as FaInput } from './src/input/index.vue'
+130 -212
View File
@@ -1,6 +1,6 @@
# FaImageUpload 图片上传
专门用于图片上传的组件,支持预览、排序和粘贴上传。
专门用于图片上传的组件,支持预览、排序、粘贴上传、自定义上传请求和文件夹上传。
## 使用场景
@@ -15,22 +15,33 @@
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `action` | `string` | **必需** | 上传接口地址 |
| `method` | `string` | `'post'` | 请求方法 |
| `headers` | `Headers \| Record<string, any>` | `{}` | 请求头 |
| `data` | `Record<string, any>` | `{}` | 额外表单数据 |
| `name` | `string` | `'file'` | 文件字段名 |
| `afterUpload` | `(response: any) => string \| Promise<string>` | - | 上传成功后处理函数 |
| `multiple` | `boolean` | `false` | 是否支持多选 |
| `ext` | `string[]` | `[]` | 允许的图片格式 |
| `max` | `number` | `1` | 最大上传数量 |
| `dimension` | `{ width: number, height: number }` | - | 建议图片尺寸 |
| `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: UploadRequestOptions) => any \| Promise<any>` | - | 自定义上传请求,返回值会传给 `afterUpload` 和 `onSuccess` |
| `multiple` | `boolean` | `false` | 是否支持多选文件 |
| `max` | `number` | `1` | 最大上传数量,`0` 表示不限制 |
| `width` | `number` | `100` | 预览图宽度 (px) |
| `height` | `number` | `100` | 预览图高度 (px) |
| `size` | `number` | `5242880` (5MB) | 单张图片大小限制 |
| `hideTips` | `boolean` | `false` | 是否隐藏提示 |
| `directory` | `boolean` | `false` | 是否选择文件夹;启用后只能选择文件夹,文件夹内文件会扁平化上传 |
| `disabled` | `boolean` | `false` | 是否禁用 |
```ts
interface UploadRequestOptions {
action: string
method: string
headers: Headers | Record<string, any>
data: Record<string, any>
name: string
file: File
onProgress: (percent: number) => void
}
```
### Model
| 名称 | 类型 | 说明 |
@@ -51,7 +62,7 @@
## 粘贴上传
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的图片。粘贴上传会复用组件现有的数量、格式和大小校验逻辑。
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的图片。启用 `directory` 后,粘贴上传不可用。
## 示例
@@ -59,46 +70,18 @@
```vue
<script setup lang="ts">
const images = ref([])
const images = ref<string[]>([])
function afterUpload(response: any) {
return response.url
}
</script>
<template>
<FaImageUpload
<FaImageUpload
v-model="images"
action="/api/upload"
/>
</template>
```
### 基础图片上传
```vue
<script setup lang="ts">
const images = ref([])
</script>
<template>
<FaImageUpload
v-model="images"
action="/api/upload"
/>
</template>
```
### 头像上传(单张)
```vue
<script setup lang="ts">
const avatar = ref([])
</script>
<template>
<FaImageUpload
v-model="avatar"
action="/api/upload"
:max="1"
:width="150"
:height="150"
:after-upload="afterUpload"
/>
</template>
```
@@ -107,106 +90,120 @@ const avatar = ref([])
```vue
<script setup lang="ts">
const gallery = ref([])
const gallery = ref<string[]>([])
function afterUpload(response: any) {
return response.data.url
}
</script>
<template>
<FaImageUpload
<FaImageUpload
v-model="gallery"
action="/api/upload"
multiple
:max="9"
:width="120"
:height="120"
/>
</template>
```
### 限制图片格式
```vue
<script setup lang="ts">
const images = ref([])
</script>
<template>
<FaImageUpload
v-model="images"
action="/api/upload"
:ext="['jpg', 'png', 'gif']"
/>
</template>
```
### 带尺寸提示
```vue
<script setup lang="ts">
const images = ref([])
</script>
<template>
<FaImageUpload
v-model="images"
action="/api/upload"
:dimension="{ width: 800, height: 600 }"
:max="1"
/>
</template>
```
### 处理上传结果
```vue
<script setup lang="ts">
const images = ref([])
function afterUpload(response: any) {
// 假设后端返回 { code: 200, data: { url: '...' } }
if (response.code === 200) {
return response.data.url
}
throw new Error('上传失败')
}
</script>
<template>
<FaImageUpload
v-model="images"
action="/api/upload"
:after-upload="afterUpload"
/>
</template>
```
### 商品图片上传
### 上传前校验
组件不再内置图片格式、大小或尺寸校验。如需校验,请通过 `beforeUpload` 实现。
```vue
<script setup lang="ts">
const productImages = ref([])
const images = ref<string[]>([])
function handleSuccess(response: any) {
console.log('上传成功:', response)
function beforeUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt3M = file.size <= 3 * 1024 * 1024
if (!isImage) {
faToast.error('只能上传图片')
return false
}
if (!isLt3M) {
faToast.error('图片不能超过 3MB')
return false
}
return true
}
function afterUpload(response: any) {
return response.url
}
</script>
<template>
<FaCard title="商品图片">
<FaImageUpload
v-model="productImages"
action="/api/upload"
multiple
:max="9"
:width="150"
:height="150"
:ext="['jpg', 'png', 'webp']"
:size="3 * 1024 * 1024"
@on-success="handleSuccess"
/>
<p class="text-sm text-muted-foreground mt-2">
建议尺寸 800x800,支持 JPG、PNG、WebP 格式,单张不超过 3MB
</p>
</FaCard>
<FaImageUpload
v-model="images"
action="/api/upload"
:before-upload="beforeUpload"
:after-upload="afterUpload"
/>
</template>
```
### 自定义上传请求
```vue
<script setup lang="ts">
const images = ref<string[]>([])
async function httpRequest({ file, onProgress }: any) {
onProgress(20)
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/api/custom-upload', {
method: 'POST',
body: formData,
})
onProgress(100)
return await response.json()
}
function afterUpload(response: any) {
return response.data.url
}
</script>
<template>
<FaImageUpload
v-model="images"
:http-request="httpRequest"
:after-upload="afterUpload"
/>
</template>
```
### 文件夹上传
启用 `directory` 后,文件选择器只能选择文件夹。选择文件夹后,文件夹内的文件会作为扁平文件列表逐个上传。
```vue
<script setup lang="ts">
const images = ref<string[]>([])
function afterUpload(response: any) {
return response.url
}
</script>
<template>
<FaImageUpload
v-model="images"
action="/api/upload"
directory
:max="0"
:after-upload="afterUpload"
/>
</template>
```
@@ -214,7 +211,7 @@ function handleSuccess(response: any) {
```vue
<script setup lang="ts">
const images = ref([])
const images = ref<string[]>([])
</script>
<template>
@@ -227,89 +224,10 @@ const images = ref([])
</template>
```
### 带加载状态
```vue
<script setup lang="ts">
const images = ref([])
const uploading = ref(false)
function handleUploadStart() {
uploading.value = true
}
function handleSuccess() {
uploading.value = false
}
</script>
<template>
<div>
<FaImageUpload
v-model="images"
action="/api/upload"
@on-success="handleSuccess"
/>
<div v-if="uploading" class="mt-2 text-sm text-muted-foreground">
正在上传图片...
</div>
</div>
</template>
```
### 图片编辑表单
```vue
<script setup lang="ts">
const form = ref({
title: '',
cover: [],
gallery: [],
})
function handleSubmit() {
// 提交表单
console.log(form.value)
}
</script>
<template>
<form class="space-y-6">
<FaInput v-model="form.title" label="标题" />
<div>
<FaLabel label="封面图片" />
<FaImageUpload
v-model="form.cover"
action="/api/upload"
:max="1"
:width="200"
:height="150"
/>
</div>
<div>
<FaLabel label="详情图片" />
<FaImageUpload
v-model="form.gallery"
action="/api/upload"
multiple
:max="9"
:width="100"
:height="100"
/>
</div>
<FaButton @click="handleSubmit">保存</FaButton>
</form>
</template>
```
## 注意事项
1. **图片预览**:上传成功后可点击预览大图
2. **图片排序**:多图上传时可拖拽排序(通过左右箭头按钮)
3. **删除图片**:鼠标悬停显示删除按钮
4. **上传进度**:上传时显示进度条
5. **格式校验**:根据 `ext` 属性校验图片格式
6. **大小限制**:根据 `size` 属性校验图片大小
1. **图片预览**:上传成功后可点击预览大图。
2. **图片排序**:多图上传时可通过左右箭头调整顺序。
3. **删除图片**:鼠标悬停显示删除按钮。
4. **上传进度**:默认上传请求会自动更新进度;自定义 `httpRequest` 可调用 `onProgress` 更新进度。
5. **上传校验**:组件不再内置格式、大小、尺寸校验,请使用 `beforeUpload` 自行处理。
+74 -103
View File
@@ -1,45 +1,47 @@
<script setup lang="ts">
import axios from 'axios'
import { filesize } from 'filesize'
import { toast } from 'vue-sonner'
import { cn } from '../../utils'
import { useFaImagePreview } from '../image-preview'
export interface UploadRequestOptions {
action: string
method: string
headers: Headers | Record<string, any>
data: Record<string, any>
name: string
file: File
onProgress: (percent: number) => void
}
defineOptions({
name: 'FaImageUpload',
})
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: UploadRequestOptions) => any | Promise<any>
multiple?: boolean
ext?: string[]
max?: number
width?: number
height?: number
dimension?: {
width: number
height: number
}
size?: number
hideTips?: boolean
directory?: boolean
disabled?: boolean
}>(), {
action: '',
method: 'post',
headers: () => ({}),
data: () => ({}),
name: 'file',
multiple: false,
ext: () => [],
max: 1,
width: 100,
height: 100,
size: 5 * 1024 * 1024,
hideTips: false,
directory: false,
disabled: false,
})
@@ -63,10 +65,10 @@ const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isHoveringContainer = ref(false)
const isContainerFocused = ref(false)
const isUploading = computed(() => activeUploadCount.value > 0)
const acceptedExts = computed(() => new Set(props.ext.flatMap(ext => normalizeExtVariants(ext))))
const canHandlePaste = computed(() =>
!props.disabled
&& !isUploading.value
&& !props.directory
&& (isHoveringContainer.value || isContainerFocused.value),
)
@@ -99,92 +101,69 @@ watch(images, (currentImages) => {
immediate: true,
})
function normalizeExt(ext?: string) {
if (!ext) {
return ''
function getHeadersObject(headers: Headers | Record<string, any>) {
if (!(headers instanceof Headers)) {
return { ...headers }
}
return ext.toLowerCase().trim().replace(/^\./, '').split('+')[0] ?? ''
const headersObj: Record<string, any> = {}
headers.forEach((value, key) => {
headersObj[key] = value
})
return headersObj
}
function normalizeExtVariants(ext?: string) {
const normalizedExt = normalizeExt(ext)
if (!normalizedExt) {
return []
}
if (normalizedExt === 'jpg' || normalizedExt === 'jpeg') {
return ['jpg', 'jpeg']
}
return [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 uploadFile(file: File) {
activeUploadCount.value += 1
uploadProgress.value = 0
async function defaultHttpRequest(options: UploadRequestOptions) {
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 }
}
return axios({
url: props.action,
method: props.method,
headers: headersObj,
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) {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
options.onProgress(Math.round((progressEvent.loaded * 100) / progressEvent.total))
}
},
}).then(async (response) => {
const url = await props.afterUpload?.(response.data)
})
return response.data
}
async function uploadFile(file: File) {
const canUpload = await props.beforeUpload?.(file)
if (canUpload === false) {
return
}
activeUploadCount.value += 1
uploadProgress.value = 0
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) => {
uploadProgress.value = percent
},
})
const url = await props.afterUpload?.(response)
if (url) {
images.value.push(url)
}
emits('onSuccess', response.data, file)
}).finally(() => {
emits('onSuccess', response, file)
}
finally {
activeUploadCount.value -= 1
if (!isUploading.value) {
uploadProgress.value = 0
}
})
}
}
function onSelectFile(e: Event) {
@@ -233,7 +212,7 @@ function handleFiles(files: FileList | File[] | null) {
if (!files || props.disabled || isUploading.value) {
return false
}
const selectedFiles = [...files]
const selectedFiles = [...files].filter(file => file instanceof File)
const remain = props.max === 0 ? selectedFiles.length : props.max - images.value.length
if (remain <= 0) {
return false
@@ -241,7 +220,7 @@ 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
}
@@ -353,26 +332,18 @@ function onBeforeLeave(el: Element) {
<template v-if="isUploading && uploadProgress > 0 && uploadProgress < 100">
<FaProgress :model-value="uploadProgress" class="h-1 w-auto inset-x-1 inset-b-1 absolute" />
</template>
<input ref="fileInputRef" type="file" accept="image/*" :multiple="props.multiple" class="hidden" @change="onSelectFile">
<input
ref="fileInputRef"
type="file"
accept="image/*"
:multiple="props.directory || props.multiple"
:webkitdirectory="props.directory || undefined"
:directory="props.directory || undefined"
class="hidden"
@change="onSelectFile"
>
</button>
</TransitionGroup>
<div v-if="!props.hideTips" class="text-xs text-card-foreground/50 flex flex-wrap gap-1 empty:hidden">
<div v-if="props.dimension" class="after:content-[';_'] last:after:content-empty">
建议尺寸为 {{ props.dimension.width }}*{{ props.dimension.height }}
</div>
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
支持 {{ props.ext.join(' / ') }} 格式
</div>
<div v-if="!props.disabled" 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 > 1" class="after:content-[';_'] last:after:content-empty">
数量不超过 {{ props.max }} 个
</div>
</div>
</div>
</template>