目录
0 前言
黑马程序员视频地址:Vue3大事件项目-项目介绍和pnpm创建项目
接口文档:登录 - 黑马程序员-大事件
1 准备工作
1.1 安装pnpm
官网:pnpm - 速度快、节省磁盘空间的软件包管理器 | pnpm中文文档 | pnpm中文网
安装pnpm命令:
npm i pnpm -g
pnpm创建vue项目命令:
pnpm create vue
命令对比:
npm | yarn | pnpm |
---|---|---|
npm install | yarn | pnpm install |
npm install axios | yarn add axios | pnpm add axios |
npm install axios -D | yarn add axios -D | pnpm add axios -D |
npm uninstall axios | yarn remove axios | pnpm remove axios |
npm run dev | yarn dev | pnpm dev |
1.2 创建vue项目
使用pnpm创建vue项目时,选择以下配置
请选择要包含的功能: (↑/↓ 切换,空格选择,a 全选,回车确认)
| [ ] TypeScript
| [ ] JSX 支持
| [+] Router(单页面应用开发)
| [+] Pinia(状态管理)
| [ ] Vitest(单元测试)
| [ ] 端到端测试
| [+] ESLint(错误预防)
| [+] Prettier(代码格式化)
标记:警告提示(待解决)
创建完项目需要进入相应文件夹中,安装所有依赖
pnpm install
1.3 Eslint & Prettier的配置
见 【VUE3】Eslint 与 Prettier 的配置-CSDN博客
推荐使用里面的方案二,即将 prettier 的规则让 eslint 来执行
因为 1.4 中的检查代码需要 eslint 来检查
1.4 husky 提交代码检查
husky 是一个 git hooks 工具 ( git的钩子工具,可以在特定时机执行特定的命令 )
第一步:初始化仓库
git init
第二步:初始化 husky 工具配置
pnpm dlx husky-init; pnpm install
第三步:修改 .husky/pre-commit 文件
pnpm lint
但是这样会有一个问题!
我们可以打开 package.json 文件,看到里面的 lint 命令对应为:
"lint": "eslint . --fix"
即默认进行的是全量检查,耗时问题,历史问题,因此需要再导入一个包 lint-staged :
第一步:安装
pnpm i lint-staged -D
第二步:配置 package.json 文件
{
// ... 省略 ...
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}
{
"scripts": {
// ... 省略 ...
"lint-staged": "lint-staged"
}
}
第三步:修改 .husky/pre-commit 文件
pnpm lint-staged
这样就会只检查修改过的文件,哪怕以前提交的文件有问题,也不会检查报错
可以通过控制 1.3 中的那篇文章的第七节中的 'no-undef': 'off'来模拟提交以前有问题的文件
1.5 目录调整
删除默认文件后,增加 api 与 utils 文件夹
如果使用 sass,则需安装对应依赖
pnpm add sass -D
1.6 VueRouter4
更多内容见官网:Vue Router | Vue.js 的官方路由
1.6.1 基础配置
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
1.6.2 路由跳转
由于 setup 下,this 指向 undefined ,因此需要引入包创建 router 与 router 对象
<script setup>
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const gotoCart = () => {
console.log(route)
router.push('/individual')
}
</script>
<template>
<div>我是App</div>
<button @click="gotoCart()">跳转购物车页面</button>
</template>
也可以导入 1.6.1 中的 router 对象,调用其身上的 push 方法
ai说前者只能在setup语法糖中使用,只适用于组合式api
1.7 引入 Element Plus 组件库
官方手册:一个 Vue 3 UI 框架 | Element Plus
安装组件库
pnpm install element-plus
按需引入:
第一步:安装插件
pnpm add -D unplugin-vue-components unplugin-auto-import
第二步:把下列代码插入到你的 Vite
的配置文件中
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
// ...
plugins: [
// ...
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
全部引入此处不赘述
注意:
1.在引入之后,无需任何配置,即可使用组件库内的组件
2.并且components下的vue组件也可以直接使用,无需导入
1.8 Pinia
注意:由于创建项目时勾选了Pinia,所以此处不需要再手动安装配置Pinia,直接可以使用
但是持久化还需手动配置,此处不再赘述
1.8.1 优化
将 main.js 中关于 pinia 的代码抽离到 store/index.js 中,并且将 store/modules 中的所有仓库文件统一从 index.js 中导出,方便管理且简化代码
// main.js
// ...
import pinia from './stores'
// ...
app.use(pinia)
// ...
// store/index.js
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(persist)
export default pinia
export * from '@/stores/modules/user'
export * from '@/stores/modules/count'
// 上面的代码等同于
// import { useUserStore, ... } from '@/stores/modules/user'
// export { useUserStore, ... }
// 组件.vue
import { useUserStore, useCountStore } from '@/stores'
const userStore = useUserStore()
const userCount = useCountStore()
1.9 封装请求工具
1.9.1 安装 axios 与配置框架
1.安装 axios
pnpm add axios
2.框架代码
// utils/request.js
import axios from 'axios'
const baseURL = 'http://big-event-vue-api-t.itheima.net'
const instance = axios.create({
// TODO 1. 基础地址,超时时间
})
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错误
return Promise.reject(err)
}
)
export default instance
1.9.2 示例代码
// utils/request.js
import axios from 'axios'
import { useUserStore } from '@/stores'
import { ElMessage } from 'element-plus'
import router from '@/router'
const baseURL = 'http://big-event-vue-api-t.itheima.net'
const instance = axios.create({
// TODO 1. 基础地址,超时时间
baseURL,
timeout: 100000
})
instance.interceptors.request.use(
(config) => {
// TODO 2. 携带token
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = userStore.token
}
return config
},
(err) => Promise.reject(err)
)
instance.interceptors.response.use(
(res) => {
// TODO 3. 处理业务失败
if (res.data.code === 0) {
return res
}
ElMessage({ message: res.data.message || '服务异常', type: 'error' })
// TODO 4. 摘取核心响应数据
return Promise.reject(res.data)
},
(err) => {
ElMessage({ message: err.response.data.message || '服务异常', type: 'error' })
// TODO 5. 处理401错误
if (err.response?.status === 401) {
router.push('/login')
}
return Promise.reject(err)
}
)
export default instance
2 路由配置
示例:采用 路由懒加载
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
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') }
]
}
]
})
export default router
记得准备路由出口,如:
// App.vue
<template>
<router-view></router-view>
</template>
3 登录注册
3.1 静态页面
1.安装 element-plus 图标库
pnpm i @element-plus/icons-vue
2.静态结构准备
注意:登录页面与注册页面使用 v-if 与 v-else 控制切换
// views/login/LoginPage.vue
<script setup>
import { User, Lock } from '@element-plus/icons-vue'
import { ref } from 'vue'
const isRegister = ref(true)
</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>
3.2 规则校验
四大校验方式:
1.非空校验:required
2.长度校验:min、max
3.正则校验:pattern
4.自定义校验 :validator
第一步:声明表单数据对象与规则对象,其中表单数据对象必须是响应式的
const formData = ref({
username: '',
password: '',
repassword: ''
})
const formRules = {
username: [
{ required: true, message: '用户名不能为空!', trigger: 'blur' },
// blur是失去焦点事件
{ min: 5, max: 10, message: '用户名必须为5-10位字符', trigger: 'blur' }
],
password: [
{ required: true, message: '密码不能为空!', trigger: 'blur' },
{ pattern: /^\S{8,15}$/, message: '密码必须为8-15位非空字符', trigger: 'change' }
// change为改变时校验,发现当检验提示错误后,若触发失焦事件,会导致提示消失,因此不推荐
]
}
第二步:给整个表单的大标签绑定数据对象与规则对象
<el-form
...
:model="formData"
:rules="formRules"
>
<!-- ... -->
</el-form>
第三步:给输入框绑定数据,并且给表单元素标签绑定要使用的规则
<el-form-item prop="username"> ❗
<el-input
:prefix-icon="User"
placeholder="请输入用户名"
v-model="formData.username" ❗
></el-input>
</el-form-item>
自定义校验方式:
使用方式相同,只是配置规则时,validator 指向一个函数
参数:
rule:当前校验规则相关的信息
value:所校验的表单元素目前的表单值
callback:无论成功还是失败,都需要 callback 回调
const formRules = {
// ...
repassword: [
{
validator: (rule, value, callback) => {
if (value !== formData.value.password) {
callback(new Error('两次密码不一致,请重新输入!'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
3.3 表单预校验
由于vue3的特性,即导入的组件内的方法默认不会暴露,因此需要获取组件对象,然后再获取,如:
第一步:获取组件对象
详细见 【VUE3】组合式API-CSDN博客 中的 9.2
const form = ref()
<el-form
ref="form" ❗
size="large"
autocomplete="off"
v-if="isRegister"
:model="formData"
:rules="formRules"
>
第二步:调用 validate 方法
validate 方法即对整个表单的内容进行验证,接收一个回调函数,或返回 Promise
const register = async () => {
await form.value.validate() ❗
await userRegisterService(formData.value) // 这个是提交数据api
ElMessage.success('注册成功!')
isRegister.value = false // 切换登录
}
注意:因为之前我们设置了插件来帮我们自动导入Element组件,所以上述代码中的ElMessage方法我们没有导入,但是eslint会报错,可以在 eslint.config.js 中来配置,让其不报错,位置及代码如下:
// ...
export default defineConfig([
// ...
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
// 除了以下这三行,其他的都是eslint.config.js中自带的,直接把这三行插入到这即可
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
}
},
// ...
])
3.4 封装注册api
// api/user.js
import request from '@/utils/request'
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
调用见 2.2.3
3.5 登录校验
与注册校验相同,此处不再赘述
需要注意的是,记得在请求完成后,将token存到store中,并且跳转页面
4 首页
4.1 静态资源
<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">
<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>
4.2 访问拦截
官方手册:导航守卫 | Vue Router
// router/index.js
import { useUserStore } from '@/stores'
// ...
router.beforeEach((to) => {
const userStore = useUserStore()
if (!userStore.token && to.path !== '/login') return '/login'
})
默认是直接放行
根据返回值决定,是放行还是拦截
返回值:
1. undefined / true :直接放行
2. false :拦截到 from 的地址页面
3. 具体路径 或 路径对象 :拦截到对于的地址
‘/login’ { name: 'login' }
4.3 获取用户数据&动态渲染
如果使用生命周期函数,记得从vue中导入
此处不再赘述,具体见黑马文档
1. 配置api
2. 在store中封装请求api并存储的函数
3. 在首页中调用store的函数
4.4 退出功能
4.4.1 下拉菜单
手册:Dropdown 下拉菜单 | Element Plus
见其中的指令事件
<el-dropdown placement="bottom-end" @command="handleCommand">
<span class="el-dropdown__box">
<el-avatar :src="userStore.userInfo.user_pic || avatar" />
<el-icon><CaretBottom /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile" :icon="User">基本资料</el-dropdown-item>
<el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
<el-dropdown-item command="password" :icon="EditPen">重置密码</el-dropdown-item>
<el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
注意:给整个下拉菜单绑定一个command监听事件,并给一个回调函数,当点击选项时,会触发此回调函数,并将选项中的command值传给回调函数,可以用一个形参接收
// ...
const handleCommand = async (command) => {
if (command === 'logout') {
// 退出登录
await ElMessageBox.confirm(
'您确定要退出登录吗?',
'温馨提示',
{
type: 'warning',
confirmButtonText:'确定',
cancelButtonText:'取消'
}
)
// 清空内容
userStore.removeToken()
userStore.removeUserInfo()
// 跳转登录页面
router.push('/login')
} else {
// 跳转
router.push(`/user/${command}`)
}
}
4.4.2 消息弹出框
手册:MessageBox 消息弹框 | Element Plus
见其中的确认消息
注意:
1.给消息弹出框使用异步标识符 async await,否则还没点击确认,就已经退出了
2.由于配置了element组件库的自动导入,有时候如果上面再手动导入相应的模块时,反而会失效
5 文章分类
5.1 文章分类架子—封装 card 组件
插槽基础知识:
【VUE2】第三期——样式冲突、组件通信、异步更新、自定义指令、插槽!!-CSDN博客
<!-- components/pageContainer.vue -->
<script setup>
defineProps({
title:{
type: String,
required: true
}
})
</script>
<template>
<el-card class="page-container">
<template #header>
<div class="header">
<span>{{ title }}</span>
<slot name="extra"></slot>
</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>
5.2 文章分类渲染
5.2.1 封装获取文章分类列表api
// api/article.js
import request from '@/utils/request'
// 获取文章列表
export const artGetArticleListService = () => request.get('/my/cate/list')
5.2.2 封装 pinia
// stores/modules/article.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { artGetArticleListService } from '@/api/article'
export const useArticleStore = defineStore('article', () => {
// 文章分类列表
const articleClassList = ref()
// 控制loading效果开关
const articleLoading = ref(false)
// 获取文章分类列表
const getArticleClassList = async () => {
articleLoading.value = true
const res = await artGetArticleListService()
articleClassList.value = res.data.data
articleLoading.value = false
}
return {
articleClassList,
getArticleClassList,
articleLoading
}
})
在 stores/index.js 中导出
export * from '@/stores/modules/article'
5.2.3 页面布局、数据渲染、loading效果及空状态
使用到的组件:
完整代码示例:
<!--views/article/ArticleChannel.vue-->
<script setup>
// 导入按钮图标
import { Edit, Delete } from '@element-plus/icons-vue'
// 导入pinia
import { useArticleStore } from '@/stores'
const articleStore = useArticleStore()
// 获取数据并存入pinia
articleStore.getArticleClassList()
// 每一个item的点击事件,获取索引与整个item对象,其他参数见 表格手册 中 的 自定义列模板
const handleEdit = ($index, row) => {
console.log($index, row)
}
const handleDelete = ($index, row) => {
console.log($index, row)
}
</script>
<template>
<page-container title="频道管理">
<template #extra>
<el-button type="primary">添加分类</el-button>
</template>
<el-table
:data="articleStore.articleClassList" <!--绑定列表数据-->
style="width: 100%"
v-loading="articleStore.articleLoading"
>
<el-table-column label="序号" type="index" width="80"></el-table-column>
<!--label是显示的文字,type="index"是显示序号,width是宽度,prop是绑定数据-->
<el-table-column prop="cate_name" label="分类名"></el-table-column>
<el-table-column prop="cate_alias" label="分类别名"></el-table-column>
<el-table-column label="操作" width="150">
<!--这里通过默认插槽后的变量名,获取插槽传来的值-->
<template #default="{ $index, row }">
<el-button
type="primary" <!--按钮类型(颜色)这里不应该写注释,使用时记得删掉-->
:icon="Edit" <!--按钮图标-->
circle <!--设置为圆形-->
plain <!--设置为镂空-->
@click="handleEdit($index, row)"
></el-button>
<el-button
type="danger"
:icon="Delete"
circle
plain
@click="handleDelete($index, row)"
></el-button>
</template>
</el-table-column>
<!--通过empty具名插槽来设置无数据时的状态-->
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
</page-container>
</template>
5.3 弹层表单
封装成组件:
<!--view/article/components/ChannelEdit.vue-->
<script setup>
import { ref } from 'vue'
const dialogVisible = ref(false) // 控制显示与隐藏
// 封装控制开关的方法
const open = (row) => { // row用来接收外部传进来的值
dialogVisible.value = true
console.log(row)
}
// 向外暴露出方法
defineExpose({
open
})
</script>
<template>
<el-dialog v-model="dialogVisible" title="Tips" width="500">
<span>消息标题</span>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="dialogVisible = false"> 确定 </el-button>
</div>
</template>
</el-dialog>
</template>
使用:
<!--views/article/ArticleChannel.vue-->
<script setup>
import { Edit, Delete } from '@element-plus/icons-vue'
import { useArticleStore } from '@/stores'
// 导入组件
import ChannelEdit from './components/ChannelEdit.vue'
import { ref } from 'vue'
const articleStore = useArticleStore()
// 获取组件对象,以获取内部方法
const dialog = ref()
articleStore.getArticleClassList()
// 编辑按钮事件
const handleEdit = ($index, row) => {
// 使用组件内部提供的方法,向组件传递当前item的值
dialog.value.open(row)
}
// 删除按钮事件
const handleDelete = ($index, row) => {
console.log($index, row)
}
// 新增按钮事件
const onAddChannel = () => {
// 使用组件内部提供的方法,向组件传递空对象
dialog.value.open({})
}
</script>
<template>
<page-container title="频道管理">
<template #extra>
<el-button type="primary" @click="onAddChannel()">添加分类</el-button>
</template>
<el-table
:data="articleStore.articleClassList"
style="width: 100%"
v-loading="articleStore.articleLoading"
>
<el-table-column label="序号" type="index" width="80"></el-table-column>
<el-table-column prop="cate_name" label="分类名"></el-table-column>
<el-table-column prop="cate_alias" label="分类别名"></el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ $index, row }">
<el-button
type="primary"
:icon="Edit"
circle
plain
@click="handleEdit($index, row)"
></el-button>
<el-button
type="danger"
:icon="Delete"
circle
plain
@click="handleDelete($index, row)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
</page-container>
<!--弹层组件-->
<ChannelEdit ref="dialog"></ChannelEdit>
</template>
5.3.1 表单检测
规则校验见3.2
<!--views/article/components/ChannelEdit.vue-->
<script setup>
// ...
// 提供表单数据对象
const formData = ref({
cate_name: '',
cate_alias: ''
})
// 提供校验规则对象
const formRules = {
cate_name: [
{ required: true, message: '分类名称不能为空!', trigger: 'blur' },
{ pattern: /^\S{2,6}$/, message: '请输入2-6位非空字符', trigger: 'blur' }
],
cate_alias: [
{ required: true, message: '分类别名不能为空!', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9]{2,9}$/, message: '请输入2-9位字母或数字', trigger: 'blur' }
]
}
</script>
<!--下面只需要注意绑定四个值即可-->
<template>
<el-dialog v-model="dialogVisible" title="新增频道" width="500">
<el-form label-width="100px" style="padding-right: 30px" :model="formData" :rules="formRules">
<el-form-item label="分类名称" prop="cate_name">
<el-input
placeholder="请输入分类名称"
v-model="formData.cate_name"
minlength="2"
maxlength="6"
></el-input>
</el-form-item>
<el-form-item label="分类别名" prop="cate_alias">
<el-input
placeholder="请输入分类别名"
v-model="formData.cate_alias"
minlength="2"
maxlength="9"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="dialogVisible = false"> 确定 </el-button>
</div>
</template>
</el-dialog>
</template>
5.4 更新分类与新增分类功能
第一步:封装接口api
// api/article.js
// ...
// 新增文章分类
export const artAddArticleClassService = ({ cate_name, cate_alias }) =>
request.post('/my/cate/add', { cate_name, cate_alias })
// 更新文章分类
export const artUpdateArticleClassService = ({ id, cate_name, cate_alias }) =>
request.put('/my/cate/info', {
id,
cate_name,
cate_alias
})
第二步:子组件校验数据、提交数据、触发事件
// views/article/components/ChannelEdit.vue
// ...
// 定义向父组件触发的事件
const emit = defineEmits(['success'])
// 获取表单对象
const form = ref()
// 提交方法
const onSubmit = async () => {
// 提交校验
await form.value.validate()
// 发送数据
formData.value.id
? await artUpdateArticleClassService(formData.value)
: await artAddArticleClassService(formData.value)
// 提示框
ElMessage.success(`${formData.value.id ? '已保存!' : '新增成功!'}`)
// 关闭弹窗
dialogVisible.value = false
// 向父组件发送事件,刷新数据
emit('success')
}
第三步:父组件监听事件并刷新数据
// views/article/ArticleChannel.vue
// ...
// 监听子组件success事件
const onSuccess = () => {
articleStore.getArticleClassList()
}
<!--弹层组件-->
<ChannelEdit ref="dialog" @success="onSuccess"></ChannelEdit>
5.5 删除分类
第一步:封装api接口
// api/article.js
// ...
// 删除文章分类
export const artDeleteArticleClassService = (id) =>
request.delete('/my/cate/del', {
params: {
id
}
})
第二步:调用接口及确认弹窗
// views/article/ArticleChannel.vue
// ...
// 删除按钮事件
const handleDelete = async ($index, row) => {
// 提示框——确认删除
await ElMessageBox.confirm('你确认要删除吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
// 调用api
await artDeleteArticleClassService(row.id)
// 刷新数据
articleStore.getArticleClassList()
// 成功提示
ElMessage.success('删除成功!')
}
6 文章管理
6.1 基础布局
运用了表单、表格、链接、按钮等组件
注意:表格绑定数据除了可以用prop,也可以选择在内部使用作用域插槽来渲染数据
<!--views/article/ArticleManage.vue-->
<script setup>
import { ref } from 'vue'
import { Edit, Delete } from '@element-plus/icons-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: '草稿',
cate_name: '体育'
}
])
// 编辑事件
const onEdit = (row) => {
console.log(row)
}
// 删除事件
const onDelete = (row) => {
console.log(row)
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">新增文章</el-button>
</template>
<!--筛选区域——表单-->
<el-form inline>
<el-form-item label="文章分类:">
<el-select style="width: 240px">
<el-option label="新闻" value="新闻"></el-option>
<el-option label="体育" value="体育"></el-option>
</el-select>
</el-form-item>
<el-form-item label="发布状态:">
<el-select style="width: 240px">
<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="articleList">
<el-table-column label="文章标题" prop="title">
<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>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" circle plain :icon="Edit" @click="onEdit(row)"></el-button>
<el-button type="danger" circle plain :icon="Delete" @click="onDelete(row)"></el-button>
</template>
</el-table-column>
</el-table>
</page-container>
</template>
6.2 中英语言包切换
注意:
1.默认为英文
2.切换的是组件库内部的文字,自己写的文字不能切换
3.切换的是el-config-provider内部的组件
<!--App.vue-->
<script setup>
// import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import en from 'element-plus/dist/locale/en.mjs'
</script>
<template>
<el-config-provider :locale="en">
<router-view></router-view>
</el-config-provider>
</template>
6.3 封装获取分类列表组件
<!--views/article/components/ChannelSelect.vue-->
<script setup>
import { artGetArticleClassListService } from '@/api/article'
import { ref } from 'vue'
// 存储文章分类列表
const articleClassList = ref({})
// 获取文章分类列表
const getArticleClassList = async () => {
const res = await artGetArticleClassListService()
articleClassList.value = res.data.data
}
getArticleClassList()
// 接收数据
defineProps({
modelValue: {
type: [Number, String]
}
})
// 定义事件
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<el-select
style="width: 240px"
:modelValue="modelValue"
@update:modelValue="emit('update:modelValue', $event)"
>
<el-option
v-for="classList in articleClassList"
:key="classList.id"
:label="classList.cate_name"
:value="classList.id"
></el-option>
</el-select>
</template>
<!--vews/article/ArticleManage.vue-->
<script setup>
// ...
// 统一管理获取文章列表参数数据
const params = ref({
pagenum: 1,
pagesize: 2,
cate_id: '',
state: ''
})
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">新增文章</el-button>
</template>
<!--筛选区域——表单-->
<el-form inline>
<el-form-item label="文章分类:">
<ChannelSelect v-model="params.cate_id"></ChannelSelect>// 向组件传数据,并监听事件
</el-form-item>
<el-form-item label="发布状态:">
<el-select style="width: 240px" v-model="params.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>
<!-- ... -->
</page-container>
</template>
6.4 封装获取列表接口并格式化时间
第一步:封装获取文章列表接口
// api/article.js
// ...
// 获取文章列表
export const artGetArticleListService = (params) =>
request.get('/my/article/list', {
params
})
第二步:调用接口,获取数据
// views/article/ArticleManage.vue
import { artGetArticleListService } from '@/api/article'
// ...
// 统一管理获取文章列表参数数据
const params = ref({
pagenum: 1,
pagesize: 5,
cate_id: '',
state: ''
})
// 文章数据列表
const articleList = ref([])
// 文章总条数
const articleToatlNum = ref()
// 获取文章列表
const getArticleList = async () => {
const res = await artGetArticleListService(params.value)
articleList.value = res.data.data
articleToatlNum.value = res.data.total
}
getArticleList()
第三步:封装格式化时间工具
// utils/format.js
import { dayjs } from 'element-plus'
export const formatTime = (time) => dayjs(time).format('YYYY年MM月DD日')
第四步:使用
<!-- views/article/ArticleManage.vue -->
<script setup>
<!-- ... -->
import { formatTime } from '@/utils/format'
<!-- ... -->
</script>
<!-- ... -->
<el-table-column label="发表时间" prop="pub_date">
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<!-- ... -->
6.5 分页渲染
手册:Pagination 分页 | Element Plus
<!--分页器-->
<el-pagination
v-model:current-page="params.pagenum"
v-model:page-size="params.pagesize"
:page-sizes="[2, 3, 5, 10]"
:background="true"
layout="jumper, total, sizes, prev, pager, next"
:total="articleToatlNum"
@size-change="onSize"
@current-change="onCurrent"
style="justify-content: flex-end; margin-top: 20px"
/>
// 单页条数改变时触发
const onSize = (size) => {
// 重置为第一页
params.value.pagenum = 1
// 改变每页数量
params.value.pagesize = size
// 重新渲染
getArticleList()
}
// 页码改变时触发
const onCurrent = (page) => {
// 改变页码
params.value.pagenum = page
// 重新渲染
getArticleList()
}
添加loading效果:
此处不再赘述
6.6 搜索与重置功能
给按钮绑定点击事件即可
// views/article/ArticleManage.vue
// 搜索
const onSearch = () => {
// 重回第一页
params.value.pagenum = 1
// 重新获取数据并渲染
getArticleList()
}
// 重置
const onReset = () => {
// 重回第一页
params.value.pagenum = 1
// 清空搜索条件
params.value.cate_id = ''
params.value.state = ''
// 重新获取数据并渲染
getArticleList()
}
空状态处理:
<!--views/article/ArticleManage.vue-->
<!-- ... -->
</el-table-column>
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
<!-- ... -->
6.7 新增与编辑功能——抽屉组件
<!--views/article/components/ArticleEdit.vue-->
<script setup>
import { ref } from 'vue'
const isShow = ref(false)
// 封装向外暴露的方法——用来控制抽屉是否显示与判断是什么类型的
const open = (data) => {
// 显示抽屉
isShow.value = true
console.log(data)
}
// 向外暴露对象
defineExpose({
open
})
</script>
<template>
<el-drawer v-model="isShow" title="标题">
<span>正文</span>
</el-drawer>
</template>
<!--views/article/ArticleManage.vue-->
<script setup>
// ...
import ArticleEdit from './components/ArticleEdit.vue'
// ...
// 获取抽屉组件对象
const articleEditRef = ref()
// 新增文章事件
const onAdd = () => {
articleEditRef.value.open({})
}
// 编辑文章事件
const onEdit = (row) => {
articleEditRef.value.open(row)
}
// 删除文章事件
const onDelete = (row) => {
console.log(row)
}
</script>
<template>
<!-- ... -->
</page-container>
<!--抽屉-->
<ArticleEdit ref="articleEditRef"></ArticleEdit>
</template>
6.8 编辑模板
<!--views/article/ArticleManage.vue-->
<script setup>
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
const isShow = ref(false)
// 封装向外暴露的方法——用来控制抽屉是否显示与判断是什么类型的
const open = (data) => {
// 显示抽屉
isShow.value = true
// 判断编辑还是新增
if (data.id) {
// id存在,则为编辑
} else {
// id不存在,则为新增
// 清空表单数据
formModel.value = { ...formDefault }
}
}
// 向外暴露对象
defineExpose({
open
})
// 默认表单数据
const formDefault = {
title: '',
cate_id: '',
cover_img: '',
content: '',
state: ''
}
// 表单数据
const formModel = ref({ ...formDefault })
</script>
<template>
<el-drawer
v-model="isShow"
: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>
上述代码中给分类选择组件传递了宽度参数,若想使用,应将选择组件内部按如下配置:
// 接收数据
defineProps({
modelValue: {
type: [Number, String]
},
width: {
type: String,
default: 'width: 240px'
}
})
<el-select
:style="width"
:modelValue="modelValue"
@update:modelValue="emit('update:modelValue', $event)"
>
注意:黑马程序的案例中并没有给默认值,仍然可以显示一定宽度,但是新版貌似必须给一个宽度,否则不能正常显示
6.9 图片上传
<el-upload
class="avatar-uploader"
:auto-upload="false"
:show-file-list="false"
:on-change="onUploadFile"
>
<img v-if="imageUrl" :src="imageUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
该组件默认会自动将选择的图片上传到指定的服务器上,需要手动关闭
一般选择与其他表单数据一起上传到服务器上
// 接收图片链接
const imageUrl = ref()
// 上传图像
const onUploadFile = (uploadFile) => {
// 回显
imageUrl.value = URL.createObjectURL(uploadFile.raw)
// 将图片保存到表单数据对象中
formModel.value.cover_img = uploadFile.raw
}
样式可以从手册中截取,如:
<style scoped>
.avatar-uploader .avatar {
width: 178px;
height: 178px;
display: block;
}
</style>
<style>
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .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;
}
</style>
也可以用黑马程序员的代码:scss写法
<style scoped lang="scss">
.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>
6.10 富文本编辑器
官网:VueQuill | Rich Text Editor Component for Vue 3
第一步:安装包
pnpm add @vueup/vue-quill@latest
第二部:局部使用
<script setup>
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// ...
</script>
<!-- .... -->
<QuillEditor theme="snow" />
第三步:样式美化
<style scoped lang="scss">
/*
...
*/
.editor {
width: 100%;
:deep(.ql-editor) {
min-height: 200px;
}
}
</style>
6.11 添加文章
第一步:封装接口
// 发布文章
export const artPublishArticle = (data) => request.post('/my/article/add', data)
第二步:编写点击事件
重点:
1.清空富文本编辑器的内容
2.普通对象数据转化成formdata数据类型
// 定义事件
const emit = defineEmits(['success'])
// 获取富文本编辑器对象,以便使用其身上的方法来清空数据
const editorRef = ref()
// 提交数据
const onPublish = async (state) => {
// 设置发布类型
formModel.value.state = state
// 数据转换成 formData 类型
const fd = new FormData()
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 调用接口
if (formModel.value.id) {
// id存在,则为编辑
} else {
// 否则为新增
// 提交数据
await artPublishArticle(fd)
// 关闭抽屉
isShow.value = false
// 通知父组件成功,以做相应处理
emit('success', 'add')
// 清空表单中的图片与富文本编辑器的内容 ❗️这里的内容位置在6.12中有做改变
imageUrl.value = ''
editorRef.value.setHTML('')
// 弹窗提示
ElMessage.success('成功提交!')
}
}
<el-form-item>
<el-button @click="onPublish('已发布')" type="primary">发布</el-button>
<el-button @click="onPublish('草稿')" type="info">草稿</el-button>
</el-form-item>
第三步:父组件监听事件并跳转最后一页
重点:计算最后一页
// 成功提交处理函数
const onSuccess = (type) => {
if (type === 'add') {
// 新增文章处理
// 计算最后一页
const lastPage = Math.ceil((articleToatlNum.value + 1) / params.value.pagesize)
// 跳转到最后一页
params.value.pagenum = lastPage
}
// 重新渲染数据
getArticleList()
}
<!--抽屉-->
<ArticleEdit ref="articleEditRef" @success="onSuccess"></ArticleEdit>
第四步:表单校验
这里不再赘述
6.12 编辑回显与提交数据
第一步:在request.js中导出基地址
export { baseURL }
第二步:封装api
// 获取文章详情数据
export const artGetArticleDetailService = (id) =>
request.get('/my/article/info', { params: { id } })
// 更新文章详情
export const artUpdateArticleDetailService = (data) => request.put('/my/article/info', data)
第三步:在判断编辑还是新增事件中使用接口,同时添加了loading效果
注意:此处将得到的图片链接又转换成了file格式,因为后端不支持
import { artGetArticleDetailService } from '@/api/article'
import axios from 'axios'
import { baseURL } from '@/utils/request'
// loading
const isLoading = ref(false)
// 封装向外暴露的方法——用来控制抽屉是否显示与判断是什么类型的
const open = async (data) => {
// 显示抽屉
isShow.value = true
// 判断编辑还是新增
if (data.id) {
// id存在,则为编辑
// 将id存到formModel中
formModel.value.id = data.id
isLoading.value = true
// 获取文章详情数据
const res = await artGetArticleDetailService(data.id)
formModel.value = res.data.data
// 拼接图片链接
imageUrl.value = baseURL + formModel.value.cover_img
// 将url转为file
const file = await imageUrlToFile(imageUrl.value, formModel.value.cover_img)
// 重新存入formModel
formModel.value.cover_img = file
isLoading.value = false
} else {
// id不存在,则为新增
// 清空表单数据
formModel.value = { ...formDefault }
// 清空表单中的图片与富文本编辑器的内容 ❗️6.11中的内容移到这了
imageUrl.value = ''
editorRef.value.setHTML('')
}
}
// ai
// 将网络图片地址转换为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
}
}
第四步:提交数据
import { artUpdateArticleDetailService } from '@/api/article'
// 提交数据
const onPublish = async (state) => {
// 设置发布类型
formModel.value.state = state
// 数据转换成 formData 类型
const fd = new FormData()
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 调用接口
if (formModel.value.id) {
// id存在,则为编辑
// 提交数据
await artUpdateArticleDetailService(fd)
// 关闭抽屉
isShow.value = false
// 通知父组件成功,以做相应处理
emit('success', 'edit')
// 弹窗提示
ElMessage.success('修改成功!')
} else {
// 否则为新增
// 提交数据
await artPublishArticle(fd)
// 关闭抽屉
isShow.value = false
// 通知父组件成功,以做相应处理
emit('success', 'add')
// 弹窗提示
ElMessage.success('提交成功!')
}
}
6.13 删除文章
此部分见黑马文档,此处不再赘述
7 个人中心
此部分暂不更新,至此本文完