Переглянути джерело

feat(components): 添加虚拟滚动容器组件并优化机构详情弹窗性能

为提升长列表渲染性能,新增 VirtualScrollContainer 组件,基于 vue-virtual-scroller 实现。同时优化 InstitutionDetailModal 弹窗,使用 IntersectionObserver 实现维度组件的懒加载,减少初始渲染压力。

chore: 更新 package.json 添加 vue-virtual-scroller 依赖
docs(data): 统一数据文件注释前缀为"首页-"
easyforever 1 тиждень тому
батько
коміт
f75e78d3d6

+ 2 - 1
package.json

@@ -17,7 +17,8 @@
     "pinia": "^2.2.6",
     "swiper": "^12.1.3",
     "vue": "^3.5.13",
-    "vue-router": "^4.4.5"
+    "vue-router": "^4.4.5",
+    "vue-virtual-scroller": "^2.0.0-beta.8"
   },
   "devDependencies": {
     "@types/node": "^22.10.1",

+ 178 - 6
src/components/InstitutionDetailModal.vue

@@ -1,6 +1,6 @@
 <template>
   <ModalDialog :visible="visible" :title="institution?.name || ''" @close="handleClose">
-    <div class="institution-detail">
+    <div class="institution-detail" ref="detailContainer">
       <!-- 基本信息和摄像头区域 -->
       <div class="detail-section detail-section-first" style="height: 936px">
         <!-- 基本信息 -->
@@ -164,15 +164,61 @@
 
         <div class="five-dimensions">
           <!-- 钱花得明白 -->
-          <FinanceDimension :visible="visible" />
+          <div :ref="(el) => setDimensionRef(el as HTMLElement, 0)" class="dimension-wrapper">
+            <FinanceDimension v-if="loadedDimensions[0]" :visible="visible" />
+            <div v-else class="dimension-placeholder">
+              <div class="loading-spinner"></div>
+              <div class="loading-text">加载中...</div>
+            </div>
+          </div>
           <!-- 饭吃得满意 -->
-          <FoodSatisfactionDimension :visible="visible" style="margin-top: 60px" />
+          <div
+            :ref="(el) => setDimensionRef(el as HTMLElement, 1)"
+            class="dimension-wrapper"
+            style="margin-top: 60px"
+          >
+            <FoodSatisfactionDimension v-if="loadedDimensions[1]" :visible="visible" />
+            <div v-else class="dimension-placeholder">
+              <div class="loading-spinner"></div>
+              <div class="loading-text">加载中...</div>
+            </div>
+          </div>
           <!-- 衣穿得舒适 -->
-          <ClothingSatisfactionDimension :visible="visible" style="margin-top: 60px" />
+          <div
+            :ref="(el) => setDimensionRef(el as HTMLElement, 2)"
+            class="dimension-wrapper"
+            style="margin-top: 60px"
+          >
+            <ClothingSatisfactionDimension v-if="loadedDimensions[2]" :visible="visible" />
+            <div v-else class="dimension-placeholder">
+              <div class="loading-spinner"></div>
+              <div class="loading-text">加载中...</div>
+            </div>
+          </div>
           <!-- 房住得安全 -->
-          <HouseSafetyDimension :visible="visible" style="margin-top: 60px" />
+          <div
+            :ref="(el) => setDimensionRef(el as HTMLElement, 3)"
+            class="dimension-wrapper"
+            style="margin-top: 60px"
+          >
+            <HouseSafetyDimension v-if="loadedDimensions[3]" :visible="visible" />
+            <div v-else class="dimension-placeholder">
+              <div class="loading-spinner"></div>
+              <div class="loading-text">加载中...</div>
+            </div>
+          </div>
           <!-- 人活得体面 -->
-          <DignityDimension :visible="visible" style="margin-top: 60px" />
+          <div
+            :ref="(el) => setDimensionRef(el as HTMLElement, 4)"
+            class="dimension-wrapper"
+            style="margin-top: 60px"
+          >
+            <DignityDimension v-if="loadedDimensions[4]" :visible="visible" />
+            <div v-else class="dimension-placeholder">
+              <div class="loading-spinner"></div>
+              <div class="loading-text">加载中...</div>
+            </div>
+          </div>
         </div>
       </div>
     </div>
@@ -180,6 +226,7 @@
 </template>
 
 <script setup lang="ts">
+import { ref, onMounted, onUnmounted, watch } from 'vue'
 import ModalDialog from './ModalDialog.vue'
 import NumberAnimation from './NumberAnimation.vue'
 import FinanceDimension from './FinanceDimension.vue'
@@ -198,9 +245,99 @@ const emit = defineEmits<{
   close: []
 }>()
 
+// 五维组件的引用 - 使用 Map 存储,key 为索引
+const dimensionRefs = ref<Map<number, HTMLElement>>(new Map())
+// 记录哪些维度已经加载
+const loadedDimensions = ref<boolean[]>(new Array(5).fill(false))
+// IntersectionObserver 实例
+let observer: IntersectionObserver | null = null
+
+// 设置 ref 的函数
+const setDimensionRef = (el: HTMLElement | null, index: number) => {
+  if (el) {
+    dimensionRefs.value.set(index, el)
+  }
+}
+
+// 创建 IntersectionObserver
+const createObserver = () => {
+  if (observer) {
+    observer.disconnect()
+  }
+
+  observer = new IntersectionObserver(
+    (entries) => {
+      entries.forEach((entry) => {
+        if (entry.isIntersecting) {
+          // 通过遍历 Map 找到对应的索引
+          let foundIndex = -1
+          dimensionRefs.value.forEach((el, idx) => {
+            if (el === entry.target) {
+              foundIndex = idx
+            }
+          })
+
+          if (foundIndex !== -1 && !loadedDimensions.value[foundIndex]) {
+            // 使用 requestAnimationFrame 确保在下一帧渲染,避免阻塞
+            requestAnimationFrame(() => {
+              loadedDimensions.value[foundIndex] = true
+            })
+            // 加载后取消观察
+            observer?.unobserve(entry.target)
+          }
+        }
+      })
+    },
+    {
+      rootMargin: '200px', // 在元素进入视口前200px开始加载
+      threshold: 0.1,
+    }
+  )
+
+  // 观察所有维度组件容器
+  dimensionRefs.value.forEach((el) => {
+    if (el) {
+      observer?.observe(el)
+    }
+  })
+}
+
+// 重置加载状态
+const resetLoadedDimensions = () => {
+  loadedDimensions.value = new Array(5).fill(false)
+}
+
+// 监听弹窗可见性变化
+watch(
+  () => props.visible,
+  (newVisible) => {
+    if (newVisible) {
+      // 弹窗打开时重置状态
+      resetLoadedDimensions()
+      // 等待DOM更新后创建观察者
+      requestAnimationFrame(() => {
+        createObserver()
+      })
+    } else {
+      // 弹窗关闭时清理观察者
+      if (observer) {
+        observer.disconnect()
+        observer = null
+      }
+    }
+  }
+)
+
 const handleClose = () => {
   emit('close')
 }
+
+onUnmounted(() => {
+  if (observer) {
+    observer.disconnect()
+    observer = null
+  }
+})
 </script>
 
 <style lang="scss" scoped>
@@ -473,5 +610,40 @@ $neon-cyan: #00ffe6;
     flex-direction: column;
     gap: 30px;
   }
+
+  .dimension-wrapper {
+    min-height: 800px;
+  }
+
+  .dimension-placeholder {
+    min-height: 800px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    border: 1px dashed rgba(0, 255, 230, 0.3);
+    background: rgba(0, 255, 230, 0.02);
+
+    .loading-spinner {
+      width: 60px;
+      height: 60px;
+      border: 4px solid rgba(0, 255, 230, 0.1);
+      border-top-color: #00ffe6;
+      border-radius: 50%;
+      animation: spin 1s linear infinite;
+    }
+
+    .loading-text {
+      margin-top: 20px;
+      font-size: 28px;
+      color: rgba(0, 255, 230, 0.6);
+    }
+  }
+
+  @keyframes spin {
+    to {
+      transform: rotate(360deg);
+    }
+  }
 }
 </style>

+ 56 - 0
src/components/VirtualScrollContainer.vue

@@ -0,0 +1,56 @@
+<template>
+  <div class="virtual-scroll-container" ref="containerRef">
+    <RecycleScroller
+      class="scroller"
+      :items="items"
+      :item-size="itemSize"
+      :key-field="'id'"
+    >
+      <template #default="{ item }">
+        <div class="scroll-item">
+          <component :is="item.component" v-bind="item.props" />
+        </div>
+      </template>
+    </RecycleScroller>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { RecycleScroller } from 'vue-virtual-scroller'
+import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
+
+interface ScrollItem {
+  id: string
+  component: any
+  props?: Record<string, any>
+}
+
+interface Props {
+  items: ScrollItem[]
+  itemSize?: number
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  itemSize: 1200,
+})
+
+const containerRef = ref<HTMLElement>()
+</script>
+
+<style lang="scss" scoped>
+.virtual-scroll-container {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+
+  .scroller {
+    width: 100%;
+    height: 100%;
+  }
+
+  .scroll-item {
+    width: 100%;
+  }
+}
+</style>

+ 2 - 2
src/data/clothing.ts

@@ -1,4 +1,4 @@
-// 衣穿得舒适模块模拟数据
+// 首页-衣穿得舒适模块模拟数据
 export const clothingData = {
   // 顶部数字数据
   percentageData: [
@@ -132,7 +132,7 @@ export const clothingData = {
   images: [
     {
       id: 1,
-      url: '', // 实际项目中使用真实图片路径
+      url: './images/cloth1.png', // 实际项目中使用真实图片路径
       alt: '衣物图片1',
     },
     {

+ 1 - 1
src/data/dignity.ts

@@ -1,4 +1,4 @@
-// 人活得体面模块模拟数据
+// 首页-人活得体面模块模拟数据
 export const dignityData = {
   // 顶部统计数据
   percentageData: [

+ 1 - 1
src/data/food.ts

@@ -1,4 +1,4 @@
-// 饭吃得满意模块模拟数据
+// 首页-饭吃得满意模块模拟数据
 export const foodData = {
   // 顶部百分比数据
   percentageData: [

+ 1 - 1
src/data/housing.ts

@@ -1,4 +1,4 @@
-// 居住得安全模块模拟数据
+// 首页-居住得安全模块模拟数据
 export const housingData = {
   // 顶部百分比数据
   percentageData: [

BIN
src/data/images/cloth1.png


+ 2 - 2
src/data/institutionService.ts

@@ -1,4 +1,4 @@
-// 养老机构五项服务状况数据
+// 首页-养老机构五项服务状况数据
 export const institutionServiceData = {
   institutions: [
     { name: '安城区养老服务中心', problemCount: 0 },
@@ -28,5 +28,5 @@ export const institutionServiceData = {
     { name: '安城区养老服务中心', problemCount: 1 },
     { name: '安城区养老服务中心', problemCount: 3 },
     { name: '安城区养老服务中心', problemCount: 0 },
-  ]
+  ],
 }

+ 1 - 1
src/data/regionElderly.ts

@@ -1,4 +1,4 @@
-// 区域老人情况模块模拟数据
+// 首页-区域老人情况模块模拟数据
 export const regionElderlyData = {
   // 第一列 - 机构统计数据
   institutionStats: {

+ 1 - 1
src/data/robotTasks.ts

@@ -1,4 +1,4 @@
-// 机器人任务数据
+// 机器人详情页面数据
 export interface Institution {
   name: string
   problemCount?: number

+ 1 - 1
src/data/subsidy.ts

@@ -1,4 +1,4 @@
-// 补贴相关数据
+// 首页-钱花的明白
 export const subsidyData = {
   // 发放补贴数字(以万元为单位)
   amount: 7182,

+ 3 - 0
src/types/vue-virtual-scroller.d.ts

@@ -0,0 +1,3 @@
+declare module 'vue-virtual-scroller' {
+  export const RecycleScroller: any
+}