vue实践之商场app - Layout组件

App.vue作为程序的入口,一般只充当程序的入口

1
2
3
4
<template>
<!-- 一级路由出口组件 -->
<router-view/>
</template>

在路由中,该部分的组件为Layout,即这层存放网页各处都需要的布局组件

在Layout文件夹下新建index.vue和components文件夹,index.vue文件使用各种组件,指明路由关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<layout-nav />
<layout-header />
<layout-fixed />
<router-view/>
<layout-footer />
</template>

<script setup>
import LayoutFooter from './components/LayoutFooter.vue';
import LayoutHeader from './components/LayoutHeader.vue';
import LayoutNav from './components/LayoutNav.vue';
import LayoutFixed from './components/LayoutFixed.vue';

import { useCategoryStore } from '@/stores/category'
import { onMounted } from 'vue'

const categoryStore = useCategoryStore()
onMounted(() => categoryStore.getCategory())
</script>

onMounted()函数是vue3提供的生命周期钩子,当该组件挂载在DOM上时开始执行某些逻辑

导航栏

导航栏在页面的最上方,用于快速跳转到某些页面。此处只给某些地方配置了路由,实际开发中视情况而定。
在components文件夹下新建LayoutNav.vue文件,具体代码如下

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
<script setup>
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'

const userStore = useUserStore()
const router = useRouter()
const confirm = () => {
userStore.clearUserInfo()
router.push('/login')
}
</script>

<template>
<nav class="app-topnav">
<div class="container">
<ul>
<template v-if="userStore.userInfo.token">
<li>
<a href="javascript:;">
<i class=" iconfont icon-user"></i>{{ userStore.userInfo.account }}
</a>
</li>
<li>
<el-popconfirm @confirm="confirm" title="确认退出吗?" confirm-button-text="确认"
cancel-button-text="取消">
<template #reference>
<a href="javascript:;">退出登录</a>
</template>
</el-popconfirm>
</li>
<li><a href="javascript:;" @click="$router.push('/member/order')">我的订单</a></li>
<li><a href="javascript:;" @click="$router.push('/member')">会员中心</a></li>
</template>
<template v-else>
<li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li>
<li><a href="javascript:;">帮助中心</a></li>
<li><a href="javascript:;">关于我们</a></li>
</template>
</ul>
</div>
</nav>
</template>

<style scoped lang="scss">
.app-topnav {
background: #333;
position: relative;

ul {
display: flex;
height: 53px;
justify-content: flex-end;
align-items: center;

li {
a {
padding: 0 15px;
color: #cdcdcd;
line-height: 1;
display: inline-block;

i {
font-size: 14px;
margin-right: 2px;
}

&:hover {
color: $xtxColor;
}
}

~li {
a {
border-left: 2px solid #666;
}
}
}
}
}
</style>

在该组件中,导航栏以表格的形式存在,当用户信息存在时显示客户相关操作,否则显示登录操作

为什么此处的空连接是href="javascript:;"而非href=""或者省略?href=""可能会在点击时跳转到当前页面的根路径。省略该属性时链接可能会失去默认的样式或行为。

一级导航

该部分用于跳转到各部分功能中。在该电商平台上,则是对应不同的购物模块。
在components文件夹下新建LayoutHeader.vue文件,具体代码如下

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
<script setup>
import { useCategoryStore } from '@/stores/category'
import HeaderCart from './HeaderCart.vue'
const categoryStore = useCategoryStore()
</script>

<template>
<header class='app-header'>
<div class="container">
<h1 class="logo">
<RouterLink to="/">小兔鲜</RouterLink>
</h1>

<ul class="app-header-nav">
<li class="home">
<RouterLink to="/">首页</RouterLink>
</li>
<li class="home" v-for="item in categoryStore.categoryList" :key="item.id">
<RouterLink active-class="active" :to="`/category/${item.id}`">
{{ item.name }}
</RouterLink>
</li>
</ul>

<div class="search">
<i class="iconfont icon-search"></i>
<input type="text" placeholder="搜一搜">
</div>

<HeaderCart />
</div>
</header>
</template>

<style scoped lang='scss'>
.app-header {
background: white;

.container {
display: flex;
align-items: center;
}

.logo {
width: 200px;

a {
display: block;
height: 132px;
width: 100%;
text-indent: -9999px;
background: url('@/assets/images/logo.png') no-repeat center 18px / contain;
}
}

.app-header-nav {
width: 820px;
display: flex;
padding-left: 40px;
position: relative;
z-index: 998;

li {
margin-right: 40px;
width: 38px;
text-align: center;

a {
font-size: 16px;
line-height: 32px;
height: 32px;
display: inline-block;

&:hover {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
}

.active {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
}
}


.search {
width: 170px;
height: 32px;
position: relative;
border-bottom: 1px solid #e7e7e7;
line-height: 32px;

.icon-search {
font-size: 18px;
margin-left: 5px;
}

input {
width: 140px;
padding-left: 5px;
color: #666;
}
}

.cart {
width: 50px;

.curr {
height: 32px;
line-height: 32px;
text-align: center;
position: relative;
display: block;

.icon-cart {
font-size: 22px;
}

em {
font-style: normal;
position: absolute;
right: 0;
top: 0;
padding: 1px 6px;
line-height: 1;
background: $helpColor;
color: #fff;
font-size: 12px;
border-radius: 10px;
font-family: Arial;
}
}
}
}
</style>

在该部分中,logo部分显示应用图标,并提供跳转至首页的功能。其他的各类别信息则从服务器抓取。由于类别信息其他文件也需要,所以这里使用了Store来储存,该部分需参阅Store相关文件的定义。购物车组件显示用户的购物车信息,该部分在购物车组件部分会有说明。

头部效果

吸顶的一级导航

为了方便用户快速跳转所需类别,一般会有两种选择:固定一级导航栏在页面顶端,或者提供回到顶部按钮。此处使用的是前者。
在components文件夹下新建LayoutFixed.vue文件,具体代码如下

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
<script setup>
import { useScroll } from '@vueuse/core'
import { useCategoryStore } from '@/stores/category'

const { y } = useScroll(window)
const categoryStore = useCategoryStore()
</script>

<template>
<div class="app-header-sticky" :class="{ show: y > 78 }">
<div class="container">
<h1 class="logo">
<router-link to="/"></router-link>
</h1>
<ul class="app-header-nav">
<li class="home">
<RouterLink to="/">首页</RouterLink>
</li>
<li class="home" v-for="item in categoryStore.categoryList" :key="item.id">
<router-link :to="`/category/${item.id}`" active-class="active">{{ item.name }}</router-link>
</li>
</ul>
<div class="search">
<i class="iconfont icon-search"></i>
<input type="text" placeholder="搜一搜">
</div>
</div>
</div>
</template>
<style scoped lang='scss'>
.app-header-sticky {
width: 100%;
height: 80px;
position: fixed;
left: 0;
top: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1px solid #e4e4e4;
transform: translateY(-100%);
opacity: 0;

&.show {
transition: all 0.3s linear;
transform: none;
opacity: 1;
}

.container {
display: flex;
align-items: center;
}

.logo {
width: 200px;
height: 80px;
background: url("@/assets/images/logo.png") no-repeat right 2px;
background-size: 160px auto;
}

.right {
width: 220px;
display: flex;
text-align: center;
padding-left: 40px;
border-left: 2px solid $xtxColor;

a {
width: 38px;
margin-right: 40px;
font-size: 16px;
line-height: 1;

&:hover {
color: $xtxColor;
}
}
}
}

.app-header {
background: #fff;

.container {
display: flex;
align-items: center;
}

.logo {
width: 200px;

a {
display: block;
height: 132px;
width: 100%;
text-indent: -9999px;
background: url('@/assets/images/logo.png') no-repeat center 18px / contain;
}
}


.search {
width: 170px;
height: 32px;
position: relative;
border-bottom: 1px solid #e7e7e7;
line-height: 32px;

.icon-search {
font-size: 18px;
margin-left: 5px;
}

input {
width: 140px;
padding-left: 5px;
color: #666;
}
}

.cart {
width: 50px;

.curr {
height: 32px;
line-height: 32px;
text-align: center;
position: relative;
display: block;

.icon-cart {
font-size: 22px;
}

em {
font-style: normal;
position: absolute;
right: 0;
top: 0;
padding: 1px 6px;
line-height: 1;
background: $helpColor;
color: #fff;
font-size: 12px;
border-radius: 10px;
font-family: Arial;
}
}
}
}

.app-header-nav {
width: 820px;
display: flex;
padding-left: 40px;
position: relative;
z-index: 998;

li {
margin-right: 40px;
width: 38px;
text-align: center;

a {
font-size: 16px;
line-height: 32px;
height: 32px;
display: inline-block;

&:hover {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
}

.active {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
}
}
</style>

相比一级导航,这里的吸顶版本又有什么区别?此处使用了vueuse中的useScroll方法,获得页面的滚动位置和状态。当状态符合时(此处为y > 78),为相应的类名做出改变,从而改变后面的scss样式。后面的scss样式分两种,当在页面顶端时,组件移出到页面外(y轴偏移为-1)并完全透明。满足条件后,组件线性移动到页面顶端(通过特定属性清除y轴偏移)并解除透明。

吸顶一级导航的效果,可以看到,当不在页面顶端时,该部件飘在页面顶端

页尾

网页的页尾一般用于放置版权信息、备案声明和相关链接。在components下新建LayoutFooter.vue文件,代码如下

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
<template>
<footer class="app_footer">
<!-- 联系我们 -->
<div class="contact">
<div class="container">
<dl>
<dt>客户服务</dt>
<dd><i class="iconfont icon-kefu"></i> 在线客服</dd>
<dd><i class="iconfont icon-question"></i> 问题反馈</dd>
</dl>
<dl>
<dt>关注我们</dt>
<dd><i class="iconfont icon-iconfontweixin"></i> 公众号</dd>
<dd><i class="iconfont icon-weibo"></i> 微博</dd>
</dl>
<dl>
<dt>下载APP</dt>
<dd class="qrcode"><img src="@/assets/images/qrcode.jpg" /></dd>
<dd class="download">
<span>扫描二维码</span>
<span>立马下载APP</span>
<a href="javascript:;">下载页面</a>
</dd>
</dl>
<dl>
<dt>服务热线</dt>
<dd class="hotline">400-0000-000 <small>周一至周日 8:00-18:00</small></dd>
</dl>
</div>
</div>
<!-- 其它 -->
<div class="extra">
<div class="container">
<div class="slogan">
<a href="javascript:;">
<i class="iconfont icon-footer01"></i>
<span>价格亲民</span>
</a>
<a href="javascript:;">
<i class="iconfont icon-footer02"></i>
<span>物流快捷</span>
</a>
<a href="javascript:;">
<i class="iconfont icon-footer03"></i>
<span>品质新鲜</span>
</a>
</div>
<!-- 版权信息 -->
<div class="copyright">
<p>
<a href="javascript:;">关于我们</a>
<a href="javascript:;">帮助中心</a>
<a href="javascript:;">售后服务</a>
<a href="javascript:;">配送与验收</a>
<a href="javascript:;">商务合作</a>
<a href="javascript:;">搜索推荐</a>
<a href="javascript:;">友情链接</a>
</p>
<p>CopyRight © 小兔鲜儿</p>
</div>
</div>
</div>
</footer>
</template>

<style scoped lang='scss'>
.app_footer {
overflow: hidden;
background-color: #f5f5f5;
padding-top: 20px;

.contact {
background: #fff;

.container {
padding: 60px 0 40px 25px;
display: flex;
}

dl {
height: 190px;
text-align: center;
padding: 0 72px;
border-right: 1px solid #f2f2f2;
color: #999;

&:first-child {
padding-left: 0;
}

&:last-child {
border-right: none;
padding-right: 0;
}
}

dt {
line-height: 1;
font-size: 18px;
}

dd {
margin: 36px 12px 0 0;
float: left;
width: 92px;
height: 92px;
padding-top: 10px;
border: 1px solid #ededed;

.iconfont {
font-size: 36px;
display: block;
color: #666;
}

&:hover {
.iconfont {
color: $xtxColor;
}
}

&:last-child {
margin-right: 0;
}



.download {
padding-top: 5px;
font-size: 14px;
width: auto;
height: auto;
border: none;

span {
display: block;
}

a {
display: block;
line-height: 1;
padding: 10px 25px;
margin-top: 5px;
color: #fff;
border-radius: 2px;
background-color: $xtxColor;
}
}

}

.qrcode img {
width: 92px;
height: 92px;
border: 1px solid #ededed;
}

.hotline {
padding-top: 20px;
font-size: 22px;
color: #666;
width: auto;
height: auto;
border: none;
}
small {
display: block;
font-size: 15px;
color: #999;
}
}

.extra {
background-color: #333;
}

.slogan {
height: 100px;
line-height: 58px;
padding: 60px 100px;
border-bottom: 1px solid #434343;
display: flex;
justify-content: space-between;

a {
height: 58px;
line-height: 58px;
color: #fff;
font-size: 28px;

i {
font-size: 50px;
vertical-align: middle;
margin-right: 10px;
font-weight: 100;
}

span {
vertical-align: middle;
text-shadow: 0 0 1px #333;
}
}
}

.copyright {
height: 170px;
padding-top: 40px;
text-align: center;
color: #999;
font-size: 15px;

p {
line-height: 1;
margin-bottom: 20px;
}

a {
color: #999;
line-height: 1;
padding: 0 10px;
border-right: 1px solid #999;

&:last-child {
border-right: none;
}
}
}
}
</style>

页尾效果

HeaderCart购物车部分在购物车一章中讲

Store的定义与接口的调用

在apis文件夹下新建文件category.js,用于存放相关接口。代码如下

1
2
3
4
5
6
7
8
9
10
import request from "@/utils/http"

export function getCategoryAPI(id) {
return request({
url:'/category',
params: {
id
}
})
}

这是在axios较为标准的创建请求方法。此处的request为axios相关文件在导出时使用的别名。使用的get方法作为默认方法,因此此处没有指定method。url指定向后端发起请求的地址。params即传递的参数。此处axios请求以函数的方式export到其他地方,方便复用。

在stores文件夹下新建文件category.js,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { getCategoryAPI } from "@/apis/layout"

export const useCategoryStore = defineStore("category", () => {
// state
const categoryList = ref([])
// action
const getCategory = async () => {
const res = await getCategoryAPI()
categoryList.value = res.result
}

return {
categoryList,
getCategory
}
})

action部分是一个比较标准的调用接口方法。该函数声明为异步函数,配合await在Promise被解决或拒绝后才执行相关操作,即在接收到数据后进行赋值,并在最后返回相关数据。该接口定义在Store中,Store的名字为category。


vue实践之商场app - Layout组件
http://example.com/2025/05/02/mall-front-2/
作者
Ivan Chen
发布于
2025年5月2日
许可协议
IVAN