Vue3 大事件管理系统
Vue3 大事件管理系统
以下为学习过程中的极简提炼笔记,以供重温巩固学习
学习准备
准备工作
学习目的
- 结合项目实战,深入学习/掌握Vue3&Pinia
项目大体介绍 & 确认技术栈
- 结合项目实战,深入学习/掌握Vue3&Pinia
- Vue3 组合式api compositionAPI
- Pinia状态管理工具 & pinia-plugin-persistedstate持久化处理
- Element Plus组件库,包括表单校验、表格处理、组件封装
- 包管理器采用pnpm
- Eslint + prettier 更深入、规范配置
- husky(Git hooks工具),在代码提交前,进行规范校验
- 请求模块设置,包括响应拦截器,请求拦截器的处理
- 基于Vue3的VueRouter4路由设计
- AI大模型开发一整个项目模块,掌握最新最高效的开发方式
项目页面介绍
登录
注册
内容页
- 文章分类
- 文章管理
- 个人中心(综合模块)
接口文档:https://apifox.com/apidoc/shared-26c67aee-0233-4d23-aab7-08448fdf95ff/api-93850835
通过pnpm 包管理器创建&管理项目
- 一些优势:比同类工具快2倍左右、节省磁盘空间... https://www.pnpm.cn/
- 安装方式:
npm install -g pnpm
- 创建项目:
pnpm create vue
,pnpm不支持init,可以使用pnpm create vue@latest
以最新的框架建立项目 - 注意:
- 养成习惯,通过包管理器创建项目时,不要直接往磁盘根目录创建目录,可能会遇到权限不足
- 先在磁盘根目录下建文件夹子目录,进去以后再通过命令创建项目
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 |
ESLint & prettier 配置代码风格规范校验
- 通过ESLint配置代码规范时,可通过配置文件
.eslintrc.cjs
作自定义配置 - prettier是一个专注于代码的美观度的,代码格式化的插件;ESLint是代码格式纠错校验
通过.eslintrc.cjs
配置文件,自定义配置如下ESLint校验规则
- prettier 风格配置, prettier官网https://prettier.io
- 单引号
- 不使用分号
- 每行宽度至多80字符
- 不加对象|数组最后逗号
- 换行符号不限制(win mac 不一致)
- vue组件名称多单词组成(忽略index.vue,关闭组件名称必须多单词)
- props解构(关闭解构报错)
提示:安装Eslint且配置保存修复,不要开启默认的自动保存格式化
前提:
- 环境同步:
- 禁用格式化插件 Prettier,并将prettier的format on save关闭,关闭保存自动格式化
- 安装插件 ESlint,并配置开启保存时自动修复
- 环境同步:
前提设置:VSCode中的设置配置C:\Users\Administrator\AppData\Roaming\Code\User\settings.json
// ESlint插件 + Vscode配置 实现自动格式化修复
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"editor.formatOnSave": false,
- 项目的根目录下,新建文件
.eslintrc.cjs
,并配置
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
// prettier专注于代码的美观度 (格式化工具)
// 前置:
// 1. 禁用格式化插件 prettier format on save 关闭
// 2. 安装Eslint插件, 并配置保存时自动修复
'prettier/prettier': [
'warn',
{
singleQuote: true, // 单引号
semi: false, // 无分号
printWidth: 80, // 每行宽度至多80字符
trailingComma: 'none', // 不加对象|数组最后逗号
endOfLine: 'auto' // 换行符号不限制(win mac 不一致)
}
],
// ESLint关注于规范, 如果不符合规范,报错
// vue定义组件名字必须使用多单词混合
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index'] // vue组件名称多单词组成(忽略index.vue)
}
],
'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验(props解构丢失响应式)
// 💡 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
'no-undef': 'error'
},
// 配置全局变量名,以解决使用el的时候报错
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
}
- 如项目的根目录下,ESLint的配置文件为
eslint.config.js
,可配置以下,其中prettier/prettier部分改为配置到vscode中的setting
import js from '@eslint/js'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import pluginVue from 'eslint-plugin-vue'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
{
// 配置全局变量名,以解决使用el的时候报错
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
}
]
个人经验:
- 觉得ESLint不好用,使用prettier插件其实也可以,只要功能配置成一致即可,例如一行80个字符这种,两个插件都可以配置
- 注意需要配置vscode的C:\Users\Administrator\AppData\Roaming\Code\User\settings.json,特别麻烦,注意插件冲突
个人的VSCode中的设置配置C:\Users\Administrator\AppData\Roaming\Code\User\settings.json
// 粘贴时自动格式化
"editor.formatOnPaste": true,
// 保存时自动格式化
"editor.formatOnSave": true,
// #使用单引号替代双引号,保持单引号,不自动变双引号
"prettier.singleQuote": true,
// 格式化保存时不加末尾分号
"prettier.semi": false,
// 每行宽度至多80字符
"prettier.printWidth": 80,
// 不加对象|数组最后逗号
"prettier.trailingComma": "none",
// 尊重输入中对于引号的用法(不会自动加或删)
"prettier.quoteProps": "preserve",
// 配置默认自动格式化程序为JavaScript时,"insert"为格式化时不删除也不添加分号
"javascript.format.semicolons":"remove",
"editor.codeActionsOnSave": {
// 保存的时候整理 import ,按字母顺序进行排序,同时去掉没用的 import
"source.organizeImports": "explicit",
"source.fixAll": "explicit",
// "source.fixAll.eslint": "explicit"
},
// tab缩进为2
"editor.tabSize": 2,
"editor.formatOnType": true,
"emmet.preferences": {},
"editor.wordWrap": "on",
基于 husky 配置代码检查工作流
背景:
- 通过ESLint & prettier配置代码风格规范校验,代码变得规范。
- 但若出现了疏忽,出现了不规范的代码,且没有被发现和改正,可能会将不规范的代码提交到了仓库中暂存
- 为了避免将不规范的代码提交到了仓库中暂存,因此应该在提交前做代码检查,在将代码提交仓库前,应保证代码是符合规范的
通过husky工具,在提交前做代码检查,是一个Git hooks工具
- 备注:hooks工具可以理解为是一个钩子,在特定的时机执行特定的命令,这里的husky就是git相关操作时的钩子,执行git命令时触发连带执行,在代码提交仓库前作检查
使用husky工具步骤:
- 初始化 git 仓库,执行
git init
即可,git相关的操作,建议使用git bash终端,能看到项目有没有使用git进行管理,有的话会显示(master) - 初始化 husky 工具配置,执行
pnpm dlx husky-init && pnpm install
即可,生成.husky/pre-commit 配置目录和文件- https://typicode.github.io/husky/
- pre-commit中的配置,就是在git命令执行提交代码前要做的配置
- 可通过此pre-commit配置,在git提交前,让项目先执行package.json中配置的全局命令
- 可以通过package.json查看到,如
"lint": "eslint . --fix",
- 完成husky工具的安装和初始化后,修改 .husky/pre-commit 配置文件
- 修改 .husky/pre-commit 配置文件,注掉npm test,改为
pnpm lint
- lint命令是,默认在项目的 package.json 包配置文件中的,已经配置好的,对项目全局所有文件一次性生效的校验,并且在发现错误时,会进行自动修复
- 修改 .husky/pre-commit 配置文件,注掉npm test,改为
- 初始化 git 仓库,执行
修改 .husky/pre-commit 配置文件,注掉npm test,改为pnpm lint
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# npm test
pnpm lint
问题:
- pnpm lint 默认进行的是全量检查问题
- 会带来两个问题,一个是项目全局的文件检查,会有耗时问题,另一个是,如是接手他人项目,仅风格不一致但不影响代码的历史问题,应改为只检查自己贡献的部分
通过 lint-staged 配置暂存区 eslint 校验,实现 仅对新添加部分的代码/自己贡献的部分 作校验,不对历史代码作校验
- 安装 lint-staged 包
pnpm i lint-staged -D
- 在项目的 package.json 中,配置 lint-staged 命令
- 修改.husky/pre-commit 文件
- 安装 lint-staged 包
在项目的 package.json 中,配置 lint-staged 命令
{
// ... 省略其他配置 ...
// lint-staged的配置
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}
{
// scripts自定义命令,配置 lint-staged 命令
"scripts": {
// ... 省略 ...
"lint-staged": "lint-staged"
}
}
- 修改.husky/pre-commit 文件
pnpm lint-staged
- 总结:
- 如何在 git commit 前执行 eslint 检查?
- 使用 husky 这个 git hooks 工具
- 如何只检查暂存区代码?
- 使用 lint-staged 工具
- 如何在 git commit 前执行 eslint 检查?
调整项目目录
- 背景:默认生成的目录结构不满足我们的开发需求,所以这里需要做一些自定义改动。
- 主要是自定义以下工作:
- 删除一些初始化的默认文件,例如默认生成的src\assets目录中的默认图片,src\components目录中的默认组件,如果不需要留作参考,可以删除
- 修改剩余代码内容,如router目录,main.js和app.vue文件
- 新增调整我们需要的目录结构,如api接口请求函数目录,utils自行封装的工具函数目录
- 拷贝全局样式和图片到assets目录,引入样式时,尽量往下放,保证优先级/权重高一点点
- 安装预处理器支持
- 安装 sass 预处理器
pnpm add sass -D
- 导入 main.js 作为全局样式
import './assets/main.scss'
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
],
})
export default router
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import '@/assets/main.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
pnpm add sass -D
VueRouter4 路由代码解析
- 在Vue3中的组合式API,获取VueRouter4的路由初始化
创建路由实例,由 createRouter 函数实现
- 从Vue2的VueRouter默认导入和导出,改为Vue3的
{createRouter}
按需导入后再导出 - 通过
{createRouter}
导入创建路由实例的方法,底层是对原来Vue2中的new VueRouter({})
做了封装,可以将createRouter({})
当成一个封装的函数,内部还是执行了new VueRouter的
- 从Vue2的VueRouter默认导入和导出,改为Vue3的
路由模式(在
createRouter({函数中的history选项中})
配置路由模式)- 根据在createRouter函数中导入的不同方法,决定配置为使用哪种路由模式
- 使用 history 模式,在createRouter函数中的history选项,导入的方法配置为: createWebHistory()
- 使用 hash 模式(带#号),在createRouter函数中的history选项,导入的方法配置为: createWebHashHistory()
- 函数调用时传入的参数,是基础路径(也就是访问时所有的路径的前缀),默认是
/
, - 传入
import.meta.env.BASE_URL
参数,也就是提供一个变量,不写死基础路径,此变量为vite的环境变量 - 可到vite.config.js中,配置base选项中的值,默认是
base: '/'
;- vite 中的环境变量 import.meta.env.BASE_URL 就是 vite.config.js 中的 base 配置项
页面中,通过引入方法,获取路由对象和路由参数
- 页面中,通过
import { useRoute, useRouter } from 'vue-router'
引入VueRouter4的路由方法 - 通过方法调用
const router = useRouter()
获取路由对象,以及获取路由参数const route = useRoute()
- 获取路由对象和路由参数后,可以自行封装路由跳转的方法,提取成函数,后续在模板中调用即可渲染
- 页面中,通过
案例:
- 创建一个路由实例,路由模式是history模式,路由的基础地址是 vite.config.js中的 base 配置的值,默认是
/
- 创建一个路由实例,路由模式是history模式,路由的基础地址是 vite.config.js中的 base 配置的值,默认是
- src\router\index.js创建路由实例,由 createRouter 函数实现
import { createRouter, createWebHistory } from 'vue-router'
// import { createRouter, createWebHashHistory } from 'vue-router'
// createRouter 创建路由实例
// 配置 history 模式
// 1. history模式:createWebHistory 地址栏不带 #
// 2. hash模式: createWebHashHistory 地址栏带 #
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
],
})
export default router
- 在页面中跳转路由,以及vue3组合式api中,通过方法调用,获取路由对象和参数,并自行封装路由跳转的方法
<script setup>
import { useRoute, useRouter } from 'vue-router'
// 在 Vue3 CompositionAPI 中
// 1. 获取路由对象 router useRouter
// const router = useRouter()
// 2. 获取路由参数 route useRoute
// const route = useRoute()
// 获取路由对象
const router = useRouter()
// 获取路由参数
const route = useRoute()
// 将路由跳转的方法,提取成一个函数
const goList = () => {
// setup中没有this,setup中的this是underfined,因此不能使用$router.push('/home')方法
// 通过路由对象获取
router.push('/list')
// 打印可知,router是大的路由信息对象,route是路由参数
console.log(router, route)
}
</script>
<template>
<div>
我是App
<!-- vue3中,兼容vue2的路由跳转语法,模板中可以使用$router.push('/home')方法传路径调用 -->
<button @click="$router.push('/home')">跳首页</button>
<!-- 将路由跳转的方法,提取成一个函数后调用 -->
<button @click="goList">跳列表页</button>
</div>
</template>
<style scoped></style>
- vite 中的环境变量 import.meta.env.BASE_URL 就是 vite.config.js 中的 base 配置项
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
// vite 中的环境变量 import.meta.env.BASE_URL
base: '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
- 各项说明
Vue Router 4 路由器实例方法
**方法名称 用途**
addRoute 动态添加一个路由。
afterEach 在每次导航之后执行回调。
back 返回上一页。
beforeEach 在每次导航之前执行回调。
beforeResolve 在导航被确认之前执行回调。
clearRoutes 清除所有已注册的路由。
currentRoute 当前激活的路由信息。
forward 前进到下一页。
getRoutes 获取所有已注册的路由。
go 相对于当前页面前进或后退指定的步数。
hasRoute 检查是否存在具有指定名称的路由。
install 安装 Vue Router 插件。
isReady 检查路由器是否已准备好(所有异步路由都已解析)。
listening 指示路由器是否正在监听 URL 更改。
onError 在导航期间发生错误时执行回调。
options 路由器实例的选项。
push 向历史记录中添加一个新条目,并导航到该条目。
removeRoute 根据名称移除一个路由。
replace 替换当前历史记录条目,并导航到新条目。
resolve 将路径或位置对象解析为路由记录。
__hasDevtools 指示路由器是否已安装了 Devtools 插件。
示例1
// 动态添加一个路由
router.addRoute({ path: '/foo', component: Foo });
// 在每次导航之前执行回调
router.beforeEach((to, from, next) => {
// ...
});
// 返回上一页
router.back();
// 导航到新路由
router.push('/foo');
// 检查是否存在具有指定名称的路由
if (router.hasRoute('foo')) {
// ...
}
**配置项名称 用途**
path 路由的路径。例如,/foo 表示当 URL 为 /foo 时,将渲染此路由。
name 路由的名称。这对于在代码中通过名称引用路由非常有用。
component 要渲染的组件。可以是组件类或导入的组件定义。
redirect 重定向到另一个路由。可以是字符串(重定向到另一个命名路由)或对象(指定目标路由的完整配置)。
alias 路由的别名。这允许您使用多个 URL 访问同一路由。
props 传递给组件的属性。可以是对象或函数,返回对象。
children 子路由的数组。这允许您创建嵌套路由。
meta 附加数据,可以附加到路由记录上。这对于存储诸如页面标题或权限之类的信息很有用。
beforeEnter 在进入路由之前调用的钩子函数。它可以用来执行导航守卫或异步数据加载。
beforeLeave 在离开路由之前调用的钩子函数。它可以用来执行导航守卫或确认对话框。
示例2
{
path: '/foo',
name: 'foo',
component: Foo,
meta: {
title: 'Foo 页面',
requiresAuth: true
},
beforeEnter: (to, from, next) => {
if (!this.$store.getters.isAuthenticated) {
next('/login');
} else {
next();
}
}
}
以上示例2路由配置将:
当 URL 为 /foo 时渲染 Foo 组件。
将页面标题设置为“Foo 页面”。
要求用户在导航到此路由之前进行身份验证。
在用户未经身份验证的情况下尝试导航到此路由时,将用户重定向到 /login 路由。
引入 element-ui 组件库
背景:PC端项目,更适合使用 Element Plus 组件库作开发
通过按需引入方式,引入 Element Plus
- 安装:
pnpm add element-plus
或pnpm install element-plus
- 配置完整导入:略,见快速开始中的完整引入
- 配置按需导入:通过安装插件+配置插件,实现按需导入
- 官方文档: https://element-plus.org/zh-CN/guide/quickstart.html中的按需导入
- 自动按需引入:
- 先安装unplugin-vue-components(组件注册) 和 unplugin-auto-import(自动引入)这两款插件:
pnpm add -D unplugin-vue-components unplugin-auto-import
- 然后把
配置Element Plus自动按需导入的代码
,作为配置,插入到你的Vite
或Webpack
的配置文件中
- 先安装unplugin-vue-components(组件注册) 和 unplugin-auto-import(自动引入)这两款插件:
- 后续按项目需要,在项目全局的模板中,直接使用组件即可,这两个插件会帮我们自动引入和注册组件
- 彩蛋:
- 使用了这两个插件后,默认 components 目录下的组件,也会被自动注册,后续在页面中可以直接引入该目录中的组件直接使用
- 在
Vite
或Webpack
的配置文件中,配置Element Plus自动按需导入的代码
,如vite.config.js
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()]
})
]
})
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools'
// 按需引入Element Plus时,所需配置的自动按需引入插件的引入
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
// 按需引入Element Plus时,所需配置的自动按需引入插件的引入
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
// vite 中的环境变量 import.meta.env.BASE_URL
base: '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
<template>
<div>
我是App
<test-demo></test-demo>
<!-- vue3中,兼容vue2的路由跳转语法,模板中可以使用$router.push('/home')方法传路径调用 -->
<el-button @click="$router.push('/home')">跳首页</el-button>
<!-- 将路由跳转的方法,提取成一个函数后调用 -->
<el-button @click="goList">跳列表页</el-button>
<!-- 后续直接使用element plus的组件,如以下按钮 -->
<el-button type="primary">Primary</el-button>
<el-button type="success">Success</el-button>
</div>
</template>
Pinia - 构建用户仓库 和 持久化
背景:为满足多组件共享数据的需求
流程:
- 状态管理 Pinia
- 用户仓库 User
- 持久化 基于pinia-plugin-persistedstate
- 统一管理
通过Pinia状态管理工具对数据维护
- 背景:通过脚手架创建的项目,已经在src/main.js中,配好了Pinia的引入和初始化
步骤:
- 在src/stores仓库目录中,新建用户仓库文件 User.js,定义用户模块数据
- 定义了用户模块数据后,在页面app.vue中,导入和使用用户模块数据
import { useUserStore } from '@/stores/user'
- 声明变量接收导入的用户模块数据,调用一下方法useUserStore(),
const userStore = useUserStore()
拿到仓库的数据对象,包括store数据和方法actions - 模板中基于拿到的
仓库数据对象.value属性
,作渲染 - 发现页面数据随着刷新消失,因此需要基于pinia-plugin-persistedstate插件,对数据作持久化
- 在项目中安装插件 pinia-plugin-persistedstate:
pnpm add pinia-plugin-persistedstate -D
- 在src/main.js中引入
import persist from 'pinia-plugin-persistedstate'
,以及注册使用app.use(createPinia().use(persist))
- 到src/stores/user.js用户模块数据仓库文件中,在函数的第三个参数,配置持久化
persist: true
- 在项目中安装插件 pinia-plugin-persistedstate:
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 用户模块 token setToken removeToken (一个state,两个actions)
export const useUserStore = defineStore(
'big-user',
// 箭头函数中写逻辑
() => {
// 定义数据
const token = ref('')
// 定义操作数据的方法1
const setToken = (newToken) => {
token.value = newToken
}
// 定义操作数据的方法2
const removeToken = () => {
token.value = ''
}
// 对外暴露
return {
token,
setToken,
removeToken
}
}
)
// 定义了用户模块数据后,页面中就可以导入和使用
import { useUserStore } from '@/stores/user'
// 声明变量接收导入的用户模块数据,调用一下方法useUserStore(),拿到仓库的数据对象,包括store数据和方法actions
const userStore = useUserStore()
<template>
<div>
<!-- 建立了src/stores/User.js,定义了用户模块数据后,页面中就可以导入和使用 -->
<!-- 页面中渲染 -->
<p>{{ userStore.token }}</p>
<!-- 模拟登录token -->
<el-button @click="userStore.setToken('Bearer sidfhheiwhuvyweqrjh')">
登录
</el-button>
<el-button @click="userStore.removeToken()">退出</el-button>
</div>
</template>
import { createPinia } from 'pinia'
// 为Pinia导入持久化插件
import persist from 'pinia-plugin-persistedstate'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import '@/assets/main.scss'
const app = createApp(App)
app.use(createPinia())
// 并注册使用
app.use(createPinia().use(persist))
// 可以合并写成链式,然后就不用写app.use(createPinia()),一起写也没事
// app.use(createPinia().use(persist))
app.use(router)
app.mount('#app')
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 用户模块 token setToken removeToken (一个state,两个actions)
export const useUserStore = defineStore(
'big-user',
// 箭头函数中写逻辑
() => {
// 定义数据
const token = ref('')
// 定义操作数据的方法1
const setToken = (newToken) => {
token.value = newToken
}
// 定义操作数据的方法2
const removeToken = () => {
token.value = ''
}
// 对外暴露
return {
token,
setToken,
removeToken
}
},
{
persist: true
}
)
抽离封装思想 Pinia - 配置仓库统一管理
将脚手架预设的pinia,改造为独立维护
- 背景:后续仓库增多,容易出现问题,需要统一管理,通过仓库的统一管理,提升仓库和项目的可维护性
- 现在的情况(脚手架在创建项目时预设的模式):
- Pinia初始化代码在 src/main.js 中,仓库代码在 src/stores 中,代码分散职能不单一
- 每使用一个仓库,就引入一次,如
import { useUserStore } from './stores/user.js'
,不同的仓库文件,存放路径不一致,不便引入
优化目标1:pinia 独立维护
- 优化为:由 stores 统一维护,在 stores/index.js 中完成 pinia 初始化,交付 main.js 使用
- 步骤:
- 将原在src/main.js中pinia相关的代码(包括创建和持久化等),提取到src\stores\index.js中,放到一个文件独立维护
- 并默认导出
export default pinia
- 在原src/main.js中,通过
import pinia from '@/stores/index'
,导入抽离打包后的src\stores\index.js,并使用app.use(pinia)
优化目标2:仓库作统一导出
- 优化为:仓库作统一导出,后续使用store仓库数据时,从统一的store出口作导入,方便导入
- 步骤:
- 将各个store模块(除index出口文件),统一改为存放于src\stores\modules目录中
- 在 stores/index.js 出口文件中,将抽离到modules目录下的store模块逐个导入,并export导出,以后就由 stores/index.js 文件,作为仓库的统一出口导出/统一对外暴露方法
- 注意在统一出口导出/对外暴露方法时,需要与导入时相同的方法作导出,保持方式一直,如通过{花括号}按需进行导入的,也需要{花括号}按需进行导出
- 在 stores/index.js 出口文件中,可以使用简写语法
export * from './modules目录/store模块文件名'
作按需作导入后再按需导出 - 完成统一导出后,在页面App.vue中导入仓库时,路径统一改为
import { useCountStore, useUserStore } from '@/stores'
导入,即从同一个目录,导入多个不同的仓库import { 仓库A,仓库B... } from '@/stores'
- 页面中完成仓库导入后,不要忘记提供变量接收对象,需要用变量接收后,后续在模板中才能使用
- 后续在 stores/modules 目录下对仓库作维护
备注:
- store相关命名规则的简单理解:
- 作为函数/方法作导出时,导出的名字前加上use,名字后加上store
- 在页面中导入使用时,需要导入函数/方法
import{ use函数/方法store }
- 导入后,用 const 函数/方法的名字 建立变量,调用
use函数/方法store()
实现赋值
- 方法命名规则的简单理解:
- 通过方法的命名,体现所属模块和功能,如模块名use模块下的方法名Register注册相关后缀Service服务
- 模块名+方法名+后缀
- store相关命名规则的简单理解:
优化目标1:pinia 独立维护
import { createPinia } from 'pinia'
// 为Pinia导入持久化插件
import persist from 'pinia-plugin-persistedstate'
// 将原来的app.use(createPinia().use(persist))拆分
const pinia = createPinia()
pinia.use(persist)
// 导出pinia
export default pinia
// 导入抽离打包后的src\stores\index.js
import pinia from '@/stores/index'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import '@/assets/main.scss'
const app = createApp(App)
// 注册使用抽离打包后的pinia
app.use(pinia)
app.use(router)
app.mount('#app')
- 优化目标2:仓库作统一导出
import { createPinia } from 'pinia'
// 为Pinia导入持久化插件
import persist from 'pinia-plugin-persistedstate'
// 将原来的app.use(createPinia().use(persist))拆分
const pinia = createPinia()
pinia.use(persist)
// 导出pinia
export default pinia
// 将抽离到modules目录下的store模块逐个导入,并export导出
import { useCountStore } from './modules/counter'
import { useUserStore } from './modules/user'
export { useCountStore, useUserStore }
// 改为从store的统一出口导入
import { useCountStore, useUserStore } from '@/stores'
// 声明变量接收导入的计数器仓库
const countStore = useCounterStore()
// 使用
<template>
<div>
<!-- 从store统一出口导入的仓库,使用方法一样 -->
<hr />
{{ countStore.count }}
<el-button @click="countStore.doubleCount(2)">*2</el-button>
</div>
</template>
import { createPinia } from 'pinia'
// 为Pinia导入持久化插件
import persist from 'pinia-plugin-persistedstate'
// 将原来的app.use(createPinia().use(persist))拆分
const pinia = createPinia()
pinia.use(persist)
// 导出pinia
export default pinia
// 将抽离到modules目录下的store模块逐个导入,并export导出,等价于下面export * from
// import { useCountStore } from './modules/counter'
// import { useUserStore } from './modules/user'
// export { useCountStore, useUserStore }
// 接收user模块的所有方法,并按需作导入后,再按需导出
export * from './modules/user'
// 接收counter模块的所有方法,并按需作导入后,再按需导出
export * from './modules/counter'
<script setup>
// import { useCountStore, useUserStore } from '@/stores'
import { useRoute, useRouter } from 'vue-router'
// 定义了用户模块数据后,页面中就可以导入和使用
// import { useUserStore } from '@/stores/user'
// 改为从store的统一出口导入
import { useCountStore, useUserStore } from '@/stores'
// 在 Vue3 CompositionAPI 中
// 1. 获取路由对象 router useRouter
// const router = useRouter()
// 2. 获取路由参数 route useRoute
// const route = useRoute()
// 获取路由对象
const router = useRouter()
// 获取路由参数
const route = useRoute()
// 将路由跳转的方法,提取成一个函数
const goList = () => {
// setup中没有this,setup中的this是underfined,因此不能使用$router.push('/home')方法
// 通过路由对象获取
router.push('/list')
// 打印可知,router是大的路由信息对象,route是路由参数
console.log(router, route)
}
// 声明变量接收导入的用户模块数据,调用一下方法useUserStore(),拿到仓库的数据对象,包括store数据和方法actions
const userStore = useUserStore()
// 声明变量接收导入的计数器仓库
const countStore = useCountStore()
</script>
<template>
<div>
我是App
<test-demo></test-demo>
<!-- vue3中,兼容vue2的路由跳转语法,模板中可以使用$router.push('/home')方法传路径调用 -->
<el-button @click="$router.push('/home')">跳首页</el-button>
<!-- 将路由跳转的方法,提取成一个函数后调用 -->
<el-button @click="goList">跳列表页</el-button>
<!-- 后续直接使用element plus的组件,如以下按钮 -->
<el-button type="primary">Primary</el-button>
<el-button type="success">Success</el-button>
<!-- 建立了src/stores/User.js,定义了用户模块数据后,页面中就可以导入和使用 -->
<!-- 页面中渲染 -->
<p>{{ userStore.token }}</p>
<!-- 模拟登录token -->
<el-button @click="userStore.setToken('Bearer sidfhheiwhuvyweqrjh')">
登录
</el-button>
<el-button @click="userStore.removeToken()">退出</el-button>
<!-- 从store统一出口导入的仓库,使用方法一样 -->
<hr />
{{ countStore.count }}
<el-button @click="countStore.add(2)">加法2</el-button>
</div>
</template>
<style scoped></style>
数据交互 - 请求工具设计
背景:
- 使用 axios 来请求后端接口,并且一般都会对 axios 进行二次配置 (比如: 配置基础地址等)
- 有了pinia的抽离封装思想后,也同样应用在对axios的抽离封装
- 在项目开发中,都会对 axios 进行基本的二次封装,将其单独封装到一个模块中,便于使用
封装的axios配置模块,包括以下3个核心基础配置内容:
- 创建axios实例:配基准地址,超时时间
- 请求拦截器:携带 token
- 响应拦截器:对业务返回的数据作判断,成功则摘取核心响应数据,业务失败则作处理,包括401处理等
步骤:
装包,安装 axios:
pnpm add axios
新建自行封装的工具函数目录utils,并新建
utils/request.js
封装 axios 模块- 利用 axios.create 创建一个自定义的 axios 来使用,自定义实例默认值步骤
- 默认导出
export default instance
按需导出export { baseURL }
两种导出方式不冲突
在src\utils\request.js中,完成 axios 基本配置
- token在仓库中,通过
import { useUserStore } from '@/stores'
引入,通过const useStore = useUserStore()
拿到仓库中的useStore.token - 配合接口文档,写请求拦截器逻辑config.headers.Authorization = useStore.token
- 配合接口文档,确认状态码res.data.code === 0时为成功
- 如果返回err错误,确认err.response?.status === 401为错误特殊情况,是权限不足或token过期 => 此时拦截到登录;
- 或err.response.data.message错误的默认情况 => 此时只要给提示
- token在仓库中,通过
创建 axios 实例
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
- 完成 axios 基本配置
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: 10000
})
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// TODO 2. 携带token
const useStore = useUserStore()
if (useStore.token) {
config.headers.Authorization = useStore.token
}
return config
},
(err) => Promise.reject(err)
)
// 响应拦截器
instance.interceptors.response.use(
(res) => {
// TODO 4. 摘取核心响应数据
if (res.data.code === 0) {
return res
}
// TODO 3. 处理业务失败
// 处理业务失败, 通过element插件,给错误提示,抛出错误
ElMessage.error(res.data.message || '服务异常')
return Promise.reject(res.data)
},
(err) => {
// TODO 5. 处理401错误
// 错误的特殊情况 => 401 权限不足 或 token 过期 => 拦截到登录
if (err.response?.status === 401) {
router.push('/login')
}
// 通过element插件,错误的默认情况 => 只要给提示
ElMessage.error(err.response.data.message || '服务异常')
return Promise.reject(err)
}
)
// 默认导出
export default instance
// 按需导出
export { baseURL }
// 两种导出方式不冲突
正式页面开发
- 背景:
- 完成前面几节的配置
- 前面几节的配置,ESlink、pinia仓库管理,axios请求&响应拦截器,都属于基础配置
首页整体路由设计 第1步
正式页面开发第一步:分析项目需要写哪些页面,并为页面配置对应路由
实现目标:
- 完成整体路由规划,重点:【搞清楚要做几个页面,它们分别在哪个路由下面,怎么跳转的.....】
- 通过观察项目效果/实际开发中产品经理输出的规划/UI做的效果确认
- 如在本案例中去分析:
- 登录页是一级路由
- 登录页的登录功能和注册功能,点击切换时,页面路径/地址栏路径并没有发生切换,原因是本质上并没有路由切换,是通过切换组件路径/切换内容v-if v-else来实现,不涉及路由切换,登录页的登录功能和注册功能共用同一个路由
- 登陆后到首页,首页中左侧列表包括3个可切换模块,因此,登陆后的首页,必定是一个独立的一级路由;首页的一级路由的内容包括左侧的竖向导航框架,上方顶部的横向导航框架
- 可通过左侧导航/左侧列表菜单标签,在点击切换的右侧内容页面(在一直根据切换发生变化的右侧切换区域页面),必定是一个二级路由;包括各个可切换的导航/列表菜单标签
- 可能后续还会在切换区域下继续往下嵌套多层级路由
- 结论:我们需要搭建嵌套路由
输出目标:
- 把项目中所有用到的组件及路由表, 约定下来
约定路由规则如下:
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 | 二级路由 |
- 按照路由规则,在src\views目录下建立各个子页面文件夹,以及各个子页面.vue,并写路由配置src\router\index.js
import { createRouter, createWebHistory } from 'vue-router'
// import { createRouter, createWebHashHistory } from 'vue-router'
// createRouter 创建路由实例
// 配置 history 模式
// 1. history模式:createWebHistory 地址栏不带 #
// 2. hash模式: createWebHashHistory 地址栏带 #
// console.log(import.meta.env.DEV)
// vite 中的环境变量 import.meta.env.BASE_URL 就是 vite.config.js 中的 base 配置项
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
<template>
<div>
布局架子
<router-view></router-view>
</div>
</template>
<script setup>
// import { useCountStore, useUserStore } from '@/stores'
import { useRoute, useRouter } from 'vue-router'
// 定义了用户模块数据后,页面中就可以导入和使用
// import { useUserStore } from '@/stores/user'
// 改为从store的统一出口导入
import { useCountStore, useUserStore } from '@/stores'
// 在 Vue3 CompositionAPI 中
// 1. 获取路由对象 router useRouter
// const router = useRouter()
// 2. 获取路由参数 route useRoute
// const route = useRoute()
// 获取路由对象
const router = useRouter()
// 获取路由参数
const route = useRoute()
// 将路由跳转的方法,提取成一个函数
const goList = () => {
// setup中没有this,setup中的this是underfined,因此不能使用$router.push('/home')方法
// 通过路由对象获取
router.push('/list')
// 打印可知,router是大的路由信息对象,route是路由参数
console.log(router, route)
}
// 声明变量接收导入的用户模块数据,调用一下方法useUserStore(),拿到仓库的数据对象,包括store数据和方法actions
const userStore = useUserStore()
// 声明变量接收导入的计数器仓库
const countStore = useCountStore()
</script>
<template>
<div>
<hr />
<!-- 配置路由出口 -->
<router-view></router-view>
<hr />
<hr />
<hr />
我是App
<test-demo></test-demo>
<!-- vue3中,兼容vue2的路由跳转语法,模板中可以使用$router.push('/home')方法传路径调用 -->
<el-button @click="$router.push('/home')">跳首页</el-button>
<!-- 将路由跳转的方法,提取成一个函数后调用 -->
<el-button @click="goList">跳列表页</el-button>
<!-- 后续直接使用element plus的组件,如以下按钮 -->
<el-button type="primary">Primary</el-button>
<el-button type="success">Success</el-button>
<!-- 建立了src/stores/User.js,定义了用户模块数据后,页面中就可以导入和使用 -->
<!-- 页面中渲染 -->
<p>{{ userStore.token }}</p>
<!-- 模拟登录token -->
<el-button @click="userStore.setToken('Bearer sidfhheiwhuvyweqrjh')">
登录
</el-button>
<el-button @click="userStore.removeToken()">退出</el-button>
<!-- 从store统一出口导入的仓库,使用方法一样 -->
<hr />
{{ countStore.count }}
<el-button @click="countStore.add(2)">加法2</el-button>
</div>
</template>
<style scoped></style>
- 个人经验:
- 个人觉得,实际开发中,可能不会采用这种路由配法,将
/
重定向到某个子路由,对子母层级嵌套描述不清晰 - 个人会采用
/
重定向到index页,开一个index页引入二级组件成为一个专有主页
- 个人觉得,实际开发中,可能不会采用这种路由配法,将
具体业务功能实现 第2步
登录注册页面开发 [element-plus 表单 & 表单校验]
登录注册 页面开发/业务实现 涉及技术栈:element-plus 表单 & 表单校验
功能需求说明:
- 注册登录 静态结构 & 基本切换
- 通过使用 element-plus 表单标签实现,安装 element-plus 图标库
pnpm i @element-plus/icons-vue
- 通过v-if实现注册和登录切换
- el-form相关语法
- 通过使用 element-plus 表单标签实现,安装 element-plus 图标库
- 注册功能 (校验 + 注册)
- 掌握常见校验规则,包括:非空校验,长度校验,正则校验,自定义校验
- 2.1 注册时的信息校验:
- 在注册登录页src\views\login\LoginPage.vue中,准备ruleForm收集绑定的整个form的数据对象
const formModel = ref({})
,根据接口文档,敲定3个接口字段 - 声明校验规则,并实现注册时的表单校验,确认用户注册的信息,符合注册要求:el-form表单校验方法,以及声明校验规则
- 注意:表单校验相关,注意要在el-form中绑定上这四个
:model="ruleForm"
,:rules="rules"
,v-model="ruleForm.xxx"
,el-form-item => prop="生效规则名字"
- 在注册登录页src\views\login\LoginPage.vue中,准备ruleForm收集绑定的整个form的数据对象
- 2.2 注册前的预校验:注册成功之前,先进行校验,校验成功 → 请求,校验失败 → 自动提示
- 2.3 封装 api 实现注册功能,以及与后台服务器校验,校验注册信息是否存在
- 登录功能 (校验 + 登录 + 存token)
- 巩固在注册时所学校验规则
- 切换登录和注册页面时,清空表单数据
- 登录前的预校验 & 表单提交,实现登录 & 登录成功后,token存入pinia
- 实现登录表单校验,看用户的输入,与后台账号登录接口信息校验是否通过
- 本节重点
1. 结构相关
el-row表示一行,一行分成24份
el-col表示列
(1) :span="12" 代表在一行中,占12份 (50%)
(2) :span="6" 表示在一行中,占6份 (25%)
(3) :offset="3" 代表在一行中,左侧margin份数(离左侧边缘的份数)
el-form 整个表单组件
el-form-item 表单的一行 (一个表单域)
el-input 表单元素(输入框)
2. 校验相关,注意要在el-form中绑定上
(1) el-form => :model="ruleForm" ruleForm收集绑定的整个form的数据对象 { xxx, xxx, xxx }
(2) el-form => :rules="rules" 绑定的整个rules规则对象 { xxx, xxx, xxx }
(3) 表单元素 => v-model="ruleForm.xxx" 给表单元素,绑定form的子属性
(4) el-form-item => prop="生效规则名字"配置生效的是哪个校验规则 (和rules中的字段要对应)
整个表单的校验规则
1. 非空校验 required: true message消息提示, trigger触发校验的时机 blur change
2. 长度校验 min:xx, max: xx
3. 正则校验 pattern: 正则规则 \S 非空字符
4. 自定义校验 => 自己写逻辑校验 (校验函数)
validator: (rule, value, callback)
(1) rule 当前校验规则相关的信息
(2) value 所校验的表单元素目前的表单值
(3) callback 无论成功还是失败,都需要 callback 回调
- callback() 校验成功
- callback(new Error(错误信息)) 校验失败
- 注册登录 静态结构 & 基本切换
<script setup></script>
<template>
<div>
<!-- App.vue只需要留一个路由出口 router-view即可 -->
<router-view></router-view>
</div>
</template>
<style scoped></style>
<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>
<template>
<!--
1. 结构相关
el-row表示一行,一行分成24份
el-col表示列
(1) :span="12" 代表在一行中,占12份 (50%)
(2) :span="6" 表示在一行中,占6份 (25%)
(3) :offset="3" 代表在一行中,左侧margin份数(离左侧边缘的份数)
el-form 整个表单组件
el-form-item 表单的一行 (一个表单域)
el-input 表单元素(输入框)
2. 校验相关
(1) el-form => :model="ruleForm" 绑定的整个form的数据对象 { xxx, xxx, xxx }
(2) el-form => :rules="rules" 绑定的整个rules规则对象 { xxx, xxx, xxx }
(3) 表单元素 => v-model="ruleForm.xxx" 给表单元素,绑定form的子属性
(4) el-form-item => prop配置生效的是哪个校验规则 (和rules中的字段要对应)
-->
<el-row class="login-page">
<el-col :span="12" class="bg"></el-col>
<el-col :span="6" :offset="3" class="form">
<!-- 注册相关表单,每一行都是一个el-form -->
<el-form
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
v-if="isRegister"
>
<el-form-item>
<h1>注册</h1>
</el-form-item>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formModel.password"
:prefix-icon="Lock"
type="password"
placeholder="请输入密码"
></el-input>
</el-form-item>
<el-form-item prop="repassword">
<el-input
v-model="formModel.repassword"
:prefix-icon="Lock"
type="password"
placeholder="请输入再次密码"
></el-input>
</el-form-item>
<el-form-item>
<el-button
@click="register"
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
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
v-else
>
<el-form-item>
<h1>登录</h1>
</el-form-item>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formModel.password"
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
@click="login"
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.1 注册时的信息校验:
- 在注册登录页src\views\login\LoginPage.vue中,准备ruleForm收集绑定的整个form的数据对象
const formModel = ref({})
,根据接口文档,敲定3个接口字段 - 声明校验规则,并实现注册时的表单校验,确认用户注册的信息,符合注册要求
const form = ref()
// 整个的用于提交的form数据对象
const formModel = ref({
username: '',
password: '',
repassword: ''
})
// 整个表单的校验规则
// 1. 非空校验 required: true message消息提示, trigger触发校验的时机 blur change
// 2. 长度校验 min:xx, max: xx
// 3. 正则校验 pattern: 正则规则 \S 非空字符
// 4. 自定义校验 => 自己写逻辑校验 (校验函数)
// validator: (rule, value, callback)
// (1) rule 当前校验规则相关的信息
// (2) value 所校验的表单元素目前的表单值
// (3) callback 无论成功还是失败,都需要 callback 回调
// - callback() 校验成功
// - callback(new Error(错误信息)) 校验失败
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() // 就算校验成功,也需要callback
}
},
trigger: 'blur'
}
]
}
- 2.2 注册前的预校验:注册成功之前,先进行校验,校验成功 → 请求,校验失败 → 自动提示
// 声明表单数据
const form = ref()
const register = async () => {
// 注册成功之前,先进行校验,校验成功 → 请求,校验失败 → 自动提示
await form.value.validate()
}
- 2.3 封装 api 实现注册功能,以及与后台服务器校验,校验注册信息是否存在
- 注意:通过方法的命名,体现所属模块和功能,如模块名use模块下的方法名Register注册相关后缀Service服务,模块名+方法名+后缀
- 箭头函数简写,可以省去后面的花括号和return
import request from '@/utils/request'
// 简写前的写法
export const userRegisterService = ({ username, password, repassword }) =>{
return request.post('/api/reg', { username, password, repassword })
}
// 注册接口
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
// 登录接口
export const userLoginService = ({ username, password }) =>
request.post('/api/login', { username, password })
// 获取用户基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
import { userLoginService, userRegisterService } from '@/api/user.js'
const register = async () => {
// 注册成功之前,先进行校验,校验成功 → 请求,校验失败 → 自动提示
// 找到当前的表单,验证表单项validate()
await form.value.validate()
await userRegisterService(formModel.value)
ElMessage.success('注册成功')
isRegister.value = false
}
// ==========另在.eslintrc.cjs中===============
// 配置全局变量名,以解决使用el的时候报错
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
- 登录功能 (校验 + 登录 + 存token)
<script setup>
import { userLoginService, userRegisterService } from '@/api/user.js'
import { useUserStore } from '@/stores'
import { Lock, User } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
const isRegister = ref(false)
// 声明表单数据
const form = ref()
// 整个的用于提交的form数据对象
const formModel = ref({
username: '',
password: '',
repassword: ''
})
// 整个表单的校验规则
// 1. 非空校验 required: true message消息提示, trigger触发校验的时机 blur change
// 2. 长度校验 min:xx, max: xx
// 3. 正则校验 pattern: 正则规则 \S 非空字符
// 4. 自定义校验 => 自己写逻辑校验 (校验函数)
// validator: (rule, value, callback)
// (1) rule 当前校验规则相关的信息
// (2) value 所校验的表单元素目前的表单值
// (3) callback 无论成功还是失败,都需要 callback 回调
// - callback() 校验成功
// - callback(new Error(错误信息)) 校验失败
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() // 就算校验成功,也需要callback
}
},
trigger: 'blur'
}
]
}
const register = async () => {
// 注册成功之前,先进行校验,校验成功 → 请求,校验失败 → 自动提示
// 找到当前的表单,验证表单项validate()
await form.value.validate()
await userRegisterService(formModel.value)
ElMessage.success('注册成功')
isRegister.value = false
}
const userStore = useUserStore()
const router = useRouter()
// 预校验
const login = async () => {
await form.value.validate()
const res = await userLoginService(formModel.value)
// 引入并调用pinia的方法,对token存起来
userStore.setToken(res.data.token)
ElMessage.success('登录成功')
router.push('/')
}
// 切换的时候,重置表单内容
watch(isRegister, () => {
formModel.value = {
username: '',
password: '',
repassword: ''
}
})
</script>
<template>
<!--
1. 结构相关
el-row表示一行,一行分成24份
el-col表示列
(1) :span="12" 代表在一行中,占12份 (50%)
(2) :span="6" 表示在一行中,占6份 (25%)
(3) :offset="3" 代表在一行中,左侧margin份数
el-form 整个表单组件
el-form-item 表单的一行 (一个表单域)
el-input 表单元素(输入框)
2. 校验相关
(1) el-form => :model="ruleForm" 绑定的整个form的数据对象 { xxx, xxx, xxx }
(2) el-form => :rules="rules" 绑定的整个rules规则对象 { xxx, xxx, xxx }
(3) 表单元素 => v-model="ruleForm.xxx" 给表单元素,绑定form的子属性
(4) el-form-item => prop配置生效的是哪个校验规则 (和rules中的字段要对应)
-->
<el-row class="login-page">
<el-col :span="12" class="bg"></el-col>
<el-col :span="6" :offset="3" class="form">
<!-- 注册相关表单 -->
<el-form
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
v-if="isRegister"
>
<el-form-item>
<h1>注册</h1>
</el-form-item>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formModel.password"
:prefix-icon="Lock"
type="password"
placeholder="请输入密码"
></el-input>
</el-form-item>
<el-form-item prop="repassword">
<el-input
v-model="formModel.repassword"
:prefix-icon="Lock"
type="password"
placeholder="请输入再次密码"
></el-input>
</el-form-item>
<el-form-item>
<el-button
@click="register"
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
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
v-else
>
<el-form-item>
<h1>登录</h1>
</el-form-item>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formModel.password"
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
@click="login"
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>
首页 layout 架子 [element-plus 菜单]
- 功能需求说明:
- 基本架子拆解
- element-plus菜单组件的使用
- el-aside 左侧
- el-menu 左侧边栏菜单(一级菜单)
- 激活颜色active-text-color="#ffd04b"
- 背景颜色background-color="#232323"
- :default-active="$route.path" 配置默认高亮的菜单项
- router router选项开启,el-menu-item 的 index 就是点击跳转的路径
- el-menu-item 菜单项 index="/article/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮
- el-sub-menu多级菜单
- 二级菜单路径index="/user"
- 多级菜单的标题
template #title
- 具名插槽 title
- el-container 右侧
- el-header 右侧头部
- el-dropdown
- el-main 右侧主体
- router-view
- el-header 右侧头部
- 各个el组件,可以将其简单理解为div,只是el的组件带了名字,语义更清晰一些
- 学会理解element-plus Container 布局容器组件的使用,将来接手/模仿他人项目,懂得改造
- 登录访问拦截
- 用户基本信息获取&渲染
- 用户基本信息属于通用数据,需要存到pinia,后续修改、渲染,都会有多个组件多个位置用到数据的场景
- 结合接口文档封装接口src\api\user.js
- 需要在pinia中存储,在src\stores\modules\user.js中,
- 声明用户数据,定义user,添加一个ref,
const user = ref({})
新维护一个数据 - 写通过调用api请求获取数据的方法,通过调用api获取数据
- 通过看接口文档,确认数据返回的结构,将请求回来的数据,准确取出并存入到user变量中,取代以前经常通过打印log去确认返回的数据结构
- 最后通过return,将请求回来的数据user,以及将请求获取数据的方法getUser、setUser,对外暴露,提供好user对象和getUser/setUser方法
- 页面中,调用pinia的方法,发起请求&模板中渲染
- 导入
import { useUserStore } from '@/stores'
,方法赋予变量const userStore = useUserStore()
- 一进页面发起调用,生命周期钩子
onMounted(() => {userStore.getUser()})
发起调用(如果是在外面<script setup></script>
中直接写,相当于在create钩子中调用)
- 右上角下拉菜单渲染导航-下拉菜单
- 监听下拉菜单的@command事件名的传值,作对应页面跳转
- 退出功能 [element-plus 确认框]
- 配置退出操作确认框
ElMessageBox.confirm('你确认要进行退出么', '温馨提示', {type: 'warning',confirmButtonText: '确认',cancelButtonText: '取消'})
- 配置退出操作
const handleCommand = async (key) => {退出的逻辑}
- 清除本地的数据 (token + user信息)
- 配置退出操作确认框
- 实现步骤如下:
- 基本架子拆解 (左侧菜单拆解)
<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-menu 整个菜单组件
:default-active="$route.path" 配置默认高亮的菜单项
router router选项开启,el-menu-item 的 index 就是点击跳转的路径
el-menu-item 菜单项
index="/article/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮
-->
<el-container class="layout-container">
<el-aside width="200px">
<div class="el-aside__logo"></div>
<!--
从上到下,分别为
激活颜色active-text-color="#ffd04b"
背景颜色background-color="#232323"
:default-active="$route.path" 配置默认高亮的菜单项
router router选项开启,el-menu-item 的 index 就是点击跳转的路径
-->
<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/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮 -->
<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>
- 在src\router\index.js下部署登陆访问拦截
import { useUserStore } from '@/stores'
import { createRouter, createWebHistory } from 'vue-router'
// import { createRouter, createWebHashHistory } from 'vue-router'
// createRouter 创建路由实例
// 配置 history 模式
// 1. history模式:createWebHistory 地址栏不带 #
// 2. hash模式: createWebHashHistory 地址栏带 #
// console.log(import.meta.env.DEV)
// vite 中的环境变量 import.meta.env.BASE_URL 就是 vite.config.js 中的 base 配置项
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')
}
]
}
]
})
// 登录访问拦截 => 默认是直接放行的
// 根据返回值决定,是放行还是拦截
// 返回值:
// 1. undefined / true 直接放行
// 2. false 拦回from的地址页面
// 3. 具体路径 或 路径对象 拦截到对应的地址
// '/login' { name: 'login' }
router.beforeEach((to) => {
// 如果没有token, 且访问的是非登录页,拦截到登录,其他情况正常放行
const useStore = useUserStore()
if (!useStore.token && to.path !== '/login') return '/login'
})
export default router
- 3.1 结合接口文档封装接口src\api\user.js
import request from '@/utils/request'
// 注册接口
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
// 登录接口
export const userLoginService = ({ username, password }) =>
request.post('/api/login', { username, password })
// 获取用户基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
- 3.2 需要在pinia中存储,在src\stores\modules\user.js中
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { userGetInfoService } from '../../api/user'
// 用户模块 token setToken removeToken
export const useUserStore = defineStore(
'big-user',
() => {
const token = ref('')
const setToken = (newToken) => {
token.value = newToken
}
const removeToken = () => {
token.value = ''
}
// 声明用户数据,定义user
const user = ref({})
// 写通过调用api请求获取数据的方法,通过调用api获取数据
const getUser = async () => {
const res = await userGetInfoService() // 请求获取数据
// 通过看接口文档,确认数据返回的结构,将请求回来的数据,准确取出并存入到user变量中
user.value = res.data.data
}
const setUser = (obj) => {
user.value = obj
}
// 最后通过return,将请求回来的数据user,以及将请求获取数据的方法getUser、setUser,对外暴露
return {
token,
setToken,
removeToken,
user,
getUser,
setUser
}
},
{
persist: true
}
)
- 3.3 src\views\layout\LayoutContainer.vue页面中,调用pinia的方法,发起请求&模板中渲染
<script setup>
import avatar from '@/assets/default.png'
import { useUserStore } from '@/stores'
import {
CaretBottom,
Crop,
EditPen,
Management,
Promotion,
SwitchButton,
User,
UserFilled
} from '@element-plus/icons-vue'
import { onMounted } from 'vue'
const userStore = useUserStore()
onMounted(() => {
userStore.getUser()
})
// 一进页面发起调用,生命周期钩子onMounted(() => {userStore.getUser()})发起调用
// (如果是在外面script setup和script标签对中直接写,相当于在create钩子中调用)
const onCommand = async (command) => {
if (command === 'logout') {
await ElMessageBox.confirm('你确认退出大事件吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
userStore.removeToken()
userStore.setUser({})
router.push(`/login`)
} else {
router.push(`/user/${command}`)
}
}
</script>
<template>
<!--
el-menu 整个菜单组件
:default-active="$route.path" 配置默认高亮的菜单项
router router选项开启,el-menu-item 的 index 就是点击跳转的路径
el-menu-item 菜单项
index="/article/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮
-->
<el-container class="layout-container">
<el-aside width="200px">
<div class="el-aside__logo"></div>
<!--
从上到下,分别为
激活颜色active-text-color="#ffd04b"
背景颜色background-color="#232323"
:default-active="$route.path" 配置默认高亮的菜单项
router router选项开启,el-menu-item 的 index 就是点击跳转的路径
-->
<el-menu
active-text-color="#ffd04b"
background-color="#232323"
:default-active="$route.path"
text-color="#fff"
router
>
<el-menu-item index="/article/channel">
<!-- el-menu-item 菜单项 index="/article/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮 -->
<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>{{ userStore.user.nickname || userStore.user.username }}</strong>
</div>
<el-dropdown placement="bottom-end" @command="handleCommand">
<!-- 展示给用户,默认看到的 -->
<span class="el-dropdown__box">
<el-avatar :src="avatar" />
<el-icon><CaretBottom /></el-icon>
</span>
<!-- 折叠的下拉部分 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="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 + 3.4 在src\views\layout\LayoutContainer.vue页面中,监听下拉菜单的@command事件名的传值,作对应页面跳转 + [element-plus 确认框] 退出功能
<script setup>
import avatar from '@/assets/default.png'
import { useUserStore } from '@/stores'
import {
CaretBottom,
Crop,
EditPen,
Management,
Promotion,
SwitchButton,
User,
UserFilled
} from '@element-plus/icons-vue'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
onMounted(() => {
userStore.getUser()
})
// 一进页面发起调用,生命周期钩子onMounted(() => {userStore.getUser()})发起调用
// (如果是在外面script setup和script标签对中直接写,相当于在create钩子中调用)
// 监听下拉菜单的command事件的传值,作对应页面跳转
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}`)
}
}
</script>
<template>
<!--
el-menu 整个菜单组件
:default-active="$route.path" 配置默认高亮的菜单项
router router选项开启,el-menu-item 的 index 就是点击跳转的路径
el-menu-item 菜单项
index="/article/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮
-->
<el-container class="layout-container">
<el-aside width="200px">
<div class="el-aside__logo"></div>
<!--
从上到下,分别为
激活颜色active-text-color="#ffd04b"
背景颜色background-color="#232323"
:default-active="$route.path" 配置默认高亮的菜单项
router router选项开启,el-menu-item 的 index 就是点击跳转的路径
-->
<el-menu
active-text-color="#ffd04b"
background-color="#232323"
:default-active="$route.path"
text-color="#fff"
router
>
<el-menu-item index="/article/channel">
<!-- el-menu-item 菜单项 index="/article/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮 -->
<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>{{ userStore.user.nickname || userStore.user.username }}</strong>
</div>
<el-dropdown placement="bottom-end" @command="handleCommand">
<!-- 展示给用户,默认看到的 -->
<span class="el-dropdown__box">
<el-avatar :src="avatar" />
<el-icon><CaretBottom /></el-icon>
</span>
<!-- 折叠的下拉部分 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="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>
文章分类页面 - [element-plus 表格]
- 功能需求说明:增删改查
- 基本架子组件 - PageContainer 模块的封装,src\views\article\ArticleChannel.vue
- 基本结构样式,用到了 el-card 组件
- 组件包含头部和底部内容,分析组件库使用说明,可知具名插槽提供头部,默认插槽提供内容,如具名插槽
<template #header>
- 因为分类、管理、个人中心多个地方都用到,因此封装组件复用,用到组件插槽,
- 通过,默认插槽
<slot></slot>
占位定制内容 - 通过具名插槽
<slot name="extra"></slot>
定制右上角按钮 - 通过父传子拿到tittle
defineProps({})
- 通过,默认插槽
- 抽离好组件后,回到原页面,使用封装的组件PageContainer
- 通过tittle配置传子的标题
- 通过默认插槽给子组件提供内容,定制主体
- 通过具名插槽
<template #extra>
定制右上角按钮
- 无需特意引入组件,因为此前的组件插件已经帮助我们按需引入
- 文章分类渲染 & loading 处理
- 到api目录下,封装文章相关的渲染的接口,后续用于发请求获取数据
- 页面中,发请求,调用api,注意一进页面直接调用,写到script setup中
- 基于数据,使用el-table 表格动态渲染
- 通过
:data="channelList"
为表格渲染注入数据, - channelList,也就是注入的数据的格式为数组包对象,因此
const channelList = ref([])
声明为数组 - el-table-column是列,用 prop 属性来对应对象中的键名即可填入数据,用 label 属性来定义表格的列名。 可以使用 width 属性来定义列宽
- 通过
- 添加删除后loading 处理效果
- Feedback 反馈组件 中的 Loading 加载
- 定义变量,在表格中通过v-loading绑定
- loading逻辑:发送请求前开启,请求结束关闭
- 文章分类添加编辑 [element-plus 弹层]
- 文章分类添加功能和编辑功能,可以复用,Dialog 对话框
- 封装element-plus弹层组件,实现逻辑复用,因为是属于src\views\article\ArticleChannel.vue下的组件,因此在src\views\article目录中,再新建一个src\views\article\components目录,存放复用的组件src\views\article\components\ChannelEdit.vue,将el-dialog部分,以及相关的变量,都放过去
- 封装好后,组件通过
defineExpose({open})
对外暴露一个方法 open,基于open传来的参数,区分添加还是编辑- open({}) => 表单无需渲染,说明是添加
- open({ id, cate_name, ... }) => 表单需要渲染,说明是编辑
- open调用后,可以打开弹窗,打开弹层
dialogVisible.value = true
- 在原父组件,在显示弹层的逻辑,改为调用open方法实现调用+传值,并声明
const dialog = ref()
,在插入子组件的地方,标签加上ref="dialog"
实现传值 - 在子组件,根据组件库要求,需要声明
const dialogVisible = ref(false)
一个变量,并在模板中通过v-model拿到传值,同时声明open方法逻辑中使用这个变量
- 重点:
- 封装组件,并对外暴露方法
- 对外暴露方法比对外暴露属性方便得多,对外暴露方法不仅能实现功能,还能另外传参
- 在封装的组件内部,整理传递的参数,初始化表单/组件内部的数据,完成功能
- 完善组件/表单内容,通过el-form + el-form-item 实现
- :model,在src\views\article\components\ChannelEdit.vue中,提供需要处理的数据
const formModel = ref({cate_name: '', cate_alias: ''})
通过接口文档确认,分类名称和分类别名 - :rules,在src\views\article\components\ChannelEdit.vue中,提供form结构,以及校验rule,字段与数据一一对应,且每个字段都可以配置多个规则,是数组包多个规则对象
- v-model,绑定规则rules,prop传值prop="cate_name"和prop="cate_alias"
- :model,在src\views\article\components\ChannelEdit.vue中,提供需要处理的数据
- 编辑回显
- 数据传入,赋值给formModel变量,通过
...row
展开运算符,将row中/item中的全部数据,在value中展开formModel.value = { ...row }
;在操作添加时 → 相当于重置了表单内容,编辑 → 存储了需要回显的数据 - 通过
formModel.id
区分是添加还是编辑,选择性回显:title="formModel.id ? '编辑分类' : '添加分类'"
- 最后封装接口,子组件通过接口提交数据修改或者添加
- 父组件监听
@success="onSuccess"
完成编辑后刷新页面重新获取一次接口
- 数据传入,赋值给formModel变量,通过
- 文章分类删除
- 封装api接口
- 父组件页面中实现具体删除逻辑,页面中添加确认框,调用接口进行提示
- 实现步骤如下:
- 1.1 基本结构样式,用到了 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>
<!-- 具名插槽下方,是默认插槽部分 -->
<div>内容部分</div>
<div>内容部分</div>
<div>内容部分</div>
<div>内容部分</div>
</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>
- 1.2 将src\views\article\ArticleChannel.vue基本结构样式,封装到src\components\PageContainer.vue模块
<script setup>
// 父传子拿到tittle
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;
justify-content: space-between;
align-items: center;
}
}
</style>
- 1.3 抽离好组件后,回到原页面ArticleChannel.vue,使用封装的组件PageContainer,并根据本页面内容,传递插槽数据
<template>
<page-container title="文章分类">
<template #extra>
<el-button type="primary"> 添加分类 </el-button>
</template>
主体部分,是列表
</page-container>
</template>
- 1.4 抽离好组件后,到页面ArticleManage.vue,使用封装的组件PageContainer,类似操作
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
主体部分,是列表 + 分页
</page-container>
</template>
- 2.1 到api目录下,封装文章相关的渲染的接口,后续用于发请求获取数据
import request from '@/utils/request'
// 获取文章分类
export const artGetChannelsService = () => request.get('/my/cate/list')
// 添加文章分类
export const artAddChannelService = (data) => request.post('/my/cate/add', data)
// 编辑文章分类
export const artEditChannelService = (data) =>
request.put('/my/cate/info', data)
// 删除文章分类
export const artDelChannelService = (id) =>
request.delete('/my/cate/del', {
params: { id }
})
- 2.2 ArticleChannel页面中,发请求,调用api
<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
}
// 一进页面直接调用
getChannelList()
</script>
- 2.3 ArticleChannel基于数据,使用el-table 表格动态渲染
<script setup>
import { ref } from 'vue'
// 导入删除编辑图标
import { Delete, Edit } from '@element-plus/icons-vue'
import { artGetChannelsService } from '../../api/article'
const channelList = ref([])
const getChannelList = async () => {
const res = await artGetChannelsService()
channelList.value = res.data.data
}
// 一进页面直接调用
getChannelList()
const onEditChannel = (row) => {
console.log(row)
}
const onDelChannel = (row) => {
console.log(row)
}
</script>
<template>
<page-container title="文章分类">
<template #extra>
<el-button type="primary"> 添加分类 </el-button>
</template>
<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">
<!-- 通过作用域插槽做自定义列 -->
<!-- row 就是 channelList 的一项, $index 下标 -->
<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>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
</page-container>
</template>
<style lang="scss" scoped></style>
- 2.4 ArticleChannel添加删除后loading + empty空状态 处理效果
<script setup>
import { ref } from 'vue'
// 导入删除编辑图标
import { Delete, Edit } from '@element-plus/icons-vue'
import { artGetChannelsService } from '../../api/article'
const channelList = ref([])
// loading逻辑:发送请求前开启,请求结束关闭
const loading = ref(false)
const getChannelList = async () => {
loading.value = true
const res = await artGetChannelsService()
channelList.value = res.data.data
loading.value = false
}
// 一进页面直接调用
getChannelList()
const onEditChannel = (row) => {
console.log(row)
}
const onDelChannel = (row) => {
console.log(row)
}
</script>
<template>
<page-container title="文章分类">
<template #extra>
<el-button type="primary"> 添加分类 </el-button>
</template>
<!-- 添加loading -->
<el-table v-loading="loading" :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">
<!-- 通过作用域插槽做自定义列 -->
<!-- row 就是 channelList 的一项, $index 下标 -->
<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>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
</page-container>
</template>
<style lang="scss" scoped></style>
- 3.1 src\views\article\ArticleChannel.vue中,文章分类添加功能和编辑功能
<script setup>
import { ref } from 'vue'
// 导入删除编辑图标
import { Delete, Edit } from '@element-plus/icons-vue'
import { artGetChannelsService } from '../../api/article'
const channelList = ref([])
// loading逻辑:发送请求前开启,请求结束关闭
const loading = ref(false)
// 弹层可见默认状态
const dialogVisible = ref(false)
const getChannelList = async () => {
loading.value = true
const res = await artGetChannelsService()
channelList.value = res.data.data
loading.value = false
}
// 一进页面直接调用
getChannelList()
const onEditChannel = (row) => {
console.log(row)
}
const onDelChannel = (row) => {
console.log(row)
}
// 添加分类按钮功能,显示弹层
const onAddChannel = () => {
dialogVisible.value = true
}
</script>
<template>
<page-container title="文章分类">
<template #extra>
<el-button type="primary" @click="onAddChannel">添加分类</el-button>
</template>
<!-- 添加loading -->
<el-table v-loading="loading" :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">
<!-- 通过作用域插槽做自定义列 -->
<!-- row 就是 channelList 的一项, $index 下标 -->
<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>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
<!-- 添加编辑弹层 -->
<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>
</page-container>
</template>
<style lang="scss" scoped></style>
- 3.2 src\views\article\components\ChannelEdit.vue复用组件的封装,将
<script setup>
import { ref } from 'vue'
// 弹层可见默认状态
const dialogVisible = ref(false)
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>
- 3.3 src\views\article\components\ChannelEdit.vue完善组件的封装,添加数据处理规则校验
<script setup>
import { ref } from 'vue'
// 弹层可见默认状态
const dialogVisible = ref(false)
const formRef = ref()
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'
}
]
}
// 组件对外暴露一个方法 open,基于open传来的参数,区分添加还是编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, cate_name, ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开弹窗
const open = async (row) => {
dialogVisible.value = true
console.log(row)
}
// 向外暴露方法
defineExpose({
open
})
</script>
<template>
<!-- 添加编辑弹层 -->
<el-dialog v-model="dialogVisible" title="添加弹层" width="30%">
<el-form
ref="formRef"
: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"
placeholder="请输入分类名称"
></el-input>
</el-form-item>
<el-form-item label="分类别名" prop="cate_alias">
<el-input
v-model="formModel.cate_alias"
placeholder="请输入分类别名"
></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>
</template>
- 3.4 ChannelEdit.vue中编辑回显+通过接口提交数据修改或者添加
<script setup>
import { artAddChannelService, artEditChannelService } from '@/api/article.js'
import { ref } from 'vue'
// 弹层可见默认状态
const dialogVisible = ref(false)
const formRef = ref()
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'
}
]
}
const emit = defineEmits(['success'])
// 另一种写法
// 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 ? '编辑成功' : '添加成功'
// })
const onSubmit = async () => {
await formRef.value.validate()
// 判断是否编辑
const isEdit = formModel.value.id
if (isEdit) {
await artEditChannelService(formModel.value)
ElMessage.success('编辑成功')
} else {
await artAddChannelService(formModel.value)
ElMessage.success('添加成功')
}
// 关闭弹层
dialogVisible.value = false
// 子传父通知更新
emit('success')
}
// 组件对外暴露一个方法 open,基于open传来的参数,区分添加还是编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, cate_name, ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开弹窗
const open = async (row) => {
dialogVisible.value = true
console.log(row)
formModel.value = { ...row }
}
// 向外暴露方法
defineExpose({
open
})
</script>
<template>
<!-- 添加编辑弹层 -->
<el-dialog v-model="dialogVisible" :title="formModel.id ? '编辑分类' : '添加分类'" width="30%">
<!-- formRef校验 -->
<el-form
ref="formRef"
: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"
placeholder="请输入分类名称"
></el-input>
</el-form-item>
<el-form-item label="分类别名" prop="cate_alias">
<el-input
v-model="formModel.cate_alias"
placeholder="请输入分类别名"
></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="onSubmit"> 确认 </el-button>
</span>
</template>
</el-dialog>
</template>
- 3.5 父组件中,监听
@success="onSuccess"
完成编辑后刷新页面重新获取一次接口
<script setup>
// 导入删除编辑图标
import { Delete, Edit } from '@element-plus/icons-vue'
import { ref } from 'vue'
import { artGetChannelsService } from '../../api/article'
// 导入组件
import ChannelEdit from './components/ChannelEdit.vue'
const channelList = ref([])
// loading逻辑:发送请求前开启,请求结束关闭
const loading = ref(false)
const getChannelList = async () => {
loading.value = true
const res = await artGetChannelsService()
channelList.value = res.data.data
loading.value = false
}
// 一进页面直接调用
getChannelList()
// const onEditChannel = (row) => {
// console.log(row)
// }
const onDelChannel = (row) => {
console.log(row)
}
// 添加分类按钮功能,显示弹层
// const onAddChannel = () => {
// dialogVisible.value = true
// }
// 在封装组件后,改为导入组件+调用组件方法open
const onAddChannel = () => {
dialog.value.open({})
}
const dialog = ref()
// 编辑改为
const onEditChannel = (row) => {
dialog.value.open(row)
}
// 完成编辑后刷新页面重新获取一次接口
const onSuccess = () => {
getChannelList()
}
</script>
<template>
<page-container title="文章分类">
<template #extra>
<el-button type="primary" @click="onAddChannel">添加分类</el-button>
</template>
<!-- 添加loading -->
<el-table v-loading="loading" :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">
<!-- 通过作用域插槽做自定义列 -->
<!-- row 就是 channelList 的一项, $index 下标 -->
<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>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
<channel-edit ref="dialog" @success="onSuccess"></channel-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
- 4.1封装api接口
import request from '@/utils/request'
// 获取文章分类 get
export const artGetChannelsService = () => request.get('/my/cate/list')
// 添加文章分类 post, Body 参数
export const artAddChannelService = (data) => request.post('/my/cate/add', data)
// 编辑文章分类 put, Body 参数
export const artEditChannelService = (data) =>
request.put('/my/cate/info', data)
// 删除文章分类 query传参
export const artDelChannelService = (id) =>
request.delete('/my/cate/del', {
params: { id }
})
- 4.2父组件配置删除逻辑,页面中添加确认框,调用接口进行提示
<script setup>
// 导入删除编辑图标
import { Delete, Edit } from '@element-plus/icons-vue'
import { ref } from 'vue'
import { artDelChannelService, artGetChannelService } from '../../api/article'
// 导入组件
import ChannelEdit from './components/ChannelEdit.vue'
const channelList = ref([])
// loading逻辑:发送请求前开启,请求结束关闭
const loading = ref(false)
const getChannelList = async () => {
loading.value = true
const res = await artGetChannelService()
channelList.value = res.data.data
loading.value = false
}
// 一进页面直接调用
getChannelList()
// const onEditChannel = (row) => {
// console.log(row)
// }
// const onDelChannel = (row) => {
// console.log(row)
// }
// 添加分类按钮功能,显示弹层
// const onAddChannel = () => {
// dialogVisible.value = true
// }
// 在封装组件后,改为导入组件+调用组件方法open
const onAddChannel = () => {
dialog.value.open({})
}
const dialog = ref()
// 编辑改为
const onEditChannel = (row) => {
dialog.value.open(row)
}
// 完成编辑后刷新页面重新获取一次接口
const onSuccess = () => {
getChannelList()
}
const onDelChannel = async (row) => {
await ElMessageBox.confirm('你确认要删除该分类么', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
await artDelChannelService(row.id)
ElMessage.success('删除成功')
getChannelList()
}
</script>
<template>
<page-container title="文章分类">
<template #extra>
<el-button type="primary" @click="onAddChannel">添加分类</el-button>
</template>
<!-- 添加loading -->
<el-table v-loading="loading" :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">
<!-- 通过作用域插槽做自定义列 -->
<!-- row 就是 channelList 的一项, $index 下标 -->
<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>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
<channel-edit ref="dialog" @success="onSuccess"></channel-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
文章管理页面 - [element-plus 强化]
- 背景及分析:
- 管理页面用到的element组件,是分类页面的晋级版
- 文章列表渲染 - 基本架子搭建
- 在本模块中,不仅用到el表格,还结合筛选条件实现搜索 和 重置功能功能
- 加上封装 API 接口,请求分页渲染 [element-plus 分页]
- 文章分类选择
- 中英国际化处理
- 修改编辑和添加也共用弹层,分类页面用到的弹层是dialog对话框,管理页用到的是抽屉组件
- 包含文件上传、富文本编辑器
- 文章删除功能
文章列表渲染 - 基本架子搭建
- 搜索表单区域结构
- el-select选择器
- 表单区域:
- el-form包el-form-item,
- el-form的inline属性,显示为一行
- 左边el-form-item包el-select两个,右边el-form-item包el-button一个
- 表格渲染区域结构
- 表格区域:
- el-table包每一列el-table-column
- el-table-column内容通过两种方式配置,一种是prop直接数据的填充,另一种是作用域插槽
template #default="{ row }"
自己拿数据并自定义填充
- 表格区域:
<script setup>
// 引入图标
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: '体育'
}
])
// 编辑逻辑
const onEditArticle = (row) => {
console.log(row);
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<!-- 选择器 -->
<el-select>
<!-- label前端展示给用户 value是收集用于与后台通信的标记id -->
<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>
<!-- 表格区域 -->
<el-table :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<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="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
</page-container>
</template>
中英国际化处理
- 背景:element plus默认是英文
- 配置:Config Provider 全局配置
- 官网案例:标签包裹的局部组件进行中英切换
- 由于这里不涉及切换, 所以在 App.vue 中直接导入设置成中文即可,实现全局配置
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<template>
<div>
<!-- App.vue只需要留一个路由出口 router-view即可 -->
<!-- <router-view></router-view> -->
<!-- 全局中文配置 -->
<el-config-provider :locale="zhCn">
<router-view></router-view>
</el-config-provider>
</div>
</template>
<style scoped></style>
下拉菜单组件封装 - 文章分类选择
背景:文章分类这个下拉菜单,在多个地方会用到,因此将其封装成组件
步骤:
- 从src\views\article\ArticleManage.vue的表单区域中,抽离出第一个el-select部分,封装成ChannelSelect,后续在组件内部进行动态渲染
- 一进页面发起请求获取数据,并动态渲染组件
- v-model双向绑定收集数据用
- el-select关联的数据项,由父组件传入prop值,子组件定义
defineProps({})
defineProps({自定义属性 :值类型})
用props来接收父亲传来的值,defineEmits([‘自定义属性’])
用emit传值给父亲
- 父级组件通过
<ChannelSelect></ChannelSelect>
引入
重点:
- Vue2 => v-model :value 和 @input 的简写
- Vue3 => v-model :modelValue 和 @update:modelValue 的简写
- 子传父
@update:modelValue="emit('update:modelValue', $event)"
<script setup>
import { artGetChannelService } from '@/api/article.js'
import { ref } from 'vue'
defineProps({
modelValue: {
type: [Number, String]
},
width: {
type: String
}
})
const emit = defineEmits(['update:modelValue'])
const channelList = ref([])
const getChannelList = async () => {
const res = await artGetChannelService()
channelList.value = res.data.data
}
getChannelList()
</script>
<template>
<!-- 选择器原抽离写死部分 -->
<!-- <el-select> -->
<!-- label前端展示给用户 value是收集用于与后台通信的标记id -->
<!-- <el-option label="新闻" value="111"></el-option> -->
<!-- <el-option label="体育" value="222"></el-option> -->
<!-- </el-select> -->
<!-- label 展示给用户看的,value 收集起来提交给后台的 -->
<el-select
:modelValue="modelValue"
@update:modelValue="emit('update:modelValue', $event)"
:style="{ width }"
>
<el-option
v-for="channel in channelList"
:key="channel.id"
:label="channel.cate_name"
:value="channel.id"
></el-option>
</el-select>
</template>
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>
表格区域结构渲染 - 第一步渲染
- 分两步
- 第一步渲染
- 获取文章列表,封装接口
- 页面中,去掉假数据,改为调用api获取,并保存数据(基于接口文档和假数据来写,减少使用log,省时间,提升效率)
- src\utils\format.js中,封装时间格式转化工具,从element导入组件已附带的dayjs,
- 封装工具后页面导入使用
- 第二步分页
// 文章:获取文章列表 get请求,传query参数,是个对象,如果是post,直接data即可
export const artGetListService = (params) =>
request.get('/my/article/list', {
params
})
// 文章:添加文章
// 注意:data需要是一个formData格式的对象
export const artPublishService = (data) => request.post('/my/article/add', data)
// 文章:获取文章详情
export const artGetDetailService = (id) =>
request.get('/my/article/info', {
params: { id }
})
// 文章:编辑文章接口
export const artEditService = (data) => request.put('/my/article/info', data)
// 文章:删除文章接口
export const artDelService = (id) =>
request.delete('/my/article/info', { params: { id } })
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
import { artGetListService } from '@/api/article.js'
// 假数据
// 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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// 基于params参数,获取文章列表
const getArticleList = async () => {
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
}
getArticleList()
// 编辑逻辑
const onEditArticle = (row) => {
console.log(row);
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<channel-select v-model="params.cate_id"></channel-select>
</el-form-item>
<el-form-item label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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>
<!-- 表格区域 -->
<el-table :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<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="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
</page-container>
</template>
<style lang="scss" scoped></style>
import { dayjs } from 'element-plus'
export const formatTime = (time) => dayjs(time).format('YYYY年MM月DD日')
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { formatTime } from '@/utils/format.js'
import { artGetListService } from '@/api/article.js'
// 假数据
// 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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// 基于params参数,获取文章列表
const getArticleList = async () => {
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
}
getArticleList()
// 编辑逻辑
const onEditArticle = (row) => {
console.log(row);
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<channel-select v-model="params.cate_id"></channel-select>
</el-form-item>
<el-form-item label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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>
<!-- 表格区域 -->
<el-table :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
</page-container>
</template>
<style lang="scss" scoped></style>
表格区域结构渲染 - 第二步分页
- 第二步分页渲染 [element-plus 分页]
- 分页组件Pagination 分页
<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="true" // 背景颜色
- :total="total" // 总数
- @size-change="onSizeChange" // 监听 重置页码,重置每页条数,并重新渲染的函数
- @current-change="onCurrentChange" // 监听 更新页码,重新渲染
- style="margin-top: 20px; justify-content: flex-end" // 加样式
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { artGetListService } from '@/api/article.js'
// 假数据
// 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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// 基于params参数,获取文章列表
const getArticleList = async () => {
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
}
getArticleList()
// 分页组件按钮,处理分页逻辑
const onSizeChange = (size) => {
// console.log('当前每页条数', size)
// 只要是每页条数变化了,那么原本正在访问的当前页意义不大了,数据大概率已经不在原来那一页了
// 重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的当前页 和 每页条数,渲染数据
getArticleList()
}
const onCurrentChange = (page) => {
// 更新当前页页码的函数
params.value.pagenum = page
// 基于最新的当前页,渲染数据
getArticleList()
}
// 编辑逻辑
const onEditArticle = (row) => {
console.log(row);
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<channel-select v-model="params.cate_id"></channel-select>
</el-form-item>
<el-form-item label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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>
<!-- 表格区域 -->
<el-table :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
<!-- 分页区域,基于flex布局 -->
<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="true"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
</template>
<style lang="scss" scoped></style>
添加 loading 处理
- 在ArticleManage.vue页面中准备数据
const loading = ref(false)
- el-table上面绑定
<el-table v-loading="loading" > ... </el-table>
- 在ArticleManage.vue页面中发送请求时添加 loading,发送请求前
loading.value = true
开启loading效果,请求回来后loading.value = false
关闭loading效果
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { artGetListService } from '@/api/article.js'
// 假数据
// 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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// loading状态
const loading = ref(false)
// 基于params参数,获取文章列表
const getArticleList = async () => {
// 发送请求前
loading.value = true
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
// 请求回来后
loading.value = false
}
getArticleList()
// 分页组件按钮,处理分页逻辑
const onSizeChange = (size) => {
// console.log('当前每页条数', size)
// 只要是每页条数变化了,那么原本正在访问的当前页意义不大了,数据大概率已经不在原来那一页了
// 重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的当前页 和 每页条数,渲染数据
getArticleList()
}
const onCurrentChange = (page) => {
// 更新当前页页码的函数
params.value.pagenum = page
// 基于最新的当前页,渲染数据
getArticleList()
}
// 编辑逻辑
const onEditArticle = (row) => {
console.log(row);
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<channel-select style="width: 240px" v-model="params.cate_id"></channel-select>
</el-form-item>
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<el-form-item style="width: 240px" label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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>
<!-- 表格区域 -->
<el-table v-loading="loading" :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
<!-- 分页区域,基于flex布局 -->
<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="true"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
</template>
<style lang="scss" scoped></style>
搜索 和 重置功能
- 根据分类下拉列表的选中状态,作为搜索筛选的前置条件,搭配发布状态作为第二条件,发起api请求,实现搜索功能
- 给ArticleManage.vue页面中的按钮,注册点击事件
- 给ArticleManage.vue页面中事件绑定处理方法
- 处理方法: - 搜索逻辑 => 按照最新的条件,重新检索,从第一页开始展示 - 重置逻辑 => 将筛选条件清空,重新检索,从第一页开始展示 - 在调用
getArticleList()
重新检索时,会基于组件插槽传值v-model="params.cate_id"
来执行搜索,因此达到了选中的条件发起搜索
<script setup>
// 搜索逻辑 => 按照最新的条件,重新检索,从第一页开始展示
const onSearch = () => {
params.value.pagenum = 1 // 重置页面
getArticleList()
}
// 重置逻辑 => 将筛选条件清空,重新检索,从第一页开始展示
const onReset = () => {
params.value.pagenum = 1 // 重置页面
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
</script>
<template>
<el-form-item>
<!-- 注册搜索和重置按钮的点击事件 -->
<el-button @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</template>
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { artGetListService } from '@/api/article.js'
// 假数据
// 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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// loading状态
const loading = ref(false)
// 基于params参数,获取文章列表
const getArticleList = async () => {
// 发送请求前
loading.value = true
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
// 请求回来后
loading.value = false
}
getArticleList()
// 分页组件按钮,处理分页逻辑
const onSizeChange = (size) => {
// console.log('当前每页条数', size)
// 只要是每页条数变化了,那么原本正在访问的当前页意义不大了,数据大概率已经不在原来那一页了
// 重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的当前页 和 每页条数,渲染数据
getArticleList()
}
const onCurrentChange = (page) => {
// 更新当前页页码的函数
params.value.pagenum = page
// 基于最新的当前页,渲染数据
getArticleList()
}
// 搜索逻辑 => 按照最新的条件,重新检索,从第一页开始展示
const onSearch = () => {
params.value.pagenum = 1 // 重置页面
getArticleList()
}
// 重置逻辑 => 将筛选条件清空,重新检索,从第一页开始展示
const onReset = () => {
params.value.pagenum = 1 // 重置页面
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
// 编辑逻辑
const onEditArticle = (row) => {
console.log(row);
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<channel-select style="width: 240px" v-model="params.cate_id"></channel-select>
</el-form-item>
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<el-form-item style="width: 240px" label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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 @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table v-loading="loading" :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
<!-- 分页区域,基于flex布局 -->
<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="true"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
</template>
<style lang="scss" scoped></style>
文章管理页面下的 发布&修改 [element-plus - 抽屉]
添加文章功能,通过封装抽屉组件实现
抽屉组件的封装及使用步骤:
点击显示抽屉
- 先往src\views\article\ArticleManage.vue页面中,准备抽屉容器,写入抽屉组件,并定义抽屉状态
const visibleDrawer = ref(false)
定义抽屉逻辑,通过v-model绑定来控制显示隐藏,默认插槽就是当前弹层显示的内容,绑定本页面的按钮点击事件 - 封装抽屉组件 ArticleEdit,分析如下:
- 添加 和 编辑,可以共用一个抽屉,所以可以将抽屉封装成一个组件,src\views\article\components\ArticleEdit.vue,将抽屉组件的结构,状态定义,控制方法都从ArticleManage.vue页面中抽离后放入,子组件中,通过定义ref默认为false来控制抽屉状态隐藏
const visibleDrawer = ref(false)
,在open调用后打开抽屉const open = (row) => {visibleDrawer.value = true}
- 原页面改为导入组件
import ArticleEdit from './components/ArticleEdit.vue'
+插槽渲染<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
+调用方法articleEditRef.value.open({})
,视乎添加(传空对象)还是编辑,传不同的对象 - 子组件对外暴露一个方法
defineExpose({open})
, 父组件基于子组件,调用ref身上的子组件open方法,并传递的参数,初始化表单数据,并判断区分是添加 还是 编辑;open({ })为空对象
=> 则作添加操作,添加表单初始化无数据;open({ id: xx, ... })
=> 编辑操作,编辑表单初始化需回显
- 先往src\views\article\ArticleManage.vue页面中,准备抽屉容器,写入抽屉组件,并定义抽屉状态
完善抽屉表单结构
- 结合接口文档准备数据,ArticleEdit.vue抽屉组件中准备数据
- 在ArticleEdit父组件插入子组件channel-select时,插槽上的
width="100%"
不生效,原因是封装成组件后,插槽标签上加的width="100%"
变成了属性,相当于给插槽加了100%的宽度属性,属性生效需要自己配逻辑,在ChannelSelect组件的defineProps({中})
,扩展一个属性width: {type: String}
,在el-select模板中:style="{ width }"
,此时才能生效(或者不加属性,直接改为style="width:100%"
)
上传文件 [element-plus - 文件预览]
- 分析上传的逻辑,以及普通做法,需要产品经理定采用哪种做法
- 普通做法1:一种是实际的图片上传与当前的提交/发布按钮是分离的,点击+号传图片时,实际上图片就已经完成上传,并返回一个url地址,点击提交/发布按钮时提交这个url,此方式稍微有一定服务器负担,可能有无效上传,产生垃圾图片
- 普通做法2:用户点击+号上传,本质上是图片在本地的预览,点击发布/草稿时,才真正上传服务器,服务器压力相对小,此方式由于在点发布/草稿按钮提交时,是整个表单数据包括图片等一起打包提交上传的,进度会稍微慢一点,可能会有卡顿
- 通过接口文档,确认没有单独的图片/文件提交上传的接口,那么就一定是图片在本地的预览,点击发布/草稿时,才真正上传服务器;在接口文档-发布文章的接口,可以确认,请求体中有传递文件对象/文章封面的body参数form-data方式进行提交的,不仅图片,整个body参数都是通过form-data方式进行提交的
- element-plus默认支持自动文件上传,Upload 上传默认配置中,可通过
:before-upload="beforeAvatarUpload"
函数控制上传的文件大小和类型等,默认通过action="https://图片默认提交的后台接口地址"
以post请求方式加name='file'
请求参数,关闭自动图片文件上传:auto-upload="false"
- 准备数据的template结构 和 选择图片的处理逻辑,通过前端api语法,前端api图片本地预览语法
URL.createObjectURL(...)
,在处理逻辑中,通过创建本地预览的地址来实现预览
富文本编辑器 [ vue-quill ],官网地址:https://vueup.github.io/vue-quill/
- 装包
pnpm add @vueup/vue-quill@latest
- 在ArticleEdit.vue抽屉组件中,局部引入富文本编辑器,并注册成局部组件
import { QuillEditor } from '@vueup/vue-quill'
和import '@vueup/vue-quill/dist/vue-quill.snow.css'
- 页面中使用+样式美化,双向绑定数据
v-model:content="formModel.content"
,通过pinia的调试确认数据已存入 - 可以通过浏览器调试审查元素,找到组件的css参数名,从而自定义设计组件的css样式
- 装包
最后实现按钮功能:点击添加按钮,完成添加文章功能
- 点击按钮添加前的校验:略,主要是非空校验,以及校验后重置
- 发布按钮主核心业务:封装接口
export const artPublishService = (data) =>request.post('/my/article/add', data)
, - 抽屉组件中,注册点击事件,点发布按钮时调用接口并传参发布,点草稿调用接口时传参草稿,调用同一个
@click="onPublish
方法,传参状态state不一样,由调方法时的传参决定 - 操作成功时触发success事件,父组件监听事件,并触发重新渲染
- 添加完成后的内容重置,由于图片预览及富文本编辑器并不是使用form中的数据去预览,因此需要手动重置图片预览(基于imgUrl预览)及富文本编辑器(需要养成习惯,文件上传与富文本编辑器一类,都需要手动重置,以确保重置干净)
抽屉组件的复用步骤:
点击编辑文章按钮,实现共用抽屉组件
- 编辑文章回显,封装获取文章详情接口,调用接口拿到数据,
- 获取-文章详情的接口注意点: cover_img的值, 需要自己拼接服务器前缀地址, 和接口服务的基地址相同(在src\utils\request.js中,按需导出了基地址
export { baseURL }
) - 在ArticleEdit.vue抽屉组件中调用接口,页面中一打开抽屉,就需要发送请求,获取数据进行回显
- 数据回显
formModel.value = res.data.data
- 图片回显需要单独处理,基地址拼接口返回的地址,让后台返回的图片数据回显
imgUrl.value = baseURL + formModel.value.cover_img
- 数据回显
- 编辑文章功能,点击编辑时的发布,走的提交接口是编辑接口
- 提交给后台,走的是更新文章详情接口,接口要求的需要的图片数据格式,是file对象格式,需要将网络图片地址 => 转换成 file对象,存储起来, 将来便于提交
- 备注:正常后台接口,应该加处理,实现两个方法都支持,图片地址也能传(直接使用),file对象也能传(转存)
- 利用GPT实现
将网络图片地址 => 转换成 file对象
这一需求,prompt提示词:封装一个函数,基于 axios, 网络图片地址,转 file 对象, 请注意:写中文注释
- 编辑提交
- 编辑后提交,存在id就是编辑
文章删除功能
- 封装删除接口,ArticleManage.vue页面中调用即可
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { artGetListService } from '@/api/article.js'
// 假数据
// 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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// loading状态
const loading = ref(false)
// 定义抽屉状态
const visibleDrawer = ref(false)
// 基于params参数,获取文章列表
const getArticleList = async () => {
// 发送请求前
loading.value = true
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
// 请求回来后
loading.value = false
}
getArticleList()
// 分页组件按钮,处理分页逻辑
const onSizeChange = (size) => {
// console.log('当前每页条数', size)
// 只要是每页条数变化了,那么原本正在访问的当前页意义不大了,数据大概率已经不在原来那一页了
// 重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的当前页 和 每页条数,渲染数据
getArticleList()
}
// 抽屉组件逻辑
const onAddArticle = () => {
visibleDrawer.value = true
}
const onCurrentChange = (page) => {
// 更新当前页页码的函数
params.value.pagenum = page
// 基于最新的当前页,渲染数据
getArticleList()
}
// 搜索逻辑 => 按照最新的条件,重新检索,从第一页开始展示
const onSearch = () => {
params.value.pagenum = 1 // 重置页面
getArticleList()
}
// 重置逻辑 => 将筛选条件清空,重新检索,从第一页开始展示
const onReset = () => {
params.value.pagenum = 1 // 重置页面
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
// 编辑逻辑
const onEditArticle = (row) => {
console.log(row);
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<!-- 给抽屉组件传事件 -->
<el-button type="primary" @click="onAddArticle">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<channel-select style="width: 240px" v-model="params.cate_id"></channel-select>
</el-form-item>
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<el-form-item style="width: 240px" label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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 @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table v-loading="loading" :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
<!-- 分页区域,基于flex布局 -->
<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="true"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 准备抽屉容器,写入抽屉组件 -->
<el-drawer
v-model="visibleDrawer"
title="大标题"
direction="rtl"
size="50%"
>
<span>Hi there!</span>
</el-drawer>
</page-container>
</template>
<style lang="scss" scoped></style>
<script setup>
import { ref } from 'vue'
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = (row) => {
// open调用后,可以打开抽屉
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>
<script setup>
import { ref } from 'vue'
import ArticleEdit from './components/ArticleEdit.vue'
// 定义插槽ref数据
const articleEditRef = ref()
// 抽屉组件逻辑,添加
const onAddArticle = () => {
// 添加改为传空对象,注掉以下
// visibleDrawer.value = true
// 父组件改为调用ref身上的子组件open方法
articleEditRef.value.open({})
}
// 抽屉组件逻辑,编辑
const onEditArticle = (row) => {
articleEditRef.value.open(row)
}
</script>
<template>
<page-container title="文章管理">
<!-- 插槽的原来的位置,改为添加编辑的抽屉 -->
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
</page-container>
</template>
// 根据接口文档,准备提供5个数据
const formModel = ref({
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
})
const open = async (row) => {
visibleDrawer.value = true
// 判断是否回显
if (row.id) {
console.log('编辑回显')
} else {
console.log('添加功能')
}
}
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>
<script setup>
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 根据接口文档,准备提供5个数据,将其定义为默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
}
// 默认数据赋值给formModel
const formModel = ref({ ...defaultForm })
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = (row) => {
// open调用后,可以打开抽屉
visibleDrawer.value = true// 显示抽屉
console.log(row)
// 回显判断
if (row.id) {
console.log('编辑回显')
// 后续需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
} else {
console.log('添加功能')
// 需要重置当前的表单数据
formModel.value = { ...defaultForm } // 基于默认的数据,重置form数据
// 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
}
}
// 向外暴露方法
defineExpose({
open
})
</script>
<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>
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { artGetListService } from '@/api/article.js'
import ArticleEdit from './components/ArticleEdit.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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// loading状态
const loading = ref(false)
// 定义抽屉状态
const visibleDrawer = ref(false)
const articleEditRef = ref()
// 基于params参数,获取文章列表
const getArticleList = async () => {
// 发送请求前
loading.value = true
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
// 请求回来后
loading.value = false
}
getArticleList()
// 分页组件按钮,处理分页逻辑
const onSizeChange = (size) => {
// console.log('当前每页条数', size)
// 只要是每页条数变化了,那么原本正在访问的当前页意义不大了,数据大概率已经不在原来那一页了
// 重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的当前页 和 每页条数,渲染数据
getArticleList()
}
// 抽屉组件逻辑
const onAddArticle = () => {
// 添加改为传空对象,注掉以下
// visibleDrawer.value = true
// 改为
articleEditRef.value.open({})
}
// 编辑逻辑
const onEditArticle = (row) => {
articleEditRef.value.open(row)
}
const onCurrentChange = (page) => {
// 更新当前页页码的函数
params.value.pagenum = page
// 基于最新的当前页,渲染数据
getArticleList()
}
// 搜索逻辑 => 按照最新的条件,重新检索,从第一页开始展示
const onSearch = () => {
params.value.pagenum = 1 // 重置页面
getArticleList()
}
// 重置逻辑 => 将筛选条件清空,重新检索,从第一页开始展示
const onReset = () => {
params.value.pagenum = 1 // 重置页面
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<!-- 给抽屉组件传事件 -->
<el-button type="primary" @click="onAddArticle">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<channel-select style="width: 240px" v-model="params.cate_id"></channel-select>
</el-form-item>
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<el-form-item style="width: 240px" label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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 @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table v-loading="loading" :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
<!-- 分页区域,基于flex布局 -->
<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="true"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 添加编辑的抽屉 -->
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 根据接口文档,准备提供5个数据,将其定义为默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
}
// 图片上传相关逻辑,创建本地预览的地址来实现预览
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw) // 预览图片
// 立刻将图片对象,存入 formModel.value.cover_img 将来用于提交
formModel.value.cover_img = uploadFile.raw
}
// 默认数据赋值给formModel
const formModel = ref({ ...defaultForm })
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = (row) => {
// open调用后,可以打开抽屉
visibleDrawer.value = true// 显示抽屉
console.log(row)
// 回显判断
if (row.id) {
console.log('编辑回显')
// 后续需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
} else {
console.log('添加功能')
// 需要重置当前的表单数据
formModel.value = { ...defaultForm } // 基于默认的数据,重置form数据
// 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
}
}
// 向外暴露方法
defineExpose({
open
})
</script>
<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="cover_img">
<!-- 此处需要关闭 element-plus 的自动上传,不需要配置 action 等参数
只需要做前端的本地预览图片即可,无需在提交前上传图标
语法:URL.createObjectURL(...) 创建本地预览的地址,来预览
-->
<el-upload
class="avatar-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="onSelectFile"
>
<!-- 没有图片时展示加号 -->
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</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>
<!-- scoped保证样式仅在当前组件内生效 -->
<style lang="scss" scoped>
.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;
}
}
}
</style>
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 局部引入富文本编辑器,并注册成局部组件
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// 根据接口文档,准备提供5个数据,将其定义为默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
}
// 图片上传相关逻辑,创建本地预览的地址来实现预览
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw) // 预览图片
// 立刻将图片对象,存入 formModel.value.cover_img 将来用于提交
formModel.value.cover_img = uploadFile.raw
}
// 默认数据赋值给formModel
const formModel = ref({ ...defaultForm })
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = (row) => {
// open调用后,可以打开抽屉
visibleDrawer.value = true// 显示抽屉
console.log(row)
// 回显判断
if (row.id) {
console.log('编辑回显')
// 后续需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
} else {
console.log('添加功能')
// 需要重置当前的表单数据
formModel.value = { ...defaultForm } // 基于默认的数据,重置form数据
// 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
}
}
// 向外暴露方法
defineExpose({
open
})
</script>
<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="cover_img">
<!-- 此处需要关闭 element-plus 的自动上传,不需要配置 action 等参数
只需要做前端的本地预览图片即可,无需在提交前上传图标
语法:URL.createObjectURL(...) 创建本地预览的地址,来预览
-->
<el-upload
class="avatar-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="onSelectFile"
>
<!-- 没有图片时展示加号 -->
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="文章内容" prop="content">
<!-- <div class="editor">富文本编辑器</div> -->
<!-- 引入富文本编辑器组件 -->
<!-- 双向绑定数据`v-model:content="formModel.content"` -->
<div class="editor">
<quill-editor
theme="snow"
v-model:content="formModel.content"
contentType="html"
>
</quill-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>
<!-- scoped保证样式仅在当前组件内生效 -->
<style lang="scss" scoped>
.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>
import { artPublishService } from '@/api/article'
// 发布文章按钮业务逻辑
// 通知父组件
const emit = defineEmits(['success'])
const onPublish = async (state) => {
// 将不管已发布还是草稿状态,都存入 formModel
formModel.value.state = state
// 转换 formData 数据
// 注意:当前接口,需要的是 formData 对象
// 将js的普通对象formModel.value => 转换成 => formData对象
const fd = new FormData()
// 遍历对象中的所有数据
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 发请求
// 存在id就是编辑
if (formModel.value.id) {
console.log('编辑操作')
// 编辑操作
} else {
// 添加请求
console.log('添加成功');
// 添加操作,调方法
await artPublishService(fd)
ElMessage.success('添加成功')
visibleDrawer.value = false
// 通知到父组件,添加成功了,额外传参'add'以作区分,是添加成功
emit('success', 'add')
}
}
<el-form-item>
<el-button @click="onPublish('已发布')" type="primary">发布</el-button>
<el-button @click="onPublish('草稿')" type="info">草稿</el-button>
</el-form-item>
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
// 添加或者编辑 修改成功的回调
const onSuccess = (type) => {
if (type === 'add') {
// 如果是添加,需要跳转并渲染最后一页,如果是编辑,直接渲染当前页
const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
// 更新成最大页码数,再渲染
params.value.pagenum = lastPage
}
getArticleList()
}
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 局部引入富文本编辑器,并注册成局部组件
import { artPublishService } from '@/api/article'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// 根据接口文档,准备提供5个数据,将其定义为默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
}
// 图片上传相关逻辑,创建本地预览的地址来实现预览
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw) // 预览图片
// 立刻将图片对象,存入 formModel.value.cover_img 将来用于提交
formModel.value.cover_img = uploadFile.raw
}
// 发布文章按钮业务逻辑
// 通知父组件
const emit = defineEmits(['success'])
const onPublish = async (state) => {
// 将不管已发布还是草稿状态,都存入 formModel
formModel.value.state = state
// 转换 formData 数据
// 注意:当前接口,需要的是 formData 对象
// 将js的普通对象formModel.value => 转换成 => formData对象
const fd = new FormData()
// 遍历对象中的所有数据
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 发请求
// 存在id就是编辑
if (formModel.value.id) {
console.log('编辑操作')
// 编辑操作
} else {
// 添加请求
console.log('添加成功');
// 添加操作,调方法
await artPublishService(fd)
ElMessage.success('添加成功')
visibleDrawer.value = false
// 通知到父组件,添加成功了,额外传参'add'以作区分,是添加成功
emit('success', 'add')
}
}
// 默认数据赋值给formModel
const formModel = ref({ ...defaultForm })
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = (row) => {
// open调用后,可以打开抽屉
visibleDrawer.value = true// 显示抽屉
console.log(row)
// 回显判断
if (row.id) {
console.log('编辑回显')
// 后续需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
} else {
console.log('添加功能')
// 需要重置当前的表单数据
formModel.value = { ...defaultForm } // 基于默认的数据,重置form数据
// 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
}
}
// 向外暴露方法
defineExpose({
open
})
</script>
<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="cover_img">
<!-- 此处需要关闭 element-plus 的自动上传,不需要配置 action 等参数
只需要做前端的本地预览图片即可,无需在提交前上传图标
语法:URL.createObjectURL(...) 创建本地预览的地址,来预览
-->
<el-upload
class="avatar-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="onSelectFile"
>
<!-- 没有图片时展示加号 -->
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="文章内容" prop="content">
<!-- <div class="editor">富文本编辑器</div> -->
<!-- 引入富文本编辑器组件 -->
<!-- 双向绑定数据`v-model:content="formModel.content"` -->
<div class="editor">
<quill-editor
theme="snow"
v-model:content="formModel.content"
contentType="html"
>
</quill-editor>
</div>
</el-form-item>
<el-form-item>
<!-- 注册点击事件调用 -->
<el-form-item>
<el-button @click="onPublish('已发布')" type="primary">发布</el-button>
<el-button @click="onPublish('草稿')" type="info">草稿</el-button>
</el-form-item>
</el-form-item>
</el-form>
</el-drawer>
</template>
<!-- scoped保证样式仅在当前组件内生效 -->
<style lang="scss" scoped>
.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>
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { artGetListService } from '@/api/article.js'
import ArticleEdit from './components/ArticleEdit.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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// loading状态
const loading = ref(false)
// 定义抽屉状态
const visibleDrawer = ref(false)
const articleEditRef = ref()
// 基于params参数,获取文章列表
const getArticleList = async () => {
// 发送请求前
loading.value = true
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
// 请求回来后
loading.value = false
}
getArticleList()
// 分页组件按钮,处理分页逻辑
const onSizeChange = (size) => {
// console.log('当前每页条数', size)
// 只要是每页条数变化了,那么原本正在访问的当前页意义不大了,数据大概率已经不在原来那一页了
// 重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的当前页 和 每页条数,渲染数据
getArticleList()
}
// 抽屉组件逻辑
const onAddArticle = () => {
// 添加改为传空对象,注掉以下
// visibleDrawer.value = true
// 改为
articleEditRef.value.open({})
}
// 编辑逻辑
const onEditArticle = (row) => {
articleEditRef.value.open(row)
}
const onCurrentChange = (page) => {
// 更新当前页页码的函数
params.value.pagenum = page
// 基于最新的当前页,渲染数据
getArticleList()
}
// 搜索逻辑 => 按照最新的条件,重新检索,从第一页开始展示
const onSearch = () => {
params.value.pagenum = 1 // 重置页面
getArticleList()
}
// 重置逻辑 => 将筛选条件清空,重新检索,从第一页开始展示
const onReset = () => {
params.value.pagenum = 1 // 重置页面
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
// 删除逻辑
const onDeleteArticle = (row) => {
console.log(row);
}
// 添加或者编辑 修改成功的回调
const onSuccess = (type) => {
if (type === 'add') {
// 如果是添加,需要跳转并渲染最后一页,如果是编辑,直接渲染当前页,Math.ceil向上取整
const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
// 更新成最大页码数,再渲染
params.value.pagenum = lastPage
}
getArticleList()
}
// 本质上是如下,由于两个逻辑在最后都有请求调用,因此提取出来,放到逻辑外,并省去else
// 添加或者编辑 修改成功的回调
// const onSuccess = (type) => {
// if (type === 'add') {
// // 如果是添加,需要跳转并渲染最后一页,如果是编辑,直接渲染当前页,Math.ceil向上取整
// const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
// // 更新成最大页码数,再渲染
// params.value.pagenum = lastPage
// getArticleList()
// }else{
// getArticleList()
// }
// }
</script>
<template>
<page-container title="文章管理">
<template #extra>
<!-- 给抽屉组件传事件 -->
<el-button type="primary" @click="onAddArticle">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<channel-select style="width: 240px" v-model="params.cate_id"></channel-select>
</el-form-item>
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<el-form-item style="width: 240px" label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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 @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table v-loading="loading" :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
<!-- 分页区域,基于flex布局 -->
<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="true"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 添加编辑的抽屉 -->
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 局部引入富文本编辑器,并注册成局部组件
import { artPublishService } from '@/api/article'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// 根据接口文档,准备提供5个数据,将其定义为默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
}
// 图片上传相关逻辑,创建本地预览的地址来实现预览
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw) // 预览图片
// 立刻将图片对象,存入 formModel.value.cover_img 将来用于提交
formModel.value.cover_img = uploadFile.raw
}
// 发布文章按钮业务逻辑
// 通知父组件
const emit = defineEmits(['success'])
const onPublish = async (state) => {
// 将不管已发布还是草稿状态,都存入 formModel
formModel.value.state = state
// 转换 formData 数据
// 注意:当前接口,需要的是 formData 对象
// 将js的普通对象formModel.value => 转换成 => formData对象
const fd = new FormData()
// 遍历对象中的所有数据
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 发请求
// 存在id就是编辑
if (formModel.value.id) {
console.log('编辑操作')
// 编辑操作
} else {
// 添加请求
console.log('添加成功');
// 添加操作,调方法
await artPublishService(fd)
ElMessage.success('添加成功')
visibleDrawer.value = false
// 通知到父组件,添加成功了,额外传参'add'以作区分,是添加成功
emit('success', 'add')
}
}
// 默认数据赋值给formModel
const formModel = ref({ ...defaultForm })
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 声明表单数据,以及富文本编辑器数据
const formRef = ref()
const editorRef = ref()
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = (row) => {
// open调用后,可以打开抽屉
visibleDrawer.value = true// 显示抽屉
console.log(row)
// 回显判断
if (row.id) {
console.log('编辑回显')
// 后续需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
} else {
console.log('添加功能')
// 需要重置当前的表单数据
formModel.value = { ...defaultForm } // 基于默认的数据,重置form数据
// 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
// 重置图片缩略图回显
imgUrl.value = ''
// 调用富文本编辑器的官方方法,重置富文本编辑器
editorRef.value.setHTML('')
}
}
// 向外暴露方法
defineExpose({
open
})
</script>
第一次的prompt:基于axios,如何将网络图片地址,转换成file对象
回答:
在浏览器环境中,由于安全性限制,不能直接将网络图片地址转换成File对象。File对象是用户在客户端上传文件时才能创建的,而不能由前端代码直接将网络图片地址转换成 File对象。
如果你想要将网络图片地址转换成 File 对象,你需要先将图片下载到客户端,然后再将其转换为 File 对象。这可以通过以下步骤完成:
1. 使用 Axios(或其他网络请求库)下载图片数据。
2. 将下载的数据转换成 Blob 对象。
3. 创建一个新的 File 对象,将 Blob 对象放入其中,
下面是实现这个过程的示例代码:
//引入 Axios
const axios = require('axios');
// 将网络图片地址转换为 File 对象的函数
async function imageUrlToFileObject(imageUrl, filename) {
try {
// 使用 Axios 下载图片数据
const response = await axios.get(imageUrl, { responseType: 'arraybuffer' })
// 将下载的数据转换成 Blob 对象
const blob = new Blob([response.data], {
type: response.headers['content-type']
})
// 创建 File 对象
const file = new File([blob], filename, {
type: response.headers['content-type']
})
return file
} catch (error) {
console.error('Error converting image URL to File object:', error)
return null
}
}
//示例使用
const imageUrl= 'https://example.com/path/to/image.jpg';
const filename ='image.jpg';
imageUrlToFile0bject(imageUrl, filename)
.then((file)=>{
if(file){
console.log('Successfully converted image URL to File object:', file)
//在这里可以使用 File 对象进行后续操作,比如上传到服务器等。
} else {
console.log('Failed to convert image URL to File object.');
}
});
prompt提示词:`封装一个函数,基于 axios, 网络图片地址,转 file 对象, 请注意:写中文注释`
/**
* 将网络图片地址转换为 File 对象
*
* @param {string} url 图片地址
* @param {string} filename 文件名(可选,默认使用图片名称)
* @returns {Promise<File>} File 对象
*/
function urlToFile(url, filename) {
return new Promise((resolve, reject) => {
// 创建一个新的 XMLHttpRequest 对象
const xhr = new XMLHttpRequest();
// 设置请求类型和请求地址
xhr.open('GET', url, true);
// 设置响应类型为 blob
xhr.responseType = 'blob';
// 设置请求头
xhr.setRequestHeader('Accept', 'image/*');
// 加载完成后的回调函数
xhr.onload = () => {
if (xhr.status === 200) {
// 获取响应的 blob 数据
const blob = xhr.response;
// 根据文件名或图片名称创建文件名
const name = filename || url.split('/').pop();
// 创建 File 对象
const file = new File([blob], name, {
type: blob.type,
});
// 将 File 对象解析为 Promise 对象
resolve(file);
} else {
// 请求失败
reject(new Error('请求失败,请检查网络或图片地址是否正确'));
}
};
// 发送请求
xhr.send();
});
}
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 局部引入富文本编辑器,并注册成局部组件
import { artPublishService } from '@/api/article'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { baseURL } from '@/utils/request'
import axios from 'axios'
// 根据接口文档,准备提供5个数据,将其定义为默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
}
// 图片上传相关逻辑,创建本地预览的地址来实现预览
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw) // 预览图片
// 立刻将图片对象,存入 formModel.value.cover_img 将来用于提交
formModel.value.cover_img = uploadFile.raw
}
// 发布文章按钮业务逻辑
// 通知父组件
const emit = defineEmits(['success'])
const onPublish = async (state) => {
// 将不管已发布还是草稿状态,都存入 formModel
formModel.value.state = state
// 转换 formData 数据
// 注意:当前接口,需要的是 formData 对象
// 将js的普通对象formModel.value => 转换成 => formData对象
const fd = new FormData()
// 遍历对象中的所有数据
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 发请求
// 存在id就是编辑
if (formModel.value.id) {
console.log('编辑操作')
// 编辑操作
} else {
// 添加请求
console.log('添加成功');
// 添加操作,调方法
await artPublishService(fd)
ElMessage.success('添加成功')
visibleDrawer.value = false
// 通知到父组件,添加成功了,额外传参'add'以作区分,是添加成功
emit('success', 'add')
}
}
// 默认数据赋值给formModel
const formModel = ref({ ...defaultForm })
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 声明表单数据,以及富文本编辑器数据
const formRef = ref()
const editorRef = ref()
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = async (row) => {
visibleDrawer.value = true // 显示抽屉
if (row.id) {
// 需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
const res = await artGetDetailService(row.id)
// 打印确认是对象
console.log('编辑回显')
// 数据回显
formModel.value = res.data.data
// 图片需要单独处理回显,基地址拼接口返回的地址,让后台返回的图片数据回显
imgUrl.value = baseURL + formModel.value.cover_img
// 注意:提交给后台,走的是更新文章详情接口,接口要求的需要的图片数据格式,是file对象格式,
// file对象格式示例值file://C:\Users\lenovo\Pictures\Saved Pictures\Snipaste_2022-07-03_08-11-33.png
// 需要将网络图片地址 => 转换成 file对象,存储起来, 将来便于提交
// 使用GPT给的方法
const file = await imageUrlToFileObject(
imgUrl.value,
formModel.value.cover_img
)
formModel.value.cover_img = file
} else {
console.log('添加功能')
// 需要重置当前的表单数据
formModel.value = { ...defaultForm } // 基于默认的数据,重置form数据
// 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
// 重置图片缩略图回显
imgUrl.value = ''
// 调用富文本编辑器的官方方法,重置富文本编辑器
editorRef.value.setHTML('')
}
}
// 直接使用GPT的回答:将网络图片地址转换为 File 对象的函数
async function imageUrlToFileObject(imageUrl, filename) {
try {
// 使用 Axios 下载图片数据
const response = await axios.get(imageUrl, { responseType: 'arraybuffer' })
// 将下载的数据转换成 Blob 对象
const blob = new Blob([response.data], {
type: response.headers['content-type']
})
// 创建 File 对象
const file = new File([blob], filename, {
type: response.headers['content-type']
})
return file
} catch (error) {
console.error('Error converting image URL to File object:', error)
return null
}
}
// 向外暴露方法
defineExpose({
open
})
</script>
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 局部引入富文本编辑器,并注册成局部组件
import { artPublishService } from '@/api/article'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import axios from 'axios'
// 根据接口文档,准备提供5个数据,将其定义为默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类id
cover_img: '', // 封面图片 file 对象
content: '', // string 内容
state: '' // 状态
}
// 图片上传相关逻辑,创建本地预览的地址来实现预览
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw) // 预览图片
// 立刻将图片对象,存入 formModel.value.cover_img 将来用于提交
formModel.value.cover_img = uploadFile.raw
}
// 发布文章按钮业务逻辑
// 通知父组件
const emit = defineEmits(['success'])
const onPublish = async (state) => {
// 将不管已发布还是草稿状态,都存入 formModel
formModel.value.state = state
// 转换 formData 数据
// 注意:当前接口,需要的是 formData 对象
// 将js的普通对象formModel.value => 转换成 => formData对象
const fd = new FormData()
// 遍历对象中的所有数据
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 发请求
// 存在id就是编辑,编辑后提交
if (formModel.value.id) {
console.log('编辑操作')
// 编辑操作
await artEditService(fd)
ElMessage.success('修改成功')
visibleDrawer.value = false
emit('success', 'edit')
} else {
// 添加请求
console.log('添加成功');
// 添加操作,调方法
await artPublishService(fd)
ElMessage.success('添加成功')
visibleDrawer.value = false
// 通知到父组件,添加成功了,额外传参'add'以作区分,是添加成功
emit('success', 'add')
}
}
// 默认数据赋值给formModel
const formModel = ref({ ...defaultForm })
// 定义抽屉状态,控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 声明表单数据,以及富文本编辑器数据
const formRef = ref()
const editorRef = ref()
// 组件对外暴露一个方法 open, 基于 open 的参数,初始化表单数据,并判断区分是添加 还是 编辑
// open({}) => 表单无需渲染,说明是添加
// open({ id, ..., ... }) => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const open = async (row) => {
visibleDrawer.value = true // 显示抽屉
if (row.id) {
// 需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
const res = await artGetDetailService(row.id)
// 打印确认是对象
console.log('编辑回显')
// 数据回显
formModel.value = res.data.data
// 图片需要单独处理回显,基地址拼接口返回的地址,让后台返回的图片数据回显
imgUrl.value = baseURL + formModel.value.cover_img
// 注意:提交给后台,走的是更新文章详情接口,接口要求的需要的图片数据格式,是file对象格式,
// file对象格式示例值file://C:\Users\lenovo\Pictures\Saved Pictures\Snipaste_2022-07-03_08-11-33.png
// 需要将网络图片地址 => 转换成 file对象,存储起来, 将来便于提交
// 使用GPT给的方法
const file = await imageUrlToFileObject(
imgUrl.value,
formModel.value.cover_img
)
formModel.value.cover_img = file
} else {
console.log('添加功能')
// 需要重置当前的表单数据
formModel.value = { ...defaultForm } // 基于默认的数据,重置form数据
// 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
// 重置图片缩略图回显
imgUrl.value = ''
// 调用富文本编辑器的官方方法,重置富文本编辑器
editorRef.value.setHTML('')
}
}
// 直接使用GPT的回答:将网络图片地址转换为 File 对象的函数
async function imageUrlToFileObject(imageUrl, filename) {
try {
// 使用 Axios 下载图片数据
const response = await axios.get(imageUrl, { responseType: 'arraybuffer' })
// 将下载的数据转换成 Blob 对象
const blob = new Blob([response.data], {
type: response.headers['content-type']
})
// 创建 File 对象
const file = new File([blob], filename, {
type: response.headers['content-type']
})
return file
} catch (error) {
console.error('Error converting image URL to File object:', error)
return null
}
}
// 向外暴露方法
defineExpose({
open
})
</script>
<script setup>
// 引入图标
import { Delete, Edit } from '@element-plus/icons-vue'
// 引入抽离的组件
import { formatTime } from '@/utils/format'
import { ref } from 'vue'
import ChannelSelect from './components/ChannelSelect.vue'
// 引入格式化时间的工具组件
import { artDelService, artGetListService } from '@/api/article.js'
import ArticleEdit from './components/ArticleEdit.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: '体育'
// }
// ])
// 这个params = ref({})定义放在上面,放在前,否则可能调试工具中显示出错
const params = ref({
pagenum: 1,// 当前页
pagesize: 5, // 当前生效的每页条数
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 总条数
const total = ref(0)
// loading状态
const loading = ref(false)
// 定义抽屉状态
const visibleDrawer = ref(false)
const articleEditRef = ref()
// 基于params参数,获取文章列表
const getArticleList = async () => {
// 发送请求前
loading.value = true
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
// 请求回来后
loading.value = false
}
getArticleList()
// 分页组件按钮,处理分页逻辑
const onSizeChange = (size) => {
// console.log('当前每页条数', size)
// 只要是每页条数变化了,那么原本正在访问的当前页意义不大了,数据大概率已经不在原来那一页了
// 重新从第一页渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的当前页 和 每页条数,渲染数据
getArticleList()
}
// 抽屉组件逻辑
const onAddArticle = () => {
// 添加改为传空对象,注掉以下
// visibleDrawer.value = true
// 改为
articleEditRef.value.open({})
}
// 编辑逻辑
const onEditArticle = (row) => {
articleEditRef.value.open(row)
}
const onCurrentChange = (page) => {
// 更新当前页页码的函数
params.value.pagenum = page
// 基于最新的当前页,渲染数据
getArticleList()
}
// 搜索逻辑 => 按照最新的条件,重新检索,从第一页开始展示
const onSearch = () => {
params.value.pagenum = 1 // 重置页面
getArticleList()
}
// 重置逻辑 => 将筛选条件清空,重新检索,从第一页开始展示
const onReset = () => {
params.value.pagenum = 1 // 重置页面
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
// 删除逻辑
const onDeleteArticle = async (row) => {
console.log(row);
// 提示用户是否要删除
await ElMessageBox.confirm('你确认删除该文章信息吗?此操作将永久删除该文件, 是否继续?', '温馨提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await artDelService(row.id)
ElMessage.success('删除成功')
// 重新渲染列表
getArticleList()
}
// 添加或者编辑 修改成功的回调
const onSuccess = (type) => {
if (type === 'add') {
// 如果是添加,需要跳转并渲染最后一页,如果是编辑,直接渲染当前页,Math.ceil向上取整
const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
// 更新成最大页码数,再渲染
params.value.pagenum = lastPage
}
getArticleList()
}
// 本质上是如下,由于两个逻辑在最后都有请求调用,因此提取出来,放到逻辑外,并省去else
// 添加或者编辑 修改成功的回调
// const onSuccess = (type) => {
// if (type === 'add') {
// // 如果是添加,需要跳转并渲染最后一页,如果是编辑,直接渲染当前页,Math.ceil向上取整
// const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
// // 更新成最大页码数,再渲染
// params.value.pagenum = lastPage
// getArticleList()
// }else{
// getArticleList()
// }
// }
</script>
<template>
<page-container title="文章管理">
<template #extra>
<!-- 给抽屉组件传事件 -->
<el-button type="primary" @click="onAddArticle">发布文章</el-button>
</template>
<!-- 表单区域 inline属性,显示为一行-->
<el-form inline>
<el-form-item label="文章分类:">
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<channel-select style="width: 240px" v-model="params.cate_id"></channel-select>
</el-form-item>
<!-- 没有长度的话可以参考组件使用说明,给一个长度 -->
<el-form-item style="width: 240px" label="发布状态:">
<!-- 这里后台标记发布状态,就是通过中文标记的,已发布 / 草稿 -->
<el-select 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 @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table v-loading="loading" :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<!-- 作用域插槽,row一行的数据 -->
<template #default="{ row }">
<!-- el-link链接组件 -->
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<!-- 引入格式化时间的工具组件后调用formatTime(row.pub_date) -->
<el-table-column label="发表时间" prop="pub_date">
<!-- 如果需要使用处理后的数据(非直接使用数据渲染)需要写上template,配置了默认插槽后prop会被忽略 -->
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<!-- 绑定事件,并将row作为参数传递 -->
<!-- 利用作用域插槽 row 可以获取当前行的数据 => v-for 遍历 item -->
<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>
<!-- 分页区域,基于flex布局 -->
<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="true"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 添加编辑的抽屉 -->
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
基于AI完成个人中心页面
AI 的认知 & 讲解内容说明
技术栈:ChatGPT & Copilot
- 圈内平替1:Cline (prev. Claude Dev) + deepseek v3 的api
- 圈内平替2:Cline (prev. Claude Dev) + Gemini 的api,需要梯&tun加成
认知同步:
- AI 早已不是新事物 (接受+拥抱)
- => 语音识别,人脸识别,无人驾驶,智能机器人... (包括 ChatGPT 也是研发了多年的产物)
- AI 本质是智能辅助工具 (认识)
- => 人工智能辅助,可以提升效率,但不具备思想意识,无法从零到一取代人类工作
- AI 一定会淘汰掉一部分人,也会衍生新的职业方向和机遇
- => 逆水行舟,不进则退;学会拥抱变化,尽早上车
- AI 早已不是新事物 (接受+拥抱)
两个必用工具:
- ChatGPT等llm语言大模型工具:产品经理、测试等等。。任意行业都支持问询,可适用性强
- Copilot:与编码相关,实际开发编码用
- ChatGPT 3.5 的使用 (4.0 使用方式一致,回答准确度更高,但付费,且每3小时,有次数限制)
- 正常注册流程 (IP限制,手机号限制)
- 三方整合产品 - 谷歌搜索:chatgpt 免费网站列表 - GitHub整理的项目列表https://github.com/LiLittleCat/awesome-free-chatgpt
- 工具 Github Copilot 智能生成代码
ChatGPT 的基本使用 - Prompt(提示词) 优化策略
从学习的角度说,学习使用GPT,就是学习提问
- 与拥有庞大信息量的机器人互动,需要清晰描述你的需求,才能得到最为接近你的需求的答案
AI 互动的过程中,容易出现的问题:
- AI未能理解问题的核心要点
- AI的回答过于宽泛 或 过于具体
- AI提供了错误的信息或观点
- AI未能提供有价值的建议或解决方案
学习使用ChatGPT的重点:Prompt优化
- 在识别了问题所在之后,我们可以尝试以下策略来优化我们的Prompt提问方式:
1 明确提问:
- 确保问题表述清晰明确,关键字的准确度直接决定了AI 对于需求的理解,必要时可使用引号、加粗(支持输入markdown提问)等强调输入的提示词。
2 细化需求:
- 将问题拆分成多个小问题,可以帮助AI更具针对性地回答,也利于即时纠错。
- 将问题拆分逻辑链,从上游逻辑链先开始问
- 例如,分析问题,将问题拆分为多个方面提问,包括问题背景、问题逻辑等
- 抽离问题框架与分支
3 添加背景信息:
- 提供有关问题背景的详细信息,也可以给 AI 预设一个角色,将有助于AI生成更具深度和价值的回答。
4 适当引导:
- 比如:“例如”、“请注意”、“请使用”等,来告诉模型你期望它做什么 或者 不做什么
5 限制范围:
- 通过限定回答的范围和长度,可以引导AI生成更精炼的回答
提问案例:前端简历
Prompt 优化前:
Prompt1:
前端简历
Prompt 优化后:
Prompt1:
背景:你是一名【具有三年开发经验】的前端开发工程师,这三年期间,前两年,你做的【金融】相关四个项目,最后一年做的是【医疗】相关领域的两个项目,且有一定的管理 10人+ 团队的经验。主要的技术栈:【Vue】 和 【小程序】。由于你是计算机软件工程专业,所以你具备一些Java后台、Mysql数据库的知识,也掌握一些基础的算法。
问题:你会如何编写你的简历个人技能介绍
要求:8条技能介绍,请注意:你不会 angular。
Prompt2:
基于上文情境,你会如何编写你的项目经验介绍
Prompt3:
你刚才说的方向完全没有问题,但是我想看到更多的项目技术亮点,项目业务解决方案。
请注意:每个项目3个技术亮点,3个业务解决方案。
工具 Github Copilot 代码实战 - 智能生成代码的使用
背景:
- 类似的平替工具链/代码补全工具还包括:vscode中的IntelliCode插件/Tabnine插件,等等
- Github Copilot 是 Github(微软)出品的,人工智能代码生成插件,它会根据你编写代码的上下文,自动生成你后面想要编写的代码。
- 你可以只写一个函数名,它就会根据算法,预测函数的功能,并自动生成对应的代码。
- 或者,你可以写一段注释,用自然语言描述一下代码的逻辑,它就会根据描述,生成对应的代码,不得不说是摸鱼神器。
- Github Copilot 的优势在于,它基于 Github 庞大的代码库,作为机器学习的训练集,这是任何平台都不具有的特色,这样齐全的代码库,势必会对人工智能的精确性带来更大的精确性,让生成的代码根本无需进行改动。
- Copilot 的含义是(飞机)副驾驶员,使用它作为产品名的原因是,Github 想让程序员在写代码时,有一个得力助手,帮忙把一些枯燥、重复的代码完成,而我们只需要关注核心的逻辑。更有意思的是,Github 的宣传之一是,它可以让开发者不再孤单
Github Copilot支持功能:
- 根据上下文生成实际代码,会自动分析代码的上下文风格、习惯、命名规范写法等,综合借鉴并生成代码
- 写一个函数名,他会预测函数的功能,并自动生成对应的代码
- 根据注释生成代码,写一段注释,用自然语言描述一下代码的逻辑,它就会根据描述,生成对应的代码
安装步骤
- 登录 github,打开 Copilot 试用
- 打开 vscode,搜索并安装插件 Copilot,关联GitHub账号,绑定
使用场景:
- 在写注释时,触发自动注释补全
- 在写逻辑业务代码时,触发代码补全
使用说明
- 删除键(此处指退格键):不接受
- Tab键:接受补全
- Ctrl + RightArrow右箭头:接收Word补全
- Ctrl + enter: 查看更多方案
个人中心页面项目实战 ChatGPT & Github Copilot/其他平替
- 静态结构 + 校验处理
- chatgpt prompt 提示词参考:
请基于 elementPlus 和 Vue3 的语法,生成组件代码
要求:
一、表单结构要求
1. 组件中包含一个el-form表单,有四行内容,前三行是输入框,第四行是按钮
2. 第一行 label 登录名称,输入框禁用不可输入状态
3. 第二行 label 用户昵称,输入框可输入
4. 第三行 label 用户邮箱,输入框可输入
5. 第四行按钮,提交修改
二、校验需求
给昵称 和 邮箱添加校验
1. 昵称 nickname 必须是2-10位的非空字符串
2. 邮箱 email 符合邮箱格式即可,且不能为空
<script setup>
import { userUpdateInfoService } from '@/api/user'
import PageContainer from '@/components/PageContainer.vue'
import { useUserStore } from '@/stores'
import { ref } from 'vue'
const useStore = useUserStore()
const {user:{email,id,nickname,username},getUser} = useUserStore()
const formRef = ref();
const form = ref({
username,
nickname,
email,
id
});
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', 'change'] }
]
};
const submitForm = async () => {
const valid = await formRef.value.validate()
if (valid) {
await userUpdateInfoService(userInfo.value)
await getUser()
ElMessage.success('修改成功')
}
};
</script>
<template>
<page-container title="基本资料">
<!-- 默认插槽 template #default,可删掉这一对标签对-->
<template #default>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px" class="form-container">
<!-- 登录名称(禁用) -->
<el-form-item label="登录名称" prop="username">
<el-input v-model="form.username" :disabled="true" />
</el-form-item>
<!-- 用户昵称 -->
<el-form-item label="用户昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="请输入昵称" />
</el-form-item>
<!-- 用户邮箱 -->
<el-form-item label="用户邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<!-- 提交按钮 -->
<el-form-item>
<el-button type="primary" @click="submitForm">提交修改</el-button>
</el-form-item>
</el-form>
</template>
</page-container>
</template>
<style lang="scss" scoped>
</style>
- 封装接口,更新个人信息
import request from '@/utils/request'
// 注册接口
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
// 登录接口
export const userLoginService = ({ username, password }) =>
request.post('/api/login', { username, password })
// 获取用户基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
// 更新用户基本信息
export const userUpdateInfoService = ({ id, nickname, email }) =>
request.put('/my/userinfo', { id, nickname, email })
// 更新用户头像
export const userUpdateAvatarService = (avatar) =>
request.patch('/my/update/avatar', { avatar })
<script setup>
import { userUpdateInfoService } from '@/api/user'
import PageContainer from '@/components/PageContainer.vue'
import { useUserStore } from '@/stores'
import { ref } from 'vue'
const useStore = useUserStore()
const {user:{email,id,nickname,username},getUser} = useUserStore()
const formRef = ref();
const form = ref({
username,
nickname,
email,
id
});
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', 'change'] }
]
};
const submitForm = async () => {
const valid = await formRef.value.validate()
if (valid) {
await userUpdateInfoService(userInfo.value)
await getUser()
ElMessage.success('修改成功')
}
};
</script>
<template>
<page-container title="基本资料">
<!-- 默认插槽 template #default,可删掉这一对标签对-->
<template #default>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px" class="form-container">
<!-- 登录名称(禁用) -->
<el-form-item label="登录名称" prop="username">
<el-input v-model="form.username" :disabled="true" />
</el-form-item>
<!-- 用户昵称 -->
<el-form-item label="用户昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="请输入昵称" />
</el-form-item>
<!-- 用户邮箱 -->
<el-form-item label="用户邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<!-- 提交按钮 -->
<el-form-item>
<el-button type="primary" @click="submitForm">提交修改</el-button>
</el-form-item>
</el-form>
</template>
</page-container>
</template>
<style lang="scss" scoped>
</style>
- 更换头像,包括静态结构+图片预览+上传接口
- 通过uploadRef.$el找dom元素,再找querySelector('input').监听click()
<script setup>
import { Plus, Upload } from '@element-plus/icons-vue'
import { ref } from 'vue'
// 基于store的数据,初始化imgurl的初始值
import { useUserStore } from '@/stores'
// 上传接口调用
import { userUpdateAvatarService } from '@/api/user'
const userStore = useUserStore()
const imgUrl = ref(userStore.user.user_pic)
const uploadRef = ref()
// 回显
const onSelectFile = (uploadFile) => {
// 基于 FileReader 读取图片做预览,得到base64格式的图片
const reader = new FileReader()
reader.readAsDataURL(uploadFile.raw)
reader.onload = () => {
imgUrl.value = reader.result
}
}
const onUpdateAvatar = async () => {
// 发送请求更新头像
await userUpdateAvatarService(imgUrl.value)
// userStore 重新渲染
await userStore.getUser()
// 提示用户
ElMessage({ type: 'success', message: '更换头像成功' })
}
</script>
<template>
<page-container title="更换头像">
<el-upload
ref="uploadRef"
class="avatar-uploader"
:auto-upload="false"
:show-file-list="false"
:on-change="onSelectFile"
>
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<img v-else src="@/assets/avatar.jpg" width="278" />
</el-upload>
<br />
<!-- 通过uploadRef.$el找dom元素,再找querySelector('input').监听click() -->
<el-button @click="uploadRef.$el.querySelector('input').click()" type="primary" :icon="Plus" size="large">
选择图片
</el-button>
<el-button @click="onUpdateAvatar" type="success" :icon="Upload" size="large">
上传头像
</el-button>
</page-container>
</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>
- 重置密码
- chatgpt prompt 提示词参考:
请基于 elementPlus 和 Vue3 的语法,生成组件代码
要求:
一、表单结构要求
1. 组件中包含一个el-form表单,有四行内容,前三行是表单输入框,第四行是两个按钮
2. 第一行 label 原密码
3. 第二行 label 新密码
4. 第三行 label 确认密码
5. 第四行两个按钮,修改密码 和 重置
二、form绑定字段如下:
const pwdForm = ref({
old_pwd: '',
new_pwd: '',
re_pwd: ''
})
三、校验需求
所有字段,都是 6-15位 非空
自定义校验1:原密码 和 新密码不能一样
自定义校验2:新密码 和 确认密码必须一样
- 注意:
- 需要检查GPT生成的代码,将变量声明类的语句放到前面/上面/头部,避免调试报错说找不到变量;将变量声明类的语句,应该放到变量的使用逻辑前
- 导入组件时忘记导入写法,可通过写注释(如
//导入路由
),唤出Github Copilot联想辅助
- 导入组件时忘记导入写法,可通过写注释(如
<script setup>
import { ref } from 'vue'
const formRef = ref(null);
// 表单数据
const pwdForm = ref({
old_pwd: '',
new_pwd: '',
re_pwd: ''
});
// 自定义校验1:原密码和新密码不能一样
const validateNewPwd = (rule, value, callback) => {
if (value === pwdForm.value.old_pwd) {
callback(new Error('新密码不能与原密码相同'));
} else {
callback();
}
};
// 自定义校验2:新密码和确认密码必须一致
const validateRePwd = (rule, value, callback) => {
if (value !== pwdForm.value.new_pwd) {
callback(new Error('确认密码与新密码不一致'));
} else {
callback();
}
};
// 提交表单
const submitForm = () => {
formRef.value.validate((valid) => {
if (valid) {
alert('密码修改成功');
// 这里可以添加密码修改的提交逻辑
} else {
console.log('表单验证失败');
return false;
}
});
};
// 重置表单
const resetForm = () => {
formRef.value.resetFields();
};
// 校验规则
const rules = {
old_pwd: [
{ required: true, message: '原密码不能为空', trigger: 'blur' },
{ min: 6, max: 15, message: '原密码长度必须在 6 到 15 个字符之间', trigger: 'blur' }
],
new_pwd: [
{ required: true, message: '新密码不能为空', trigger: 'blur' },
{ min: 6, max: 15, message: '新密码长度必须在 6 到 15 个字符之间', trigger: 'blur' },
// 自定义校验:原密码和新密码不能一样
{ validator: validateNewPwd, trigger: 'blur' }
],
re_pwd: [
{ required: true, message: '确认密码不能为空', trigger: 'blur' },
{ min: 6, max: 15, message: '确认密码长度必须在 6 到 15 个字符之间', trigger: 'blur' },
// 自定义校验:新密码和确认密码必须一致
{ validator: validateRePwd, trigger: 'blur' }
]
};
</script>
<template>
<page-container title="修改密码">
<template #default>
<el-form :model="pwdForm" :rules="rules" ref="formRef" label-width="100px" class="form-container">
<!-- 原密码 -->
<el-form-item label="原密码" prop="old_pwd">
<el-input v-model="pwdForm.old_pwd" type="password" placeholder="请输入原密码" />
</el-form-item>
<!-- 新密码 -->
<el-form-item label="新密码" prop="new_pwd">
<el-input v-model="pwdForm.new_pwd" type="password" placeholder="请输入新密码" />
</el-form-item>
<!-- 确认密码 -->
<el-form-item label="确认密码" prop="re_pwd">
<el-input v-model="pwdForm.re_pwd" type="password" placeholder="请输入确认密码" />
</el-form-item>
<!-- 按钮组 -->
<el-form-item>
<el-button type="primary" @click="submitForm">修改密码</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</template>
</page-container>
</template>
<style scoped>
.form-container {
width: 400px;
margin: 0 auto;
}
.el-form-item {
margin-bottom: 20px;
}
.el-button {
margin-right: 10px;
}
</style>
import request from '@/utils/request'
// 注册接口
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
// 登录接口
export const userLoginService = ({ username, password }) =>
request.post('/api/login', { username, password })
// 获取用户基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
// 更新用户基本信息
export const userUpdateInfoService = ({ id, nickname, email }) =>
request.put('/my/userinfo', { id, nickname, email })
// 更新用户头像
export const userUpdateAvatarService = (avatar) =>
request.patch('/my/update/avatar', { avatar })
// 更新用户密码
export const userUpdatePasswordService = ({ old_pwd, new_pwd, re_pwd }) =>
request.patch('/my/updatepwd', { old_pwd, new_pwd, re_pwd })
<script setup>
import { userUpdatePasswordService } from '@/api/user'
import { useUserStore } from '@/stores'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const formRef = ref();
const router = useRouter()
const userStore = useUserStore()
// 表单数据
const pwdForm = ref({
old_pwd: '',
new_pwd: '',
re_pwd: ''
});
// 自定义校验1:原密码和新密码不能一样
const validateNewPwd = (rule, value, callback) => {
if (value === pwdForm.value.old_pwd) {
callback(new Error('新密码不能与原密码相同'));
} else {
callback();
}
};
// 自定义校验2:新密码和确认密码必须一致
const validateRePwd = (rule, value, callback) => {
if (value !== pwdForm.value.new_pwd) {
callback(new Error('确认密码与新密码不一致'));
} else {
callback();
}
};
// 提交表单
const submitForm = async () => {
const valid = await formRef.value.validate()
if (valid) {
// 这里可以添加密码修改的提交逻辑
await userUpdatePasswordService(pwdForm.value)
ElMessage({ type: 'success', message: '更换密码成功' })
// 密码修改成功后,退出重新登录
// 清空本地存储的 token 和 个人信息
userStore.setToken('')
userStore.setUser({})
// 拦截登录
router.push('/login')
} else {
console.log('表单验证失败');
return false;
}
// 另一种写法
// await formRef.value.validate()
// await userUpdatePasswordService(pwdForm.value)
// ElMessage.success('密码修改成功')
// 密码修改成功后,退出重新登录
// 清空本地存储的 token 和 个人信息
// userStore.setToken('')
// userStore.setUser({})
// 拦截登录
// router.push('/login')
};
// 重置表单
const resetForm = () => {
formRef.value.resetFields();
};
// 校验规则
const rules = {
old_pwd: [
{ required: true, message: '原密码不能为空', trigger: 'blur' },
{ pattern: /^\S{6,15}$/, message: '原密码长度必须在 6 到 15 个字符之间', trigger: 'blur' }
],
new_pwd: [
{ required: true, message: '新密码不能为空', trigger: 'blur' },
{ pattern: /^\S{6,15}$/, message: '新密码长度必须在 6 到 15 个字符之间', trigger: 'blur' },
// 自定义校验:原密码和新密码不能一样
{ validator: validateNewPwd, trigger: 'blur' }
],
re_pwd: [
{ required: true, message: '确认密码不能为空', trigger: 'blur' },
{ pattern: /^\S{6,15}$/, message: '确认密码长度必须在 6 到 15 个字符之间', trigger: 'blur' },
// 自定义校验:新密码和确认密码必须一致
{ validator: validateRePwd, trigger: 'blur' }
]
};
</script>
<template>
<page-container title="修改密码">
<template #default>
<el-form :model="pwdForm" :rules="rules" ref="formRef" label-width="100px" class="form-container">
<!-- 原密码 -->
<el-form-item label="原密码" prop="old_pwd">
<el-input v-model="pwdForm.old_pwd" type="password" placeholder="请输入原密码" />
</el-form-item>
<!-- 新密码 -->
<el-form-item label="新密码" prop="new_pwd">
<el-input v-model="pwdForm.new_pwd" type="password" placeholder="请输入新密码" />
</el-form-item>
<!-- 确认密码 -->
<el-form-item label="确认密码" prop="re_pwd">
<el-input v-model="pwdForm.re_pwd" type="password" placeholder="请输入确认密码" />
</el-form-item>
<!-- 按钮组 -->
<el-form-item>
<el-button type="primary" @click="submitForm">修改密码</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</template>
</page-container>
</template>
<style scoped>
.form-container {
width: 400px;
margin: 0 auto;
}
.el-form-item {
margin-bottom: 20px;
}
.el-button {
margin-right: 10px;
}
</style>