小兔鲜电商项目练手

vue 学习推荐去看 vue 官方文档https://cn.vuejs.org/

视频学习推荐张天禹老师https://www.bilibili.com/video/BV1Zy4y1K7SH/?spm_id_from=333.337.search-card.all.click

一、项目简介

这是一个 B2C 电商平台类项目

1、功能模块

首页模块:顶部通栏,吸顶导航,网站头部,左侧分类,轮播图,新鲜好物,人气推荐,热门品牌,分类商品推荐,专题推荐,网站底部

  • 一级分类:面包屑,轮播图,全部二级分类,二级分类推荐商品
  • 二级分类:筛选区域,排序功能,商品列表,无限加载。
  • 商品详情:商品图片展示,基本信息展示,配送城市选择,SKU 选择,库存选择,商品详情展示,商品评价展示,24 小时热销,相关专题,加入购物车
  • 购物车:头部购物车:展示商品数量和列表,删除商品,跳转购物车页面。购物车页面:购物车商品展示,选择商品,修改数量,修改商品规格,价格计算,跳转下单
  • 登录模块:表单校验,账户密码登录
  • 订单模块:订单商品展示,收货地址选择,收货地址新增,支付方式选择,生成订单
  • 支付模块:订单信息展示,跳转支付网关,提示正在支付,等待支付结果,跳转支付成功页面。
  • 个人中心:中心首页:展示个人信息,近期收藏商品,近期足迹,猜你喜欢 订单管理:全部订单,待付款,待发货,待收货,待评价,已完成,已取消。立即付款,取消订单,确认收货,删除订单,查看物流。订单详情:订单状态,订单进度,详细信息。

2、技术栈

  • vue3.x (组合式 API)
  • vite(构件工具)
  • axios(请求接口)
  • vue-router (单页路由)
  • pinia(状态管理)
  • element-plus(适用 vue3 的 UI 组件库)
  • @vueuse/core(vue 组合式 API 的工具库)

二、项目初始化

1、创建项目

1
npm init vue@latest

image-20230601085910553

2、jsconfig.json 配置别名路径

配置别名路径可以在写代码时联想提示路径

1
2
3
4
5
6
7
8
9
// jsconfig.json文件中    @别名联想提示
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}

这里需要注意的是,这个配置只是让我们在写代码,输入@时有一个路径联想提示的功能。真正实现 src 转化为@功能的是 vite.config.js 中的如下配置

1
2
3
4
5
6
7
// ...省略其他代码
// @别名设置
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}

3、ElementPlus 自动按需导入

此处可以参考 ElementPlus 官网教程配置自动按需导入,并定制主题。最终配置的 vite.config.js 如下

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
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// 配置elementui自动按需导入
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
// 导入对应包
import ElementPlus from "unplugin-element-plus/vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: "sass" })],
}),
// 按需定制主题配置
ElementPlus({
useSource: true,
}),
],
// @别名设置
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
css: {
preprocessorOptions: {
//按需导入自定义主题和自动导入scss变量
scss: {
additionalData: `@use "@/styles/element/index.scss" as *;
@use "@/styles/var.scss" as *;`,
},
},
},
});

按照官网教程在 src/styles 下新建 element/index.scss 定制化的主题样式文件

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
/* 只需要重写你需要的即可 */
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
// 主色
"base": #27ba9b,
),
"success": (
// 成功色
"base": #1dc779,
),
"warning": (
// 警告色
"base": #ffb302,
),
"danger": (
// 危险色
"base": #e26237,
),
"error": (
// 错误色
"base": #cf4444,
),
)
);

4、axios

utils 下新建 request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import axios from "axios";

// 创建axios实例
const request = axios.create({
baseURL: "http://pcapi-xiaotuxian-front-devtest.itheima.net",
timeout: 5000,
});

// axios请求拦截器
request.interceptors.request.use(
(config) => {
return config;
},
(e) => Promise.reject(e)
);

// axios响应式拦截器
request.interceptors.response.use(
(res) => res.data,
(e) => Promise.reject(e)
);

export default request;

src 下新建 api 文件夹,之后我们的所有接口将按模块集中管理在这个文件夹下

5、路由

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
// router/index.js
import { createRouter, createWebHistory } from "vue-router";

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
// history: createWebHistory('/#'),
routes: [
{
path: "/",
name: "layout",
component: () => import("@/views/layout/index.vue"),
children: [
{
path: "",
name: "home",
component: () => import("@/views/layout/components/home/index.vue"),
},
{
path: "category/sub/:id",
name: "sub-category",
component: () =>
import("@/views/layout/components/sub-category/index.vue"),
},
{
path: "category/:id",
name: "category",
component: () =>
import("@/views/layout/components/category/index.vue"),
},
{
path: "goodsInfo/:id",
name: "goodsInfo",
component: () =>
import("@/views/layout/components/goodsInfo/index.vue"),
},
{
path: "cartList",
name: "cartList",
component: () => import("@/views/layout/components/cartList.vue"),
},
{
path: "orders",
name: "orders",
component: () => import("@/views/layout/components/orders/index.vue"),
},
{
path: "payPage/:id",
name: "payPage",
component: () =>
import("@/views/layout/components/payPage/index.vue"),
},
{
path: "paycallback",
name: "paycallback",
component: () =>
import("@/views/layout/components/payPage/paycallback.vue"),
},
{
path: "VIP",
name: "VIP",
component: () => import("@/views/layout/components/VIP/index.vue"),
children: [
{
path: "",
name: "userInfo",
component: () =>
import("@/views/layout/components/VIP/userInfo.vue"),
},
{
path: "myOrders",
name: "myOrders",
component: () =>
import("@/views/layout/components/VIP/myOrders.vue"),
},
],
},
],
},
{
path: "/login",
name: "login",
component: () => import("@/views/login/index.vue"),
},
],
scrollBehavior() {
// 始终滚动到顶部
return { top: 0 };
},
});

export default router;

6、静态资源引入

样式资源 - 把 common.scss 文件放到 styles 目录下

之后不要忘了在 main.js 中导入

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
//  common.scss
// 重置样式
* {
box-sizing: border-box;
}

html {
height: 100%;
font-size: 14px;
}
body {
height: 100%;
color: #333;
min-width: 1240px;
font: 1em/1.4 "Microsoft Yahei", "PingFang SC", "Avenir", "Segoe UI", "Hiragino Sans GB",
"STHeiti", "Microsoft Sans Serif", "WenQuanYi Micro Hei", sans-serif;
}
body,
ul,
h1,
h3,
h4,
p,
dl,
dd {
padding: 0;
margin: 0;
}
a {
text-decoration: none;
color: #333;
outline: none;
}
i {
font-style: normal;
}
input[type="text"],
input[type="search"],
input[type="password"],
input[type="checkbox"] {
padding: 0;
outline: none;
border: none;
-webkit-appearance: none;
&::placeholder {
color: #ccc;
}
}
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
background: #ebebeb url("@/assets/images/200.png") no-repeat center / contain;
}
ul {
list-style: none;
}

#app {
background: #f5f5f5;
user-select: none;
}

.container {
width: 1240px;
margin: 0 auto;
position: relative;
}
.ellipsis {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}

.ellipsis-2 {
word-break: break-all;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}

.fl {
float: left;
}

.fr {
float: right;
}

.clearfix:after {
content: ".";
display: block;
visibility: hidden;
height: 0;
line-height: 0;
clear: both;
}

// reset element
.el-breadcrumb__inner.is-link {
font-weight: 400 !important;
}

scss 变量

1
2
3
4
5
6
7
//  styles/var.scss

$xtxColor: #27ba9b;
$helpColor: #e26237;
$sucColor: #1dc779;
$warnColor: #ffb302;
$priceColor: #cf4444;

三、Layout 组件

1、总体架构

本项目一级路由有 Login 组件、Layout 组件,当然还可以有 404 组件(我并没有做)

Layout 组件结构如下

image-20230601100747054

因此,依次创建 4 个组件并导入到 Layout 组件

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
<script setup>
import Topnav from "./components/topnav.vue";
import Header from "./components/header.vue";
import Footer from "./components/footer.vue";
import HeaderSticky from "./components/header-sticky.vue";
import { useScroll } from "@vueuse/core";
import { useCategoryStore } from "@/stores/category";
const { y } = useScroll(window);
const { getCategory } = useCategoryStore();
getCategory();
</script>

<template>
<!-- 顶部Nav栏 -->
<Topnav />
<!-- 顶部导航栏 -->
<Header />
<!-- 吸顶导航 -->
<HeaderSticky :y="y" />
<!-- 中间二级菜单 -->
<router-view></router-view>
<!-- 底部 -->
<Footer />
</template>

<style scoped lang="scss"></style>

其中的 Topnav 组件

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
<script setup>
import { useUserInfoStore } from "@/stores/userInfo.js";
import { useRouter } from "vue-router";
const router = useRouter();
const { userInfo, clearUserInfo } = useUserInfoStore();
// 确认退出
const confirm = () => {
clearUserInfo();
router.push("/login");
};
</script>

<template>
<nav class="app-topnav">
<div class="container">
<ul>
<template v-if="userInfo.account">
<li>
<a href="javascript:;"
><i class="iconfont icon-user"></i>{{ userInfo.account }}</a
>
</li>
<li>
<el-popconfirm
title="确认退出吗?"
confirm-button-text="确认"
cancel-button-text="取消"
@confirm="confirm"
>
<template #reference>
<a href="javascript:;">退出登录</a>
</template>
</el-popconfirm>
</li>
<li><a href="javascript:;">我的订单</a></li>
<li><a href="/VIP">会员中心</a></li>
</template>
<template v-else>
<li><a href="/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;
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>

Header 组件

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
<script setup>
import { toRefs } from "vue";
import { useCategoryStore } from "@/stores/category";
import HeadCart from "./headCart.vue";
const { categoryList } = toRefs(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 v-for="item in categoryList" :key="item.id">
<RouterLink :to="`/category/${item.id}`" active-class="active">{{
item.name
}}</RouterLink>
</li>
</ul>
<div class="search">
<i class="iconfont icon-search"></i>
<input type="text" placeholder="搜一搜" />
</div>
<!-- 头部购物车 -->
<HeadCart />
</div>
</header>
</template>

<style scoped lang="scss">
.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;
}
}

.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>

HeaderSticky 组件

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
<script setup>
import { toRefs } from "vue";
import { useCategoryStore } from "@/stores/category";
defineProps({
y: {
type: Number,
required: true,
},
});
const { categoryList } = toRefs(useCategoryStore());
</script>

<template>
<div class="app-header-sticky" :class="{ show: y > 78 }">
<div class="container">
<RouterLink class="logo" to="/" />
<!-- 导航区域 -->
<ul class="app-header-nav">
<li class="home">
<RouterLink to="/">首页</RouterLink>
</li>
<li v-for="item in categoryList" :key="item.id">
<RouterLink :to="`/category/${item.id}`" active-class="active">{{
item.name
}}</RouterLink>
</li>
</ul>

<div class="right">
<RouterLink to="/">品牌</RouterLink>
<RouterLink to="/">专题</RouterLink>
</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-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>

Footer 组件

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
<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-weixin"></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;
}
}

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

.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;
}
}

.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: 178px;
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>

2、吸顶交互效果

实现吸顶交互效果的核心逻辑:根据滚动距离判断当前 show 类名是否显示,大于 78 显示,小于 78,不显示

这里我们使用@vueuse/core 提供的 useScroll 方法可以很方便的实现获取页面滚动距离的目的

VueUse 是一个为 Vue 组合式 API 提供服务的工具库,也就是说在 Vue 选项式 API 中它将不能使用

VueUse 官网https://vueuse.org/

3、Pinia 优化重复请求

注意到获取商品的分类信息在多个模块中都会用到,为了避免重复请求,我们将其抽取成一个 store 模块

在 src/stores 中新建 categoryStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { ref } from "vue";
import { defineStore } from "pinia";
import { getCategoryAPI } from "@/apis/layout";
export const useCategoryStore = defineStore("category", () => {
//分类列表数据
const isLoading = ref(true);
const categoryList = ref([]);
function getCategory() {
getCategoryAPI().then((res) => {
categoryList.value = res.data.result;
isLoading.value = false;
});
}
return { categoryList, getCategory, isLoading };
});

四、Home 页

1、总体架构

Home 页整体结构如下

image-20230601103822925

按照结构新增五个组件,并在 Home 模块的入口组件中引入

Home/index.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
<script setup>
import HomeCategory from "./HomeCategory.vue";
import HomeBanner from "./HomeBanner.vue";
import HomeHot from "./HomeHot.vue";
import HomeNew from "./HomeNew.vue";
import HomeProduct from "./HomeProduct.vue";
</script>

<template>
<div class="container">
<!-- 分类区域 -->
<HomeCategory />
<!-- 轮播图区域 -->
<HomeBanner />
</div>
<!-- 新鲜好物区域 -->
<HomeNew />
<!-- 人气推荐区域 -->
<HomeHot />
<!-- 产品列表区域 -->
<HomeProduct />
</template>

<style scoped lang="scss"></style>

其中 HomeCategory 组件

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
<script setup>
import { toRefs } from "vue";
import { useCategoryStore } from "@/stores/category";
const { categoryList, isLoading } = toRefs(useCategoryStore());
</script>

<template>
<div class="home-category">
<ul class="menu">
<li v-for="item in categoryList" :key="item.id">
<!-- 骨架屏 -->
<el-skeleton
style="width: 200px"
:loading="isLoading"
animated
:throttle="10000"
>
<template #template>
<el-skeleton-item variant="p" style="width: 32px; height: 20px" />
<el-skeleton-item
variant="p"
style="width: 100px; margin-left: 10px"
/>
</template>
<template #default>
<RouterLink :to="`/category/${item.id}`">{{
item.name
}}</RouterLink>
<RouterLink
v-for="i in item.children.slice(0, 2)"
:key="i.id"
:to="`/category/sub/${i.id}`"
>{{ i.name }}</RouterLink
>
</template>
</el-skeleton>
<!-- 弹层layer位置 -->
<div class="layer">
<h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4>
<ul>
<li v-for="i in item.goods" :key="i.id">
<RouterLink :to="`/goodsInfo/${i.id}`">
<img alt="" :src="i.picture" />
<div class="info">
<p class="name ellipsis-2">{{ i.name }}</p>
<p class="desc ellipsis">{{ i.desc }}</p>
<p class="price"><i>¥</i>{{ i.price }}</p>
</div>
</RouterLink>
</li>
</ul>
</div>
</li>
</ul>
</div>
</template>

<style scoped lang="scss">
.home-category {
width: 250px;
height: 500px;
background: rgba(0, 0, 0, 0.8);
position: relative;
z-index: 99;

.menu {
li {
padding-left: 40px;
height: 55px;
line-height: 55px;

&:hover {
background: $xtxColor;
}

a {
margin-right: 4px;
color: #fff;

&:first-child {
font-size: 16px;
}
}

.layer {
width: 990px;
height: 500px;
background: rgba(255, 255, 255, 0.8);
position: absolute;
left: 250px;
top: 0;
display: none;
padding: 0 15px;

h4 {
font-size: 20px;
font-weight: normal;
line-height: 80px;

small {
font-size: 16px;
color: #666;
}
}

ul {
display: flex;
flex-wrap: wrap;

li {
width: 310px;
height: 120px;
margin-right: 15px;
margin-bottom: 15px;
border: 1px solid #eee;
border-radius: 4px;
background: #fff;

&:nth-child(3n) {
margin-right: 0;
}

a {
display: flex;
width: 100%;
height: 100%;
align-items: center;
padding: 10px;

&:hover {
background: #e3f9f4;
}

img {
width: 95px;
height: 95px;
}

.info {
padding-left: 10px;
line-height: 24px;
overflow: hidden;

.name {
font-size: 16px;
color: #666;
}

.desc {
color: #999;
}

.price {
font-size: 22px;
color: $priceColor;

i {
font-size: 16px;
}
}
}
}
}
}
}

// 关键样式 hover状态下的layer盒子变成block
&:hover {
.layer {
display: block;
}
}
}
}
}
</style>

HomeBanner 组件

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
<script setup>
import { ref } from "vue";
import { getBannerAPI } from "@/apis/home";
const bannerList = ref([]);
getBannerAPI().then((res) => {
bannerList.value = res.data.result;
});
</script>

<template>
<div class="home-banner">
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerList" :key="item.id">
<img :src="item.imgUrl" alt="" />
</el-carousel-item>
</el-carousel>
</div>
</template>

<style scoped lang="scss">
.home-banner {
width: 1240px;
height: 500px;
position: absolute;
left: 0;
top: 0;
z-index: 98;

img {
width: 100%;
height: 500px;
}
}
</style>

HomeNew 组件

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
<script setup>
import HomePanel from "./HomePanel.vue";
import { getNewList } from "@/apis/home";
import { useRequestLazy } from "@/hooks/request-lazy";
const { target, list, isLoading } = useRequestLazy(getNewList);
</script>

<template>
<HomePanel title="新鲜好物" sub-title="新鲜出炉 品质靠谱" ref="target">
<ul class="goods-list">
<li v-for="item in list" :key="item.id">
<!-- 骨架屏 -->
<el-skeleton style="width: 306px" animated :loading="isLoading">
<template #template>
<el-skeleton-item
variant="image"
style="width: 306px; height: 306px"
/>
<div style="padding: 14px; text-align: center">
<el-skeleton-item variant="p" style="width: 50%" />
<div
style="display: flex; align-items: center; justify-items: space-between"
>
<el-skeleton-item variant="text" style="margin-right: 16px" />
</div>
</div>
</template>
<template #default>
<RouterLink :to="`/goodsInfo/${item.id}`">
<img :src="item.picture" alt="" />
<p>{{ item.name }}</p>
<p class="price">&yen;{{ item.price }}</p>
</RouterLink>
</template>
</el-skeleton>
</li>
</ul>
</HomePanel>
</template>

<style scoped lang="scss">
.goods-list {
display: flex;
justify-content: space-between;
height: 406px;

li {
width: 306px;
height: 406px;

background: #f0f9f4;
transition: all 0.5s;

&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}

img {
width: 306px;
height: 306px;
}

p {
font-size: 22px;
padding-top: 12px;
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}

.price {
color: $priceColor;
}
}
}
</style>

这里由于几个模块结构类似,因此封装了一个 HomePanel 组件

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
<script setup lang="ts">
defineProps({
title: {
type: String,
require: true,
},
subTitle: {
type: String,
require: true,
},
});
</script>

<template>
<div class="home-panel">
<div class="container">
<div class="head">
<!-- 主标题和副标题 -->
<h3>
{{ title }}<small>{{ subTitle }}</small>
</h3>
</div>
<!-- 主体内容区域 -->
<slot></slot>
</div>
</div>
</template>

<style scoped lang="scss">
.home-panel {
background-color: #fff;

.head {
padding: 40px 0;
display: flex;
align-items: flex-end;

h3 {
flex: 1;
font-size: 32px;
font-weight: normal;
margin-left: 6px;
height: 35px;
line-height: 35px;

small {
font-size: 16px;
color: #999;
margin-left: 20px;
}
}
}
}
</style>

HomeHot 组件

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
<script setup>
import HomePanel from "./HomePanel.vue";
import { getHotList } from "@/apis/home";
import { useRequestLazy } from "@/hooks/request-lazy";
const { target, list, isLoading } = useRequestLazy(getHotList);
</script>

<template>
<HomePanel title="人气推荐" sub-title="人气爆款 不容错过" ref="target">
<ul class="goods-list">
<li v-for="item in list" :key="item.id">
<!-- 骨架屏 -->
<el-skeleton style="width: 306px" animated :loading="isLoading">
<template #template>
<el-skeleton-item
variant="image"
style="width: 306px; height: 306px"
/>
<div style="padding: 14px; text-align: center">
<el-skeleton-item variant="p" style="width: 50%" />
<div
style="display: flex; align-items: center; justify-items: space-between"
>
<el-skeleton-item variant="text" style="margin-right: 16px" />
</div>
</div>
</template>
<template #default>
<RouterLink to="/">
<img :src="item.picture" alt="" />
<p class="name">{{ item.title }}</p>
<p class="alt">{{ item.alt }}</p>
</RouterLink>
</template>
</el-skeleton>
</li>
</ul>
</HomePanel>
</template>

<style scoped lang="scss">
.goods-list {
display: flex;
justify-content: space-between;
height: 406px;

li {
width: 306px;
height: 406px;

background: #f0f9f4;
transition: all 0.5s;

&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}

img {
width: 306px;
height: 306px;
}

p {
font-size: 22px;
padding-top: 12px;
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.alt {
font-size: 18px;
color: #b5b5b5;
}
}
}
</style>

HomeProduct 组件

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
<script setup>
import { ref } from "vue";
import HomePanel from "./HomePanel.vue";
import { getGoods } from "@/apis/home";
const goodsList = ref([]);
getGoods().then((res) => {
goodsList.value = res.data.result;
});
</script>

<template>
<div class="home-product">
<HomePanel :title="cate.name" v-for="cate in goodsList" :key="cate.id">
<div class="box">
<RouterLink class="cover" :to="`/category/${cate.id}`">
<el-image lazy fit="fill" :src="cate.picture">
<template #error>
<div class="image-slot">
<el-image src="@/assets/images/200.png"></el-image>
</div>
</template>
</el-image>
<strong class="label">
<span>{{ cate.name }}馆</span>
<span>{{ cate.saleInfo }}</span>
</strong>
</RouterLink>
<ul class="goods-list">
<li v-for="good in cate.goods" :key="good.id">
<GoodsItem :goods="good" />
</li>
</ul>
</div>
</HomePanel>
</div>
</template>

<style scoped lang="scss">
.home-product {
background: #fff;
margin-top: -20px;
.sub {
margin-bottom: 2px;

a {
padding: 2px 12px;
font-size: 16px;
border-radius: 4px;

&:hover {
background: $xtxColor;
color: #fff;
}

&:last-child {
margin-right: 80px;
}
}
}

.box {
display: flex;

.cover {
width: 240px;
height: 610px;
margin-right: 10px;
position: relative;

img {
width: 100%;
height: 100%;
}

.label {
width: 188px;
height: 66px;
display: flex;
font-size: 18px;
color: #fff;
line-height: 66px;
font-weight: normal;
position: absolute;
left: 0;
top: 50%;
transform: translate3d(0, -50%, 0);

span {
text-align: center;

&:first-child {
width: 76px;
background: rgba(0, 0, 0, 0.9);
}

&:last-child {
flex: 1;
background: rgba(0, 0, 0, 0.7);
}
}
}
}

.goods-list {
width: 990px;
display: flex;
flex-wrap: wrap;

li {
width: 240px;
height: 300px;
margin-right: 10px;
margin-bottom: 10px;

&:nth-last-child(-n + 4) {
margin-bottom: 0;
}

&:nth-child(4n) {
margin-right: 0;
}
}
}

.goods-item {
display: block;
width: 220px;
padding: 20px 30px;
text-align: center;
transition: all 0.5s;

&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}

img {
width: 160px;
height: 160px;
}

p {
padding-top: 10px;
}

.name {
font-size: 16px;
}

.desc {
color: #999;
height: 29px;
}

.price {
color: $priceColor;
font-size: 20px;
}
}
}
}
</style>

同样的结构类似,并且在后面的模块中会多次用到。因此在 src/components 下封装一个全局组件 GoodsItem

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
<script setup>
import errorIMG from "@/assets/images/200.png";
defineProps({
goods: {
type: Object,
default: () => {},
},
});
</script>

<template>
<RouterLink :to="`/goodsInfo/${goods.id}`" class="goods-item">
<el-image lazy :src="goods.picture" alt="">
<template #error>
<div class="image-slot">
<el-image :src="errorIMG"></el-image>
</div>
</template>
</el-image>
<p class="name ellipsis">{{ goods.name }}</p>
<p class="desc ellipsis">{{ goods.desc }}</p>
<p class="price">&yen;{{ goods.price }}</p>
</RouterLink>
</template>

<style scoped lang="scss">
.goods-item {
display: block;
width: 220px;
padding: 20px 30px;
text-align: center;
transition: all 0.5s;

&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgb(0 0 0 / 20%);
}

img {
width: 160px;
height: 160px;
}

p {
padding-top: 10px;
}

.name {
font-size: 16px;
}

.desc {
color: #999;
height: 29px;
}

.price {
color: $priceColor;
font-size: 20px;
}
}
</style>

不要忘了全局注册它

在 src/components/index.js

1
2
3
4
5
6
7
// 通用组件全局注册
import GoodsItem from "./GoodsItem.vue";
export default {
install(app) {
app.component("GoodsItem", GoodsItem);
},
};

main.js 中

1
2
3
4
// ...省略其他代码
import Components from "./components/index.js";
app.use(Components);
// ...省略其他代码

2、实现数据懒加载和图片懒加载

对于这种页面较长的网页,可以使用数据懒加载来优化性能。

数据懒加载的核心逻辑:当页面滚动到当前模块,也即模块在可视区域时才发起请求加载数据。而不是一上来就将数据全部加载。问题的关键在于如何判断模块是否处于可视区域了呢。

这里同样使用@vueuse/core 提供的 useIntersectionObserver 方法,它可以方便的检测模块是否处于可视区域

由于这是一个公共的方法,因此在 src 下新建 hooks/request-lazy.js

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
// 利用vueuse的useIntersectionObserver封装一个请求懒加载的方法
import { ref } from "vue";
import { useIntersectionObserver } from "@vueuse/core";
export function useRequestLazy(apiFn) {
// 定义一个判断数据是否在加载的变量
const isLoading = ref(true);
// 监视的目标ref对象容器,最后返回出去
const target = ref(null);
// 发送请求得到的数据
const list = ref([]);
const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
// 进入可视区域的逻辑
if (isIntersecting) {
stop();
apiFn().then((res) => {
list.value = res.data.result;
isLoading.value = false;
});
}
});

return {
target,
list,
isLoading,
};
}

以后就可以在需要做数据懒加载的地方使用它了

图片懒加载我直接使用了 el-image 的 lazy 属性,当然也可以自己封装一个图片懒加载自定义指令来实现。图片懒加载自定义指令的核心逻辑与数据懒加载一样,因此它主要还是用到@vueuse/core 提供的 useIntersectionObserver 方法

3、骨架屏效果

为了优化用户体验,这种涉及图片较多、页面较长、加载速度可能较慢的网站,可以使用骨架屏效果

**但是,这个项目我用 Element-Plus 提供的骨架屏组件始终不生效,不知道哪里不对**😂😂😂

五、一级分类页

1、总体架构

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
<script setup>
import { getTopCategoryAPI } from "@/apis/category";
import { ref } from "vue";
import { useRoute, onBeforeRouteUpdate } from "vue-router";
import { getBannerAPI } from "@/apis/home";
const route = useRoute();
// 获取一级分类数据
const categoryData = ref({});
const getTopCategory = (id) => {
getTopCategoryAPI(id).then((res) => {
categoryData.value = res.data.result;
});
};
getTopCategory(route.params.id);
// 会有缓存问题:当路由path一样,参数不同的时候会优先复用路由对应的组件,而不是销毁重建,调用onBeforeRouteUpdate解决该问题,它能检测路由变化
onBeforeRouteUpdate((to) => getTopCategory(to.params.id));
// 获取轮播图数据
const bannerList = ref([]);
getBannerAPI(2).then((res) => {
bannerList.value = res.data.result;
});
</script>

<template>
<div class="top-category">
<div class="container m-top-20">
<!-- 面包屑 -->
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 轮播图 -->
<el-carousel height="500px" class="container">
<el-carousel-item v-for="item in bannerList" :key="item.id">
<img :src="item.imgUrl" alt="" />
</el-carousel-item>
</el-carousel>
<!-- 分类数据 -->
<div class="sub-list">
<h3>全部分类</h3>
<ul>
<li v-for="i in categoryData.children" :key="i.id">
<RouterLink :to="`/category/sub/${i.id}`">
<img :src="i.picture" />
<p>{{ i.name }}</p>
</RouterLink>
</li>
</ul>
</div>
<div
class="ref-goods"
v-for="item in categoryData.children"
:key="item.id"
>
<div class="head">
<h3>- {{ item.name }}-</h3>
</div>
<div class="body">
<GoodsItem v-for="good in item.goods" :goods="good" :key="good.id" />
</div>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.top-category {
h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 100px;
}

.sub-list {
margin-top: 20px;
background-color: #fff;

ul {
display: flex;
padding: 0 32px;
flex-wrap: wrap;

li {
width: 168px;
height: 160px;

a {
text-align: center;
display: block;
font-size: 16px;

img {
width: 100px;
height: 100px;
}

p {
line-height: 40px;
}

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

.ref-goods {
background-color: #fff;
margin-top: 20px;
position: relative;

.head {
.xtx-more {
position: absolute;
top: 20px;
right: 20px;
}

.tag {
text-align: center;
color: #999;
font-size: 20px;
position: relative;
top: -20px;
}
}

.body {
display: flex;
justify-content: space-around;
padding: 0 40px 30px;
}
}

.bread-container {
padding: 25px 0;
}
}
</style>

2、路由缓存问题解决

使用带有参数的路由时需要注意的是,当路由 path 一样,参数不同的时候相同的组件实例将被重复使用。因为两个路由都渲染同个组件,比起销毁再创建,复用则显得更加高效。不过,这也意味着组件的生命周期钩子不会被调用。因此当我们在不同分类间切换时会发现,路径确实发生了变化,但对应的页面并没有任何变化

解决方案:使用 onBeforeRouteUpdate 钩子函数,做精确更新

详情参考https://router.vuejs.org/zh/guide/essentials/dynamic-matching.html#%E5%93%8D%E5%BA%94%E8%B7%AF%E7%94%B1%E5%8F%82%E6%95%B0%E7%9A%84%E5%8F%98%E5%8C%96

六、二级分类页

1、整体架构

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
<script setup>
import { getCategoryFilterAPI, getSubCategoryAPI } from "@/apis/sub-category";
import { useRoute } from "vue-router";
import { ref } from "vue";
import GoodsItem from "@/components/GoodsItem.vue";
const route = useRoute();
const subCategoryData = ref({});
const activeName = ref("publishTime");
getCategoryFilterAPI(route.params.id).then((res) => {
subCategoryData.value = res.data.result;
});
// 选项卡改变时
const queryData = ref({
categoryId: subCategoryData.value.parentId,
page: 1,
pageSize: 20,
sortField: "publishTime",
});
const goodsList = ref([]);
const getSubCategory = () => {
getSubCategoryAPI(queryData.value).then((res) => {
goodsList.value = [...goodsList.value, ...res.data.result.items];
});
};
getSubCategory();
const changeSelected = (val) => {
queryData.value.sortField = val;
getSubCategory();
};
// 无限滚动加载
const disabled = ref(false);
const load = () => {
queryData.value.page++;
getSubCategoryAPI(queryData.value).then((res) => {
goodsList.value = [...goodsList.value, ...res.data.result.items];
if (!res.data.result.items.length) disabled.value = true;
});
};
</script>

<template>
<div class="container">
<!-- 面包屑 -->
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="`/category/${subCategoryData.parentId}`">{{
subCategoryData.parentName
}}</el-breadcrumb-item>
<el-breadcrumb-item>{{ subCategoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="sub-container">
<el-tabs @tab-change="changeSelected" v-model="activeName">
<el-tab-pane label="最新商品" name="publishTime"></el-tab-pane>
<el-tab-pane label="最高人气" name="orderNum"></el-tab-pane>
<el-tab-pane label="评论最多" name="evaluateNum"></el-tab-pane>
</el-tabs>
<div
class="body"
v-infinite-scroll="load"
:infinite-scroll-disabled="disabled"
>
<!-- 商品列表-->
<GoodsItem v-for="item in goodsList" :key="item.id" :goods="item" />
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
.bread-container {
padding: 25px 0;
color: #666;
}

.sub-container {
padding: 20px 10px;
background-color: #fff;

.body {
display: flex;
flex-wrap: wrap;
padding: 0 10px;
}

.goods-item {
display: block;
width: 220px;
margin-right: 20px;
padding: 20px 30px;
text-align: center;

img {
width: 160px;
height: 160px;
}

p {
padding-top: 10px;
}

.name {
font-size: 16px;
}

.desc {
color: #999;
height: 29px;
}

.price {
color: $priceColor;
font-size: 20px;
}
}

.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
}
</style>

七、商品详情

1、整体架构

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
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
<script setup>
import { getDetail } from "@/apis/goodsInfo";
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import GoodsHot from "./goodsHot.vue";
import GoodsPreview from "@/components/goodsPreview.vue";
import GoodsSKU from "@/XtxSku/index.vue";
import { useCartStore } from "@/stores/cart";
import { ElMessage } from "element-plus";
const route = useRoute();
const goodsInfo = ref({});
const getGoodsInfo = async () => {
const { data } = await getDetail(route.params.id);
goodsInfo.value = data.result;
};
onMounted(() => {
getGoodsInfo();
});
// 数量数据
const count = ref(1);
const countChange = (val) => {
count.value = val;
};
// SKU组件得到的数据
const SKUData = ref({});
const change = (data) => {
SKUData.value = data;
};
// 加入购物车
const { addCart } = useCartStore();
const addtocart = () => {
if (!SKUData.value.skuId) return ElMessage.warning("请选择规格");
addCart({
id: goodsInfo.value.id,
name: goodsInfo.value.name,
picture: goodsInfo.value.mainPictures[0],
price: goodsInfo.value.price,
count: count.value,
skuId: SKUData.value.skuId,
attrsText: SKUData.value.specsText,
selected: true,
});
ElMessage.success("加入购物车成功");
};
</script>

<template>
<div class="xtx-goods-page">
<div class="container">
<div class="bread-container">
<el-breadcrumb separator=">" v-if="goodsInfo.categories">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="`category/${goodsInfo.categories[1].id}`"
>{{ goodsInfo.categories[1].name }}
</el-breadcrumb-item>
<el-breadcrumb-item :to="`category/sub/${goodsInfo.categories[0].id}`"
>{{ goodsInfo.categories[0].name }}
</el-breadcrumb-item>
<el-breadcrumb-item>{{ goodsInfo.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 商品信息 -->
<div class="info-container">
<div>
<div class="goods-info">
<div class="media">
<!-- 图片预览区 -->
<GoodsPreview :mainPictures="goodsInfo.mainPictures" />
<!-- 统计数量 -->
<ul class="goods-sales">
<li>
<p>销量人气</p>
<p>{{ goodsInfo.salesCount }}+</p>
<p><i class="iconfont icon-task-filling"></i>销量人气</p>
</li>
<li>
<p>商品评价</p>
<p>{{ goodsInfo.commentCount }}+</p>
<p><i class="iconfont icon-comment-filling"></i>查看评价</p>
</li>
<li>
<p>收藏人气</p>
<p>{{ goodsInfo.collectCount }}+</p>
<p><i class="iconfont icon-favorite-filling"></i>收藏商品</p>
</li>
<li>
<p>品牌信息</p>
<p v-if="goodsInfo.brand">{{ goodsInfo.brand.name }}</p>
<p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p>
</li>
</ul>
</div>
<div class="spec">
<!-- 商品信息区 -->
<p class="g-name">{{ goodsInfo.name }}</p>
<p class="g-desc">{{ goodsInfo.desc }}</p>
<p class="g-price">
<span>{{ goodsInfo.oldPrice }}</span>
<span>{{ goodsInfo.price }}</span>
</p>
<div class="g-service">
<dl>
<dt>促销</dt>
<dd>12月好物放送,App领券购买直降120元</dd>
</dl>
<dl>
<dt>服务</dt>
<dd>
<span>无忧退货</span>
<span>快速退款</span>
<span>免费包邮</span>
<a href="javascript:;">了解详情</a>
</dd>
</dl>
</div>
<!-- sku组件 -->
<GoodsSKU :goods="goodsInfo" @change="change" />
<!-- 数据组件 -->
<el-input-number v-model="count" @change="countChange" />
<!-- 按钮组件 -->
<div>
<el-button size="large" class="btn" @click="addtocart">
加入购物车
</el-button>
</div>
</div>
</div>
<div class="goods-footer">
<div class="goods-article">
<!-- 商品详情 -->
<div class="goods-tabs">
<nav>
<a>商品详情</a>
</nav>
<div class="goods-detail" v-if="goodsInfo.details">
<!-- 属性 -->
<ul class="attrs">
<li
v-for="item in goodsInfo.details.properties"
:key="item.value"
>
<span class="dt">{{ item.name }}</span>
<span class="dd">{{ item.value }}</span>
</li>
</ul>
<!-- 图片 -->
<img
v-for="img in goodsInfo.details.pictures"
:src="img"
:key="img"
alt=""
/>
</div>
</div>
</div>
<!-- 24热榜+专题推荐 -->
<div class="goods-aside">
<GoodsHot :type="1" />
<GoodsHot :type="2" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.xtx-goods-page {
.goods-info {
min-height: 600px;
background: #fff;
display: flex;

.media {
width: 580px;
height: 600px;
padding: 30px 50px;
}

.spec {
flex: 1;
padding: 30px 30px 30px 0;
}
}

.goods-footer {
display: flex;
margin-top: 20px;

.goods-article {
width: 940px;
margin-right: 20px;
}

.goods-aside {
width: 280px;
min-height: 1000px;
}
}

.goods-tabs {
min-height: 600px;
background: #fff;
}

.goods-warn {
min-height: 600px;
background: #fff;
margin-top: 20px;
}

.number-box {
display: flex;
align-items: center;

.label {
width: 60px;
color: #999;
padding-left: 10px;
}
}

.g-name {
font-size: 22px;
}

.g-desc {
color: #999;
margin-top: 10px;
}

.g-price {
margin-top: 10px;

span {
&::before {
content: "¥";
font-size: 14px;
}

&:first-child {
color: $priceColor;
margin-right: 10px;
font-size: 22px;
}

&:last-child {
color: #999;
text-decoration: line-through;
font-size: 16px;
}
}
}

.g-service {
background: #f5f5f5;
width: 500px;
padding: 20px 10px 0 10px;
margin-top: 10px;

dl {
padding-bottom: 20px;
display: flex;
align-items: center;

dt {
width: 50px;
color: #999;
}

dd {
color: #666;

&:last-child {
span {
margin-right: 10px;

&::before {
content: "•";
color: $xtxColor;
margin-right: 2px;
}
}

a {
color: $xtxColor;
}
}
}
}
}

.goods-sales {
display: flex;
width: 400px;
align-items: center;
text-align: center;
height: 140px;

li {
flex: 1;
position: relative;

~ li::after {
position: absolute;
top: 10px;
left: 0;
height: 60px;
border-left: 1px solid #e4e4e4;
content: "";
}

p {
&:first-child {
color: #999;
}

&:nth-child(2) {
color: $priceColor;
margin-top: 10px;
}

&:last-child {
color: #666;
margin-top: 10px;

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

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

.goods-tabs {
min-height: 600px;
background: #fff;

nav {
height: 70px;
line-height: 70px;
display: flex;
border-bottom: 1px solid #f5f5f5;

a {
padding: 0 40px;
font-size: 18px;
position: relative;

> span {
color: $priceColor;
font-size: 16px;
margin-left: 10px;
}
}
}
}

.goods-detail {
padding: 40px;

.attrs {
display: flex;
flex-wrap: wrap;
margin-bottom: 30px;

li {
display: flex;
margin-bottom: 10px;
width: 50%;

.dt {
width: 100px;
color: #999;
}

.dd {
flex: 1;
color: #666;
}
}
}

> img {
width: 100%;
}
}

.btn {
margin-top: 20px;
}

.bread-container {
padding: 25px 0;
}
</style>

其中热榜组件 goodsHot

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
<script setup>
import { fetchHotGoodsAPI } from "@/apis/goodsInfo";
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const queryData = defineProps(["type"]);
const hotGoods = ref([]);
const getHotGoods = async () => {
const { data } = await fetchHotGoodsAPI({
id: route.params.id,
type: queryData.type,
});
hotGoods.value = data.result;
};
onMounted(() => {
getHotGoods();
});
</script>

<template>
<div class="goods-hot">
<h3>{{ queryData.type === 1 ? "24小时热销榜" : "周热销榜" }}</h3>
<!-- 商品区块 -->
<RouterLink
:to="`/goodsInfo/${item.id}`"
class="goods-item"
v-for="item in hotGoods"
:key="item.id"
>
<img :src="item.picture" alt="" />
<p class="name ellipsis">{{ item.name }}</p>
<p class="desc ellipsis">{{ item.desc }}</p>
<p class="price">&yen;{{ item.price }}</p>
</RouterLink>
</div>
</template>

<style scoped lang="scss">
.goods-hot {
h3 {
height: 70px;
background: $helpColor;
color: #fff;
font-size: 18px;
line-height: 70px;
padding-left: 25px;
margin-bottom: 10px;
font-weight: normal;
}

.goods-item {
display: block;
padding: 20px 30px;
text-align: center;
background: #fff;

img {
width: 160px;
height: 160px;
}

p {
padding-top: 10px;
}

.name {
font-size: 16px;
}

.desc {
color: #999;
height: 29px;
}

.price {
color: $priceColor;
font-size: 20px;
}
}
}
</style>

图片预览组件 goodsPreview

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
<script setup>
import { ref, onMounted, watch, computed } from "vue";
import { useMouseInElement } from "@vueuse/core";
// 图片列表
defineProps({
mainPictures: {
type: Array,
default: () => [],
},
});
// 当前选中的图片索引
const currentIndex = ref(0);
// 获取鼠标相对位置
const target = ref(null);
const { elementX, elementY, isOutside } = useMouseInElement(target);
const X = ref(0);
const Y = ref(0);
// 监听鼠标相对位置的变化
watch([elementX, elementY, isOutside], () => {
if (isOutside.value) {
return;
}
if (elementX.value > 100 && elementX.value < 300) {
X.value = elementX.value - 100;
} else if (elementX.value <= 100) {
X.value = 0;
} else {
X.value = 200;
}
if (elementY.value > 100 && elementY.value < 300) {
Y.value = elementY.value - 100;
} else if (elementY.value <= 100) {
Y.value = 0;
} else {
Y.value = 200;
}
});
const positionX = computed(() => -X.value * 2);
const positionY = computed(() => -Y.value * 2);
</script>

<template>
<div class="goods-image">
<!-- 左侧大图-->
<div class="middle" ref="target">
<img :src="mainPictures[currentIndex]" alt="" />
<!-- 蒙层小滑块 -->
<div
class="layer"
v-show="!isOutside"
:style="{ left: `${X}px`, top: `${Y}px` }"
></div>
</div>
<!-- 小图列表 -->
<ul class="small">
<li
v-for="(img, i) in mainPictures"
:key="i"
@mouseenter="currentIndex = i"
:class="{ active: currentIndex === i }"
>
<img :src="img" alt="" />
</li>
</ul>
<!-- 放大镜大图 -->
<div
class="large"
:style="[
{
backgroundImage: `url(${mainPictures[currentIndex]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},
]"
v-show="!isOutside"
></div>
</div>
</template>

<style scoped lang="scss">
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;

.middle {
width: 400px;
height: 400px;
background: #f5f5f5;
}

.large {
position: absolute;
top: 0;
left: 412px;
width: 400px;
height: 400px;
z-index: 500;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-repeat: no-repeat;
// 背景图:盒子的大小 = 2:1 将来控制背景图的移动来实现放大的效果查看 background-position
background-size: 800px 800px;
background-color: #f8f8f8;
}

.layer {
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.2);
// 绝对定位 然后跟随咱们鼠标控制left和top属性就可以让滑块移动起来
left: 0;
top: 0;
position: absolute;
}

.small {
width: 80px;

li {
width: 68px;
height: 68px;
margin-left: 12px;
margin-bottom: 15px;
cursor: pointer;

&:hover,
&.active {
border: 2px solid $xtxColor;
}
}
}
}
</style>

2、图片预览组件

功能拆解:

  1. 通过小图切换大图显示

    image-20230605085705589

  2. 放大镜效果

image-20230605085737400

小图切换大图实现起来比较简单,主要是放大镜效果有点复杂,我们再对其进行进一步分解:

  1. 左侧滑块跟随鼠标移动

    image-20230605090240710

  2. 右侧大图放大效果实现

    image-20230605090400445

  3. 鼠标移入控制滑块和大图显示隐藏

3、规格组件 SKU&SPU

  • SPU(Standard Product Unit):标准化产品单元。是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以称为一个 SPU。
  • SKU(Stock Keeping Unit)库存量单位,即库存进出计量的单位, 可以是以件、盒、托盘等为单位。SKU 是物理上不可分割的最小存货单元。在使用时要根据不同业态,不同管理模式来处理

image-20230605090703869

即,spu 代表一个商品,拥有很多相同的属性。sku 代表该商品可选规格的任意组合,他是库存单位的唯一标识。

image-20230605090822108

八、登录页

1、整体架构

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
<script setup>
import { ElMessage } from "element-plus";
import { ref, toRefs } from "vue";
import { useUserInfoStore } from "@/stores/userInfo";
const ruleFormRef = ref(null);
const ruleForm = ref({
account: "",
password: "",
agreement: false,
});
// 单独校验是否同意条款
const validateAgree = (rule, value, callback) => {
if (value) {
callback();
} else {
callback(new Error("请同意条款"));
}
};
const rules = ref({
account: [{ required: true, message: "请输入账户", trigger: "blur" }],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 6, max: 16, message: "长度在 6 到 16 个字符", trigger: "blur" },
],
agreement: [{ validator: validateAgree, trigger: "change" }],
});
// 登录按钮
const loginBtn = () => {
ruleFormRef.value.validate((valid) => {
if (valid) {
const { getUserInfo } = toRefs(useUserInfoStore());
getUserInfo.value(ruleForm.value);
} else {
ElMessage.error("验证失败");
}
});
};
</script>

<template>
<div>
<header class="login-header">
<div class="container m-top-20">
<h1 class="logo">
<RouterLink to="/">小兔鲜</RouterLink>
</h1>
<RouterLink class="entry" to="/">
进入网站首页
<i class="iconfont icon-angle-right"></i>
<i class="iconfont icon-angle-right"></i>
</RouterLink>
</div>
</header>
<section class="login-section">
<div class="wrapper">
<nav>
<a href="javascript:;">账户登录</a>
</nav>
<div class="account-box">
<div class="form">
<el-form
label-position="right"
label-width="60px"
status-icon
ref="ruleFormRef"
:model="ruleForm"
:rules="rules"
>
<el-form-item prop="account" label="账户">
<el-input v-model="ruleForm.account" />
</el-form-item>
<el-form-item prop="password" label="密码">
<el-input
type="password"
show-password
v-model="ruleForm.password"
/>
</el-form-item>
<el-form-item label-width="22px" prop="agreement">
<el-checkbox size="large" v-model="ruleForm.agreement">
我已同意隐私条款和服务条款
</el-checkbox>
</el-form-item>
<el-button size="large" class="subBtn" @click="loginBtn"
>点击登录</el-button
>
</el-form>
</div>
</div>
</div>
</section>

<footer class="login-footer">
<div class="container">
<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 &copy; 小兔鲜儿</p>
</div>
</footer>
</div>
</template>

<style scoped lang="scss">
.login-header {
background: #fff;
border-bottom: 1px solid #e4e4e4;

.container {
display: flex;
align-items: flex-end;
justify-content: space-between;
}

.logo {
width: 200px;

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

.sub {
flex: 1;
font-size: 24px;
font-weight: normal;
margin-bottom: 38px;
margin-left: 20px;
color: #666;
}

.entry {
width: 120px;
margin-bottom: 38px;
font-size: 16px;

i {
font-size: 14px;
color: $xtxColor;
letter-spacing: -5px;
}
}
}

.login-section {
background: url("@/assets/images/login-bg.png") no-repeat center / cover;
height: 488px;
position: relative;

.wrapper {
width: 380px;
background: #fff;
position: absolute;
left: 50%;
top: 54px;
transform: translate3d(100px, 0, 0);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.15);

nav {
font-size: 14px;
height: 55px;
margin-bottom: 20px;
border-bottom: 1px solid #f5f5f5;
display: flex;
padding: 0 40px;
text-align: right;
align-items: center;

a {
flex: 1;
line-height: 1;
display: inline-block;
font-size: 18px;
position: relative;
text-align: center;
}
}
}
}

.login-footer {
padding: 30px 0 50px;
background: #fff;

p {
text-align: center;
color: #999;
padding-top: 20px;

a {
line-height: 1;
padding: 0 10px;
color: #999;
display: inline-block;

~ a {
border-left: 1px solid #ccc;
}
}
}
}

.account-box {
.toggle {
padding: 15px 40px;
text-align: right;

a {
color: $xtxColor;

i {
font-size: 14px;
}
}
}

.form {
padding: 0 20px 20px 20px;

&-item {
margin-bottom: 28px;

.input {
position: relative;
height: 36px;

> i {
width: 34px;
height: 34px;
background: #cfcdcd;
color: #fff;
position: absolute;
left: 1px;
top: 1px;
text-align: center;
line-height: 34px;
font-size: 18px;
}

input {
padding-left: 44px;
border: 1px solid #cfcdcd;
height: 36px;
line-height: 36px;
width: 100%;

&.error {
border-color: $priceColor;
}

&.active,
&:focus {
border-color: $xtxColor;
}
}

.code {
position: absolute;
right: 1px;
top: 1px;
text-align: center;
line-height: 34px;
font-size: 14px;
background: #f5f5f5;
color: #666;
width: 90px;
height: 34px;
cursor: pointer;
}
}

> .error {
position: absolute;
font-size: 12px;
line-height: 28px;
color: $priceColor;

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

.agree {
a {
color: #069;
}
}

.btn {
display: block;
width: 100%;
height: 40px;
color: #fff;
text-align: center;
line-height: 40px;
background: $xtxColor;

&.disabled {
background: #cfcdcd;
}
}
}

.action {
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;

.url {
a {
color: #999;
margin-left: 10px;
}
}
}
}

.subBtn {
background: $xtxColor;
width: 100%;
color: #fff;
}
</style>

2、pinia 管理用户数据

stores 下新建 userInfo.js

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
import { ref } from "vue";
import { defineStore } from "pinia";
import { loginAPI } from "@/apis/login";
import router from "@/router/index.js";
import { ElMessage } from "element-plus";
import { useCartStore } from "@/stores/cart";
const { mergeCart } = useCartStore();
export const useUserInfoStore = defineStore(
"userInfo",
() => {
//用户数据
const userInfo = ref({});
// 获取用户数据
async function getUserInfo(Data) {
try {
const { data } = await loginAPI(Data);
userInfo.value = data.result;
mergeCart();
ElMessage.success("登录成功");
router.replace("/");
} catch {
ElMessage.error("登录失败");
}
}
// 清除用户数据
function clearUserInfo() {
userInfo.value = {};
}
return { userInfo, getUserInfo, clearUserInfo };
},
{
persist: true,
}
);

3、请求拦截器统一注入 token

utils/request.js

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
import axios from "axios";
import { useUserInfoStore } from "@/stores/userInfo";
import { ElMessage } from "element-plus";
const request = axios.create({
baseURL: "http://pcapi-xiaotuxian-front-devtest.itheima.net/",
timeout: 5000, // 单位是毫秒
});
// 请求拦截器,统一注入token
request.interceptors.request.use((config) => {
const { userInfo } = useUserInfoStore();
if (userInfo.token) {
config.headers.Authorization = `Bearer ${userInfo.token}`;
}
return config;
});
// 响应拦截器,这里可以将axios默认添加的一层data剥离出来
request.interceptors.response.use(
(response) => response,
(error) => {
if (error.response.status === 401) {
ElMessage.error("请先登录");
}
return Promise.reject(error);
}
);
export default request;

九、购物车

1、整体架构

头部购物车 HeadCart

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
<script setup>
import { useCartStore } from "@/stores/cart";
import { toRefs } from "vue";
const { cartList, total, totalPrice, removeCart } = toRefs(useCartStore());
</script>

<template>
<div class="cart">
<a class="curr" href="javascript:;">
<i class="iconfont icon-cart"></i><em>{{ cartList.length }}</em>
</a>
<div class="layer">
<div class="list">
<div class="item" v-for="i in cartList" :key="i.id">
<RouterLink to="">
<img :src="i.picture" alt="" />
<div class="center">
<p class="name ellipsis-2">
{{ i.name }}
</p>
<p class="attr ellipsis">{{ i.attrsText }}</p>
</div>
<div class="right">
<p class="price">&yen;{{ i.price }}</p>
<p class="count">x{{ i.count }}</p>
</div>
</RouterLink>
<i class="iconfont icon-close-new" @click="removeCart(i)"></i>
</div>
</div>
<div class="foot">
<div class="total">
<p>共 {{ total }} 件商品</p>
<p>&yen; {{ totalPrice.toFixed(2) }}</p>
</div>
<el-button
size="large"
type="primary"
@click="$router.push('/cartList')"
>去购物车结算</el-button
>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.cart {
width: 50px;
position: relative;
z-index: 600;

.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;
}
}

&:hover {
.layer {
opacity: 1;
transform: none;
}
}

.layer {
opacity: 0;
transition: all 0.4s 0.2s;
transform: translateY(-200px) scale(1, 0);
width: 400px;
height: 400px;
position: absolute;
top: 50px;
right: 0;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
background: #fff;
border-radius: 4px;
padding-top: 10px;

&::before {
content: "";
position: absolute;
right: 14px;
top: -10px;
width: 20px;
height: 20px;
background: #fff;
transform: scale(0.6, 1) rotate(45deg);
box-shadow: -3px -3px 5px rgba(0, 0, 0, 0.1);
}

.foot {
position: absolute;
left: 0;
bottom: 0;
height: 70px;
width: 100%;
padding: 10px;
display: flex;
justify-content: space-between;
background: #f8f8f8;
align-items: center;

.total {
padding-left: 10px;
color: #999;

p {
&:last-child {
font-size: 18px;
color: $priceColor;
}
}
}
}
}

.list {
height: 310px;
overflow: auto;
padding: 0 10px;

&::-webkit-scrollbar {
width: 10px;
height: 10px;
}

&::-webkit-scrollbar-track {
background: #f8f8f8;
border-radius: 2px;
}

&::-webkit-scrollbar-thumb {
background: #eee;
border-radius: 10px;
}

&::-webkit-scrollbar-thumb:hover {
background: #ccc;
}

.item {
border-bottom: 1px solid #f5f5f5;
padding: 10px 0;
position: relative;

i {
position: absolute;
bottom: 38px;
right: 0;
opacity: 0;
color: #666;
transition: all 0.5s;
}

&:hover {
i {
opacity: 1;
cursor: pointer;
}
}

a {
display: flex;
align-items: center;

img {
height: 80px;
width: 80px;
}

.center {
padding: 0 10px;
width: 200px;

.name {
font-size: 16px;
}

.attr {
color: #999;
padding-top: 5px;
}
}

.right {
width: 100px;
padding-right: 20px;
text-align: center;

.price {
font-size: 16px;
color: $priceColor;
}

.count {
color: #999;
margin-top: 5px;
font-size: 16px;
}
}
}
}
}
}
</style>

列表购物车 cartList

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
<script setup>
import { useCartStore } from "@/stores/cart";
import { toRefs } from "vue";
// 注意这里因为有点击选择框导致选择框状态的改变,选择框状态的改变需要同步到pinia中,解构会使响应式数据丢失响应性,所以这里使用toRefs解构
const {
cartList,
total,
removeCart,
isCheckAll,
selectedCount,
selectedTotalPrice,
} = toRefs(useCartStore());
</script>

<template>
<div class="xtx-cart-page">
<div class="container m-top-20">
<div class="cart">
<table>
<thead>
<tr>
<th width="120">
<el-checkbox v-model="isCheckAll" />
</th>
<th width="400">商品信息</th>
<th width="220">单价</th>
<th width="180">数量</th>
<th width="180">小计</th>
<th width="140">操作</th>
</tr>
</thead>
<!-- 商品列表 -->
<tbody>
<tr v-for="i in cartList" :key="i.id">
<td>
<el-checkbox v-model="i.selected" />
</td>
<td>
<div class="goods">
<RouterLink to="/"
><img :src="i.picture" alt=""
/></RouterLink>
<div>
<p class="name ellipsis">
{{ i.name }}
</p>
</div>
</div>
</td>
<td class="tc">
<p>&yen;{{ i.price }}</p>
</td>
<td class="tc">
<el-input-number v-model="i.count" />
</td>
<td class="tc">
<p class="f16 red">&yen;{{ (i.price * i.count).toFixed(2) }}</p>
</td>
<td class="tc">
<p>
<el-popconfirm
title="确认删除吗?"
confirm-button-text="确认"
cancel-button-text="取消"
@confirm="removeCart(i)"
>
<template #reference>
<a href="javascript:;">删除</a>
</template>
</el-popconfirm>
</p>
</td>
</tr>
<tr v-if="cartList.length === 0">
<td colspan="6">
<div class="cart-none">
<el-empty description="购物车列表为空">
<el-button type="primary">随便逛逛</el-button>
</el-empty>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 操作栏 -->
<div class="action">
<div class="batch">
共 {{ total }} 件商品,已选择 {{ selectedCount }} 件,商品合计:
<span class="red">¥ {{ selectedTotalPrice.toFixed(2) }} </span>
</div>
<div class="total">
<el-button
size="large"
type="primary"
@click="$router.push('/orders')"
>下单结算</el-button
>
</div>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.xtx-cart-page {
margin-top: 20px;

.cart {
background: #fff;
color: #666;

table {
border-spacing: 0;
border-collapse: collapse;
line-height: 24px;

th,
td {
padding: 10px;
border-bottom: 1px solid #f5f5f5;

&:first-child {
text-align: left;
padding-left: 30px;
color: #999;
}
}

th {
font-size: 16px;
font-weight: normal;
line-height: 50px;
}
}
}

.cart-none {
text-align: center;
padding: 120px 0;
background: #fff;

p {
color: #999;
padding: 20px 0;
}
}

.tc {
text-align: center;

a {
color: $xtxColor;
}

.xtx-numbox {
margin: 0 auto;
width: 120px;
}
}

.red {
color: $priceColor;
}

.green {
color: $xtxColor;
}

.f16 {
font-size: 16px;
}

.goods {
display: flex;
align-items: center;

img {
width: 100px;
height: 100px;
}

> div {
width: 280px;
font-size: 16px;
padding-left: 10px;

.attr {
font-size: 14px;
color: #999;
}
}
}

.action {
display: flex;
background: #fff;
margin-top: 20px;
height: 80px;
align-items: center;
font-size: 16px;
justify-content: space-between;
padding: 0 30px;

.xtx-checkbox {
color: #999;
}

.batch {
a {
margin-left: 20px;
}
}

.red {
font-size: 18px;
margin-right: 20px;
font-weight: bold;
}
}

.tit {
color: #666;
font-size: 16px;
font-weight: normal;
line-height: 50px;
}
}
</style>

接下来需要分两条路:一是未登录状态下操作购物车存储到本地,二是登录状态下操作购物车存储到数据库(调用接口)

2、pinia 管理购物车数据

由于购物车数据多个模块都需要用到,因此在 stores 新建 cart.js

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
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { insertCartAPI, delCartAPI, getCartListAPI } from "@/apis/cartList.js";
import { mergeCartAPI } from "@/apis/login.js";
import { useUserInfoStore } from "@/stores/userInfo.js";
export const useCartStore = defineStore(
"useCartStore",
() => {
//购物车列表
const cartList = ref([]);
//商品总数量
const total = computed(() =>
cartList.value.reduce((total, item) => total + item.count, 0)
);
//商品总价格
const totalPrice = computed(() =>
cartList.value.reduce((total, item) => total + item.count * item.price, 0)
);
//已选件数
const selectedCount = computed(() => {
const selectedGoods = cartList.value.filter((item) => item.selected);
return selectedGoods.reduce((total, item) => total + item.count, 0);
});
// 已选商品总价
const selectedTotalPrice = computed(() => {
const selectedGoods = cartList.value.filter((item) => item.selected);
return selectedGoods.reduce(
(total, item) => total + item.count * item.price,
0
);
});
// 全选状态
const isCheckAll = computed({
get() {
return cartList.value.every((item) => item.selected === true);
},
set(val) {
cartList.value.forEach((item) => (item.selected = val));
},
});
// 获取购物车列表
const getCartList = async () => {
const { data } = await getCartListAPI();
cartList.value = data.result;
};
// 添加购物车
const addCart = async (goods) => {
//如果用户已登录,则调用接口,否则走本地购物车流程
const { userInfo } = useUserInfoStore();
if (userInfo.token) {
await insertCartAPI({
skuId: goods.skuId,
count: goods.count,
});
// 调用添加接口后,重新获取购物车列表以覆盖本地购物车
getCartList();
} else {
//如果购物车中有该商品,数量加1,否则添加该商品
const index = cartList.value.findIndex(
(item) => item.skuId === goods.skuId
);
if (index === -1) {
cartList.value.push(goods);
} else {
cartList.value[index].count++;
}
}
};
//删除购物车的数据
const removeCart = (goods) => {
//如果用户已登录,则调用接口,否则走本地购物车流程
const { userInfo } = useUserInfoStore();
if (userInfo.token) {
delCartAPI([goods.skuId]);
// 调用删除接口后,重新获取购物车列表以覆盖本地购物车
getCartList();
} else {
const index = cartList.value.findIndex(
(item) => item.skuId === goods.skuId
);
cartList.value.splice(index, 1);
}
};
// 合并购物车
const mergeCart = async () => {
await mergeCartAPI(cartList.value);
};
return {
cartList,
total,
totalPrice,
addCart,
removeCart,
isCheckAll,
selectedCount,
selectedTotalPrice,
getCartList,
mergeCart,
};
},
{
persist: true,
}
);

注意,这里有一个合并购物车的方法,主要是为了将用户未登录时的购物车操作在登录时合并到服务器

十、订单页

1、整体架构

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
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
<script setup>
import { useRouter } from "vue-router";
import { ref, onMounted, computed } from "vue";
import {
getCheckoutInfoAPI,
addAddressAPI,
getAddressAPI,
deleteAddressAPI,
submitOrderAPI,
} from "@/apis/orders";
import { regionData, codeToText } from "element-china-area-data";
import { ElMessage } from "element-plus";
import { useCartStore } from "@/stores/cart";
const checkInfo = ref({}); // 订单对象
const curAddress = ref({}); // 地址对象
const newAddressList = ref([]); // 地址列表
getCheckoutInfoAPI().then((res) => {
checkInfo.value = res.data.result;
});
const getAddress = async () => {
const { data } = await getAddressAPI();
curAddress.value = data.result.find((item) => item.isDefault === 0);
newAddressList.value = data.result.map((ele) => {
ele.isSelected = false;
return ele;
});
};
onMounted(() => getAddress());
// 控制切换地址弹窗显示隐藏
const dialogVisible = ref(false);
const toggle = () => {
dialogVisible.value = true;
getAddress();
};
// 存放临时地址
const tempAddress = ref({});
// 选中地址时高亮效果
const confirmAddrsss = (item) => {
newAddressList.value
.filter((element) => element.id != item.id)
.forEach((element) => (element.isSelected = false));
item.isSelected = !item.isSelected;
if (item.isSelected) {
tempAddress.value = item;
}
};
// 确定切换地址
const toggleAddress = () => {
if (tempAddress.value.id) {
curAddress.value = tempAddress.value;
tempAddress.value = {};
}

dialogVisible.value = false;
};
// 删除地址
const removeAddress = async (item) => {
try {
await deleteAddressAPI(item.id);
ElMessage.success("删除成功");
getAddress();
} catch {
ElMessage.error("删除失败");
}
};
// 添加地址
// 添加地址显示隐藏
const addDialogVisible = ref(false);
// 表单数据
const formData = ref({
receiver: "",
contact: "",
region: [],
address: "",
postalCode: "",
addressTags: "",
isDefault: 1,
});
// 取消按钮
const cancel = () => {
addDialogVisible.value = false;
formData.value = {
receiver: "",
contact: "",
region: [],
address: "",
postalCode: "",
addressTags: "",
isDefault: 1,
};
};
// 确认按钮
const confirm = async () => {
try {
await addAddressAPI({
...formData.value,
provinceCode: formData.value.region[0],
cityCode: formData.value.region[1],
countyCode: formData.value.region[2],
fullLocation:
codeToText[formData.value.region[0]] +
codeToText[formData.value.region[1]] +
codeToText[formData.value.region[2]] +
formData.value.address,
});
ElMessage.success("添加成功");
} catch {
ElMessage.error("添加失败");
}
cancel();
};
// 提交订单
// 订单数据
const orderData = ref({
deliveryTimeType: 1,
payType: 1,
payChannel: 1,
buyerMessage: "",
goods: computed(() => checkInfo.value.goods),
addressId: computed(() => curAddress.value.id),
});
const router = useRouter();
const submitOrders = async () => {
const { data } = await submitOrderAPI(orderData.value);
// 更新购物车数据
const { removeCart } = useCartStore();
removeCart(orderData.value);
router.push(`/payPage/${data.result.id}`);
};
</script>

<template>
<div class="xtx-pay-checkout-page">
<div class="container">
<div class="wrapper">
<!-- 收货地址 -->
<h3 class="box-title">收货地址</h3>
<div class="box-body">
<div class="address">
<div class="text">
<div class="none" v-if="!curAddress">
您需要先添加收货地址才可提交订单。
</div>
<ul v-else>
<li>
<span>收<i />货<i />人:</span>{{ curAddress.receiver }}
</li>
<li><span>联系方式:</span>{{ curAddress.contact }}</li>
<li>
<span>收货地址:</span>{{ curAddress.fullLocation }}
{{ curAddress.address }}
</li>
</ul>
</div>
<div class="action">
<el-button size="large" @click="toggle">切换地址</el-button>
<el-button size="large" @click="addDialogVisible = true"
>添加地址</el-button
>
</div>
</div>
</div>
<!-- 商品信息 -->
<h3 class="box-title">商品信息</h3>
<div class="box-body">
<table class="goods">
<thead>
<tr>
<th width="520">商品信息</th>
<th width="170">单价</th>
<th width="170">数量</th>
<th width="170">小计</th>
<th width="170">实付</th>
</tr>
</thead>
<tbody>
<tr v-for="i in checkInfo.goods" :key="i.id">
<td>
<a href="javascript:;" class="info">
<img :src="i.picture" alt="" />
<div class="right">
<p>{{ i.name }}</p>
<p>{{ i.attrsText }}</p>
</div>
</a>
</td>
<td>&yen;{{ i.price }}</td>
<td>{{ i.count }}</td>
<td>&yen;{{ i.totalPrice }}</td>
<td>&yen;{{ i.totalPayPrice }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 配送时间 -->
<h3 class="box-title">配送时间</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;"
>不限送货时间:周一至周日</a
>
<a class="my-btn" href="javascript:;">工作日送货:周一至周五</a>
<a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a>
</div>
<!-- 支付方式 -->
<h3 class="box-title">支付方式</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;">在线支付</a>
<a class="my-btn" href="javascript:;">货到付款</a>
<span style="color: #999">货到付款需付5元手续费</span>
</div>
<!-- 金额明细 -->
<h3 class="box-title">金额明细</h3>
<div class="box-body">
<div class="total">
<dl>
<dt>商品件数:</dt>
<dd>{{ checkInfo.summary?.goodsCount }}件</dd>
</dl>
<dl>
<dt>商品总价:</dt>
<dd>¥{{ checkInfo.summary?.totalPrice.toFixed(2) }}</dd>
</dl>
<dl>
<dt>运<i></i>费:</dt>
<dd>¥{{ checkInfo.summary?.postFee.toFixed(2) }}</dd>
</dl>
<dl>
<dt>应付总额:</dt>
<dd class="price">
{{ checkInfo.summary?.totalPayPrice.toFixed(2) }}
</dd>
</dl>
</div>
</div>
<!-- 提交订单 -->
<div class="submit">
<el-button type="primary" size="large" @click="submitOrders"
>提交订单</el-button
>
</div>
</div>
</div>
</div>
<!-- 切换地址 -->
<el-dialog title="切换收货地址" width="30%" center v-model="dialogVisible">
<div class="addressWrapper">
<div
class="text item"
v-for="item in newAddressList"
:key="item.id"
@click="confirmAddrsss(item)"
:class="{ active: item.isSelected }"
>
<ul>
<li>
<span>收<i />货<i />人:</span>{{ item.receiver }}
</li>
<li><span>联系方式:</span>{{ item.contact }}</li>
<li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li>
</ul>
<i
class="iconfont icon-close-new close"
@click="removeAddress(item)"
></i>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="toggleAddress">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 添加地址 -->
<el-dialog title="添加收货地址" width="30%" center v-model="addDialogVisible">
<div class="addressWrapper">
<el-form label-width="100px" :model="formData" style="max-width: 460px">
<el-form-item label="收货人" required>
<el-input placeholder="请输入收货人" v-model="formData.receiver" />
</el-form-item>
<el-form-item label="手机号" required>
<el-input placeholder="请输入手机号" v-model="formData.contact" />
</el-form-item>
<el-form-item label="地区" required>
<el-cascader
placeholder="请选择所在地区"
v-model="formData.region"
:options="regionData"
@change="handleChange"
required
/>
</el-form-item>
<el-form-item label="详细地址" required>
<el-input placeholder="请输入详细地址" v-model="formData.address" />
</el-form-item>
<el-form-item label="邮政编码" required>
<el-input
placeholder="请输入邮政编码"
v-model="formData.postalCode"
/>
</el-form-item>
<el-form-item label="地址标签" required>
<el-input
placeholder="请输入地址标签"
v-model="formData.addressTags"
/>
</el-form-item>
<el-form-item label="设为默认地址" required>
<el-switch
v-model="formData.isDefault"
:active-value="0"
:inactive-value="1"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</span>
</template>
</el-dialog>
</template>

<style scoped lang="scss">
.xtx-pay-checkout-page {
margin-top: 20px;

.wrapper {
background: #fff;
padding: 0 20px;

.box-title {
font-size: 16px;
font-weight: normal;
padding-left: 10px;
line-height: 70px;
border-bottom: 1px solid #f5f5f5;
}

.box-body {
padding: 20px 0;
}
}
}

.address {
border: 1px solid #f5f5f5;
display: flex;
align-items: center;

.text {
flex: 1;
min-height: 90px;
display: flex;
align-items: center;

.none {
line-height: 90px;
color: #999;
text-align: center;
width: 100%;
}

> ul {
flex: 1;
padding: 20px;

li {
line-height: 30px;

span {
color: #999;
margin-right: 5px;

> i {
width: 0.5em;
display: inline-block;
}
}
}
}

> a {
color: $xtxColor;
width: 160px;
text-align: center;
height: 90px;
line-height: 90px;
border-right: 1px solid #f5f5f5;
}
}

.action {
width: 420px;
text-align: center;

.btn {
width: 140px;
height: 46px;
line-height: 44px;
font-size: 14px;

&:first-child {
margin-right: 10px;
}
}
}
}

.goods {
width: 100%;
border-collapse: collapse;
border-spacing: 0;

.info {
display: flex;
text-align: left;

img {
width: 70px;
height: 70px;
margin-right: 20px;
}

.right {
line-height: 24px;

p {
&:last-child {
color: #999;
}
}
}
}

tr {
th {
background: #f5f5f5;
font-weight: normal;
}

td,
th {
text-align: center;
padding: 20px;
border-bottom: 1px solid #f5f5f5;

&:first-child {
border-left: 1px solid #f5f5f5;
}

&:last-child {
border-right: 1px solid #f5f5f5;
}
}
}
}

.my-btn {
width: 228px;
height: 50px;
border: 1px solid #e4e4e4;
text-align: center;
line-height: 48px;
margin-right: 25px;
color: #666666;
display: inline-block;

&.active,
&:hover {
border-color: $xtxColor;
}
}

.total {
dl {
display: flex;
justify-content: flex-end;
line-height: 50px;

dt {
i {
display: inline-block;
width: 2em;
}
}

dd {
width: 240px;
text-align: right;
padding-right: 70px;

&.price {
font-size: 20px;
color: $priceColor;
}
}
}
}

.submit {
text-align: right;
padding: 60px;
border-top: 1px solid #f5f5f5;
}

.addressWrapper {
max-height: 500px;
overflow-y: auto;
:deep(.el-form-item__label) {
width: 120px !important;
}
}

.text {
flex: 1;
position: relative;
min-height: 90px;
display: flex;
align-items: center;
&.item {
border: 1px solid #f5f5f5;
margin-bottom: 10px;
cursor: pointer;

&.active,
&:hover {
border-color: $xtxColor;
background: lighten($xtxColor, 50%);
}
&:hover .close {
opacity: 1;
}

> ul {
padding: 10px;
font-size: 14px;
line-height: 30px;
}
.close {
position: absolute;
top: 10px;
right: 10px;
opacity: 0;
}
}
}
</style>

十一、支付页

1、整体结构

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
<script setup>
import { getOrderAPI } from "@/apis/payPage";
import { useRoute } from "vue-router";
import { ref, computed } from "vue";
import { onMounted } from "vue";
import dayjs from "dayjs";
import { onUnmounted } from "vue";
const route = useRoute();
const payInfo = ref({});
const time = ref(0);
let timer = null;
const getPayInfo = async () => {
const { data } = await getOrderAPI(route.params.id);
payInfo.value = data.result;
time.value = data.result.countdown;
timer = setInterval(() => {
time.value--;
}, 1000);
};
const formateTime = computed(() => dayjs.unix(time.value).format("mm分ss秒"));
onMounted(() => getPayInfo());
// 点击支付宝付款
const baseURL = "http://pcapi-xiaotuxian-front-devtest.itheima.net/";
const backURL = "http://localhost:5173/paycallback";
const redirectUrl = encodeURIComponent(backURL);
const payUrl = `${baseURL}pay/aliPay?orderId=${route.params.id}&redirect=${redirectUrl}`;
// 别忘了组件销毁时手动清除定时器,因为浏览器在组件销毁后并不会自动清除定时器
onUnmounted(() => clearInterval(timer));
</script>

<template>
<div class="xtx-pay-page">
<div class="container">
<!-- 付款信息 -->
<div class="pay-info">
<span class="icon iconfont icon-queren2"></span>
<div class="tip">
<p>订单提交成功!请尽快完成支付。</p>
<p>
支付还剩 <span>{{ formateTime }}</span
>, 超时后将取消订单
</p>
</div>
<div class="amount">
<span>应付总额:</span>
<span>¥{{ payInfo.payMoney?.toFixed(2) }}</span>
</div>
</div>
<!-- 付款方式 -->
<div class="pay-type">
<p class="head">选择以下支付方式付款</p>
<div class="item">
<p>支付平台</p>
<a class="btn wx" href="javascript:;"></a>
<a class="btn alipay" :href="payUrl"></a>
</div>
<div class="item">
<p>支付方式</p>
<a class="btn" href="javascript:;">招商银行</a>
<a class="btn" href="javascript:;">工商银行</a>
<a class="btn" href="javascript:;">建设银行</a>
<a class="btn" href="javascript:;">农业银行</a>
<a class="btn" href="javascript:;">交通银行</a>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.xtx-pay-page {
margin-top: 20px;
}

.pay-info {
background: #fff;
display: flex;
align-items: center;
height: 240px;
padding: 0 80px;

.icon {
font-size: 80px;
color: #1dc779;
}

.tip {
padding-left: 10px;
flex: 1;

p {
&:first-child {
font-size: 20px;
margin-bottom: 5px;
}

&:last-child {
color: #999;
font-size: 16px;
}
}
}

.amount {
span {
&:first-child {
font-size: 16px;
color: #999;
}

&:last-child {
color: $priceColor;
font-size: 20px;
}
}
}
}

.pay-type {
margin-top: 20px;
background-color: #fff;
padding-bottom: 70px;

p {
line-height: 70px;
height: 70px;
padding-left: 30px;
font-size: 16px;

&.head {
border-bottom: 1px solid #f5f5f5;
}
}

.btn {
width: 150px;
height: 50px;
border: 1px solid #e4e4e4;
text-align: center;
line-height: 48px;
margin-left: 30px;
color: #666666;
display: inline-block;

&.active,
&:hover {
border-color: $xtxColor;
}

&.alipay {
background: url(https://cdn.cnbj1.fds.api.mi-img.com/mi-mall/7b6b02396368c9314528c0bbd85a2e06.png)
no-repeat center / contain;
}

&.wx {
background: url(https://cdn.cnbj1.fds.api.mi-img.com/mi-mall/c66f98cff8649bd5ba722c2e8067c6ca.jpg)
no-repeat center / contain;
}
}
}
</style>

2、支付业务流程

image-20230605104940358

因此,在前端我们只需要给点击支付一个 URL 地址,并携带 orderId 参数和 redirect 参数(用于指定支付完成后的回跳地址)即可。支付完成后,页面会携带订单 id 和支付状态的参数自动跳转到我们给定的这个回跳地址

3、支付结果展示

paycallback 组件

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
<script setup>
import { getOrderAPI } from "@/apis/payPage";
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const orderInfo = ref({});

const getOrderInfo = async () => {
const { data } = await getOrderAPI(route.query.orderId);
orderInfo.value = data.result;
};

onMounted(() => getOrderInfo());
</script>

<template>
<div class="xtx-pay-page">
<div class="container">
<!-- 支付结果 -->
<div class="pay-result">
<span
class="iconfont icon-queren2 green"
v-if="$route.query.payResult === 'true'"
></span>
<span class="iconfont icon-shanchu red" v-else></span>
<p class="tit">
支付{{ $route.query.payResult === "true" ? "成功" : "失败" }}
</p>
<p class="tip">我们将尽快为您发货,收货期间请保持手机畅通</p>
<p>支付方式:<span>支付宝</span></p>
<p>
支付金额:<span>¥{{ orderInfo.payMoney?.toFixed(2) }}</span>
</p>
<div class="btn">
<el-button
type="primary"
style="margin-right: 20px"
@click="$router.push('/VIP')"
>查看订单</el-button
>
<el-button @click="$router.push('/')">进入首页</el-button>
</div>
<p class="alert">
<span class="iconfont icon-tip"></span>
温馨提示:小兔鲜儿不会以订单异常、系统升级为由要求您点击任何网址链接进行退款操作,保护资产、谨慎操作。
</p>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.pay-result {
padding: 100px 0;
background: #fff;
text-align: center;
margin-top: 20px;

> .iconfont {
font-size: 100px;
}

.green {
color: #1dc779;
}

.red {
color: $priceColor;
}

.tit {
font-size: 24px;
}

.tip {
color: #999;
}

p {
line-height: 40px;
font-size: 16px;
}

.btn {
margin-top: 50px;
}

.alert {
font-size: 12px;
color: #999;
margin-top: 50px;
}
}
</style>

十二、会员中心

1、整体结构

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
<script setup></script>

<template>
<div class="container">
<div class="xtx-member-aside">
<div class="user-manage">
<h4>我的账户</h4>
<div class="links">
<RouterLink to="/VIP">个人中心</RouterLink>
</div>
<h4>交易管理</h4>
<div class="links">
<RouterLink to="/VIP/myOrders">我的订单</RouterLink>
</div>
</div>
</div>
<div class="article">
<!-- 三级路由的挂载点 -->
<RouterView />
</div>
</div>
</template>

<style scoped lang="scss">
.container {
display: flex;
padding-top: 20px;

.xtx-member-aside {
width: 220px;
margin-right: 20px;
border-radius: 2px;
background-color: #fff;

.user-manage {
background-color: #fff;

h4 {
font-size: 18px;
font-weight: 400;
padding: 20px 52px 5px;
border-top: 1px solid #f6f6f6;
}

.links {
padding: 0 52px 10px;
}

a {
display: block;
line-height: 1;
padding: 15px 0;
font-size: 14px;
color: #666;
position: relative;

&:hover {
color: $xtxColor;
}

&.active,
&.router-link-exact-active {
color: $xtxColor;

&:before {
display: block;
}
}

&:before {
content: "";
display: none;
width: 6px;
height: 6px;
border-radius: 50%;
position: absolute;
top: 19px;
left: -16px;
background-color: $xtxColor;
}
}
}
}

.article {
width: 1000px;
background-color: #fff;
}
}
</style>

我的订单 myOrders 组件

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
<script setup>
import { getUserOrder } from "@/apis/vip";
import { onMounted } from "vue";
import { ref } from "vue";
// 订单列表
const orderList = ref([]);
const queryList = ref({
orderState: 0,
page: 1,
pageSize: 2,
totalCount: 0,
});
const getOrders = async () => {
const { data } = await getUserOrder(queryList.value);
orderList.value = data.result.items;
queryList.value.totalCount = data.result.counts;
};
onMounted(() => getOrders());
// tab列表
const tabTypes = [
{ name: "all", label: "全部订单" },
{ name: "unpay", label: "待付款" },
{ name: "deliver", label: "待发货" },
{ name: "receive", label: "待收货" },
{ name: "comment", label: "待评价" },
{ name: "complete", label: "已完成" },
{ name: "cancel", label: "已取消" },
];
// 页码改变时
const pageChange = async (val) => {
queryList.value.page = val;
getOrders();
};
// tab栏切换
const change = (value) => {
queryList.value.orderState = value;
getOrders();
};
// 格式化订单状态显示
const stateMap = {
1: "待付款",
2: "待发货",
3: "待收货",
4: "待评价",
5: "已完成",
6: "已取消",
};
</script>

<template>
<div class="order-container">
<el-tabs @tab-change="change">
<!-- tab切换 -->
<el-tab-pane
v-for="item in tabTypes"
:key="item.name"
:label="item.label"
/>

<div class="main-container">
<div class="holder-container" v-if="orderList.length === 0">
<el-empty description="暂无订单数据" />
</div>
<div v-else>
<!-- 订单列表 -->
<div class="order-item" v-for="order in orderList" :key="order.id">
<div class="head">
<span>下单时间:{{ order.createTime }}</span>
<span>订单编号:{{ order.id }}</span>
<!-- 未付款,倒计时时间还有 -->
<span class="down-time" v-if="order.orderState === 1">
<i class="iconfont icon-down-time"></i>
<b>付款截止: {{ order.countdown }}</b>
</span>
</div>
<div class="body">
<div class="column goods">
<ul>
<li v-for="item in order.skus" :key="item.id">
<a class="image" href="javascript:;">
<img :src="item.image" alt="" />
</a>
<div class="info">
<p class="name ellipsis-2">
{{ item.name }}
</p>
<p class="attr ellipsis">
<span>{{ item.attrsText }}</span>
</p>
</div>
<div class="price">¥{{ item.realPay?.toFixed(2) }}</div>
<div class="count">x{{ item.quantity }}</div>
</li>
</ul>
</div>
<div class="column state">
<p>{{ stateMap[order.orderState] }}</p>
<p v-if="order.orderState === 3">
<a href="javascript:;" class="green">查看物流</a>
</p>
<p v-if="order.orderState === 4">
<a href="javascript:;" class="green">评价商品</a>
</p>
<p v-if="order.orderState === 5">
<a href="javascript:;" class="green">查看评价</a>
</p>
</div>
<div class="column amount">
<p class="red">¥{{ order.payMoney?.toFixed(2) }}</p>
<p>(含运费:¥{{ order.postFee?.toFixed(2) }})</p>
<p>在线支付</p>
</div>
<div class="column action">
<el-button
v-if="order.orderState === 1"
type="primary"
size="small"
>
立即付款
</el-button>
<el-button
v-if="order.orderState === 3"
type="primary"
size="small"
>
确认收货
</el-button>
<p><a href="javascript:;">查看详情</a></p>
<p v-if="[2, 3, 4, 5].includes(order.orderState)">
<a href="javascript:;">再次购买</a>
</p>
<p v-if="[4, 5].includes(order.orderState)">
<a href="javascript:;">申请售后</a>
</p>
<p v-if="order.orderState === 1">
<a href="javascript:;">取消订单</a>
</p>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
background
layout="prev, pager, next"
:total="queryList.totalCount"
:page-size="queryList.pageSize"
:current-page="queryList.page"
@current-change="pageChange"
/>
</div>
</div>
</div>
</el-tabs>
</div>
</template>

<style scoped lang="scss">
.order-container {
padding: 10px 20px;

.pagination-container {
display: flex;
justify-content: center;
}

.main-container {
min-height: 500px;

.holder-container {
min-height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
}
}

.order-item {
margin-bottom: 20px;
border: 1px solid #f5f5f5;

.head {
height: 50px;
line-height: 50px;
background: #f5f5f5;
padding: 0 20px;
overflow: hidden;

span {
margin-right: 20px;

&.down-time {
margin-right: 0;
float: right;

i {
vertical-align: middle;
margin-right: 3px;
}

b {
vertical-align: middle;
font-weight: normal;
}
}
}

.del {
margin-right: 0;
float: right;
color: #999;
}
}

.body {
display: flex;
align-items: stretch;

.column {
border-left: 1px solid #f5f5f5;
text-align: center;
padding: 20px;

> p {
padding-top: 10px;
}

&:first-child {
border-left: none;
}

&.goods {
flex: 1;
padding: 0;
align-self: center;

ul {
li {
border-bottom: 1px solid #f5f5f5;
padding: 10px;
display: flex;

&:last-child {
border-bottom: none;
}

.image {
width: 70px;
height: 70px;
border: 1px solid #f5f5f5;
}

.info {
width: 220px;
text-align: left;
padding: 0 10px;

p {
margin-bottom: 5px;

&.name {
height: 38px;
}

&.attr {
color: #999;
font-size: 12px;

span {
margin-right: 5px;
}
}
}
}

.price {
width: 100px;
}

.count {
width: 80px;
}
}
}
}

&.state {
width: 120px;

.green {
color: $xtxColor;
}
}

&.amount {
width: 200px;

.red {
color: $priceColor;
}
}

&.action {
width: 140px;

a {
display: block;

&:hover {
color: $xtxColor;
}
}
}
}
}
}
</style>

个人中心 userInfo 组件

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
<script setup>
import { useUserInfoStore } from "@/stores/userInfo";
import { getLikeListAPI } from "@/apis/vip";
import { ref, onMounted } from "vue";
const { userInfo } = useUserInfoStore();
const likeList = ref([]);
const getLikeList = async () => {
const { data } = await getLikeListAPI({ limit: 4 });
likeList.value = data.result;
};
onMounted(() => getLikeList());
</script>

<template>
<div class="home-overview">
<!-- 用户信息 -->
<div class="user-meta">
<div class="avatar">
<img :src="userInfo?.avatar" />
</div>
<h4>{{ userInfo?.account }}</h4>
</div>
<div class="item">
<a href="javascript:;">
<span class="iconfont icon-hy"></span>
<p>会员中心</p>
</a>
<a href="javascript:;">
<span class="iconfont icon-aq"></span>
<p>安全设置</p>
</a>
<a href="javascript:;">
<span class="iconfont icon-dw"></span>
<p>地址管理</p>
</a>
</div>
</div>
<div class="like-container">
<div class="home-panel">
<div class="header">
<h4 data-v-bcb266e0="">猜你喜欢</h4>
</div>
<div class="goods-list">
<GoodsItem v-for="good in likeList" :key="good.id" :goods="good" />
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.home-overview {
height: 132px;
background: url(@/assets/images/center-bg.png) no-repeat center / cover;
display: flex;

.user-meta {
flex: 1;
display: flex;
align-items: center;

.avatar {
width: 85px;
height: 85px;
border-radius: 50%;
overflow: hidden;
margin-left: 60px;

img {
width: 100%;
height: 100%;
}
}

h4 {
padding-left: 26px;
font-size: 18px;
font-weight: normal;
color: white;
}
}

.item {
flex: 1;
display: flex;
align-items: center;
justify-content: space-around;

&:first-child {
border-right: 1px solid #f4f4f4;
}

a {
color: white;
font-size: 16px;
text-align: center;

.iconfont {
font-size: 32px;
}

p {
line-height: 32px;
}
}
}
}

.like-container {
margin-top: 20px;
border-radius: 4px;
background-color: #fff;
}

.home-panel {
background-color: #fff;
padding: 0 20px;
margin-top: 20px;
height: 400px;

.header {
height: 66px;
border-bottom: 1px solid #f5f5f5;
padding: 18px 0;
display: flex;
justify-content: space-between;
align-items: baseline;

h4 {
font-size: 22px;
font-weight: 400;
}
}

.goods-list {
display: flex;
justify-content: space-around;
}
}
</style>

这样一个基本的电商类项目就开发完成了,源码见https://github.com/congtianfeng/xtx-pc/

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2023-2025 congtianfeng
  • 访问人数: | 浏览次数: