Quellcode durchsuchen

perf(photography): 优化图片缩略图加载性能

- 实现懒加载缩略图功能,使用 IntersectionObserver 监听元素可见性
- 添加缩略图缓存机制,避免重复生成相同图片的缩略图
- 创建 useThumbnails 组合函数统一管理缩略图生成逻辑
- 在 GoodsSelectDialog、processImage 和 shot 组件中集成缩略图懒加载
- 移除原有的懒加载属性,改用自定义的懒加载实现
- 添加画布生成缩略图的功能,支持指定最大宽高尺寸
- 实现观察器的启动和停止逻辑,确保组件卸载时正确清理资源
panqiuyao vor 17 Stunden
Ursprung
Commit
7a71abace5

+ 21 - 4
frontend/src/views/Photography/components/GoodsSelectDialog.vue

@@ -15,7 +15,7 @@
         *请注意,选择货号后,该模板会自动使用此货号的相关配置!<span v-if="selectedGoodsArtNo">选中货号 {{ selectedGoodsArtNo }} 包含 {{ getSelectedGoodsImageCount }} 张图片({{ getSelectedGoodsImageName.join(',') }})</span>
       </div>
     </template>
-    <div class="goods-select-content">
+    <div class="goods-select-content" v-if="visible" ref="containerRef">
 
 
       <el-radio-group :model-value="selectedGoodsArtNo" class="goods-radio-group">
@@ -59,13 +59,12 @@
               }}</span>
               <el-image
                 v-if="image.PhotoRecord?.image_path"
-                :src="getFilePath(image.PhotoRecord.image_path)"
+                :src="thumbnailMap[image.PhotoRecord.image_path] ? thumbnailMap[image.PhotoRecord.image_path] : ''"
                 :preview-src-list="getPreviewImageList(item)"
                 :initial-index="getPreviewIndex(item, index)"
                 class="preview-image"
                 fit="contain"
                 :preview-teleported="true"
-                lazy
               >
                 <template #error>
                   <div class="image-slot">
@@ -114,7 +113,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, defineProps, defineEmits, ref, watch } from 'vue'
+import { computed, defineProps, defineEmits, ref, watch, nextTick, onBeforeUnmount } from 'vue'
 import tokenInfo from '@/stores/modules/token'
 import client from '@/stores/modules/client'
 import icpList from '@/utils/ipc'
@@ -150,6 +149,24 @@ const {
 const selectedGoodsArtNo = ref<string>('')
 const goodsGenerateLoading = ref(false)
 
+// 使用复用的 thumbnails 组合函数
+import { useThumbnails } from '../composables/useThumbnails'
+const { thumbnailMap, observe, stop } = useThumbnails(getFilePath)
+const containerRef = ref<HTMLElement | null>(null)
+
+onBeforeUnmount(() => {
+  stop()
+})
+
+// 当 goodsList 更新或 loading 结束时,设置 IntersectionObserver
+watch(goodsList, () => {
+  if (props.visible) nextTick(() => observe(containerRef, goodsList))
+})
+
+watch(() => loading.value, (val) => {
+  if (val === false && props.visible) nextTick(() => observe(containerRef, goodsList))
+})
+
 // getPhotoRecords 使用 usePhotography 中的实现(通过解构得到)
 
 // 根据选中的货号调用后端生成商品模板

+ 109 - 0
frontend/src/views/Photography/composables/useThumbnails.ts

@@ -0,0 +1,109 @@
+import { ref, nextTick, onBeforeUnmount, Ref } from 'vue'
+
+export function useThumbnails(getFilePath: (p: string) => string) {
+  const thumbnailMap = ref<Record<string, string>>({})
+  const generatingSet = new Set<string>()
+  const placeholderDataUrl =
+    'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200"><rect width="100%" height="100%" fill="#F5F7FA"/></svg>'
+
+  async function generateThumbnail(imagePath: string, maxW = 400, maxH = 400) {
+    if (!imagePath) return
+    if (thumbnailMap.value[imagePath]) return
+    if (generatingSet.has(imagePath)) return
+    generatingSet.add(imagePath)
+    try {
+      const src = getFilePath(imagePath)
+      const img = new Image()
+      img.src = src
+      await new Promise<void>((resolve, reject) => {
+        img.onload = () => resolve()
+        img.onerror = () => reject(new Error('image load error'))
+      })
+      const w = img.naturalWidth || img.width
+      const h = img.naturalHeight || img.height
+      if (!w || !h) {
+        thumbnailMap.value[imagePath] = ''
+        return
+      }
+      const ratio = Math.min(1, maxW / w, maxH / h)
+      const tw = Math.max(1, Math.round(w * ratio))
+      const th = Math.max(1, Math.round(h * ratio))
+      const canvas = document.createElement('canvas')
+      canvas.width = tw
+      canvas.height = th
+      const ctx = canvas.getContext('2d')
+      if (!ctx) {
+        thumbnailMap.value[imagePath] = ''
+        return
+      }
+      ctx.drawImage(img, 0, 0, tw, th)
+      const dataUrl = canvas.toDataURL('image/jpeg', 0.7)
+      thumbnailMap.value[imagePath] = dataUrl
+    } catch (e) {
+      console.warn('generateThumbnail error', e)
+      thumbnailMap.value[imagePath] = ''
+    } finally {
+      generatingSet.delete(imagePath)
+    }
+  }
+
+  let observer: IntersectionObserver | null = null
+
+  function observe(containerRef: Ref<HTMLElement | null>, goodsListRef: Ref<any[]>) {
+    if (!containerRef.value) return
+    if (observer) observer.disconnect()
+    observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting) {
+            const el = entry.target as HTMLElement
+            const goodsNo = el.getAttribute('data-goods-art-no')
+            if (!goodsNo) return
+            const item = goodsListRef.value.find((g: any) => g.goods_art_no === goodsNo)
+            if (item && Array.isArray(item.items)) {
+              item.items.forEach((img: any) => {
+                const p = img?.PhotoRecord?.image_path
+                if (p && !thumbnailMap.value[p]) generateThumbnail(p, 400, 400)
+              })
+            }
+            observer?.unobserve(el)
+          }
+        })
+      },
+      { root: null, rootMargin: '300px', threshold: 0.05 },
+    )
+
+    nextTick(() => {
+      const nodes = containerRef.value!.querySelectorAll('.history-item, .goods-item')
+      nodes.forEach((n) => {
+        if (n instanceof HTMLElement) {
+          const keyEl = n.querySelector('.goods-art-no') as HTMLElement | null
+          const key = keyEl ? keyEl.innerText : n.getAttribute('data-goods-art-no') || ''
+          if (key) n.setAttribute('data-goods-art-no', key)
+          observer?.observe(n)
+        }
+      })
+    })
+  }
+
+  function stop() {
+    if (observer) {
+      observer.disconnect()
+      observer = null
+    }
+  }
+
+  onBeforeUnmount(() => {
+    stop()
+  })
+
+  return {
+    thumbnailMap,
+    generateThumbnail,
+    observe,
+    stop,
+    placeholderDataUrl,
+  }
+}
+
+

+ 22 - 12
frontend/src/views/Photography/processImage.vue

@@ -11,7 +11,7 @@
     <div class="main-container page—wrap max-w-full">
       <div class="history-section flex-col koutu-section">
 
-          <div class="history-warp">
+          <div class="history-warp" ref="containerRef">
             <div v-if="!goodsList.length" class="fs-14 c-666 mar-top-50">
               {{ loading ? '数据正在加载中,请稍候...' : '暂无数据,请先进行拍摄'}}
             </div>
@@ -60,7 +60,7 @@
                   <span class="tag" v-if="!image.PhotoRecord.image_path">{{ image.action_name }}</span>
                   <el-image
                     v-if="image.PhotoRecord.image_path"
-                    :src="getFilePath(image.PhotoRecord.image_path)"
+                    :src="thumbnailMap[image.PhotoRecord.image_path] ? thumbnailMap[image.PhotoRecord.image_path] : ''"
                     :preview-src-list="getPreviewImageList(item)"
                     hide-on-click-modal
                     :initial-index="getPreviewIndex(item, index)"
@@ -132,16 +132,13 @@
 </template>
 <script setup lang="ts">
 import headerBar from '@/components/header-bar/index.vue'
-import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
+import { onMounted, onBeforeUnmount, ref, computed, nextTick, watch } from 'vue'
 import HardwareCheck from '@/components/check/index.vue'
 import usePhotography from './mixin/usePhotography'
+import { useThumbnails } from './composables/useThumbnails'
 import generate from '@/utils/menus/generate'
-import { ElMessage,ElMessageBox } from 'element-plus'
-import { clickLog, setLogInfo } from '@/utils/log'
-import {useRoute, useRouter} from "vue-router";
-import client from "@/stores/modules/client";
-import icpList from '@/utils/ipc'
-import { getFilePath, getRouterUrl } from '@/utils/appfun'
+import { ElMessageBox } from 'element-plus'
+// logging helpers not needed here
 
 const {
   loading,
@@ -164,10 +161,12 @@ const {
   totalPages,
 } = usePhotography()
 
+// thumbnails
+const containerRef = ref<HTMLElement | null>(null)
+const { thumbnailMap, observe, stop } = useThumbnails(getFilePath)
 
-const Router = useRouter()
-const route = useRoute();
-const clientStore = client();
+
+// router/client variables not needed here
 // 覆盖 openPhotographyDetail 方法,只传递选中的货号
 /*const openPhotographyDetail = () => {
 
@@ -302,10 +301,21 @@ const getPreviewIndex = (item: any, currentIndex: number) => {
 onMounted(async () => {
   await getPhotoRecords()
   initEventListeners()
+  nextTick(() => {
+    observe(containerRef, goodsList)
+  })
 })
 
 onBeforeUnmount(() => {
   cleanupEventListeners()
+  stop()
+})
+
+// 当 goodsList 变化时(翻页或刷新),重新挂载 observer
+watch(goodsList, () => {
+  nextTick(() => {
+    observe(containerRef, goodsList)
+  })
 })
 </script>
 

+ 16 - 3
frontend/src/views/Photography/shot.vue

@@ -59,7 +59,7 @@
       </div>
 
       <div class="history-section flex-col koutu-section">
-          <div class="history-warp">
+          <div class="history-warp" ref="containerRef">
             <div v-if="!goodsList.length" class="fs-14 c-666 mar-top-50">
               {{ loading ? '数据正在加载中,请稍候...' : '暂无数据,请先进行拍摄'}}
             </div>
@@ -109,7 +109,7 @@
 <!--                  <span class="tag" v-if="!image.PhotoRecord.image_path">{{ image.action_name }}</span>-->
                   <div class="el-image_view" >
                     <el-image
-                      :src="getFilePath(image.PhotoRecord.image_path)"
+                      :src="thumbnailMap[image.PhotoRecord.image_path] ? thumbnailMap[image.PhotoRecord.image_path] : ''"
                       :preview-src-list="getPreviewImageList(item)"
                       hide-on-click-modal
                       :initial-index="getPreviewIndex(item, index)"
@@ -181,11 +181,12 @@
 </template>
 <script setup lang="ts">
 import headerBar from '@/components/header-bar/index.vue'
-import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
+import { onMounted, onBeforeUnmount, ref, computed, watch, nextTick } from 'vue'
 import HardwareCheck from '@/components/check/index.vue'
 // @ts-ignore
 import RemoteControl from '@/views/RemoteControl/index.vue'
 import usePhotography from './mixin/usePhotography'
+import { useThumbnails } from './composables/useThumbnails'
 import generate from '@/utils/menus/generate'
 import { ElMessageBox } from 'element-plus'
 
@@ -220,6 +221,10 @@ const {
 // 选中的货号列表
 const selectedGoods = ref<Set<string>>(new Set())
 
+// thumbnails
+const containerRef = ref<HTMLElement | null>(null)
+const { thumbnailMap, observe, stop, placeholderDataUrl } = useThumbnails(getFilePath)
+
 // 全选状态
 const isSelectAll = computed(() => {
   return goodsList.value.length > 0 && selectedGoods.value.size === goodsList.value.length
@@ -320,10 +325,18 @@ const getPreviewIndex = (item: any, currentIndex: number) => {
 onMounted(async () => {
   await getPhotoRecords()
   initEventListeners()
+  nextTick(() => {
+    observe(containerRef, goodsList)
+  })
 })
 
 onBeforeUnmount(() => {
   cleanupEventListeners()
+  stop()
+})
+
+watch(goodsList, () => {
+  nextTick(() => observe(containerRef, goodsList))
 })
 </script>