feat(short-drama-agent-front): 优化图片加载动效
This commit is contained in:
@@ -7,3 +7,4 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.worktree/
|
.worktree/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -1,71 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NImage } from 'naive-ui'
|
import { NImage } from 'naive-ui'
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
/** 全局图片加载组件参数。 */
|
||||||
|
interface Props {
|
||||||
|
/** 图片地址。 */
|
||||||
|
src: string
|
||||||
|
/** 图片替代文本。 */
|
||||||
|
alt: string
|
||||||
|
/** 图片在容器内的适应方式。 */
|
||||||
|
objectFit?: 'contain' | 'cover'
|
||||||
|
/** 是否允许查看原图。 */
|
||||||
|
preview?: boolean
|
||||||
|
/** 是否启用浏览器原生懒加载。 */
|
||||||
|
lazy?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局图片加载组件事件。 */
|
||||||
|
interface Emits {
|
||||||
|
/** 图片加载失败事件。 */
|
||||||
|
error: [event: Event]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图片展示参数。 */
|
||||||
|
const props = withDefaults(defineProps<Props>(), { objectFit: 'cover', preview: false, lazy: true })
|
||||||
|
|
||||||
|
/** 图片加载事件。 */
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
/** 图片是否已经加载完成。 */
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
/** 图片地址变化后恢复加载占位状态。 */
|
||||||
|
function reset() {
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图片加载完成后直接切换为淡入状态。 */
|
||||||
|
function reveal() {
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局图片加载动效:借鉴 Grid Reveal 的网格落图方式,但只使用 16 个 CSS 单元,
|
* 转发图片加载失败事件。
|
||||||
* 避免列表中的每张缩略图都创建 Canvas、采样像素并持续运行 requestAnimationFrame。
|
* @param event 图片加载失败事件
|
||||||
*/
|
*/
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
src: string
|
|
||||||
alt: string
|
|
||||||
objectFit?: 'contain' | 'cover'
|
|
||||||
preview?: boolean
|
|
||||||
lazy?: boolean
|
|
||||||
}>(),
|
|
||||||
{ objectFit: 'cover', preview: false, lazy: true }
|
|
||||||
)
|
|
||||||
const emit = defineEmits<{ error: [event: Event] }>()
|
|
||||||
const loaded = ref(false)
|
|
||||||
const settled = ref(false)
|
|
||||||
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
/** 让相邻方格错开退出,视觉上从中心和高关注区域向外展开。 */
|
|
||||||
const cells = computed(() => {
|
|
||||||
const order = [12, 8, 4, 13, 3, 0, 1, 9, 11, 2, 5, 14, 10, 6, 7, 15]
|
|
||||||
return Array.from({ length: 16 }, (_, index) => ({
|
|
||||||
index,
|
|
||||||
order: order[index] ?? index,
|
|
||||||
column: index % 4,
|
|
||||||
row: Math.floor(index / 4)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
clearTimeout(settleTimer)
|
|
||||||
loaded.value = false
|
|
||||||
settled.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function reveal() {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
loaded.value = true
|
|
||||||
settleTimer = setTimeout(() => {
|
|
||||||
settled.value = true
|
|
||||||
}, 760)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(event: Event) {
|
function fail(event: Event) {
|
||||||
clearTimeout(settleTimer)
|
|
||||||
emit('error', event)
|
emit('error', event)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellStyle(cell: (typeof cells.value)[number]) {
|
/** 监听图片地址变化并重置加载状态。 */
|
||||||
return {
|
|
||||||
'--reveal-order': cell.order,
|
|
||||||
backgroundImage: loaded.value ? `url(${JSON.stringify(props.src)})` : undefined,
|
|
||||||
backgroundPosition: `${(cell.column / 3) * 100}% ${(cell.row / 3) * 100}%`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.src, reset)
|
watch(() => props.src, reset)
|
||||||
onBeforeUnmount(() => clearTimeout(settleTimer))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="reveal-image" :class="{ 'is-loaded': loaded, 'is-settled': settled }">
|
<span class="reveal-image" :class="{ 'is-loaded': loaded }" :aria-busy="!loaded">
|
||||||
<NImage
|
<NImage
|
||||||
:src="src"
|
:src="src"
|
||||||
:preview-src="src"
|
:preview-src="src"
|
||||||
@@ -78,9 +67,8 @@ onBeforeUnmount(() => clearTimeout(settleTimer))
|
|||||||
@load="reveal"
|
@load="reveal"
|
||||||
@error="fail"
|
@error="fail"
|
||||||
/>
|
/>
|
||||||
<span v-if="!settled" class="reveal-image-grid" aria-hidden="true">
|
<!-- 以下是 Instagram 风格的图片加载骨架模块 -->
|
||||||
<span v-for="cell in cells" :key="cell.index" class="reveal-image-cell" :style="cellStyle(cell)" />
|
<span v-if="!loaded" class="reveal-image-placeholder" aria-hidden="true" />
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -95,53 +83,27 @@ onBeforeUnmount(() => clearTimeout(settleTimer))
|
|||||||
}
|
}
|
||||||
.reveal-image > .n-image img {
|
.reveal-image > .n-image img {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
filter: blur(9px);
|
transition: opacity 220ms cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
transform: scale(1.025);
|
|
||||||
transition:
|
|
||||||
opacity 280ms ease,
|
|
||||||
filter 620ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
}
|
||||||
.reveal-image.is-loaded > .n-image img {
|
.reveal-image.is-loaded > .n-image img {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: blur(0);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
}
|
||||||
.reveal-image-grid {
|
.reveal-image-placeholder {
|
||||||
@apply absolute inset-0 grid grid-cols-4 grid-rows-4 pointer-events-none;
|
@apply absolute inset-0 pointer-events-none;
|
||||||
gap: 1px;
|
|
||||||
background: var(--app-body);
|
|
||||||
}
|
|
||||||
.reveal-image-cell {
|
|
||||||
background-color: var(--app-control);
|
background-color: var(--app-control);
|
||||||
background-repeat: no-repeat;
|
animation: reveal-image-pulse 1.2s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
|
||||||
background-size: 400% 400%;
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.01);
|
|
||||||
transition:
|
|
||||||
opacity 420ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
transition-delay: calc(var(--reveal-order) * 18ms);
|
|
||||||
}
|
}
|
||||||
.reveal-image:not(.is-loaded) .reveal-image-cell {
|
@keyframes reveal-image-pulse {
|
||||||
animation: reveal-image-wait 1.8s ease-in-out infinite alternate;
|
|
||||||
animation-delay: calc(var(--reveal-order) * -55ms);
|
|
||||||
}
|
|
||||||
.reveal-image.is-loaded .reveal-image-cell {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.88);
|
|
||||||
}
|
|
||||||
@keyframes reveal-image-wait {
|
|
||||||
from {
|
from {
|
||||||
filter: brightness(0.96);
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
filter: brightness(1.08);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reveal-image > .n-image img,
|
.reveal-image > .n-image img,
|
||||||
.reveal-image-cell {
|
.reveal-image-placeholder {
|
||||||
animation: none;
|
animation: none;
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,19 +33,15 @@ describe('资源图片展示与原图预览', () => {
|
|||||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
|
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('图片加载完成后按网格逐块显现,并在动画结束后移除覆盖层', async () => {
|
it('图片加载前显示轻量骨架占位,加载完成后直接淡入图片并移除占位', async () => {
|
||||||
vi.useFakeTimers()
|
wrapper = mount(AssetImage, { props: { src: '/storage/reveal.png', alt: '加载中的图片' } })
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
expect(wrapper.get('.reveal-image').attributes('aria-busy')).toBe('true')
|
||||||
callback(0)
|
expect(wrapper.find('.reveal-image-placeholder').exists()).toBe(true)
|
||||||
return 1
|
expect(wrapper.find('.reveal-image-grid').exists()).toBe(false)
|
||||||
})
|
|
||||||
wrapper = mount(AssetImage, { props: { src: '/storage/reveal.png', alt: '加载动效图片' } })
|
|
||||||
expect(wrapper.findAll('.reveal-image-cell')).toHaveLength(16)
|
|
||||||
await wrapper.get('img').trigger('load')
|
await wrapper.get('img').trigger('load')
|
||||||
expect(wrapper.get('.reveal-image').classes()).toContain('is-loaded')
|
expect(wrapper.get('.reveal-image').classes()).toContain('is-loaded')
|
||||||
vi.advanceTimersByTime(760)
|
expect(wrapper.get('.reveal-image').attributes('aria-busy')).toBe('false')
|
||||||
await wrapper.vm.$nextTick()
|
expect(wrapper.find('.reveal-image-placeholder').exists()).toBe(false)
|
||||||
expect(wrapper.find('.reveal-image-grid').exists()).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user