本文将学习一个示例项目(大事件)的前端搭建过程。
创建一个名称为big-event
的 Vue3 项目,具体可以参考这篇文章。
安装 ElementPlus:
npm install element-plus --save
安装好后还需要在main.js
中导入相关模块和样式:
import './assets/main.scss'
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')
安装 Axios:
npm install axios
安装 sass:
npm install sass -D
这是一个 CSS 扩展。
删除components目录下的内容
删除App.vue中的内容,只保留script和template标签
在src
下新建如下目录:
? api:存放接口调用的js文件
? utils:存放工具js文件
? 拷贝request.js到util目录
? views:存放页面的.vue文件
在views
下添加 Login.view 作为登录页,并在 App.vue
中使用:
<script setup>
import LoginVue from '@/views/Login.vue'
</script>
<template>
<LoginVue/>
</template>
为注册表单绑定响应式数据:
<script setup>
// ...
// 注册表单的响应式数据
const registData = ref({
username: '',
password: '',
repassword: ''
})
</script>
<template>
<el-form ... :model="registData">
<el-form-item>
<el-input ... v-model="registData.username"></el-input>
</el-form-item>
<el-form-item>
<el-input ... v-model="registData.password"></el-input>
</el-form-item>
<el-form-item>
<el-input ... v-model="registData.repassword"></el-input>
</el-form-item>
</el-form>
</template>
为注册表单定义验证规则:
// 注册表单的验证规则
const rules = ref({
username: [
{ 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: [{ validator: checkPass, trigger: 'blur' }],
})
其中再次输入密码(repassword)的验证规则是自定义验证规则:
// 检查密码是否一致的验证函数
const checkPass = (rule, value, callback) => {
if (value === '') {
callback(new Error('请输入密码'))
}
else if(value !== registData.value.password){
callback(new Error('密码不一致'))
}
else{
callback()
}
}
将验证规则绑定到表单:
<el-form ... :rules="rules">
<!-- ... -->
<el-form-item prop="username">
<!-- ... -->
</el-form-item>
<el-form-item prop="password">
<!-- ... -->
</el-form-item>
<el-form-item prop="repassword">
<!-- ... -->
</el-form-item>
<!-- ... -->
</el-form>
这里对应的服务端 jar 包是 big-event.jar,对应的数据库表结构是 big-event.sql。
jar 包中登录数据库的帐号密码是
root:1234
,如果不符,需要手动自行修改。
可以通过以下命令运行服务端:
java -jar .\big-event-1.0-SNAPSHOT.jar
该服务端提供的接口可以查阅接口文档。
在/src/api/user.js
下创建接口调用函数:
import request from '@/utils/request.js'
export const registService = (registData) => {
return request.post("/user/register", registData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
}
需要注意的是,这里通过 POST 方法传递的参数是以x-www-form-urlencoded
方式传递的,也就是在方法体中以 URL 编码方式传递,并非 Axios 默认的 JSON 格式传递,所以需要添加上额外参数指定请求头信息(content-type)。
除了上述方式,也可以使用另一种方式传递 x-www-form-urlencoded
编码的 POST 参数:
export const registService = (registData) => {
var params = new URLSearchParams()
for(let key in registData){
params.append(key, registData[key])
}
return request.post("/user/register", params)
}
两者效果上是相同的。
在login.vue
中使用该函数完成注册,并绑定按钮点击事件:
<script setup>
// 注册用户
import { registService } from '@/api/user.js'
const regist = async () => {
let result = await registService(registData.value)
if (result.code === 0) {
alert("注册成功!")
}
else {
alert(result.msg ? result.msg : "注册失败!")
}
}
</script>
<template>
<el-button class="button" type="primary" auto-insert-space @click="regist">
注册
</el-button>
</template>
上边的示例实际上是有问题的,运行后观察控制台会出现类似下面的报错信息:
Access to XMLHttpRequest at 'http://localhost:8080/user/register' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
该信息说明了:程序尝试从源 http://localhost:5173
进行 Ajax 请求到 http://localhost:8080/user/register
,这种请求被 CORS 规则所阻止。
实际上 CORS 是一种浏览器端的 AJAX 跨域访问的安全策略,只要是对不同源(协议\域名\端口任一不同)的 AJAX 调用,都会被浏览器阻止(除非服务端允许跨域访问)。
目前我们的前端程序(Vue)是运行在 Node.js 服务上的,所以可以通过在 Node.js 服务(Vue)上添加代理,让浏览器先访问 Node.js 服务,再通过 Node.js 服务将请求转发到 Jar 包运行的服务,这样就可以规避浏览器的跨域限制。
首先,修改request.js
,让所有 AJAX 调用都以/api
开头:
// const baseURL = 'http://localhost:8080';
const baseURL = '/api'
其次,修改 Vue 配置文件vite.config.js
:
export default defineConfig({
// ...
// 代理配置
server: {
proxy: {
'/api': {
target: 'http://localhost:8080', // 后端服务器地址
changeOrigin: true, // 是否改变请求域名
rewrite: (path) => path.replace(/^\/api/, '')//将原有请求路径中的api替换为''
}
}
}
})
这里的正则
/^\/api/
的作用是将以/api
开头的路径中的/api
替换为空字符串。
在user.js
中添加登录接口的调用函数:
export const loginService = (loginData) => {
return request.post("/user/login", loginData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
}
在login.vue
中添加登录逻辑:
// 登录
const login = async () => {
let result = await loginService(registData.value)
if (result.code === 0) {
alert("登录成功!")
}
else {
alert(result.message ? result.message : "登录失败!")
}
}
给登录按钮绑定点击事件:
<el-button class="button" type="primary" auto-insert-space @click="login">登录</el-button>
为登录表单绑定响应式数据(与注册表单的响应式数据复用):
<el-form ... :model="registData" :rules="rules">
<el-form-item prop="username">
<el-input ... v-model="registData.username"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input ... v-model="registData.password"></el-input>
</el-form-item>
</el-form>
这里对验证规则也进行了复用。
最后,还需要在切换注册或登录页面时对响应式数据清空:
// 重置注册登录数据
const resetRegistData = ()=>{
registData.value = {
username: '',
password: '',
repassword: ''
}
}
<el-link type="info" :underline="false" @click="isRegister = false; resetRegistData()">
← 返回
</el-link>
<!-- ... -->
<el-link type="info" :underline="false" @click="isRegister = true; resetRegistData()">
注册 →
</el-link>
一般的,接口都会用统一的返回标识符表示业务的成功和失败,比如这里的result.data.code
。可以利用这一点对响应拦截器进一步优化,在响应拦截器中判断业务接口的成功和失败,如果失败,直接输出失败信息,这样就不需要在前端的业务代码中对接口失败进行逐一处理。
修改request.js
,添加处理业务失败时的逻辑:
instance.interceptors.response.use(
result => {
if (result.data.code === 0) {
// 接口业务调用成功
return result.data;
}
// 接口业务失败
alert(result.data.message ? result.data.message : "业务接口调用出错")
return Promise.reject(result)
},
err => {
alert('服务异常');
return Promise.reject(err);//异步的状态转化成失败的状态
}
)
修改Login.vue
,调用接口后只需要显示成功信息:
// 注册用户
import { registService, loginService } from '@/api/user.js'
const regist = async () => {
await registService(registData.value)
alert("注册成功!")
}
// 登录
const login = async () => {
await loginService(registData.value)
alert("登录成功!")
}
使用alert
显示提示信息不够友好,可以使用 ElementPlus 的消息提示组件:
//添加响应拦截器
import { ElMessage } from 'element-plus'
instance.interceptors.response.use(
result => {
if (result.data.code === 0) {
// 接口业务调用成功
return result.data;
}
// 接口业务失败
let errorMsg = result.data.message ? result.data.message : "业务接口调用出错"
ElMessage.error(errorMsg)
return Promise.reject(result)
},
err => {
ElMessage.error('服务异常')
return Promise.reject(err);//异步的状态转化成失败的状态
}
)
修改Login.vue
:
import { ElMessage } from 'element-plus'
const regist = async () => {
await registService(registData.value)
ElMessage.success("注册成功!")
}
// 登录
const login = async () => {
await loginService(registData.value)
ElMessage.success("登录成功!")
}
添加 Layout.vue 到项目的views
目录下。
修改App.vue
以显示主页面:
<script setup>
import LoginVue from '@/views/Login.vue'
import LayoutVue from '@/views/Layout.vue'
</script>
<template>
<!-- <LoginVue/> -->
<LayoutVue/>
</template>
关于主页面代码的结构说明,可以观看这个视频。
前端框架可以使用多种路由,这里使用的是 Vue 的官方路由 VueRouter。
首先需要安装:
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
在main.js
中使用路由实例:
// ...
import router from '@/router'
const app = createApp(App)
app.use(router)
app.mount('#app')
在App.vue
中使用路由标签展示内容:
<script setup>
</script>
<template>
<router-view></router-view>
</template>
修改Login.vue
,在登录成功时自动完成路径跳转:
// 登录
import { useRouter } from 'vue-router'
// 获取当前路由实例
const router = useRouter()
const login = async () => {
await loginService(registData.value)
ElMessage.success("登录成功!")
//使用路由进行跳转
router.push('/')
}
现在访问 http://localhost:5173/login 就会展示登录页,且登录后会自动跳转到主页面。
在主页面,需要点击左侧菜单加载不同的内容,这就需要用到子路由。
首先添加代表不同菜单内容的 Vue 文件 到 views 目录下。
接下来需要修改路由定义(router/index.js
),为路径/
添加子路由:
import ArticleCategoryVue from '@/views/article/ArticleCategory.vue'
import ArticleManageVue from '@/views/article/ArticleManage.vue'
import UserAvatarVue from '@/views/user/UserAvatar.vue'
import UserInfoVue from '@/views/user/UserInfo.vue'
import UserResetPasswordVue from '@/views/user/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 }
]
}
]
子路由添加好后,还需要在Layout.vue
的菜单组件中使用这些子路由的路径:
<el-menu-item index="/article/category">
<!-- ... -->
</el-menu-item>
<el-menu-item index="/article/manage">
<!-- ... -->
</el-menu-item>
<!-- ... -->
最后,还需要在内容展示区中使用router-view
标签:
<el-main>
<router-view></router-view>
</el-main>
现在点击主页面的菜单就可以完成不同内容的切换。
这里还有一个问题,如果访问的是/
路径,默认不会加载任何内容,因此还需要为其配置一个跳转:
// 定义路由规则
const routes = [
{ path: '/login', component: LoginVue },
{
path: '/', component: LayoutVue, redirect:'/article/manage', children: [
// ...
]
}
]
这里通过设置redirect
属性添加自动跳转。现在,如果访问/
,会自动跳转到/article/manage
路径。
基本的文章分类(ArticleCategory.vue)代码可以从这里获取。
添加api/article.js
,负责具体接口调用:
import request from '@/utils/request.js'
export const getArticleCategoryList = ()=>{
return request.get('/category');
}
修改ArticleCategory.vue
,通过接口加载文章分类数据:
<script setup>
// ...
import {getArticleCategoryList} from '@/api/article.js'
const loadArticleCategoryList = async () => {
result = await getArticleCategoryList()
categorys.value = result
}
loadArticleCategoryList()
</script>
实际上这里调用接口会报错,返回状态码是 401,因为调用接口时没有传递 token,这需要用到下面介绍的状态管理库 Pinia。
Pinia是 Vue 的状态管理库,我们可以利用它保存需要在全局使用和保存状态的变量。
首先需要安装 Pinia:
npm install pinia
修改main.js
,在 Vue 实例上使用 pinia:
import { createPinia } from 'pinia'
const app = createApp(App)
const pinia = createPinia()
app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.mount('#app')
创建src/store/token.js
,添加存储库定义:
import { defineStore } from 'pinia'
// pinia 中的 token 定义
export const useTokenStore = defineStore('token', () => {
const token = ref('')
const updateToken = (newTokenVal) => {
token.value = newTokenVal
}
const removeToken = () => {
token.value = ''
}
return { token, updateToken, removeToken }
})
存储库定义通常都以
use
开头Store
结尾。
修改Login.vue
,在登录后存储 token:
import { useTokenStore } from '@/store/token.js'
const tokenStore = useTokenStore()
const login = async () => {
let result = await loginService(registData.value)
// 写入 token 信息
tokenStore.updateToken(result.data)
ElMessage.success("登录成功!")
//使用路由进行跳转
router.push('/')
}
修改article.js
,调用接口时使用存储库中的 token 作为头信息:
import request from '@/utils/request.js'
import { useTokenStore } from '@/store/token.js'
export const getArticleCategoryList = ()=>{
const tokenStore = useTokenStore()
return request.get('/category', {
headers: {
Authorization: tokenStore.token
}
});
}
注意,虽然
tokenStore.token
是一个响应式数据,但这里不需要使用.value
获取值,Pinia 中的存储库定义都是如此。
通常,大多数接口都需要传递 token 作为身份认证信息,如果每个接口调用都手动添加就会相当繁琐,可以使用请求拦截器进行简化。
修改request.js
,使用 Axios 的请求拦截器统一传递 token 作为头信息:
// 添加请求拦截器,对所有请求都传递 token 作为头信息
import { useTokenStore } from '@/store/token.js'
instance.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么
const tokenStore = useTokenStore()
if (tokenStore.token) {
config.headers.Authorization = tokenStore.token
}
return config;
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error);
}
);
这样就不需要在单独调用接口时添加 Token 作为请求头:
import request from '@/utils/request.js'
export const getArticleCategoryList = ()=>{
return request.get('/category');
}
默认情况下 Pinia 中的数据是保存在内存中的,当浏览器页面刷新后会丢失,所以需要借助 Pinia 插件实现持久化存储。
安装持久化插件 pinia-plugin-persistedstate:
npm i pinia-plugin-persistedstate
修改main.js
,在 Pinia 实例上使用持久化插件:
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
修改store/token.js
,在存储库定义中指定持久化属性persist
:
export const useTokenStore = defineStore('token', () => {
const token = ref('')
const updateToken = (newTokenVal) => {
token.value = newTokenVal
}
const removeToken = () => {
token.value = ''
}
return { token, updateToken, removeToken }
}, { persist: true })
可以在响应拦截器中判断登录状态,对于接口返回的状态码是401的,视作未登录,统一跳转到登录页。
修改request.js
:
import router from '@/router'
instance.interceptors.response.use(
result => {
if (result.data.code === 0) {
// 接口业务调用成功
return result.data;
}
// 接口业务失败
let errorMsg = result.data.message ? result.data.message : "业务接口调用出错"
ElMessage.error(errorMsg)
return Promise.reject(result)
},
err => {
if (err.response.status === 401){
//未登录,跳转到登录页
ElMessage.error("请登录")
router.push('/login')
}
else{
ElMessage.error('服务异常')
}
return Promise.reject(err);//异步的状态转化成失败的状态
}
)
需要注意的是,因为模块加载顺序的关系,在request.js
中不能通过以下方式获取router
:
import { useRouter } from 'vue-router'
const router = useRouter()
而是需要通过import router from '@/router'
的方式获取。
在ArticleCategory.vue
中添加弹窗组件:
<!-- 添加分类弹窗 -->
<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>
修改api/article.js
,添加新增分类接口调用:
export const addArticleCategoryService = (categoryData)=>{
return request.post('/category', categoryData)
}
修改ArticleCategory.vue
,添加新增文章分类逻辑:
// 添加文章分类
import { ElMessage } from 'element-plus'
const addArticleCategory = async () => {
let result = await addArticleCategoryService(categoryModel.value)
// 添加成功后显示提示信息
ElMessage.success(result.message ? result.message : '添加成功!')
// 刷新列表信息
loadArticleCategoryList()
// 隐藏弹窗
dialogVisible.value = false
// 清空数据模型
categoryModel.value.categoryName = ''
categoryModel.value.categoryAlias = ''
}
绑定确认按钮的点击事件:
<el-button type="primary" @click="addArticleCategory"> 确认 </el-button>
编辑文章分类可以和添加文章分类共用弹窗,通过绑定不同的 Title 进行区分:
const dialogTitle = ref('')
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="30%">
这样做的好处是可以复用响应式数据和表单校验规则。
定义不同的函数用于展示不同用途的弹窗:
// 显示添加分类弹窗
const showAddDialog = () => {
dialogTitle.value = '添加分类'
// 清空数据模型
categoryModel.value.categoryName = ''
categoryModel.value.categoryAlias = ''
// 显示弹窗
dialogVisible.value = true
}
// 显示编辑分类弹窗
const showEditDialog = (row) => {
dialogTitle.value = '编辑分类'
// 加载已有信息
categoryModel.value.categoryName = row.categoryName
categoryModel.value.categoryAlias = row.categoryAlias
categoryModel.value.id = row.id
dialogVisible.value = true
}
在显示编辑弹窗时,需要加载所需的行数据用于展示现存信息,可以通过表单上的 row
传入:
<template #default="{ row }">
<el-button :icon="Edit" circle plain type="primary" @click="showEditDialog(row)"> </el-button>
<el-button :icon="Delete" circle plain type="danger"></el-button>
</template>
编辑时通常需要传入 id 用于服务端定位表数据行,所以这里相比添加数据,需要给数据模型新增一个
id
属性。
在点击弹窗的确认按钮时,按照不同的 Title 决定不同的行为:
<el-button type="primary" @click="dialogConfirmBtnClick"> 确认 </el-button>
const dialogConfirmBtnClick = () => {
if (dialogTitle.value === '添加分类') {
// 添加分类
addArticleCategory()
}
else {
// 编辑分类
updateArticleCategory()
}
}
具体的编辑逻辑:
// 编辑分类
const updateArticleCategory = async () => {
let result = await updateArticleCategoryService(categoryModel.value)
ElMessage.success(result.message ? result.message : '编辑成功!')
// 更新分类信息
loadArticleCategoryList()
// 隐藏弹窗
dialogVisible.value = false
}
对应的接口调用函数:
export const updateArticleCategoryService = (categoryData) => {
return request.put('/category', categoryData)
}
我们希望点击删除按钮后,弹出一个消息提示框,点击确认后删除该文章分类。
首先为删除按钮绑定点击事件:
<el-button :icon="Delete" circle plain type="danger" @click="btnDeleteClick(row)"></el-button>
通常删除时需要传递 id
给服务端,所以这里传入row
。
删除按钮点击后要弹出消息提示框:
// 删除文章分类按钮点击事件
const btnDeleteClick = (row) => {
// 弹出确认框
ElMessageBox.confirm(
'确定是否要删除该文章分类?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
// 执行删除操作
await deleteArticleCategoryService(row.id)
// 重新加载列表页
loadArticleCategoryList()
ElMessage({
type: 'success',
message: '成功删除',
})
})
.catch(() => {
ElMessage({
type: 'info',
message: '取消删除',
})
})
}
具体的接口调用:
export const deleteArticleCategoryService = (id) => {
return request.delete('/category?id=' + id)
}
ArticleManage.vue
的初始代码见这里。
分页条是英文显示,需要使用中文语言包。修改main.js
:
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(ElementPlus, {locale: zhCn})
文章分类需要从后端加载:
// 加载文章分类
import { getArticleCategoryList } from '@/api/article.js'
const loadCategorys = async () => {
let result = await getArticleCategoryList()
categorys.value = result.data
}
loadCategorys()
加载文章列表:
// 根据文章分类id 获取分类
const getCategoryNameById = (id) => {
for (let i = 0; i < categorys.value.length; i++) {
let category = categorys.value[i]
if (category.id === id) {
return category.categoryName
}
}
return ''
}
// 加载文章列表
const loadArticles = async () => {
let params = {
pageNum: pageNum.value,
pageSize: pageSize.value,
categoryId: categoryId.value ? categoryId.value : null,
state: state.value ? state.value : null
}
let result = await getArticlesService(params)
articles.value = result.data.items
total.value = result.data.total
for (let i = 0; i < articles.value.length; i++) {
let article = articles.value[i]
article.categoryName = getCategoryNameById(article.categoryId)
}
}
loadArticles()
因为接口返回的文章列表中只有 categoryId,没有 categoryName,所以需要循环遍历进行处理。
接口调用:
export const getArticlesService = (params) => {
return request.get('/article', { params: params })
}
为搜索按钮绑定点击事件:
<el-button type="primary" @click="loadArticles">搜索</el-button>
为重置按钮设置点击事件:
<el-button @click="categoryId = ''; state = ''">重置</el-button>
当分页信息发生变化时,同样需要重新加载列表:
//当每页条数发生了变化,调用此函数
const onSizeChange = (size) => {
pageSize.value = size
loadArticles()
}
//当前页码发生变化,调用此函数
const onCurrentChange = (num) => {
pageNum.value = num
loadArticles()
}
添加文章抽屉组件:
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="false" :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'
在页面使用富文本编辑器:
<el-form-item label="文章内容">
<div class="editor">
<quill-editor theme="snow" v-model:content="articleModel.content" contentType="html">
</quill-editor>
</div>
</el-form-item>
添加CSS样式:
.editor {
width: 100%;
:deep(.ql-editor) {
min-height: 200px;
}
}
将来当点击+图标,选择本地图片后,el-upload这个组件会自动发送请求,把图片上传到指定的服务器上,而不需要我们自己使用axios发送异步请求,所以需要给el-upload标签添加一些属性,控制请求的发送:
auto-upload:是否自动上传
action: 服务器接口路径
name: 上传的文件字段名
headers: 设置上传的请求头
on-success: 上传成功的回调函数
<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>
需要注意的是,这里的headers
属性前要用:
,因为其属性值是对象。
上传文件时需要附带 token:
import { useTokenStore } from '@/store/token.js'
const tokenStore = useTokenStore();
const uploadSuccess = (result) => {
articleModel.value.coverImg = result.data
}
import { ElMessage } from 'element-plus'
const addArticle = async (state) => {
articleModel.value.state = state
let result = await addArticleService(articleModel.value)
// 显示提示信息
ElMessage.success(result.message ? result.message : '添加成功')
// 隐藏抽屉
visibleDrawer.value = false
// 重新加载列表
loadArticles()
}
<el-button type="primary" @click="addArticle('已发布')">发布</el-button>
<el-button type="info" @click="addArticle('草稿')">草稿</el-button>
export const addArticleService = (articleData) => {
return request.post('/article', articleData)
}
服务端有限制,图片地址不能为空,这里可以先设置一个假的图片地址用于添加文章。
顶部导航栏需要展示个人信息,比如昵称。这些个人信息需要从接口获取,并保存到全局(Pinia 存储库)。
先定义个人信息的存储库:
import { defineStore } from 'pinia'
import { ref } from 'vue'
// pinia 中的 userInfo 定义
export const useUserInfoStore = defineStore('userInfo', () => {
const userInfo = ref('')
const updateUserInfo = (newUserInfo) => {
userInfo.value = newUserInfo
}
const removeUserInfo = () => {
userInfo.value = {}
}
return { userInfo, updateUserInfo, removeUserInfo }
}, { persist: true })
在Layout.vue
中加载个人信息,并保存到存储库:
import { useUserInfoStore } from '@/store/userInfo.js'
const userInfoStore = useUserInfoStore()
import { getUserInfoService } from '@/api/user.js'
const loadUserInfo = async () => {
let result = await getUserInfoService()
userInfoStore.updateUserInfo(result.data)
}
loadUserInfo()
将存储库中的内容渲染到页面:
<div>黑马程序员:<strong>{{ userInfoStore.userInfo.nickname }}</strong></div>
ElementPlus 中的下拉菜单组件是el-dropdown
标签:
<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>
可以为下拉菜单绑定一个command
事件,该事件在点击下拉菜单中的选项时会被触发,且会传入菜单的command
属性作为参数:
<el-dropdown placement="bottom-end" @command="handleCommand">
<span class="el-dropdown__box">
<el-avatar :src="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>
// 点击下拉菜单
import { useRouter } from 'vue-router'
const router = useRouter()
import { useTokenStore } from '@/store/token.js'
const tokenStore = useTokenStore()
import { ElMessage, ElMessageBox } from 'element-plus';
const handleCommand = (command) => {
if (command === 'logout') {
// 退出登录
// 消息提示框
ElMessageBox.confirm(
'确定是否要退出登录?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
// 删除存储库中的 token 和个人信息
tokenStore.removeToken()
userInfoStore.removeUserInfo()
// 跳转到登录页
router.push('/login')
ElMessage({
type: 'success',
message: '成功退出',
})
})
.catch(() => {
ElMessage({
type: 'info',
message: '取消退出',
})
})
}
else {
// 跳转到对应页面
router.push('/user/' + command)
}
}
初始的用户信息修改页面代码可以见 UserInfo.vue。
个人信息保存在存储库中,因此可以从存储库中读取个人信息,修改后同样要保存回存储库:
// 从存储库中读取个人信息
import { useUserInfoStore } from '@/store/userInfo.js'
const userInfoStore = useUserInfoStore()
const userInfo = ref({ ...userInfoStore.userInfo })
// 修改个人信息
import { updateUserInfoService } from '@/api/user.js'
import { ElMessage } from 'element-plus'
const btnCommitClick = async () => {
let result = await updateUserInfoService(userInfo.value)
// 修改存储库中的个人信息
userInfoStore.updateUserInfo({ ...userInfo.value })
ElMessage.success(result.message ? result.message : '修改成功')
}
要注意的是,响应式数据模型userInfo
从存储库读取数据时需要用{...userInfoStore.userInfo}
的方式获取存储库对象的一个拷贝,而非直接获取存储库对象的引用,否则会有bug(修改数据模型后存储库对象也会立即改变)。同样的道理,将修改后的数据模型回写到存储库时,同样需要传递一个结构后的新对象,而非直接传递。
修改头像页面的基本代码见 UserAvatar.vue。
首先要从存储库中的用户信息加载头像:
import { useUserInfoStore } from "@/store/userInfo.js";
const userInfoStore = useUserInfoStore()
// 从用户信息加载头像
const loadAvatar = ()=>{
imgUrl = userInfoStore.userInfo.userPic
}
loadAvatar()
设置图像自动上传:
<el-upload ref="uploadRef" 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="avatar" width="278" />
</el-upload>
在图片成功更新后修改图片绑定的地址信息:
//用户头像地址
let imgUrl = avatar
const uploadSuccess = (result) => {
imgUrl = 'https://p.qqan.com/up/2020-12/16070652276806379.jpg'
}
这里使用假数据。
设置图片上传按钮的点击事件:
const updateAvatar = async () => {
let result = await updateUserAvatarService(imgUrl)
//更新成功后更新存储库中的头像
userInfoStore.userInfo.userPic = imgUrl
ElMessage.success(result.msg ? result.msg : '更新成功')
}
<el-button type="success" :icon="Upload" size="large" @click="updateAvatar">
上传头像
</el-button>
谢谢阅读,本文的完整示例代码可以从这里获取。