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
+54 -92
View File
@@ -1,71 +1,60 @@
<script setup lang="ts">
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) {
clearTimeout(settleTimer)
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)
onBeforeUnmount(() => clearTimeout(settleTimer))
</script>
<template>
<span class="reveal-image" :class="{ 'is-loaded': loaded, 'is-settled': settled }">
<span class="reveal-image" :class="{ 'is-loaded': loaded }" :aria-busy="!loaded">
<NImage
:src="src"
:preview-src="src"
@@ -78,9 +67,8 @@ onBeforeUnmount(() => clearTimeout(settleTimer))
@load="reveal"
@error="fail"
/>
<span v-if="!settled" class="reveal-image-grid" aria-hidden="true">
<span v-for="cell in cells" :key="cell.index" class="reveal-image-cell" :style="cellStyle(cell)" />
</span>
<!-- 以下是 Instagram 风格的图片加载骨架模块 -->
<span v-if="!loaded" class="reveal-image-placeholder" aria-hidden="true" />
</span>
</template>
@@ -95,53 +83,27 @@ onBeforeUnmount(() => clearTimeout(settleTimer))
}
.reveal-image > .n-image img {
opacity: 0;
filter: blur(9px);
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);
transition: opacity 220ms cubic-bezier(0.25, 1, 0.5, 1);
}
.reveal-image.is-loaded > .n-image img {
opacity: 1;
filter: blur(0);
transform: scale(1);
}
.reveal-image-grid {
@apply absolute inset-0 grid grid-cols-4 grid-rows-4 pointer-events-none;
gap: 1px;
background: var(--app-body);
}
.reveal-image-cell {
.reveal-image-placeholder {
@apply absolute inset-0 pointer-events-none;
background-color: var(--app-control);
background-repeat: no-repeat;
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);
animation: reveal-image-pulse 1.2s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
}
.reveal-image:not(.is-loaded) .reveal-image-cell {
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 {
@keyframes reveal-image-pulse {
from {
filter: brightness(0.96);
opacity: 0.72;
}
to {
filter: brightness(1.08);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-image > .n-image img,
.reveal-image-cell {
.reveal-image-placeholder {
animation: none;
transition: none;
}