feat!: 新增 FaImageUpload 组件,移除 ImageUpload 和 ImagesUpload 组件

This commit is contained in:
Hooray
2025-07-03 23:56:01 +08:00
parent 43a85f5b39
commit b153bda743
6 changed files with 223 additions and 533 deletions
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
import axios from 'axios'
import { filesize } from 'filesize'
import { toast } from 'vue-sonner'
import { useFaImagePreview } from '@/ui/components/FaImagePreview'
import { cn } from '@/utils'
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[]
max?: number
width?: number
height?: number
dimension?: {
width: number
height: number
}
size?: number
hideTips?: boolean
disabled?: boolean
}>(), {
method: 'post',
headers: () => ({}),
data: () => ({}),
name: 'file',
multiple: false,
ext: () => [],
max: 1,
width: 100,
height: 100,
size: 5 * 1024 * 1024,
hideTips: false,
disabled: false,
})
const emits = defineEmits<{
onSuccess: [response: any, file: File]
}>()
const images = defineModel<string[]>('modelValue', { required: true })
const isUploading = ref(false)
const uploadProgress = ref(0)
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
function onSelectFile(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) {
return
}
const selectedFiles = Array.from(input.files)
const remain = props.max === 0 ? selectedFiles.length : props.max - images.value.length
const filesToAdd: File[] = []
for (const file of selectedFiles.slice(0, remain)) {
const fileSize = file.size / 1024 / 1024
if (fileSize > props.size) {
toast.error(`上传图片大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`)
return
}
// 类型校验
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(' / ')} 格式`, {
description: file.name,
})
continue
}
}
// 大小校验
if (props.size > 0) {
if (file.size > props.size) {
toast.error(`上传图片大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`, {
description: `${file.name} (${filesize(file.size, { standard: 'jedec' })})`,
})
continue
}
}
filesToAdd.push(file)
}
fileInputRef.value!.value = ''
// 上传每个文件
filesToAdd.forEach((file) => {
isUploading.value = true
uploadProgress.value = 0
const formData = new FormData()
// 附加额外参数
Object.entries(props.data).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append(props.name, file)
// 处理 headers,确保为普通对象
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,
data: formData,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
},
}).then(async (response) => {
const url = await props.afterUpload?.(response.data)
if (url) {
images.value.push(url)
}
emits('onSuccess', response.data, file)
}).finally(() => {
isUploading.value = false
uploadProgress.value = 0
})
})
}
function onRemove(idx: number) {
images.value.splice(idx, 1)
}
function onPreview(index: number) {
useFaImagePreview().open(images.value, index)
}
function onMove(index: number, direction: 'forward' | 'backward') {
if (direction === 'forward' && index !== 0) {
images.value[index] = images.value.splice(index - 1, 1, images.value[index])[0]
}
if (direction === 'backward' && index !== images.value.length - 1) {
images.value[index] = images.value.splice(index + 1, 1, images.value[index])[0]
}
}
</script>
<template>
<div class="space-y-2">
<div class="flex flex-wrap gap-2">
<div
v-for="(img, index) in images" :key="img" class="group/image-upload relative flex items-center justify-center overflow-hidden border rounded-lg" :style="{
width: `${props.width}px`,
height: `${props.height}px`,
}"
>
<img :src="img" class="h-full w-full object-contain">
<div v-if="!props.disabled" class="absolute inset-0 grid grid-cols-2 place-items-center rounded-lg bg-black/50 p-2 text-white opacity-0 transition-opacity group-hover/image-upload:opacity-100">
<div class="flex-center cursor-pointer opacity-60 transition-all hover:(scale-110 opacity-100)" @click.prevent="onPreview(index)">
<FaIcon name="i-icon-park-outline:preview-open" class="size-6" />
</div>
<div class="flex-center cursor-pointer opacity-60 transition-all hover:(scale-110 opacity-100)" @click.prevent="onRemove(index)">
<FaIcon name="i-icon-park-outline:delete" class="size-6" />
</div>
<div
v-show="images.length > 1" :class="cn('flex-center cursor-pointer opacity-60 transition-all', {
'hover:(scale-110 opacity-100)': index !== 0,
'cursor-not-allowed': index === 0,
})" @click="onMove(index, 'forward')"
>
<FaIcon name="i-icon-park-outline:arrow-circle-left" class="size-6" />
</div>
<div
v-show="images.length > 1" :class="cn('flex-center cursor-pointer opacity-60 transition-all', {
'hover:(scale-110 opacity-100)': index !== images.length - 1,
'cursor-not-allowed': index === images.length - 1,
})" @click="onMove(index, 'backward')"
>
<FaIcon name="i-icon-park-outline:arrow-circle-right" class="size-6" />
</div>
</div>
</div>
<button
v-if="images.length < props.max || props.max === 0" class="relative flex-center overflow-hidden bg-transparent" :class="{
'cursor-not-allowed': props.disabled || isUploading,
}" :style="{
width: `${props.width}px`,
height: `${props.height}px`,
}" :disabled="props.disabled || isUploading" @click="fileInputRef?.click()"
>
<slot>
<div class="size-full flex-center border border-2 rounded-lg border-dashed p-0 text-primary">
<FaIcon name="i-icon-park-outline:upload" class="text-2xl text-card-foreground/50" />
</div>
</slot>
<template v-if="isUploading && uploadProgress > 0 && uploadProgress < 100">
<FaProgress :model-value="uploadProgress" class="absolute inset-x-1 inset-b-1 h-1 w-auto" />
</template>
<input ref="fileInputRef" type="file" accept="image/*" :multiple="props.multiple" class="hidden" @change="onSelectFile">
</button>
</div>
<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.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.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>