feat: FaImageUpload 组件增加入场、出场、排序动画

This commit is contained in:
Hooray
2026-04-19 02:39:21 +08:00
parent 1c81115605
commit 38e6abab21
4 changed files with 98 additions and 7 deletions
+1 -1
View File
@@ -185,4 +185,4 @@ declare global {
const FaTooltip: typeof import('@fantastic-admin/components')['FaTooltip']
const FaTree: typeof import('@fantastic-admin/components')['FaTree']
const FaTrend: typeof import('@fantastic-admin/components')['FaTrend']
}
}
@@ -10,7 +10,7 @@ function handleSuccess() {
<FaImageUpload
v-model="files"
action="/fake/upload"
:after-upload="(response) => response.data.url"
:after-upload="(response) => `${response.data.url}?fake=${Math.random()}`"
:max="2"
@on-success="handleSuccess"
/>
@@ -10,7 +10,7 @@ function handleSuccess() {
<FaImageUpload
v-model="files"
action="/fake/upload"
:after-upload="(response) => response.data.url"
:after-upload="(response) => `${response.data.url}?fake=${Math.random()}`"
:width="200"
:height="130"
:dimension="{ width: 400, height: 260 }"
+95 -4
View File
@@ -49,8 +49,15 @@ const emits = defineEmits<{
const images = defineModel<string[]>('modelValue', { required: true })
interface ImageItem {
key: string
src: string
}
const activeUploadCount = ref(0)
const uploadProgress = ref(0)
const imageItemSeed = shallowRef(0)
const imageItems = ref<ImageItem[]>([])
const containerRef = useTemplateRef<HTMLElement>('containerRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isHoveringContainer = ref(false)
@@ -63,6 +70,35 @@ const canHandlePaste = computed(() =>
&& (isHoveringContainer.value || isContainerFocused.value),
)
watch(images, (currentImages) => {
const reusableItems = new Map<string, ImageItem[]>()
imageItems.value.forEach((item) => {
const itemsForSrc = reusableItems.get(item.src)
if (itemsForSrc) {
itemsForSrc.push(item)
}
else {
reusableItems.set(item.src, [item])
}
})
imageItems.value = currentImages.map((src) => {
const reusableItem = reusableItems.get(src)?.shift()
if (reusableItem) {
return reusableItem
}
imageItemSeed.value += 1
return {
key: `fa-image-upload-item-${imageItemSeed.value}`,
src,
}
})
}, {
deep: true,
immediate: true,
})
function normalizeExt(ext?: string) {
if (!ext) {
return ''
@@ -231,6 +267,20 @@ function onMove(index: number, direction: 'forward' | 'backward') {
images.value[index] = images.value.splice(index + 1, 1, images.value[index])[0]
}
}
function onBeforeLeave(el: Element) {
if (!(el instanceof HTMLElement) || !(el.parentElement instanceof HTMLElement)) {
return
}
const elementRect = el.getBoundingClientRect()
const listRect = el.parentElement.getBoundingClientRect()
el.style.left = `${elementRect.left - listRect.left}px`
el.style.top = `${elementRect.top - listRect.top}px`
el.style.width = `${elementRect.width}px`
el.style.height = `${elementRect.height}px`
}
</script>
<template>
@@ -243,14 +293,19 @@ function onMove(index: number, direction: 'forward' | 'backward') {
@focusin="onContainerFocusIn"
@focusout="onContainerFocusOut"
>
<div class="flex flex-wrap gap-2">
<TransitionGroup
tag="div"
name="fa-image-upload-list"
class="fa-image-upload-list flex flex-wrap gap-2"
@before-leave="onBeforeLeave"
>
<div
v-for="(img, index) in images" :key="img" class="group/image-upload border rounded-lg flex items-center justify-center relative overflow-hidden" :style="{
v-for="(item, index) in imageItems" :key="item.key" class="fa-image-upload-item group/image-upload border rounded-lg flex items-center justify-center relative overflow-hidden" :style="{
width: `${props.width}px`,
height: `${props.height}px`,
}"
>
<img :src="img" class="h-full w-full object-contain">
<img :src="item.src" class="h-full w-full object-contain">
<div v-if="!props.disabled" class="text-white p-2 rounded-lg bg-black/50 opacity-0 grid grid-cols-2 transition-opacity inset-0 place-items-center absolute group-hover/image-upload:opacity-100">
<div class="opacity-60 flex-center cursor-pointer transition-all hover:(opacity-100 scale-110)" @click.prevent="onPreview(index)">
<FaIcon name="i-icon-park-outline:preview-open" class="size-6" />
@@ -300,7 +355,7 @@ function onMove(index: number, direction: 'forward' | 'backward') {
</template>
<input ref="fileInputRef" type="file" accept="image/*" :multiple="props.multiple" class="hidden" @change="onSelectFile">
</button>
</div>
</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 }}
@@ -320,3 +375,39 @@ function onMove(index: number, direction: 'forward' | 'backward') {
</div>
</div>
</template>
<style scoped>
.fa-image-upload-list {
--fa-image-upload-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
}
.fa-image-upload-item {
transform-origin: center;
}
.fa-image-upload-list-enter-active,
.fa-image-upload-list-leave-active {
transition:
opacity 180ms var(--fa-image-upload-ease),
transform 240ms var(--fa-image-upload-ease);
}
.fa-image-upload-list-move {
transition: transform 240ms var(--fa-image-upload-ease);
will-change: transform;
}
.fa-image-upload-list-enter-from,
.fa-image-upload-list-leave-to {
opacity: 0;
transform: scale(0.94);
}
.fa-image-upload-list-leave-active {
position: absolute;
z-index: 2;
pointer-events: none;
}
</style>