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
+1
View File
@@ -37,6 +37,7 @@
"element-plus": "^2.10.2",
"eruda": "^3.4.3",
"es-toolkit": "^1.39.5",
"filesize": "^10.1.6",
"hotkeys-js": "^3.13.14",
"lucide-vue-next": "^0.525.0",
"mitt": "^3.0.1",
+3
View File
@@ -47,6 +47,9 @@ importers:
es-toolkit:
specifier: ^1.39.5
version: 1.39.5
filesize:
specifier: ^10.1.6
version: 10.1.6
hotkeys-js:
specifier: ^3.13.14
version: 3.13.14
-253
View File
@@ -1,253 +0,0 @@
<script setup lang="ts">
import type { UploadProps } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'ImageUpload',
})
const props = withDefaults(
defineProps<{
action: UploadProps['action']
headers?: UploadProps['headers']
data?: UploadProps['data']
name?: UploadProps['name']
size?: number
width?: number
height?: number
placeholder?: string
notip?: boolean
ext?: string[]
httpRequest?: UploadProps['httpRequest']
}>(),
{
name: 'file',
size: 2,
width: 150,
height: 150,
placeholder: '',
notip: false,
ext: () => ['jpg', 'png', 'gif', 'bmp'],
},
)
const emits = defineEmits<{
onSuccess: [
res: any,
]
}>()
const url = defineModel<string>({
default: '',
})
const uploadData = ref({
imageViewerVisible: false,
progress: {
preview: '',
percent: 0,
},
})
// 预览
function preview() {
uploadData.value.imageViewerVisible = true
}
// 关闭预览
function previewClose() {
uploadData.value.imageViewerVisible = false
}
// 移除
function remove() {
url.value = ''
}
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!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
}
return isTypeOk && isSizeOk
}
const onProgress: UploadProps['onProgress'] = (file) => {
uploadData.value.progress.percent = ~~file.percent
}
const onSuccess: UploadProps['onSuccess'] = (res) => {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emits('onSuccess', res)
}
</script>
<template>
<div class="upload-container">
<ElUpload
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
:http-request="httpRequest"
drag
class="image-upload"
>
<ElImage v-if="url === ''" :src="url === '' ? placeholder : url" :style="`width:${width}px;height:${height}px;`" fit="fill">
<template #error>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<FaIcon name="i-ep:plus" class="icon" />
</div>
</template>
</ElImage>
<div v-else class="image">
<ElImage :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<div class="mask">
<div class="actions">
<span @click.stop="preview">
<FaIcon name="i-ep:zoom-in" class="icon" />
</span>
<span @click.stop="remove">
<FaIcon name="i-ep:delete" class="icon" />
</span>
</div>
</div>
</div>
<div v-show="url === '' && uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</ElUpload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`" type="info" show-icon :closable="false" />
</div>
</div>
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="[url]" teleported @close="previewClose" />
</div>
</template>
<style scoped>
.upload-container {
line-height: initial;
}
.el-image {
display: block;
}
.image {
position: relative;
overflow: hidden;
border-radius: 6px;
.mask {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
opacity: 0;
transition: opacity 0.3s;
.actions {
position: absolute;
top: 50%;
left: 50%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
transform: translateX(-50%) translateY(-50%);
span {
width: 50%;
color: var(--el-color-white);
text-align: center;
cursor: pointer;
transition: color 0.1s, transform 0.1s;
&:hover {
transform: scale(1.5);
}
.icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.image-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
.icon {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: "";
background-color: var(--el-overlay-color-lighter);
}
.el-progress {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
transform: translateX(-50%) translateY(-50%);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
}
}
</style>
-278
View File
@@ -1,278 +0,0 @@
<script setup lang="ts">
import type { UploadProps } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'ImagesUpload',
})
const props = withDefaults(
defineProps<{
action: UploadProps['action']
headers?: UploadProps['headers']
data?: UploadProps['data']
name?: UploadProps['name']
size?: number
max?: number
width?: number
height?: number
placeholder?: string
notip?: boolean
ext?: string[]
httpRequest?: UploadProps['httpRequest']
}>(),
{
name: 'file',
size: 2,
max: 3,
width: 150,
height: 150,
placeholder: '',
notip: false,
ext: () => ['jpg', 'png', 'gif', 'bmp'],
},
)
const emits = defineEmits<{
onSuccess: [
res: any,
]
}>()
const url = defineModel<string[]>({
default: [],
})
const uploadData = ref({
dialogImageIndex: 0,
imageViewerVisible: false,
progress: {
preview: '',
percent: 0,
},
})
// 预览
function preview(index: number) {
uploadData.value.dialogImageIndex = index
uploadData.value.imageViewerVisible = true
}
// 关闭预览
function previewClose() {
uploadData.value.imageViewerVisible = false
}
// 移除
function remove(index: number) {
url.value.splice(index, 1)
}
// 移动
function move(index: number, type: 'left' | 'right') {
if (type === 'left' && index !== 0) {
url.value[index] = url.value.splice(index - 1, 1, url.value[index])[0]
}
if (type === 'right' && index !== url.value.length - 1) {
url.value[index] = url.value.splice(index + 1, 1, url.value[index])[0]
}
}
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!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
}
return isTypeOk && isSizeOk
}
const onProgress: UploadProps['onProgress'] = (file) => {
uploadData.value.progress.percent = ~~file.percent
}
const onSuccess: UploadProps['onSuccess'] = (res) => {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emits('onSuccess', res)
}
</script>
<template>
<div class="upload-container">
<div v-for="(item, index) in (url as string[])" :key="index" class="images">
<ElImage v-if="index < max" :src="item" :style="`width:${width}px;height:${height}px;`" fit="cover" />
<div class="mask">
<div class="actions">
<span @click="preview(index)">
<FaIcon name="i-ep:zoom-in" class="icon" />
</span>
<span @click="remove(index)">
<FaIcon name="i-ep:delete" class="icon" />
</span>
<span v-show="url.length > 1" :class="{ disabled: index === 0 }" @click="move(index, 'left')">
<FaIcon name="i-ep:back" class="icon" />
</span>
<span v-show="url.length > 1" :class="{ disabled: index === url.length - 1 }" @click="move(index, 'right')">
<FaIcon name="i-ep:right" class="icon" />
</span>
</div>
</div>
</div>
<ElUpload
v-show="url.length < max"
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
:http-request="httpRequest"
drag
class="images-upload"
>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<FaIcon name="i-ep:plus" class="icon" />
</div>
<div v-show="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</ElUpload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`" type="info" show-icon :closable="false" />
</div>
</div>
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="url as string[]" :initial-index="uploadData.dialogImageIndex" teleported @close="previewClose" />
</div>
</template>
<style scoped>
.upload-container {
line-height: initial;
}
.el-image {
display: block;
}
.images {
position: relative;
display: inline-block;
margin-inline-end: 10px;
overflow: hidden;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
.mask {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
opacity: 0;
transition: opacity 0.3s;
.actions {
position: absolute;
top: 50%;
left: 50%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
transform: translateX(-50%) translateY(-50%);
span {
width: 50%;
color: var(--el-color-white);
text-align: center;
cursor: pointer;
transition: color 0.1s, transform 0.1s;
&.disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
&:hover:not(.disabled) {
transform: scale(1.5);
}
.icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.images-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
.icon {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: "";
background-color: var(--el-overlay-color-lighter);
}
.el-progress {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
transform: translateX(-50%) translateY(-50%);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
}
}
</style>
+1 -2
View File
@@ -25,6 +25,7 @@ declare module 'vue' {
FaHoverCard: typeof import('./../ui/components/FaHoverCard/index.vue')['default']
FaIcon: typeof import('./../ui/components/FaIcon/index.vue')['default']
FaImagePreview: typeof import('./../ui/components/FaImagePreview/index.vue')['default']
FaImageUpload: typeof import('./../ui/components/FaImageUpload/index.vue')['default']
FaInput: typeof import('./../ui/components/FaInput/index.vue')['default']
FaKbd: typeof import('./../ui/components/FaKbd/index.vue')['default']
FaModal: typeof import('./../ui/components/FaModal/index.vue')['default']
@@ -47,8 +48,6 @@ declare module 'vue' {
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']
ImagesUpload: typeof import('./../components/ImagesUpload/index.vue')['default']
ImageUpload: typeof import('./../components/ImageUpload/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+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>