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

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

1. 整体认识

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

①src/views/Login/index.vue

html 复制代码
<script setup></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>
              <el-form-item label="账户">
                <el-input />
              </el-form-item>
              <el-form-item label="密码">
                <el-input />
              </el-form-item>
              <el-form-item label-width="22px">
                <el-checkbox size="large">
                  我已同意隐私条款和服务条款
                </el-checkbox>
              </el-form-item>
              <el-button size="large" class="subBtn">点击登录</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>

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

html 复制代码
<script setup></script>

<template>
  <nav class="app-topnav">
    <div class="container">
      <ul>
        <!-- 多模板渲染 区分登录状态和非登录状态 -->
        <template v-if="false">
          <li>
            <a href="javascript:;"><i class="iconfont icon-user"></i>周杰伦</a>
          </li>
          <li>
            <el-popconfirm
              title="确认退出吗?"
              confirm-button-text="确认"
              cancel-button-text="取消"
            >
              <template #reference>
                <a href="javascript:;">退出登录</a>
              </template>
            </el-popconfirm>
          </li>
          <li><a href="javascript:;">我的订单</a></li>
          <li><a href="javascript:;">会员中心</a></li>
        </template>
        <template v-else>
          <li>
            <a href="javascript:;" @click="$router.push('/login')">请先登录</a>
          </li>
          <li><a href="javascript:;">帮助中心</a></li>
          <li><a href="javascript:;">关于我们</a></li>
        </template>
      </ul>
    </div>
  </nav>
</template>

<style scoped lang="scss">
<!-- ... ... -->
</style>

二、登录 - 表单校验实现

1. 为什么需要校验

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

2. 表单如何进行校验

Form 表单 | Element Plus

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

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

表单校验步骤

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

自定义校验规则

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

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

src/views/Login/index.vue

html 复制代码
<script setup>
// 表单校验
// 整个表单的校验规则
// 1. 非空校验 required: true   message消息提示,trigger触发校验的时机:blur change
// 2. 长度校验 min:xxx, max:xxx
// 3. 正则校验 pattern: 正则规则  \S:非空字符
// 4. 自定义校验 => 自己写逻辑校验(校验函数)
//    validator: (rule, value, callback)
//    (1)rule: 当前校验规则的相关信息
//    (2)value: 所校验的表单元素目前的表单值
//    (3)callback 无论成功还是失败,都需要callback回调
//        - callback()校验成功

import { ref } from 'vue'
const form = ref()
// 1. 准备表单对象
const formModel = ref({
  account: '',
  password: '',
  agree: false
})

// 2. 准备校验规则对象
const rules = {
  account: [
    { required: true, message: '用户名不能为空', trigger: 'blur' },
    {
      pattern: /^\S{5,15}$/,
      message: '账户名必须是5-15位的非空字符',
      trigger: 'blur'
    }
  ],
  password: [
    { required: true, message: '密码不能为空', trigger: 'blur' },
    {
      pattern: /^\S{6,15}$/,
      message: '密码必须是5-16位的非空字符',
      trigger: 'blur'
    }
  ],
  agree: [
    {
      // 自定义校验规则
      validator: (rule, value, callback) => {
        console.log(value)
        // 判断是否勾选协议
        if (!value) {
          callback(new Error('请先勾选同意协议'))
        } else {
          callback()
        }
      }
    }
  ]
}
</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
              :model="formModel"
              :rules="rules"
              ref="form"
              label-position="right"
              label-width="60px"
              status-icon
            >
              <el-form-item label="账户" prop="account">
                <el-input
                  v-model="formModel.account"
                  placeholder="请输入账户名"
                />
              </el-form-item>
              <el-form-item label="密码" prop="password">
                <el-input
                  v-model="formModel.password"
                  placeholder="请输入密码"
                />
              </el-form-item>
              <el-form-item label-width="22px" prop="agree">
                <el-checkbox size="large" v-model="formModel.agree">
                  我已同意隐私条款和服务条款
                </el-checkbox>
              </el-form-item>
              <el-button size="large" class="subBtn">点击登录</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>

3. 整个表单的内容验证

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

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

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

  1. 封装登录接口 - src/apis/user.js
javascript 复制代码
import instance from '@/utils/http.js'

// 登录接口
// export const loginAPI = ({ account, password }) => {
//   instance.post('/login', { account, password })
// }
export const loginAPI = ({ account, password }) => {
  return instance({
    url: '/login',
    method: 'POST',
    data: {
      account,
      password
    }
  })
}
  1. 登录成功后续逻辑处理 - src/views/Login/index.vue
html 复制代码
<script setup>
// 表单校验
// 整个表单的校验规则
// 1. 非空校验 required: true   message消息提示,trigger触发校验的时机:blur change
// 2. 长度校验 min:xxx, max:xxx
// 3. 正则校验 pattern: 正则规则  \S:非空字符
// 4. 自定义校验 => 自己写逻辑校验(校验函数)
//    validator: (rule, value, callback)
//    (1)rule: 当前校验规则的相关信息
//    (2)value: 所校验的表单元素目前的表单值
//    (3)callback 无论成功还是失败,都需要callback回调
//        - callback()校验成功

import { loginAPI } from '@/apis/user.js'
import { ref } from 'vue'
import { useRouter } from 'vue-router'

const form = ref(null)
// 1. 准备表单对象
const formModel = ref({
  account: '',
  password: '',
  agree: false
})

// 2. 准备校验规则对象
const rules = {
  account: [
    { required: true, message: '用户名不能为空', trigger: 'blur' },
    {
      pattern: /^\S{5,15}$/,
      message: '账户名必须是5-15位的非空字符',
      trigger: 'blur'
    }
  ],
  password: [
    { required: true, message: '密码不能为空', trigger: 'blur' },
    {
      pattern: /^\S{6,15}$/,
      message: '密码必须是5-16位的非空字符',
      trigger: 'blur'
    }
  ],
  agree: [
    {
      // 自定义校验规则
      validator: (rule, value, callback) => {
        console.log(value)
        // 判断是否勾选协议
        if (!value) {
          callback(new Error('请先勾选同意协议'))
        } else {
          callback()
        }
      }
    }
  ]
}

// 带r,调用方法;不带r,获取参数
const router = useRouter()
const doLogin = async () => {
  // 登录之前,先进行校验。校验成功,发请求;校验失败,自动提示
  await form.value.validate()
  const { account, password } = formModel.value
  await loginAPI({ account, password })
  ElMessage.success('登录成功')
  // 跳转首页
  router.replace({ path: '/' })
}
</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">
          <!-- 
              (1) el-form => :model="ruleForm"      绑定的整个form的数据对象 { xxx, xxx, xxx }
              (2) el-form => :rules="rules"         绑定的整个rules规则对象  { xxx, xxx, xxx }
              (3) 表单元素 => v-model="ruleForm.xxx" 给表单元素,绑定form的子属性
              (4) el-form-item => prop配置生效的是哪个校验规则 (和rules中的字段要对应)
           -->
          <div class="form">
            <el-form
              :model="formModel"
              :rules="rules"
              ref="form"
              label-position="right"
              label-width="60px"
              status-icon
            >
              <el-form-item label="账户" prop="account">
                <el-input
                  v-model="formModel.account"
                  placeholder="请输入账户名"
                />
              </el-form-item>
              <el-form-item label="密码" prop="password">
                <el-input
                  v-model="formModel.password"
                  placeholder="请输入密码"
                />
              </el-form-item>
              <el-form-item label-width="22px" prop="agree">
                <el-checkbox size="large" v-model="formModel.agree">
                  我已同意隐私条款和服务条款
                </el-checkbox>
              </el-form-item>
              <el-button @click="doLogin" size="large" class="subBtn"
                >点击登录</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>
  1. .eslintrc.cjs - 配置全局变量
javascript 复制代码
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  root: true,
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-prettier/skip-formatting'
  ],
  parserOptions: {
    ecmaVersion: 'latest'
  },
  rules: {
    // prettier专注于代码的美观度 (格式化工具)
    // 前置:
    // 1. 禁用格式化插件 prettier  format on save 关闭
    // 2. 安装Eslint插件, 并配置保存时自动修复
    'prettier/prettier': [
      'warn',
      {
        singleQuote: true, // 单引号
        semi: false, // 无分号
        printWidth: 80, // 每行宽度至多80字符
        trailingComma: 'none', // 不加对象|数组最后逗号
        endOfLine: 'auto' // 换行符号不限制(win mac 不一致)
      }
    ],
    // ESLint关注于规范, 如果不符合规范,报错
    'vue/multi-word-component-names': [
      'warn',
      {
        ignores: ['index'] // vue组件名称多单词组成(忽略index.vue)
      }
    ],
    'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验 (props解构丢失响应式)
    // 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
    'no-undef': 'error'
  },
  // 全局变量
  globals: {
    ElMessage: 'readonly',
    ElMessageBox: 'readonly',
    ElLoading: 'readonly'
  }
}
  1. 登录失败的逻辑处理 - src/utils/http.js
javascript 复制代码
import axios from 'axios'

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

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

// axios响应式拦截器
instance.interceptors.response.use(
  (res) => res.data,
  (e) => {
    console.log(e)
    // 统一错误提示
    ElMessage({
      type: 'warning',
      message: e.response.data.message
    })
    return Promise.reject(e)
  }
)

export default instance

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

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

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

2. 如何使用Pinia管理数据

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

①src/stores/user.js

javascript 复制代码
import { defineStore } from 'pinia'
import { loginAPI } from '@/apis/user'
import { ref } from 'vue'

export const useUserStore = defineStore(
  'user',
  () => {
    // 1. 定义管理用户数据的state
    const userInfo = ref({})

    // 2. 定义获取数据的action函数
    const getUserInfo = async ({ account, password }) => {
      const res = await loginAPI({ account, password })
      userInfo.value = res.result
    }
    // 3. 以对象的形式把state和action return
    return {
      userInfo,
      getUserInfo
    }
  },
  {
    persist: true
  }
)

②src/views/Login/index.vue

javascript 复制代码
<script setup>
import { useUserStore } from '@/stores/user.js'

const userStore = useUserStore()

// ... ...

// 带r,调用方法;不带r,获取参数
const router = useRouter()
const doLogin = async () => {
  // 登录之前,先进行校验。校验成功,发请求;校验失败,自动提示
  await form.value.validate()
  const { account, password } = formModel.value
  await userStore.getUserInfo({ account, password })
  ElMessage.success('登录成功')
  // 跳转首页
  router.replace({ path: '/' })
}
</script>

3. Pinia用户数据持久化

持久化用户数据说明

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

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

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

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

快速开始 | pinia-plugin-persistedstate

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

①安装插件

javascript 复制代码
pnpm i pinia-plugin-persistedstate

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

javascript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

import App from './App.vue'
import router from './router'
// 引入初始化样式文件
import '@/styles/common.scss'
// 引入懒加载指令插件并注册
import { lazyPlugin } from '@/direactives'
// 引入全局组件插件
import { componentPlugin } from '@/components/index.js'

const app = createApp(App)
const pinia = createPinia()

pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.use(lazyPlugin)
app.use(componentPlugin)

app.mount('#app')

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

javascript 复制代码
import { defineStore } from 'pinia'
import { loginAPI } from '@/apis/user'
import { ref } from 'vue'

export const useUserStore = defineStore(
  'user',
  () => {
    // 1. 定义管理用户数据的state
    const userInfo = ref({})

    // 2. 定义获取数据的action函数
    const getUserInfo = async ({ account, password }) => {
      const res = await loginAPI({ account, password })
      userInfo.value = res.result
    }
    // 3. 以对象的形式把state和action return
    return {
      userInfo,
      getUserInfo
    }
  },
  {
    persist: true
  }
)

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

1. 需求理解

src/views/Layout/components/LayoutNav.vue

html 复制代码
<script setup>
import { useUserStore } from '@/stores/user.js'
const userStore = useUserStore()
</script>

<template>
  <nav class="app-topnav">
    <div class="container">
      <ul>
        <!-- 多模板渲染 区分登录状态和非登录状态 -->
        <!-- 判断是否有token -->
        <template v-if="userStore.userInfo.token">
          <li>
            <a href="javascript:;">
              <i class="iconfont icon-user"></i>
              {{ userStore.userInfo.nickname || userStore.userInfo.account }}
            </a>
          </li>
          <li>
            <el-popconfirm
              title="确认退出吗?"
              confirm-button-text="确认"
              cancel-button-text="取消"
            >
              <template #reference>
                <a href="javascript:;">退出登录</a>
              </template>
            </el-popconfirm>
          </li>
          <li><a href="javascript:;">我的订单</a></li>
          <li><a href="javascript:;">会员中心</a></li>
        </template>
        <template v-else>
          <li>
            <a href="javascript:;" @click="$router.push('/login')">请先登录</a>
          </li>
          <li><a href="javascript:;">帮助中心</a></li>
          <li><a href="javascript:;">关于我们</a></li>
        </template>
      </ul>
    </div>
  </nav>
</template>

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

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

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

2. 如何配置

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

javascript 复制代码
instance.interceptors.request.use(config => {
  const userStore = useUserStore()
  const token = userStore.userInfo.token
  if( token ) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
}, e=> Promise.reject(e))

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

1. 退出登录业务实现

Popconfirm 气泡确认框 | Element Plus

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

javascript 复制代码
import { defineStore } from 'pinia'
import { loginAPI } from '@/apis/user'
import { ref } from 'vue'

export const useUserStore = defineStore(
  'user',
  () => {
    // 1. 定义管理用户数据的state
    const userInfo = ref({})

    // 2. 定义获取数据的action函数
    const getUserInfo = async ({ account, password }) => {
      const res = await loginAPI({ account, password })
      userInfo.value = res.result
    }
    // 退出登录时清除用户信息
    const clearUserInfo = () => {
      userInfo.value = {}
    }

    // 3. 以对象的形式把state和action return
    return {
      userInfo,
      getUserInfo,
      clearUserInfo
    }
  },
  {
    persist: true
  }
)

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

html 复制代码
<script setup>
import { useUserStore } from '@/stores/user.js'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()

const confirm = () => {
  // 清除登录信息
  userStore.clearUserInfo()
  // 跳转到登录页
  router.push('/login')
}
</script>

<template>
  <nav class="app-topnav">
    <div class="container">
      <ul>
        <!-- 多模板渲染 区分登录状态和非登录状态 -->
        <!-- 判断是否有token -->
        <template v-if="userStore.userInfo.token">
          <li>
            <a href="javascript:;">
              <i class="iconfont icon-user"></i>
              {{ userStore.userInfo.nickname || userStore.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="javascript:;">会员中心</a></li>
        </template>
        <template v-else>
          <li>
            <a href="javascript:;" @click="$router.push('/login')">请先登录</a>
          </li>
          <li><a href="javascript:;">帮助中心</a></li>
          <li><a href="javascript:;">关于我们</a></li>
        </template>
      </ul>
    </div>
  </nav>
</template>

八、登录 - Token失效401拦截

1. 业务背景

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

两个需要思考的问题:

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

答:响应拦截器

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

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

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

src/utils/http.js

javascript 复制代码
import axios from 'axios'
import { useUserStore } from '@/stores/user.js'
import router from '@/router'

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

// axios请求拦截器
instance.interceptors.request.use(
  (config) => {
    // 1. 从pinia获取token数据
    const userStore = useUserStore()
    // 2. 按照后端的要求拼接token数据
    const token = userStore.userInfo.token
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (e) => Promise.reject(e)
)

// axios响应式拦截器
instance.interceptors.response.use(
  (res) => res.data,
  (e) => {
    const userStore = useUserStore()
    // 统一错误提示
    ElMessage({
      type: 'warning',
      message: e.response.data.message
    })

    // 401 token失效处理
    if (e.response.status === 401) {
      // 1. 清除本地用户信息
      userStore.clearUserInfo()
      // 2. 跳转到登录页(进入到详情页才会)
      router.push('/login')
    }
    return Promise.reject(e)
  }
)

export default instance

九、购物车功能实现

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

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

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

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

Input Number 数字输入框 | Element Plus

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

javascript 复制代码
// 封装购物车模块
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore(
  'cart',
  () => {
    // 1. 定义state - cartList
    const cartList = ref([])
    // 2. 定义action - addCart
    const addCart = (goods) => {
      // 添加购物车操作
      // 思路:通过匹配传递过来的商品对象中是skuId能不能在cartList中找到,找到了就是添加过
      const item = cartList.value.find((item) => goods.skuId === item.skuId)
      if (item) {
        // 已添加过,count + 1
        item.count++
      } else {
        // 没有添加过,直接push
        cartList.value.push(goods)
      }
    }

    return {
      cartList,
      addCart
    }
  },
  {
    persist: true
  }
)

②src/views/Detail/index.vue

html 复制代码
<script setup>
// ... ... 
import { useCartStore } from '@/stores/cart.js'

const cartStore = useCartStore()

// sku规格被操作时
let skuObj = {}
const skuChange = (sku) => {
  console.log(sku)
  skuObj = sku
}

const count = ref(1)
const handleChange = (count) => {
  console.log(count)
}

// 添加购物车
const addCart = () => {
  if (skuObj.skuId) {
    // 规格已选择
    cartStore.addCart({
      id: goods.value.id,
      name: goods.value.name,
      picture: goods.value.mainPictures[0],
      price: goods.value.price,
      count: count.value,
      skuId: skuObj.skuId,
      attrsText: skuObj.specsText,
      selected: true
    })
    ElMessage.success('加入购物车成功')
  } else {
    // 规格没有选择,提示用户
    ElMessage.warning('请选择规格')
  }
}
</script>

<template>
  <!-- ... ... -->
  <!-- sku组件 -->
  <XtxSku :goods="goods" @change="skuChange"></XtxSku>
  <!-- 数据组件 -->
  <el-input-number
     v-model="count"
     @change="handleChange"
     :min="1"
  />
  <!-- 按钮组件 -->
  <div>
    <el-button @click="addCart" size="large" class="btn">
      加入购物车
    </el-button>
  </div>
<!-- ... ... -->
</template>

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

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

html 复制代码
<script setup>
import { useCartStore } from '@/stores/cart.js'
const cartStore = useCartStore()
</script>

<template>
  <div class="cart">
    <a class="curr" href="javascript:;">
      <i class="iconfont icon-cart"></i><em>{{ cartStore.cartList.length }}</em>
    </a>
    <div class="layer">
      <div class="list">
        <div class="item" v-for="i in cartStore.cartList" :key="i">
          <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="store.delCart(i.skuId)"
          ></i>
        </div>
      </div>
      <div class="foot">
        <div class="total">
          <p>共 10 件商品</p>
          <p>&yen; 100.00</p>
        </div>
        <el-button size="large" type="primary">去购物车结算</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>

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

html 复制代码
<script setup>
import { useCategoryStore } from '@/stores/category.js'
import HeaderCart from './HeaderCart.vue'

// 使用pinia中的数据
const categoryStore = useCategoryStore()
</script>

<template>
  <header class="app-header">
    <div class="container">
      <h1 class="logo">
        <RouterLink to="/">小兔鲜</RouterLink>
      </h1>
      <ul class="app-header-nav">
        <li
          class="home"
          v-for="item in categoryStore.categoryList"
          :key="item.id"
        >
          <RouterLink active-class="active" :to="`/category/${item.id}`">{{
            item.name
          }}</RouterLink>
        </li>
      </ul>
      <div class="search">
        <i class="iconfont icon-search"></i>
        <input type="text" placeholder="搜一搜" />
      </div>
      <!-- 头部购物车 -->
      <HeaderCart></HeaderCart>
    </div>
  </header>
</template>

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

①src/stores/cart.js

javascript 复制代码
// 封装购物车模块
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore(
  'cart',
  () => {
    // 1. 定义state - cartList
    const cartList = ref([])
    // 2. 定义action - addCart
    // 添加购物车
    const addCart = (goods) => {
      // 添加购物车操作
      // 思路:通过匹配传递过来的商品对象中是skuId能不能在cartList中找到,找到了就是添加过
      const item = cartList.value.find((item) => goods.skuId === item.skuId)
      if (item) {
        // 已添加过,count + 1
        item.count++
      } else {
        // 没有添加过,直接push
        cartList.value.push(goods)
      }
    }

    // 删除购物车
    const delCart = (skuId) => {
      // 思路:1. 找到要删除的下标值 - splice
      //       2. 使用组件的过滤方法 - filter
      const idx = cartList.value.findIndex((item) => skuId === item.skuId)
      cartList.value.splice(idx, 1)
    }

    return {
      cartList,
      addCart,
      delCart
    }
  },
  {
    persist: true
  }
)

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

html 复制代码
<script setup>
import { useCartStore } from '@/stores/cart.js'
const cartStore = useCartStore()
</script>

<template>
  <div class="cart">
    <a class="curr" href="javascript:;">
      <i class="iconfont icon-cart"></i>
      <em v-if="cartStore.cartList.length">{{ cartStore.cartList.length }}</em>
    </a>
    <div class="layer">
      <div class="list">
        <div class="item" v-for="i in cartStore.cartList" :key="i">
          <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="cartStore.delCart(i.skuId)"
          ></i>
        </div>
      </div>
      <div class="foot">
        <div class="total">
          <p>共 10 件商品</p>
          <p>&yen; 100.00</p>
        </div>
        <el-button
          @click="$router.push('/cartlist')"
          size="large"
          type="primary"
          >去购物车结算</el-button
        >
      </div>
    </div>
  </div>
</template>

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

实现思路:计算属性

计算逻辑是什么:

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

①src/stores/cart.js

javascript 复制代码
// 封装购物车模块
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore(
  'cart',
  () => {
    // ... ...

    // 计算属性
    // 1. 总的数量 所有项的count之和
    const allCount = computed(() =>
      cartList.value.reduce((sum, item) => sum + item.count, 0)
    )
    // 2. 总价 所有项的count * price之和
    const allPrice = computed(() =>
      cartList.value.reduce((sum, item) => sum + item.count * item.price, 0)
    )
    return {
      cartList,
      addCart,
      delCart,
      allCount,
      allPrice
    }
  },
  {
    persist: true
  }
)

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

html 复制代码
      <div class="foot">
        <div class="total">
          <p>共 {{ cartStore.allCount }} 件商品</p>
          <p>&yen; {{ cartStore.allPrice.toFixed(2) }}</p>
        </div>
        <el-button
          @click="$router.push('/cartlist')"
          size="large"
          type="primary"
          >去购物车结算</el-button
        >
      </div>
相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax