feat: 优化生产面板并增加图片网格加载
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NImage } from 'naive-ui'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ImageOff } from '@lucide/vue'
|
||||
import { referenceImageUrl } from '../../lib/assets'
|
||||
import RevealImage from './RevealImage.vue'
|
||||
|
||||
/** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */
|
||||
const props = withDefaults(
|
||||
@@ -28,16 +29,12 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="asset-image">
|
||||
<NImage
|
||||
<RevealImage
|
||||
v-if="url && !failed"
|
||||
:src="url"
|
||||
:preview-src="url"
|
||||
:alt="alt"
|
||||
lazy
|
||||
:preview-disabled="!preview"
|
||||
:preview="preview"
|
||||
:object-fit="objectFit"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<span v-else class="asset-image-empty"
|
||||
@@ -63,6 +60,7 @@ watch(
|
||||
.asset-image-empty {
|
||||
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
|
||||
}
|
||||
.asset-image .reveal-image,
|
||||
.asset-image .n-image {
|
||||
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 全局图片加载动效:借鉴 Grid Reveal 的网格落图方式,但只使用 16 个 CSS 单元,
|
||||
* 避免列表中的每张缩略图都创建 Canvas、采样像素并持续运行 requestAnimationFrame。
|
||||
*/
|
||||
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 }">
|
||||
<NImage
|
||||
:src="src"
|
||||
:preview-src="src"
|
||||
:alt="alt"
|
||||
:lazy="lazy"
|
||||
:preview-disabled="!preview"
|
||||
:object-fit="objectFit"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
||||
@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>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@reference "../../styles/styles.css";
|
||||
.reveal-image {
|
||||
@apply relative block w-full h-full min-w-0 min-h-0 overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.reveal-image > .n-image,
|
||||
.reveal-image > .n-image img {
|
||||
@apply w-full h-full min-w-0 min-h-0;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
.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 {
|
||||
from {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
to {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-image > .n-image img,
|
||||
.reveal-image-cell {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,5 +3,6 @@ export { default as AppDialog } from './AppDialog.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as StatusBadge } from './StatusBadge.vue'
|
||||
export { default as AssetImage } from './AssetImage.vue'
|
||||
export { default as RevealImage } from './RevealImage.vue'
|
||||
export { default as DirectoryItem } from './DirectoryItem.vue'
|
||||
export { default as WorkspaceTools } from './WorkspaceTools.vue'
|
||||
|
||||
Reference in New Issue
Block a user