Explorar el Código

feat(inspectionWork): 添加懒加载功能和优化 Tab 切换逻辑(修复检测计划进入检测工单的过渡问题)

xieyong hace 1 día
padre
commit
0a3e7a929a

+ 21 - 1
src/components/RouterLayout/index.vue

@@ -2,7 +2,7 @@
 <template>
   <transition :name="transitionName" mode="out-in" appear>
     <keep-alive :include="include">
-      <router-view />
+      <router-view :key="routeKey" />
     </keep-alive>
   </transition>
 </template>
@@ -14,7 +14,27 @@
       include() {
         return this.$store.getters['theme/keepAliveInclude'] ?? [];
       },
+      /**
+       * 路由级 key
+       *
+       * <router-view> 是函数式组件,<transition> 取到的「直接子节点」其实是它渲染出的页面组件。
+       * Vue 的 transition 只有在直接子节点 key 变化时,才会把旧页面标记为退场并用占位节点顶替,
+       * 从而做到「旧页面退场完成后再渲染新页面」;没有 key 时新旧页面会同时存在于 DOM 中。
+       *
+       * 用 path 而不是 fullPath,保持「仅 query 变化时不重建页面」的原有语义。
+       */
+      routeKey() {
+        return this.$route.path;
+      },
       transitionName() {
+        // 系统开启「减少动态效果」时关闭路由过渡动画,避免低性能设备上动画卡顿
+        if (
+          typeof window !== 'undefined' &&
+          window.matchMedia &&
+          window.matchMedia('(prefers-reduced-motion: reduce)').matches
+        ) {
+          return '';
+        }
         return this.$store.state.theme.transitionName;
       }
     }

+ 63 - 0
src/layout/index.vue

@@ -102,6 +102,11 @@
     REDIRECT_PATH,
     I18N_ENABLE
   } from '@/config/setting';
+  import {
+    prefetchRouteComponent,
+    prefetchVisibleMenuRoutes,
+    resolveRoutePath
+  } from '@/utils/route-prefetch';
   import {
     addPageTab,
     removePageTab,
@@ -135,6 +140,12 @@
         fullscreen: false
       };
     },
+    mounted () {
+      this.initRoutePrefetch();
+    },
+    beforeDestroy () {
+      this.destroyRoutePrefetch();
+    },
     computed: {
       isQianKun () {
         return window.__POWERED_BY_QIANKUN__;
@@ -151,6 +162,58 @@
       ...mapGetters(['theme'])
     },
     methods: {
+      /* 鼠标移到菜单上时预取目标路由组件,避免点击时才下载 chunk 造成卡顿 */
+      initRoutePrefetch () {
+        this.__prefetchTimer = null;
+        this.__prefetchHref = null;
+        const schedule = (link) => {
+          const href = link.getAttribute('href');
+          if (!href || href === this.__prefetchHref) {
+            return;
+          }
+          this.__prefetchHref = href;
+          // 鼠标在菜单上快速划过时只预取最后停留的那一项
+          clearTimeout(this.__prefetchTimer);
+          this.__prefetchTimer = setTimeout(() => {
+            const path = resolveRoutePath(href, this.$router.options.base);
+            if (path) {
+              prefetchRouteComponent(this.$router, path);
+            }
+          }, 100);
+        };
+        const onPointer = (e) => {
+          const target = e.target;
+          if (!target || typeof target.closest !== 'function') {
+            return;
+          }
+          const link = target.closest('a[href]');
+          if (link) {
+            schedule(link);
+          }
+        };
+        this.__prefetchHandler = onPointer;
+        document.addEventListener('mouseover', onPointer, true);
+        // 键盘操作菜单时同样触发预取
+        document.addEventListener('focusin', onPointer, true);
+        // 空闲后把侧边栏里可见的菜单组件预取掉
+        this.__warmTimer = setTimeout(() => {
+          prefetchVisibleMenuRoutes(this.$router);
+        }, 3000);
+      },
+      /* 移除预取监听 */
+      destroyRoutePrefetch () {
+        if (this.__prefetchHandler) {
+          document.removeEventListener(
+            'mouseover',
+            this.__prefetchHandler,
+            true
+          );
+          document.removeEventListener('focusin', this.__prefetchHandler, true);
+          this.__prefetchHandler = null;
+        }
+        clearTimeout(this.__prefetchTimer);
+        clearTimeout(this.__warmTimer);
+      },
       /* 侧栏折叠切换 */
       updateCollapse (value) {
         // console.log('value:', this.$store.state.user.menus);

+ 33 - 0
src/styles/transition/common.scss

@@ -12,6 +12,39 @@
     margin-bottom: 16px;
   }
 }
+
+/**
+ * 路由过渡(.ele-body 为路由组件根元素)
+ *
+ * 路由切换时新旧两个页面会同时存在于 DOM 中,若都按正常流参与布局,
+ * 新页面会被旧页面的高度顶到下方(视觉上像是被“压”在下面),
+ * 旧页面退场移除的瞬间,新页面又整体上跳回来——这就是切换时看到的卡顿点。
+ *
+ * 处理方式:把过渡中的页面改成绝对定位,让新旧页面在同一位置叠放,
+ * 既不占用布局空间,也不会产生位置跳动。
+ */
+.ele-admin-content-view {
+  // 作为过渡页面的定位参照
+  position: relative;
+  // 过渡期间页面脱离文档流,用最小高度撑住内容区,避免滚动条闪烁
+  min-height: 100%;
+}
+
+.slide-right-enter-active,
+.slide-right-leave-active,
+.slide-bottom-enter-active,
+.slide-bottom-leave-active,
+.zoom-in-enter-active,
+.zoom-in-leave-active,
+.zoom-out-enter-active,
+.zoom-out-leave-active,
+.fade-enter-active,
+.fade-leave-active {
+  position: absolute !important;
+  top: 0;
+  left: 0;
+  right: 0;
+}
 // ele-split-layout 左侧盒子充满
 .split-layout-right-content {
   position: absolute;

+ 1 - 0
src/styles/transition/fade.scss

@@ -2,6 +2,7 @@
 .fade-enter-active,
 .fade-leave-active {
   transition: opacity 0.2s ease-in-out;
+  will-change: opacity;
 }
 
 .fade-enter,

+ 3 - 0
src/styles/transition/slide.scss

@@ -2,6 +2,8 @@
 .slide-bottom-enter-active,
 .slide-bottom-leave-active {
   transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+  /* 整页做位移/淡出时提升为合成层,避免每一帧都重新绘制整个页面 */
+  will-change: transform, opacity;
 }
 
 .slide-bottom-enter {
@@ -18,6 +20,7 @@
 .slide-right-leave-active,
 .slide-right-enter-active {
   transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+  will-change: transform, opacity;
 }
 
 .slide-right-enter {

+ 2 - 0
src/styles/transition/zoom.scss

@@ -2,6 +2,7 @@
 .zoom-in-enter-active,
 .zoom-in-leave-active {
   transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+  will-change: transform, opacity;
 }
 
 .zoom-in-enter {
@@ -18,6 +19,7 @@
 .zoom-out-leave-active,
 .zoom-out-enter-active {
   transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+  will-change: transform, opacity;
 }
 
 .zoom-out-enter {

+ 111 - 0
src/utils/route-prefetch.js

@@ -0,0 +1,111 @@
+/**
+ * 路由组件预取
+ *
+ * 菜单对应的路由组件是异步 chunk(import('@/views/' + component)),
+ * 点击菜单时才会第一次下载并执行这个 chunk,一次往往要几百毫秒。
+ * 这段时间里页面完全没有反馈,过渡动画也被推迟,表现就是「点击后卡住」。
+ *
+ * 这里做两件事:
+ * 1. 用户把鼠标移到菜单上时,提前把目标组件加载好(悬停预取);
+ * 2. 页面空闲后,把侧边栏里可见的菜单组件依次预取,等用户点过去时已经就绪。
+ *
+ * 预取只是提前发起同一个 import(),webpack 会复用同一个加载 promise,
+ * 所以即使预取还没完成,点击也不会比原来更慢。
+ */
+const prefetched = new Set();
+
+/**
+ * 找到路径对应的路由记录
+ * @param {Object} router vue-router 实例
+ * @param {String} path 路由路径
+ */
+function findRouteRecord(router, path) {
+  if (!router || typeof router.getRoutes !== 'function') {
+    return null;
+  }
+  const records = router.getRoutes();
+  const target = path.replace(/\/+$/, '') || '/';
+  return (
+    records.find((item) => item.path === target) ||
+    records.find((item) => (item.path || '').replace(/\/+$/, '') === target) ||
+    null
+  );
+}
+
+/**
+ * 预取指定路径的路由组件
+ * @param {Object} router vue-router 实例
+ * @param {String} path 路由路径
+ */
+export function prefetchRouteComponent(router, path) {
+  if (!router || !path) {
+    return;
+  }
+  const key = path.replace(/\/+$/, '') || '/';
+  if (prefetched.has(key)) {
+    return;
+  }
+  const record = findRouteRecord(router, key);
+  const loader = record && record.components && record.components.default;
+  // 只有异步组件(返回 Promise 的工厂函数)才需要预取
+  if (typeof loader !== 'function') {
+    return;
+  }
+  prefetched.add(key);
+  try {
+    const result = loader();
+    if (result && typeof result.catch === 'function') {
+      result.catch(() => {
+        // 预取失败时允许下次重试
+        prefetched.delete(key);
+      });
+    }
+  } catch (e) {
+    prefetched.delete(key);
+  }
+}
+
+/**
+ * 从链接地址里解析出路由路径
+ * @param {String} href 链接地址
+ * @param {String} base 路由 base
+ */
+export function resolveRoutePath(href, base) {
+  if (
+    !href ||
+    href.startsWith('http') ||
+    href.startsWith('//') ||
+    href.startsWith('#')
+  ) {
+    return '';
+  }
+  let path = href.split('?')[0].split('#')[0];
+  if (base && base !== '/' && path.startsWith(base)) {
+    path = '/' + path.slice(base.length);
+  }
+  return path;
+}
+
+/**
+ * 空闲时依次预取侧边栏里可见的菜单路由组件
+ * @param {Object} router vue-router 实例
+ * @param {Number} limit 最多预取多少个
+ * @param {Number} stagger 每个之间的间隔(毫秒)
+ */
+export function prefetchVisibleMenuRoutes(router, limit = 4, stagger = 800) {
+  if (!router || typeof document === 'undefined') {
+    return;
+  }
+  const links = document.querySelectorAll('.el-menu a[href]');
+  const base = router.options.base;
+  const paths = [];
+  for (let i = 0; i < links.length; i++) {
+    const path = resolveRoutePath(links[i].getAttribute('href'), base);
+    if (path && paths.indexOf(path) === -1) {
+      paths.push(path);
+    }
+  }
+  paths.slice(0, limit).forEach((path, index) => {
+    setTimeout(() => prefetchRouteComponent(router, path), index * stagger);
+  });
+}

+ 22 - 7
src/views/inspectionWork/index.vue

@@ -3,10 +3,10 @@
     <el-card shadow="never">
       <!-- Tab 页 -->
       <el-tabs v-model="activeTab" @tab-click="handleTabClick">
-        <el-tab-pane label="我的工单" name="myWork">
+        <el-tab-pane label="我的工单" name="myWork" lazy>
           <inspection-work-list ref="myWorkRef" :my-work="true" />
         </el-tab-pane>
-        <el-tab-pane label="检测工单" name="workList">
+        <el-tab-pane label="检测工单" name="workList" lazy>
           <inspection-work-list ref="workListRef" :my-work="false" />
         </el-tab-pane>
       </el-tabs>
@@ -17,6 +17,9 @@
 <script>
   import InspectionWorkList from './components/inspectionWorkList.vue';
 
+  // 可用的 tab 名称
+  const TAB_NAMES = ['myWork', 'workList'];
+
   export default {
     name: 'InspectionWork',
     components: {
@@ -24,21 +27,33 @@
     },
     data() {
       return {
-        activeTab: 'myWork'
+        activeTab: 'myWork',
+        // 已挂载过的 tab:lazy 面板首次挂载时表格会自动加载数据,无需再手动 search
+        mountedTabs: {}
       };
     },
     created() {
-      this.activeTab = this.$route.query.name || 'myWork';
+      const name = this.$route.query.name;
+      this.activeTab = TAB_NAMES.includes(name) ? name : 'myWork';
+      this.$set(this.mountedTabs, this.activeTab, true);
     },
     methods: {
       /* Tab 切换 */
       handleTabClick(tab) {
         console.log(tab, 'tab');
+        const name = tab.paneName;
         this.$nextTick(() => {
-          if (tab.paneName === 'myWork') {
-            this.$refs.myWorkRef?.search();
+          const ref =
+            name === 'myWork' ? this.$refs.myWorkRef : this.$refs.workListRef;
+          if (!ref) {
+            return;
+          }
+          if (this.mountedTabs[name]) {
+            // 面板之前已挂载,手动刷新
+            ref.search();
           } else {
-            this.$refs.workListRef?.search();
+            // 首次挂载,ele-pro-table 会自动加载数据,这里只做标记,避免重复请求
+            this.$set(this.mountedTabs, name, true);
           }
         });
       }