Vue3+ElementPlus大事件

环境准备

  1. 创建Vue工程

    npm init vue@latest

  2. 安装依赖

    • Element-Plus

      npm install element-plus --save。然后再main.js中导入

    • Axios

      npm install axios

    • Sass【是CSS的语言扩展包,编写CSS样式时会用到】

      npm install sass -D

  3. 目录调整

    • 删除components下面自动生成的内容
    • 新建目录api、utils、views。【这里可以把之前Vue3入门阶段的request.js写好的工具放到utils中】
    • 将资料中的静态资源拷贝到assets目录下
    • 删除App.uve中自动生成的内容。然后运行测试是否有问题

开发步骤

这里重点看后两个步骤

image

注册

image

代码开发

  1. 写入前端代码【通过以后的开发文档】

  2. 定义数据模型,【名字尽量与后端开发文档保持一致,这样传递参数时就不用额外处理了】

    //定义数据模型
    const registerData = ref({
        username: '',
        password: '',
        rePassword: ''
    })
    
  3. 绑定数据

    //表单中
    :model="registerData
    //子级
    v-model="registerData.username"
    v-model="registerData.password"
    v-model="registerData.rePassword"
    
  4. 数据校验,因为用的时Element组件,可以参考Element校验方式。具体可以查看表达校验模块源代码

    //校验密码的函数
    const checkRePassword = (rule, value, callback)=>{
        if (value === '') {
            callback(new Error('请再次确认密码'))
            // 主要要访问响应式对象的内容要.value
        } else if(value !== registerData.value.password){
            callback(new Error('请确保两次输入的密码一样'))
        } else {
            callback()
        }
    }
    //定义表单校验规则
    const rules = {
        username: [
            // 非空true,错误提示信息,在blur即失去焦点的时候响应
            { required: true, message: '请输入用户名', trigger: 'blur' },
            { min: 5, max: 16, message: '长度为5~16位非空字符', trigger: 'blur' }
        ],
        password: [
            { required: true, message: '请输入密码', trigger: 'blur' },
            { min: 5, max: 16, message: '长度为5~16位非空字符', trigger: 'blur' }
        ],
        rePassword: [
            //指定自定义的校验函数,在blur即失去焦点的时候响应
            { validator: checkRePassword, trigger: 'blur' }
        ]
    }
    
  5. 绑定校验规则和校验数据.【在表单中用:rules="rules"绑定校验规则。用prop属性绑定校验数据】

    <!-- 注册表单 -->
    <el-form ref="form" size="large" autocomplete="off" v-if="isRegister" :model="registerData" :rules="rules">
        <el-form-item>
            <h1>注册</h1>
        </el-form-item>
        <el-form-item prop="username">
            <el-input :prefix-icon="User" placeholder="请输入用户名" v-model="registerData.username"></el-input>
        </el-form-item>
        <el-form-item prop="password">
            <el-input :prefix-icon="Lock" type="password" placeholder="请输入密码" v-model="registerData.password"></el-input>
        </el-form-item>
        <el-form-item prop="rePassword">
            <el-input :prefix-icon="Lock" type="password" placeholder="请输入再次密码" v-model="registerData.rePassword"></el-input>
        </el-form-item>
        <!-- 注册按钮 -->
        <el-form-item>
            <el-button class="button" type="primary" auto-insert-space>
                注册
            </el-button>
        </el-form-item>
        <el-form-item class="flex">
            <el-link type="info" :underline="false" @click="isRegister = false">
                ← 返回
            </el-link>
        </el-form-item>
    </el-form>
    
  6. 注册接口调用。先运行已经打包好的后端jar包和redis。然后根据后端开发文档编写调用注册接口的函数

    • 创建user.js文件,编写函数

      //导入request.js请求工具
      import request from '@/utils/request.js'
      
      //提供调用注册接口的函数
      export const userRegisterService = (registerData)=>{
          //文档要求请求参数类型为x-www-form-urlencoded,这里要借助于UrlSearchParams完成传递。
          // 如果这里不借助这个,那么默认是json格式,不符合要求
          const params = new URLSearchParams()
          for (let key in registerData) {
              params.append(key, registerData[key]);
          }
          return request.post('/user/register', params);
      }
      
    • 调用后台接口完成注册

      //调用后台接口,完成注册
      import { userRegisterService } from '@/api/user.js'
      const register = async ()=>{
          // registerData是一个响应式对象,如果要获取值,需要.value
          let result = await userRegisterService(registerData.value);
          if (result.code === 0) {
              //成功了
              alert(result.msg ? result.msg : '注册成功');
          } else {
              //失败了
              alert('注册失败');
          }
      }
      
    • 绑定单击事件

      <el-button class="button" type="primary" auto-insert-space @click="register">
          注册
      </el-button>
      

跨域问题

上面代码编写玩后发现不能成功注册,这是因为跨域问题,即由于浏览器的同源策略限制,向不同源(不同协议、不同域名、不同端口)发送ajax请求会失败

image

跨域通过添加代理解决问题,就是让前端开启代理。配置代理后请求不会直接通过浏览器发给后端服务,而是发给前端服务。然后再由前端服务转发给后端服务,这样就避免了浏览器去访问不同源

image

配置步骤

  1. 在request.js文件中改写baseURL。在没有配置源的情况下,默认就是当前的源,那先最终的路径就是http://localhost:5173/api。其中api作为标记使用

    //定义一个变量,记录公共的前缀,baseURL
    // const baseURL = 'http://localhost:8080';
    const baseURL = '/api';
    
  2. 配置vite.config.js文件。声明操作信息

    server: {
        proxy: {
            '/api': { //获取路径中包含/api的请求。这是的路径一般是http://localhost:5173/api/user/register
                target: 'http://localhost:8080',//修改为后台服务所在源
                    changeOrigin: true,//修改源
                        rewrite: (path)=>path.replace(/^\/api/, '') ///api替换为''
            }
        }
    }
    

登录

image

代码开发

  1. 绑定数据,因为和注册用重合,这里可以复用注册表单数据模型。然后参照注册,进行数据绑定

  2. 数据校验,也可以共用,这届绑定校验规则就行

  3. 事件

    • 先写接口函数

      //提供调用登录接口的函数
      export const userLoginService = (loginData)=>{
          const params = new URLSearchParams();
          for(let key in loginData){
              params.append(key,loginData[key])
          }
          return request.post('/user/login',params)
      }
      
    • 再调用函数

      import { userRegisterService, userLoginService } from '@/api/user.js'
      //绑定数据,复用注册表单的数据模型
      //表单数据校验
      //登录函数
      const login =async ()=>{
          //调用接口,完成登录
         let result =  await userLoginService(registerData.value);
         if(result.code===0){
          alert(result.msg? result.msg : '登录成功')
         }else{
          alert('登录失败')
         }
      }
      
    • 再绑定单击事件

      <el-button class="button" type="primary" auto-insert-space @click="login">
          登录
      </el-button>
      

数据清空

上面发现,输入的数据不会情况,注册和登录数据串扰了。这里可以添加一个单击切换按钮的清空功能

  1. 编写函数

    //定义函数,清空数据模型的数据
    const clearRegisterData = ()=>{
        registerData.value={
            username:'',
            password:'',
            rePassword:''
        }
    }
    
  2. 绑定单击事件,注意这里已经有了单击事件了,可以加个分号继续加上另外单击事件名称。相当于js代码了

    <el-link type="info" :underline="false" @click="isRegister = false;clearRegisterData()">
        ← 返回
    </el-link>
    <el-link type="info" :underline="false" @click="isRegister = true;clearRegisterData()">
        注册 →
    </el-link>
    

axios响应拦截器

在之前会判断成功与否,可以看出代码高度相似。这里可以用响应拦截器统一实现

  1. 改写已有的响应拦截器

    //添加响应拦截器
    instance.interceptors.response.use(
        result=>{
            //判断业务状态码
            if(result.data.code===0) {
                return result.data;
            }
    
            //操作失败
            alert(result.data.msg?result.data.msg:'服务异常')
    
            //异步操作的状态转换为失败
            return Promise.reject(result.data)
        },
        err=>{
            alert('服务异常');
            return Promise.reject(err);//异步的状态转化成失败的状态
        }
    )
    
  2. 修改Login.vue文件,这里以注册为例

    const register = async ()=>{
        // registerData是一个响应式对象,如果要获取值,需要.value
        let result = await userRegisterService(registerData.value);
        /* if (result.code === 0) {
            //成功了
            alert(result.msg ? result.msg : '注册成功');
        } else {
            //失败了
            alert('注册失败');
        } */
        //因为通过了拦截器也就意味着响应成功了,所以直接输出成功即可
        alert(result.msg ? result.msg : '注册成功');
    }
    

提示框

由于现在是浏览器自带的提示框,看起来比较丑,这里同样可以用Element提供的组件Message消息提示

  1. 分别在request.js和Login.vue两个文件中引入

    import { ElMessage } from 'element-plus'
    
  2. 改写提示部分的代码

    //request.js
    //操作失败
    // alert(result.data.msg?result.data.msg:'服务异常')
    ElMessage.error(result.data.msg?result.data.msg:'服务异常')
    
    //Login.vue
    // alert(result.msg ? result.msg : '注册成功');
    ElMessage.success(result.msg ? result.msg : '注册成功')
    

主页面搭建

image

创建Layout.vue文件,同样要显示要引入到App.vue文件中

<script setup>
import {
    Management,
    Promotion,
    UserFilled,
    User,
    Crop,
    EditPen,
    SwitchButton,
    CaretBottom
} from '@element-plus/icons-vue'
import avatar from '@/assets/default.png'
</script>

<template>
    <!-- element-plus中的容器 -->
    <el-container class="layout-container">
        <!-- 左侧菜单 -->
         <!-- element-plus中的菜单标签 -->
        <el-aside width="200px">
            <div class="el-aside__logo"></div>
            <el-menu active-text-color="#ffd04b" background-color="#232323"  text-color="#fff"
                router>
                <el-menu-item >
                    <el-icon>
                        <Management />
                    </el-icon>
                    <span>文章分类</span>
                </el-menu-item>
                <el-menu-item >
                    <el-icon>
                        <Promotion />
                    </el-icon>
                    <span>文章管理</span>
                </el-menu-item>
                <!-- element-plus中的子菜单标签 -->
                <el-sub-menu >
                    <template #title>
                        <el-icon>
                            <UserFilled />
                        </el-icon>
                        <span>个人中心</span>
                    </template>
                    <el-menu-item >
                        <el-icon>
                            <User />
                        </el-icon>
                        <span>基本资料</span>
                    </el-menu-item>
                    <el-menu-item >
                        <el-icon>
                            <Crop />
                        </el-icon>
                        <span>更换头像</span>
                    </el-menu-item>
                    <el-menu-item >
                        <el-icon>
                            <EditPen />
                        </el-icon>
                        <span>重置密码</span>
                    </el-menu-item>
                </el-sub-menu>
            </el-menu>
        </el-aside>
        <!-- 右侧主区域 -->
        <el-container>
            <!-- 头部区域 -->
            <el-header>
                <div>程序员:<strong>ario</strong></div>
                <el-dropdown placement="bottom-end">
                    <span class="el-dropdown__box">
                        <el-avatar :src="avatar" />
                        <el-icon>
                            <CaretBottom />
                        </el-icon>
                    </span>
                    //下拉菜单
                    <template #dropdown>
                        <el-dropdown-menu>
                            <el-dropdown-item command="profile" :icon="User">基本资料</el-dropdown-item>
                            <el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
                            <el-dropdown-item command="password" :icon="EditPen">重置密码</el-dropdown-item>
                            <el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
                        </el-dropdown-menu>
                    </template>
                </el-dropdown>
            </el-header>
            <!-- 中间区域 -->
            <el-main>
                <div style="width: 1290px; height: 570px;border: 1px solid red;">
                    内容展示区
                </div>
            </el-main>
            <!-- 底部区域 -->
            <el-footer>大事件 ©2023 Created by 程序员</el-footer>
        </el-container>
    </el-container>
</template>

<style lang="scss" scoped>
.layout-container {
    height: 100vh;

    .el-aside {
        background-color: #232323;

        &__logo {
            height: 120px;
            background: url('@/assets/logo.png') no-repeat center / 120px auto;
        }

        .el-menu {
            border-right: none;
        }
    }

    .el-header {
        background-color: #fff;
        display: flex;
        align-items: center;
        justify-content: space-between;

        .el-dropdown__box {
            display: flex;
            align-items: center;

            .el-icon {
                color: #999;
                margin-left: 10px;
            }

            &:active,
            &:focus {
                outline: none;
            }
        }
    }

    .el-footer {
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 14px;
        color: #666;
    }
}
</style>

路由

介绍

搭建主页面是发现要显示主页面就要把登录注册页面注释掉。拿实际开发中肯定不是这样的。这里就用到了路由来解决

  • 路由,决定从起点到终点的路径的进程
  • 在前端工程中,路由指的是根据不同的访问路径,展示不同组件的内容
  • 实现路由的方法有很多,这里外面使用Vue Router,是Vue.js的官方路由

image

Vue Router

  • 安装vue-router npm install vue-router@4

  • 在src/router/index.js中创建路由器,并导出

    import {createRouter, createWebHistory} from 'vue-router'
    
    // 导入组件
    import LoginVue from '@/views/Login.vue'
    import LayoutVue from '@/views/Layout.vue'
    
    //定义路由关系
    const routes = [
        { path: '/login', component: LoginVue},
        { path: '/', component: LayoutVue}
    ]
    
    //创建路由器,指定路由模式和路由关系
    const router = createRouter({
        history: createWebHistory(),
        routes:routes
    })
    
    //导出路由
    export default router
    
  • 在vue应用实例中使用vue-router,【main.js文件中】

    import './assets/main.scss'
    
    import { createApp } from 'vue'
    import ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css'
    // 这里路径省略了index.js,因为默认的即使这个名字,如果是别的名称就要显示声明了
    import router from './router'
    
    import App from './App.vue'
    
    const app = createApp(App)
    app.use(router)
    app.use(ElementPlus)
    app.mount('#app')
    
  • 声明router-view标签,展示组件内容【App.vue文件】

    <script setup>
    </script>
    
    <template>
      <router-view></router-view>
    </template>
    
    <style scoped>
    
    </style>
    

页面跳转

以登录成功跳转到首页为例【注意useRouter拼写,别user了。否则出错】

//这里相当于导入了userRouter函数
import {useRouter} from 'vue-router'
// //调用函数生成路由器
const router = useRouter()
const login =async ()=>{
    //调用接口,完成登录
   let result =  await userLoginService(registerData.value);
   /* if(result.code===0){
    alert(result.msg? result.msg : '登录成功')
   }else{
    alert('登录失败')
   } */
//    alert(result.msg? result.msg : '登录成功')
   ElMessage.success(result.msg ? result.msg : '登录成功')
   //跳转到首页,路由完成跳转
   router.push('/')
}

子路由

image

这里的二级路由就是一级路由的子路由

案例步骤:

  • 复制资料中提供好的五个组件

  • 配置子路由【router/index.js】

    // 导入组件
    import LoginVue from '@/views/Login.vue'
    import LayoutVue from '@/views/Layout.vue'
    
    import {ArticleCategoryVue} from '@/views/article/ArticleCategory.vue'
    import {ArticleManageVue} from '@/views/article/ArticleManage.vue'
    import {UserAvatarVue} from '@/views/article/UserAvatargory.vue'
    import {UserInfoVue} from '@/views/article/UserInfo.vue'
    import {UserResetPasswordVue} from '@/views/article/UserResetPassword.vue'
    
    //定义路由关系
    const routes = [
        { path: '/login', component: LoginVue },
        { path: '/', component: LayoutVue, children: [
            { path: '/article/category', component: ArticleCategoryVue },
            { path: '/article/manage', component: ArticleManageVue },
            { path: '/user/avatar', component: UserAvatarVue },
            { path: '/user/info', component: UserInfoVue },
            { path: '/user/resetPassword', component: UserResetPasswordVue }
        ]}
    ]
    
  • 声明router-view标签【Layout.vue】

    <!-- 中间区域 -->
    <el-main>
        <!-- <div style="width: 1290px; height: 570px;border: 1px solid red;">
    内容展示区
    </div> -->
        <router-view></router-view>
    </el-main>
    
  • 为菜单项 el-menu-item 设置index属性,设置点击后的路由路径

    <template>
        <!-- element-plus中的容器 -->
        <el-container class="layout-container">
            <!-- 左侧菜单 -->
             <!-- element-plus中的菜单标签 -->
            <el-aside width="200px">
                <div class="el-aside__logo"></div>
                <el-menu active-text-color="#ffd04b" background-color="#232323"  text-color="#fff"
                    router>
                    <el-menu-item index="/article/category">
                        <el-icon>
                            <Management />
                        </el-icon>
                        <span>文章分类</span>
                    </el-menu-item>
                    <el-menu-item index="/article/manage">
                        <el-icon>
                            <Promotion />
                        </el-icon>
                        <span>文章管理</span>
                    </el-menu-item>
                    <!-- element-plus中的子菜单标签 -->
                    <el-sub-menu >
                        <template #title>
                            <el-icon>
                                <UserFilled />
                            </el-icon>
                            <span>个人中心</span>
                        </template>
                        <el-menu-item index="/user/info">
                            <el-icon>
                                <User />
                            </el-icon>
                            <span>基本资料</span>
                        </el-menu-item>
                        <el-menu-item index="/user/avatar">
                            <el-icon>
                                <Crop />
                            </el-icon>
                            <span>更换头像</span>
                        </el-menu-item>
                        <el-menu-item index="/user/resetPassword">
                            <el-icon>
                                <EditPen />
                            </el-icon>
                            <span>重置密码</span>
                        </el-menu-item>
                    </el-sub-menu>
                </el-menu>
            </el-aside>
            <!-- 右侧主区域 -->
            <el-container>
                <!-- 头部区域 -->
                <el-header>
                    <div>程序员:<strong>ario</strong></div>
                    <el-dropdown placement="bottom-end">
                        <span class="el-dropdown__box">
                            <el-avatar :src="avatar" />
                            <el-icon>
                                <CaretBottom />
                            </el-icon>
                        </span>
                        //下拉菜单
                        <template #dropdown>
                            <el-dropdown-menu>
                                <el-dropdown-item command="profile" :icon="User">基本资料</el-dropdown-item>
                                <el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
                                <el-dropdown-item command="password" :icon="EditPen">重置密码</el-dropdown-item>
                                <el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
                            </el-dropdown-menu>
                        </template>
                    </el-dropdown>
                </el-header>
                <!-- 中间区域 -->
                <el-main>
                    <!-- <div style="width: 1290px; height: 570px;border: 1px solid red;">
                        内容展示区
                    </div> -->
                    <router-view></router-view>
                </el-main>
                <!-- 底部区域 -->
                <el-footer>大事件 ©2023 Created by 程序员</el-footer>
            </el-container>
        </el-container>
    </template>
    
  • 优化:这里子页面切换没问题,但是直接访问主页面是子区域为空。这里可以设置一个子页面,要用到在index.js中的重定向redirect指定页面

    //定义路由关系
    const routes = [
        { path: '/login', component: LoginVue },
        { path: '/', component: LayoutVue, redirect: '/article/manage', children: [
            { path: '/article/category', component: ArticleCategoryVue },
            { path: '/article/manage', component: ArticleManageVue },
            { path: '/user/avatar', component: UserAvatarVue },
            { path: '/user/info', component: UserInfoVue },
            { path: '/user/resetPassword', component: UserResetPasswordVue }
        ]}
    ]
    

文章分类列表

文章分类组件

<script setup>
import {
    Edit,
    Delete
} from '@element-plus/icons-vue'
import { ref } from 'vue'
const categorys = ref([
    {
        "id": 3,
        "categoryName": "美食",
        "categoryAlias": "my",
        "createTime": "2023-09-02 12:06:59",
        "updateTime": "2023-09-02 12:06:59"
    },
    {
        "id": 4,
        "categoryName": "娱乐",
        "categoryAlias": "yl",
        "createTime": "2023-09-02 12:08:16",
        "updateTime": "2023-09-02 12:08:16"
    },
    {
        "id": 5,
        "categoryName": "军事",
        "categoryAlias": "js",
        "createTime": "2023-09-02 12:08:33",
        "updateTime": "2023-09-02 12:08:33"
    }
])
</script>
<template>
    <el-card class="page-container">
        <template #header>
            <div class="header">
                <span>文章分类</span>
                <div class="extra">
                    <el-button type="primary">添加分类</el-button>
                </div>
            </div>
        </template>
        <el-table :data="categorys" style="width: 100%">
            <el-table-column label="序号" width="100" type="index"> </el-table-column>
            <el-table-column label="分类名称" prop="categoryName"></el-table-column>
            <el-table-column label="分类别名" prop="categoryAlias"></el-table-column>
            <el-table-column label="操作" width="100">
                <template #default="{ row }">
                    <el-button :icon="Edit" circle plain type="primary" ></el-button>
                    <el-button :icon="Delete" circle plain type="danger"></el-button>
                </template>
            </el-table-column>
            <template #empty>
                <el-empty description="没有数据" />
            </template>
        </el-table>
    </el-card>
</template>

<style lang="scss" scoped>
.page-container {
    min-height: 100%;
    box-sizing: border-box;

    .header {
        display: flex;
        align-items: center;
        justify-content: space-between;
    }
}
</style>

列表接口调用

src/api/article.js

//导入请求工具类
import request from '@/utils/request.js'

//文章分类列表查询
export const articleCategoryListService = ()=>{
    return request.get('/category')
}

ArticleCategory.vue

//获取所有文章分类数据
import { articleCategoryListService } from '@/api/article.js'
const getAllCategory = async () => {
    let result = await articleCategoryListService();
    categorys.value = result.data;
}
getAllCategory();

但是上述的代码并不能真正的获取到所有文章分类数据,服务器响应状态码为401,因为目前请求头中并没有携带token

Pinia状态管理库

Pinia是Vue的专属状态管理库,它允许你跨组件或页面共享状态

image

使用步骤

  • 安装pinia npm install pinia

  • 在vue应用实例中使用pinia

    在main.js中,引入pinia,创建pinia实例,并调用vue应用实例的use方法使用pinia

    import { createPinia } from 'pinia'
    
    const pinia = createPinia()
    app.use(pinia)
    
  • 在src/stores/token.js中定义store

    在src/stores目录下定义token.js

    import { defineStore } from "pinia";
    import {ref} from 'vue';
    
    /*
        命名规则为use+对象+Store
        defineStore参数描述:
            第一个参数:给状态起名,具有唯一性
            第二个参数:函数,可以把定义该状态中拥有的内容
    
        defineStore返回值描述:
            返回的是一个函数,将来可以调用该函数,得到第二个参数中返回的内容
    */
    export const useTokenStore = defineStore('token',()=>{
        //1.定义描述token
        const token = ref('')
    
        //2.定义修改token的方法
        const setToken = (newToken)=>{
            token.value = newToken
        }
    
        //3.定义移除token的方法
        const removeToken = ()=>{
            token.value=''
        }
        return {
            token,setToken,removeToken
        }
    })
    
  • 在组件中使用store

    在需要使用状态的地方,导入@/stores/*.js , 使用即可

    在Login.vue中导入@/stores/token.js, 并且当用户登录成功后,将token保存pinia中

    //导入token状态
    import { useTokenStore } from '@/stores/token.js'
    
    //调用useTokenStore得到状态
    const tokenStore = useTokenStore();
    
    //用于登录的事件函数
    const login = async () => {
        let result = await userloginService(registerData.value)
        //保存token
        tokenStore.setToken(result.data)
        
        ElMessage.success('登录成功!')
        router.push('/')
    }
    

    在article.js中导入@/stores/token.js, 从pinia中获取到存储的token,在发起查询文章分类列表的时候把token通过请求头的形式携带给服务器

    //导入@/stores/token.js
    import { useTokenStore } from '../stores/token'
    
    
    //文章分类列表查询
    export const articleCategoryListService = () => {
        //获取token状态
        const tokenStore = useTokenStore()
        //通过请求头Authorization携带token。同时注意在pinia中定义的响应式对象不需要.value使用
        return request.get('/category', { headers: { 'Authorization': tokenStore.token } })
    }
    

axios请求拦截器

当进入主页后,将来要与后台交互,都需要携带token,如果每次请求都写这样的代码,将会比较繁琐,此时可以将携带token的代码通过请求拦截器统一处理。这样在api文件中就可以不用传token了

在 src/util/request.js中

//导入token状态
import { useTokenStore } from '@/stores/token.js';
//添加请求拦截器
instance.interceptors.request.use(
    (config)=>{
        //在发送请求之前做什么
        let tokenStore = useTokenStore()
        //如果token中有值,在携带
        if(tokenStore.token){
            config.headers.Authorization=tokenStore.token
        }
        return config
    },
    (err)=>{
        //如果请求错误做什么
        Promise.reject(err)
    }
)

Pinia持久化插件

默认情况下,由于pinia是内存存储,当你刷新页面的时候pinia中的数据会丢失,可以借助于persist插件解决这个问题,persist插件支持将pinia中的数据持久化到sessionStorage和localStorage中

安装persist插件

npm install pinia-persistedstate-plugin

pinia中使用persist插件

在main.js中

import { createPinia } from 'pinia'
//导入持久化插件
import {createPersistedState} from'pinia-persistedstate-plugin'
const pinia = createPinia()
const persist = createPersistedState()
//pinia使用持久化插件
pinia.use(persist)
app.use(pinia)

在创建定义状态是配置持久化

在src/stores/token.js中

export const useTokenStore = defineStore('token',()=>{
    //1.定义描述token
    const token = ref('')

    //2.定义修改token的方法
    const setToken = (newToken)=>{
        token.value = newToken
    }

    //3.定义移除token的方法
    const removeToken = ()=>{
        token.value=''
    }
    return {
        token,setToken,removeToken
    }
}
,
//参数持久化
{
    persist:true
}
)

未登录统一处理

上面发现没有登录时依然可以访问主页面,虽然后端连接了请求,但是前端页面的访问也要拦截。

在后续访问接口时,如果没有登录,则前端不携带token,后台服务器会返回响应状态码401,代表未登录,此时可以在axios的响应拦截器中,统一对未登录的情况做处理

request.js

//注意这里的导入对象,要导入已有的router对象
import router from '@/router'


//添加响应拦截器
instance.interceptors.response.use(
    result => {
        //如果业务状态码为0,代表本次操作成功
        if (result.data.code == 0) {
            return result.data;
        }
        //代码走到这里,代表业务状态码不是0,本次操作失败
        ElMessage.error(result.data.message || '服务异常');
        return Promise.reject(result.data);//异步的状态转化成失败的状态
    },
    err => {
        //如果响应状态码时401,代表未登录,给出对应的提示,并跳转到登录页
        if(err.response.status===401){
            ElMessage.error('请先登录!')
            router.push('/login')
        }else{
            ElMessage.error('服务异常');
        }
        return Promise.reject(err);//异步的状态转化成失败的状态
    }
)

添加文章分类

添加分类弹窗页面

<!-- 在el-card标签中添加分类弹窗 -->
<el-dialog v-model="dialogVisible" title="添加弹层" width="30%">
    <el-form :model="categoryModel" :rules="rules" label-width="100px" style="padding-right: 30px">
        <el-form-item label="分类名称" prop="categoryName">
            <el-input v-model="categoryModel.categoryName" minlength="1" maxlength="10"></el-input>
        </el-form-item>
        <el-form-item label="分类别名" prop="categoryAlias">
            <el-input v-model="categoryModel.categoryAlias" minlength="1" maxlength="15"></el-input>
        </el-form-item>
    </el-form>
    <template #footer>
        <span class="dialog-footer">
            <el-button @click="dialogVisible = false">取消</el-button>
            <el-button type="primary"> 确认 </el-button>
        </span>
    </template>
</el-dialog>

数据模型和校验规则

//控制添加分类弹窗
const dialogVisible = ref(false)

//添加分类数据模型
const categoryModel = ref({
    categoryName: '',
    categoryAlias: ''
})
//添加分类表单校验
const rules = {
    categoryName: [
        { required: true, message: '请输入分类名称', trigger: 'blur' },
    ],
    categoryAlias: [
        { required: true, message: '请输入分类别名', trigger: 'blur' },
    ]
}

添加分类按钮单击事件

 <el-button type="primary" @click="dialogVisible = true">添加分类</el-button>

接口调用

在article.js中提供添加分类的函数

//添加文章分类
export const articleCategoryAddService = (categoryModel) => {
    //这次需要json格式的参数,直接传就行
    return request.post('/category', categoryModel)
}

在页面中调用接口

//访问后台,添加文章分类。上面要导入相关信息
const addCategory = async ()=>{
    //调用接口
    let result = await articleCategoryAddService(categoryModel.value);
    ElMessage.success(result.message? result.message:'添加成功')
    //隐藏弹窗
    dialogVisible.value = false
    //再次访问后台接口,查询所有分类
    getAllCategory()
}
<el-button type="primary" @click="addCategory"> 确认 </el-button>

修改文章分类

修改分类弹窗页面

image

修改分类弹窗和新增文章分类弹窗长的一样,所以可以复用添加分类的弹窗

弹窗标题显示
定义标题

//弹窗标题
const title=ref('')

在弹窗上绑定标题

 <el-dialog v-model="dialogVisible" :title="title" width="30%">

为添加分类按钮绑定事件

<el-button type="primary" @click="title='添加分类';dialogVisible = true">添加分类</el-button>

为修改分类按钮绑定事件

<el-button :icon="Edit" circle plain type="primary" @click="title='修改分类';dialogVisible=true"></el-button>

数据回显

当点击修改分类按钮时,需要把当前这一条数据的详细信息显示到修改分类的弹窗上,这个叫回显

通过插槽的方式得到被点击按钮所在行的数据,这里row就是当前行的数据。上面click绑定的属性太多了,这里可以直接写个函数,可以直接调用函数,更加方便

<template #default="{ row }">
    <el-button :icon="Edit" circle plain type="primary" @click="updateCategoryEcho(row)"></el-button>
    <el-button :icon="Delete" circle plain type="danger"></el-button>
</template>

回显函数

//修改分类回显
const updateCategoryEcho = (row) => {
    //这里就不同于在标签内部的负责了,要用.value赋值
    title.value = '修改分类'
    dialogVisible.value = true
    //将row中的数据赋值给categoryModel
    categoryModel.value.categoryName=row.categoryName
    categoryModel.value.categoryAlias=row.categoryAlias
    //修改的时候必须传递分类的id,所以扩展一个id属性
    categoryModel.value.id=row.id
}

接口调用

article.js中提供修改分类的函数。注意这里的请求路径一样,是因为后端当时用请求类型区分的

//修改分类
export const articleCategoryUpdateService = (categoryModel)=>{
    return request.put('/category',categoryModel)
}

修改确定按钮的绑定事件,因为确认按钮也复用了,要判断一下

<span class="dialog-footer">
    <el-button @click="dialogVisible = false">取消</el-button>
    <el-button type="primary" @click="title==='添加分类'? addCategory():updateCategory()"> 确认 </el-button>
</span>

调用接口完成修改的函数

//修改分类
const updateCategory=async ()=>{
    let result = await articleCategoryUpdateService(categoryModel.value)
    ElMessage.success(result.message? result.message:'修改成功')
    //隐藏弹窗
    dialogVisible.value=false
    //再次访问后台接口,查询所有分类
    getAllCategory()
}

由于现在修改和新增共用了一个数据模型,所以在点击添加分类后,有时候会显示数据,此时可以将categoryModel中的数据清空

//清空模型数据
const clearCategoryModel = ()=>{
    categoryModel.value.categoryName='',
    categoryModel.value.categoryAlias=''
}

修改添加按钮的点击事件

 <el-button type="primary" @click="title = '添加分类'; dialogVisible = true;clearCategoryModel()">添加分类</el-button>

删除分类

image

确认框

//删除分类  给删除按钮绑定事件
const deleteCategory = (row) => {
    ElMessageBox.confirm(
        '你确认删除该分类信息吗?',
        '温馨提示',
        {
            confirmButtonText: '确认',
            cancelButtonText: '取消',
            type: 'warning',
        }
    )
        .then(() => {
            //用户点击了确认
            ElMessage({
                type: 'success',
                message: '删除成功',
            })
        })
        .catch(() => {
            //用户点击了取消
            ElMessage({
                type: 'info',
                message: '取消删除',
            })
        })
}

接口调用

article.js中提供删除分类的函数

//删除分类
export const articleCategoryDeleteService = (id) => {
    //这里开发文档去参数类型时qureyString,可以直接拼接参数
    return request.delete('/category?id='+id)
}

当用户点击确认后,调用接口删除分类。记得及时导入ElMessageBox和articleCategoryDeleteService

//删除分类
const deleteCategory = (row) => {
    ElMessageBox.confirm(
        '你确认删除该分类信息吗?',
        '温馨提示',
        {
            confirmButtonText: '确认',
            cancelButtonText: '取消',
            type: 'warning',
        }
    )
        .then(async () => {
            //用户点击了确认
            let result = await articleCategoryDeleteService(row.id)
            ElMessage.success(result.message?result.message:'删除成功')
            //再次调用getAllCategory,获取所有文章分类
            getAllCategory()
        })
        .catch(() => {
            //用户点击了取消
            ElMessage({
                type: 'info',
                message: '取消删除',
            })
        })
}

文章列表

image

文章列表页面组件

可以根据页面,数据,事件逐一分析

<script setup>
import {
    Edit,
    Delete
} from '@element-plus/icons-vue'

import { ref } from 'vue'

//文章分类数据模型
const categorys = ref([
    {
        "id": 3,
        "categoryName": "美食",
        "categoryAlias": "my",
        "createTime": "2023-09-02 12:06:59",
        "updateTime": "2023-09-02 12:06:59"
    },
    {
        "id": 4,
        "categoryName": "娱乐",
        "categoryAlias": "yl",
        "createTime": "2023-09-02 12:08:16",
        "updateTime": "2023-09-02 12:08:16"
    },
    {
        "id": 5,
        "categoryName": "军事",
        "categoryAlias": "js",
        "createTime": "2023-09-02 12:08:33",
        "updateTime": "2023-09-02 12:08:33"
    }
])

//用户搜索时选中的分类id
const categoryId=ref('')

//用户搜索时选中的发布状态
const state=ref('')

//文章列表数据模型
const articles = ref([
    {
        "id": 5,
        "title": "陕西旅游攻略",
        "content": "兵马俑,华清池,法门寺,华山...爱去哪去哪...",
        "coverImg": "https://big-event-gwd.oss-cn-beijing.aliyuncs.com/9bf1cf5b-1420-4c1b-91ad-e0f4631cbed4.png",
        "state": "草稿",
        "categoryId": 2,
        "createTime": "2023-09-03 11:55:30",
        "updateTime": "2023-09-03 11:55:30"
    },
    {
        "id": 5,
        "title": "陕西旅游攻略",
        "content": "兵马俑,华清池,法门寺,华山...爱去哪去哪...",
        "coverImg": "https://big-event-gwd.oss-cn-beijing.aliyuncs.com/9bf1cf5b-1420-4c1b-91ad-e0f4631cbed4.png",
        "state": "草稿",
        "categoryId": 2,
        "createTime": "2023-09-03 11:55:30",
        "updateTime": "2023-09-03 11:55:30"
    },
    {
        "id": 5,
        "title": "陕西旅游攻略",
        "content": "兵马俑,华清池,法门寺,华山...爱去哪去哪...",
        "coverImg": "https://big-event-gwd.oss-cn-beijing.aliyuncs.com/9bf1cf5b-1420-4c1b-91ad-e0f4631cbed4.png",
        "state": "草稿",
        "categoryId": 2,
        "createTime": "2023-09-03 11:55:30",
        "updateTime": "2023-09-03 11:55:30"
    },
])

//分页条数据模型
const pageNum = ref(1)//当前页
const total = ref(20)//总条数
const pageSize = ref(3)//每页条数

//当每页条数发生了变化,调用此函数
const onSizeChange = (size) => {
    pageSize.value = size
}
//当前页码发生变化,调用此函数
const onCurrentChange = (num) => {
    pageNum.value = num
}
</script>
<template>
    <el-card class="page-container">
        <template #header>
            <div class="header">
                <span>文章管理</span>
                <div class="extra">
                    <el-button type="primary">添加文章</el-button>
                </div>
            </div>
        </template>
        <!-- 搜索表单 -->
        <el-form inline class="demo-form-inline">
            <el-form-item label="文章分类:">
                <el-select placeholder="请选择" v-model="categoryId">
                    <el-option 
                        v-for="c in categorys" 
                        :key="c.id" 
                        :label="c.categoryName"
                        :value="c.id">
                    </el-option>
                </el-select>
            </el-form-item>

            <el-form-item label="发布状态:">
                <el-select placeholder="请选择" v-model="state">
                    <el-option label="已发布" value="已发布"></el-option>
                    <el-option label="草稿" value="草稿"></el-option>
                </el-select>
            </el-form-item>
            <el-form-item>
                <el-button type="primary">搜索</el-button>
                <el-button>重置</el-button>
            </el-form-item>
        </el-form>
        <!-- 文章列表 -->
        <el-table :data="articles" style="width: 100%">
            <el-table-column label="文章标题" width="400" prop="title"></el-table-column>
            <el-table-column label="分类" prop="categoryId"></el-table-column>
            <el-table-column label="发表时间" prop="createTime"> </el-table-column>
            <el-table-column label="状态" prop="state"></el-table-column>
            <el-table-column label="操作" width="100">
                <template #default="{ row }">
                    <el-button :icon="Edit" circle plain type="primary"></el-button>
                    <el-button :icon="Delete" circle plain type="danger"></el-button>
                </template>
            </el-table-column>
            <template #empty>
                <el-empty description="没有数据" />
            </template>
        </el-table>
        <!-- 分页条 -->
        <el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize" :page-sizes="[3, 5 ,10, 15]"
            layout="jumper, total, sizes, prev, pager, next" background :total="total" @size-change="onSizeChange"
            @current-change="onCurrentChange" style="margin-top: 20px; justify-content: flex-end" />
    </el-card>
</template>
<style lang="scss" scoped>
.page-container {
    min-height: 100%;
    box-sizing: border-box;

    .header {
        display: flex;
        align-items: center;
        justify-content: space-between;
    }
}
.demo-form-inline .el-input {
    --el-input-width: 220px;
}

.demo-form-inline .el-select {
    --el-select-width: 220px;
}
</style>

使用中文语言包,解决分页条中文问题, 在main.js中完成

import locale from 'element-plus/dist/locale/zh-cn.js'

app.use(ElementPlus,{locale})

文章分类数据回显

调用后台接口,获取所有文章分类信息,并且显示

ArticleMange.vue

//文章列表查询
import { articleCategoryListService } from '@/api/article.js'
const getArticleCategoryList = async () => {
    //获取所有分类
    let resultC = await articleCategoryListService();
    categorys.value = resultC.data
}
getArticleCategoryList();

文章列表接口调用

article.js中提供获取文章列表数据的函数

//文章列表查询
export const articleListService = (params) => {
    return request.get('/article', { params: params })
}

ArticleManage.vue中,调用接口获取数据

//文章列表查询
import { articleListService } from '@/api/article.js'
const getArticles = async () => {
    //因为参数比较多,这里组合封装一下
    let params = {
        //页数,具体可见开发文档和后端代码
        pageNum: pageNum.value,
        pageSize: pageSize.value,
        //分类id,如果没有赋值,就传空值
        categoryId: categoryId.value ? categoryId.value : null,
        state: state.value ? state.value : null
    }
    let result = await articleListService(params);
    //渲染列表数据
    articles.value = result.data.items
    //为列表中添加categoryName属性。因为前面在文章分类字段还是分类id号,这里要转换成为名称
    //第一个for循环遍历的是返回的数据
    for(let i=0;i<articles.value.length;i++){
        let article = articles.value[i];
        //第二个for是遍历已有的文章分类,找到与当前分类匹配的分类
        for(let j=0;j<categorys.value.length;j++){
            if(article.categoryId===categorys.value[j].id){
                article.categoryName=categorys.value[j].categoryName
            }
        }
    }
    //渲染总条数
    total.value=result.data.total
}

当分页条的当前页和每页条数发生变化,重新发送请求获取数据

//当每页条数发生了变化,调用此函数
const onSizeChange = (size) => {
    pageSize.value = size
    getArticles()
}
//当前页码发生变化,调用此函数
const onCurrentChange = (num) => {
    pageNum.value = num
    getArticles()
}

绑定搜索和重置事件按钮

<el-form-item>
    <el-button type="primary" @click="getArticles">搜索</el-button>
    <el-button @click="categoryId='';state=''">重置</el-button>
</el-form-item>

搜索和重置

为搜索按钮绑定单击事件,调用getArticles函数即可

<el-button type="primary" @click="getArticles">搜索</el-button>

为重置按钮绑定单击事件,清除categoryId和state的之即可

 <el-button @click="categoryId='';state=''">重置</el-button>

添加文章

image

添加文章抽屉组件

import {Plus} from '@element-plus/icons-vue'
//控制抽屉是否显示
const visibleDrawer = ref(false)
//添加表单数据模型
const articleModel = ref({
    title: '',
    categoryId: '',
    coverImg: '',
    content:'',
    state:''
})
<!-- 抽屉 -->
<el-drawer v-model="visibleDrawer" title="添加文章" direction="rtl" size="50%">
    <!-- 添加文章表单 -->
    <el-form :model="articleModel" label-width="100px" >
        <el-form-item label="文章标题" >
            <el-input v-model="articleModel.title" placeholder="请输入标题"></el-input>
        </el-form-item>
        <el-form-item label="文章分类">
            <el-select placeholder="请选择" v-model="articleModel.categoryId">
                <el-option v-for="c in categorys" :key="c.id" :label="c.categoryName" :value="c.id">
                </el-option>
            </el-select>
        </el-form-item>
        <el-form-item label="文章封面">

            <el-upload class="avatar-uploader" :auto-upload="true" :show-file-list="false">
                <img v-if="articleModel.coverImg" :src="articleModel.coverImg" class="avatar" />
                <el-icon v-else class="avatar-uploader-icon">
                    <Plus />
                </el-icon>
            </el-upload>
        </el-form-item>
        <el-form-item label="文章内容">
            <div class="editor">富文本编辑器</div>
        </el-form-item>
        <el-form-item>
            <el-button type="primary">发布</el-button>
            <el-button type="info">草稿</el-button>
        </el-form-item>
    </el-form>
</el-drawer>
/* 抽屉样式 */
.avatar-uploader {
    :deep() {
        .avatar {
            width: 178px;
            height: 178px;
            display: block;
        }

        .el-upload {
            border: 1px dashed var(--el-border-color);
            border-radius: 6px;
            cursor: pointer;
            position: relative;
            overflow: hidden;
            transition: var(--el-transition-duration-fast);
        }

        .el-upload:hover {
            border-color: var(--el-color-primary);
        }

        .el-icon.avatar-uploader-icon {
            font-size: 28px;
            color: #8c939d;
            width: 178px;
            height: 178px;
            text-align: center;
        }
    }
}
.editor {
  width: 100%;
  :deep(.ql-editor) {
    min-height: 200px;
  }
}

为添加文章按钮添加单击事件,展示抽屉

<el-button type="primary" @click="visibleDrawer = true">添加文章</el-button>

富文本编辑器

文章内容需要使用到富文本编辑器,这里咱们使用一个开源的富文本编辑器 Quill

官网地址: https://vueup.github.io/vue-quill/

安装:

npm install @vueup/vue-quill@latest --save

导入组件和样式:

import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'

页面长使用quill组件:添加到对应的标签之中

<quill-editor
              theme="snow"
              v-model:content="articleModel.content"
              contentType="html"
              >
</quill-editor>

样式美化:

.editor {
  width: 100%;
  :deep(.ql-editor) {
    min-height: 200px;
  }
}

文章封面图片上传

将来当点击+图标,选择本地图片后,el-upload这个组件会自动发送请求,把图片上传到指定的服务器上,而不需要我们自己使用axios发送异步请求,所以需要给el-upload标签添加一些属性,控制请求的发送

auto-upload:是否自动上传

action: 服务器接口路径

name: 上传的文件字段名

headers: 设置上传的请求头

on-success: 上传成功的回调函数

import {
    Plus
} from '@element-plus/icons-vue'

<el-form-item label="文章封面">
    <!-- 
                        auto-upload:是否自动上传,false:不自动上传,这里要用true,因为这里点击打开就要自动上传
                        action: 服务器接口路径。这里也涉及到跨域问题,所以要加/api
                        name: 上传的文件字段名
                        headers: 设置上传的请求头
                        on-success: 上传成功的回调函数
                        以上之所以配置这些,是因为没经过拦截器,所以这里要手动配置。识别api的是前端代理干的事儿
                    -->
                    <el-upload class="avatar-uploader" :auto-upload="true" :show-file-list="false"
                    action="/api/upload"
                    name="file"
                    :headers="{'Authorization':tokenStore.token}"
                    :on-success="uploadSuccess"
                    >
        <img v-if="articleModel.coverImg" :src="articleModel.coverImg" class="avatar" />
        <el-icon v-else class="avatar-uploader-icon">
            <Plus />
        </el-icon>
    </el-upload>
</el-form-item>

注意:

  1. 由于这个请求时el-upload自动发送的异步请求,并没有使用咱们的request.js请求工具,所以在请求的路ing上,需要加上/api, 这个时候请求代理才能拦截到这个请求,转发到后台服务器上

  2. 要携带请求头,还需要导入pinia状态才可以使用

    import { useTokenStore } from '@/stores/token.js'
    const tokenStore = useTokenStore();
    
  3. 在成功的回调函数中,可以拿到服务器响应的数据,其中有一个属性为data,对应的就是图片在阿里云oss上存储的访问地址,需要把它赋值给articleModel的coverImg属性,这样img标签就能显示这张图片了,因为img标签上通过src属性绑定了articleModel.coverImg

    //上传图片成功回调
    const uploadSuccess = (img) => {
        //img就是后台响应的数据,格式为:{code:状态码,message:提示信息,data: 图片的存储地址}
        articleModel.value.coverImg=img.data
    }
    

添加文章接口调用

article.js中提供添加文章函数

//添加文章
export const articleAddService = (articleModel)=>{
    return request.post('/article',articleModel)
}

为已发布和草稿按钮绑定事件

<el-form-item>
    <el-button type="primary" @click="addArticle('已发布')">发布</el-button>
    <el-button type="info" @click="addArticle('草稿')">草稿</el-button>
</el-form-item>

ArticleManage.vue中提供addArticle函数完成添加文章接口的调用

//添加文章
const addArticle=async (state)=>{
    articleModel.value.state = state
    let result = await articleAddService(articleModel.value);
    ElMessage.success(result.message? result.message:'添加成功')
    //再次调用getArticles,获取文章
    getArticles()
    //隐藏抽屉
    visibleDrawer.value=false
}

顶部导航栏个人信息显示

image

在Layout.vue中,页面加载完就发送请求,获取个人信息展示,并存储到pinia中,因为将来在个人中心中修改信息的时候还需要使用

user.js中提供获取个人信息的函数

//获取个人信息
export const userInfoGetService = ()=>{
    return request.get('/user/userInfo');
}

src/stores/user.js中,定义个人中心状态

import { defineStore } from "pinia"
import {ref} from 'vue'

export const useUserInfoStore = defineStore('userInfo',()=>{
    //1.定义用户信息
    const info = ref({})
    //2.定义修改用户信息的方法
    const setInfo = (newInfo)=>{
        info.value = newInfo
    }
    //3.定义清空用户信息的方法
    const removeInfo = ()=>{
        info.value={}
    }

    return{info,setInfo,removeInfo}
},{
    persist:true
})

Layout.vue中获取个人信息,并存储到pinia中

//导入接口函数
import {userInfoGetService} from '@/api/user.js'
//导入pinia
import {useUserInfoStore} from '@/stores/user.js'
const userInfoStore = useUserInfoStore();
import {ref} from 'vue'

//获取个人信息
const getUserInf = async ()=>{
    let result = await userInfoGetService();
    //存储pinia
    userInfoStore.info =result.data;
}
getUserInf()

Layou.vue的顶部导航栏中,展示昵称和头像

<!-- 注意这里是昵称,默认为空 -->
<div>程序员:<strong>{{ userInfoStore.info.nickname ? userInfoStore.info.nickname : userInfoStore.info.username }}</strong></div>

<el-avatar :src="userInfoStore.info.userPic ? userInfoStore.info.userPic : avatar" />

el-dropdown中功能实现

image

在el-dropdown中有四个子条目,分别是:

  • 基本资料
  • 更换头像
  • 重置密码
  • 退出登录

其中其三个起到路由功能,跟左侧菜单中【个人中心】下面的二级菜单是同样的功能,退出登录需要删除本地pinia中存储的token以及userInfo

路由实现:

在el-dropdown-item标签上添加command属性,属性值和路由表中/user/xxx保持一致。条目被点击后会触发,在事件函数上可以声明的一个参数,接收条目对应的指令

<el-dropdown placement="bottom-end" @command="handleCommand">
    <span class="el-dropdown__box">
        <el-avatar :src="userInfoStore.info.userPic ? userInfoStore.info.userPic : avatar" />
        <el-icon>
            <CaretBottom />
        </el-icon>
    </span>
    <template #dropdown>
        <el-dropdown-menu>
            <el-dropdown-item command="info" :icon="User">基本资料</el-dropdown-item>
            <el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
            <el-dropdown-item command="resetPassword" :icon="EditPen">重置密码</el-dropdown-item>
            <el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
        </el-dropdown-menu>
    </template>
</el-dropdown>

在el-dropdown标签上绑定command事件,当有条目被点击后,会触发这个事件

<el-dropdown placement="bottom-end" @command="handleCommand">

提供handleCommand函数,参数为点击条目的command属性值

//dropDown条目被点击后,回调的函数
import {useRouter} from 'vue-router'
const router = useRouter()
const handleCommand = (command)=>{
    if(command==='logout'){
        //退出登录
        alert('退出登录')
    }else{
        //路由
        router.push('/user/'+command)
    }
}

退出登录实现:

import {ElMessage,ElMessageBox} from 'element-plus'
import { useTokenStore } from '@/stores/token.js'
const tokenStore = useTokenStore()
const handleCommand = (command) => {
    if (command === 'logout') {
        //退出登录
        ElMessageBox.confirm(
            '你确认退出登录码?',
            '温馨提示',
            {
                confirmButtonText: '确认',
                cancelButtonText: '取消',
                type: 'warning',
            }
        )
            .then(async () => {
                //用户点击了确认
            	//清空pinia中的token和个人信息。这里没有清除redis中的令牌
                userInfoStore.info={}
                tokenStore.token=''
                //跳转到登录页
                router.push('/login')
            })
            .catch(() => {
                //用户点击了取消
                ElMessage({
                    type: 'info',
                    message: '取消退出',
                })
            })
    } else {
        //路由
        router.push('/user/' + command)
    }
}

优化

清空pinia中的token和个人信息。这里没有清除redis中的令牌

在用户取消登录(即退出登录)时,仅清除前端 Pinia 中的 JWT 令牌是不够的,还需要清除后端 Redis 中的对应令牌。下面来详细分析:

仅清除前端 Pinia 中 JWT 令牌的情况

当你仅清除前端 Pinia 中的 JWT 令牌时,前端在后续与后端交互的请求中,将不会携带该 JWT 令牌。这样一来,前端就无法通过 JWT 令牌向后端证明自己的身份,后端在验证请求时会判定请求未携带有效令牌,从而拒绝部分需要认证的操作。

不过,存储在后端 Redis 中的 JWT 令牌仍然存在,这意味着如果这个令牌被他人获取(例如通过中间人攻击),攻击者可以使用该令牌向后端发送请求,因为后端 Redis 里存在该有效令牌,后端会认为这是合法请求。所以,仅清除前端的令牌无法保证系统的安全性。

同时清除前端 Pinia 和后端 Redis 中 JWT 令牌的情况

当用户退出登录时,前端清除 Pinia 中的 JWT 令牌,同时向后端发送一个退出登录的请求。后端接收到请求后,根据请求中携带的相关信息(如用户 ID),从 Redis 里删除对应的 JWT 令牌。

这样做之后,前端不会再携带该令牌发起请求,并且后端也不再认可该令牌,即使令牌被他人获取,由于后端已经删除了该令牌,后端在验证请求时会判定令牌无效,从而拒绝请求,提高了系统的安全性。

所以这里在后端在写一个接口,用于取消登录会更好

基本资料修改

image

基本资料页面组件

<script setup>
import { ref } from 'vue'
const userInfo = ref({
    id: 0,
    username: 'zhangsan',
    nickname: 'zs',
    email: 'zs@163.com',
})
const rules = {
    nickname: [
        { required: true, message: '请输入用户昵称', trigger: 'blur' },
        {
            pattern: /^\S{2,10}$/,
            message: '昵称必须是2-10位的非空字符串',
            trigger: 'blur'
        }
    ],
    email: [
        { required: true, message: '请输入用户邮箱', trigger: 'blur' },
        { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
    ]
}
</script>
<template>
    <el-card class="page-container">
        <template #header>
            <div class="header">
                <span>基本资料</span>
            </div>
        </template>
        <el-row>
            <el-col :span="12">
                <el-form :model="userInfo" :rules="rules" label-width="100px" size="large">
                    <el-form-item label="登录名称">
                        <el-input v-model="userInfo.username" disabled></el-input>
                    </el-form-item>
                    <el-form-item label="用户昵称" prop="nickname">
                        <el-input v-model="userInfo.nickname"></el-input>
                    </el-form-item>
                    <el-form-item label="用户邮箱" prop="email">
                        <el-input v-model="userInfo.email"></el-input>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary">提交修改</el-button>
                    </el-form-item>
                </el-form>
            </el-col>
        </el-row>
    </el-card>
</template>

表单数据回显

个人信息之前已经存储到了pinia中,只需要从pinia中获取个人信息,替换模板数据即可

import { useUserInfoStore } from '@/stores/user.js';
const userInfoStore = useUserInfoStore()
const userInfo = ref({...userInfoStore.info})

接口调用

在src/api/user.js中提供修改基本资料的函数

//修改个人信息
export const userInfoUpdateService = (userInfo)=>{
    return request.put('/user/update',userInfo)
}

为修改按钮绑定单击事件

  <el-button type="primary" @click="updateUserInfo">提交修改</el-button>

提供updateUserInfo函数

//修改用户信息
import {userInfoUpdateService} from '@/api/user.js'
import { ElMessage } from 'element-plus';
const updateUserInfo = async ()=>{
    let result = await userInfoUpdateService(userInfo.value)
    ElMessage.success(result.message? result.message:'修改成功')
    //更新pinia中的数据
    userInfoStore.info.nickname=userInfo.value.nickname
    userInfoStore.info.email = userInfo.value.email
}

修改头像

image

修改头像页面组件

<script setup>
import { Plus, Upload } from '@element-plus/icons-vue'
import {ref} from 'vue'
import avatar from '@/assets/default.png'
const uploadRef = ref()

//用户头像地址
const imgUrl= avatar

</script>

<template>
    <el-card class="page-container">
        <template #header>
            <div class="header">
                <span>更换头像</span>
            </div>
        </template>
        <el-row>
            <el-col :span="12">
                <el-upload 
                    ref="uploadRef"
                    class="avatar-uploader" 
                    :show-file-list="false"
                    >
                    <img v-if="imgUrl" :src="imgUrl" class="avatar" />
                    <img v-else :src="avatar" width="278" />
                </el-upload>
                <br />
                <el-button type="primary" :icon="Plus" size="large"  @click="uploadRef.$el.querySelector('input').click()">
                    选择图片
                </el-button>
                <el-button type="success" :icon="Upload" size="large">
                    上传头像
                </el-button>
            </el-col>
        </el-row>
    </el-card>
</template>

<style lang="scss" scoped>
.avatar-uploader {
    :deep() {
        .avatar {
            width: 278px;
            height: 278px;
            display: block;
        }

        .el-upload {
            border: 1px dashed var(--el-border-color);
            border-radius: 6px;
            cursor: pointer;
            position: relative;
            overflow: hidden;
            transition: var(--el-transition-duration-fast);
        }

        .el-upload:hover {
            border-color: var(--el-color-primary);
        }

        .el-icon.avatar-uploader-icon {
            font-size: 28px;
            color: #8c939d;
            width: 278px;
            height: 278px;
            text-align: center;
        }
    }
}
</style>

头像回显

从pinia中读取用户的头像数据

//读取用户信息
import {ref} from 'vue'
import {useUserInfoStore} from '@/stores/user.js'
const userInfoStore = useUserInfoStore()
const imgUrl=ref(userInfoStore.info.userPic)

img标签上绑定图片地址

<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<img v-else src="@/assets/avatar.jpg" width="278" />

头像上传

为el-upload指定属性值,分别有:

  • action: 服务器接口路径
  • headers: 设置请求头,需要携带token
  • on-success: 上传成功的回调函数
  • name: 上传图片的字段名称
<el-upload 
           class="avatar-uploader" 
           :show-file-list="false"
           :auto-upload="true"
           action="/api/upload"
           name="file"
           :headers="{'Authorization':tokenStore.token}"
           :on-success="uploadSuccess"
           >
    <img v-if="imgUrl" :src="imgUrl" class="avatar" />
    <img v-else src="@/assets/avatar.jpg" width="278" />
</el-upload>

提供上传成功的回调函数

//读取token信息
import {useTokenStore} from '@/stores/token.js'
const tokenStore = useTokenStore()

//图片上传成功的回调
const uploadSuccess = (result)=>{
    //回显图片
    imgUrl.value = result.data
}

外部触发图片选择

​ 需要获取到el-upload组件,然后再通过$el.querySelector('input')获取到el-upload对应的元素,触发click事件

//获取el-upload元素
const uploadRef = ref()


<el-button type="primary" :icon="Plus" size="large"  @click="uploadRef.$el.querySelector('input').click()">
    选择图片
</el-button>

接口调用

在user.js中提供修改头像的函数

//修改头像
export const userAvatarUpdateService=(avatarUrl)=>{
    let params = new URLSearchParams();
    params.append('avatarUrl',avatarUrl)
    return request.patch('/user/updateAvatar',params)
}

为【上传头像】按钮绑定单击事件

<el-button type="success" :icon="Upload" size="large" @click="updateAvatar">
    上传头像
</el-button>

提供updateAvatar函数,完成头像更新

//调用接口,更新头像url
import {userAvatarUpdateService} from '@/api/user.js'
import {ElMessage} from 'element-plus'
const updateAvatar = async ()=>{
    let result = await userAvatarUpdateService(imgUrl.value)
    ElMessage.success(result.message? result.message:'修改成功')
    //更新pinia中的数据。这里只更新一项,可以用着形式修改
    userInfoStore.info.userPic=imgUrl.value
}

优化

这里修改完之后旧头型可以从阿里云里面删除吧,否则太浪费空间了

功能补充

重置用户密码

在user.js中提供修改密码的函数

//修改密码
export const userResetPasswordService=(params)=>{
    return request.patch('/user/updatePwd',params)
}

页面组件

<script setup>
import { ref } from 'vue'
const resetPassword = ref({
    old_pwd: '',
    new_pwd: '',
    re_pwd: ''
})
const rules = {
    old_pwd: [
        { required: true, message: '请输入原密码', trigger: 'blur' },
        {
            pattern: /^\S{5,16}$/,
            message: '密码必须是5-16位的非空字符',
            trigger: 'blur'
        }
    ],
    new_pwd: [
        { required: true, message: '请输入新密码', trigger: 'blur' },
        {
            pattern: /^\S{5,16}$/,
            message: '密码必须是5-16位的非空字符',
            trigger: 'blur'
        }
    ],
    re_pwd: [
        { required: true, message: '请再次输入新密码', trigger: 'blur' },
        {
            pattern: /^\S{5,16}$/,
            message: '密码必须是5-16位的非空字符',
            trigger: 'blur'
        }
    ]
}

// 重置输入
const resetInput = ()=>{
    resetPassword.value.old_pwd = '',
    resetPassword.value.new_pwd = '',
    resetPassword.value.re_pwd = ''
}

//重置密码
import { userResetPasswordService } from '@/api/user.js'
import {ElMessageBox, ElMessage} from 'element-plus'
import router from '@/router';
import { useUserInfoStore } from '@/stores/user.js';
const userInfoStore = useUserInfoStore()
import { useTokenStore } from '@/stores/token.js'
const tokenStore = useTokenStore()
const submitResetPassword = ()=>{
    ElMessageBox.confirm(
        '你确认修改密码?',
        '温馨提示',
        {
            confirmButtonText: '确认',
            cancelButtonText: '取消',
            type: 'warning',
        }
        )
        .then(async () => {
            //可以通过try catch和log进行调试
            try {
                //用户点击了确认
                console.log('开始执行修改密码操作');
                //修改密码
                let result = await userResetPasswordService(resetPassword.value);
                console.log('修改密码操作成功,结果:', result);
                ElMessage.success(result.message? result.message:'修改成功')
                //清空pinia中的token和个人信息。这里没有清除redis中的令牌
                userInfoStore.info = {};
                tokenStore.token = '';
                //跳转到登录页
                console.log('开始导航到登录页');
                await router.push('/login');
                console.log('导航到登录页成功');
            } catch (error) {
                console.error('修改密码或导航过程中出现错误:', error);
                throw error; // 重新抛出异常,确保 catch 块能正确处理
            }
        })
        .catch(() => {
            //用户点击了取消
            console.log('进入取消操作分支');
            resetInput();
            ElMessage({
                type: 'info',
                message: '取消修改',
            });
        });
}
</script>
<template>
    <el-card class="page-container">
        <template #header>
            <div class="header">
                <span>重置密码</span>
            </div>
        </template>
        <el-row>
            <el-col :span="12">
                <el-form :model="resetPassword" :rules="rules" label-width="100px" size="large">
                    <el-form-item label="原密码" prop="old_pwd">
                        <el-input v-model="resetPassword.old_pwd" type="password"></el-input>
                    </el-form-item>
                    <el-form-item label="新密码" prop="new_pwd">
                        <el-input v-model="resetPassword.new_pwd" type="password"></el-input>
                    </el-form-item>
                    <el-form-item label="确认新密码" prop="re_pwd">
                        <el-input v-model="resetPassword.re_pwd" type="password"></el-input>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary" @click="submitResetPassword()">提交修改</el-button>
                        <el-button @click="resetInput()">重置</el-button>
                    </el-form-item>
                </el-form>
            </el-col>
        </el-row>
    </el-card>
</template>

文章内容显示

文章详情展示组件

<!-- 文章详情抽屉 -->
<el-drawer v-model="detailVisibleDrawer" title="文章详情" direction="rtl" size="50%">
    <div>
        <p><strong>{{ articlesDetail.title }}</strong></p>
        <p>作者:{{ userInfoStore.info.username }}&ensp;|&ensp;发布时间:{{ articlesDetail.createTime }}&ensp;|&ensp;文章分类:{{ articlesDetail.categoryName }}</p>
        <hr/>
        <img :src="articlesDetail.coverImg" class="avatar" width="200px" height="200px"/>
        <p>{{ articlesDetail.content }}</p>

    </div>
</el-drawer>

数据模型

// 文章详情数据
const articlesDetail = ref({
    id: 1,
    title: '北京旅游攻略',
    content: '天安门,颐和园,鸟巢,长城...爱去哪去哪...',
    coverImg: 'https://big-event343.oss-cn-beijing.aliyuncs.com/1e89b829-f31c-4e86-9b93-6d2b51975781.jpg',
    state: '已发布',
    categoryName: '分类',
    createTime: '2023-09-03 11:35:04',
    updateTime: "2023-09-03 11:40:31",
    
});

文章列表文章标题优化

这里点击文章标题要显示文章内容

<el-table-column label="文章标题" width="400" prop="title" @click="">
    <template #default="{ row }">
<el-link  class="article-title" @click="detailVisibleDrawer=true;viewArticlesDetail(row)">{{ row.title }}</el-link>
    </template>
</el-table-column>

//样式添加
.article-title {
  color: rgb(135, 135, 202);
  text-decoration: none;
}

.article-title:hover {
  text-decoration: none;
}

调用接口的函数

const viewArticlesDetail = async ( row )=>{
    //调用接口
    let result = await articleArticlesDetailService(row.id);
    //检查调用结果
    console.log(result.data);
    articlesDetail.value = result.data
    articlesDetail.value.categoryName = row.categoryName
    //规范时间的表示
    articlesDetail.value.createTime = articlesDetail.value.createTime.replace(/T/g, ' ')
}

接口函数

//文章详情
export const articleArticlesDetailService=(id)=>{
    //检查传参
    // console.log(id);
    return request.get('/article/detail?id='+id)
}

待补充

文章删除功能暂时没有做

posted @ 2025-04-15 20:25  韩熙隐ario  阅读(104)  评论(0)    收藏  举报