Vue3+ElementPlus大事件
环境准备
-
创建Vue工程
npm init vue@latest
-
安装依赖
-
Element-Plus
npm install element-plus --save。然后再main.js中导入
-
Axios
npm install axios
-
Sass【是CSS的语言扩展包,编写CSS样式时会用到】
npm install sass -D
-
-
目录调整
- 删除components下面自动生成的内容
- 新建目录api、utils、views。【这里可以把之前Vue3入门阶段的request.js写好的工具放到utils中】
- 将资料中的静态资源拷贝到assets目录下
- 删除App.uve中自动生成的内容。然后运行测试是否有问题
开发步骤
这里重点看后两个步骤
注册
代码开发
-
写入前端代码【通过以后的开发文档】
-
定义数据模型,【名字尽量与后端开发文档保持一致,这样传递参数时就不用额外处理了】
//定义数据模型 const registerData = ref({ username: '', password: '', rePassword: '' })
-
绑定数据
//表单中 :model="registerData //子级 v-model="registerData.username" v-model="registerData.password" v-model="registerData.rePassword"
-
数据校验,因为用的时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' } ] }
-
绑定校验规则和校验数据.【在表单中用: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>
-
注册接口调用。先运行已经打包好的后端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请求会失败
跨域通过添加代理解决问题,就是让前端开启代理。配置代理后请求不会直接通过浏览器发给后端服务,而是发给前端服务。然后再由前端服务转发给后端服务,这样就避免了浏览器去访问不同源
配置步骤
-
在request.js文件中改写baseURL。在没有配置源的情况下,默认就是当前的源,那先最终的路径就是http://localhost:5173/api。其中api作为标记使用
//定义一个变量,记录公共的前缀,baseURL // const baseURL = 'http://localhost:8080'; const baseURL = '/api';
-
配置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替换为'' } } }
登录
代码开发
-
绑定数据,因为和注册用重合,这里可以复用注册表单数据模型。然后参照注册,进行数据绑定
-
数据校验,也可以共用,这届绑定校验规则就行
-
事件
-
先写接口函数
//提供调用登录接口的函数 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>
-
数据清空
上面发现,输入的数据不会情况,注册和登录数据串扰了。这里可以添加一个单击切换按钮的清空功能
-
编写函数
//定义函数,清空数据模型的数据 const clearRegisterData = ()=>{ registerData.value={ username:'', password:'', rePassword:'' } }
-
绑定单击事件,注意这里已经有了单击事件了,可以加个分号继续加上另外单击事件名称。相当于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响应拦截器
在之前会判断成功与否,可以看出代码高度相似。这里可以用响应拦截器统一实现
-
改写已有的响应拦截器
//添加响应拦截器 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);//异步的状态转化成失败的状态 } )
-
修改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消息提示
-
分别在request.js和Login.vue两个文件中引入
import { ElMessage } from 'element-plus'
-
改写提示部分的代码
//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 : '注册成功')
主页面搭建
创建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的官方路由
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('/')
}
子路由
这里的二级路由就是一级路由的子路由
案例步骤:
-
复制资料中提供好的五个组件
-
配置子路由【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的专属状态管理库,它允许你跨组件或页面共享状态
使用步骤
-
安装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>
修改文章分类
修改分类弹窗页面
修改分类弹窗和新增文章分类弹窗长的一样,所以可以复用添加分类的弹窗
弹窗标题显示
定义标题
//弹窗标题
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>
删除分类
确认框
//删除分类 给删除按钮绑定事件
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: '取消删除',
})
})
}
文章列表
文章列表页面组件
可以根据页面,数据,事件逐一分析
<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>
添加文章
添加文章抽屉组件
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>
注意:
-
由于这个请求时el-upload自动发送的异步请求,并没有使用咱们的request.js请求工具,所以在请求的路ing上,需要加上/api, 这个时候请求代理才能拦截到这个请求,转发到后台服务器上
-
要携带请求头,还需要导入pinia状态才可以使用
import { useTokenStore } from '@/stores/token.js' const tokenStore = useTokenStore();
-
在成功的回调函数中,可以拿到服务器响应的数据,其中有一个属性为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
}
顶部导航栏个人信息显示
在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中功能实现
在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 令牌。
这样做之后,前端不会再携带该令牌发起请求,并且后端也不再认可该令牌,即使令牌被他人获取,由于后端已经删除了该令牌,后端在验证请求时会判定令牌无效,从而拒绝请求,提高了系统的安全性。
所以这里在后端在写一个接口,用于取消登录会更好
基本资料修改
基本资料页面组件
<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
}
修改头像
修改头像页面组件
<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 }} | 发布时间:{{ articlesDetail.createTime }} | 文章分类:{{ 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)
}
待补充
文章删除功能暂时没有做