赞
踩
登录页面的主要功能就是表单校验和登录退出业务
①src/views/Login/index.vue
- <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 © 小兔鲜儿</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
- <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>
作用:前端提前校验可以省去一些错误的请求提交,为后端节省接口压力
ElementPlus表单组件内置了表单校验功能,只需要按照组件要求配置必要参数即可。
思想:当功能很复杂时,通过多个组件各自负责某个功能,再组合成一个大功能是组件设计中的常用方法。
表单校验步骤
自定义校验规则
ElementPlus表单组件内置了初始的校验配置,应付简单的校验只需要通过配置即可,如果想要定制一些特殊的校验需求,可以使用自定义校验规则,格式如下:
校验逻辑:如果勾选了协议框,通过校验,如果没有勾选,不通过校验
src/views/Login/index.vue
- <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 © 小兔鲜儿</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>
思考:每个表单域都有自己的校验触发事件,如果用户一上来就点击登录怎么办呢?
答:在点击登录时需要对所有需要校验的表单进行统一校验
1. 封装登录接口 - src/apis/user.js
- 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
- }
- })
- }
2. 登录成功后续逻辑处理 - src/views/Login/index.vue
- <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 © 小兔鲜儿</p>
- </div>
- </footer>
- </div>
- </template>
3. .eslintrc.cjs - 配置全局变量
- /* 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'
- }
- }
4. 登录失败的逻辑处理 - src/utils/http.js
- 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管理会更加方便
遵循理念:和数据相关的所有操作(state + action)都放到Pinia中,组件只负责触发action函数
①src/stores/user.js
- 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
- <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>
-
持久化用户数据说明
1. 用户数据中有一个关键的数据叫做Token(用来标识当前用户是否登录),而Token持续一段时间才会过期
2. Pinia的存储是基于内存的,刷新就丢失,为了保持登录状态就要做到刷新不丢失,需要配合持久化进行存储。
目的:保持token不丢失,保持登录状态
最终效果:操作state时会自动把用户数据在本地的localStorage也存一份,刷新的时候会从localStorage中先取
快速开始 | pinia-plugin-persistedstate
运行机制:在设置state的时候会自动把数据同步给localstorage,在获取state数据的时候会优先从localstorage中获取。
①安装插件
pnpm i pinia-plugin-persistedstate
②将插件添加到pinia实例上 - main.js
- 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
- 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/Layout/components/LayoutNav.vue
- <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作为用户标识,在很多个接口中都需要携带Token才可以正确获取数据,所以需要在接口调用时携带Token。另外,为了统一控制采取请求拦截器携带的方案。
Axios请求拦截器可以在接口正式发起之前对请求参数做一些事情,通常Token数据会被注入到请求header中,格式按照后端要求的格式进行拼接处理
- 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))
Popconfirm 气泡确认框 | Element Plus
①新增清除用户信息action - src/stores/user.js
- 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
- <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的有效性可以保持一定时间,如果用户一段时间不做任何操作,Token就会失效,使用失效的Token再去请求一些接口,接口就会报401状态码错误,需要我们做额外处理
两个需要思考的问题:
1. 我们能确定用户到底是在访问哪个接口时出现的401错误吗?在什么位置去拦截这个401?
答:响应拦截器
2. 检测到401之后又该干什么呢?
答:清除掉过期的用户信息,跳转到登录页
解决方案:在axios响应拦截器做统一处理
src/utils/http.js
- 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. 整个购物车的实现分为两个大分支,本地购物车操作和接口购物车操作
2. 由于购物车数据的特殊性,采取pinia管理购物车列表数据并添加持久化缓存
Input Number 数字输入框 | Element Plus
①封装购物车模块 - src/stores/cart.js
- // 封装购物车模块
- 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
- <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>
-
①头部购物车组件 - src/views/Layout/components/HeaderCart.vue
- <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">¥{{ 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>¥ 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
- <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>
①src/stores/cart.js
- // 封装购物车模块
- 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
- <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">¥{{ 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>¥ 100.00</p>
- </div>
- <el-button
- @click="$router.push('/cartlist')"
- size="large"
- type="primary"
- >去购物车结算</el-button
- >
- </div>
- </div>
- </div>
- </template>
实现思路:计算属性
计算逻辑是什么:
①src/stores/cart.js
- // 封装购物车模块
- 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
- <div class="foot">
- <div class="total">
- <p>共 {{ cartStore.allCount }} 件商品</p>
- <p>¥ {{ cartStore.allPrice.toFixed(2) }}</p>
- </div>
- <el-button
- @click="$router.push('/cartlist')"
- size="large"
- type="primary"
- >去购物车结算</el-button
- >
- </div>
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。