当前位置:   article > 正文

前端Vue小兔鲜儿电商项目实战Day05_vue小兔鲜儿项目

vue小兔鲜儿项目

一、登录 - 整体认识和路由配置

1. 整体认识

登录页面的主要功能就是表单校验和登录退出业务

①src/views/Login/index.vue

  1. <script setup></script>
  2. <template>
  3. <div>
  4. <header class="login-header">
  5. <div class="container m-top-20">
  6. <h1 class="logo">
  7. <RouterLink to="/">小兔鲜</RouterLink>
  8. </h1>
  9. <RouterLink class="entry" to="/">
  10. 进入网站首页
  11. <i class="iconfont icon-angle-right"></i>
  12. <i class="iconfont icon-angle-right"></i>
  13. </RouterLink>
  14. </div>
  15. </header>
  16. <section class="login-section">
  17. <div class="wrapper">
  18. <nav>
  19. <a href="javascript:;">账户登录</a>
  20. </nav>
  21. <div class="account-box">
  22. <div class="form">
  23. <el-form label-position="right" label-width="60px" status-icon>
  24. <el-form-item label="账户">
  25. <el-input />
  26. </el-form-item>
  27. <el-form-item label="密码">
  28. <el-input />
  29. </el-form-item>
  30. <el-form-item label-width="22px">
  31. <el-checkbox size="large">
  32. 我已同意隐私条款和服务条款
  33. </el-checkbox>
  34. </el-form-item>
  35. <el-button size="large" class="subBtn">点击登录</el-button>
  36. </el-form>
  37. </div>
  38. </div>
  39. </div>
  40. </section>
  41. <footer class="login-footer">
  42. <div class="container">
  43. <p>
  44. <a href="javascript:;">关于我们</a>
  45. <a href="javascript:;">帮助中心</a>
  46. <a href="javascript:;">售后服务</a>
  47. <a href="javascript:;">配送与验收</a>
  48. <a href="javascript:;">商务合作</a>
  49. <a href="javascript:;">搜索推荐</a>
  50. <a href="javascript:;">友情链接</a>
  51. </p>
  52. <p>CopyRight &copy; 小兔鲜儿</p>
  53. </div>
  54. </footer>
  55. </div>
  56. </template>
  57. <style scoped lang="scss">
  58. .login-header {
  59. background: #fff;
  60. border-bottom: 1px solid #e4e4e4;
  61. .container {
  62. display: flex;
  63. align-items: flex-end;
  64. justify-content: space-between;
  65. }
  66. .logo {
  67. width: 200px;
  68. a {
  69. display: block;
  70. height: 132px;
  71. width: 100%;
  72. text-indent: -9999px;
  73. background: url('@/assets/images/logo.png') no-repeat center 18px /
  74. contain;
  75. }
  76. }
  77. .sub {
  78. flex: 1;
  79. font-size: 24px;
  80. font-weight: normal;
  81. margin-bottom: 38px;
  82. margin-left: 20px;
  83. color: #666;
  84. }
  85. .entry {
  86. width: 120px;
  87. margin-bottom: 38px;
  88. font-size: 16px;
  89. i {
  90. font-size: 14px;
  91. color: $xtxColor;
  92. letter-spacing: -5px;
  93. }
  94. }
  95. }
  96. .login-section {
  97. background: url('@/assets/images/login-bg.png') no-repeat center / cover;
  98. height: 488px;
  99. position: relative;
  100. .wrapper {
  101. width: 380px;
  102. background: #fff;
  103. position: absolute;
  104. left: 50%;
  105. top: 54px;
  106. transform: translate3d(100px, 0, 0);
  107. box-shadow: 0 0 10px rgba(0, 0, 0, 0.15);
  108. nav {
  109. font-size: 14px;
  110. height: 55px;
  111. margin-bottom: 20px;
  112. border-bottom: 1px solid #f5f5f5;
  113. display: flex;
  114. padding: 0 40px;
  115. text-align: right;
  116. align-items: center;
  117. a {
  118. flex: 1;
  119. line-height: 1;
  120. display: inline-block;
  121. font-size: 18px;
  122. position: relative;
  123. text-align: center;
  124. }
  125. }
  126. }
  127. }
  128. .login-footer {
  129. padding: 30px 0 50px;
  130. background: #fff;
  131. p {
  132. text-align: center;
  133. color: #999;
  134. padding-top: 20px;
  135. a {
  136. line-height: 1;
  137. padding: 0 10px;
  138. color: #999;
  139. display: inline-block;
  140. ~ a {
  141. border-left: 1px solid #ccc;
  142. }
  143. }
  144. }
  145. }
  146. .account-box {
  147. .toggle {
  148. padding: 15px 40px;
  149. text-align: right;
  150. a {
  151. color: $xtxColor;
  152. i {
  153. font-size: 14px;
  154. }
  155. }
  156. }
  157. .form {
  158. padding: 0 20px 20px 20px;
  159. &-item {
  160. margin-bottom: 28px;
  161. .input {
  162. position: relative;
  163. height: 36px;
  164. > i {
  165. width: 34px;
  166. height: 34px;
  167. background: #cfcdcd;
  168. color: #fff;
  169. position: absolute;
  170. left: 1px;
  171. top: 1px;
  172. text-align: center;
  173. line-height: 34px;
  174. font-size: 18px;
  175. }
  176. input {
  177. padding-left: 44px;
  178. border: 1px solid #cfcdcd;
  179. height: 36px;
  180. line-height: 36px;
  181. width: 100%;
  182. &.error {
  183. border-color: $priceColor;
  184. }
  185. &.active,
  186. &:focus {
  187. border-color: $xtxColor;
  188. }
  189. }
  190. .code {
  191. position: absolute;
  192. right: 1px;
  193. top: 1px;
  194. text-align: center;
  195. line-height: 34px;
  196. font-size: 14px;
  197. background: #f5f5f5;
  198. color: #666;
  199. width: 90px;
  200. height: 34px;
  201. cursor: pointer;
  202. }
  203. }
  204. > .error {
  205. position: absolute;
  206. font-size: 12px;
  207. line-height: 28px;
  208. color: $priceColor;
  209. i {
  210. font-size: 14px;
  211. margin-right: 2px;
  212. }
  213. }
  214. }
  215. .agree {
  216. a {
  217. color: #069;
  218. }
  219. }
  220. .btn {
  221. display: block;
  222. width: 100%;
  223. height: 40px;
  224. color: #fff;
  225. text-align: center;
  226. line-height: 40px;
  227. background: $xtxColor;
  228. &.disabled {
  229. background: #cfcdcd;
  230. }
  231. }
  232. }
  233. .action {
  234. padding: 20px 40px;
  235. display: flex;
  236. justify-content: space-between;
  237. align-items: center;
  238. .url {
  239. a {
  240. color: #999;
  241. margin-left: 10px;
  242. }
  243. }
  244. }
  245. }
  246. .subBtn {
  247. background: $xtxColor;
  248. width: 100%;
  249. color: #fff;
  250. }
  251. </style>

②src/views/Layout/components/LayoutNav.vue

  1. <script setup></script>
  2. <template>
  3. <nav class="app-topnav">
  4. <div class="container">
  5. <ul>
  6. <!-- 多模板渲染 区分登录状态和非登录状态 -->
  7. <template v-if="false">
  8. <li>
  9. <a href="javascript:;"><i class="iconfont icon-user"></i>周杰伦</a>
  10. </li>
  11. <li>
  12. <el-popconfirm
  13. title="确认退出吗?"
  14. confirm-button-text="确认"
  15. cancel-button-text="取消"
  16. >
  17. <template #reference>
  18. <a href="javascript:;">退出登录</a>
  19. </template>
  20. </el-popconfirm>
  21. </li>
  22. <li><a href="javascript:;">我的订单</a></li>
  23. <li><a href="javascript:;">会员中心</a></li>
  24. </template>
  25. <template v-else>
  26. <li>
  27. <a href="javascript:;" @click="$router.push('/login')">请先登录</a>
  28. </li>
  29. <li><a href="javascript:;">帮助中心</a></li>
  30. <li><a href="javascript:;">关于我们</a></li>
  31. </template>
  32. </ul>
  33. </div>
  34. </nav>
  35. </template>
  36. <style scoped lang="scss">
  37. <!-- ... ... -->
  38. </style>

二、登录 - 表单校验实现

1. 为什么需要校验

作用:前端提前校验可以省去一些错误的请求提交,为后端节省接口压力

2. 表单如何进行校验

Form 表单 | Element Plus

ElementPlus表单组件内置了表单校验功能,只需要按照组件要求配置必要参数即可。

思想:当功能很复杂时,通过多个组件各自负责某个功能,再组合成一个大功能是组件设计中的常用方法。

表单校验步骤

  • 1. 按照接口字段准备表单对象并绑定
  • 2. 按照产品要求准备规则对象并绑定
  • 3. 指定表单域的校验字段名
  • 4. 把表单对象进行双向绑定

自定义校验规则

ElementPlus表单组件内置了初始的校验配置,应付简单的校验只需要通过配置即可,如果想要定制一些特殊的校验需求,可以使用自定义校验规则,格式如下:

校验逻辑:如果勾选了协议框,通过校验,如果没有勾选,不通过校验

src/views/Login/index.vue

  1. <script setup>
  2. // 表单校验
  3. // 整个表单的校验规则
  4. // 1. 非空校验 required: true message消息提示,trigger触发校验的时机:blur change
  5. // 2. 长度校验 min:xxx, max:xxx
  6. // 3. 正则校验 pattern: 正则规则 \S:非空字符
  7. // 4. 自定义校验 => 自己写逻辑校验(校验函数)
  8. // validator: (rule, value, callback)
  9. // (1)rule: 当前校验规则的相关信息
  10. // (2)value: 所校验的表单元素目前的表单值
  11. // (3)callback 无论成功还是失败,都需要callback回调
  12. // - callback()校验成功
  13. import { ref } from 'vue'
  14. const form = ref()
  15. // 1. 准备表单对象
  16. const formModel = ref({
  17. account: '',
  18. password: '',
  19. agree: false
  20. })
  21. // 2. 准备校验规则对象
  22. const rules = {
  23. account: [
  24. { required: true, message: '用户名不能为空', trigger: 'blur' },
  25. {
  26. pattern: /^\S{5,15}$/,
  27. message: '账户名必须是5-15位的非空字符',
  28. trigger: 'blur'
  29. }
  30. ],
  31. password: [
  32. { required: true, message: '密码不能为空', trigger: 'blur' },
  33. {
  34. pattern: /^\S{6,15}$/,
  35. message: '密码必须是5-16位的非空字符',
  36. trigger: 'blur'
  37. }
  38. ],
  39. agree: [
  40. {
  41. // 自定义校验规则
  42. validator: (rule, value, callback) => {
  43. console.log(value)
  44. // 判断是否勾选协议
  45. if (!value) {
  46. callback(new Error('请先勾选同意协议'))
  47. } else {
  48. callback()
  49. }
  50. }
  51. }
  52. ]
  53. }
  54. </script>
  55. <template>
  56. <div>
  57. <header class="login-header">
  58. <div class="container m-top-20">
  59. <h1 class="logo">
  60. <RouterLink to="/">小兔鲜</RouterLink>
  61. </h1>
  62. <RouterLink class="entry" to="/">
  63. 进入网站首页
  64. <i class="iconfont icon-angle-right"></i>
  65. <i class="iconfont icon-angle-right"></i>
  66. </RouterLink>
  67. </div>
  68. </header>
  69. <section class="login-section">
  70. <div class="wrapper">
  71. <nav>
  72. <a href="javascript:;">账户登录</a>
  73. </nav>
  74. <div class="account-box">
  75. <div class="form">
  76. <el-form
  77. :model="formModel"
  78. :rules="rules"
  79. ref="form"
  80. label-position="right"
  81. label-width="60px"
  82. status-icon
  83. >
  84. <el-form-item label="账户" prop="account">
  85. <el-input
  86. v-model="formModel.account"
  87. placeholder="请输入账户名"
  88. />
  89. </el-form-item>
  90. <el-form-item label="密码" prop="password">
  91. <el-input
  92. v-model="formModel.password"
  93. placeholder="请输入密码"
  94. />
  95. </el-form-item>
  96. <el-form-item label-width="22px" prop="agree">
  97. <el-checkbox size="large" v-model="formModel.agree">
  98. 我已同意隐私条款和服务条款
  99. </el-checkbox>
  100. </el-form-item>
  101. <el-button size="large" class="subBtn">点击登录</el-button>
  102. </el-form>
  103. </div>
  104. </div>
  105. </div>
  106. </section>
  107. <footer class="login-footer">
  108. <div class="container">
  109. <p>
  110. <a href="javascript:;">关于我们</a>
  111. <a href="javascript:;">帮助中心</a>
  112. <a href="javascript:;">售后服务</a>
  113. <a href="javascript:;">配送与验收</a>
  114. <a href="javascript:;">商务合作</a>
  115. <a href="javascript:;">搜索推荐</a>
  116. <a href="javascript:;">友情链接</a>
  117. </p>
  118. <p>CopyRight &copy; 小兔鲜儿</p>
  119. </div>
  120. </footer>
  121. </div>
  122. </template>
  123. <style scoped lang="scss">
  124. .login-header {
  125. background: #fff;
  126. border-bottom: 1px solid #e4e4e4;
  127. .container {
  128. display: flex;
  129. align-items: flex-end;
  130. justify-content: space-between;
  131. }
  132. .logo {
  133. width: 200px;
  134. a {
  135. display: block;
  136. height: 132px;
  137. width: 100%;
  138. text-indent: -9999px;
  139. background: url('@/assets/images/logo.png') no-repeat center 18px /
  140. contain;
  141. }
  142. }
  143. .sub {
  144. flex: 1;
  145. font-size: 24px;
  146. font-weight: normal;
  147. margin-bottom: 38px;
  148. margin-left: 20px;
  149. color: #666;
  150. }
  151. .entry {
  152. width: 120px;
  153. margin-bottom: 38px;
  154. font-size: 16px;
  155. i {
  156. font-size: 14px;
  157. color: $xtxColor;
  158. letter-spacing: -5px;
  159. }
  160. }
  161. }
  162. .login-section {
  163. background: url('@/assets/images/login-bg.png') no-repeat center / cover;
  164. height: 488px;
  165. position: relative;
  166. .wrapper {
  167. width: 380px;
  168. background: #fff;
  169. position: absolute;
  170. left: 50%;
  171. top: 54px;
  172. transform: translate3d(100px, 0, 0);
  173. box-shadow: 0 0 10px rgba(0, 0, 0, 0.15);
  174. nav {
  175. font-size: 14px;
  176. height: 55px;
  177. margin-bottom: 20px;
  178. border-bottom: 1px solid #f5f5f5;
  179. display: flex;
  180. padding: 0 40px;
  181. text-align: right;
  182. align-items: center;
  183. a {
  184. flex: 1;
  185. line-height: 1;
  186. display: inline-block;
  187. font-size: 18px;
  188. position: relative;
  189. text-align: center;
  190. }
  191. }
  192. }
  193. }
  194. .login-footer {
  195. padding: 30px 0 50px;
  196. background: #fff;
  197. p {
  198. text-align: center;
  199. color: #999;
  200. padding-top: 20px;
  201. a {
  202. line-height: 1;
  203. padding: 0 10px;
  204. color: #999;
  205. display: inline-block;
  206. ~ a {
  207. border-left: 1px solid #ccc;
  208. }
  209. }
  210. }
  211. }
  212. .account-box {
  213. .toggle {
  214. padding: 15px 40px;
  215. text-align: right;
  216. a {
  217. color: $xtxColor;
  218. i {
  219. font-size: 14px;
  220. }
  221. }
  222. }
  223. .form {
  224. padding: 0 20px 20px 20px;
  225. &-item {
  226. margin-bottom: 28px;
  227. .input {
  228. position: relative;
  229. height: 36px;
  230. > i {
  231. width: 34px;
  232. height: 34px;
  233. background: #cfcdcd;
  234. color: #fff;
  235. position: absolute;
  236. left: 1px;
  237. top: 1px;
  238. text-align: center;
  239. line-height: 34px;
  240. font-size: 18px;
  241. }
  242. input {
  243. padding-left: 44px;
  244. border: 1px solid #cfcdcd;
  245. height: 36px;
  246. line-height: 36px;
  247. width: 100%;
  248. &.error {
  249. border-color: $priceColor;
  250. }
  251. &.active,
  252. &:focus {
  253. border-color: $xtxColor;
  254. }
  255. }
  256. .code {
  257. position: absolute;
  258. right: 1px;
  259. top: 1px;
  260. text-align: center;
  261. line-height: 34px;
  262. font-size: 14px;
  263. background: #f5f5f5;
  264. color: #666;
  265. width: 90px;
  266. height: 34px;
  267. cursor: pointer;
  268. }
  269. }
  270. > .error {
  271. position: absolute;
  272. font-size: 12px;
  273. line-height: 28px;
  274. color: $priceColor;
  275. i {
  276. font-size: 14px;
  277. margin-right: 2px;
  278. }
  279. }
  280. }
  281. .agree {
  282. a {
  283. color: #069;
  284. }
  285. }
  286. .btn {
  287. display: block;
  288. width: 100%;
  289. height: 40px;
  290. color: #fff;
  291. text-align: center;
  292. line-height: 40px;
  293. background: $xtxColor;
  294. &.disabled {
  295. background: #cfcdcd;
  296. }
  297. }
  298. }
  299. .action {
  300. padding: 20px 40px;
  301. display: flex;
  302. justify-content: space-between;
  303. align-items: center;
  304. .url {
  305. a {
  306. color: #999;
  307. margin-left: 10px;
  308. }
  309. }
  310. }
  311. }
  312. .subBtn {
  313. background: $xtxColor;
  314. width: 100%;
  315. color: #fff;
  316. }
  317. </style>

3. 整个表单的内容验证

思考:每个表单域都有自己的校验触发事件,如果用户一上来就点击登录怎么办呢?

答:在点击登录时需要对所有需要校验的表单进行统一校验

三、登录 - 基础登录业务实现

1. 封装登录接口 - src/apis/user.js

  1. import instance from '@/utils/http.js'
  2. // 登录接口
  3. // export const loginAPI = ({ account, password }) => {
  4. // instance.post('/login', { account, password })
  5. // }
  6. export const loginAPI = ({ account, password }) => {
  7. return instance({
  8. url: '/login',
  9. method: 'POST',
  10. data: {
  11. account,
  12. password
  13. }
  14. })
  15. }

2. 登录成功后续逻辑处理 - src/views/Login/index.vue

  1. <script setup>
  2. // 表单校验
  3. // 整个表单的校验规则
  4. // 1. 非空校验 required: true message消息提示,trigger触发校验的时机:blur change
  5. // 2. 长度校验 min:xxx, max:xxx
  6. // 3. 正则校验 pattern: 正则规则 \S:非空字符
  7. // 4. 自定义校验 => 自己写逻辑校验(校验函数)
  8. // validator: (rule, value, callback)
  9. // (1)rule: 当前校验规则的相关信息
  10. // (2)value: 所校验的表单元素目前的表单值
  11. // (3)callback 无论成功还是失败,都需要callback回调
  12. // - callback()校验成功
  13. import { loginAPI } from '@/apis/user.js'
  14. import { ref } from 'vue'
  15. import { useRouter } from 'vue-router'
  16. const form = ref(null)
  17. // 1. 准备表单对象
  18. const formModel = ref({
  19. account: '',
  20. password: '',
  21. agree: false
  22. })
  23. // 2. 准备校验规则对象
  24. const rules = {
  25. account: [
  26. { required: true, message: '用户名不能为空', trigger: 'blur' },
  27. {
  28. pattern: /^\S{5,15}$/,
  29. message: '账户名必须是5-15位的非空字符',
  30. trigger: 'blur'
  31. }
  32. ],
  33. password: [
  34. { required: true, message: '密码不能为空', trigger: 'blur' },
  35. {
  36. pattern: /^\S{6,15}$/,
  37. message: '密码必须是5-16位的非空字符',
  38. trigger: 'blur'
  39. }
  40. ],
  41. agree: [
  42. {
  43. // 自定义校验规则
  44. validator: (rule, value, callback) => {
  45. console.log(value)
  46. // 判断是否勾选协议
  47. if (!value) {
  48. callback(new Error('请先勾选同意协议'))
  49. } else {
  50. callback()
  51. }
  52. }
  53. }
  54. ]
  55. }
  56. // 带r,调用方法;不带r,获取参数
  57. const router = useRouter()
  58. const doLogin = async () => {
  59. // 登录之前,先进行校验。校验成功,发请求;校验失败,自动提示
  60. await form.value.validate()
  61. const { account, password } = formModel.value
  62. await loginAPI({ account, password })
  63. ElMessage.success('登录成功')
  64. // 跳转首页
  65. router.replace({ path: '/' })
  66. }
  67. </script>
  68. <template>
  69. <div>
  70. <header class="login-header">
  71. <div class="container m-top-20">
  72. <h1 class="logo">
  73. <RouterLink to="/">小兔鲜</RouterLink>
  74. </h1>
  75. <RouterLink class="entry" to="/">
  76. 进入网站首页
  77. <i class="iconfont icon-angle-right"></i>
  78. <i class="iconfont icon-angle-right"></i>
  79. </RouterLink>
  80. </div>
  81. </header>
  82. <section class="login-section">
  83. <div class="wrapper">
  84. <nav>
  85. <a href="javascript:;">账户登录</a>
  86. </nav>
  87. <div class="account-box">
  88. <!--
  89. (1) el-form => :model="ruleForm" 绑定的整个form的数据对象 { xxx, xxx, xxx }
  90. (2) el-form => :rules="rules" 绑定的整个rules规则对象 { xxx, xxx, xxx }
  91. (3) 表单元素 => v-model="ruleForm.xxx" 给表单元素,绑定form的子属性
  92. (4) el-form-item => prop配置生效的是哪个校验规则 (和rules中的字段要对应)
  93. -->
  94. <div class="form">
  95. <el-form
  96. :model="formModel"
  97. :rules="rules"
  98. ref="form"
  99. label-position="right"
  100. label-width="60px"
  101. status-icon
  102. >
  103. <el-form-item label="账户" prop="account">
  104. <el-input
  105. v-model="formModel.account"
  106. placeholder="请输入账户名"
  107. />
  108. </el-form-item>
  109. <el-form-item label="密码" prop="password">
  110. <el-input
  111. v-model="formModel.password"
  112. placeholder="请输入密码"
  113. />
  114. </el-form-item>
  115. <el-form-item label-width="22px" prop="agree">
  116. <el-checkbox size="large" v-model="formModel.agree">
  117. 我已同意隐私条款和服务条款
  118. </el-checkbox>
  119. </el-form-item>
  120. <el-button @click="doLogin" size="large" class="subBtn"
  121. >点击登录</el-button
  122. >
  123. </el-form>
  124. </div>
  125. </div>
  126. </div>
  127. </section>
  128. <footer class="login-footer">
  129. <div class="container">
  130. <p>
  131. <a href="javascript:;">关于我们</a>
  132. <a href="javascript:;">帮助中心</a>
  133. <a href="javascript:;">售后服务</a>
  134. <a href="javascript:;">配送与验收</a>
  135. <a href="javascript:;">商务合作</a>
  136. <a href="javascript:;">搜索推荐</a>
  137. <a href="javascript:;">友情链接</a>
  138. </p>
  139. <p>CopyRight &copy; 小兔鲜儿</p>
  140. </div>
  141. </footer>
  142. </div>
  143. </template>

3. .eslintrc.cjs - 配置全局变量

  1. /* eslint-env node */
  2. require('@rushstack/eslint-patch/modern-module-resolution')
  3. module.exports = {
  4. root: true,
  5. extends: [
  6. 'plugin:vue/vue3-essential',
  7. 'eslint:recommended',
  8. '@vue/eslint-config-prettier/skip-formatting'
  9. ],
  10. parserOptions: {
  11. ecmaVersion: 'latest'
  12. },
  13. rules: {
  14. // prettier专注于代码的美观度 (格式化工具)
  15. // 前置:
  16. // 1. 禁用格式化插件 prettier format on save 关闭
  17. // 2. 安装Eslint插件, 并配置保存时自动修复
  18. 'prettier/prettier': [
  19. 'warn',
  20. {
  21. singleQuote: true, // 单引号
  22. semi: false, // 无分号
  23. printWidth: 80, // 每行宽度至多80字符
  24. trailingComma: 'none', // 不加对象|数组最后逗号
  25. endOfLine: 'auto' // 换行符号不限制(win mac 不一致)
  26. }
  27. ],
  28. // ESLint关注于规范, 如果不符合规范,报错
  29. 'vue/multi-word-component-names': [
  30. 'warn',
  31. {
  32. ignores: ['index'] // vue组件名称多单词组成(忽略index.vue)
  33. }
  34. ],
  35. 'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验 (props解构丢失响应式)
  36. // 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
  37. 'no-undef': 'error'
  38. },
  39. // 全局变量
  40. globals: {
  41. ElMessage: 'readonly',
  42. ElMessageBox: 'readonly',
  43. ElLoading: 'readonly'
  44. }
  45. }

4. 登录失败的逻辑处理 - src/utils/http.js

  1. import axios from 'axios'
  2. // 创建axios实例
  3. const instance = axios.create({
  4. baseURL: 'http://pcapi-xiaotuxian-front-devtest.itheima.net',
  5. timeout: 5000
  6. })
  7. // axios请求拦截器
  8. instance.interceptors.request.use(
  9. (config) => {
  10. return config
  11. },
  12. (e) => Promise.reject(e)
  13. )
  14. // axios响应式拦截器
  15. instance.interceptors.response.use(
  16. (res) => res.data,
  17. (e) => {
  18. console.log(e)
  19. // 统一错误提示
  20. ElMessage({
  21. type: 'warning',
  22. message: e.response.data.message
  23. })
  24. return Promise.reject(e)
  25. }
  26. )
  27. export default instance

四、登录 - Pinia管理用户数据

1. 为什么要用Pinia管理数据

由于用户数据的特殊性,在很多组件中都有可能进行共享,共享的数据使用Pinia管理会更加方便

2. 如何使用Pinia管理数据

遵循理念:和数据相关的所有操作(state + action)都放到Pinia中,组件只负责触发action函数

①src/stores/user.js

  1. import { defineStore } from 'pinia'
  2. import { loginAPI } from '@/apis/user'
  3. import { ref } from 'vue'
  4. export const useUserStore = defineStore(
  5. 'user',
  6. () => {
  7. // 1. 定义管理用户数据的state
  8. const userInfo = ref({})
  9. // 2. 定义获取数据的action函数
  10. const getUserInfo = async ({ account, password }) => {
  11. const res = await loginAPI({ account, password })
  12. userInfo.value = res.result
  13. }
  14. // 3. 以对象的形式把state和action return
  15. return {
  16. userInfo,
  17. getUserInfo
  18. }
  19. },
  20. {
  21. persist: true
  22. }
  23. )

②src/views/Login/index.vue

  1. <script setup>
  2. import { useUserStore } from '@/stores/user.js'
  3. const userStore = useUserStore()
  4. // ... ...
  5. // 带r,调用方法;不带r,获取参数
  6. const router = useRouter()
  7. const doLogin = async () => {
  8. // 登录之前,先进行校验。校验成功,发请求;校验失败,自动提示
  9. await form.value.validate()
  10. const { account, password } = formModel.value
  11. await userStore.getUserInfo({ account, password })
  12. ElMessage.success('登录成功')
  13. // 跳转首页
  14. router.replace({ path: '/' })
  15. }
  16. </script>

3. Pinia用户数据持久化

持久化用户数据说明

1. 用户数据中有一个关键的数据叫做Token(用来标识当前用户是否登录),而Token持续一段时间才会过期

2. Pinia的存储是基于内存的,刷新就丢失,为了保持登录状态就要做到刷新不丢失,需要配合持久化进行存储。

目的:保持token不丢失,保持登录状态

最终效果:操作state时会自动把用户数据在本地的localStorage也存一份,刷新的时候会从localStorage中先取

快速开始 | pinia-plugin-persistedstate

运行机制:在设置state的时候会自动把数据同步给localstorage,在获取state数据的时候会优先从localstorage中获取。

①安装插件

pnpm i pinia-plugin-persistedstate

②将插件添加到pinia实例上 - main.js

  1. import { createApp } from 'vue'
  2. import { createPinia } from 'pinia'
  3. import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
  4. import App from './App.vue'
  5. import router from './router'
  6. // 引入初始化样式文件
  7. import '@/styles/common.scss'
  8. // 引入懒加载指令插件并注册
  9. import { lazyPlugin } from '@/direactives'
  10. // 引入全局组件插件
  11. import { componentPlugin } from '@/components/index.js'
  12. const app = createApp(App)
  13. const pinia = createPinia()
  14. pinia.use(piniaPluginPersistedstate)
  15. app.use(pinia)
  16. app.use(router)
  17. app.use(lazyPlugin)
  18. app.use(componentPlugin)
  19. app.mount('#app')

③创建Store时,将persist选项设置为true

  1. import { defineStore } from 'pinia'
  2. import { loginAPI } from '@/apis/user'
  3. import { ref } from 'vue'
  4. export const useUserStore = defineStore(
  5. 'user',
  6. () => {
  7. // 1. 定义管理用户数据的state
  8. const userInfo = ref({})
  9. // 2. 定义获取数据的action函数
  10. const getUserInfo = async ({ account, password }) => {
  11. const res = await loginAPI({ account, password })
  12. userInfo.value = res.result
  13. }
  14. // 3. 以对象的形式把state和action return
  15. return {
  16. userInfo,
  17. getUserInfo
  18. }
  19. },
  20. {
  21. persist: true
  22. }
  23. )

五、登录 - 登录和非登录状态的模板适配

1. 需求理解

src/views/Layout/components/LayoutNav.vue

  1. <script setup>
  2. import { useUserStore } from '@/stores/user.js'
  3. const userStore = useUserStore()
  4. </script>
  5. <template>
  6. <nav class="app-topnav">
  7. <div class="container">
  8. <ul>
  9. <!-- 多模板渲染 区分登录状态和非登录状态 -->
  10. <!-- 判断是否有token -->
  11. <template v-if="userStore.userInfo.token">
  12. <li>
  13. <a href="javascript:;">
  14. <i class="iconfont icon-user"></i>
  15. {{ userStore.userInfo.nickname || userStore.userInfo.account }}
  16. </a>
  17. </li>
  18. <li>
  19. <el-popconfirm
  20. title="确认退出吗?"
  21. confirm-button-text="确认"
  22. cancel-button-text="取消"
  23. >
  24. <template #reference>
  25. <a href="javascript:;">退出登录</a>
  26. </template>
  27. </el-popconfirm>
  28. </li>
  29. <li><a href="javascript:;">我的订单</a></li>
  30. <li><a href="javascript:;">会员中心</a></li>
  31. </template>
  32. <template v-else>
  33. <li>
  34. <a href="javascript:;" @click="$router.push('/login')">请先登录</a>
  35. </li>
  36. <li><a href="javascript:;">帮助中心</a></li>
  37. <li><a href="javascript:;">关于我们</a></li>
  38. </template>
  39. </ul>
  40. </div>
  41. </nav>
  42. </template>

六、登录 - 请求拦截器携带Token

1. 为什么要在请求拦截器携带Token

Token作为用户标识,在很多个接口中都需要携带Token才可以正确获取数据,所以需要在接口调用时携带Token。另外,为了统一控制采取请求拦截器携带的方案。

2. 如何配置

Axios请求拦截器可以在接口正式发起之前对请求参数做一些事情,通常Token数据会被注入到请求header中,格式按照后端要求的格式进行拼接处理

  1. instance.interceptors.request.use(config => {
  2. const userStore = useUserStore()
  3. const token = userStore.userInfo.token
  4. if( token ) {
  5. config.headers.Authorization = `Bearer ${token}`
  6. }
  7. return config
  8. }, e=> Promise.reject(e))

七、登录 - 退出登录功能实现

1. 退出登录业务实现

Popconfirm 气泡确认框 | Element Plus

①新增清除用户信息action - src/stores/user.js

  1. import { defineStore } from 'pinia'
  2. import { loginAPI } from '@/apis/user'
  3. import { ref } from 'vue'
  4. export const useUserStore = defineStore(
  5. 'user',
  6. () => {
  7. // 1. 定义管理用户数据的state
  8. const userInfo = ref({})
  9. // 2. 定义获取数据的action函数
  10. const getUserInfo = async ({ account, password }) => {
  11. const res = await loginAPI({ account, password })
  12. userInfo.value = res.result
  13. }
  14. // 退出登录时清除用户信息
  15. const clearUserInfo = () => {
  16. userInfo.value = {}
  17. }
  18. // 3. 以对象的形式把state和action return
  19. return {
  20. userInfo,
  21. getUserInfo,
  22. clearUserInfo
  23. }
  24. },
  25. {
  26. persist: true
  27. }
  28. )

②组件中执行业务逻辑 - src/views/Layout/components/LayoutNav.vue

  1. <script setup>
  2. import { useUserStore } from '@/stores/user.js'
  3. import { useRouter } from 'vue-router'
  4. const userStore = useUserStore()
  5. const router = useRouter()
  6. const confirm = () => {
  7. // 清除登录信息
  8. userStore.clearUserInfo()
  9. // 跳转到登录页
  10. router.push('/login')
  11. }
  12. </script>
  13. <template>
  14. <nav class="app-topnav">
  15. <div class="container">
  16. <ul>
  17. <!-- 多模板渲染 区分登录状态和非登录状态 -->
  18. <!-- 判断是否有token -->
  19. <template v-if="userStore.userInfo.token">
  20. <li>
  21. <a href="javascript:;">
  22. <i class="iconfont icon-user"></i>
  23. {{ userStore.userInfo.nickname || userStore.userInfo.account }}
  24. </a>
  25. </li>
  26. <li>
  27. <el-popconfirm
  28. title="确认退出吗?"
  29. confirm-button-text="确认"
  30. cancel-button-text="取消"
  31. @confirm="confirm"
  32. >
  33. <template #reference>
  34. <a href="javascript:;">退出登录</a>
  35. </template>
  36. </el-popconfirm>
  37. </li>
  38. <li><a href="javascript:;">我的订单</a></li>
  39. <li><a href="javascript:;">会员中心</a></li>
  40. </template>
  41. <template v-else>
  42. <li>
  43. <a href="javascript:;" @click="$router.push('/login')">请先登录</a>
  44. </li>
  45. <li><a href="javascript:;">帮助中心</a></li>
  46. <li><a href="javascript:;">关于我们</a></li>
  47. </template>
  48. </ul>
  49. </div>
  50. </nav>
  51. </template>

八、登录 - Token失效401拦截

1. 业务背景

Token的有效性可以保持一定时间,如果用户一段时间不做任何操作,Token就会失效,使用失效的Token再去请求一些接口,接口就会报401状态码错误,需要我们做额外处理

两个需要思考的问题:

1. 我们能确定用户到底是在访问哪个接口时出现的401错误吗?在什么位置去拦截这个401?

答:响应拦截器

2. 检测到401之后又该干什么呢?

答:清除掉过期的用户信息,跳转到登录页

解决方案:在axios响应拦截器做统一处理

src/utils/http.js

  1. import axios from 'axios'
  2. import { useUserStore } from '@/stores/user.js'
  3. import router from '@/router'
  4. // 创建axios实例
  5. const instance = axios.create({
  6. baseURL: 'http://pcapi-xiaotuxian-front-devtest.itheima.net',
  7. timeout: 5000
  8. })
  9. // axios请求拦截器
  10. instance.interceptors.request.use(
  11. (config) => {
  12. // 1. 从pinia获取token数据
  13. const userStore = useUserStore()
  14. // 2. 按照后端的要求拼接token数据
  15. const token = userStore.userInfo.token
  16. if (token) {
  17. config.headers.Authorization = `Bearer ${token}`
  18. }
  19. return config
  20. },
  21. (e) => Promise.reject(e)
  22. )
  23. // axios响应式拦截器
  24. instance.interceptors.response.use(
  25. (res) => res.data,
  26. (e) => {
  27. const userStore = useUserStore()
  28. // 统一错误提示
  29. ElMessage({
  30. type: 'warning',
  31. message: e.response.data.message
  32. })
  33. // 401 token失效处理
  34. if (e.response.status === 401) {
  35. // 1. 清除本地用户信息
  36. userStore.clearUserInfo()
  37. // 2. 跳转到登录页(进入到详情页才会)
  38. router.push('/login')
  39. }
  40. return Promise.reject(e)
  41. }
  42. )
  43. export default instance

九、购物车功能实现

1. 购物车业务逻辑梳理拆解

1. 整个购物车的实现分为两个大分支,本地购物车操作和接口购物车操作

2. 由于购物车数据的特殊性,采取pinia管理购物车列表数据并添加持久化缓存

2. 本地购物车 - 加入购物车实现

Input Number 数字输入框 | Element Plus

①封装购物车模块 - src/stores/cart.js

  1. // 封装购物车模块
  2. import { ref } from 'vue'
  3. import { defineStore } from 'pinia'
  4. export const useCartStore = defineStore(
  5. 'cart',
  6. () => {
  7. // 1. 定义state - cartList
  8. const cartList = ref([])
  9. // 2. 定义action - addCart
  10. const addCart = (goods) => {
  11. // 添加购物车操作
  12. // 思路:通过匹配传递过来的商品对象中是skuId能不能在cartList中找到,找到了就是添加过
  13. const item = cartList.value.find((item) => goods.skuId === item.skuId)
  14. if (item) {
  15. // 已添加过,count + 1
  16. item.count++
  17. } else {
  18. // 没有添加过,直接push
  19. cartList.value.push(goods)
  20. }
  21. }
  22. return {
  23. cartList,
  24. addCart
  25. }
  26. },
  27. {
  28. persist: true
  29. }
  30. )

②src/views/Detail/index.vue

  1. <script setup>
  2. // ... ...
  3. import { useCartStore } from '@/stores/cart.js'
  4. const cartStore = useCartStore()
  5. // sku规格被操作时
  6. let skuObj = {}
  7. const skuChange = (sku) => {
  8. console.log(sku)
  9. skuObj = sku
  10. }
  11. const count = ref(1)
  12. const handleChange = (count) => {
  13. console.log(count)
  14. }
  15. // 添加购物车
  16. const addCart = () => {
  17. if (skuObj.skuId) {
  18. // 规格已选择
  19. cartStore.addCart({
  20. id: goods.value.id,
  21. name: goods.value.name,
  22. picture: goods.value.mainPictures[0],
  23. price: goods.value.price,
  24. count: count.value,
  25. skuId: skuObj.skuId,
  26. attrsText: skuObj.specsText,
  27. selected: true
  28. })
  29. ElMessage.success('加入购物车成功')
  30. } else {
  31. // 规格没有选择,提示用户
  32. ElMessage.warning('请选择规格')
  33. }
  34. }
  35. </script>
  36. <template>
  37. <!-- ... ... -->
  38. <!-- sku组件 -->
  39. <XtxSku :goods="goods" @change="skuChange"></XtxSku>
  40. <!-- 数据组件 -->
  41. <el-input-number
  42. v-model="count"
  43. @change="handleChange"
  44. :min="1"
  45. />
  46. <!-- 按钮组件 -->
  47. <div>
  48. <el-button @click="addCart" size="large" class="btn">
  49. 加入购物车
  50. </el-button>
  51. </div>
  52. <!-- ... ... -->
  53. </template>

3. 本地购物车 - 头部购物车列表渲染

①头部购物车组件 - src/views/Layout/components/HeaderCart.vue

  1. <script setup>
  2. import { useCartStore } from '@/stores/cart.js'
  3. const cartStore = useCartStore()
  4. </script>
  5. <template>
  6. <div class="cart">
  7. <a class="curr" href="javascript:;">
  8. <i class="iconfont icon-cart"></i><em>{{ cartStore.cartList.length }}</em>
  9. </a>
  10. <div class="layer">
  11. <div class="list">
  12. <div class="item" v-for="i in cartStore.cartList" :key="i">
  13. <RouterLink to="">
  14. <img :src="i.picture" alt="" />
  15. <div class="center">
  16. <p class="name ellipsis-2">
  17. {{ i.name }}
  18. </p>
  19. <p class="attr ellipsis">{{ i.attrsText }}</p>
  20. </div>
  21. <div class="right">
  22. <p class="price">&yen;{{ i.price }}</p>
  23. <p class="count">x{{ i.count }}</p>
  24. </div>
  25. </RouterLink>
  26. <i
  27. class="iconfont icon-close-new"
  28. @click="store.delCart(i.skuId)"
  29. ></i>
  30. </div>
  31. </div>
  32. <div class="foot">
  33. <div class="total">
  34. <p>共 10 件商品</p>
  35. <p>&yen; 100.00</p>
  36. </div>
  37. <el-button size="large" type="primary">去购物车结算</el-button>
  38. </div>
  39. </div>
  40. </div>
  41. </template>
  42. <style scoped lang="scss">
  43. .cart {
  44. width: 50px;
  45. position: relative;
  46. z-index: 600;
  47. .curr {
  48. height: 32px;
  49. line-height: 32px;
  50. text-align: center;
  51. position: relative;
  52. display: block;
  53. .icon-cart {
  54. font-size: 22px;
  55. }
  56. em {
  57. font-style: normal;
  58. position: absolute;
  59. right: 0;
  60. top: 0;
  61. padding: 1px 6px;
  62. line-height: 1;
  63. background: $helpColor;
  64. color: #fff;
  65. font-size: 12px;
  66. border-radius: 10px;
  67. font-family: Arial;
  68. }
  69. }
  70. &:hover {
  71. .layer {
  72. opacity: 1;
  73. transform: none;
  74. }
  75. }
  76. .layer {
  77. opacity: 0;
  78. transition: all 0.4s 0.2s;
  79. transform: translateY(-200px) scale(1, 0);
  80. width: 400px;
  81. height: 400px;
  82. position: absolute;
  83. top: 50px;
  84. right: 0;
  85. box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
  86. background: #fff;
  87. border-radius: 4px;
  88. padding-top: 10px;
  89. &::before {
  90. content: '';
  91. position: absolute;
  92. right: 14px;
  93. top: -10px;
  94. width: 20px;
  95. height: 20px;
  96. background: #fff;
  97. transform: scale(0.6, 1) rotate(45deg);
  98. box-shadow: -3px -3px 5px rgba(0, 0, 0, 0.1);
  99. }
  100. .foot {
  101. position: absolute;
  102. left: 0;
  103. bottom: 0;
  104. height: 70px;
  105. width: 100%;
  106. padding: 10px;
  107. display: flex;
  108. justify-content: space-between;
  109. background: #f8f8f8;
  110. align-items: center;
  111. .total {
  112. padding-left: 10px;
  113. color: #999;
  114. p {
  115. &:last-child {
  116. font-size: 18px;
  117. color: $priceColor;
  118. }
  119. }
  120. }
  121. }
  122. }
  123. .list {
  124. height: 310px;
  125. overflow: auto;
  126. padding: 0 10px;
  127. &::-webkit-scrollbar {
  128. width: 10px;
  129. height: 10px;
  130. }
  131. &::-webkit-scrollbar-track {
  132. background: #f8f8f8;
  133. border-radius: 2px;
  134. }
  135. &::-webkit-scrollbar-thumb {
  136. background: #eee;
  137. border-radius: 10px;
  138. }
  139. &::-webkit-scrollbar-thumb:hover {
  140. background: #ccc;
  141. }
  142. .item {
  143. border-bottom: 1px solid #f5f5f5;
  144. padding: 10px 0;
  145. position: relative;
  146. i {
  147. position: absolute;
  148. bottom: 38px;
  149. right: 0;
  150. opacity: 0;
  151. color: #666;
  152. transition: all 0.5s;
  153. }
  154. &:hover {
  155. i {
  156. opacity: 1;
  157. cursor: pointer;
  158. }
  159. }
  160. a {
  161. display: flex;
  162. align-items: center;
  163. img {
  164. height: 80px;
  165. width: 80px;
  166. }
  167. .center {
  168. padding: 0 10px;
  169. width: 200px;
  170. .name {
  171. font-size: 16px;
  172. }
  173. .attr {
  174. color: #999;
  175. padding-top: 5px;
  176. }
  177. }
  178. .right {
  179. width: 100px;
  180. padding-right: 20px;
  181. text-align: center;
  182. .price {
  183. font-size: 16px;
  184. color: $priceColor;
  185. }
  186. .count {
  187. color: #999;
  188. margin-top: 5px;
  189. font-size: 16px;
  190. }
  191. }
  192. }
  193. }
  194. }
  195. }
  196. </style>

②导入渲染 - src/views/Layout/components/LayoutHeader.vue

  1. <script setup>
  2. import { useCategoryStore } from '@/stores/category.js'
  3. import HeaderCart from './HeaderCart.vue'
  4. // 使用pinia中的数据
  5. const categoryStore = useCategoryStore()
  6. </script>
  7. <template>
  8. <header class="app-header">
  9. <div class="container">
  10. <h1 class="logo">
  11. <RouterLink to="/">小兔鲜</RouterLink>
  12. </h1>
  13. <ul class="app-header-nav">
  14. <li
  15. class="home"
  16. v-for="item in categoryStore.categoryList"
  17. :key="item.id"
  18. >
  19. <RouterLink active-class="active" :to="`/category/${item.id}`">{{
  20. item.name
  21. }}</RouterLink>
  22. </li>
  23. </ul>
  24. <div class="search">
  25. <i class="iconfont icon-search"></i>
  26. <input type="text" placeholder="搜一搜" />
  27. </div>
  28. <!-- 头部购物车 -->
  29. <HeaderCart></HeaderCart>
  30. </div>
  31. </header>
  32. </template>

4. 本地购物车 - 头部购物车删除实现

①src/stores/cart.js

  1. // 封装购物车模块
  2. import { ref } from 'vue'
  3. import { defineStore } from 'pinia'
  4. export const useCartStore = defineStore(
  5. 'cart',
  6. () => {
  7. // 1. 定义state - cartList
  8. const cartList = ref([])
  9. // 2. 定义action - addCart
  10. // 添加购物车
  11. const addCart = (goods) => {
  12. // 添加购物车操作
  13. // 思路:通过匹配传递过来的商品对象中是skuId能不能在cartList中找到,找到了就是添加过
  14. const item = cartList.value.find((item) => goods.skuId === item.skuId)
  15. if (item) {
  16. // 已添加过,count + 1
  17. item.count++
  18. } else {
  19. // 没有添加过,直接push
  20. cartList.value.push(goods)
  21. }
  22. }
  23. // 删除购物车
  24. const delCart = (skuId) => {
  25. // 思路:1. 找到要删除的下标值 - splice
  26. // 2. 使用组件的过滤方法 - filter
  27. const idx = cartList.value.findIndex((item) => skuId === item.skuId)
  28. cartList.value.splice(idx, 1)
  29. }
  30. return {
  31. cartList,
  32. addCart,
  33. delCart
  34. }
  35. },
  36. {
  37. persist: true
  38. }
  39. )

②src/views/Layout/components/HeaderCart.vue

  1. <script setup>
  2. import { useCartStore } from '@/stores/cart.js'
  3. const cartStore = useCartStore()
  4. </script>
  5. <template>
  6. <div class="cart">
  7. <a class="curr" href="javascript:;">
  8. <i class="iconfont icon-cart"></i>
  9. <em v-if="cartStore.cartList.length">{{ cartStore.cartList.length }}</em>
  10. </a>
  11. <div class="layer">
  12. <div class="list">
  13. <div class="item" v-for="i in cartStore.cartList" :key="i">
  14. <RouterLink to="">
  15. <img :src="i.picture" alt="" />
  16. <div class="center">
  17. <p class="name ellipsis-2">
  18. {{ i.name }}
  19. </p>
  20. <p class="attr ellipsis">{{ i.attrsText }}</p>
  21. </div>
  22. <div class="right">
  23. <p class="price">&yen;{{ i.price }}</p>
  24. <p class="count">x{{ i.count }}</p>
  25. </div>
  26. </RouterLink>
  27. <i
  28. class="iconfont icon-close-new"
  29. @click="cartStore.delCart(i.skuId)"
  30. ></i>
  31. </div>
  32. </div>
  33. <div class="foot">
  34. <div class="total">
  35. <p>共 10 件商品</p>
  36. <p>&yen; 100.00</p>
  37. </div>
  38. <el-button
  39. @click="$router.push('/cartlist')"
  40. size="large"
  41. type="primary"
  42. >去购物车结算</el-button
  43. >
  44. </div>
  45. </div>
  46. </div>
  47. </template>

5. 本地购物车 - 头部购物车统计计算

实现思路:计算属性

计算逻辑是什么:

  • 1. 商品总数计算逻辑:商品列表中的所有商品count累加之和
  • 2. 商品总价钱计算逻辑:商品列表中的所有商品的count * price累加之和

①src/stores/cart.js

  1. // 封装购物车模块
  2. import { ref, computed } from 'vue'
  3. import { defineStore } from 'pinia'
  4. export const useCartStore = defineStore(
  5. 'cart',
  6. () => {
  7. // ... ...
  8. // 计算属性
  9. // 1. 总的数量 所有项的count之和
  10. const allCount = computed(() =>
  11. cartList.value.reduce((sum, item) => sum + item.count, 0)
  12. )
  13. // 2. 总价 所有项的count * price之和
  14. const allPrice = computed(() =>
  15. cartList.value.reduce((sum, item) => sum + item.count * item.price, 0)
  16. )
  17. return {
  18. cartList,
  19. addCart,
  20. delCart,
  21. allCount,
  22. allPrice
  23. }
  24. },
  25. {
  26. persist: true
  27. }
  28. )

②src/views/Layout/components/HeaderCart.vue

  1. <div class="foot">
  2. <div class="total">
  3. <p>共 {{ cartStore.allCount }} 件商品</p>
  4. <p>&yen; {{ cartStore.allPrice.toFixed(2) }}</p>
  5. </div>
  6. <el-button
  7. @click="$router.push('/cartlist')"
  8. size="large"
  9. type="primary"
  10. >去购物车结算</el-button
  11. >
  12. </div>

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/2023面试高手/article/detail/669548
推荐阅读
相关标签
  

闽ICP备14008679号