feat(short-drama-agent-front): 优化图片加载动效

This commit is contained in:
GJ
2026-09-21 10:58:00 +08:00
parent b78152778b
commit c1ec88daf9
3 changed files with 62 additions and 103 deletions
+1
View File
@@ -7,3 +7,4 @@ coverage
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store
.worktree/ .worktree/
.worktrees/
+54 -92
View File
@@ -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;
} }
+7 -11
View File
@@ -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 () => {