黑马大事件前端代码
一、环境准备
1.1 创建vue工程(big-event)
npm init vue@latest
cd big-event npm install
|
1.2 安装插件
-
安装element-plus
1.1 执行命令: npm install element-plus --save 1.2 在main.js中做如下配置 import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' app.use(ElementPlus)
|
-
安装axios
-
安装sass依赖
1.3 目录调整
-
删除components目录下的内容
-
删除App.vue中的内容,只保留script和template标签
-
新建如下目录:
api:存放接口调用的js文件
utils:存放工具js文件
拷贝request.js到util目录
views:存放页面的.vue文件
- 删除assets目录中的内容, 将资料中的静态资源文件全部拷贝到该目录下
二、注册
2.1 页面搭建
<script setup> import { User, Lock } from '@element-plus/icons-vue' import { ref } from 'vue'
const isRegister = ref(false) </script>
<template> <el-row class="login-page"> <el-col :span="12" class="bg"></el-col> <el-col :span="6" :offset="3" class="form"> <el-form ref="form" size="large" autocomplete="off" v-if="isRegister"> <el-form-item> <h1>注册</h1> </el-form-item> <el-form-item> <el-input :prefix-icon="User" placeholder="请输入用户名"></el-input> </el-form-item> <el-form-item> <el-input :prefix-icon="Lock" type="password" placeholder="请输入密码"></el-input> </el-form-item> <el-form-item> <el-input :prefix-icon="Lock" type="password" placeholder="请输入再次密码"></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> <el-form ref="form" size="large" autocomplete="off" v-else> <el-form-item> <h1>登录</h1> </el-form-item> <el-form-item> <el-input :prefix-icon="User" placeholder="请输入用户名"></el-input> </el-form-item> <el-form-item> <el-input name="password" :prefix-icon="Lock" type="password" placeholder="请输入密码"></el-input> </el-form-item> <el-form-item class="flex"> <div class="flex"> <el-checkbox>记住我</el-checkbox> <el-link type="primary" :underline="false">忘记密码?</el-link> </div> </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 = true"> 注册 → </el-link> </el-form-item> </el-form> </el-col> </el-row> </template>
<style lang="scss" scoped>
.login-page { height: 100vh; background-color: #fff;
.bg { background: url('@/assets/logo2.png') no-repeat 60% center / 240px auto, url('@/assets/login_bg.jpg') no-repeat center / cover; border-radius: 0 20px 20px 0; }
.form { display: flex; flex-direction: column; justify-content: center; user-select: none;
.title { margin: 0 auto; }
.button { width: 100%; }
.flex { width: 100%; display: flex; justify-content: space-between; } } } </style>
|
2.2 页面数据绑定与事件绑定
2.2.1 数据绑定
const registerData = ref({ username: '', password: '', rePassword: '' })
|
2.2.2 表单校验
const rePasswordValid = (rule, value, callback) => { if (value == null || value === '') { return callback(new Error('请再次确认密码')) } if (registerData.password !== value) { return callback(new Error('两次输入密码不一致')) } }
const registerDataRules = 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: rePasswordValid, trigger: 'blur' } ] })
|
2.2.3 事件绑定
const register = () => { console.log('注册...'); }
|
2.3 接口调用
2.3.1 在src/api/user.js中提供访问注册接口的函数
export const registerService = (registerData) => { var params = new URLSearchParams() for (let key in registerData) { params.append(key, registerData[key]) } return request.post('/user/register', params) }
|
2.3.2 在Login.vue中完成注册接口调用
import { registerService} from '@/api/user.js'
const register = async () => { let result = await registerService(registerData.value); if (result.code == 0) { alert('注册成功!') } else { alert('注册失败!') } }
|
2.4 处理跨域问题
由于发起ajax请求的域为http://localhost:5173, 而后台服务器的域为 http://localhost:8080, 所以浏览器会限制该请求的发送, 这种问题称为跨域问题, 跨域问题可以在服务器端解决,也可以在浏览器端解决, 咱们这一块通过配置代理的方式解决
request.js中配置统一前缀 /api
import axios from 'axios';
const baseURL = '/api'; const instance = axios.create({baseURL})
instance.interceptors.response.use( result=>{ return result.data; }, err=>{ alert('服务异常'); return Promise.reject(err); } )
export default instance;
|
vie.config.js中配置代理
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [ vue(), ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })
|
三、登录
3.1页面数据绑定与事件绑定
3.1.1 绑定数据
const registerData = ref({ username: '', password: '', rePassword: '' })
|
3.1.2 每次点击注册或者登录,共用数据模型中的数据
const clearRegisterData = () => { registerData.value = { username: '', password: '', rePassword: '' } }
|
3.1.3 事件绑定
3.2 接口调用
3.2.1 在src/api/user.js中提供访问注册接口的函数
export const loginService = (loginData)=>{ var params = new URLSearchParams() for(let key in loginData){ params.append(key,loginData[key]) } return request.post('/user/login',params) }
|
3.2.2 在Login.vue中完成登录接口调用
import { registerService, loginService } from '@/api/user.js'
const login = async () => { let result = await loginService(registerData.value) if(result.code==0){ alert('登录成功!') }else{ alert('登录失败!') } }
|
四、优化axios响应截器
在接口调用的API中,我们都需要对业务响应的状态进行判断,从而给用户对应的提示,这个工作不难,但是每个接口的调用,都这样写代码,显然是比较繁琐的,我们可以在axios的相应拦截器中,如果服务器响应成功了,统一判断后台返回的业务状态码code,如果成功了,正常返回数据,如果失败了,则给出用户对应的提示即可
请求工具request.js
instance.interceptors.response.use( result => { if (result.data.code == 0) { return result.data; } alert(result.data.message || '服务异常'); return Promise.reject(result.data); }, err => { alert('服务异常'); return Promise.reject(err); } )
|
接口调用user.js
const register = async () => { await registerService(registerData.value); alert('注册成功!')
}
const login = async () => { await loginService(registerData.value) alert('登录成功!') }
|
Element-Plus提示框的使用
import { ElMessage } from 'element-plus'
ElMessage.error('服务异常'); ElMessage.success('登录成功!')
|
五、主页面布局
<script setup> import { Management, Promotion, UserFilled, User, Crop, EditPen, SwitchButton, CaretBottom } from '@element-plus/icons-vue' import avatar from '@/assets/default.png' </script>
<template> <el-container class="layout-container"> <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> <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>东哥</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>
|
六、路由
在App.vue中,不能同时展示Login.vue和Layout.vue,实际的需求是用户第一次访问程序,先展示登录页面,当用户登录成功后,再展示主页面,如果要达成这个需求,需要用到vue提供的路由相关的知识
路由,从起点到终点时,决定从起点到终点的路径的进程,在前端工程中,路由指的是根据不同的访问路径,展示不同组件的内容。Vue Router是Vue.js的官方路由,它与Vue.js深度集成,让Vue.js构建单页面应用变得更加轻而易举
6.1安装路由
6.2创建路由器,并导出
在src/router目录下,定义一个js文件,起名为index.js。这样名字的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
|
6.3在vue应用实例中使用router
在main.js中导入创建应用实力的js文件,并调用实例的use方法使用路由器
import router from '@/router'
app.use(router)
|
6.4定义展示路由组件的地方
在App.vue文件的template标签中,定义router-view标签
<template> <router-view></router-view> </template>
|
将来不管根据路由匹配到的组件内容,会在router-view标签内进行展示
6.5 测试
在浏览器地址栏分别访问:http://localhost:5173/ 和 http://localhost:5173/login
6.6 路由API
在登录成功后,需要通过代码的方式将页面切换到首页,此时就需要调用路由器相关的API
获取路由器
import { useRouter } from 'vue-router' const router = useRouter();
|
调用API
七、子路由
在咱们的主页面中,当用户点击左侧的菜单时,右侧主区域的内容需要发生变化,将来每切换一个菜单,右侧需要加载对应组件的内容进行展示,像这样的场景咱们也需要使用路由来完成
由于这些组件都需要在Layout.vue中展示, 而Layout.vue本身已经参与了路由,因此我们需要在Layout.vue中通过子路由的方式来完成组件的切换
7.1提供菜单对应的组件
可以复制资料中的文件,也可以自己创建:
- ArticleCategory.vue
- ArticleManage.vue
- UserInfo.vue
- UserAvatar.vue
- UserResetPassword.vue
7.2配置子路由
在src/router/index.js中配置子路由
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/info', component: UserInfoVue }, { path: '/user/avatar', component: UserAvatarVUe }, { path: '/user/password', component: UserResetPasswordVue }, ] } ]
|
7.3 在Layout.vue组件的右侧中间区域,添加router-view标签
<el-main> <div style="width: 1290px; height: 570px;border: 1px solid red;"> <router-view></router-view> </div> </el-main>
|
7.4 菜单项设置点击后跳转的路由路径
el-menu-item 标签的index属性可以设置点击后的路由路径
<el-menu-item index="/article/category"> <el-icon> <Management /> </el-icon> <span>文章分类</span> </el-menu-item>
|
八、文章分类列表
8.1 文章分类组件
<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>
|
8.2 列表接口调用
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的专属状态管理库,它允许你跨组件或页面共享状态
9.1安装
9.2使用Pinia
在main.js中,引入pinia,创建pinia实例,并调用vue应用实例的use方法使用pinia
import { createPinia } from 'pinia'
const pinia = createPinia() app.use(pinia)
|
9.3 定义Store
在src/stores目录下定义token.js
import { defineStore } from "pinia"; import {ref} from 'vue';
export const useTokenStore = defineStore('token',()=>{ const token = ref('')
const setToken = (newToken)=>{ token.value = newToken }
const removeToken = ()=>{ token.value='' } return { token,setToken,removeToken } })
|
9.4 使用Store
在需要使用状态的地方,导入@/stores/*.js , 使用即可
在Login.vue中导入@/stores/token.js, 并且当用户登录成功后,将token保存pinia中
import { useTokenStore } from '@/stores/token.js'
const tokenStore = useTokenStore();
const login = async () => { let result = await loginService(registerData.value) tokenStore.setToken(result.data) ElMessage.success('登录成功!') router.push('/') }
|
在article.js中导入@/stores/token.js, 从pinia中获取到存储的token,在发起查询文章分类列表的时候把token通过请求头的形式携带给服务器
import { useTokenStore } from '../stores/token'
export const articleCategoryListService = () => { const tokenStore = useTokenStore() return request.get('/category', { headers: { 'Authorization': tokenStore.token } }) }
|
十、axios请求拦截器
当进入主页后,将来要与后台交互,都需要携带token,如果每次请求都写这样的代码,将会比较繁琐,此时可以将携带token的代码通过请求拦截器统一处理
在 src/util/request.js中
import { useTokenStore } from '@/stores/token.js';
instance.interceptors.request.use( (config)=>{ let tokenStore = useTokenStore() if(tokenStore.token){ config.headers.Authorization=tokenStore.token } return config }, (err)=>{ Promise.reject(err) } )
|
十一、Pinia持久化插件
默认情况下,由于pinia是内存存储,当你刷新页面的时候pinia中的数据会丢失,可以借助于persist插件解决这个问题,persist插件支持将pinia中的数据持久化到sessionStorage和localStorage中
11.1 安装persist插件
npm install pinia-persistedstate-plugin
|
11.2 pinia中使用persist插件
在main.js中
import { createPinia } from 'pinia'
import {createPersistedState} from'pinia-persistedstate-plugin' const pinia = createPinia() const persist = createPersistedState()
pinia.use(persist) app.use(pinia)
|
11.3 在创建定义状态是配置持久化
在src/stores/token.js中
export const useTokenStore = defineStore('token',()=>{ const token = ref('')
const setToken = (newToken)=>{ token.value = newToken }
const removeToken = ()=>{ token.value='' } return { token,setToken,removeToken } } ,
{ persist:true } )
|
十二、未登录统一处理
在后续访问接口时,如果没有登录,则前端不携带token,后台服务器会返回响应状态码401,代表未登录,此时可以在axios的响应拦截器中,统一对未登录的情况做处理
request.js
import router from '@/router'
instance.interceptors.response.use( result => { if (result.data.code == 0) { return result.data; } ElMessage.error(result.data.message || '服务异常'); return Promise.reject(result.data); }, err => { if(err.response.status===401){ ElMessage.error('请先登录!') router.push('/login') }else{ ElMessage.error('服务异常'); } return Promise.reject(err); } )
|
十三、添加文章分类
13.1 添加分类弹窗页面
<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>
|
13.2 数据模型和校验规则
const dialogVisible = ref(false)
const categoryModel = ref({ categoryName: '', categoryAlias: '' })
const rules = { categoryName: [ { required: true, message: '请输入分类名称', trigger: 'blur' }, ], categoryAlias: [ { required: true, message: '请输入分类别名', trigger: 'blur' }, ] }
|
13.3 添加分类按钮单击事件
<el-button type="primary" @click="dialogVisible = true">添加分类</el-button>
|
13.4 接口调用
在article.js中提供添加分类的函数
export const articleCategoryAddService = (categoryModel) => { 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>
|
十四、修改文章分类
14.1 修改分类弹窗页面
修改分类弹窗和新增文章分类弹窗长的一样,所以可以服用添加分类的弹窗
弹窗标题显示
定义标题
在弹窗上绑定标题
<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>
|
14.2 数据回显
当点击修改分类按钮时,需要把当前这一条数据的详细信息显示到修改分类的弹窗上,这个叫回显
通过插槽的方式得到被点击按钮所在行的数据
<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) => { title.value = '修改分类' dialogVisible.value = true categoryModel.value.categoryName=row.categoryName categoryModel.value.categoryAlias=row.categoryAlias categoryModel.value.id=row.id }
|
14.3 接口调用
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>
|
十五、删除分类(后台需要补齐)
15.1 确认框
const deleteCategory = (row) => { ElMessageBox.confirm( '你确认删除该分类信息吗?', '温馨提示', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', } ) .then(() => { ElMessage({ type: 'success', message: '删除成功', }) }) .catch(() => { ElMessage({ type: 'info', message: '取消删除', }) }) }
|
15.2 接口调用
article.js中提供删除分类的函数
export const articleCategoryDeleteService = (id) => { return request.delete('/category?id='+id) }
|
当用户点击确认后,调用接口删除分类
const deleteCategory = (row) => { ElMessageBox.confirm( '你确认删除该分类信息吗?', '温馨提示', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', } ) .then(async () => { let result = await articleCategoryDeleteService(row.id) ElMessage.success(result.message?result.message:'删除成功') getAllCategory() }) .catch(() => { ElMessage({ type: 'info', message: '取消删除', }) }) }
|
十六、文章列表
16.1 文章列表页面组件
<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" } ])
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> <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; } } </style>
|
使用中文语言包,解决分页条中文问题, 在main.js中完成
import locale from 'element-plus/dist/locale/zh-cn.js'
app.use(ElementPlus,{locale})
|
16.2、文章分类数据回显
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, categoryId: categoryId.value ? categoryId.value : null, state: state.value ? state.value : null } let result = await articleListService(params); articles.value = result.data.items for(let i=0;i<articles.value.length;i++){ let article = articles.value[i]; 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 } getArticles()
|
当分页条的当前页和每页条数发生变化,重新发送请求获取数据
const onSizeChange = (size) => { pageSize.value = size getArticles() }
const onCurrentChange = (num) => { pageNum.value = num getArticles() }
|
十八、搜索和重置
为搜索按钮绑定单击事件,调用getArticles函数即可
<el-button type="primary" @click="getArticles">搜索</el-button>
|
为重置按钮绑定单击事件,清除categoryId和state的之即可
<el-button @click="categoryId='';state=''">重置</el-button>
|
十九、添加文章
19.1 添加文章抽屉组件
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>
|
19.2 富文本编辑器
文章内容需要使用到富文本编辑器,这里咱们使用一个开源的富文本编辑器 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; } }
|
19.3 文章封面图片上传
将来当点击+图标,选择本地图片后,el-upload这个组件会自动发送请求,把图片上传到指定的服务器上,而不需要我们自己使用axios发送异步请求,所以需要给el-upload标签添加一些属性,控制请求的发送
auto-upload:是否自动上传
action: 服务器接口路径
name: 上传的文件字段名
headers: 设置上传的请求头
on-success: 上传成功的回调函数
import { Plus } from '@element-plus/icons-vue'
<el-form-item label="文章封面"> <el-upload class="avatar-uploader" :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-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) => { articleModel.value.coverImg=img.data }
|
19.4 添加文章接口调用
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() 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',()=>{ const info = ref({}) const setInfo = (newInfo)=>{ info.value = newInfo } const removeInfo = ()=>{ info.value={} }
return{info,setInfo,removeInfo} },{ persist:true })
|
Layout.vue中获取个人信息,并存储到pinia中
import {userInfoGetService} from '@/api/user.js'
import {useUserInfoStore} from '@/stores/user.js' const userInfoStore = useUserInfoStore(); import {ref} from 'vue'
const getUserInf = async ()=>{ let result = await userInfoGetService(); userInfoStore.info =result.data; } getUserInf()
|
Layout.vue的顶部导航栏中,展示昵称和头像
<div>黑马程序员:<strong>{{ userInfoStore.info.nickname ? userInfoStore.info.nickname : userInfoStore.info.usrename }}</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-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="password" :icon="EditPen">重置密码</el-dropdown-item> <el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item> </el-dropdown-menu>
|
在el-dropdown标签上绑定command事件,当有条目被点击后,会触发这个事件
<el-dropdown placement="bottom-end" @command="handleCommand">
|
提供handleCommand函数,参数为点击条目的command属性值
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 () => { userInfoStore.info={} tokenStore.token='' router.push('/login') }) .catch(() => { ElMessage({ type: 'info', message: '取消退出', }) }) } else { router.push('/user/' + command) } }
|
二十二、基本资料修改
22.1 基本资料页面组件
<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>
|
22.2 表单数据回显
个人信息之前已经存储到了pinia中,只需要从pinia中获取个人信息,替换模板数据即可
import { useUserInfoStore } from '@/stores/user.js'; const userInfoStore = useUserInfoStore() const userInfo = ref({...userInfoStore.info})
|
22.3 接口调用
在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:'修改成功') userInfoStore.info.nickname=userInfo.value.nickname userInfoStore.info.email = userInfo.value.email }
|
二十三、修改头像
23.1 修改头像页面组件
<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>
|
23.2 头像回显
从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" />
|
23.3 头像上传
为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>
|
提供上传成功的回调函数
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>
|
24.4 接口调用
在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函数,完成头像更新
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:'修改成功') userInfoStore.info.userPic=imgUrl.value }
|
前端源代码
一. api
article.js
import request from '@/utils/request.js'
import { useTokenStore } from '@/stores/token.js'
export const articleCategoryListService = () => { return request.get('/category') }
export const articleCategoryAddService = (categoryModel) => { return request.post('/category', categoryModel) }
export const articleCategoryUpdateService = (categoryModel) => { return request.put('/category', categoryModel) }
export const articleCategoryDeleteService = (id) => { return request.delete('/category?id=' + id) }
export const articleListService = (params) => { return request.get('/article', { params: params }) }
export const articleAddService = (articleModel) => { return request.post('/article', articleModel) }
export const articleEditService = (articleModel) => { return request.put('/article', articleModel) }
export const articleDeleteService = (id) => { return request.delete('/article?id=' + id) }
|
user.js
import request from '@/utils/request.js'
export const userRegisterService = (registerData)=>{ const params = new URLSearchParams() for (let key in registerData) { params.append(key, registerData[key]) } return request.post('/user/register',params) }
export const userLoginService = (loginData)=>{ const params = new URLSearchParams() for (let key in loginData) { params.append(key, loginData[key]) } return request.post('/user/login',params) } export const userInfoService = () => { return request.get('/user/userInfo') }
export const userInfoUpdateService = (userInfo) => { return request.put('/user/update', userInfo) }
export const userAvatarUpdateService = (avatarUrl) => { let params = new URLSearchParams(); params.append('avatarUrl', avatarUrl) return request.patch('/user/updateAvatar', params) }
export const userUpdatePasswordService = (pwdModel) => { const params = { old_pwd: pwdModel.oldPwd, new_pwd: pwdModel.newPwd, re_pwd: pwdModel.rePwd }; return request.patch('/user/updatePwd', params) }
|
二. router
index.js
import { createRouter, createWebHistory } from 'vue-router'
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/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/info', component: UserInfoVue }, { path: '/user/avatar', component: UserAvatarVUe }, { path: '/user/resetpassword', component: UserResetPasswordVue }, ] } ]
const router = createRouter({ history: createWebHistory(), routes: routes });
export default router
|
三. stores
token.js
import { defineStore } from "pinia"; import { ref } from 'vue';
export const useTokenStore = defineStore('token', () => { const token = ref('')
const setToken = (newToken) => { token.value = newToken }
const removeToken = () => { token.value = '' } return { token, setToken, removeToken } } , { persist: true } )
|
userInfo.js
import { defineStore } from "pinia" import { ref } from 'vue'
export const useUserInfoStore = defineStore('userInfo', () => { const info = ref({}) const setInfo = (newInfo) => { info.value = newInfo } const removeInfo = () => { info.value = {} }
return { info, setInfo, removeInfo } }, { persist: true })
|
四. utils
request.js
import axios from 'axios'; import { ElMessage } from 'element-plus';
const baseURL = '/api';
const instance = axios.create({baseURL})
import { useTokenStore } from '@/stores/token.js';
import router from '@/router/index.js';
instance.interceptors.request.use( (config) => { let tokenStore = useTokenStore() if (tokenStore.token) { config.headers.Authorization = tokenStore.token } return config }, (err) => { Promise.reject(err) } )
instance.interceptors.response.use( result => { if (result.data.code === 0) { return result.data; } ElMessage.error(result.data.message ? result.data.message : '服务异常') return Promise.reject(result.data); }, err => { if (err.response.status === 401) { ElMessage.error('请先登录!') router.push('/login') } else { ElMessage.error('服务异常'); } return Promise.reject(err); } )
export default instance;
|
五. views/article
ArticleCategory.vue
<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" } ]) import {articleCategoryListService, articleCategoryAddService, articleCategoryUpdateService,articleCategoryDeleteService} from '@/api/article.js' const articleCategoryList = async() => { let result = await articleCategoryListService(); categorys.value = result.data; } articleCategoryList();
const dialogVisible = ref(false)
const categoryModel = ref({ categoryName: '', categoryAlias: '' })
const rules = { categoryName: [ { required: true, message: '请输入分类名称', trigger: 'blur' }, ], categoryAlias: [ { required: true, message: '请输入分类别名', trigger: 'blur' }, ] } import { ElMessage } from 'element-plus' const addCategory = async() => { let result = await articleCategoryAddService(categoryModel.value); ElMessage.success(result.message?result.message:'添加成功'); articleCategoryList(); dialogVisible.value = false; } const title = ref('') const showDialog = (row) => { dialogVisible.value = true; title.value = '编辑分类' categoryModel.value.categoryName = row.categoryName categoryModel.value.categoryAlias = row.categoryAlias categoryModel.value.id = row.id }
const updateCategory = async () => { let result = await articleCategoryUpdateService(categoryModel.value) ElMessage.success(result.message ? result.message : '修改成功') dialogVisible.value = false articleCategoryList() }
const clearCategoryModel = () => { categoryModel.value.categoryName = '', categoryModel.value.categoryAlias = '' }
import { ElMessageBox } from 'element-plus'
const deleteCategory = (row) => { ElMessageBox.confirm( '你确认删除该分类信息吗?', '温馨提示', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', } ) .then(async () => { let result = await articleCategoryDeleteService(row.id) ElMessage.success(result.message ? result.message : '删除成功') articleCategoryList() }) .catch(() => { ElMessage({ type: 'info', message: '取消删除', }) }) } </script> <template> <el-card class="page-container"> <template #header> <div class="header"> <span>文章分类</span> <div class="extra"> <el-button type="primary" @click="title = '添加分类'; dialogVisible = true; clearCategoryModel()">添加分类</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" @click="showDialog(row)"></el-button> <el-button :icon="Delete" circle plain type="danger" @click="deleteCategory(row)"></el-button> </template> </el-table-column> <template #empty> <el-empty description="没有数据" /> </template> </el-table>
<el-dialog v-model="dialogVisible" :title="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" @click="title==='添加分类'?addCategory():updateCategory()"> 确认 </el-button> </span> </template> </el-dialog> </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>
|
ArticleManage.vue
<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" } ])
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 articleList() }
const onCurrentChange = (num) => { pageNum.value = num articleList() }
import { articleCategoryListService, articleListService, articleAddService, articleEditService, articleDeleteService } from '@/api/article.js' const articleCategoryList = async () => { let resultC = await articleCategoryListService(); categorys.value = resultC.data }
const articleList = async () => { let params = { pageNum: pageNum.value, pageSize: pageSize.value, categoryId: categoryId.value ? categoryId.value : null, state: state.value ? state.value : null } let result = await articleListService(params);
total.value = result.data.total articles.value = result.data.items for (let i = 0; i < articles.value.length; i++) { let article = articles.value[i]; for (let j = 0; j < categorys.value.length; j++) { if (article.categoryId === categorys.value[j].id) { article.categoryName = categorys.value[j].categoryName } } }
} articleCategoryList(); articleList();
import { QuillEditor } from '@vueup/vue-quill' import '@vueup/vue-quill/dist/vue-quill.snow.css' import { Plus } from '@element-plus/icons-vue'
const visibleDrawer = ref(false)
const articleModel = ref({ title: '', categoryId: '', coverImg: '', content: '', state: '' }) import { useTokenStore } from '@/stores/token.js'; const tokenStore = useTokenStore();
const uploadSuccess = (result) => { articleModel.value.coverImg = result.data console.log(result.data) } const title = ref('新增文章')
import { ElMessage } from 'element-plus' const addArticle = async (state) => { articleModel.value.state = state let result = await articleAddService(articleModel.value); ElMessage.success(result.message ? result.message : '添加成功')
visibleDrawer.value = false articleList(); } const showForm = (row) => { title.value = '编辑文章' articleModel.value.title = row.title articleModel.value.categoryId = row.categoryId articleModel.value.coverImg = row.coverImg articleModel.value.content = row.content articleModel.value.state = row.state articleModel.value.id = row.id
} const editArticle = async (state) => { articleModel.value.state = state let result = await articleEditService(articleModel.value)
ElMessage.success('修改成功') visibleDrawer.value = false articleList();
} const clearArticelForm = () => { title.value = '新增文章' articleModel.value.title = ''; articleModel.value.categoryId = ''; articleModel.value.coverImg = ''; articleModel.value.content = ''; articleModel.value.state = ''; if (document.querySelector('.ql-editor')) { document.querySelector('.ql-editor').innerHTML = ''; } } const deleteArticle = async (row) => { let result = await articleDeleteService(row.id) ElMessage.success(result.message ? result.message : '删除成功') articleList() }
</script> <template> <el-card class="page-container"> <template #header> <div class="header"> <span>文章管理</span> <div class="extra"> <el-button type="primary" @click="visibleDrawer = true; clearArticelForm()">添加文章</el-button> </div> </div> </template> <el-form inline> <el-form-item label=" 文章分类:"> <el-select placeholder="请选择" v-model="categoryId" style="width: 200px;"> <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" style="width: 200px;"> <el-option label=" 已发布" value="已发布"></el-option> <el-option label="草稿" value="草稿"></el-option> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="articleList">搜索</el-button> <el-button @click="categoryId = ''; state = ''; articleList()">重置</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="categoryName"></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" @click="visibleDrawer = true; showForm(row);"></el-button> <el-button :icon="Delete" circle plain type="danger" @click="deleteArticle(row)"></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-drawer v-model="visibleDrawer" :title="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" 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-form-item label="文章内容"> <div class="editor"> <quill-editor theme="snow" v-model:content="articleModel.content" contentType="html"> </quill-editor> </div> </el-form-item> <el-form-item> <el-button type="primary" @click="title === '编辑文章' ? editArticle('已发布') : addArticle('已发布')">发布</el-button> <el-button type="info" @click="title === '编辑文章' ? editArticle('草稿') : addArticle('草稿')">草稿</el-button> </el-form-item> </el-form> </el-drawer> </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; } }
.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; } } </style>
|
六. views/user
UserAvatar.vue
<script setup> import { Plus, Upload } from '@element-plus/icons-vue' import { ref } from 'vue' import avatar from '@/assets/default.png' const uploadRef = ref() import { useTokenStore } from '@/stores/token.js' const tokenStore = useTokenStore()
import { useUserInfoStore } from '@/stores/userInfo.js' const userInfoStore = useUserInfoStore()
const imgUrl = ref(userInfoStore.info.userPic)
const uploadSuccess = (result) => { imgUrl.value = result.data }
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 : '修改成功') userInfoStore.info.userPic = imgUrl.value } </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" :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> <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" @click="updateAvatar"> 上传头像 </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>
|
UserInfo.vue
<script setup> import { ref } from 'vue' import { useUserInfoStore } from '@/stores/userInfo.js'; const userInfoStore = useUserInfoStore() const userInfo = ref({ ...userInfoStore.info }) 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' } ] } 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 : '修改成功') userInfoStore.setInfo(userInfo.value) } </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" @click="updateUserInfo">提交修改</el-button> </el-form-item> </el-form> </el-col> </el-row> </el-card> </template>
|
UserResetPassword.vue
<template>
<el-card > <el-form style="max-width: 600px" :model="pwdModel" status-icon :rules="rules" label-width="auto" class="demo-ruleForm"> <el-form-item label="原密码" prop="oldPwd"> <el-input v-model="pwdModel.oldPwd" type="password" autocomplete="off" /> </el-form-item> <el-form-item label="新密码" prop="newPwd"> <el-input v-model="pwdModel.newPwd" type="password" autocomplete="off" /> </el-form-item> <el-form-item label="确认新密码" prop="rePwd"> <el-input v-model="pwdModel.rePwd" type="password" autocomplete="off" /> </el-form-item>
<el-form-item> <el-button type="primary" @click="submitForm()" style="margin-left: 30%;"> 确认 </el-button> <el-button @click="resetForm()">重置</el-button> </el-form-item> </el-form> </el-card>
</template>
<script lang="ts" setup> import {ref } from 'vue'
const validateRePwd = (rule, value, callback) => { if (value === '') { callback(new Error('请再次输入新密码')) } else if (value !== pwdModel.value.newPwd) { callback(new Error("确认密码与新密码不匹配")) } else { callback() } }
const pwdModel= ref({ oldPwd: '', newPwd: '', rePwd: '', })
const rules = { oldPwd: [{ required: true, message: '请输入原密码', trigger: 'blur' }, { pattern: /^\S{6,16}$/, message: '密码长度必须是6-16位的非空字符串', trigger: 'blur' } ], newPwd: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { pattern: /^\S{6,16}$/, message: '密码长度必须是6-16位的非空字符串', trigger: 'blur' } ], rePwd: [{ validator: validateRePwd, trigger: 'blur' }, { pattern: /^\S{6,16}$/, message: '密码长度必须是6-16位的非空字符串', trigger: 'blur' } ], }
import { ElMessage } from 'element-plus' import { useUserInfoStore } from '@/stores/userInfo.js' import { useRouter } from 'vue-router'; const router = useRouter() const userInfoStore = useUserInfoStore() import { userUpdatePasswordService } from '@/api/user.js' const submitForm = async () => { let result = await userUpdatePasswordService(pwdModel.value) ElMessage.success('修改成功,即将跳转登录页') userInfoStore.setInfo(pwdModel.value) resetForm() setTimeout(() => { router.push('/login') }, 2000);
} const resetForm = () => { pwdModel.value = { oldPwd: '', newPwd: '', rePwd: '' } }
</script>
|
七. Layout.vue
<script setup> import { Management, Promotion, UserFilled, User, Crop, EditPen, SwitchButton, CaretBottom } from '@element-plus/icons-vue' import avatar from '@/assets/default.png'
import { userInfoService } from '@/api/user.js' import { useUserInfoStore } from '@/stores/userInfo'; import { useTokenStore } from '@/stores/token';
const TokenStore= useTokenStore(); const userInfoStore = useUserInfoStore();
const getUserInfo = async() => { let result = await userInfoService(); userInfoStore.setInfo(result.data); } getUserInfo();
import { ElMessageBox, ElMessage } from 'element-plus'; import { useRouter } from 'vue-router' const router = useRouter(); const handleCommand = (command) => { if (command === 'logout') { ElMessageBox.confirm( '你确认退出登录吗?', '温馨提示', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', } ) .then(async () => { TokenStore.removeToken(); userInfoStore.removeInfo(); router.push('/login'); ElMessage.success('退出登录成功')
}) .catch(() => { ElMessage({ type: 'info', message: '取消登录', }) }) } else { router.push(`/user/${command}`); } } </script>
<template> <el-container class="layout-container"> <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> <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>{{ userInfoStore.info.nickname }}</strong></div> <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-header> <el-main>
<router-view></router-view> </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>
|
八. Login,vue
<script setup> import { User, Lock } from '@element-plus/icons-vue' import { ref } from 'vue'
const isRegister = ref(false)
const registerData = ref({ username: '', password: '', rePassword: '' })
const rePasswordValid = (rule, value, callback) => { if (value == null || value === '') { return callback(new Error('请再次确认密码')) } if (registerData.value.password !== value) { return callback(new Error('两次输入密码不一致')) } }
const registerDataRules = { 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: rePasswordValid, trigger: 'blur' } ] }
import { userRegisterService, userLoginService } from '@/api/user.js'; import { ElMessage } from 'element-plus'; const register = async () => { let result = await userRegisterService(registerData.value) ElMessage.success(result.message ? result.message : '注册成功') } import { useTokenStore } from '@/stores/token.js' import { useRouter } from 'vue-router'
const tokenStore = useTokenStore(); const router = useRouter();
const login = async () => { let result = await userLoginService(registerData.value) tokenStore.setToken(result.data) ElMessage.success('登录成功') router.push('/') } const clearRegisterData = () => { registerData.value = { username: '', password: '', rePassword: '' } }
</script>
<template> <el-row class="login-page"> <el-col :span="12" class="bg"></el-col> <el-col :span="6" :offset="3" class="form"> <el-form ref="form" size="large" autocomplete="off" v-if="isRegister" :model="registerData" :rules="registerDataRules"> <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 @click="register(registerData)" 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; clearRegisterData()"> ← 返回 </el-link> </el-form-item> </el-form> <el-form ref="form" size="large" autocomplete="off" v-else :model="registerData" :rules="registerDataRules"> <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 name="password" :prefix-icon="Lock" type="password" placeholder="请输入密码" v-model="registerData.password"></el-input> </el-form-item> <el-form-item class="flex"> <div class="flex"> <el-checkbox>记住我</el-checkbox> <el-link type="primary" :underline="false">忘记密码?</el-link> </div> </el-form-item> <el-form-item> <el-button class="button" type="primary" auto-insert-space @click="login(registerData)">登录</el-button> </el-form-item> <el-form-item class="flex"> <el-link type="info" :underline="false" @click="isRegister = true; clearRegisterData()"> 注册 → </el-link> </el-form-item> </el-form> </el-col> </el-row> </template>
<style lang="scss" scoped>
.login-page { height: 100vh; background-color: #fff;
.bg { background: url('@/assets/logo2.png') no-repeat 60% center / 240px auto, url('@/assets/login_bg.jpg') no-repeat center / cover; border-radius: 0 20px 20px 0; }
.form { display: flex; flex-direction: column; justify-content: center; user-select: none;
.title { margin: 0 auto; }
.button { width: 100%; }
.flex { width: 100%; display: flex; justify-content: space-between; } } } </style>
|
九. App.vue
<script setup>
</script>
<template> <RouterView></RouterView>
</template>
|
十. main.js
import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import locale from 'element-plus/dist/locale/zh-cn.js'
import App from './App.vue' import router from '@/router' import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-persistedstate-plugin'
const app = createApp(App) const pinia = createPinia() const persist = createPersistedState()
pinia.use(persist) app.use(pinia) app.use(ElementPlus, { locale }) app.use(router) app.mount('#app')
|