前端学习笔记 5:大事件

发布时间:2024年01月06日

前端学习笔记 5:大事件

本文将学习一个示例项目(大事件)的前端搭建过程。

1.准备工作

1.1.创建工程

创建一个名称为big-event的 Vue3 项目,具体可以参考这篇文章

1.2.安装插件

安装 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 扩展。

1.3.调整目录

  1. 删除components目录下的内容

  2. 删除App.vue中的内容,只保留script和template标签

  3. src下新建如下目录:

? api:存放接口调用的js文件

? utils:存放工具js文件

? 拷贝request.js到util目录

? views:存放页面的.vue文件

  1. 删除assets目录中的内容, 将资料中的静态资源文件全部拷贝到该目录下

2.注册

2.1.页面

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>

2.2.接口调用

这里对应的服务端 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>

2.3.跨域

上边的示例实际上是有问题的,运行后观察控制台会出现类似下面的报错信息:

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替换为空字符串。

3.登录

3.1.实现

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>

3.2.拦截器优化

一般的,接口都会用统一的返回标识符表示业务的成功和失败,比如这里的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("登录成功!")
}

3.3.优化消息提示

使用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("登录成功!")
}

4.主页面布局

添加 Layout.vue 到项目的views目录下。

修改App.vue以显示主页面:

<script setup>
import LoginVue from '@/views/Login.vue'
import LayoutVue from '@/views/Layout.vue'
</script>
<template>
  <!-- <LoginVue/> -->
  <LayoutVue/>
</template>

关于主页面代码的结构说明,可以观看这个视频

4.1.路由

前端框架可以使用多种路由,这里使用的是 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 就会展示登录页,且登录后会自动跳转到主页面。

4.2.子路由

在主页面,需要点击左侧菜单加载不同的内容,这就需要用到子路由。

首先添加代表不同菜单内容的 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路径。

5.文章分类

5.1.列表页

基本的文章分类(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。

5.2.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 中的存储库定义都是如此。

5.3.请求拦截器

通常,大多数接口都需要传递 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');
}

5.4.Pinia 持久化

默认情况下 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 })

5.5.未登录状态处理

可以在响应拦截器中判断登录状态,对于接口返回的状态码是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'的方式获取。

5.6.添加文章分类

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>

5.7.编辑文章分类

编辑文章分类可以和添加文章分类共用弹窗,通过绑定不同的 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)
}

5.8.删除文章

我们希望点击删除按钮后,弹出一个消息提示框,点击确认后删除该文章分类。

首先为删除按钮绑定点击事件:

<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)
}

6.文章管理

6.1.文章列表

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()
}

6.2.添加文章

添加文章抽屉组件:

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>

6.2.1.富文本编辑器

文章内容需要使用到富文本编辑器,这里咱们使用一个开源的富文本编辑器 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;
  }
}

6.2.2.图片上传

将来当点击+图标,选择本地图片后,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
}

6.2.3.添加文章

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)
}

服务端有限制,图片地址不能为空,这里可以先设置一个假的图片地址用于添加文章。

7.顶部导航栏

7.1.个人信息

顶部导航栏需要展示个人信息,比如昵称。这些个人信息需要从接口获取,并保存到全局(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>

7.2.下拉菜单

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)
    }
}

8.用户信息修改

初始的用户信息修改页面代码可以见 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(修改数据模型后存储库对象也会立即改变)。同样的道理,将修改后的数据模型回写到存储库时,同样需要传递一个结构后的新对象,而非直接传递。

9.修改头像

修改头像页面的基本代码见 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>

谢谢阅读,本文的完整示例代码可以从这里获取。

10.参考资料

文章来源:https://blog.csdn.net/hy6533/article/details/135425235
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。