公司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
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
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
2
3
4
5
6
7
8
# 2.图片懒加载
使用 img 标签的 loading="lazy"即可,当图片快到视口的时候才会开始加载。
<img loading="lazy" src="/img/new_home/t5.jpg" alt="">
如果想要更精准的控制图片离视口多远才开始加载,可以使用 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>
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>
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>
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>
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();
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);
}
});
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}
}
]
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;
}
}
}
}
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;
}
}
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()
})
}
})
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))
}
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
}
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': '技术支持',
}
2
3
4
5
6
7
8
9
首次页面加载的时候初始化加载语言包
// main.js
import store from './store' // vuex配置
// 初始化时加载保存的语言
store.dispatch('lang/changeLang', store.state.lang.currentLang)
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>
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>
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>
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
}
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>
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!')
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"
}
}
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),
},
};
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),
},
};
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",
},
}
2
3
4
5
6
7