Fork me on GitHub

基于RBAC的菜单权限控制

RBAC (Role-Based Access Control) 是一种基于角色的访问控制方式。在这种模式中,权限不是直接分配给用户,而是分配给角色,然后用户被分配到不同的角色。用户通过角色获得权限。这种方式使得权限管理变得更加灵活和方便。

在 RBAC 模型中,可以包含以下几个主要元素:

  • 用户(User):系统的操作者,可以是人,也可以是内部的程序或进程。
  • 角色(Role):用户的身份,它是权限的集合。一个用户可以拥有多个角色。
  • 权限(Permission):对一种操作或者资源的访问控制。权限通常是系统定义好的,例如读权限、写权限等。

RBAC 模型的主要优点是可以简化权限管理。在大型组织中,用户经常会更换部门,或者角色会随着时间而变化。使用 RBAC 模型,只需要更改用户的角色,而不需要单独更改每个用户的权限。

在企业服务中,权限一般分割为 页面访问权限按钮操作权限API 访问权限

一、权限受控的主体思路

  • 我们将路由模块分为静态路由和动态路由,静态路由是所有人都能访问的,而动态路由则是需要权限才能访问的
  • 在用户登录获取的用户信息中,携带有用户拥有的权限标识。我们可以将用户携带的权限标识和动态路由对应起来(比如对应到路由模块的 name),这样就可以从动态路由中筛选出用户所拥有的路由了
  • 静态路由和筛选出的路由合并得到用户完整路由。侧边栏根据完整路由渲染出菜单栏、路由守卫根据完整路由拦截或放行

二、拆分路由模块

拆分后的/src/router.index.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
import Vue from "vue";
import Router from "vue-router";

Vue.use(Router);
// 引入多个模块的规则
import approvalsRouter from "./modules/approvals";
import departmentsRouter from "./modules/departments";
import employeesRouter from "./modules/employees";
import permissionRouter from "./modules/permission";
import attendancesRouter from "./modules/attendances";
import salarysRouter from "./modules/salarys";
import settingRouter from "./modules/setting";
import socialRouter from "./modules/social";
import userRouter from "./modules/user";
/* Layout */
import Layout from "@/layout";
export const constantRoutes = [
{
path: "/login",
component: () => import("@/views/login/index"),
hidden: true,
},

{
path: "/404",
component: () => import("@/views/404"),
hidden: true,
},

{
path: "/",
component: Layout,
redirect: "/dashboard",
children: [
{
path: "dashboard",
name: "dashboard",
component: () => import("@/views/dashboard/index"),
meta: { title: "首页", icon: "dashboard" },
},
],
},
{
path: "/import",
component: Layout,
hidden: true, // 该组件不在菜单中显示
children: [
{
path: "",
component: () => import("@/views/import"),
meta: {
title: "导入",
},
},
],
},
userRouter,
// 404 page must be placed at the end !!!
// { path: '*', redirect: '/404', hidden: true }
];
// 动态路由的变量
export const asyncRoutes = [
approvalsRouter,
departmentsRouter,
employeesRouter,
permissionRouter,
attendancesRouter,
salarysRouter,
settingRouter,
socialRouter,
];
const createRouter = () =>
new Router({
mode: "history", // require service support
base: "/hrsaas/",
scrollBehavior: () => ({ y: 0 }),
routes: [...constantRoutes],
});

const router = createRouter();

// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
export function resetRouter() {
const newRouter = createRouter();
router.matcher = newRouter.matcher; // reset router
}

export default router;

三、在 vuex 中新建 permission 模块

src/store/modules/permission.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vuex的权限模块
import { constantRoutes } from "@/router";
// vuex中的permission模块用来存放当前的 静态路由 + 当前用户的 权限路由
const state = {
routes: constantRoutes, // 所有人默认拥有静态路由
};
const mutations = {
// newRoutes可以认为是 用户登录 通过权限所得到的动态路由的部分
setRoutes(state, newRoutes) {
// 下面这么写不对 不是语法不对 是业务不对
// state.routes = [...state.routes, ...newRoutes]
// 有一种情况 张三 登录 获取了动态路由 追加到路由上 李四登录 4个动态路由
// 应该是每次更新 都应该在静态路由的基础上进行追加
state.routes = [...constantRoutes, ...newRoutes];
},
};
const actions = {};
export default {
namespaced: true,
state,
mutations,
actions,
};

在 Vuex 管理模块中引入 permisson 模块

1
2
3
4
5
6
7
8
9
10
11
12
13
import permission from "./modules/permission";

const store = new Vuex.Store({
modules: {
// 子模块 $store.state.app.
// mapGetters([])
app,
settings,
user,
permission,
},
getters,
});

四、vuex 筛选路由权限

我们可以按照这张图,做进一步的操作

image-20200815184407204

后端返回的用户信息

image-20200815185230597

接下来, 在 vuex 的 permission 中写一个 action,通过路由模块的 name 与用户信息中的权限标识进行关联,最终筛选出用户所拥有的权限路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { asyncRoutes, constantRoutes } from '@/router'

const actions = {
// 筛选权限路由
// 第二个参数为当前用户的所拥有的菜单权限
// menus: ["settings","permissions"]
// asyncRoutes是所有的动态路由
// asyncRoutes [{path: 'setting',name: 'setting'},{}]
filterRoutes(context, menus) {
const routes = []
// 筛选出 动态路由中和menus中能够对上的路由
menus.forEach(key => {
// key就是标识
// asyncRoutes 找 有没有对象中的name属性等于 key的 如果找不到就没权限 如果找到了 要筛选出来
routes.push(...asyncRoutes.filter(item => item.name === key)) // 得到一个数组 有可能 有元素 也有可能是空数组
})
// 得到的routes是所有模块中满足权限要求的路由数组
// routes就是当前用户所拥有的 动态路由的权限
context.commit('setRoutes', routes) // 将动态路由提交给mutations
return routes // 这里为什么还要return state数据 是用来 显示左侧菜单用的 return 是给路由addRoutes用的
}

五、权限拦截处调用筛选权限 Action

在拦截的位置,调用关联 action, 获取新增 routes,并且addRoutes

注意,由于内容较多,我们可以将拦截这块从/src/router/index.js 中抽离出来,形成一个单独的模块,/src/permission.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
// 权限拦截在路由跳转  导航守卫

import router from "@/router";
import store from "@/store"; // 引入store实例 和组件中的this.$store是一回事
import nprogress from "nprogress"; // 引入进度条
import "nprogress/nprogress.css"; // 引入进度条样式
// 不需要导出 因为只需要让代码执行即可
// 前置守卫
// next是前置守卫必须必须必须执行的钩子 next必须执行 如果不执行 页面就死了
// next() 放过
// next(false) 跳转终止
// next(地址) 跳转到某个地址
const whiteList = ["/login", "/404"]; // 定义白名单
router.beforeEach(async (to, from, next) => {
nprogress.start(); // 开启进度条的意思
if (store.getters.token) {
// 只有有token的情况下 才能获取资料
// 如果有token
if (to.path === "/login") {
// 如果要访问的是 登录页
next("/"); // 跳到主页 // 有token 用处理吗?不用
} else {
// 只有放过的时候才去获取用户资料
// 是每次都获取吗
// 如果当前vuex中有用户的资料的id 表示 已经有资料了 不需要获取了 如果没有id才需要获取
if (!store.getters.userId) {
// 如果没有id才表示当前用户资料没有获取过
// async 函数所return的内容 用 await就可以接收到
const { roles } = await store.dispatch("user/getUserInfo");
// 如果说后续 需要根据用户资料获取数据的话 这里必须改成 同步
// 筛选用户的可用路由
// actions中函数 默认是Promise对象 调用这个对象 想要获取返回的值话 必须 加 await或者是then
// actions是做异步操作的
const routes = await store.dispatch(
"permission/filterRoutes",
roles.menus
);
// routes就是筛选得到的动态路由
// 动态路由 添加到 路由表中 默认的路由表 只有静态路由 没有动态路由
// addRoutes 必须 用 next(地址) 不能用next()
router.addRoutes(routes); // 添加动态路由到路由表 铺路
// 添加完动态路由之后
next(to.path); // 相当于跳到对应的地址 相当于多做一次跳转 为什么要多做一次跳转
// 进门了,但是进门之后我要去的地方的路还没有铺好,直接走,掉坑里,多做一次跳转,再从门外往里进一次,跳转之前 把路铺好,再次进来的时候,路就铺好了
} else {
next();
}
}
} else {
// 没有token的情况下
if (whiteList.indexOf(to.path) > -1) {
// 表示要去的地址在白名单
next();
} else {
next("/login");
}
}
nprogress.done(); // 解决手动切换地址时 进度条不关闭的问题
});
// 后置守卫
router.afterEach(() => {
nprogress.done(); // 关闭进度条
});

在**src/store/getters.js**配置导出 routes

1
2
3
4
5
6
7
8
9
10
const getters = {
sidebar: (state) => state.app.sidebar,
device: (state) => state.app.device,
token: (state) => state.user.token,
name: (state) => state.user.userInfo.username, // 建立用户名称的映射
userId: (state) => state.user.userInfo.userId, // 建立用户id的映射
companyId: (state) => state.user.userInfo.companyId, // 建立用户的公司Id映射
routes: (state) => state.permission.routes, // 导出当前的路由
};
export default getters;

六、在左侧菜单组件中, 引入 routes

/src/layout/components/Siderbar/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
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
<template>
<div :class="{ 'has-logo': showLogo }">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="variables.menuBg"
:text-color="variables.menuText"
:unique-opened="false"
:active-text-color="variables.menuActiveText"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item
v-for="route in routes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</div>
</template>

<script>
import { mapGetters } from "vuex";
import Logo from "./Logo";
import SidebarItem from "./SidebarItem";
import variables from "@/styles/variables.scss";

export default {
components: { SidebarItem, Logo },
computed: {
...mapGetters(["sidebar", "routes"]),
// 路由 addRoutes之后 不会响应式的更新的组件
// 默认静态路由 5条 + addRoutes(6条) = 11条
// routes() {
// return this.$router.options.routes
// },
activeMenu() {
const route = this.$route;
const { meta, path } = route;
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu;
}
return path;
},
showLogo() {
return this.$store.state.settings.sidebarLogo;
},
variables() {
return variables;
},
isCollapse() {
return !this.sidebar.opened;
},
},
};
</script>

七、登出时重置路由权限和 404 问题

现在,我们看似完成了访问权限的功能,实则不然,因为当我们登出操作之后,虽然看不到菜单,但是用户实际上可以访问页面,直接在地址栏输入地址就能访问

这是怎么回事?

这是因为我们前面在addRoutes的时候,一直都是在,登出的时候,我们并没有删,也没有重置,也就是说,我们之前加的路由在登出之后一直在,这怎么处理?

router/index.js文件中提供一个重置路由方法

1
2
3
4
5
// 重置路由
export function resetRouter() {
const newRouter = createRouter();
router.matcher = newRouter.matcher; // 重新设置路由的可匹配路径
}

这个方法可以将路由重新实例化,相当于换了一个新的路由,之前**加的路由**自然不存在了,只需要在登出的时候, 调用此方法即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 登出的action
lgout(context) {
// 删除token
context.commit('removeToken') // 不仅仅删除了vuex中的 还删除了缓存中的
// 删除用户资料
context.commit('removeUserInfo') // 删除用户信息
// 重置路由
resetRouter()
// 还有一步 vuex中的数据是不是还在
// 要清空permission模块下的state数据
// vuex中 user子模块 permission子模块
// 子模块调用子模块的action 默认情况下 子模块的context是子模块的
// 父模块 调用 子模块的action
context.commit('permission/setRoutes', [], { root: true })
// 子模块调用子模块的action 可以 将 commit的第三个参数 设置成 { root: true } 就表示当前的context不是子模块了 而是父模块
}

除此之外,我们发现在页面刷新的时候,本来应该拥有权限的页面出现了 404,这是因为 404 的匹配权限放在了静态路由,而动态路由在没有 addRoutes 之前,找不到对应的地址,就会显示 404,所以我们需要将 404 放置到动态路由的最后

src/permission.js

1
router.addRoutes([...routes, { path: "*", redirect: "/404", hidden: true }]); // 添加到路由表

八、完整代码

1、路由模块

/src/router/index.js

路由模块提供了静态路由和动态路由(从/src/router 中的其他路由子模块导入)

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
import Vue from "vue";
import Router from "vue-router";

Vue.use(Router);
// 引入多个模块的规则
import approvalsRouter from "./modules/approvals";
import departmentsRouter from "./modules/departments";
import employeesRouter from "./modules/employees";
import permissionRouter from "./modules/permission";
import attendancesRouter from "./modules/attendances";
import salarysRouter from "./modules/salarys";
import settingRouter from "./modules/setting";
import socialRouter from "./modules/social";
import userRouter from "./modules/user";
/* Layout */
import Layout from "@/layout";
export const constantRoutes = [
{
path: "/login",
component: () => import("@/views/login/index"),
hidden: true,
},

{
path: "/404",
component: () => import("@/views/404"),
hidden: true,
},

{
path: "/",
component: Layout,
redirect: "/dashboard",
children: [
{
path: "dashboard",
name: "dashboard",
component: () => import("@/views/dashboard/index"),
meta: { title: "首页", icon: "dashboard" },
},
],
},
{
path: "/import",
component: Layout,
hidden: true, // 该组件不在菜单中显示
children: [
{
path: "",
component: () => import("@/views/import"),
meta: {
title: "导入",
},
},
],
},
userRouter,
];
// 动态路由的变量
export const asyncRoutes = [
approvalsRouter,
departmentsRouter,
employeesRouter,
permissionRouter,
attendancesRouter,
salarysRouter,
settingRouter,
socialRouter,
];
const createRouter = () =>
new Router({
mode: "history", // require service support
base: "/hrsaas/",
scrollBehavior: () => ({ y: 0 }),
routes: [...constantRoutes], // 动态路由和静态路由的临时合并
});

const router = createRouter();

// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
export function resetRouter() {
const newRouter = createRouter();
router.matcher = newRouter.matcher; // reset router
}

export default router;

2、store 模块

/src/store/index.js

它将其他 store 子模块汇总到一起导出供使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Vue from "vue";
import Vuex from "vuex";
import getters from "./getters";
import app from "./modules/app";
import settings from "./modules/settings";
import user from "./modules/user";
import permission from "./modules/permission";
import tagsView from "./modules/tagsView";

Vue.use(Vuex);

const store = new Vuex.Store({
modules: {
app,
settings,
user,
permission,
tagsView,
},
getters,
});

export default store;

/src/store/getter.js

getter 模块提供了其他 store 模块的快捷访问方式

1
2
3
4
5
6
7
8
9
10
11
const getters = {
sidebar: (state) => state.app.sidebar,
device: (state) => state.app.device,
token: (state) => state.user.token, // 将user模块下的token作为快捷方式放出来
name: (state) => state.user.userInfo.username, // 将子模块中的对象中的名称开放出来
userId: (state) => state.user.userInfo.userId,
staffPhoto: (state) => state.user.userInfo.staffPhoto, // 头像
companyId: (state) => state.user.userInfo.companyId,
routes: (state) => state.permission.routes,
};
export default getters;

/src/store/modules/user.js

user 模块主要用来存储 token 和用户信息

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
import { getToken, setToken, removeToken, setTimeStamp } from "@/utils/auth";
import { login, getUserInfo, getUserDetailById } from "@/api/user";
import { resetRouter } from "@/router";
const state = {
token: getToken(), // 设置token为共享
userInfo: {}, // 这里为什么不写null ?
};
const mutations = {
// 设置token的mutations
setToken(state, token) {
state.token = token; // 只是设置了vuex中的数据
// 需要将vuex中的数据同步到缓存
setToken(token);
},
removeToken(state) {
state.token = null; // 设置vuex中的token为null
removeToken(); // 同步删除缓存中的token
},
setUserInfo(state, userInfo) {
state.userInfo = userInfo;
// state.userInfo = { ...userInfo } // 浅拷贝 如果要去改属性里面的某一个值 可以用浅拷贝的方式
},
removeUserInfo(state) {
state.userInfo = {}; // 重置为空对象
},
};
const actions = {
// 封装一个登录的action
// data认为是 { mobile,password }
// 只要用async标记了函数 那么这个函数本身就是promise对象
async login(context, data) {
// 调用登录接口
const result = await login(data);
// result就是token
context.commit("setToken", result);

setTimeStamp(); // 设置时间戳
},
// 获取用户资料
async getUserInfo(context) {
const result = await getUserInfo();
// 此时result里面已经有userId
const baseInfo = await getUserDetailById(result.userId); // 用户的基本信息
context.commit("setUserInfo", { ...result, ...baseInfo }); // 修改state中的用户资料
return result;
},
// 登出action
lgout({ commit }) {
// 删除token
commit("removeToken");
// 删除用户资料
commit("removeUserInfo");
// 重置路由
resetRouter();
// 清空路由模块下的路由信息
// Vuex子模块 调用子模块的mutation
commit("permission/setRoutes", [], { root: true }); // commit默认是提交的当前子模块的mutations
// 如果加上 root: true 就表示commit此时是根级的commit
// this.$store.commit('permission/setRoutes')
},
};
export default {
namespaced: true,
state,
mutations,
actions,
};

/src/store/modules/permission.js

permission 模块主要用来筛选出用户所拥有的路由

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
// 引入了静态路由
// 权限模块的目的 是提供当前用户所拥有的路由权限的数据 静态路由 + 当前用户的自身的动态路由
import { constantRoutes, asyncRoutes } from "@/router";
const state = {
// routes表示当前人的路由权限
routes: constantRoutes, // 当前所有人都默认拥有静态路由
};
const mutations = {
// newRoutes认为是用户登录之后获取的新路由
setRoutes(state, newRoutes) {
state.routes = [...constantRoutes, ...newRoutes]; // 静态路由 + 动态路由
// 需要得到newRoutes 才能调用mutations
},
};
const actions = {
// 筛选路由模块 menus认为是当前用户资料的menus标识 代表用户所拥有的的权限
filterRoutes(context, menus) {
// menus ['setting','approvals]
// asyncRoutes [{ name: '' }]
var routes = [];
menus.forEach((item) => {
// item是字符串 去asyncRoutes里面找有没有路由的name叫这个字符串
routes.push(...asyncRoutes.filter((route) => route.name === item));
});
// routes就是当前用户的筛选之后的动态路由
context.commit("setRoutes", routes);
// 最后加一行代码
return routes; // 这里返回是因为后面调用action的时候 要使用
},
};
export default {
namespaced: true,
state,
mutations,
actions,
};

3、权限拦截模块

src/permission.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
// 路由的拦截权限问题
import router from "@/router";
import store from "@/store";
import NProgress from "nprogress";
import "nprogress/nprogress.css"; // 引入进度条样式

// 前置守卫
const whileList = ["/login", "/404"];
router.beforeEach(async (to, from, next) => {
NProgress.start(); // 开启进度条
// next是一个必须执行的钩子 不执行就卡主了
if (store.getters.token) {
if (to.path === "/login") {
// next() 放行
// next(false) 终止
// next(地址) 跳到某个 地址
next("/"); // 跳到主页
} else {
// 要判断是不是已经获取过资料了
if (!store.getters.userId) {
// 如果id不存在 意味着当前没有用户资料 就要去获取用户资料
// vuex的action是一个promise
const { roles } = await store.dispatch("user/getUserInfo");
// 此时已经获取完资料
const routes = await store.dispatch(
"permission/filterRoutes",
roles.menus
);
// 此时得到的routes是当前用户的所拥有的的动态路由的权限
router.addRoutes([
...routes,
{ path: "*", redirect: "/404", hidden: true },
]); // 将当前动态路由加到当前路由规则上
// 加await的意思是 强制等待获取完用户资料之后 才去放行 就能保证 用户进到页面时候 有资料
// 添加完路由之后 不能用next() 要用next(to.path) 否则地址不能生效 这算是一个已知 的小缺陷
// 执行完addRoutes 必须执行next(to.path) 不能执行 next() 这是一个已知的问题缺陷
next(to.path); // 解决直接执行next()时的异常
} else {
next(); // 放行
}
}
} else {
if (whileList.indexOf(to.path) > -1) {
// 表示在白名单里面
next();
} else {
next("/login");
}
}
NProgress.done(); // 是为了解决手动输入地址时 进度条不关闭的问题
});

// 后置守卫
router.afterEach(() => {
NProgress.done();
});

4、侧边栏

/src/layout/components/Siderbar/index.vue

最终侧边栏组件根据 store 中的用户路由模块,渲染出用户可以访问的菜单

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
<template>
<div :class="{ 'has-logo': showLogo }">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="variables.menuBg"
:text-color="variables.menuText"
:unique-opened="false"
:active-text-color="variables.menuActiveText"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item
v-for="route in routes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</div>
</template>

<script>
import { mapGetters } from "vuex";
import Logo from "./Logo";
import SidebarItem from "./SidebarItem";
import variables from "@/styles/variables.scss";

export default {
components: { SidebarItem, Logo },
computed: {
...mapGetters(["sidebar", "routes"]),
// 路由 addRoutes之后 不会响应式的更新的组件
// 默认静态路由 5条 + addRoutes(6条) = 11条
// routes() {
// return this.$router.options.routes
// },
activeMenu() {
const route = this.$route;
const { meta, path } = route;
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu;
}
return path;
},
showLogo() {
return this.$store.state.settings.sidebarLogo;
},
variables() {
return variables;
},
isCollapse() {
return !this.sidebar.opened;
},
},
};
</script>

axios二次封装

axios 作为网络请求的第三方工具, 可以进行请求和响应的拦截

一般我们可以在请求拦截器中处理 token 的统一注入问题、token 超时拦截问题。响应拦截器则主要处理数据结构和数据异常

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
import axios from "axios";
import { Message } from "element-ui";
import { getTimeStamp } from "@/utils/auth";
import store from "@/store";
import router from "@/router";
const TimeOut = 5400; // 定义超时时间
const service = axios.create({
// 设置基础地址
// 环境变量 npm run dev /api /生产环境 npm run build /prod-api
baseURL: process.env.VUE_APP_BASE_API,
timeout: 5000, // 认为只要超过5秒钟不响应 就超时
});
// 请求拦截器
service.interceptors.request.use(
async (config) => {
// 请求接口 config是请求配置
// 取token
if (store.getters.token) {
// 只要有token 就要检查token时效性
if (CheckIsTimeOut()) {
// 继续?
await store.dispatch("user/lgout");
router.push("/login"); // 跳到登录
return Promise.reject(new Error("您的token已经失效"));
}
// 如果存在token
config.headers.Authorization = `Bearer ${store.getters.token}`;
// return config
}
// 这里一定要注意
// 一定要return config
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
// 成功执行
// axios默认加了一层data的包裹
const { success, message, data } = response.data;
if (success) {
// 此时认为业务执行成功了
return data; // 返回用户所需要的数据
} else {
// 当业务失败的时候
Message.error(message); // 提示消息
return Promise.reject(new Error(message));
}
},
async (error) => {
// error 有response对象 config
if (
error.response &&
error.response.data &&
error.response.data.code === 10002
) {
// 后端告诉前端token超时了
await store.dispatch("user/lgout"); // 调用登出action
router.push("/login"); // 跳到登录页
}
// 失败
// Message等同于 this.$message
Message.error(error.message); // 提示错误
// reject
return Promise.reject(error); // 传入一个错误的对象 就认为promise执行链 进入了catch
}
);
// 检查token是否过期
function CheckIsTimeOut() {
// 当前时间 - 存储的时间戳 > 时效性 false tr
return (Date.now() - getTimeStamp()) / 1000 > TimeOut;
}
export default service;

组件、自定义指令和自定义过滤器的注册

一、组件的注册

1、全局注册

1
2
import DemoComponent from "@/components/Democomponent";
Vue.component("my-component-name", DemoComponent);

第一个参数是组件名,第二个参数是导入的组件对象实例

定义组件名有两种方式

第一种使用短横线分隔命名。字母全小写,就像上面的例子一样。但是使用这种方式命名,在引用这个自定义元素时也必须使用使用短横线分隔命名,字母全小写的方式,就像这样 <my-component-name>

第二种使用大驼峰命名 (首字母大写命名) 。这种方式,在引用这个自定义元素时两种命名法都可以使用。也就是说 <my-component-name><MyComponentName> 都是可接受的。注意,尽管如此,直接在 DOM (即非字符串的模板) 中使用时只有第一种方式是有效的。

给予组件的名字可能依赖于打算拿它来做什么。当直接在 DOM 中使用一个组件 (而不是在字符串模板或单文件组件) 的时候,vue 官方强烈推荐遵循 W3C 规范中的自定义组件名 (字母全小写且必须包含一个连字符)。这会帮助你避免和当前以及未来的 HTML 元素相冲突。

2、局部注册

局部注册只需在组件中配置一个 components 配置项即可

1
2
import DemoComponent from './Democomponent' export default { components: {
DemoComponent } }

3、批量全局注册

如果有大量的通用组件需要全局注册,那么我们的 main.js 文件很可能会变成这样

1
2
3
4
5
6
7
8
9
10
import DemoComponentA from "@/components/DemocomponentA";
import DemoComponentB from "@/components/DemocomponentB";
import DemoComponentC from "@/components/DemocomponentC";
import DemoComponentD from "@/components/DemocomponentD";
import DemoComponentE from "@/components/DemocomponentE";
Vue.component("my-component-nameA", DemoComponentA);
Vue.component("my-component-nameB", DemoComponentB);
Vue.component("my-component-nameC", DemoComponentC);
Vue.component("my-component-nameD", DemoComponentD);
Vue.component("my-component-nameE", DemoComponentE);

为了保持 main.js 文件的简洁,我们可以将需要全局注册的通用组件统统放在一个单独的文件夹中,/src/components,在其中的 index.js 文件中将这些组件统一导入注册,再暴露出去交给 main.js 给 Vue 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import PageTools from "./PageTools";
import UploadExcel from "./UploadExcel";
import ImageUpload from "./ImageUpload";
import ScreenFull from "./ScreenFull";
import ThemePicker from "./ThemePicker";
import Lang from "./Lang";
import TagsView from "./TagsView";

export default {
install(Vue) {
Vue.component("PageTools", PageTools);
Vue.component("UploadExcel", UploadExcel);
Vue.component("ImageUpload", ImageUpload);
Vue.component("ScreenFull", ScreenFull);
Vue.component("ThemePicker", ThemePicker);
Vue.component("Lang", Lang);
Vue.component("TagsView", TagsView);
},
};
1
2
import Components from "@/components";
Vue.use(Components); // 注册自己的自定义组件

二、自定义指令

1、全局注册

1
2
3
4
5
6
7
8
// 注册一个全局自定义指令 `v-focus`
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus()
}
})

第一个参数是指令名,注意这里不需要带 v-

第二个参数是一个配置项。可以提供如下几个钩子函数 (均为可选):

  • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  • inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
  • update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 (详细的钩子函数参数见下)。
  • componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。
  • unbind:只调用一次,指令与元素解绑时调用。

指令钩子函数会被传入以下参数:

  • el:指令所绑定的元素,可以用来直接操作 DOM。
  • binding:一个对象,包含以下 property:
    • name:指令名,不包括 v- 前缀。
    • value:指令的绑定值,例如:v-my-directive="1 + 1" 中,绑定值为 2
    • oldValue:指令绑定的前一个值,仅在 updatecomponentUpdated 钩子中可用。无论值是否改变都可用。
    • expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式为 "1 + 1"
    • arg:传给指令的参数,可选。例如 v-my-directive:foo 中,参数为 "foo"
    • modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }
  • vnode:Vue 编译生成的虚拟节点。
  • oldVnode:上一个虚拟节点,仅在 updatecomponentUpdated 钩子中可用。

注意,除了 el 之外,其它参数都应该是只读的,切勿进行修改。如果需要在钩子之间共享数据,建议通过元素的 dataset 来进行。

2、局部注册

同样局部注册,只需配置组件中的一个 directives 配置项即可

1
2
export default { directives: { focus: { // 指令的定义 inserted: function (el) {
el.focus() } } } }

3、批量全局注册

同样如果有很多自定义指令需要全局注册,我们可以从 main.js 中分离出来单独处理。新建 src/directives/index.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
// 负责管理所有的自定义指令
// 只负责导出指令对象
// 变量名称就是指令名称
export const imageerror = {
// 指令内容
// 指令作用在 图片上的 dom是图片对象
// inserted 在Vue3中也改名 => mounted
inserted(dom, options) {
// inserted执行的之后 此时 并没有对 src赋值
// 图片有地址 但是地址加载图片失败的时候 会执行一个函数 onerror
dom.src = dom.src || options.value;
dom.onerror = function () {
// 监听onerror事件
// options.value就是指令传过来的值
dom.src = options.value; // 当图片异常的时候 接收指令传过来的值 让这个值作为头像的备选
};
// 只有src有值 并且加载失败才会触发onerror
},
// 此钩子会在给image赋值之后执行
// 这个钩子函数在Vue3中改名了 => updated
componentUpdated(dom, options) {
dom.src = dom.src || options.value;
},
};

再在 main.js 中批量注册即可

1
2
3
4
5
6
import * as directives from "@/directives";
// directives是所有指令的一个集合
Object.keys(directives).forEach((key) => {
// key就是指令名称
Vue.directive(key, directives[key]);
});

三、过滤器

1、全局注册

1
2
3
4
5
Vue.filter("capitalize", function (value) {
if (!value) return "";
value = value.toString();
return value.charAt(0).toUpperCase() + value.slice(1);
});

第一个参数是过滤器的名称,第二个参数是一个函数,通常是一些处理文本格式化的操作。

2、局部注册

同样局部注册,只需配置组件中的一个 filters 配置项即可

1
2
3
export default { filters: { capitalize: function (value) { if (!value) return ''
value = value.toString() return value.charAt(0).toUpperCase() + value.slice(1) }
} }

使用时放在 JavaScript 表达式的尾部,由“管道”符号指示即可

3、全局批量注册

同样如果有很多过滤器需要全局注册,我们可以从 main.js 中分离出来单独处理。新建 src/filters/index.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
function pluralize(time, label) {
if (time === 1) {
return time + label;
}
return time + label + "s";
}

export function timeAgo(time) {
const between = Date.now() / 1000 - Number(time);
if (between < 3600) {
return pluralize(~~(between / 60), " minute");
} else if (between < 86400) {
return pluralize(~~(between / 3600), " hour");
} else {
return pluralize(~~(between / 86400), " day");
}
}
export function numberFormatter(num, digits) {
const si = [
{ value: 1e18, symbol: "E" },
{ value: 1e15, symbol: "P" },
{ value: 1e12, symbol: "T" },
{ value: 1e9, symbol: "G" },
{ value: 1e6, symbol: "M" },
{ value: 1e3, symbol: "k" },
];
for (let i = 0; i < si.length; i++) {
if (num >= si[i].value) {
return (
(num / si[i].value)
.toFixed(digits)
.replace(/\.0+$|(\.[0-9]*[1-9])0+$/, "$1") + si[i].symbol
);
}
}
return num.toString();
}

在 main.js 中批量注册

1
2
3
4
import * as filters from "@/filters";
Object.keys(filters).forEach((key) => {
Vue.filter(key, filters[key]);
});

小兔鲜电商项目练手

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/

配合腾讯云Cos封装一个上传图片组件

一、需求分析

该组件需要满足以下要求:

  1. 可以显示传入的图片地址
  2. 可以删除传入的图片地址
  3. 可以上传图片到云服务器
  4. 上传到腾讯云之后,可以返回图片地址,显示
  5. 上传成功之后,可以回调成功函数

image-20200806174643390

从上图中,我们可以看到,实际上是有两种场景的,本地场景和已经上传的场景

二、安装 JavaScript SDK

1
$ npm i cos-js-sdk-v5 --save

三、实例化上传 sdk

名称 描述
SecretId 开发者拥有的项目身份识别 ID,用以身份认证,可在 API 密钥管理 页面获取
SecretKey 开发者拥有的项目身份密钥,可在 API 密钥管理 页面获取

注意,为了方便,这里直接将参数放置在前端代码中存储,但是腾讯云本身是不建议这么做的,因为**敏感信息**放在前端很容易被捕获。

正确的做法应该是,通过网站调用接口换取敏感信息

相关文档

1
2
3
4
var cos = new COS({
SecretId: "COS_SECRETID", // 身份识别 ID
SecretKey: "COS_SECRETKEY", // 身份密钥
});

四、准备组件并注册

新建上传图片组件 src/components/ImageUpload/index.vue

上传组件,我们可以沿用 element 的 el-upload 组件,并且采用照片墙的模式 list-type="picture-card"

放置 el-upload 组件

1
2
3
4
5
<template>
<el-upload list-type="picture-card">
<i class="el-icon-plus" />
</el-upload>
</template>

全局注册组件

1
2
3
4
5
6
import ImageUpload from "./ImageUpload";
export default {
install(Vue) {
Vue.component("ImageUpload", ImageUpload); // 注册导入上传组件
},
};

五、上传动作调用上传腾讯云

腾讯云文档地址

1、上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 进行上传操作
upload(params) {
// console.log(params.file)
if (params.file) {
// 执行上传操作
cos.putObject({
Bucket: 'shuiruohanyu-106-1302806742', // 存储桶
Region: 'ap-beijing', // 地域
Key: params.file.name, // 文件名
Body: params.file, // 要上传的文件对象
StorageClass: 'STANDARD' // 上传的模式类型 直接默认 标准模式即可
// 上传到腾讯云 =》 哪个存储桶 哪个地域的存储桶 文件 格式 名称 回调
}, function(err, data) {
// data返回数据之后 应该如何处理
console.log(err || data)
})
}
}

2、上传成功后处理返回数据

确定要上传记录 id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
beforeUpload(file) {
// 先检查文件类型
const types = ['image/jpeg', 'image/gif', 'image/bmp', 'image/png']
if (!types.some(item => item === file.type)) {
// 如果不存在
this.$message.error('上传图片只能是 JPG、GIF、BMP、PNG 格式!')
return false // 上传终止
}
// 检查文件大小 5M 1M = 1024KB 1KB = 1024B
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
// 超过了限制的文件大小
this.$message.error('上传的图片大小不能大于5M')
return false
}
// 已经确定当前上传的就是当前的这个file了
this.currentFileUid = file.uid
return true // 最后一定要return true
},

处理返回的数据

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
// 进行上传操作
upload(params) {
// console.log(params.file)
if (params.file) {
// 执行上传操作
cos.putObject({
Bucket: 'shuiruohanyu-106-1302806742', // 存储桶
Region: 'ap-beijing', // 地域
Key: params.file.name, // 文件名
Body: params.file, // 要上传的文件对象
StorageClass: 'STANDARD' // 上传的模式类型 直接默认 标准模式即可
// 上传到腾讯云 =》 哪个存储桶 哪个地域的存储桶 文件 格式 名称 回调
}, (err, data) => {
// data返回数据之后 应该如何处理
console.log(err || data)
// data中有一个statusCode === 200 的时候说明上传成功
if (!err && data.statusCode === 200) {
// 此时说明文件上传成功 要获取成功的返回地址
// fileList才能显示到上传组件上 此时我们要将fileList中的数据的url地址变成 现在上传成功的地址
// 目前虽然是一张图片 但是请注意 我们的fileList是一个数组
// 需要知道当前上传成功的是哪一张图片
this.fileList = this.fileList.map(item => {
// 去找谁的uid等于刚刚记录下来的id
if (item.uid === this.currentFileUid) {
// 将成功的地址赋值给原来的url属性
return { url: 'http://' + data.Location, upload: true }
// upload 为true 表示这张图片已经上传完毕 这个属性要为我们后期应用的时候做标记
// 保存 => 图片有大有小 => 上传速度有快又慢 =>要根据有没有upload这个标记来决定是否去保存
}
return item
})
// 将上传成功的地址 回写到了fileList中 fileList变化 =》 upload组件 就会根据fileList的变化而去渲染视图
}
})
}
}

我们在 fileList 中设置了属性为 upload 为 true 的属性,表示该图片已经上传成功了,如果 fileList 还有 upload 不为 true 的数据,那就表示该图片还没有上传完毕

六、上传的进度条显示

放置进度条

1
<el-progress v-if="showPercent" style="width: 180px" :percentage="percent" />

通过腾讯云 sdk 监听上传进度

1
2
3
4
5
6
7
8
9
10
11
12
cos.putObject({
// 配置
Bucket: 'laogao-1302806742', // 存储桶名称
Region: 'ap-guangzhou', // 存储桶地域
Key: params.file.name, // 文件名作为key
StorageClass: 'STANDARD', // 此类写死
Body: params.file, // 将本地的文件赋值给腾讯云配置
// 进度条
onProgress: (params) => {
this.percent = params.percent * 100
}
}

七、完整代码

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
<template>
<!-- 上传组件 -->
<div>
<!-- :class="{ class名称:布尔值 }" -->
<!-- el-upload之所以能够显示图片 是因为 fileList中有值 -->
<el-upload
list-type="picture-card"
:file-list="fileList"
:on-preview="preview"
:on-remove="handleRemove"
:on-change="changeFile"
:before-upload="beforeUpload"
:http-request="upload"
action="#"
:class="{ disabled: fileComputed }"
>
<i class="el-icon-plus" />
</el-upload>
<el-progress
v-if="showPercent"
:percentage="percent"
style="width: 180px"
/>

<!-- 预览图片 -->
<el-dialog :visible.sync="showDialog" title="图片预览">
<img :src="imgUrl" alt="" style="width:100%" />
</el-dialog>
</div>
</template>

<script>
import COS from "cos-js-sdk-v5";
const cos = new COS({
SecretId: "", // 身份识别 ID
SecretKey: "", // 身份密钥
});
export default {
props: {
limit: {
type: Number,
default: 1,
},
},
data() {
return {
showPercent: false, // 控制进度条的显示和隐藏
percent: 0, // 当前的进度
showDialog: false, // 默认隐藏
imgUrl: "",
fileList: [],
};
},
computed: {
// 只要该计算属性为true 就表示 我们需要隐藏上传按钮
fileComputed() {
return this.fileList.length === this.limit;
},
},
methods: {
preview(file) {
this.imgUrl = file.url;
this.showDialog = true;
},
// file就是要删除的file
handleRemove(file) {
// 根据file中uid将当前的fileList中的数据进行移除
this.fileList = this.fileList.filter((item) => item.uid !== file.uid);
// filter方法会得到一个新的数组
},
// 不能够一味 的进行push 因为该函数会被多次调用 fileList其实就是当前最新的文件列表
changeFile(file, fileList) {
// this.fileList = [...fileList]
this.fileList = fileList.map((item) => item);
},
// 上传之前检查
beforeUpload(file) {
// console.log(file)
// 要开始做文件上传的检查了
// 文件类型 文件大小
const types = ["image/jpeg", "image/gif", "image/bmp", "image/png"];
if (!types.includes(file.type)) {
this.$message.error("上传图片只能是 JPG、GIF、BMP、PNG 格式!");
return false;
}
// 文件大小
const maxSize = 25 * 1024 * 1024;
if (maxSize < file.size) {
this.$message.error("图片最大的大小为5M");
return false;
}
return true; // 要返回true
},
// 上传到腾讯云
// 自定义上传动作
upload(params) {
// params中的file就是要上传的图片文件
// console.log(params.file)
if (params.file) {
this.showPercent = true; // 显示进度条
// 上传对象到腾讯云
cos.putObject(
{
Bucket: "shuiruohanyu-1302806742" /* 每个人的存储桶名称 */,
Region: "ap-nanjing" /* 存储桶所在地域,必须字段 */,
Key: params.file.name /* 文件名称 */,
StorageClass: "STANDARD", // 固定值
Body: params.file, // 上传文件对象
onProgress: (progressData) => {
// console.log(progressData.percent * 100)
this.percent = progressData.percent * 100;
},
},
(err, data) => {
console.log(err);
if (data.statusCode === 200 && data.Location) {
// 认为此时上传成功
// 需要知道当前的这个地址是谁的url地址
// params.file.uid => 当前上传文件的标识 如果找到了一一样的uid 就表示他们是一张图片
console.log(this.fileList);
// 这样相当于将原来的旧本地地址换成了新地址
this.fileList = this.fileList.map((item) => {
// 将本地的地址替换成线上已经放在腾讯云之后的地址
if (item.uid === params.file.uid) {
// upload 为true的意思是 表示这张图片 已经上传过了 已经不是本地图片了
return { url: "http://" + data.Location, upload: true };
}
return item;
});
this.showPercent = false; // 关闭进度条
this.percent = 0; // 将进度归0
}
}
);
}
},
},
};
</script>

<style>
.disabled .el-upload--picture-card {
display: none;
}
</style>

*通过上面的代码,我们会发现,我们把上传之后的图片信息都给了**fileList数据,那么在应用时,就可以直接获取该实例的fileList数据即可***

八、使用

1
2
3
4
5
6
7
8
<el-row class="inline-info">
<el-col :span="12">
<el-form-item label="员工头像">
<!-- 放置上传图片 -->
<image-upload ref="staffPhoto" />
</el-form-item>
</el-col>
</el-row>

1、读取时赋值

1
2
3
4
5
6
7
async getUserDetailById() {
this.userInfo = await getUserDetailById(this.userId)
if (this.userInfo.staffPhoto) {
// 这里我们赋值,同时需要给赋值的地址一个标记 upload: true
this.$refs.staffPhoto.fileList = [{ url: this.userInfo.staffPhoto, upload: true }]
}
},

2、更新图片时获取图片内容

1
2
3
4
5
6
7
8
9
10
11
12
async  saveUser() {
// 去读取 员工上传的头像
const fileList = this.$refs.staffPhoto.fileList // 读取上传组件的数据
if (fileList.some(item => !item.upload)) {
// 如果此时去找 upload为false的图片 找到了说明 有图片还没有上传完成
this.$message.warning('您当前还有图片没有上传完成!')
return
}
// 通过合并 得到一个新对象
await saveUserDetailById({ ...this.userInfo, staffPhoto: fileList && fileList.length ? fileList[0].url : '' })
this.$message.success('保存基本信息成功')
}

两种方式将平铺数据转化为树形结构

有时候我们需要用到树形结构的数据便于我们展示像组织架构、地区这种有层级结构的信息。那么我们就需要将普通平铺的数据转化为树形结构的数据。主要是使用递归算法。

观察这种数据不难发现,每条数据一般至少包含 id 和 pid 两个属性,id 是每条数据独一无二的身份标识,pid 则表示与其他数据的从属关系。如果当前数据的 pid 等于另外一条数据的 id,那当前数据就是另外一条数据的子数据。

image-20200721010931214

封装一个工具方法,**src/utils/index.js**

第一种:使用递归算法遍历需要处理的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/** *
*
* 将列表型的数据转化成树形数据 => 递归算法 => 自身调用自身 => 递归的关键在于要有一个入口和一个出口,入口 * 表示递归开始,出口则标识递归的结束。没有出口会造成死循环
* 遍历树形 有一个重点 要先找一个头儿
* ***/
export function tranListToTreeData(list, rootValue) {
var arr = [];
list.forEach((item) => {
if (item.pid === rootValue) {
// 找到之后 就要去找 item 下面有没有子节点
const children = tranListToTreeData(list, item.id);
if (children.length) {
// 如果children的长度大于0 说明找到了子节点
item.children = children;
}
arr.push(item); // 将内容加入到数组中
}
});
return arr;
}

第二种:上面的递归方式,每一次都需要遍历整个list,对于量特别大的数据来说,性能非常差,很大可能会造成卡死的情况。因此对于数据量比较大的情况(成千上万条数据),比如大型国企的人员组织架构,可以使用哈希表的方式实现树形数据的转化

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
// 数据量大时使用递归转化数据会有性能问题,可以使用哈希表优化
export function listToTree(list, rootValue) {
let map = {},
node,
roots = [],
i

for (i = 0; i < list.length; i += 1) {
map[list[i].org_id] = i // 初始化哈希表
list[i].children = [] // 初始化 children
}

for (i = 0; i < list.length; i += 1) {
node = list[i]
if (node.parent_org_id !== rootValue) {
// 如果存在父级
if (map[node.parent_org_id] !== undefined) {
list[map[node.parent_org_id]].children.push(node)
}
// else {
// console.error(
// `Parent ID ${node.parent_org_id} not found for node ID ${node.org_id}`
// )
// }
} else {
// 如果是顶级节点
roots.push(node)
}
}
return roots
}

使用自定义指令解决图片异常情况

项目中有时图片显示异常,我们可以使用自定义指令来指定默认图片

自定义指令可以采用统一的文件来管理 src/directives/index.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
// 负责管理所有的自定义指令
// 只负责导出指令对象
// 变量名称就是指令名称
export const imageerror = {
// 指令内容
// 指令作用在 图片上的 dom是图片对象
// inserted 在Vue3中也改名 => mounted
inserted(dom, options) {
// inserted执行的之后 此时 并没有对 src赋值
// 图片有地址 但是地址加载图片失败的时候 会执行一个函数 onerror
dom.src = dom.src || options.value;
dom.onerror = function () {
// 监听onerror事件
// options.value就是指令传过来的值
dom.src = options.value; // 当图片异常的时候 接收指令传过来的值 让这个值作为头像的备选
};
// 只有src有值 并且加载失败才会触发onerror
},
// 此钩子会在给image赋值之后执行
// 这个钩子函数在Vue3中改名了 => updated
componentUpdated(dom, options) {
dom.src = dom.src || options.value;
},
};

在 main.js 完成自定义指令全局注册

如果自定义的指令较多,可以采用批量注册的方式

1
2
3
4
5
6
7
import * as directives from "@/directives";
// 注册自定义指令
// 遍历所有的导出的指令对象 完成自定义全局注册
Object.keys(directives).forEach((key) => {
// 注册自定义指令
Vue.directive(key, directives[key]);
});

指令注册成功后,可以在组件中直接使用了

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<img v-imageerror="defaultImg" :src="staffPhoto" class="user-avatar" />
</template>
<script>
export default {
data() {
return {
defaultImg: require("@/assets/common/head.jpg"),
};
},
};
</script>

token失效的处理

一、主动处理

主动处理主要是前端在每次发起请求时都主动去检查一下 token 是否过期。流程图如下:

具体业务图如下

image-20200716231205153

流程图转化代码

流程图转化代码 src/utils/auth.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
import Cookies from "js-cookie";

const TokenKey = "hr-saas-111-token";

const timeKey = "hr-sass-time-key"; // 用来作为时间戳存储的key

export function getToken() {
return Cookies.get(TokenKey);
}

export function setToken(token) {
return Cookies.set(TokenKey, token);
}

export function removeToken() {
return Cookies.remove(TokenKey);
}

export function setTimeStamp() {
// 设置当前最新的时间戳
// Date.now() new Date.getTime()
Cookies.set(timeKey, Date.now());
}

export function getTimeStamp() {
return Cookies.get(timeKey);
}

src/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
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
import axios from "axios";
import store from "@/store";
import router from "@/router";
import { Message } from "element-ui";
import { getTimeStamp } from "@/utils/auth";
const TimeOut = 3600; // 定义超时时间

const service = axios.create({
// 当执行 npm run dev => .evn.development => /api => 跨域代理
baseURL: process.env.VUE_APP_BASE_API, // npm run dev => /api npm run build => /prod-api
timeout: 5000, // 设置超时时间
});
// 请求拦截器
service.interceptors.request.use(
(config) => {
// config 是请求的配置信息
// 注入token
if (store.getters.token) {
// 只有在有token的情况下 才有必要去检查时间戳是否超时
if (IsCheckTimeOut()) {
// 如果它为true表示 过期了
// token没用了 因为超时了
store.dispatch("user/logout"); // 登出操作
// 跳转到登录页
router.push("/login");
return Promise.reject(new Error("token超时了"));
}
config.headers["Authorization"] = `Bearer ${store.getters.token}`;
}
return config; // 必须要返回的
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
// axios默认加了一层data
const { success, message, data } = response.data;
// 要根据success的成功与否决定下面的操作
if (success) {
return data;
} else {
// 业务已经错误了 还能进then ? 不能 ! 应该进catch
Message.error(message); // 提示错误消息
return Promise.reject(new Error(message));
}
},
(error) => {
Message.error(error.message); // 提示错误信息
return Promise.reject(error); // 返回执行错误 让当前的执行链跳出成功 直接进入 catch
}
);
// 是否超时
// 超时逻辑 (当前时间 - 缓存中的时间) 是否大于 时间差
function IsCheckTimeOut() {
var currentTime = Date.now(); // 当前时间戳
var timeStamp = getTimeStamp(); // 缓存时间戳
return (currentTime - timeStamp) / 1000 > TimeOut;
}
export default service;

在登录的时候,如果登录成功,我们应该设置时间戳

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义login action  也需要参数 调用action时 传递过来的参数
// async 标记的函数其实就是一个异步函数 -> 本质是还是 一个promise
async login(context, data) {
// 经过响应拦截器的处理之后 这里的result实际上就是 token
const result = await login(data) // 实际上就是一个promise result就是执行的结果
// axios默认给数据加了一层data
// 表示登录接口调用成功 也就是意味着你的用户名和密码是正确的
// 现在有用户token
// actions 修改state 必须通过mutations
context.commit('setToken', result)
// 写入时间戳
setTimeStamp() // 将当前的最新时间写入缓存
}

二、被动处理

被动处理就是后端告诉我们超时了,我们按照后端接口做出反应即可。如果后端接口没有做处理,主动介入就是一种简单的方式

token 超时的错误码是**10002**

代码实现 src/utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(error) => {
// error 信息 里面 response的对象
if (
error.response &&
error.response.data &&
error.response.data.code === 10002
) {
// 当等于10002的时候 表示 后端告诉我token超时了
store.dispatch("user/logout"); // 登出action 删除token
router.push("/login");
} else {
Message.error(error.message); // 提示错误信息
}
return Promise.reject(error);
};

无论是主动介入还是被动处理,这些操作都是为了更好地处理 token,减少错误异常的可能性

AJAX

一、Ajax 基本介绍

Ajax 的全称是 Asynchronous Javascript And XML(异步 JavaScript 和 XML)

通过 AJAX 可以在浏览器中向服务器发送异步请求,进行数据交互

最大的优势:无刷新获取数据

AJAX 的优点

  • 可以无需刷新页面而与服务器端进行通信(实现局部刷新)
  • 允许根据用户事件来更新部分页面内容

AJAX 的缺点

  • 没有浏览历史,不能回退
  • 存在跨域问题(同源)
  • SEO 不友好

Ajax 的典型应用场景

用户名检测:注册用户时,通过 ajax 的形式,动态检测用户名是否被占用

image-20230504093210686

搜索提示:当输入搜索关键字时,通过 ajax 的形式,动态加载搜索提示列 表image-20230504093251279

数据分页显示:当点击页码值的时候,通过 ajax 的形式,根据页码值动态 刷新表格的数据image-20230504093331555

数据的增删改查:数据的添加、删除、修改、查询操作,都需要通过 ajax 的 形式,来实现数据的交互image-20230504093356633

1、XML

XML 是一种可扩展标记语,被设计用来传输和存储数据。XML 和 HTML 类似,不同的是 HTML 中都是预定义标签,而 XML 中没有预定义标签, 全都是自定义标签,用来表示一些数据。

XML 格式臃肿,和数据无关的代码多,体积大,传输效率低。在 Javascript 中解析 XML 比较麻烦。现已被 JSON 代替

2、JSON

JSON 的英文全称是 JavaScript Object Notation,即“JavaScript 对象。表示法”。简单来讲,JSON 就是 Javascript 对象和数组的字符串表示法,它使用文本表示一个 JS 对象或数组的信息,因此,JSON 的本质是字符

作用:JSON 是一种轻量级的文本数据交换格式,在作用上类似于 XML,专 门用于存储和传输数据,但是 JSON 比 XML 更小、更快、更易解析 现状:JSON 是在 2001 年开始被推广和使用的数据格式,到现今为止,JSON 已经成为了主流的数据交换格式

JSON 的两种结构

JSON 就是用字符串来表示 Javascript 的对象和数组。所以,JSON 中包含 对象和数组两种结构,通过这两种结构的相互嵌套,可以表示各种复杂的数据结构

对象结构:对象结构在 JSON 中表示为 { } 括起来的内容。数据结构为 { key: value, key: value, … } 的键值对结构。其中,key 必须是使用英文 的双引号包裹的字符串,value 的数据类型可以是数字、字符串、布尔值、null、 数组、对象 6 种类型

image-20230504092616262

数组结构:数组结构在 JSON 中表示为 [ ] 括起来的内容。数据结构为 [ “java”, “javascript”, 30, true … ] 。数组中数据的类型同样可以是数字、 字符串、布尔值、null、数组、对象 6 种类型

image-20230504092646601

JSON 语法注意事项

  • 属性名必须使用双引号包裹
  • 字符串类型的值必须使用双引号包裹
  • JSON 中不允许使用单引号表示字符串
  • JSON 中不能写注释
  • JSON 的最外层必须是对象或数组格式
  • 不能使用 undefined 或函数作为 JSON 的值

JSON 和 JS 对象的关系

JSON 是 JS 对象的字符串表示法,它使用文本表示一个 JS 对象的信息, 本质是一个字符串

JSON 和 JS 对象的互转

可以使用 JSON.parse()和 JSON.stringify()方法

二、AJAX 的使用

核心对象

XMLHttpRequest,AJAX 的所有操作都是通过该对象进行的

使用步骤

  1. 创建 XMLHttpRequest 对象
1
const xhr = new XMLHttpRequest();

2.设置请求信息

1
2
3
xhr.open(method, url);
//可以设置请求头,一般不设置
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
  1. 发送请求
1
xhr.send(body); //get 请求不传 body 参数,只有 post 请求

4.接收响应

1
2
3
4
5
6
7
8
//xhr.responseXML 接收 xml 格式的响应数据
//xhr.responseText 接收文本格式的响应数据
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
const text = xhr.responseText;
console.log(text);
}
};

xhr.readyState 可以用来查看请求当前的状态

0: 表示 XMLHttpRequest 实例已经生成,但是 open()方法还没有被调用。

1: 表示 send()方法还没有被调用,仍然可以使用 setRequestHeader(),设定 HTTP 请求的头信息

2: 表示 send()方法已经执行,并且头信息和状态码已经收到。

3: 表示正在接收服务器传来的 body

4: 表示服务器数据已经完全接收,或者本次接收已经失败了

使用 xhr 对象发起带参数的 GET 请求时,只需在调用 xhr.open 期间,为 URL 地址指定参数即可:

1
xhr.open('GET','http://congtf.top/tags?id=1&title=技术')

这种在 URL 地址后面拼接的参数,叫做查询字符串

GET 请求携带参数的本质

无论使用 $.ajax(),还是使用 $.get(),又或者直接使用 xhr 对象发起 GET 请求,当需要携带参数的时候,本质上,都是直接将参数以查询字符串的形 式,追加到 URL 地址的后面,发送到服务器的

1
2
3
$.get(url, { name: "zs", age: 20 }, function () {});
// 等价于
$.get(url + "?name=zs&age=20", function () {});

三、XMLHttpRequest Level2 的新特性

旧版 XMLHttpRequest 的缺点:

  • 只支持文本数据的传输,无法用来读取和上传文件
  • 传送和接收数据时,没有进度信息,只能提示有没有完成

XMLHttpRequest Level2 的新功能

  • 可以设置 HTTP 请求的时限
  • 可以使用 FormData
  • 可以上传文件
  • 可以获得数据传输的进度信息

1、设置 HTTP 请求时限

有时,Ajax 操作很耗时,而且无法预知要花多少时间。如果网速很慢,用户可能要等很久。新版本的 XMLHttpRequest 对象,增加了 timeout 属性,可 以设置 HTTP 请求的时限:

1
xhr.timeout = 3000;

上面的语句,将最长等待时间设为 3000 毫秒。过了这个时限,就自动停止 HTTP 请求。与之配套的还有一个 timeout 事件,用来指定回调函数:

1
2
3
xhr.ontimeout = function () {
alert("请求超时");
};

2、FormData 对象管理表单数据

Ajax 操作往往用来提交表单数据。为了方便表单处理,HTML5 新增了一个 FormData 对象,可以模拟表单操作:

1
2
3
4
5
6
7
8
9
10
11
// 1.新建FormData对象
const fd = new FormData();
// 2.为FormData添加表单项
fd.append("uname", "zs");
fd.append("upwd", "123456");
// 3.创建XHR对象
const xhr = new XMLHttpRequest();
// 4.发请求
xhr.open("POST", "http://congtf.top");
// 5.直接提交FormData对象,这与提交网页表单效果完全一样
xhr.send(fd);

FormData 对象也可以用来获取网页表单的值,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 获取表单元素
const form = document.querySelector("#form1");
// 监听表单元素的submit事件
form.addEventListener("submit", function (e) {
e.preventDefault();
// 根据form表单创建FormData对象,会自动将表单数据填充到FormData对象中
const fd = new FormData(form);
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://congtf.top");
xhr.send(fd);
xhr.onreadystatechange = function () {};
});

3、上传文件

新版 XMLHttpRequest 对象,不仅可以发送文本信息,还可以上传文件

实现步骤

  1. 定义 UI 结构

    1
    2
    3
    4
    5
    6
    7
    <!-- 文件选择框 -->
    <input type="file" id="file1" />
    <!-- 上传按钮 -->
    <button id="btnUpload">上传文件</button>
    <br />
    <!-- 显示上传到服务器上的图片 -->
    <img src="" id="img" width="800" />
  2. 验证是否选择了文件

    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
    const btnUpload = document.querySelector("#btnUpload");
    btn.addEventListener("click", function () {
    const files = document.querySelector("#file1");
    if (files.length <= 0) {
    return alert("请选择要上传的文件!");
    }
    // 创建FormData对象
    const fd = new FormData();
    // 追加文件
    fd.append("avatar", files[0]);
    // 创建xhr对象
    const xhr = new XMLHttpRequest();
    // 发请求
    xhr.open("POST", "http://congtf.top");
    xhr.send(fd);
    xhr.onreadystatechange = function () {
    if (xhr.state === 4 && xhr.status === 200) {
    const data = JSON.parse(xhr.responseText);
    if (data.status === 200) {
    // 将服务器返回的图片地址设置为img标签的src属性
    document.querySelector("#img").src = "http://congtf.top" + data.url;
    } else {
    // 文件上传失败
    console.log("data.message");
    }
    }
    };
    });
  3. 向 FormData 中追加文件

  4. 使用 xhr 发起上传文件的请求

  5. 监听 onreadystatechange 事件

4、显示文件上传进度

新版本的 XMLHttpRequest 对象中,可以通过监听 xhr.upload.onprogress 事件,来获取到文件的上传进度。语法格式如下:

1
2
3
4
5
6
7
8
9
10
const xhr = new XMLHttpRequest();
// 监听xhr.upload的onprogress事件
xhr.upload.onprogress = function (e) {
// e.lengthComputable是一个布尔值,表示当前上传的资源是否具有可计算的长度
if (e.lengthComputable) {
// e.loaded 表示已传输的字节
// e.total 表示需要传输的总字节
const percentComplete = Math.ceil((e.loaded / e.total) * 100);
}
};

四、axios

axios 是专注于网络数据请求的库

相比于原生的 XMLHttpRequest 对象,axios 简单易用

相比于 jQuery,axios 更加轻量化,只专注于网络数据请求

axios 发起 GET 请求

1
2
3
4
5
6
7
axios
.get(url, {
params: {
/*参数*/
},
})
.then(callback);

示例如下:

1
2
3
4
5
const paramsObj = { name: "zs", age: 20 };
axios.get("http:congtf.top", { params: paramsObj }).then(function (res) {
// axios默认包了一层data,res.data才是服务器返回的数据
const result = res.data;
});

axios 发起 POST 请求示例如下:

1
2
3
4
5
const dataObj = { name: "zs", age: 20 };
axios.post("http:congtf.top", dataObj).then(function (res) {
// axios默认包了一层data,res.data才是服务器返回的数据
const result = res.data;
});

直接使用 axios 发起请求:

1
2
3
4
5
6
7
8
9
10
axios({
method: "请求类型",
url: "请求的url地址",
data: {
/*POST数据*/
},
params: {
/*GET参数*/
},
}).then(callback);

五、同源与跨域、防抖与节流

1、同源策略

如果两个页面的协议,域名和端口都相同,则两个页面具有相同的源

例如,下表给出了相对于 http://www.test.com/index.html (没写端口号默认 是 80)页面的同源检测

image-20230504104555104

同源策略(英文全称 Same origin policy)是浏览器提供的一个安全功能 MDN 官方给定的概念:同源策略限制了从同一个源加载的文档或脚本如何与来自 另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制

通俗的理解:浏览器规定,A 网站的 JavaScript 不允许和非同源的网站 C 之间进行资源的交互,例如:

  1. 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB
  2. 无法接触非同源网页的 DOM
  3. 无法向非同源地址发送 Ajax 请求

同源指的是两个 URL 的协议、域名、端口一致,反之,则是跨域

出现跨域的根本原因:浏览器的同源策略不允许非同源的 URL 之间进行资源的交互

浏览器对跨域请求的拦截

image-20230504105321422

注意:浏览器允许发起跨域请求,但是,跨域请求回来的数据,会被浏览器 拦截,无法被页面获取到!

如何实现跨域数据请求

2、JSONP

出现的早,兼容性好(兼容低版本 IE)。是前端程序员为了解决跨域问题, 被迫想出来的一种临时解决方案。缺点是只支持 GET 请求,不支持 POST 请求。

JSONP (JSON with Padding) 是 JSON 的一种“使用模式”,可用于解决主流浏览器的跨域数据访问的问题。

JSONP 的实现原理

由于浏览器同源策略的限制,网页中无法通过 Ajax 请求非同源的接口数据。 但是在网页中有一些标签天生具有跨域能力,比如:img link iframe script。因此,JSONP 的实现原理,就是通过 script 标签的 src 属性,请求跨
域的数据接口,并通过函数调用的形式,接收跨域接口响应回来的数据。

JSONP 的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 动态的创建一个script标签
const script document.createElement('script')
// 设置script的src,设置回调函数
script.src='http://loacalhost:3000/testAJAX?callback=abc'
function abc(data) {
alert(data.name)
}
// 将script添加到body中
document.body.appendChild(script)
// 服务器中路由的处理
router.get('/testAJAX',function(req,res) {
console.log('收到请求')
const callback = req.query.callback
const obj = {
name:'zs',
age:20
}
res.send(callback+"("+JSON.stringfy(obj)+")")
})

3、CORS

CORS(Cross-Origin Resource Sharing),跨域资源共享。CORS 是官方的跨域解决方 案,它的特点是不需要在客户端做任何特殊的操作,完全在服务器中进行处理,支持 get 和 post 请求。跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些 源站通过浏览器有权限访问哪些资源

CORS 怎么工作的?

CORS 是通过设置一个响应头来告诉浏览器,该请求允许跨域,浏览器收到该响应 以后就会对响应放行

CORS 的使用

主要是服务器端的设置:

1
2
3
4
5
router.get("/testAJAX", function (req, res) {
// res.set("Access-Control-Allow-Origin","http://127.0.0.1:3000")
res.set("Access-Control-Allow-Origin", "*");
res.send("返回的相应");
});

4、防抖

防抖策略(debounce)是当事件被触发后,延迟 n 秒后再执行回调,如果 在这 n 秒内事件又被触发,则重新计时

image-20230504111044196

防抖的应用场景

用户在输入框中连续输入一串字符时,可以通过防抖策略,只在输入完后, 才执行查询的请求,这样可以有效减少请求次数,节约请求资源

防抖的基本原理是在一定时间内,将多次触发的函数调用合并为一次调用执行。当用户连续触发事件时,防抖函数会清空之前设定的定时器,并重新设定一个新的定时器来延迟函数的执行。只有当用户停止操作后,在设定的时间内没有再次触发事件,则函数才会被真正地执行一次

实现一个基本的防抖函数步骤如下:

  1. 在函数外部设置一个计时器变量(例如 timeoutId)和函数执行的延迟时间(例如 delay)
  2. 当函数被触发时,清除之前设定的计时器
  3. 重新设定一个计时器,并用它来延迟函数的执行
  4. 如果在设定的延迟时间内再次触发了函数,则回到步骤 2
  5. 如果在设定的延迟时间内没有再次触发函数,计时器会触发执行原始函数

以下是一个基本的防抖函数

1
2
3
4
5
6
7
8
9
10
11
function debounce(func, delay) {
let timeoutId;
return function () {
const context = this;
const args = arguments;
clearTimeout(timeoutId);
timeoutId = setTimeout(function () {
func.apply(context, args);
}, delay);
};
}

该函数接受两个参数:要执行的函数以及防抖延迟时间。它返回一个新函数,该函数会在防抖延迟时间内被多次调用时只执行一次原始函数

5、节流

节流策略(throttle),顾名思义,可以减少一段时间内事件的触发频率。

节流的应用场景

  1. 鼠标连续不断地触发某事件(如点击),只在单位时间内只触发一次
  2. 懒加载时要监听计算滚动条的位置,但不必每次滑动都触发,可以降低计算 的频率,而不必去浪费 CPU 资源

节流阀的概念

高铁卫生间是否被占用,由红绿灯控制,红灯表示被占用,绿灯表示可使用

假设每个人上卫生间都需要花费 5 分钟,则五分钟之内,被占用的卫生间无法被 其他人使用

上一个人使用完毕后,需要将红灯重置为绿灯,表示下一个人可以使用卫生 间

下一个人在上卫生间之前,需要先判断控制灯是否为绿色,来知晓能否上卫 生间

类似的

节流阀为空,表示可以执行下次操作;不为空,表示不能执行下次操作

当前操作执行完,必须将节流阀重置为空,表示可以执行下次操作了

每次执行操作前,必须先判断节流阀是否为空

一个基本的节流函数示例如下:

1
2
3
4
5
6
7
8
9
10
11
function throttle(func, delay) {
let last = 0;
return function (...args) {
const now = +new Date();
if (now - last < delay) {
return;
}
last = now;
func.apply(this, args);
};
}

总结防抖和节流的区别

防抖:如果事件被频繁触发,防抖能保证只有最有一次触发生效!前面 N 多 次的触发都会被忽略!当事件被触发时,函数在固定时间间隔内不被执行,只有事件停止触发后,函数才会执行一次。适用于处理一些不需要连续响应的事件(如输入框输入、按钮点击等),可以避免函数被频繁调用,提高性能和稳定性。

节流:如果事件被频繁触发,节流能够减少事件触发的频率,因此,节流是 有选择性地执行一部分事件!当事件被触发时,函数在固定时间间隔内最多只执行一次。适用于高频率事件(如滚动、窗口调整等),可以防止函数被频繁调用,减少不必要的计算,提高性能和响应速度

前端学习路线

本篇文章列出我的前端学习路线,当然后面有很多其实我还没有掌握。前面的知识当入门后面的知识当进阶吧。我也计划将此路线当做一个题纲,后期会将每一块知识展开后形成单独文章。

一、前端入门

1、HTML

描述:用于定义一个网页结构的基本技术

各种标签:

  • h 系列标签

  • p 标签

  • 换行标签

  • 水平分割线标签


  • 文本格式化标签(加粗标签 b 和 strong、下划线标签 u 和 ins、倾斜标签 i 和 em、删除线 s 和 del)

  • 图片标签 img

  • 音频标签 audio

  • 视频标签 video

  • a 链接标签

  • 无序列表标签 ul 搭配 li

  • 有序列表标签 ol 搭配 li

  • 自定义列表标签 dl 搭配 dt 和 dd

  • 表格标签 table 搭配 tr、th、td 等

  • 表单标签 input 系列、button、select、textarea、label 等

标签语义化

HTML5 特性:

  • 语义化标签
  • 浏览器支持
  • 多媒体标签
  • Canvas 画布
  • 本地存储
    • localStorage
    • sessionStorage
  • Web Workers
  • 应用缓存(Cache Manifest)
  • 无障碍

2、CSS

描述:层叠样式表,用于设计风格和布局

引入方式:内嵌式、外联式、行内式

基础选择器:

  • 标签选择器
  • 类选择器
  • id 选择器
  • 通配符选择器

复合选择器:

  • 后代选择器

  • 子代选择器

  • 并集选择器

  • 交集选择器

  • 伪类选择器

字体和文本样式:

  • font-size
  • font-weight
  • font-style
  • font-family
  • text-indent
  • text-align
  • text-decoration
  • line-height

背景相关样式

  • background-color

  • background-image

  • background-repeat

  • background-position

  • background-size

元素显示模式:块级元素、行内元素、行内块元素

CSS 特性:继承性、层叠性、优先级

文档流

  • 标准流
  • 浮动流
  • 定位流

伪元素

浮动:浮动和清除浮动、BFC 机制

定位:静态定位、相对定位、绝对定位、子绝父相、固定定位

其他:对齐方式问题 vertical-align、光标类型 cursor、边框圆角 border-radius、溢出部分显示效果、元素隐藏、元素透明度、边框合并、精灵图、字体图标、文字阴影 text-shadow、盒子阴影 box-shadow、过渡、图片模糊处理 filter:blur()、SEO 优化、网站 icon 图标、CSS 书写规范

平面转换(2D):位移、旋转、缩放

渐变

空间转换(3D):位移、透视、旋转、立体呈现、缩放

动画

移动端适配:屏幕尺寸和分辨率、百分比布局(流式布局)、Flex 布局(弹性布局)、媒体查询

3、JavaScript

描述:具有函数优先的轻量级,解释型或即时编译型的编程语言。

JavaScript 组成:

  • ECMAScript:规定了 js 基础语法

  • Web APIs:JavaScript 暴露的一些接口,用于操作文档和浏览器

数据类型

  • 值类型
    • 字符串 String
    • 数组 Number
    • 布尔 Boolean
    • 空值 Null
    • 未定义 Undefined
    • Symbol
  • 引用类型
    • 对象 Object
    • 数组 Array
    • 函数 Function

数据类型转换:显式转换、隐式转换

运算符:算术运算符、赋值运算符、一元运算符、比较运算符、逻辑运算符

流程控制语句:

  • 分支语句:if 分支语句、三元运算符、switch 语句

  • 循环语句:while 循环、for 循环

函数:匿名函数(函数表达式)、函数调用、函数传参

对象:内置对象

DOM 操作:DOM 树、DOM 对象、设置修改 DOM 元素内容、属性、样式、定时器-间歇函数、事件、回调函数、高阶函数、环境对象 this、事件对象 e、节点操作(查找、增加、删除)、事件委托、重绘回流、滚动事件、加载事件

BOM 操作:window 对象、定时器-延时函数、location 对象、navigator 对象、histroy 对象、本地存储、正则表达式

ES6:不再说了,阮一峰老师都讲到了。ES6 入门教程 https://es6.ruanyifeng.com/

二、AJAX

XMLHttpRequest、axios

http 基础知识:浏览器的同源策略、跨域及如何解决跨域、URL、防抖、节流、http 协议

三、git 和 github

使用版本控制软件,将代码托管到远程仓库

四、Node.js

这个我还没有系统的学过,也列出来以后学习

五、框架前置

ES6 的模块化:导入导出

Promise:回调地狱、.then、.catch、async/await

事件循环 EventLoop

六、前端工程化

Vue2 框架

Vue 的几大板块:声明式渲染、组件系统、客户端路由、大规模状态管理、构建工具

Vue 基础语法:

  • 插值表达式
  • MVVM 设计模式
  • v-bind
  • v-on
  • v-model
  • v-text 和 v-html
  • v-show 和 v-if
  • v-for
  • 虚拟 dom 和 diff 算法
  • 动态 class 和动态 style
  • 过滤器
  • 计算属性
  • 侦听器 watch

Vue 组件:

  • 组件通信
  • Vue 生命周期与钩子函数
  • $refs和$nextTick
  • 动态组件与组件缓存 keep-alive
  • 组件插槽(默认插槽、具名插槽、作用域插槽)

Vue 路由系统:

  • 声明式导航
  • 编程式导航
  • 重定向与路由模式
  • 路由嵌套和路由守卫

Vuex:

  • state
  • mutations
  • actions
  • getters
  • vuex 模块化

CSS 预编译:

  • LESS
  • SASS

开发框架:

  • Express
  • Koa
  • Egg

组件库

  • ElementUI(Vue)
  • VantUI(Vue)

数据可视化

  • Apache ECharts(echarts)
  • HighCharts

数据 Mock

  • 描述:通过随机数据,模拟各种场景,增加单元测试的真实性。
  • Mock.js

代码检查:

  • ESLint
  • StyleLint

代码风格:

  • Prettier

包管理工具:

  • npm
  • yarn

打包工具:

  • Webpack
  • Vite

部署

Web 服务器

Nginx

Apache

容器

Docker

  • 描述:容器是一个标准化的软件单元,它将代码及其所有依赖关系打包,以便应用程序从一个计算环境可靠快速地运行到另一个计算环境。Docker 容器镜像是一个轻量的独立的可执行的软件包。包含程序运行的时候所需的一切:代码,运行时间,系统工具,系统库和设置。
  • 资源:Docker 从入门到实践 https://vuepress.mirror.docker-practice.com/
  • Dockerfile

七、性能优化

  • 路由懒加载
  • 组件懒加载
  • JS 异步加载
  • Tree shaking
  • 骨架屏
  • 分页加载
  • 长列表虚拟滚动
  • Web Worker 优化长任务
  • 图片优化(动态裁剪、懒加载、图片压缩、使用字体图标、图片转 base64 格式)
  • CDN 分发
  • Copyrights © 2023-2025 congtianfeng
  • 访问人数: | 浏览次数: