Amadeus's blog Amadeus's blog
首页
  • 前端文章

    • JavaScript
    • Vue
    • TypeScript
    • 前端工程化
  • 学习笔记

    • 《JavaScript教程》笔记
    • 《ES6 教程》笔记
    • 《Vue》笔记
    • 《TypeScript 从零实现 axios》
    • 小程序笔记
  • HTML
  • CSS
  • stylus
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 口语
  • 音标
  • 语法
  • 简单
  • 中等
  • 困难
  • 20年10月
  • 20年11月
  • 20年12月
  • 21年01月
  • 21年02月
  • 21年03月
  • 21年04月
  • 21年05月
  • 21年06月
  • 21年07月
  • 21年08月
  • 21年09月
  • 21年10月
  • 21年11月
  • 21年12月
  • 22年01月
  • 22年02月
  • 22年03月
  • 22年04月
  • 22年05月
  • 22年06月
  • 22年07月
  • 22年08月
  • 22年09月
  • 21年3月
  • 知识笔记
  • 22年5月
  • 22年8月
  • 22年9月
  • 学习
  • 书法
  • 面试
  • 音乐
  • 驾照
  • 深度强化学习
  • 心情杂货
  • 友情链接
关于
  • 网站
  • 资源
  • Vue资源
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Amadeus

起风了,唯有努力生存!
首页
  • 前端文章

    • JavaScript
    • Vue
    • TypeScript
    • 前端工程化
  • 学习笔记

    • 《JavaScript教程》笔记
    • 《ES6 教程》笔记
    • 《Vue》笔记
    • 《TypeScript 从零实现 axios》
    • 小程序笔记
  • HTML
  • CSS
  • stylus
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 口语
  • 音标
  • 语法
  • 简单
  • 中等
  • 困难
  • 20年10月
  • 20年11月
  • 20年12月
  • 21年01月
  • 21年02月
  • 21年03月
  • 21年04月
  • 21年05月
  • 21年06月
  • 21年07月
  • 21年08月
  • 21年09月
  • 21年10月
  • 21年11月
  • 21年12月
  • 22年01月
  • 22年02月
  • 22年03月
  • 22年04月
  • 22年05月
  • 22年06月
  • 22年07月
  • 22年08月
  • 22年09月
  • 21年3月
  • 知识笔记
  • 22年5月
  • 22年8月
  • 22年9月
  • 学习
  • 书法
  • 面试
  • 音乐
  • 驾照
  • 深度强化学习
  • 心情杂货
  • 友情链接
关于
  • 网站
  • 资源
  • Vue资源
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • TypeScript笔记

  • 前端工程化

  • 面试

  • 小程序

  • Vue3源码解析

  • 设计模式

  • NestJS笔记

  • JavaScript文章

  • Vue文章

  • 学习笔记

  • 温州LW公司项目总结

    • 公司C端官网项目
      • 公司B端项目
    • 前端
    • 温州LW公司项目总结
    Amadeus
    2026-03-11
    目录

    公司C端官网项目

    # 项目主要框架

    vue 2.5.17 vue-router 3.0.1 vuex 3.0.1 element-ui 2.15.14 axios 0.18.0 @vue/cli-service 3.2.0

    # 首屏加载优化

    # 1.图片压缩

    使用TinyPNG对图片进行压缩,压缩网站为 TinyPNG – 智能压缩您的WebP、JPEG和PNG图片 (opens new window) 配置Python环境,使用 tinycomp-amadeus,通过命令行进行tinypng的图片压缩

    pip install tinycomp-amadeus
    
    1

    TinyPNG 的 API Key 管理命令

    # Update API key (checks current key first)
    tinycomp update-key
    
    # Force update API key even if current one is valid
    tinycomp update-key --force
    
    1
    2
    3
    4
    5

    TinyPNG 图片压缩命令

    # Basic compression
    tinycomp compress --source ./images --target ./compressed
    # With custom API key
    tinycomp compress --source ./images --target ./compressed --api-key YOUR_API_KEY
    # Set number of threads
    tinycomp compress --source ./images --target ./compressed --threads 4
    # Enable automatic API key updates when needed
    tinycomp compress --source ./images --target ./compressed --auto-update-key
    
    1
    2
    3
    4
    5
    6
    7
    8

    # 2.图片懒加载

    使用 img 标签的 loading="lazy"即可,当图片快到视口的时候才会开始加载。

    <img loading="lazy" src="/img/new_home/t5.jpg" alt="">
    
    1

    如果想要更精准的控制图片离视口多远才开始加载,可以使用 IntersectionObserver,具体代码如下:

    <template>
      <div ref="observerElement">
        <!-- 你的内容 -->
        <img class="lazy-img" data-src="./img/1.jpg" alt="懒加载1" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" /> 
        <img class="lazy-img" data-src="./img/2.jpg" alt="懒加载2" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="/> 
        <img class="lazy-img" data-src="./img/3.jpg" alt="懒加载3" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="/> 
        <img class="lazy-img" data-src="./img/4.jpg" alt="懒加载4" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="/> 
        <img class="lazy-img" data-src="./img/5.jpg" alt="懒加载5" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" />
      </div>
    </template>
    <script>
    export default {
      name: 'LazyLoadComponent',
      mounted() {
        this.initIntersectionObserver();
      },
      beforeDestroy() {
        this.destroyIntersectionObserver();
      },
      methods: {
        initIntersectionObserver() {
          const options = {
            root: null, // 使用视口作为根元素
            rootMargin: '0px', // 可选,根元素的边缘外延的距离
            threshold: 0.0 // 当目标元素的可见比例达到这个阈值时触发回调
          };
    
          this.observer = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
              if (entry.isIntersecting) {
                // 元素进入视口时执行的操作
                console.log('Element is now visible in viewport');
                // 例如,加载数据或显示更多内容
                const img = entry.target; 
                const realSrc = img.dataset.src; // 加载真实图片 
                img.src = realSrc; // 停止观察该图片 
                this.observer.unobserve(img);
              }
            });
          }, options);
    
          this.observer.observe(this.$refs.observerElement);
        },
        destroyIntersectionObserver() {
          if (this.observer) {
            this.observer.disconnect(); // 停止观察并清理资源
          }
        }
      }
    };
    </script>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51

    如果是要降低首屏加载时间,把图片的加载放在页面加载之后,代码如下:

    <template>
      <div
        v-for="(item, index) in headerMenu"
    	:key="index"
    	v-show="index === currentSubIndex"
    	class="submenu-images-container"
    >
    	<router-link
    	  v-for="(child, idx) in item.children"
    	  :key="idx"
    	  :to="child.link + '?currentIndex=' + menuMore"
    	  class="submenu-image"
    	  v-show="idx === currentChildIndex"
    >
    	  <img :src="getImagePath(child.img)" :alt="$t(child.name)" />
    	</router-link>
      </div>
    </template>
    <script>
    export default {
      name: 'newHeader',
      data () {
        return {
          menuMore: -1,
          currentSubIndex: 0, // 当前显示的子菜单索引
          currentChildIndex: 0, // 当前显示的子菜单项索引
          headerMenu: [{
            id: 1,
            name: 'Product Series',
            is_active: true,
            homepage: '',
            children: [
              {
                id: 2,
                name: 'Function modules',
                link: '/function/electronic_types',
                is_active: true,
                homepage: '',
                img: '/img/new_home/menu/[email protected]'
              }
            ]
          }, {
            id: 6,
            name: 'Technical Support',
            is_active: true,
            homepage: '',
            children: [{
              id: 7,
              name: 'App Download',
              link: '/download',
              is_active: true,
              homepage: '',
              img: '/img/new_home/menu/[email protected]'
            }]
          }, {
            id: 10,
            name: 'About LIVOLO',
            link: '/story',
            is_active: true,
            homepage: '',
            children: []
          }],
        };
      },
      mounted () {
        window.onload = () => {
          this.preloadImages();
        };
      },
      methods: {
    	preloadImages () {
          this.headerMenu.forEach((item) => {
            if (item.children) {
              item.children.forEach((child) => {
                if (child.img) {
                  const img = new Image();
                  img.src = child.img;
                  img.onload = () => {
                    console.log(`图片预加载完成: ${child.img}`);
                  };
                  img.onerror = () => {
                    console.error(`图片预加载失败: ${child.img}`);
                  };
                }
              });
            }
          });
        }
      }
    }
    </script>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91

    # 3.视频懒加载

    构建一个视频懒加载组件,这个组件作用就是截取第一帧图片优先加载,等页面加载完毕后再加载视频来降低首屏加载时间。 组件介绍如下: 1.每个视频都要配置好 refName,因为视频的加载、截取帧等操作都依赖refName来选取视频元素。 2.enableCapture 属性来开启捕捉视频帧模式,用户可以拖拽视频进度条来手动截取指定帧并保存到本地。 3.poster为视频封面,如果没有设置,组件会自动去截取第一帧作为封面。 4.src是视频源路径。

    <template>
      <div class="video-wrapper">
        <video
          :ref="refName"
          :poster="posterUrl"
          :class="{ 'hidden': !hasPoster, ...videoClass }"
          muted
          :loop="!captureMode"
          preload="none"
          x5-playsinline="true"
          playsinline="true"
          webkit-playsinline="true"
          x-webkit-airplay="true"
          x5-video-orientation="portraint"
          :style="{
            cursor: clickable ? 'pointer' : 'default',
            'pointer-events': disablePointerEvents ? 'none' : 'auto',
            'background-color': '#fff',
            ...videoStyle
          }"
          @click="handleClick"
        ></video>
        <!-- 截图控制面板 -->
        <div v-if="enableCapture && hasPoster" class="capture-controls">
          <button class="capture-btn" @click="toggleCaptureMode">
            {{ captureMode ? '退出截图' : '开始截图' }}
          </button>
          <div v-if="captureMode" class="capture-panel">
            <div class="time-slider">
              <input
                type="range"
                :min="0"
                :max="videoDuration"
                :step="0.1"
                v-model.number="currentTime"
                @input="onTimeUpdate"
              >
              <span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(videoDuration) }}</span>
            </div>
            <div class="control-buttons">
              <button @click="stepBackward">-1秒</button>
              <button @click="togglePlayPause">{{ isPlaying ? '暂停' : '播放' }}</button>
              <button @click="stepForward">+1秒</button>
              <button @click="captureCurrentFrame">截取当前帧</button>
            </div>
          </div>
        </div>
      </div>
    </template>
    <script>
    
    export default {
      name: 'LazyVideo',
      props: {
        refName: {
          type: String,
          required: true
        },
        src: {
          type: String,
          required: true
        },
        poster: {
          type: String,
          default: ''
        },
        clickable: {
          type: Boolean,
          default: false
        },
        videoClass: {
          type: Object,
          default: () => ({})
        },
        videoStyle: {
          type: Object,
          default: () => ({})
        },
        enableCapture: {
          type: Boolean,
          default: false
        },
        disablePointerEvents: {
          type: Boolean,
          default: false
        }
      },
      data() {
        return {
          isLoaded: false,
          firstFramePoster: '',
          hasPoster: false,
          captureMode: false,
          isPlaying: false,
          currentTime: 0,
          videoDuration: 0
        }
      },
      computed: {
        posterUrl() {
          return this.poster || this.firstFramePoster;
        }
      },
      async created() {
        // 在created阶段就开始获取poster
        if (!this.poster) {
          await this.generatePoster();
        } else {
          this.hasPoster = true;
        }
      },
      mounted() {
        // 页面加载完成后立即开始加载视频
        window.addEventListener('load', this.loadVideo);
        // 如果页面已经加载完成(从其他页面跳转来),直接加载视频
        if (document.readyState === 'complete') {
          this.loadVideo();
        }
        // this.loadVideo();
      },
      beforeDestroy() {
        window.removeEventListener('load', this.loadVideo);
      },
      methods: {
        handleClick() {
          if (this.clickable) {
            this.$emit('click');
          }
        },
        async generatePoster() {
          try {
            const tempVideo = document.createElement('video');
            tempVideo.crossOrigin = 'anonymous';
            await new Promise((resolve, reject) => {
              tempVideo.addEventListener('loadedmetadata', () => {
                tempVideo.currentTime = 0;
              });
              tempVideo.addEventListener('seeked', () => {
                const canvas = document.createElement('canvas');
                canvas.width = tempVideo.videoWidth;
                canvas.height = tempVideo.videoHeight;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(tempVideo, 0, 0, canvas.width, canvas.height);
                this.firstFramePoster = canvas.toDataURL('image/jpeg');
                this.hasPoster = true;
                tempVideo.remove();
                resolve();
              }, { once: true });
              tempVideo.addEventListener('error', (error) => {
                console.error('Error generating poster:', error);
                reject(error);
              });
              tempVideo.preload = 'metadata';
              tempVideo.muted = true;
              tempVideo.src = this.src;
              tempVideo.load();
            });
          } catch (error) {
            console.error('Failed to generate poster:', error);
            this.hasPoster = true;
          }
        },
        loadVideo() {
          const video = this.$refs[this.refName];
          if (!video || this.isLoaded) return;
          video.poster = this.posterUrl;
          video.src = this.src;
          video.setAttribute('autoplay', 'true');
          video.addEventListener('loadedmetadata', () => {
            this.videoDuration = video.duration;
          });
          video.addEventListener('loadeddata', () => {
            this.isLoaded = true;
            if (!this.captureMode) {
              video.play().catch(err => {
                console.warn('Video autoplay prevented:', err);
              });
            }
            this.$emit('loaded');
          }, { once: true });
          video.addEventListener('timeupdate', () => {
            if (this.captureMode) {
              this.currentTime = video.currentTime;
            }
          });
          video.addEventListener('error', (error) => {
            console.error('Video loading error:', error);
            this.$emit('error', error);
          });
        },
        toggleCaptureMode() {
          const video = this.$refs[this.refName];
          if (!video) return;
          this.captureMode = !this.captureMode;
          if (this.captureMode) {
            video.pause();
            this.isPlaying = false;
            this.currentTime = video.currentTime;
          } else {
            video.play();
            this.isPlaying = true;
          }
        },
        onTimeUpdate() {
          const video = this.$refs[this.refName];
          if (video && this.captureMode) {
            video.currentTime = this.currentTime;
          }
        },
        togglePlayPause() {
          const video = this.$refs[this.refName];
          if (!video) return;
          if (this.isPlaying) {
            video.pause();
            this.isPlaying = false;
          } else {
            video.play();
            this.isPlaying = true;
          }
        },
        stepBackward() {
          const video = this.$refs[this.refName];
          if (!video) return;
          this.currentTime = Math.max(0, this.currentTime - 1);
          video.currentTime = this.currentTime;
        },
        stepForward() {
          const video = this.$refs[this.refName];
          if (!video) return;
          this.currentTime = Math.min(this.videoDuration, this.currentTime + 1);
          video.currentTime = this.currentTime;
        },
        formatTime(seconds) {
          const mins = Math.floor(seconds / 60);
          const secs = Math.floor(seconds % 60);
          const ms = Math.floor((seconds % 1) * 10);
          return `${mins}:${secs.toString().padStart(2, '0')}.${ms}`;
        },
        captureCurrentFrame() {
          const video = this.$refs[this.refName];
          if (!video) return;
          const canvas = document.createElement('canvas');
          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;
          const ctx = canvas.getContext('2d');
          ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
          // 创建下载链接
          const link = document.createElement('a');
          const timestamp = this.formatTime(this.currentTime).replace(/[:\.]/g, '-');
          link.download = `${this.refName}_frame_${timestamp}.jpg`;
          link.href = canvas.toDataURL('image/jpeg');
          document.body.appendChild(link);
          link.click();
          document.body.removeChild(link);
        }
      }
    }
    </script>
    
    <style scoped>
    .video-wrapper {
      position: relative;
      width: 100%;
      height: 100%;
    }
    
    .hidden {
      opacity: 0;
      transition: opacity 0.3s ease-in-out;
    }
    
    video {
      width: 100%;
      height: 100%;
      transition: opacity 0.3s ease-in-out;
    }
    
    .capture-controls {
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      background: rgba(0, 0, 0, 0.7);
      padding: 10px;
      color: white;
    }
    
    .capture-panel {
      margin-top: 10px;
    }
    
    .time-slider {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }
    
    .time-slider input[type="range"] {
      flex: 1;
    }
    
    .time-display {
      min-width: 100px;
      text-align: right;
    }
    
    .control-buttons {
      display: flex;
      gap: 10px;
      justify-content: center;
    }
    
    button {
      padding: 5px 10px;
      border: none;
      border-radius: 4px;
      background: #4CAF50;
      color: white;
      cursor: pointer;
    }
    
    button:hover {
      background: #45a049;
    }
    
    .capture-btn {
      width: 100px;
      margin: 0 auto;
      display: block;
    }
    
    video::-webkit-media-controls-start-playback-button {
      display:none;
    }
    
    video::-webkit-media-controls {
      display:none;
    }
    </style>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340

    具体的组件引用代码如下:

    <template>
       <lazy-video
    	  ref-name="smartControlVideoEl"
    	  :src="videoSources[0].src"
    	  :poster="videoSources[0].poster"
    	  :disable-pointer-events="true"
    	  :video-style="{
    		cursor: 'pointer',
    		backgroundColor: '#fff',
    		width: '100%',
    		height: '100%',
    		objectFit: 'cover',
    		objectPosition: 'center'
    	  }"
    	/>
    </template>
    <script>
    export default {
      data() {
    	  videoSources: [
    		{
    		  id: 'smartControl',
    		  src: '/img/new_home/智能板块.mp4',
    		  poster: '/img/new_home/smart_control.jpg',
    		  loaded: false,
    		  ref: 'smartControlVideo'
    		},
    		{
    		  id: 'thermostat',
    		  src: '/img/new_home/温控器.mp4',
    		  poster: '/img/new_home/thermostat.jpg', // 需要添加对应的封面图
    		  loaded: false,
    		  ref: 'thermostatVideo'
    		},
    		{
    		  id: 'bluetoothSpeaker',
    		  src: '/img/new_home/蓝牙音箱.mp4',
    		  poster: '/img/new_home/bluetooth_speaker.jpg', // 需要添加对应的封面图
    		  loaded: false,
    		  ref: 'bluetoothVideo'
    		}
    	  ],
      }
    }
    </script>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45

    # 4.JS懒加载

    构建资源加载类,通过meta和路由守卫来控制当前资源页的加载

    // utils/resourceLoader.js
    import Vue from 'vue'
    // 资源加载管理器
    class ResourceLoader {
      constructor() {
        this.loaded = false;
        this.loadPromise = null;
        this.pdfLoaded = false;
        this.pdfLoadPromise = null;
      }
      async loadElementUI() {
        const ElementUI = await import('element-ui');
        const {
          Button, Input, Select, Option,
          Form, FormItem,
          Menu, Submenu, MenuItem, MenuItemGroup,
          Table, TableColumn, Pagination,
          Loading, Dialog,
          Dropdown, DropdownMenu, DropdownItem,
          Carousel, CarouselItem,
          Collapse, CollapseItem,
          Autocomplete, RadioGroup,
          Message
        } = ElementUI;
        // 注册 Element UI 组件
        Vue.use(Button);
        Vue.use(Input);
        Vue.use(Select);
        Vue.use(Option);
        Vue.use(Form);
        Vue.use(FormItem);
        Vue.use(Menu);
        Vue.use(Submenu);
        Vue.use(MenuItem);
        Vue.use(MenuItemGroup);
        Vue.use(Table);
        Vue.use(TableColumn);
        Vue.use(Pagination);
        Vue.use(Dropdown);
        Vue.use(DropdownMenu);
        Vue.use(DropdownItem);
        Vue.use(Carousel);
        Vue.use(CarouselItem);
        Vue.use(Collapse);
        Vue.use(CollapseItem);
        Vue.use(Dialog);
        Vue.use(Loading.directive);
        Vue.use(Autocomplete);
        Vue.use(RadioGroup);
        Vue.prototype.$message = Message;
        // 加载 Element UI 样式
        await import('element-ui/lib/theme-chalk/index.css');
      }
      async loadMintUI() {
        const MintUI = await import('mint-ui');
        const { Toast, Picker, Popup, InfiniteScroll, Field } = MintUI;
        Vue.use(MintUI, {
          Toast,
          Picker,
          Popup,
          InfiniteScroll,
          Field
        });
        // 加载 Mint UI 样式
        await import('mint-ui/lib/style.css');
      }
      async loadVant() {
        const Vant = await import('vant');
        Vue.use(Vant);
        await import('vant/lib/index.css');
      }
      async loadOtherResources() {
        // 加载其他资源
        const PdfPreview = await import('@/utils/pdf-preview/index.js');
        Vue.prototype.$pdfPreview = PdfPreview.default;
        const Rem = await import('@/utils/rem.js');
        Rem.default();
        const imgTrim = await import('@/utils/imgTrim.js');
        Vue.use(imgTrim.default);
        // 加载 jQuery
        const $ = await import('jquery');
        window.$ = window.jQuery = $.default;
        // 加载过滤器
        const filters = await import('@/filters');
        Object.keys(filters.default).forEach(filterName => {
          Vue.filter(filterName, filters.default[filterName]);
        });
      }
      async loadPdfResources() {
        if (this.pdfLoaded || this.pdfLoadPromise) {
          return this.pdfLoadPromise;
        }
        this.pdfLoadPromise = new Promise((resolve, reject) => {
          const script = document.createElement('script');
          script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.5.207/pdf.min.js';
          script.async = true;
          script.onload = () => {
            this.pdfLoaded = true;
            resolve();
          };
    
          script.onerror = (error) => {
            this.pdfLoadPromise = null;
            reject(new Error('Failed to load PDF.js: ' + error));
          };
          document.body.appendChild(script);
        });
        return this.pdfLoadPromise;
      }
      async loadAllResources() {
        if (this.loadPromise) {
          return this.loadPromise;
        }
        this.loadPromise = Promise.all([
          this.loadElementUI(),
          this.loadMintUI(),
          this.loadVant(),
          this.loadOtherResources()
        ]).then(() => {
          this.loaded = true;
          console.log('All resources loaded successfully');
        }).catch(error => {
          console.error('Error loading resources:', error);
          throw error;
        });
        return this.loadPromise;
      }
      isLoaded() {
        return this.loaded;
      }
      isPdfLoaded() {
        return this.pdfLoaded;
      }
    }
    export default new ResourceLoader();
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135

    路由守卫代码如下:

    // router/index.js
    // 路由守卫,确保需要额外资源的页面在资源加载完成后才能访问
    router.beforeEach(async (to, from, next) => {
        try {
            // 检查是否需要基础资源
            if (to.meta.requiresResources && !ResourceLoader.isLoaded()) {
                await ResourceLoader.loadAllResources();
            }
            // 检查是否需要 PDF 资源
            if (to.meta.requiresPdf && !ResourceLoader.isPdfLoaded()) {
                await ResourceLoader.loadPdfResources();
            }
            next();
        } catch (error) {
            console.error('Failed to load resources:', error);
            next(false);
        }
    });
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18

    最后在路由里配置资源加载参数即可,参考如下:

    // router/webRouter.js
    export const webRouter = [
      {
        path: '/',
        component: (resolve) => require(['@/views/new/index.vue'], resolve),
        name: 'index',
        meta: {title: '首页', icon: 'dashboard', affix: true, requiresResources: false }
      },
      {
        path: '/panel/:activePanel',
        component: (resolve) => require(['@/views/new/panel.vue'], resolve),
        name: 'new-panel',
        meta: {title: '首页', icon: 'dashboard', affix: true, requiresResources: false}
      },
      {
        path: '/photo',
        component: (resolve) => require(['@/views/new/brandPhoto.vue'], resolve),
        name: 'new-photo',
        meta: {title: '品牌画册', icon: 'dashboard', affix: true, requiresResources: true, requiresPdf: true}
      },
      {
        path: '/finder/curtainvideo',
        component: (resolve) => require(['@/views/new/finder/curtainvideo.vue'], resolve),
        name: 'new-finder-curtainvideo',
        meta: {title: '教程视频', icon: 'dashboard', affix: true, requiresResources: false, requiresPdf: false}
      }
    ]
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27

    # 5.html/css和js等其他资源的gzip压缩

    在vue.config.js里进行相应的配置即可

    # 2k\4k\5k屏幕适配

    定义$breakpoints端点数组和respondTo混合器,封装冗余的媒体查询,实际使用的时候直接使用 @include respondTo('lg',‘xl’) 即可,可以省去冗长的@media表达式,对于不同断点相同的样式不再需要重复书写,具体代码如下:

    $breakpoints:(
        "xs": (320px, 480px),
        "sm": (481px, 768px),
        "md": (769px, 1024px),
        "lg": (1025px, 1440px),
        "xl": (1441px, 2000px),
        "2k": (2001px, 3770px),
        "4k": (3771px, 5119px),
        "5k": (5120px, 99999px)
    );
    
    @mixin respondTo($breaknames){
        @each $breakname in $breaknames {
            $bp: map-get($breakpoints, $breakname);
            @if type-of($bp == 'list') {
                $min: nth($bp, 1);
                $max: nth($bp, 2);
                @media (min-width: $min) and (max-width: $max) {
                    @content;
                }
            }
            @else {
                @media (min-width: $bp) {
                    @content;
                }
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28

    混入调用方式如下:

    .wrapper {
      min-width: 1400px;
      @include respondTo('lg') {
        width: 1290px !important;
        min-width: 1290px !important;
      }
      @include respondTo(xl) {
        width: 1440px !important;
      }
      @include respondTo('2k') {
        width: 1920px !important;
      }
      @include respondTo('4k') {
        width: 2880px !important;
      }
      @include respondTo('5k') {
        width: 3840px !important;
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19

    媒体查询更多适用于不同设备间展示布局有较大变化的情况。除非不同断点间的元素不不是等比缩放的,需要一定调整才使用。 如果是按视口宽度百分比缩放的,不建议使用,可能会导致很多类名下都会有大量的针对不同断点的混入,难以维护,使用rem、vw之类的可能会更好,不同平台之间的兼容性也会更好,px这个单位不同设备间展示的效果都有较大差异。

    # 移动端、PC端多端适配及访问设备检测

    根据user-agent来判断当前访问的设备并通过路由守卫进行对应路径的跳转,在这里对于移动端的设备会跳转到/m路径下,否则就按PC端处理直接跳转到对应的路径下。

    // 设备检测工具函数
    const checkDevice = {
        isMobile: () => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),
        needRedirect: (path) => {
            const isMobile = checkDevice.isMobile()
            return isMobile ? !path.startsWith('/m') : path.startsWith('/m')
        },
        getRedirectPath: (path, query) => {
            const isMobile = checkDevice.isMobile()
            const redirectPath = isMobile
                ? `/m${path}` // PC端路径转为移动端路径
                : path.replace('/m', '') // 移动端路径转为PC端路径
            return { path: redirectPath, query }
        }
    }
    
    router.beforeEach((to, from, next) => {
        const currentLang = localStorage.getItem('selectedLocale') || localStorage.getItem('language') || 'en'
        if (checkDevice.needRedirect(to.path)) {
            loadLanguageAsync(currentLang).then(() => {
                next(checkDevice.getRedirectPath(to.path, to.query))
            }).catch(() => {
                next(checkDevice.getRedirectPath(to.path, to.query))
            })
        } else {
            loadLanguageAsync(currentLang).then(() => {
                next()
            }).catch(() => {
                next()
            })
        }
    })
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32

    # 多语言

    使用vue-i18n框架,8.3.2版本。实例化VueI18n对象,并定义语言的设置和语言包懒加载方法。具体代码如下所示:

    // setup/i18n-setup.js
    import Vue from 'vue'
    import VueI18n from 'vue-i18n'
    import messages from '@/lang/en_US.js'
    import axios from 'axios'
    Vue.use(VueI18n)
    // 追踪已加载的语言文件
    export const loadedLanguages = ['en_US']
    // 将语言代码转换为文件名格式
    export const getLanguageFile = (locale) => {
      // 从完整的 locale (如 'zh-CN') 中提取基础语言代码 ('zh')
      const baseLanguage = locale.split('-')[0]
      // 根据你的文件命名规则返回对应的文件名
      switch(baseLanguage) {
        case 'en': return 'en_US'  // 英语
        case 'ja': return 'ja_JP'  // 日语
        case 'zh': return 'zh_CN'  // 中文(简体)
        default: return baseLanguage
      }
    }
    // 获取浏览器语言和地区
    const getBrowserLocale = () => {
      const browserLang = navigator.language || navigator.userLanguage
      // 返回完整的语言-地区代码,如 'zh-CN', 'en-US'
      return browserLang
    }
    const savedLocale = localStorage.getItem('selectedLocale') || getBrowserLocale() || 'en-US'
    const languageFile = getLanguageFile(savedLocale)
    const loadInitialLanguage = async () => {
      if (languageFile !== 'en_US') {
        try {
          const messages = await import(/* webpackChunkName: "lang-[request]" */ `@/lang/${languageFile}.js`)
          i18n.setLocaleMessage(languageFile, messages.default)
          loadedLanguages.push(languageFile)
        } catch (error) {
          console.error(`Could not load initial language ${languageFile}:`, error)
        }
      }
    }
    export const i18n = new VueI18n({
      locale: languageFile,
      fallbackLocale: 'en_US',
      messages: {
        en_US: messages
      },
      silentTranslationWarn: true,
      // 添加自定义 modifier
      modifiers: {
        lowercase: str => str.toLowerCase(),
        uppercase: str => str.toUpperCase(),
        capitalize: str => str.charAt(0).toUpperCase() + str.slice(1)
      },
      missing: (locale, key) => {
        // 当找不到翻译时,尝试用小写key再次查找
        const messages = i18n.messages[locale]
        const lowerKey = key.toLowerCase()
        for (let k in messages) {
          if (k.toLowerCase() === lowerKey) {
            return messages[k]
          }
        }
        return key
      }
    })
    export function setI18nLanguage(locale) {
      const languageFile = getLanguageFile(locale)
      i18n.locale = languageFile
      axios.defaults.headers.common['Accept-Language'] = locale
      document.querySelector('html').setAttribute('lang', locale)
      localStorage.setItem('selectedLocale', locale)
      return languageFile
    }
    export async function loadLanguageAsync(locale) {
      const langFile = getLanguageFile(locale)
      // 如果语言已经加载
      if (i18n.locale === langFile) {
        return Promise.resolve(setI18nLanguage(locale))
      }
      // 如果语言还没有加载
      if (!loadedLanguages.includes(langFile)) {
        try {
          const messages = await import(/* webpackChunkName: "lang-[request]" */ `@/lang/${langFile}.js`)
          i18n.setLocaleMessage(langFile, messages.default)
          loadedLanguages.push(langFile)
          return setI18nLanguage(locale)
        } catch (error) {
          console.error(`Failed to load language ${langFile}:`, error)
          return Promise.reject(error)
        }
      }
      return Promise.resolve(setI18nLanguage(locale))
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92

    为了保证切换页面的时候,语言保持不变,还需要使用 vuex 来进行全局状态管理,具体代码如下:

    // store/modules/lang.js
    import { loadLanguageAsync, getLanguageFile, setI18nLanguage, loadedLanguages, i18n } from '@/setup/i18n-setup'
    const state = {
      currentLang: localStorage.getItem('selectedLocale') || 'en-US'
    }
    const mutations = {
      SET_LANG(state, lang) {
        state.currentLang = lang
      }
    }
    const actions = {
      async changeLang({ commit }, locale) {
        const languageFile = getLanguageFile(locale)
        if (!loadedLanguages.includes(languageFile)) {
          const messages = await import(/* webpackChunkName: "lang-[request]" */ `@/lang/${languageFile}.js`)
          i18n.setLocaleMessage(languageFile, messages.default)
          loadedLanguages.push(languageFile)
        }
        setI18nLanguage(locale)
        commit('SET_LANG', locale)
      }
    }
    const getters = {
      currentLang: state => state.currentLang
    }
    export default {
      namespaced: true,
      state,
      mutations,
      actions,
      getters
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32

    语言包的格式如下,也可以使用JSON文件来代替:

    // lang/zh_CN.js
    export default {
      "Condition": "\u6761\u4ef6",
      "SMART HOME SCENE": "\u667a\u80fd\u5bb6\u5c45\u573a\u666f",
      "Smart Scenarios of Human Body Sensor": "\u4eba\u4f53\u4f20\u611f\u5668\u7684\u667a\u80fd\u573a\u666f",
      'Smart Applications': '智能应用',
      'Product Series': '产品系列',
      'Technical Support': '技术支持',
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

    首次页面加载的时候初始化加载语言包

    // main.js
    import store from './store' // vuex配置
    // 初始化时加载保存的语言
    store.dispatch('lang/changeLang', store.state.lang.currentLang)
    
    1
    2
    3
    4

    语言切换组件所需的核心功能代码如下:

    <script>
    import { mapGetters, mapActions } from 'vuex'
    export default {
      data() {
    	selectedLang: '',
      },
      created() {
        this.selectedLang = localStorage.getItem('selectedLocale') || this.$i18n.locale || 'en'
      },
      computed: {
        ...mapGetters('lang', ['currentLang'])
      },
      methods: {
         ...mapActions('lang', ['changeLang']),
    	 handleLangChange(langCode) {
          this.selectedLang = langCode;
          this.changeLang(langCode);
          this.isDropdownVisible = false;
        },
        getCurrentLangName() {
          const currentLang = this.langList.find(lang => lang.code === this.selectedLang);
          return currentLang ? currentLang.name : 'Language';
        },
      }
    }
    </script>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26

    # 实际效果与原型对比分析组件

    为了方便UI设计师、开发人员等快速地查看设计稿还原度,通过这个组件可以进行粗略地判断,主要的思路就是把UI设计师的设计稿按一定的透明度覆盖在页面上,哪些地方没盖住就是没还原到位的地方。

    <template>
      <div>
        <!-- 设计稿覆盖层 - 跟随页面滚动 -->
        <div v-if="showDesignBackground && designDraftPath" class="design-overlay">
          <img :src="designDraftPath" alt="设计稿" class="design-image">
        </div>
        <!-- 设计稿切换按钮 - 始终显示,用于调试 -->
        <div class="design-toggle" @click="toggleDesignBackground">
          <span class="toggle-text">
            {{ showDesignBackground ? '隐藏设计稿' : '显示设计稿' }}
            <br>
            <small style="font-size: 10px; opacity: 0.7;">
              {{ designDraftPath || '无设计稿' }}
            </small>
          </span>
        </div>
      </div>
    </template>
    <script>
    import { getDesignDraftPath } from '@/config/designDrafts'
    export default {
      name: 'DesignOverlay',
      data() {
        return {
          showDesignBackground: false
        }
      },
      computed: {
        // 从配置文件获取设计稿路径
        designDraftPath() {
          // 构建完整的路径,包括查询参数
          const fullPath = this.$route.path + (this.$route.query && Object.keys(this.$route.query).length > 0
            ? '?' + new URLSearchParams(this.$route.query).toString()
            : '')
          const path = getDesignDraftPath(fullPath)
          console.log('当前路径:', this.$route.path, '查询参数:', this.$route.query, '完整路径:', fullPath, '设计稿路径:', path)
          return path
        }
      },
      mounted() {
        // 从本地存储读取设计稿显示状态
        const savedState = localStorage.getItem('showDesignBackground')
        if (savedState !== null) {
          this.showDesignBackground = JSON.parse(savedState)
        }
        console.log('DesignOverlay 组件已挂载,当前路径:', this.$route.path)
      },
      methods: {
        toggleDesignBackground() {
          this.showDesignBackground = !this.showDesignBackground
          // 保存状态到本地存储
          localStorage.setItem('showDesignBackground', JSON.stringify(this.showDesignBackground))
          console.log('切换设计稿显示状态:', this.showDesignBackground)
        }
      }
    }
    </script>
    <style scoped lang="scss">
    // 设计稿覆盖层 - 跟随页面滚动
    .design-overlay {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%; // 使用100%而不是100vw,避免包含滚动条宽度
      min-height: 100vh; // 确保覆盖整个页面高度
      z-index: 9998; // 比切换按钮低一层,但比网站内容高
      pointer-events: none; // 不阻止用户交互
      opacity: 0.8;
      .design-image {
        width: 100%;
        height: 100%;
        object-fit: contain; // 保持设计稿完整显示
        object-position: top center;
      }
    }
    // 设计稿切换按钮样式 - 保持固定定位
    .design-toggle {
      position: fixed;
      top: 20px;
      right: 20px;
      z-index: 9999; // 确保按钮在最顶层
      background: #323232;
      color: white;
      padding: 10px 15px;
      border-radius: 5px;
      cursor: pointer;
      font-size: 14px;
      font-weight: 500;
      box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
      transition: all 0.3s ease;
      user-select: none;
      pointer-events: auto; // 确保按钮可以点击
      &:hover {
        background: #555;
        transform: translateY(-2px);
        box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
      }
      .toggle-text {
        white-space: nowrap;
        line-height: 1.2;
      }
    }
    // 响应式调整
    @media (max-width: 768px) {
      .design-toggle {
        top: 10px;
        right: 10px;
        padding: 8px 12px;
        font-size: 12px;
      }
    }
    </style>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112

    这个组件需要放在 App.vue 全局组件下。

    // App.vue
    <template>
      <div id="app">
        <router-view />
        <!-- 全局设计稿覆盖层 - 只在开发环境且在特定的开发路由下显示 -->
        <DesignOverlay v-if="isDevelopment && $route.path.includes('/story')" /> 
      </div>
    </template>
    <script>
    import DesignOverlay from '@/components/DesignOverlay.vue'
    export default {
      name: "App",
      components: {
        DesignOverlay
      },
      computed: {
        isDevelopment () {
          return process.env.NODE_ENV === 'development'
        }
      },
    }
    </script>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

    根据当前路径匹配出对应的设计稿,而不是加载所有的设计稿,每个路径可以根据 designDrafts 匹配多个设计稿。

    // config/designDrafts.js
    // 设计稿配置文件
    export const designDrafts = {
      '/': '/img/design_drafts/home.png',
      '/story': '/img/design_drafts/story.jpg',
      // 带参数的路径配置
      '/function?type=electronic_types': '/img/design_drafts/function#type=electronic_types.png',
      '/panel?activePanel=0': '/img/design_drafts/panel#activePanel=0.jpg',
    }
    // 获取当前页面的设计稿路径
    export function getDesignDraftPath(routePath) {
      console.log(`正在查找设计稿: ${routePath}`)
      // 首先尝试精确匹配
      if (designDrafts[routePath]) {
        console.log(`精确匹配成功: ${routePath} -> ${designDrafts[routePath]}`)
        return designDrafts[routePath]
      }
      // 如果精确匹配失败,尝试智能匹配
      // 解析路径和查询参数
      const [pathname, search] = routePath.split('?')
      const queryParams = new URLSearchParams(search || '')
      console.log(`解析路径: pathname=${pathname}, search=${search}`)
      // 构建可能的匹配键
      const possibleKeys = []
      // 1. 基础路径
      possibleKeys.push(pathname)
      // 2. 带查询参数的完整路径
      if (search) {
        possibleKeys.push(routePath)
      }
      // 3. 通用查询参数匹配:尝试所有可能的查询参数组合
      if (search && queryParams.size > 0) {
        // 构建所有可能的查询参数组合
        const paramEntries = Array.from(queryParams.entries())
        // 尝试单个参数
        paramEntries.forEach(([key, value]) => {
          possibleKeys.push(`${pathname}?${key}=${value}`)
        })
        // 尝试多个参数组合(按原始顺序)
        if (paramEntries.length > 1) {
          const sortedParams = new URLSearchParams()
          paramEntries.forEach(([key, value]) => {
            sortedParams.append(key, value)
          })
          possibleKeys.push(`${pathname}?${sortedParams.toString()}`)
        }
      }
      console.log(`尝试匹配的键:`, possibleKeys)
      // 查找匹配的设计稿
      for (const key of possibleKeys) {
        if (designDrafts[key]) {
          console.log(`设计稿匹配成功: ${routePath} -> ${key} -> ${designDrafts[key]}`)
          return designDrafts[key]
        }
      }
      console.log(`未找到设计稿: ${routePath}`)
      return null
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58

    # SEO优化

    给网站添加KTD(Keywords,Title,Description)以及 Google Tag Manger 用于SEO和访问用户行为监测。另外还添加了Schema来控制网站被录入后的检索效果。

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="title" content="Home page">
        <meta name="description" content="XXX is known around the world as a leader in smart home devices that include electric switches and sockets." />
        <meta name="keywords" content="XXX,XXX touch switch,XXX socket,smart switch,app control switch,led indicate switch,light switch,wall socket"/>
        <!-- Twitter Cards -->
        <meta property="twitter:url" content="https://www.xxx.com" />
        <meta name="twitter:title" content="Home page  -- XXX experts in electric switches" />
        <meta name="twitter:description" content="XXX is known around the world as a leader in smart home devices that include electric switches and sockets." />
        <meta name="twitter:site" content="https://www.xxx.com" />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:image" content="" />
        <!-- Facebook Open Graph -->
        <meta property="og:url" content="https://www.xxx.com" />
        <meta property="og:title" content="Home page  -- XXX experts in electric switches" />
        <meta property="og:description" content="XXX is known around the world as a leader in smart home devices that include electric switches and sockets." />
        <meta property="og:image" content="" />
        <meta property="og:type" content="website" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
        <link rel="icon" href="<%= BASE_URL %>favicon.ico">
        <title>XXX</title>
        <!-- End Google Tag Manager -->
        <!-- 生产环境使用 CDN -->
        <!-- <% if (process.env.NODE_ENV === 'production') { %>
          <link rel="stylesheet" href="https://unpkg.com/[email protected]/lib/theme-chalk/index.css">
          <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/lib/style.min.css">
        <% } %> -->
        <!-- Google tag (gtag.js) -->
        <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXX"></script>
        <script>
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', 'G-XXX');
        </script>
        <!-- 添加结构化数据 -->
        <script type="application/ld+json">
        [
          {
            "@context": "https://schema.org",
            "@type": "Organization",
            "url": "https://www.xxx.com",
            "logo": "https://www.xxx.com/favicon.ico",
            "name": "XXX"
          },
          {
            "@context": "https://schema.org",
            "@type": "WebSite",
            "url": "https://www.xxx.com",
            "name": "XXX",
            "potentialAction": {
              "@type": "SearchAction",
              "target": {
                "@type": "EntryPoint",
                "urlTemplate": "https://www.xxx.com/search?q={search_term_string}"
              },
              "query-input": "required name=search_term_string"
            }
          },
          {
            "@context": "https://schema.org",
            "@type": "FAQPage",
            "mainEntity": [{
              "@type": "Question",
              "name": "What is XXX Function?",
              "acceptedAnswer": {
                "@type": "Answer",
                "text": "Explore our smart home functions including touch switches, sockets, and smart control systems",
                "url": "https://www.xxx.com/function"
              }
            }, {
              "@type": "Question",
              "name": "What is XXX Story?",
              "acceptedAnswer": {
                "@type": "Answer",
                "text": "Learn about XXX's history, development and our commitment to smart home innovation",
                "url": "https://www.xxx.com/story"
              }
            }, {
              "@type": "Question",
              "name": "How to Contact XXX?",
              "acceptedAnswer": {
                "@type": "Answer",
                "text": "Get in touch with XXX team for business cooperation or technical support through our contact page",
                "url": "https://www.xxx.com/contact"
              }
            }]
          },
          {
            "@context": "https://schema.org",
            "@type": "ItemList",
            "itemListElement": [
              {
                "@type": "ListItem",
                "position": 1,
                "item": {
                  "@type": "Article",
                  "name": "Smart Home Functions",
                  "headline": "Explore XXX Smart Home Solutions",
                  "description": "Discover our innovative smart home solutions including touch switches, smart sockets, and intelligent control systems for modern living",
                  "url": "https://www.xxx.com/function",
                  "image": {
                    "@type": "ImageObject",
                    "url": "https://www.xxx.com/img/contact_1.png",
                    "width": 1200,
                    "height": 630
                  },
                  "author": {
                    "@type": "Organization",
                    "name": "XXX",
                    "url": "https://www.xxx.com"
                  },
                  "publisher": {
                    "@type": "Organization",
                    "name": "XXX",
                    "logo": {
                      "@type": "ImageObject",
                      "url": "https://www.xxx.com/favicon.ico"
                    }
                  },
                  "datePublished": "2025-01-01T00:00:00+08:00",
                  "dateModified": "2025-03-21T00:00:00+08:00"
                }
              },
              {
                "@type": "ListItem",
                "position": 2,
                "item": {
                  "@type": "Article",
                  "name": "XXX Story",
                  "headline": "Our Journey of Innovation",
                  "description": "From our founding principles to becoming a global leader in smart home technology - explore the XXX story of continuous innovation and growth",
                  "url": "https://www.xxx.com/story",
                  "image": {
                    "@type": "ImageObject",
                    "url": "https://www.xxx.com/img/contact_3.png",
                    "width": 1200,
                    "height": 630
                  },
                  "author": {
                    "@type": "Organization",
                    "name": "XXX",
                    "url": "https://www.xxx.com"
                  },
                  "publisher": {
                    "@type": "Organization",
                    "name": "XXX",
                    "logo": {
                      "@type": "ImageObject",
                      "url": "https://www.xxx.com/favicon.ico"
                    }
                  },
                  "datePublished": "2025-01-01T00:00:00+08:00",
                  "dateModified": "2025-03-21T00:00:00+08:00"
                }
              },
              {
                "@type": "ListItem",
                "position": 3,
                "item": {
                  "@type": "Article",
                  "name": "Contact XXX",
                  "headline": "Get in Touch with XXX",
                  "description": "Connect with XXX for business opportunities, technical support, or customer service. Your smart home journey starts here",
                  "url": "https://www.xxx.com/contact",
                  "image": {
                    "@type": "ImageObject",
                    "url": "https://www.xxx.com/img/contact_4.png",
                    "width": 1200,
                    "height": 630
                  },
                  "author": {
                    "@type": "Organization",
                    "name": "XXX",
                    "url": "https://www.xxx.com"
                  },
                  "publisher": {
                    "@type": "Organization",
                    "name": "XXX",
                    "logo": {
                      "@type": "ImageObject",
                      "url": "https://www.xxx.com/favicon.ico"
                    }
                  },
                  "datePublished": "2025-01-01T00:00:00+08:00",
                  "dateModified": "2025-03-21T00:00:00+08:00"
                }
              }
            ]
          }
        ]
        </script>
      </head>
      <body>
        <noscript>
          <strong>We're sorry but vue_template doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
        </noscript>
        <div id="app"></div>
        <!-- built files will be auto injected -->
        <!-- End Google Tag Manager (noscript) -->
        <!-- 生产环境使用 CDN -->
        <!-- <% if (process.env.NODE_ENV === 'production') { %>
          <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js"></script>
          <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue-router.min.js"></script>
          <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuex.min.js"></script>
          <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js"></script>
          <script src="https://unpkg.com/[email protected]/lib/index.js"></script>
          <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/index.js"></script>
          <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue-i18n.min.js"></script>
        <% } %> -->
        <!-- <script async src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.5.207/pdf.min.js" data-body="true"></script> -->
      </body>
    </html>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216

    生成sitemap文件

    // src/utils/generate-sitemap.js
    const fs = require('fs')
    const path = require('path')
    // 定义面板参数数组
    const panelParams = ['0', '1', '2']
    // 定义面板参数数组
    const funcParams = ['electronic_types', 'mechanical_switch', 'power_socket_types', 'weak_current_socket_types']
    // 生成路由配置
    const paths = [
        {
            path: '/',
            lastmod: new Date().toISOString(),
            priority: 1.0,
            changefreq: 'daily'
        },
        // 为每个 panel 参数生成对应的路由
        ...panelParams.map(param => ({
            path: `/panel/${param}`,
            lastmod: new Date().toISOString(),
            priority: 0.8,
            changefreq: 'weekly'
        })),
        {
            path: '/download',
            lastmod: new Date().toISOString(),
            priority: 0.8,
            changefreq: 'weekly'
        },
        {
            path: '/story',
            lastmod: new Date().toISOString(),
            priority: 0.7,
            changefreq: 'weekly'
        },
        ...funcParams.map(param => ({
            path: `/function/${param}`,
            lastmod: new Date().toISOString(),
            priority: 1,
            changefreq: 'weekly'
        })),
        {
            path: '/photo',
            lastmod: new Date().toISOString(),
            priority: 0.7,
            changefreq: 'weekly'
        },
        {
            path: '/finder/curtainvideo',
            lastmod: new Date().toISOString(),
            priority: 0.6,
            changefreq: 'weekly'
        },
        {
            path: '/contact',
            lastmod: new Date().toISOString(),
            priority: 0.6,
            changefreq: 'weekly'
        }
    ]
    // 写入配置文件
    const outputPath = path.join(__dirname, '../config/sitemap-routes.json')
    // 确保目录存在
    const dir = path.dirname(outputPath)
    if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true })
    }
    // 写入文件
    fs.writeFileSync(
        outputPath,
        JSON.stringify(paths, null, 2)
    )
    console.log('Sitemap routes generated successfully!')
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72

    调用脚本生成sitemap的json文件

    // package.json
    {
    	scripts: {
    		"generate-sitemap": "node src/utils/generate-sitemap.js"
    	}
    }
    
    1
    2
    3
    4
    5
    6

    根据json文件生成sitemap.xml文件

    // vue.config.js
    const path = require('path');
    const SitemapPlugin = require('sitemap-webpack-plugin').default // 引入 sitemap 插件
    
    const paths = require('./src/config/sitemap-routes.json')
    module.exports = {
        configureWebpack: {
            plugins: [
                // Sitemap 生成配置
                // 确保只在生产环境生成 sitemap
                process.env.NODE_ENV == 'production' && new SitemapPlugin({
                    base: 'https://www.xxx.com', // 你的网站域名
                    paths: paths,
                    options: {
                        filename: 'sitemap.xml',
                        lastmod: true,
                        changefreq: 'daily',
                        priority: 0.8,
                        spacing: '  ', // 输出格式化
                        skipgzip: true // 不生成 .xml.gz 文件
                    }
                }),
            ].filter(Boolean),
        },
    };
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26

    生成sitemap.xml文件后上传 Google Search Console,让sitemap里的页面被Google录入,大概需要两天生效。

    # 预渲染

    使用prerender-spa-plugin插件,版本3.4.0进行SPA项目的预渲染,有利于SEO优化。

    // vue.config.js
    const path = require('path');
    // 预渲染
    const PrerenderSPAPlugin = require('prerender-spa-plugin');
    
    const panels = ['0', '1', '2'];
    const functions = ['electronic_types', 'mechanical_switch', 'power_socket_types', 'weak_current_socket_types']
    module.exports = {
        configureWebpack: {
            plugins: [
                process.env.PRERENDER && new PrerenderSPAPlugin({
                    staticDir: path.join(__dirname, 'dist'),
                    routes: ['/', '/download', '/story', '/photo', '/finder/curtainvideo', '/contact',
                        ...panels.map(panel => `/panel/${panel}`),
                        ...functions.map(func => `/function/${func}`)
                    ],
                    renderer: new PrerenderSPAPlugin.PuppeteerRenderer({
                        renderAfterDocumentEvent: 'render-event',
                        // GTM 相关配置
                        injectProperty: '__PRERENDER_INJECTED',
                        inject: {
                            __PRERENDER_INJECTED: true,
                            head: {
                                meta: [
                                    {
                                        name: 'title',
                                        content: 'Home page  -- XXX experts in electric switches'
                                    },
                                    {
                                        name: 'description',
                                        content: 'XXX is known around the world as a leader in smart home devices that include electric switches and sockets.'
                                    },
                                    {
                                        name: 'keywords',
                                        content: 'XXX,XXX touch switch,XXX socket,smart switch,app control switch,led indicate switch,light switch,wall socket'
                                    },
                                    {
                                        name: 'viewport',
                                        content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0;'
                                    },
                                    { // 谷歌标记
                                        name: 'google-site-verification',
                                        content: 'dLGMz-5G-sv6MSB5g3vkdpK_77_o_EntJtN0g917aFE'
                                    },
                                    { // twitter
                                        property: 'twitter:url',
                                        content: 'https://www.xxx.com/'
                                    },
                                    {
                                        name: 'twitter:title',
                                        content: 'Home page  -- XXX experts in electric switches'
                                    },
                                    {
                                        name: 'twitter:description',
                                        content: 'XXX is known around the world as a leader in smart home devices that include electric switches and sockets.'
                                    },
                                    {
                                        name: 'twitter:site',
                                        content: 'https://www.xxx.com/'
                                    },
                                    {
                                        name: 'twitter:card',
                                        content: 'summary_large_image'
                                    },
                                    {
                                        name: 'twitter:image',
                                        content: ''
                                    },
                                    { // facebook
                                        property: 'og:url',
                                        content: 'https://www.xxx.com'
                                    },
                                    {
                                        property: 'og:title',
                                        content: 'Home page  -- XXX experts in electric switches'
                                    },
                                    {
                                        property: 'og:description',
                                        content: 'XXX is known around the world as a leader in smart home devices that include electric switches and sockets.'
                                    },
                                    {
                                        property: 'og:image',
                                        content: ''
                                    },
                                    {
                                        property: 'og:type',
                                        content: 'website'
                                    }
                                ]
                            }
                        },
                        headless: true,
                        postProcess(renderedRoute) {
                            renderedRoute.html = renderedRoute.html
                                .replace(/<script[^>]*googletagmanager[^>]*>[\s\S]*?<\/script>/gi, '')
                                .replace('</head>', '<!-- GTM_PLACEHOLDER --></head>')
                            return renderedRoute
                        }
                    })
                }),
            ].filter(Boolean),
        },
    };
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103

    用脚本进行预渲染打包,把打好的包上传服务器即可。

    // package.json
    {
    	"scripts": {
            "serve:prerender": "cross-env PRERENDER=true vue-cli-service build --max-old-space-size=4096",
            "devbuild:prerender": "cross-env PRERENDER=true vue-cli-service build --mode development",
        },
    }
    
    1
    2
    3
    4
    5
    6
    7
    编辑 (opens new window)
    装机笔记
    公司B端项目

    ← 装机笔记 公司B端项目→

    最近更新
    01
    公司B端项目
    03-11
    02
    简历
    03-09
    03
    Blog环境更新测试
    03-09
    更多文章>
    Theme by Vdoing | Copyright © 2020-2026 Amadeus | MIT License
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式