用Vue3搭建一个大事件管理系统
项目介绍
-
大事件项目介绍 在线演示:fe-bigevent-web.itheima.net/login
pnpm包管理器 - 创建项目
一些优势:比同类工具快2倍左右、节省磁盘空间 www.pnpm.cn/
-
安装方式:
npm install -g pnpm
-
创建项目:
pnpm create vue
-
安装依赖👇(三种安装方式)
这个不要往根目录建,会报权限不够的错

接着cd打开——然后dev运行
Eslint配置代码风格
两者一般配合使用
Eslint 注重规范,不符合规范会报错
prettier 注重代码美观
- prettier 配置风格 prettier.io
- 单引号
- 无分号
- 每行字符至多80字符
- 不加对象|数组最后逗号
- 换行符号不限制(win mac 不一致)
- vue组件名称多单词组成(忽略index.vue)
- props解构(关闭)
- 解构不会丢失响应式
在.eslintrc.cjs后添加配置👇
prettier专注于代码的美观度(格式化工具)
- 禁用格式化插件prettier format on save关闭
- 安装Eslint插件,并配置保存时自动修复
rules: {
'prettier/prettier': [
'warn',
{
singleQuote: true, // 单引号
semi: false, // 无分号
printWidth: 80, // 每行宽度至多80字符
trailingComma: 'none', // 不加对象|数组最后逗号
endOfLine: 'auto' // 换行符号不限制(win mac 不一致)
}
],
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index'] // vue组件名称多单词组成(忽略index.vue)
}
],
'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验
// 💡 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
'no-undef': 'error'
}
设置settings里:
//当保存时,eslint自动帮我们修复错误
"editor.codeActionsOnSave": {
"source.fixAll":true
},
//保存代码,不自动格式化
"editor.formatOnSave": false
基于husky的代码检查工作流
husky是一个git hooks工具(git的钩子工具,可以在特定时机执行特定的命令)
提交仓库 前 做代码检查的
husky配置
首先打开git bash终端
- 初始化git仓库,执行
git init
即可
pnpm dlx husky-init && pnpm install
- 初始化husky工具配置, 执行
pnpm dlx husky-init && pnpm install
即可typicode.github.io/husky/ - 修改
.husky/pre-commit
文件
npm test //删掉这个
pnpm lint // 换成这个,一次性对整个项目中所有文件进行校验,并自动修复
- 然后需要校验,就在bash终端
pnpm lint
即可 - 提交
git add .
git commit -m '初始化提交'
- 如果有错误,这里提交就会报错
但是问题是:
- pnpm lint是全量检查,耗时问题,历史问题
- 所以还需要学习 暂存区eslint校验👇
暂存区eslint校验
相当于只对自己写的代码进行校验 ( 解决"历史问题" 也就是只对新添加的代码进行校验,原来就有的不会)
- 安装 lint-staged 包
pnpm i lint-staged -D
- package.json 配置 lint-staged 命令
{
// ... 省略 ...
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}
{
"scripts": {
// ... 省略 ...
"lint-staged": "lint-staged"
}
}
.husky/pre-commit
文件修改
pnpm lint-staged
目录调整
默认生成的目录结构不满足我们的开发需求,所以这里需要做一些自定义改动
主要是以下工作:
- 删除一些初始化的默认文件
- 删改剩余代码内容
- 新增调整我们需要的目录结构
- 拷贝全局样式和图片,安装预处理器支持
- 安装sass预处理器
- pnpm add sass -D
-
删除文件
-
修改内容
src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: []
})
export default router
src/App.vue
<script setup></script>
<template>
<div>
<router-view></router-view>
</div>
</template>
<style scoped></style>
src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
- 新增需要目录 api 和 utils
- 将项目需要的全局样式 和 图片文件,复制到 assets 文件夹中, 并将全局样式在 main.js 中引入
import '@/assets/main.scss'
- 这里引入还需要再bash终端再安装一个依赖
pnpm add sass -D
- 然后陪你
pnpm dev
重新启动即可
vue-router4 路由解析
跟原来相比,变成了按需导入
-
创建路由实例由createRouter实现
-
路由模式
- history 模式使用 createWebHistory()
- hash模式使用createWebHashHistory()
- 参数是基础路径,默认/ 基础代码解析
index.js
import { createRouter, createWebHistory } from 'vue-router'
// createRouter 创建路由实例,===> new VueRouter()
// 1. history模式: createWebHistory() http://xxx/user,地址栏不带井号
// 2. hash模式: createWebHashHistory() http://xxx/#/user,地址栏带井号
// vite 的配置 import.meta.env.BASE_URL 是路由的基准地址,默认是 ’/‘
// https://vitejs.dev/guide/build.html#public-base-path
// 如果将来你部署的域名路径是:http://xxx/my-path/user
// vite.config.ts 添加配置 base: my-path,路由这就会加上 my-path 前缀了
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: []
})
export default router
- 跳转路由操作
app.vue
<script setup>
// 在 Vue3 CompositionAPI中
// 1. 获取路由对象 router useRouter
// const router = useRouter()
// 2. 获取路由参数 route useRoute
// const route = useRoute()
import { useRoute,useRouter } from 'vue-router'
const router = useRouter() // 获取路由,一个大的路由信息对象
const route = useRoute() // 路由参数
const goList = () => {
router.push('/list')
console.log(router, route)
}
</script>
<template>
<button @click="golist">跳列表页</button>
</template>
总之就是👇:
- 创建一个路由实例,路由模式为history模式
- 基础地址为vite.config.js中的base配置的值
- 默认为/,环境变量地址为👆
引入 Element Plus 组件库
按需引入Element Plus
项目不是很复杂,所以直接按需引入就可以
...
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
...
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
]
})
- 直接使用组件(在.vue文件中,如果是js文件仍需再导入一下)(配置完后就可以在项目的任意位置去使用组件了,不用额外导入什么了)
而且components下面的组件也可以直接用,当标签用
<template>
<div>
<el-button type="primary">Primary</el-button>
<el-button type="success">Success</el-button>
<el-button type="info">Info</el-button>
<el-button type="warning">Warning</el-button>
<el-button type="danger">Danger</el-button>
...
</div>
</template>
Pinia - 构建用户仓库 和 持久化
步骤:
- bash终端 安装 持久化 插件
pinia-plugin-persistedstate
pnpm add pinia-plugin-persistedstate -D
- 安装完插件后就可以在main.js中使用
import persist from 'pinia-plugin-persistedstate'
...
app.use(createPinia().use(persist))
- 配置 stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 用户模块
export const useUserStore = defineStore(
'big-user',
() => {
const token = ref('') // 定义 token
const setToken = (newToken) => {
token.value = newToken // 设置 token
}
const removeToken = () => {
token.value = '' // 删除 token
}
return { token, setToken, removeToken }
},
{
persist: true // 持久化
}
)
Pinia仓库统一管理
pinia 独立维护
-
现在:初始化代码都在 main.js 中,仓库代码在 stores 中,代码分散职能不单一
-
优化:由 stores 统一维护,在 stores/index.js 中完成 pinia 初始化,交付 main.js 使用
- 需要将main.js当中跟pinia相关的内容进行提取(直接剪切)
- 提取到stores下面的index.js中
stores下面的index.js
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(persist)
export default pinia //导出(这里导出后,就可以在main.js里导入了)
// 完整写法👇
// import { useUserStore } from './modules/user'
// export { useUserStore }
// import { useCountStore } from './modules/counter'
// export { useCountStore }
// 简化版👇在这里暴露出module里的方法
export * from './modules/user'
export * from './modules/counter'
main.js
import pinia from '@/stores/index'
app.use(pinia)
仓库 统一导出
-
现在:使用一个仓库 import { useUserStore } from
./stores/user.js
不同仓库路径不一致 -
优化:由 stores/index.js 统一导出,导入路径统一
./stores
,而且仓库维护在 stores/modules 中
index.js
....
// 完整写法👇
// import { useUserStore } from './modules/user'
// export { useUserStore }
// import { useCountStore } from './modules/counter'
// export { useCountStore }
// 简化版👇在这里暴露出module里的方法
export * from './modules/user'
export * from './modules/counter'
counter.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 数字计数器模块
export const useCountStore = defineStore('big-count', () => {
const count = ref(100)
const add = (n) => {
count.value += n
}
return {
count,
add
}
})
App.vue
<script setup>
...
import { useRoute,useRouter } from 'vue-router'
import { useUserStore, useCountStore } from '@/stores'👈
...
const userStore = useUserStore()
const countStore = useCountStore()
</script>
<template>
<div>
woshiApp
<p>{{ userStore.token }}</p>
<el-button @click="userStore.setToken('Bearer uiasfghdukashfk')">
登录
</el-button>
<el-button @click="userStore.removeToken()">退出</el-button>
<hr />
{{ countStore.count }}👈
<el-button @click="countStore.add(2)">加法</el-button>👈
</div>
</template>
<style scoped></style>
数据交互——请求工具设计
创建 axios 实例
会使用 axios 来请求后端接口, 一般都会对 axios 进行一些配置 (比如: 配置基础地址等)
一般项目开发中, 都会对 axios 进行基本的二次封装, 单独封装到一个模块中, 便于使用
步骤
- 安装 axios
pnpm add axios
import axios from 'axios'
...
const baseURL = 'http://big-event-vue-api-t.itheima.net'
const instance = axios.create({
// TODO 1. 基础地址,超时时间
baseURL,
timeout:...
})
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// TODO 2. 携带token
...
return config
},
(err) => Promise.reject(err)
)
// 响应拦截器
instance.interceptors.response.use(
(res) => {
// TODO 3. 处理业务失败
// TODO 4. 摘取核心响应数据
...
return res
},
(err) => {
// TODO 5. 处理401错误
// 错误的特殊情况 => 401说明权限不足 或 token过期 => 所以这里需要拦截到登录
...
// 错误的默认情况 => 只要给提示就行
...
return Promise.reject(err)
}
)
export default instance
完整
import { useUserStore } from '@/stores/user'
import axios from 'axios'
import router from '@/router'
import { ElMessage } from 'element-plus'
const baseURL = 'http://big-event-vue-api-t.itheima.net'
const instance = axios.create({
baseURL,
timeout: 100000
})
instance.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = userStore.token
}
return config
},
(err) => Promise.reject(err)
)
instance.interceptors.response.use(
(res) => {
if (res.data.code === 0) {
return res
}
ElMessage({ message: res.data.message || '服务异常', type: 'error' })
return Promise.reject(res.data)
},
(err) => {
ElMessage({ message: err.response.data.message || '服务异常', type: 'error' })
console.log(err)
if (err.response?.status === 401) {
router.push('/login')
}
return Promise.reject(err)
}
)
export default instance
export { baseURL }
整体路由设计
- 完成整体路由规划【搞清楚要做几个页面,它们分别在哪个路由下面,怎么跳转的.....】
- 通过观察, 点击左侧导航, 右侧区域在切换, 那右侧区域内容一直在变, 那这个地方就是一个路由的出口
- 我们需要搭建嵌套路由
比如分析——>登录和注册的页面,采用的是切组件,而不是切路由(直接v-if切换的)
分析路由规则:
明确了路由规则,可以全部配完,也可以边写边配
path | 文件 | 功能 | 组件名 | 路由级别 |
---|---|---|---|---|
/login | views/login/LoginPage.vue | 登录&注册 | LoginPage | 一级路由 |
/ | views/layout/LayoutContainer.vue | 布局架子 | LayoutContainer | 一级路由 |
├─ /article/manage | views/article/ArticleManage.vue | 文章管理 | ArticleManage | 二级路由 |
├─ /article/channel | views/article/ArticleChannel.vue | 频道管理 | ArticleChannel | 二级路由 |
├─ /user/profile | views/user/UserProfile.vue | 个人详情 | UserProfile | 二级路由 |
├─ /user/avatar | views/user/UserAvatar.vue | 更换头像 | UserAvatar | 二级路由 |
├─ /user/password | views/user/UserPassword.vue | 重置密码 | UserPassword | 二级路由 |
新建完组件后,配规则
router/index.js
...
routes: [
{ path: '/login', component: () => import('@/views/login/LoginPage.vue') }, // 登录页
{
path: '/',
component: () => import('@/views/layout/LayoutContainer.vue'),
redirect: '/article/manage',
children: [
{
path: '/article/manage',
component: () => import('@/views/article/ArticleManage.vue')
},
{
path: '/article/channel',
component: () => import('@/views/article/ArticleChannel.vue')
},
{
path: '/user/profile',
component: () => import('@/views/user/UserProfile.vue')
},
{
path: '/user/avatar',
component: () => import('@/views/user/UserAvatar.vue')
},
{
path: '/user/password',
component: () => import('@/views/user/UserPassword.vue')
}
]
}
]
...
<router-view></router-view>
一. 🙃登录注册页面[element-plus 表单 & 表单校验]
功能需求说明
- 注册登录 静态结构&基本切换
- 注册功能(校验+注册)
- 登录功能(校验+登录+存token)
步骤
app.vue只需要留一个路由出口即可
<script setup></script>
<template>
<div>
<!-- app.vue只需要留一个路由出口即可 -->
<router-view></router-view>
</div>
</template>
<style scoped></style>
登录页
- 安装 element-plus 图标库(在powershell终端上安装)
pnpm i @element-plus/icons-vue
- 静态结构准备
<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>
布局
el-row
代表一行,一行有24份- 里面套
el-col
代表列,用span
去具体分所占多少份 offset
为左外边距margin-left
- 里面套
el-form
整个表单元素el-form-item
表单的一行(也就是一个表单域)- 表单域彼此独立 互不影响
el-input
表单元素(输入框):prefix-icon="..."
配的是图标(上面已经安装过图标库插件了)
注册页
...需求
- 用户名非空,长度校验5-10位
- 密码非空,长度校验6-15位
- 再次输入密码,非空,长度校验6-15位
【进阶】再次输入密码需要自定义校验规则,和密码框值一致(可选
...校验
四个部分缺一不可
-
el-form =>
:model="ruleForm"
绑定的整个form的数据对象{xxx,xxx,xxx} -
el-form =>
:rules="rules"
绑定的整个rules规则对象{xxx,xxx,xxx} -
表单元素 =>
v-model="ruleForm,xxx"
给表单元素绑定form的子属性(实现通过一整个对象去维护表单数据) -
el-model-item-
props
配置生效的是哪个校验规则
整个表单的校验规则
-
非空校验:
required:true message
消息提示trigger
触发校验时机 blur change -
长度校验
min:xx max:xx
-
正则校验
pattern
:正则规则, \S 非空字符 -
自定义校验
validator
=>自己写逻辑校验(校验函数)(一般用于密码的二次验证)-
validator : (rule,value,callback)
-
rule 当前校验相关的信息
-
value 当前校验表单元素 目前的表单值
-
callback 无论成功还是失败,都需要callback回调
- callback() 校验成功
- callback(new Error(错误信息)) 校验失败
-
四个部分
1.model 属性绑定 form 数据对象
//整个用于提交的form数据对象
const formModel = ref({
username: '',
password: '',
repassword: ''
})
<el-form :model="formModel" >
- rules 配置校验规则
// 整个表单的校验规则
//要跟上面form那的字段统一
//规则这里是数组,表示可以配置很多规则
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }, //非空校验,失焦时校验
{ min: 5, max: 10, message: '用户名必须是5-10位的字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码必须是6-15位的非空字符',
trigger: 'blur'
}
],
repassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码必须是6-15的非空字符',
trigger: 'blur'
},
{
validator: (rule, value, callback) => {
//判度value和当前form中收集的password是否一致
if (value !== formModel.value.password) {
callback(new Error('两次输入密码不一致!'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
-
- v-model 和 prop 绑定
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
...
(其他两个同理)
...点击注册前的预校验
点击注册按钮,注册之前,需要先校验
- 通过 ref 获取到 表单组件(想校验,那就先拿到form)
const form = ref()
<el-form ref="form">
- 注册之前进行校验
<el-button
@click="register"
class="button"
type="primary"
auto-insert-space
>
注册
</el-button>
//为什么用异步,因为validate返回的是promise
const register = async () => {
await form.value.validate()
console.log('开始注册请求')
}
...封装 api 实现注册功能
封装注册api,进行注册,注册成功切换到登录
- 新建 api/user.js 封装
import request from '@/utils/request'
//注册接口
//直接解构了
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
- 页面中调用
import {userRegisterService} from '@/api/user.js'
const register = async () => {
await form.value.validate()
await userRegisterService(formModel.value)
ElMessage.success('注册成功')
// 切换到登录
isRegister.value = false
}
- eslintrc 中声明全局变量名, 解决 ElMessage 报错问题(无需校验,直接使用)
- 为什么会报错
- 因为虽然你之前装了按需导入的插件,但是vscode的eslint不知道,所以虽然可以正常使用,但是会在写代码的页面报错
module.exports = {
...
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
}
登录校验和请求
也就是上面功能的最后一条:
- 登录功能(校验+登录+存token)
需求:给输入框添加表单校验(校验需求跟注册相同,不再赘述)
操作步骤:
- model 属性绑定 form 数据对象,直接绑定之前提供好的数据对象即可
- rules 配置校验规则,共用注册的规则即可
<el-form
:model="formModel"
:rules="rules"
>
- v-model 绑定 form 数据对象的子属性
<el-input
v-model="formModel.username" //!!!
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
<el-input
v-model="formModel.password"//!!!
name="password"
:prefix-icon="Lock"
type="password"
placeholder="请输入密码"
></el-input>
- prop 绑定校验规则
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
....
...
- 切换的时候重置
//切换的时候,重置表单内容
watch(isRegister, () => {
formModel.value = {
username: '',
password: '',
repassword: ''
}
})
- 预校验
【需求说明1】登录之前的预校验
- 登录请求之前,需要对用户的输入内容,进行校验
- 校验通过才发送请求
【需求说明2】登录功能
- 封装登录API,点击按钮发送登录请求
- 登录成功存储token,存入pinia 和 持久化本地storage
- 跳转到首页,给提示
- 注册事件,进行登录前的预校验 (获取到组件调用方法)
<el-form ref="form">
const login = async () => {
await form.value.validate()
console.log('开始登录',res)
}
分析:登录成功后会返回token,而我们需要将token存到本地
- 封装接口 API
export const userLoginService = ({ username, password }) =>
request.post('api/login', { username, password })
- 调用方法将 token 存入 pinia 并 自动持久化本地
// 与之相关的导入
import { userLoginService } from '@/api/user.js'
import router from '@/router'
import { useUserStore } from '@/stores'
//登录按钮校验
const userStore = useUserStore() //它里面有一个settoken的方法
const login = async () => {
await form.value.validate()
const res = await userLoginService(formModel.value)
//console.log('开始登录',res)
//登录成功后会返回token,而我们需要将token存到本地
userStore.setToken(res.data.token)
ElMessage.success('登录成功')
//跳转到本地
router.push('/')
}
二. 🙃首页layout架子[element-plus 菜单]
不管写什么,先搭架子
<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"
:default-active="$route.path"
text-color="#fff"
router
>
<el-menu-item index="/article/channel">
<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 index="/user">
<!-- 多级菜单的标题-具名插槽title -->
<template #title>
<el-icon><UserFilled /></el-icon>
<span>个人中心</span>
</template>
<!-- 展开的内容-默认插槽 -->
<el-menu-item index="/user/profile">
<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/password">
<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>
<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>
1. 基本架子拆解
1. 容器架子组件
el-container
el-aside 左侧
- el-menu 左侧边栏菜单
el-container 右侧
el-header 右侧头部
- el-dropdown
el-main 右侧主体
- router-view
2. 菜单组件
-
el-menu 整个菜单
active-text-color:
激活时文字颜色:default-active="$route.path"
配置默认高亮的是哪一个菜单项router
router选项开启,也就是开启了菜单的router模式。- el-menu-item 的
index
就是点击跳转
的路径
- el-menu-item 的
-
el-menu-item
菜单项index="/article/channel"
配置的是访问的跳转路径,配合default-active值实现高亮
-
el-sub-menu
多级菜单
2. 登录访问拦截(路由守卫)vue3
跟vue2有一些不太一样
根据返回值决定,是放行还是拦截(vue2是根据next)
三种情况:👇
-
undefined / 直接放行
- (如果什么都没有,undefined或返回true,则导航是有效的,并调用下一个导航守卫)
- 所以新版默认直接放行
-
false拦回from的地址是页面
-
具体路径或路径对象 拦截到对应的地址
- /lohin { name:'login'}
如果没有token,且访问的是非登录页,拦截到登录,其他情况正常放行
- 登录访问拦截
- 需求:
- 只有登录页,可以未授权的时候访问,其他所有页面,都需要先登录再访问
import { useUserStore } from '@/stores'
// 登录访问拦截
router.beforeEach((to) => {
const userStore = useUserStore()
if (!userStore.token && to.path !== '/login') return '/login'
// return true 可以不写,因为默认情况就是放行
})
3. 用户基本信息获取 & 渲染
1. 先封装接口api
接口文档: 获取用户的基本信息 - 黑马程序员-大事件 (apifox.com)

1. api/user.js
封装接口
// 获取用户基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
- stores/modules/user.js 定义数据
//默认是一个对象{}
const user = ref({})
//方法
const getUser = async () => {
const res = await userGetInfoService() // 请求获取数据
user.value = res.data.data
}
//暴露出去
return {
...
user,
getUser
}
3. layout/LayoutContainer
页面中调用
//从useUserStore中导入方法
import { useUserStore } from '@/stores'
//生命周期钩子(一进页面就可以调)
import { onMounted } from 'vue'
const userStore = useUserStore()
onMounted(() => {
userStore.getUser()
})
// userStore.getUser()不用钩子,直接调用也可以(也就是在create里)
2. 动态渲染
原本初始应该是没有数据的,你可以从所给的项目的在线演示里注册并完善信息,这样系统就会有信息了,然后就可以进行渲染了
<div>
黑马程序员:<strong>{{ userStore.user.nickname || userStore.user.username }}</strong>
</div>
<el-avatar :src="userStore.user.user_pic || avatar" />
4. 退出功能 [element-plus 确认框]
要么退出,要么跳转路由
- 注册点击事件
@command="onCommand"
<el-dropdown placement="bottom-end" @command="onCommand">
<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>
- 写这个对应方法
//要么就是退出,要么就是跳转路由
const handleCommand = async (key) => {
if (key === 'logout') {
//退出操作
//这里最后优化一下,可以添加一个弹窗组件
await ElMessageBox.confirm('你确认退出大事件吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
// 还需要清除本地数据(token + user信息)
userStore.removeToken()
userStore.setUser({})
router.push('/login')
} else {
//跳转操作
router.push(`/user/${key}`)
}
}
- 方法里涉及到清除本地数据(分别是removeToken和setUser)
- 而removetoken之前写过了,这里需要补充上setUser
stores/modules/user.js
const removeToken = () => {
token.value = ''
}
// 传一个空对象,不就相当于重置了吗,目的跟removetoken一样
//这个是清除user信息,上面是清除token信息
const setUser = (obj) => {
user.value = obj
}
三.🙃文章分类页面-[element-plus表格]
1. 基本架子 - PageContainer
- 基本结构样式,用到了 el-card 组件
<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-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>
- 考虑到多个页面复用,封装成组件(并将写死的内容修改)
- props 定制标题
- 默认插槽 default 定制内容主体
- 具名插槽 extra 定制头部右侧额外的按钮
也就是说,上面这些不要写在ArticleChannel里,把它封装进components中,然后在ArticleChannel里提供一个路由出口就可以了
<script setup>
// 父传子 title文章管理
defineProps({
title: {
required: true,
type: String
}
})
</script>
<template>
<el-card class="page-container">
<template #header>
<div class="header">
<!-- 文章管理 -->
<span>{{ title }}</span>
<div class="extra">
<!-- 添加文章按钮也不要写死,用具名插槽 -->
<slot name="extra"></slot>
</div>
</div>
</template>
<!-- 默认插槽定制内容 -->
<slot></slot>
</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>
- 页面中直接使用测试
直接扔进去
- 文章分类测试:
<template>
<page-container title="文章分类">
<template #extra>
<el-button type="primary"> 添加分类 </el-button>
</template>
主体部分
</page-container>
</template>
- 文章管理测试:
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
主体部分
</page-container>
</template>
2. 文章分类渲染
- 新建
api/article.js
封装获取频道列表的接口
// 获取文章分类
import request from '@/utils/request'
export const artGetChannelsService = () => request.get('/my/cate/list')
- 页面中调用接口,获取数据存储(存到channelList里面)
ArticleChannel.vue
<script setup>
import { ref } from 'vue'
import { artGetChannelsService } from '../../api/article'
const channelList = ref([])
const getChannelList = async () => {
const res = await artGetChannelsService()
channelList.value = res.data.data
}
// 直接调(或者onmounted也行)
getChannelList()
</script>
- el-table 表格动态渲染
!!row就是当前行的信息,就是channellist的每一项(可以理解为v-for的item),$index是下标
import { Edit, Delete } from '@element-plus/icons-vue'
<el-table :data="channelList" style="width: 100%">
<el-table-column label="序号" width="100" type="index"> </el-table-column>
<el-table-column label="分类名称" prop="cate_name"></el-table-column>
<el-table-column label="分类别名" prop="cate_alias"></el-table-column>
<el-table-column label="操作" width="100">
<!-- 作用域插槽 -->
<template #default="{ row, $index }">
<el-button
:icon="Edit"
circle
plain // 镂空
type="primary" // 蓝的
@click="onEditChannel(row, $index)"
></el-button>
<el-button
:icon="Delete"
circle
plain // 镂空
type="danger" // 红的
@click="onDelChannel(row, $index)"
></el-button>
</template>
</el-table-column>
// 可以定制empty插槽
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
const onEditChannel = (row, $index) => {
console.log(row, $index)
}
const onDelChannel = (row, $index) => {
console.log(row, $index)
}
3. el-table 表格的loading效果
- 定义变量,v-loading绑定
//默认不开,发请求的时候再开
const loading = ref(false)
<el-table v-loading="loading">
- 发送请求前开启,请求结束关闭
const getChannelList = async () => {
// 发请求之前开开
loading.value = true
const res = await artGetChannelsService()
channelList.value = res.data.data
// 发完请求后关掉
loading.value = false
}
4. 文章分类添加编辑 [element-plus 弹层]

1. 点击显示弹层
- 准备弹层
// 默认隐藏掉
const dialogVisible = ref(false)
<el-dialog v-model="dialogVisible" title="添加弹层" width="30%">
<div>我是内容部分</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary"> 确认 </el-button>
</span>
</template>
</el-dialog>
- 点击事件
<template #extra><el-button type="primary" @click="onAddChannel">添加分类</el-button></template>
const onAddChannel = () => {
dialogVisible.value = true
}
2. 封装弹层组件 ChannelEdit
添加 和 编辑,可以共用一个弹层,所以可以将弹层封装成一个组件
组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
- open({ }) => 添加操作,添加表单初始化无数据
- open({ id: xx, ... }) => 编辑操作,编辑表单初始化需回显
open调用后,可以打开弹窗
具体实现:
- 封装组件 (相当于建的子模块的小组件,跟全局不冲突)
article/components/ChannelEdit.vue
<script setup>
import { ref } from 'vue'
const dialogVisible = ref(false)
//组件对外暴露一个方法 open,基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
const open = async (row) => {
dialogVisible.value = true
console.log(row)
}
// 向外暴露方法
defineExpose({
open
})
</script>
<template>
<el-dialog v-model="dialogVisible" title="添加弹层" width="30%">
<div>我是内容部分</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary"> 确认 </el-button>
</span>
</template>
</el-dialog>
</template>
- 通过 ref 绑定
ArticleChannel.vue
//vue3要求必须写.vue的后缀,不然报错
import ChannelEdit from './components/ChannelEdit.vue'
const dialog = ref()
<!-- 弹窗 -->
<channel-edit ref="dialog"></channel-edit>
- 点击调用方法显示弹窗 ArticleChannel.vue
const onAddChannel = () => {
dialog.value.open({})
}
const onEditChannel = (row) => {
dialog.value.open(row)
}
3. 准备弹层表单
四个步骤,缺一不可
- 准备数据 和 校验规则
ChannelEdit.vue
// 声明一个数据
const formModel = ref({
cate_name: '',// 名称
cate_alias: ''// 别名
})
const rules = {
cate_name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{
pattern: /^\S{1,10}$/,
message: '分类名必须是1-10位的非空字符',
trigger: 'blur'
}
],
cate_alias: [
{ required: true, message: '请输入分类别名', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9]{1,15}$/,
message: '分类别名必须是1-15位的字母数字',
trigger: 'blur'
}
]
}
- 准备表单
ChannelEdit.vue
<el-form
:model="formModel"
:rules="rules"
label-width="100px"
style="padding-right: 30px"
>
<el-form-item label="分类名称" prop="cate_name">
<el-input
v-model="formModel.cate_name"
minlength="1"
maxlength="10"
></el-input>
</el-form-item>
<el-form-item label="分类别名" prop="cate_alias">
<el-input
v-model="formModel.cate_alias"
minlength="1"
maxlength="15"
></el-input>
</el-form-item>
</el-form>
- 编辑需要回显,表单数据需要初始化
回显就是,你原来的数据自动同步回显在输入框中
const open = async (row) => {
dialogVisible.value = true
// 通过展开运算符,将row里的全部数据在value里展开
// 添加——》相当于重置了表单内容
// 编辑——》相当于存储了回显的数据
formModel.value = { ...row }
}
- 基于传过来的表单数据,进行标题控制,有 id 的是编辑
//:title="添加弹层",不用写死了
:title="formModel.id ? '编辑分类' : '添加分类'"
4. 确认提交
api/article.js
封装请求 API
// 添加文章分类
export const artAddChannelService = (data) => request.post('/my/cate/add', data)
// 编辑文章分类
export const artEditChannelService = (data) =>
request.put('/my/cate/info', data)
- 页面中校验,判断,提交请求
<el-form ref="formRef">
<el-button type="primary" @click="onSubmit"> 确认 </el-button>
import { artEditChannelService, artAddChannelService } from '@/api/article.js'
const formRef = ref()
const onSubmit = async () => {
await formRef.value.validate()
formModel.value.id
? await artEditChannelService(formModel.value)
: await artAddChannelService(formModel.value)
ElMessage({
type: 'success',
message: formModel.value.id ? '编辑成功' : '添加成功'
})
// 弹层关闭
dialogVisible.value = false
}
上面是三元运算符写法👆
也可以这么写,用id判断👇
- 通知父组件进行回显(子传父)
const emit = defineEmits(['success'])
const onSubmit = async () => {
...
emit('success')
}
- 父组件监听 success 事件,进行调用回显
<channel-edit ref="dialog" @success="onSuccess"></channel-edit>
const onSuccess = () => {
getChannelList()
}
5. 文章分类的删除
api/article.js
封装接口 api
// 删除文章分类
export const artDelChannelService = (id) =>
request.delete('/my/cate/del', {
params: { id }
})
- 页面中添加确认框,调用接口进行提示
ArticleChannel.vue在父组件
import { artGetChannelsService, artDelChannelService } from '../../api/article'
//删之前的确认框
const onDelChannel = async (row) => {
await ElMessageBox.confirm('你确认删除该分类信息吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
//执行删除
await artDelChannelService(row.id)
ElMessage({ type: 'success', message: '删除成功' })
getChannelList()
}
四.🙃文章管理页面 - [element-plus 强化]
1. 文章列表渲染
1.1基本架子搭建
- 搜索表单(表单区域)
//inline在同一行显示
<el-form inline>
<el-form-item label="文章分类:">
<el-select>
<el-option label="新闻" value="111"></el-option>
<el-option label="体育" value="222"></el-option>
</el-select>
</el-form-item>
<el-form-item label="发布状态:">
//这里后台标记发布状态,就是通过中文标记的
<el-select>
<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>
- 表格准备,模拟假数据渲染(表格区域)
import { Delete, Edit } from '@element-plus/icons-vue'
import { ref } from 'vue'
// 假数据
const articleList = ref([
{
id: 5961,
title: '新的文章啊',
pub_date: '2022-07-10 14:53:52.604',
state: '已发布',
cate_name: '体育'
},
{
id: 5962,
title: '新的文章啊',
pub_date: '2022-07-10 14:54:30.904',
state: null,
cate_name: '体育'
}
])
<el-table :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
//插槽 + 链接组件
<template #default="{ row }">
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<el-table-column label="发表时间" prop="pub_date"> </el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<!-- 利用作用域插槽 row 可以获取当前行的数据 v-for遍历item -->
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
:icon="Edit"
circle
plain
type="primary"
@click="onEditArticle(row)"
></el-button>
<el-button
:icon="Delete"
circle
plain
type="danger"
@click="onDeleteArticle(row)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
//编辑逻辑
const onEditArticle = (row) => {
console.log(row)
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row)
}
1.2中英国际化处理(全局配置)
默认是英文的,由于这里不涉及切换, 所以在 App.vue 中直接导入设置成中文即可,
<script setup>
import zhCn from 'element-plus/es/locale/lang/zh-cn.mjs'
</script>
<template>
<!-- 国际化处理 -->
<el-config-provider :locale="zhCn">
# <router-view />
</el-config-provider>
</template>
1.3文章分类下拉菜单选择(父子组件通信)
为了便于维护,直接拆分成一个小组件 ChannelSelect.vue
- 新建 article/components/ChannelSelect.vue
<template>
<el-select>
<el-option label="新闻" value="新闻"></el-option>
<el-option label="体育" value="体育"></el-option>
</el-select>
</template>
- 页面中导入渲染,父组件
import ChannelSelect from './components/ChannelSelect.vue'
<el-form-item label="文章分类:">
<channel-select></channel-select>
</el-form-item>
- 调用接口,动态渲染下拉分类,设计成 v-model 的使用方式
子组件ChannelSelect.vue
<script setup>
import { artGetChannelsService } from '@/api/article'
import { ref } from 'vue'
defineProps({
modelValue: {
type: [Number, String]
}
})
const emit = defineEmits(['update:modelValue'])
const channelList = ref([])
const getChannelList = async () => {
const res = await artGetChannelsService()
channelList.value = res.data.data
}
getChannelList()
</script>
<template>
//写成这样,是子通知父亲更改,不能写成v-model,因为我们不能直接修改父
//emit触发事件
<el-select
:modelValue="modelValue"//父传子,渲染数据
@update:modelValue="emit('update:modelValue', $event)"//子传父,修改数据
>
<el-option
v-for="channel in channelList"
:key="channel.id"
:label="channel.cate_name"
:value="channel.id"
></el-option>
</el-select>
</template>
- 父组件定义参数绑定
//params参数对象(定义请求参数对象)
const params = ref({
pagenum: 1,//当前页
pagesize: 5,//每页生效条数
cate_id: '',
state: ''
})
<channel-select v-model="params.cate_id"></channel-select>
- 发布状态,也绑定一下,便于将来提交表单
<el-select v-model="params.state">
<el-option label="已发布" value="已发布"></el-option>
<el-option label="草稿" value="草稿"></el-option>
</el-select>
1.4封装 API 接口,请求渲染文章列表数据
没有数据,可以登录已完成的系统,添加几条数据
api/article.js
封装接口
// 文章:获取文章列表(传过来一个对象)
export const artGetListService = (params) =>
request.get('/my/article/list', { params })
- 页面中调用保存数据
import { artGetListService } from '@/api/article.js'
const articleList = ref([]) // 文章列表
const total = ref(0)//默认总条数为0
const getArticleList = async () => {
const res = await artGetListService(params.value)
//渲染
articleList.value = res.data.data
total.value = res.data.total
}
getArticleList()
- 新建
utils/format.js
封装格式化日期函数
import { dayjs } from 'element-plus'
export const formatTime = (time) => dayjs(time).format('YYYY年MM月DD日')
- 导入使用
import { formatTime } from '@/utils/format'
<el-table-column label="发表时间">
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
1.5分页渲染 [element-plus 分页]
- 分页组件
// 分页区域
<el-pagination
v-model:current-page="params.pagenum"
v-model:page-size="params.pagesize"
:page-sizes="[2, 3, 4, 5, 10]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
- 提供分页修改逻辑
const onSizeChange = (size) => {
//console.log('页码变化了',size)
//只要是每页条数变化了,那么原本正在访问的当前页意义就不大了,数据大概率已经不在原来那一页了
//重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
//基于最新的 当前页 和 每页条数 进行渲染
getArticleList()
}
const onCurrentChange = (page) => {
// 更新当前页
params.value.pagenum = page
//渲染
getArticleList()
}
1.6 添加 loading 处理
- 准备数据
const loading = ref(false)//loading初始状态
- el-table上面绑定
<el-table v-loading="loading" > ... </el-table>
- 发送请求时添加 loading
const getArticleList = async () => {
loading.value = true
...
loading.value = false
}
getArticleList()
1.7 搜索 和 重置功能
- 注册事件
<el-form-item>
<el-button @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
- 绑定处理
//搜索逻辑=》按照最新的条件,重新检索,从第一页开始展示
const onSearch = () => {
// 重置页码
params.value.pagenum = 1
// 重新渲染
getArticleList()
}
const onReset = () => {
params.value.pagenum = 1
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
2. 文章发布&修改 [element-plus - 抽屉]
2.1 点击显示抽屉
- 准备数据
import { ref } from 'vue'
const visibleDrawer = ref(false)
- 准备抽屉容器
<el-drawer
v-model="visibleDrawer"
title="大标题"
direction="rtl"
size="50%"
>
<span>Hi there!</span>
</el-drawer>
- 点击修改布尔值显示抽屉
<el-button type="primary" @click="onAddArticle">发布文章</el-button>
const visibleDrawer = ref(false)
const onAddArticle = () => {
visibleDrawer.value = true
}
2.2 封装抽屉组件 ArticleEdit
添加 和 编辑,可以共用一个抽屉,所以可以将抽屉封装成一个组件
组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
- open({ }) => 添加操作,添加表单初始化无数据
- open({ id: xx, ... }) => 编辑操作,编辑表单初始化需回显
具体实现:
- 封装组件
article/components/ArticleEdit.vue
<script setup>
import { ref } from 'vue'
const visibleDrawer = ref(false)
const open = (row) => {
visibleDrawer.value = true
console.log(row)
}
defineExpose({
open
})
</script>
<template>
<!-- 抽屉 -->
<el-drawer v-model="visibleDrawer" title="大标题" direction="rtl" size="50%">
<span>Hi there!</span>
</el-drawer>
</template>
- 通过 ref 绑定(父组件)
import ArticleEdit from './components/ArticleEdit.vue'
const articleEditRef = ref()
<!-- 弹窗(也就是抽屉) -->
<article-edit ref="articleEditRef"></article-edit>
- 点击调用方法显示弹窗(父组件)
const articleEditRef = ref()
// 添加逻辑
const onAddArticle = () => {
articleEditRef.value.open({})
}
//编辑逻辑
const onEditArticle = (row) => {
articleEditRef.value.open(row)
}
2.3 完善抽屉表单结构
ArticleEdit.vue
- 准备数据
const formModel = ref({
title: '',
cate_id: '',
cover_img: '',
content: '',
state: ''
})
const open = async (row) => {
visibleDrawer.value = true
if (row.id) {
console.log('编辑回显')
} else {
console.log('添加功能')
}
}
- 一打开默认重置添加的 form 表单数据
// 默认表单数据
const defaultForm = {
title: '',
cate_id: '',
cover_img: '',
content: '',
state: ''
}
const formModel = ref({ ...defaultForm })
const open = async (row) => {
visibleDrawer.value = true
if (row.id) {
console.log('编辑回显')
} else {
console.log('添加功能')
formModel.value = { ...defaultForm }
}
}
- 准备 form 表单结构
import ChannelSelect from './ChannelSelect.vue'
<template>
<el-drawer
v-model="visibleDrawer"
:title="formModel.id ? '编辑文章' : '添加文章'"
direction="rtl"
size="50%"
>
<!-- 发表文章表单 -->
<el-form :model="formModel" ref="formRef" label-width="100px">
<el-form-item label="文章标题" prop="title">
<el-input v-model="formModel.title" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="文章分类" prop="cate_id">
<channel-select
v-model="formModel.cate_id"
width="100%"
></channel-select>
</el-form-item>
<el-form-item label="文章封面" prop="cover_img"> 文件上传 </el-form-item>
<el-form-item label="文章内容" prop="content">
<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>
</template>
- 扩展 下拉菜单 width props
因为封装成组件后,你加的东西就都变成了属性,属性就需要逻辑,所以不提供逻辑就不生效,比如:width="100%"
ChannelSelect.vue
defineProps({
modelValue: {
type: [Number, String]
},
width: {
type: String
}
})
<el-select
...
:style="{ width }"
>
- 上传文件 [element-plus - 文件预览]
一点击就触发onchange事件
- 关闭自动上传(本地预览),准备结构(为的是只有在点击发布后才会真的上传到服务器,减小服务器压力)
import { Plus } from '@element-plus/icons-vue'
<el-upload
class="avatar-uploader"
:auto-upload="false"
:show-file-list="false"
:on-change="onUploadFile"
>
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
//之后再提供方法逻辑
</el-upload>
- 准备数据 和 选择图片的处理逻辑
// 图片上传相关逻辑
const imgUrl = ref('')
// 文件选择
const onUploadFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw)//预览图片
formModel.value.cover_img = uploadFile.raw
}
- 样式美化
.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;
}
}
}
- 富文本编辑器 [ vue-quill ]
官网地址:vueup.github.io/vue-quill/
- 安装包
pnpm add @vueup/vue-quill@latest
- 注册成局部组件
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
- 页面中使用绑定
<div class="editor">
<quill-editor
theme="snow"
v-model:content="formModel.content"//跟formmodel数据双向绑定
contentType="html"
>
</quill-editor>
</div>
- 样式美化
.editor {
width: 100%;
:deep(.ql-editor) {
min-height: 200px;
}
}
2.4 添加文章功能
- 封装添加接口api
//文章:添加文字
//data需要是一个formData格式的对象
export const artPublishService = (data) =>
request.post('/my/article/add', data)
- 注册点击事件调用
<el-form-item>
<el-button @click="onPublish('已发布')" type="primary">发布</el-button>
<el-button @click="onPublish('草稿')" type="info">草稿</el-button>
</el-form-item>
// 发布文章
//定义一下success
const emit = defineEmits(['success'])
const onPublish = async (state) => {
// 将已发布还是草稿状态,存入 state
//但是这是个对象,不能直接用于提交
formModel.value.state = state
// 所以:转换成 formData 数据
// 先创建一个
const fd = new FormData()
// 都要,所以遍历,全都转换进formData
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
//区别编辑还是添加
if (formModel.value.id) {
console.log('编辑操作')
} else {
// 添加请求
await artPublishService(fd)
ElMessage.success('添加成功')
visibleDrawer.value = false
//通知到父组件添加成功了,emit触发
emit('success', 'add')
}
}
- 父组件监听success事件,重新渲染(通知到父组件,添加成功了)
如果是添加,需要跳转渲染最后一页,编辑直接渲染当前页
//@success="onSuccess"注册这个事件
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
// 添加修改成功
const onSuccess = (type) => {
if (type === 'add') {
// 如果是添加,需要跳转渲染最后一页,编辑直接渲染当前页
//渲染时后台已经有你新增的了,所以渲染需要+1,更准确
//除法算出需要多少页,math向上取整(因为要求最大页码数)
const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
params.value.pagenum = lastPage
}
getArticleList()
}
2.5 添加完成后的内容重置
const formRef = ref()
const editorRef = ref()
const open = async (row) => {
visibleDrawer.value = true
if (row.id) {
console.log('编辑回显')
} else {
formModel.value = { ...defaultForm }
// 重置
imgUrl.value = ''
editorRef.value.setHTML('')
}
}
//因为上面声明了const editorRef = ref()
//所以下面ref绑一下
<quill-editor
ref="editorRef"
theme="snow"
v-model:content="formModel.content"
contentType="html"
>
</quill-editor>
2.6 编辑文章回显
如果是编辑操作,一打开抽屉,就需要发送请求,获取数据进行回显
- 封装接口,根据 id 获取详情数据
// 文章:获取文章详情
export const artGetDetailService = (id) =>
request.get('my/article/info', { params: { id } })
- 页面中调用渲染
import { artGetDetailService } from '@/api/article'
import { baseURL } from '@/utils/request'
const open = async (row) => {
visibleDrawer.value = true
if (row.id) {
console.log('编辑回显')
//基于id发请求
const res = await artGetDetailService(row.id)
formModel.value = res.data.data
imgUrl.value = baseURL + formModel.value.cover_img
// 提交给后台,需要的是 file 格式的,将网络图片,转成 file 格式
// 网络图片转成 file 对象, 需要转换一下
formModel.value.cover_img = await imageUrlToFile(imgUrl.value, formModel.value.cover_img)
} else {
console.log('添加功能')
...
}
}
chatGPT prompt:封装一个函数,基于 axios, 网络图片地址,转 file 对象, 请注意:写中文注释
import axios from 'axios'
// 将网络图片地址转换为File对象
async function imageUrlToFile(url, fileName) {
try {
// 第一步:使用axios获取网络图片数据
const response = await axios.get(url, { responseType: 'arraybuffer' });
const imageData = response.data;
// 第二步:将图片数据转换为Blob对象
const blob = new Blob([imageData], { type: response.headers['content-type'] });
// 第三步:创建一个新的File对象
const file = new File([blob], fileName, { type: blob.type });
return file;
} catch (error) {
console.error('将图片转换为File对象时发生错误:', error);
throw error;
}
}
2.7 编辑文章提交功能
- 封装编辑接口api
// 文章:编辑文章
export const artEditService = (data) => request.put('my/article/info', data)
- 提交时调用
import ...
const onPublish = async (state) => {
...
if (formModel.value.id) {
await artEditService(fd)
ElMessage.success('编辑成功')
visibleDrawer.value = false
emit('success', 'edit')
} else {
// 添加请求
...
}
}
2.8文章删除
父组件
- 封装删除接口api
// 文章:删除文章
export const artDelService = (id) => request.delete('my/article/info', { params: { id } })
- 页面中添加确认框调用
const onDeleteArticle = async (row) => {
await ElMessageBox.confirm('你确认删除该文章信息吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
await artDelService(row.id)
ElMessage({ type: 'success', message: '删除成功' })
getArticleList()
}
ChatGPT
Copilot
转载自:https://juejin.cn/post/7334311273339912207