一、Vue3的介绍
1、Vue3的优势
1. 更容易维护
- 组合式API:分散式维护转为集中式维护,更易封装复用
- 更好的TypeScript支持
2. 更快的速度
3. 更小的体积
4. 更优的数据响应式
2、create-vue
- create-vue是Vue官方新的脚手架工具,底层切换到了 vite(下一代构建工具),为开发提供极速响应
1. 创建Vue应用
npm init vue@latest
2.项目目录和关键文件
- vite.config.js - 项目的配置文件 基于vite的配置
- package.json - 项目包文件 核心依赖项变成了 Vue3.x 和 vite
- main.js - 入口文件 createApp函数创建应用实例
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
- app.vue - 根组件 SFC单文件组件 script - template - style
- 变化一:脚本script和模板template顺序调整
- 变化二:模板template不再要求唯一根元素
- 变化三:脚本script添加setup标识支持组合式API
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import TheWelcome from './components/TheWelcome.vue'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
</div>
</header>
<main>
<TheWelcome />
</main>
</template>
<style scoped>
</style>
- index.html - 单页入口 提供id为app的挂载点
二、组合式API
1、setup选项
1. setup 函数
- 执行时机,比beforeCreate早
- 无法获取this
- 数据和函数需要 return, 才能在模版中使用
<script>
export default {
setup () {
console.log('setup函数', this);
const message = 'hello world';
const logMessage = () => {
console.log(message);
}
return {
message,
logMessage
}
},
beforeCreate() {
console.log('beforeCreate函数', this);
},
}
</script>
<template>
<div class="wrapper">{{ message }}</div>
<button @click="logMessage">按钮</button>
</template>
<style scoped>
</style>
2. setup语法糖
<script setup>
const message = 'this is a message';
const logMessage = () => {
console.log(message);
}
</script>
<template>
<div class="wrapper">{{ message }}</div>
<button @click="logMessage">按钮</button>
</template>
<style scoped>
</style>
2、响应式对象
1. reactive函数
- 作用:接受对象类型数据的参数传入并返回一个响应式的对象
<script setup>
// 从 vue 包中导入 reactive 函数
import { reactive } from 'vue';
const state = reactive({ count: 100})
const setCount = () => {
state.count++
}
</script>
<template>
<div class="wrapper">{{ state.count }}</div>
<button @click="setCount">+1</button>
</template>
<style scoped>
</style>
2. ref函数
- 作用:接收简单类型或者对象类型的数据传入并返回一个响应式的对象
- 本质:将传入数据的基础上又包了一层对象
- 脚本中访问需要通过 .value;template访问不需要 .value
<script setup>
// 从 vue 包中导入 ref 函数
import { ref } from 'vue';
const count = ref(0)
const setCount = () => {
count.value++
}
const state = ref({ count: 100})
const setStateCount = () => {
state.value.count++
}
</script>
<template>
<div class="wrapper">{{ count }}</div>
<button @click="setCount">+1</button>
<div class="wrapper">{{ state.count }}</div>
<button @click="setStateCount">+1</button>
</template>
<style scoped>
</style>
3、computed
- 执行函数 在回调参数中return基于响应式数据做计算的值,用变量接收
- 计算属性中不应该有:异步请求/修改dom
- 避免直接修改计算属性的值
<script setup>
import { ref, computed } from 'vue';
// 声明数据
const list = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// 计算属性
const computedList = computed(() => {
return list.value.filter(item => item % 2 === 0)
})
const changeList = () => {
list.value.push(list.value.length)
}
</script>
<template>
<div class="wrapper">{{ list }}</div>
<div class="wrapper">{{ computedList }}</div>
<button type="button" @click="changeList">修改数组</button>
</template>
<style scoped>
</style>
4、watch
- 作用: 侦听一个或者多个数据的变化,数据变化时执行回调函数
1. 监视单个数据
<script setup>
import { ref, watch } from 'vue';
// 声明数据
const count = ref(0)
// 监视单个数据的变化
watch(count, (newVal, oldVal) => {
console.log(`count的值发生变化,由${oldVal}变为${newVal}`)
})
const change = () => {
count.value++
}
</script>
<template>
<div class="wrapper">{{ count }}</div>
<button type="button" @click="change">修改数据</button>
</template>
<style scoped>
</style>
2. 监视多个数据
<script setup>
import { ref, watch } from 'vue';
// 声明数据
const count = ref(0)
const nikeName = ref('erer')
// 监视多个数据的变化
watch([count, nikeName], ([newCount, newNikeName],[oldCount, oldNikeName]) => {
console.log(`count的值发生变化,由${oldCount}变为${newCount}`)
console.log(`nikeName的值发生变化,由${oldNikeName}变为${newNikeName}`)
})
watch([count, nikeName], (newArray,oldArray) => {
console.log(`count的值发生变化,由${oldArray[0]}变为${newArray[0]}`)
console.log(`nikeName的值发生变化,由${oldArray[1]}变为${newArray[1]}`)
})
const change = () => {
count.value++
nikeName.value = nikeName.value === '尔尔'? 'erer' : '尔尔'
}
</script>
<template>
<div class="wrapper">{{ count }}</div>
<div class="wrapper">{{ nikeName }}</div>
<button type="button" @click="change">修改数据</button>
</template>
<style scoped>
</style>
3. immediate
- 说明:在侦听器创建时立即触发回调, 响应式数据变化之后继续执行回调
<script setup>
import { ref, watch } from 'vue';
// 声明数据
const count = ref(0)
// immediate
watch(count, (newCount, oldCount) => {
console.log(`count的值发生变化,由${oldCount}变为${newCount}`)
}, {
immediate: true
})
const change = () => {
count.value++
}
</script>
<template>
<div class="wrapper">{{ count }}</div>
<button type="button" @click="change">修改数据</button>
</template>
<style scoped>
</style>
4. deep
- 默认机制:通过watch监听的ref对象默认是浅层侦听的,直接修改嵌套的对象属性不会触发回调执行,需要开启deep 选项
<script setup>
import { ref, watch } from 'vue';
// 声明数据
const user = ref({name: 'erer', age: 18})
// deep
watch(user, (newUser) => {
console.log(`user的name发生变化,变为${newUser['name']}`)
console.log(`user的age发生变化,变为${newUser['age']}`)
}, {
deep: true
})
const changeUser = () => {
user.value.name = user.value.name === 'erer'? '尔尔' : 'erer'
user.value.age++
}
</script>
<template>
<div class="wrapper">{{ user }}</div>
<button type="button" @click="changeUser">修改数据</button>
</template>
<style scoped>
</style>
5. 监视对象中的属性
<script setup>
import { ref, watch } from 'vue';
// 声明数据
const count = ref(0)
const nikeName = ref('erer')
// 监视对象中的属性
watch(() => user.value.age, (newVal, oldVal) => {
console.log(`user的age发生变化,由${oldVal}变为${newVal}`, 36)
})
const changeUser = () => {
user.value.name = user.value.name === 'erer'? '尔尔' : 'erer'
user.value.age++
}
</script>
<template>
<div class="wrapper">{{ user }}</div>
<button type="button" @click="changeUser">修改数据</button>
</template>
<style scoped>
</style>
5、生命周期
1. Vue3的生命周期API
选项式API | 组合式API | 调用时间 |
---|
beforeCreate/created | setup | 组件实例创建之前被调用 |
beforeMount | onBeforeMount | 在挂载开始之前被调用 |
mounted | onMounted | 在挂载完成之后被调用 |
beforeUpdate | onBeforeUpdate | 在更新开始之前被调用 |
updated | onUpdated | 在更新完成之后被调用 |
beforeUnmount | onBeforeUnmount | 在卸载组件之前被调用 |
unmounted | onUnmounted | 在卸载组件之后被调用 |
2. 生命周期函数基本使用
- 生命周期函数是可以执行多次的,多次执行时传入的回调会在时机成熟时依次执行
<script setup>
// 导入生命周期函数
import { onMounted } from 'vue';
// 声明函数
const getList = () => {
console.log('getList');
};
// beforeCreate/created 相关的代码
getList();
// 执行生命周期函数 传入回调
onMounted(() => {
console.log('onMounted生命周期函数 - 逻辑1');
});
onMounted(() => {
console.log('onMounted生命周期函数 - 逻辑2');
});
</script>
<template>
<div class="wrapper"> </div>
</template>
<style scoped>
</style>
6、父子通信
1. 父组件中绑定属性
<script setup>
import { ref } from 'vue';
// 引入子组件
import SonCom from '@/components/son-com.vue'
// 声明数据
const msg = ref('hello')
const money = ref(100)
// 声明方法
const getMoney = () => {
money.value += 10
}
</script>
<template>
<div>
<h3>
父组件: {{ msg }}
<input v-model="msg">
<button @click="getMoney">挣钱</button>
</h3>
<!-- 父组件中给子组件绑定属性 -->
<son-com car="BYD" :msg1="msg" :money="money"></son-com>
</div>
</template>
<style scoped>
</style>
2. 子组件通过props接收
<script setup>
// 子组件:通过 defineProps "编译器宏" 接收子组件传递的数据
const props = defineProps({
car: String,
msg1: String,
money: Number
})
// 通过 props.属性名 获取数据
console.log(props.car)
</script>
<template>
<!-- 模版中可以直接使用 -->
<div class="son"> 子组件静态对象:{{ car }}</div>
<div class="son"> 子组件动态对象:{{ msg1 }}</div>
<div class="son"> 子组件动态对象:{{ money }}</div>
</template>
<style scoped>
.son {
border: 1px solid red;
padding: 30px;
}
</style>
7、子传父
1. 父组件通过@绑定事件
<script setup>
import { ref } from 'vue';
// 引入子组件
import SonCom from '@/components/son-com.vue'
// 声明数据
const money = ref(100)
// 声明方法
const changeFn = (newMoney) => {
money.value = newMoney
}
</script>
<template>
<div>
<h3>父组件:{{ money }}</h3>
<!-- 绑定自定义事件 -->
<son-com @changeMoney="changeFn"></son-com>
</div>
</template>
<style scoped>
</style>
2. 子组件通过 emit 触发事件
<script setup>
// 子组件:通过 defineProps "编译器宏" 接收子组件传递的数据
const props = defineProps({
money: Number
})
// 通过 defineEmits 编译器宏生成 emit 方法
const emit = defineEmits(['changeMoney'])
// 触发自定义事件,并传递参数
const buy = () => {
emit('changeMoney', 5 )
}
</script>
<template>
<div class="son"> 子组件动态对象:{{ money }}</div>
<button @click="buy">花钱</button>
</template>
<style scoped>
.son {
border: 1px solid red;
padding: 30px;
}
</style>
8、模版引用
- 通过ref标识获取真实的dom对象或者组件实例对象
1. 获取 DOM
<script setup>
import TestCom from '@/components/test-com.vue'
import { ref, onMounted } from 'vue';
// 调用ref函数创建ref对象
const inp = ref(null);
// DOM渲染完成后,通过 ref对象.value 访问DOM元素
onMounted(() => {
inp.value.focus();
});
</script>
<template>
<div>
<!-- 通过 ref 标识进行绑定 -->
<input ref="inp" type="text">
</div>
</template>
2. 获取组件
- 子组件
- 默认情况下在
<script setup>
语法糖下组件内部的属性和方法是不开放给父组件访问的 - 通过 defineExpose 编译宏指定哪些属性和方法允许访问
<script setup>
import { ref } from 'vue';
const count = ref(100);
const add = () => {
count.value += 10 ;
};
// 通过defineExpose编译宏指定哪些属性和方法允许访问
defineExpose({
count,
add
});
</script>
<template>
<div>
我是用于测试的组件 - {{ count }}
</div>
</template>
<script setup>
import TestCom from '@/components/test-com.vue'
import { ref, onMounted } from 'vue';
// 调用ref函数创建ref对象
const testRef = ref(null);
// 通过 ref对象.value 访问DOM元素
const getCom = () => {
// 访问子组件数据
console.log(testRef.value.count)
// 调用子组件方法
testRef.value.add()
}
</script>
<template>
<div>
<test-com ref="testRef"></test-com>
<button @click="getCom">调用子组件方法</button>
</div>
</template>
9、provide&inject
- 顶层组件向任意的底层组件传递数据和方法,实现跨层组件通信
1. provide 函数提供数据
<script setup>
import CenterCom from '@/components/center-com.vue'
import { ref, provide } from 'vue'
// 跨层传递普通数据
provide('theme-color', 'pink')
// 跨层传递响应式数据
const count = ref(100)
provide('count', count)
// 跨层传递函数
provide('addCount', () => {
count.value++
})
provide('changeCount', (newValue) => {
count.value = newValue
})
</script>
<template>
<div class="app">
<h1>我是顶层组件: {{ count }}</h1>
<CenterCom/>
</div>
</template>
<style scoped>
.app {
border: 10px solid red;
padding: 30px;
}
</style>
2. inject 函数获取数据
<script setup>
import { inject } from 'vue';
// 跨层获取普通数据
const color = inject('theme-color');
// 跨层获取响应式数据
const count = inject('count');
// 跨层获取函数
const addFn = inject('addCount');
const changeFn = inject('changeCount');
const changeCount = () => {
changeFn(500)
}
</script>
<template>
<div class="bottom">
<h3>我是底层组件:{{ color }}</h3>
<h4>count: {{ count }}</h4>
<button @click="addFn">增加</button>
<button @click="changeCount">改变</button>
</div>
</template>
<style scoped>
.bottom {
border: 10px solid green;
padding: 30px;
}
</style>
三、新特性
1、defineOptions
- 背景说明:
- 有
<script setup>
之前,如果要定义 props, emits 可以轻而易举地添加一个与 setup 平级的属性。 - 但是用了
<script setup>
后,就没法这么干了 setup 属性已经没有了,自然无法添加与其平级的属性。
- 为了解决这一问题:
- 引入了 defineProps 与 defineEmits 这两个宏,解决了 props 与 emits 这两个属性。
- 引入了 defineOptions 宏,用来定义 Options API 的选项,props, emits, expose, slots 除外(因为这些可以使用 defineXXX 来做到)
<!-- <script>
export default {
name: 'LoginIndex',
}
</script> -->
<script setup>
defineOptions({
name: 'LoginIndex',
// ...更多自定义属性
})
</script>
<template>
<div class="app">
我是登陆页
</div>
</template>
<style scoped>
</style>
2、defineModel
- 在Vue3中,自定义组件上使用v-model, 相当于传递一个modelValue属性,同时触发 update:modelValue 事件
<Child v-model="isVisible"></Child>
<!-- 相当于 -->
<Child :modelValue="isVisible" @update:modelValue="isVisible=$event"></Child>
- 我们需要先定义 props,再定义 emits 。其中有许多重复的代码。如果需要修改此值,还需要手动调用 emit 函数。
1. 父组件绑定
<script setup>
import MyInput from '@/components/my-input.vue'
import { ref } from 'vue'
const txt = ref('hello')
</script>
<template>
<div class="app">
<MyInput v-model="txt"></MyInput>
{{ txt }}
</div>
</template>
<style scoped>
</style>
2. 子组件
<script setup>
// 子组件:通过 defineProps "编译器宏" 接收子组件传递的数据
defineProps({
modelValue: String
})
// 通过 defineEmits 编译器宏生成 emit 方法
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input :value="modelValue" @input="e => emit('update:modelValue', e.target.value)" type="text"/>
</template>
<style scoped>
</style>
3. defineModel
<script setup>
import { defineModel } from 'vue';
const modelValue = defineModel()
</script>
<template>
<input :value="modelValue" @input="e => modelValue = e.target.value" type="text"/>
</template>
<style scoped>
</style>
4. vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
script: {
defineModel: true,
}
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
四、Pinia
1、安装 Pinia
pnpm i pinia-plugin-persistedstate
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
2、定义store
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export const useCounterStore = defineStore('counter', () => {
const count = ref(10)
const addCount = () => count.value++
const subCount = () => count.value--
const doubleCount = computed(() => count.value * 2)
return{
count,
doubleCount,
addCount,
subCount
}
})
3、组件使用store
<script setup>
import Son1Com from '@/components/Son1Com.vue';
import Son2Com from '@/components/Son2Com.vue';
import { useCounterStore } from '@/store/counter';
const counterStore = useCounterStore();
</script>
<template>
<div class="app">
<h3>App.vue根组件: - {{ counterStore.count }} - {{ counterStore.doubleCount }} </h3>
<Son1Com></Son1Com>
<Son2Com></Son2Com>
</div>
</template>
<style scoped>
</style>
- src/components/Son1Com.vue
<script setup>
import { useCounterStore } from '@/store/counter';
const counterStore = useCounterStore();
</script>
<template>
<div class="app">
我是 Son1.vue: - {{ counterStore.count }} -<button @click="counterStore.addCount">+</button>
</div>
</template>
<style scoped>
</style>
- src/components/Son2Com.vue
<script setup>
import { useCounterStore } from '@/store/counter';
const counterStore = useCounterStore();
</script>
<template>
<div class="app">
我是 Son2.vue: - {{ counterStore.count }} -<button @click="counterStore.subCount">-</button>
</div>
</template>
<style scoped>
</style>
4、action异步实现
import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";
export const useChannelStore = defineStore('channel', () => {
const channelList = ref([])
const getList = async () => {
const { data: { data } } = await axios.get('http://geek.itheima.net/v1_0/channels')
channelList.value = data.channels
console.log(channelList.value)
}
return{
channelList,
getList
}
})
<script setup>
import { useChannelStore } from '@/store/channel';
const channelStore = useChannelStore();
</script>
<template>
<div class="app">
<button @click="channelStore.getList">获取频道数据</button>
<ul>
<li v-for="item in channelStore.channelList" :key="item.id">
{{ item.name }}
</li>
</ul>
</div>
</template>
<style scoped>
</style>
5、storeToRefs
- store 是一个用 readtive 包装的对象,直接结构其数据会导致响应丢失
<script setup>
import { useCounterStore } from '@/store/counter';
const counterStore = useCounterStore();
// 响应丢失,视图不再更新
const { count } = counterStore;
// 方法直接解构
const { subCount } = counterStore;
</script>
<template>
<div class="app">
我是 Son2.vue: - {{ counterStore.count }} -<button @click="subCount">-</button><br>
解构对象: - {{ count }}
</div>
</template>
<style scoped>
</style>
<script setup>
import { useCounterStore } from '@/store/counter';
import { storeToRefs } from 'pinia';
const counterStore = useCounterStore();
// 保持响应数据
const { count } = storeToRefs(counterStore)
</script>
<template>
<div class="app">
我是 Son2.vue: - {{ counterStore.count }} -<button @click="counterStore.subCount">-</button><br>
解构对象: - {{ count }}
</div>
</template>
<style scoped>
</style>
6、持久化插件
1. 安装插件
npm i pinia-plugin-persistedstate
2. 插件挂载到 pinia 实例
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
createApp(App).use(pinia).mount('#app')
3. 开启持久化
import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";
export const useChannelStore = defineStore('channel', () => {
const channelList = ref([])
const getList = async () => {
const { data: { data } } = await axios.get('http://geek.itheima.net/v1_0/channels')
channelList.value = data.channels
console.log(channelList.value)
}
return{
channelList,
getList
}
},{
persist: true,
})
4. 自定义持久化属性
import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";
export const useChannelStore = defineStore('channel', () => {
const channelList = ref([])
const getList = async () => {
const { data: { data } } = await axios.get('http://geek.itheima.net/v1_0/channels')
channelList.value = data.channels
console.log(channelList.value)
}
return{
channelList,
getList
}
},{
persist: {
key: 'erer-channel',
paths: ['channelList']
}
})
五、项目案例
1、创建项目
1. 安装方式
npm install -g pnpm
2. 创建项目
pnpm create vue
npm | yarn | pnpm |
---|
npm install | yarn | pnpm install |
npm install axios | yarn add axios | pnpm add axios |
npm install axios -D | yarn add axios -D | pnpm add axios -D |
npm uninstall axios | yarn remove axios | pnpm remove axios |
npm run dev | yarn dev | pnpm dev |
3. VScode配置
{
"workbench.iconTheme": "vscode-icons",
"editor.tabSize": 4,
"emmet.triggerExpansionOnTab": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"editor.formatOnSave": false,
"git.enableSmartCommit": true,
"git.openRepositoryInParentFolders": "always",
"window.zoomLevel": 1
}
4. 代码规范
- Eslint 代码规范插件
- prettier 格式化代码插件
- .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'
},
// 自定义 ESlint 规则配置
rules: {
// 需要禁用 prettier 插件,同时关闭 format on save
// 安装 Eslint 插件
// prettier 格式化代码插件
'prettier/prettier': [
'warn',
{
singleQuote: true, // 单引号
semi: false, // 无分号
printWidth: 800, // 每行宽度至多80字符
trailingComma: 'none', // 不加对象|数组最后逗号
endOfLine: 'auto' // 换行符号不限制(win mac 不一致)
}
],
// Eslint 代码规范插件
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index'] // vue组件名称多单词组成(忽略index.vue)
}
],
'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验
// 💡 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
'no-undef': 'error'
},
// 定义全局变量
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
}
5. 代码检查
git init
- 初始化 husky 工具配置:https://typicode.github.io/husky/
pnpm dlx husky-init && pnpm install
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint
6. 暂存区 eslint 校验
pnpm i lint-staged -D
- package.json 配置 lint-staged 命令
{
"name": "vue3-big-event-admin",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"prepare": "husky install",
"lint-staged": "lint-staged"
},
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
2、路由配置
1. 新建页面
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 | 二级路由 |
2. 创建路由实例
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'
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') }
]
}
]
})
router.beforeEach((to) => {
const userStore = useUserStore()
if (!userStore.token && to.path !== '/login') return '/login'
return true
})
export default router
3. 路由入口
<script setup>
</script>
<template>
<div>
App
<router-view></router-view>
</div>
</template>
<style scoped></style>
- 二级路由:src/views/layout/LayoutContainer.vue
<template>
<div>
布局架子
<router-view></router-view>
</div>
</template>
4. 获取路由对象及参数
<script setup>
import { useRouter, useRoute } from 'vue-router'
// 获取路由对象 router
const router = useRouter()
// 获取路由参数 route
const route = useRoute()
const goList = () => {
router.push('/list')
console.log(route, router)
}
</script>
<template>
<div>App</div>
<button @click="$router.push('/home')">首页</button>
<button @click="goList">商品列表</button>
</template>
<style scoped></style>
5. 环境变量
- import.meta.env.BASE_URL: 路径前缀 vite.config.js 中 base 配置项,默认是 /
- 环境变量地址: https://cn.vitejs.dev/guide/env-and-mode.html
3、Element Plus
1. 安装
pnpm install element-plus
pnpm add -D unplugin-vue-components unplugin-auto-import
pnpm i @element-plus/icons-vue
2. 配置按需导入
- vite.config.js
- 完成配置后:默认 components 下的文件也会被 自动注册
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
base: '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
3. 基本语法
- el-row:表示一行,一行分为24份
- el-col:表示一列
- :span=“12” 表示占一行的12份
- :offset=“3” 表示一行中左侧margin-left占一行的3份
4. 表单校验
- el-form => :model = “ruleForm” 绑定的整个form的数据对象 { xxx, yyy, zzz }
- el-form => :rules = “rules” 绑定的整个rules的规则对象 { xxx:[校验规则], yyy:[校验规则], zzz:[校验规则] }
- 表单元素 => v-model = “ruleForm.xxx” 给表单元素绑定form的子属性
- el-form-item => prop = “xxx” 配置生效的是那个规则
<script setup>
import { User, Lock } from '@element-plus/icons-vue'
import { ref } from 'vue'
import { userRegisterService } from '@/api/user.js'
const isRegister = ref(true)
// 用于提交的 form 对象
const formModel = ref({
username: '',
password: '',
repassword: ''
})
// 表单校验规则
const rules = {
username: [
// 非空校验
{ required: true, message: '请输入用户名', trigger: 'blur' },
// 长度校验
{ min: 5, max: 10, message: '长度在 5 到 10 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
// 正则校验 \S: 非空字符
{ pattern: /^\S{6,15}$/, message: '密码必须是 6-15 位的非空字符', trigger: 'blur' }
],
repassword: [
{ required: true, message: '请输入密码', trigger: 'blur' },
// 自定义校验 校验两次密码是否一致
{
// rule:当前校验规则相关信息
// value:当前校验的值
// callback:校验结束后执行的回调函数
validator: (rule, value, callback) => {
if (value !== formModel.value.password) {
// 校验失败
callback(new Error('两次输入的密码不一致'))
} else {
// 校验通过(必须写)
callback()
}
},
trigger: 'blur'
}
]
}
// 用于绑定组件实例(通过 ref="form" 进行绑定)
const form = ref()
const register = async () => {
// 注册之前,要先进行校验
await form.value.validate()
await userRegisterService(formModel.value)
ElMessage.success('注册成功')
isRegister.value = false
}
</script>
<template>
<el-row class="login-page">
<el-col :span="12" class="bg"></el-col>
<el-col :span="6" :offset="3" class="form">
<!-- 注册表单 -->
<el-form :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 class="button" type="primary" auto-insert-space @click="register"> 注册 </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-col>
</el-row>
</template>
<style lang="scss" scoped>
</style>
5. 中英国际化处理
<script setup>
// 引入 element-plus 中文包
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<template>
<div>
<el-config-provider :locale="zhCn">
<router-view></router-view>
</el-config-provider>
</div>
</template>
<style scoped></style>
4、Pinia
1. 创建用户仓库
import { userGetInfoService } from '@/api/user'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore(
'user',
() => {
const token = ref('')
const setToken = (newToken) => {
token.value = newToken
}
const clearToken = () => {
token.value = ''
}
const user = ref({})
const getUser = async () => {
const res = await userGetInfoService()
user.value = res.data.data
}
const clearUser = () => {
user.value = {}
}
return {
token,
setToken,
clearToken,
user,
getUser,
clearUser
}
},
{
persist: true
}
)
2. 使用仓库数据
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
<template>
<div>
<p>{{ userStore.token }}</p>
<el-button type="primary" @click="userStore.setToken('easdfadwfdasf')">登陆</el-button>
<el-button type="danger" @click="userStore.removeToken">退出</el-button>
</div>
</template>
<style scoped></style>
3. 持久化数据
pnpm i pinia-plugin-persistedstate
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import '@/assets/main.scss'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const app = createApp(App)
app.use(createPinia().use(piniaPluginPersistedstate))
app.use(router)
app.mount('#app')
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore(
'user',
() => {
const token = ref('')
const setToken = (newToken) => {
token.value = newToken
}
const removeToken = () => {
token.value = ''
}
return {
token,
setToken,
removeToken
}
},
{
persist: true
}
)
4. Pinia 独立维护
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from '@/stores/index'
import '@/assets/main.scss'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
5. 仓库统一导出
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
export * from './modules/user'
<script setup>
import { useUserStore } from '@/stores'
const userStore = useUserStore()
</script>
<template>
<div>
<p>{{ userStore.token }}</p>
<el-button type="primary" @click="userStore.setToken('easdfadwfdasf')">登陆</el-button>
<el-button type="danger" @click="userStore.removeToken">退出</el-button>
</div>
</template>
<style scoped></style>
5、axios配置
1. 安装 axios
pnpm add axios
2. 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({
baseURL,
timeout: 10000
})
instance.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = userStore.token
}
return config
},
(err) => Promise.reject(err)
)
instance.interceptors.response.use(
(res) => {
if (res.data.code !== 0) {
ElMessage.error(res.data.message || '服务异常')
return Promise.reject(res.data)
}
return res
},
(err) => {
if (err.response?.status === 401) {
router.push('/login')
}
ElMessage.error(err.response.data.message || '服务异常')
return Promise.reject(err)
}
)
export default instance
export { baseURL }
6、登陆和注册
1. 封装接口
import request from '@/utils/request'
export const userRegisterService = ({ username, password, repassword }) => {
return request({
url: '/api/reg',
method: 'post',
data: {
username,
password,
repassword
}
})
}
export const userLoginService = ({ username, password }) => request.post('/api/login', { username, password })
2. 页面渲染
<script setup>
import { User, Lock } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { userRegisterService, userLoginService } from '@/api/user.js'
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'
const isRegister = ref(true)
// 用于提交的 form 对象
const formModel = ref({
username: '',
password: '',
repassword: ''
})
// 表单校验规则
const rules = {
username: [
// 非空校验
{ required: true, message: '请输入用户名', trigger: 'blur' },
// 长度校验
{ min: 5, max: 10, message: '长度在 5 到 10 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
// 正则校验 \S: 非空字符
{ pattern: /^\S{6,15}$/, message: '密码必须是 6-15 位的非空字符', trigger: 'blur' }
],
repassword: [
{ required: true, message: '请输入密码', trigger: 'blur' },
// 自定义校验 校验两次密码是否一致
{
// rule:当前校验规则相关信息
// value:当前校验的值
// callback:校验结束后执行的回调函数
validator: (rule, value, callback) => {
if (value !== formModel.value.password) {
// 校验失败
callback(new Error('两次输入的密码不一致'))
} else {
// 校验通过(必须写)
callback()
}
},
trigger: 'blur'
}
]
}
// 用于绑定组件实例(通过 ref="form" 进行绑定)
const form = ref()
const register = async () => {
// 注册之前,要先进行校验
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 ref = await userLoginService(formModel.value)
// 设置token
userStore.setToken(ref.data.token)
ElMessage.success('登录成功')
// 跳转到主页
router.push('/')
}
// 切换登录和注册的时候重置表单
watch(isRegister, () => {
formModel.value = {
username: '',
password: '',
repassword: ''
}
})
</script>
<template>
<!--
el-row:表示一行,一行分为24份
el-col:表示一列,
(1):span="12" 表示占一行的12份
(2):offset="3" 表示一行中左侧margin-left占一行的3份
-->
<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 class="button" type="primary" auto-insert-space @click="register"> 注册 </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 class="button" type="primary" auto-insert-space @click="login">登录</el-button>
</el-form-item>
<el-form-item class="flex">
<el-link type="info" :underline="false" @click="isRegister = true"> 注册 → </el-link>
</el-form-item>
</el-form>
</el-col>
</el-row>
</template>
<style lang="scss" scoped>
.login-page {
height: 100vh;
background-color: #fff;
.bg {
background:
url('@/assets/logo2.png') no-repeat 60% center / 240px auto,
url('@/assets/login_bg.jpg') no-repeat center / cover;
border-radius: 0 20px 20px 0;
}
.form {
display: flex;
flex-direction: column;
justify-content: center;
user-select: none;
.title {
margin: 0 auto;
}
.button {
width: 100%;
}
.flex {
width: 100%;
display: flex;
justify-content: space-between;
}
}
}
</style>
3. 登陆拦截
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'
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') }
]
}
]
})
router.beforeEach((to) => {
const userStore = useUserStore()
if (!userStore.token && to.path !== '/login') return '/login'
return true
})
export default router
7、用户登陆和退出
1. 封装接口
import request from '@/utils/request'
export const userGetInfoService = () => request.get('/my/userinfo')
2. 仓库持久化
import { userGetInfoService } from '@/api/user'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore(
'user',
() => {
const token = ref('')
const setToken = (newToken) => {
token.value = newToken
}
const clearToken = () => {
token.value = ''
}
const user = ref({})
const getUser = async () => {
const res = await userGetInfoService()
user.value = res.data.data
}
const clearUser = () => {
user.value = {}
}
return {
token,
setToken,
clearToken,
user,
getUser,
clearUser
}
},
{
persist: true
}
)
3. 页面渲染
<script setup>
import { Management, Promotion, UserFilled, User, Crop, EditPen, SwitchButton, CaretBottom } from '@element-plus/icons-vue'
import avatar from '@/assets/default.png'
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'
// 获取用户信息
const userStore = useUserStore()
// 调用方法将用户信息缓存到 store 中
userStore.getUser()
// 通过点击按钮跳转到指定页面
const router = useRouter()
const handleCommand = (command) => {
if (command === 'logout') {
ElMessageBox.confirm('确定要退出登录吗?', '温馨提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
// 清除缓存中的用户信息
userStore.clearUser()
// 清除缓存中的 token 信息
userStore.clearToken()
// 跳转登录页
router.push('/login')
})
.catch(() => {
ElMessage({
message: '取消退出登录',
type: 'info'
})
})
} else {
router.push(`/user/${command}`)
}
}
</script>
<template>
<el-container class="layout-container">
<el-aside width="200px">
<div class="el-aside__logo"></div>
<!-- 菜单组件:active-text-color(激活颜色)、background-color(背景颜色)、default-active(配置默认高亮菜单) -->
<el-menu active-text-color="#ffd04b" background-color="#232323" :default-active="$route.path" text-color="#fff" router>
<!-- 配置访问的高亮路径:index(访问的跳转路径,配合default-active的值实现高亮) -->
<el-menu-item index="/article/channel">
<el-icon><Management /></el-icon>
<span>文章分类</span>
</el-menu-item>
<el-menu-item index="/article/manage">
<el-icon><Promotion /></el-icon>
<span>文章管理</span>
</el-menu-item>
<!-- 多级菜单 -->
<el-sub-menu index="/user">
<!-- 多级菜单的标题 - 具名插槽(#title) 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="userStore.user.user_pic || avatar" />
<el-icon><CaretBottom /></el-icon>
</span>
<!-- 折叠的下拉部分 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile" :icon="User">基本资料</el-dropdown-item>
<el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
<el-dropdown-item command="password" :icon="EditPen">重置密码</el-dropdown-item>
<el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</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>
</style>
8、组件
1. 页面布局组件
<script setup>
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>
2. 新增/修改弹窗组件
- src/views/article/components/ChannelEdit.vue
<script setup>
import { ref } from 'vue'
import { articleAddChannelService, articleEditChannelService } from '@/api/article'
const dialogVisible = ref(false)
// 表单数据
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 formRef = ref()
const submit = async () => {
await formRef.value.validate()
formModel.value.id ? await articleEditChannelService(formModel.value) : await articleAddChannelService(formModel.value)
ElMessage({
type: 'success',
message: formModel.value.id ? '编辑成功' : '添加成功'
})
dialogVisible.value = false
emit('success')
}
// 组件对外暴露一个方法 open,基于 open 传递参数判读是添加还是编辑
// open({}) => 添加:表单无需渲染
// open({id, cate_name, ...}) => 编辑:表单渲染
const open = (row) => {
dialogVisible.value = true
formModel.value = { ...row }
}
defineExpose({
open
})
</script>
<template>
<!-- 弹窗 -->
<el-dialog v-model="dialogVisible" :title="formModel.id ? '编辑分类' : '添加分类'" 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" @click="submit"> 确认 </el-button>
</span>
</template>
</el-dialog>
</template>
3. 文章分类组件
<script setup>
import { ref } from 'vue'
import { articleGetChannelsService } from '@/api/article'
defineProps({
modelValue: {
type: [Number, String]
},
width: {
type: String
}
})
const emit = defineEmits(['update:modelValue'])
// 文章分类
const channelList = ref([])
// 获取文章分类
const getChannelList = async () => {
const res = await articleGetChannelsService()
channelList.value = res.data.data
}
getChannelList()
</script>
<template>
<el-select :style="{ width }" :modelValue="modelValue" @update:modelValue="emit('update:modelValue', $event)">
<el-option v-for="channel in channelList" :label="channel.cate_name" :value="channel.id" :key="channel.id"></el-option>
</el-select>
</template>
<script setup>
import { ref } from 'vue'
import { articleGetChannelsService } from '@/api/article'
defineProps({
cateId: {
type: [Number, String]
}
})
const emit = defineEmits(['update:cateId'])
// 文章分类
const channelList = ref([])
// 获取文章分类
const getChannelList = async () => {
const res = await articleGetChannelsService()
channelList.value = res.data.data
}
getChannelList()
</script>
<template>
<el-select :modelValue="cateId" @update:modelValue="emit('update:cateId', $event)">
<el-option v-for="channel in channelList" :label="channel.cate_name" :value="channel.id" :key="channel.id"></el-option>
</el-select>
</template>
4. 文章详情组件
<script setup>
import ChannelSelect from './ChannelSelect.vue'
import axios from 'axios'
import { ref } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { articlePublishService, articleGetDetailService, articleEditService } from '@/api/article'
import { baseURL } from '@/utils/request'
// 富文本编辑器
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// 控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 默认数据
const defaultForm = {
title: '', // 标题
cate_id: '', // 分类
cover_img: '', // 封面图 file 对象
content: '', // 内容
state: '' // 状态
}
// 表单数据
const formModel = ref({ ...defaultForm })
// 图片上传逻辑
const imgUrl = ref('')
// 图片上传
const onSelectFile = (uploadFile) => {
// 实现图片预览
imgUrl.value = URL.createObjectURL(uploadFile.raw)
// 将图片存入对象
formModel.value.cover_img = uploadFile.raw
}
const formRef = ref()
// 表单校验规则
const rules = {
title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
cate_id: [{ required: true, message: '分类不能为空', trigger: 'blur' }],
cover_img: [{ required: true, message: '封面不能为空', trigger: 'change' }],
content: [{ required: true, message: '内容不能为空', trigger: 'change' }]
}
//
const emit = defineEmits(['success'])
// 提交文章
const onPublish = async (state) => {
formModel.value.state = state
// 校验表单
await formRef.value.validate()
// 将表单数据转换为 FormData
const formData = new FormData()
for (let key in formModel.value) {
formData.append(key, formModel.value[key])
}
if (formModel.value.id) {
// 编辑
formData.append('cate_name', formModel.value.cate_id)
await articleEditService(formData)
ElMessage.success('编辑成功')
visibleDrawer.value = false
emit('success', 'edit')
} else {
// 添加
await articlePublishService(formData)
ElMessage.success('发布成功')
visibleDrawer.value = false
emit('success', 'add')
}
}
// 组件对外暴露一个方法 open,基于 open 传递参数判读是添加还是编辑
// open({}) => 添加:表单无需渲染
// open({id, cate_name, ...}) => 编辑:表单渲染
const editorRef = ref()
const open = async (row) => {
visibleDrawer.value = true
if (row.id) {
// 编辑
const res = await articleGetDetailService(row.id)
formModel.value = res.data.data
// 图片回显
imgUrl.value = baseURL + formModel.value.cover_img
// 提交给后台是 file 对象,将网络图片地址 => file 对象
const file = await imgUrlToFileObject(imgUrl.value, formModel.value.cover_img)
formModel.value.cover_img = file
} else {
// 添加:基于默认数据重置表单数据
formModel.value = { ...defaultForm }
// 重置图片预览和富文本编辑器数据
imgUrl.value = ''
editorRef.value.setHTML('')
}
}
// 将网络图片地址 => file 对象
async function imgUrlToFileObject(imgUrl, fileName) {
try {
// 使用 Axios 下载图片
const response = await axios.get(imgUrl, { 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.log(error)
return null
}
}
defineExpose({
open
})
</script>
<template>
<el-drawer v-model="visibleDrawer" :title="formModel.id ? '编辑文章' : '添加文章'" direction="rtl" size="50%">
<!-- 发表文章表单 -->
<el-form :model="formModel" ref="formRef" :rules="rules" 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-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">
<quill-editor ref="editorRef" theme="snow" v-model:content="formModel.content" content-type="html"></quill-editor>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onPublish('已发布')">发布</el-button>
<el-button type="info" @click="onPublish('草稿')">草稿</el-button>
</el-form-item>
</el-form>
</el-drawer>
</template>
<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>
9、文章分类管理
1. 封装接口
import request from '@/utils/request'
export const articleGetChannelsService = () => request.get('/my/cate/list')
export const articleAddChannelService = (data) => request.post('/my/cate/add', data)
export const articleEditChannelService = (data) => request.put('/my/cate/info', data)
export const articleDeleteChannelService = (id) => request.delete('/my/cate/del', { params: { id } })
2. 页面渲染
- src/views/article/ArticleChannel.vue
<script setup>
import { ref } from 'vue'
import { articleGetChannelsService } from '@/api/article'
import { Edit, Delete } from '@element-plus/icons-vue'
import ChannelEdit from './components/ChannelEdit.vue'
import { articleDeleteChannelService } from '@/api/article'
import { ElMessageBox } from 'element-plus'
// 加载状态
const loading = ref(false)
// 文章分类
const channelList = ref([])
// 获取文章分类
const getChannelList = async () => {
loading.value = true
const res = await articleGetChannelsService()
channelList.value = res.data.data
loading.value = false
}
getChannelList()
// 添加文章分类
const dialog = ref()
const handleAddChannel = () => {
dialog.value.open({})
}
// 编辑文章分类
const handleEditChannel = (row) => {
dialog.value.open(row)
}
// 删除文章分类
const handleDeleteChannel = async (row) => {
await ElMessageBox.confirm('确定删除 ' + row.cate_name + ' 分类吗?', '温馨提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
articleDeleteChannelService(row.id).then(({ data }) => {
if (data.code === 0) {
ElMessage.success(data.message)
getChannelList()
}
})
})
}
// const handleDeleteChannel = (row, $index) => {
// console.log(row, $index)
// }
// 监听弹窗事件,完成新增/修改/删除后重新获取数据
const onSuccess = () => {
getChannelList()
}
</script>
<template>
<page-container title="文章分类" style="width: 100%">
<template #extra>
<el-button type="primary" @click="handleAddChannel">添加分类</el-button>
</template>
<el-table v-loading="loading" :data="channelList">
<el-table-column type="index" label="序号" width="100"></el-table-column>
<el-table-column prop="cate_name" label="分类名称"></el-table-column>
<el-table-column prop="cate_alias" label="分类别名"></el-table-column>
<el-table-column prop="status" label="操作" width="200">
<!-- row:channelList中对应行数据;$index:表格的索引 -->
<template #default="{ row }">
<el-button :icon="Edit" type="primary" @click="handleEditChannel(row)" circle plain=""></el-button>
<el-button :icon="Delete" type="danger" @click="handleDeleteChannel(row)" circle plain=""></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无数据"></el-empty>
</template>
</el-table>
</page-container>
<ChannelEdit ref="dialog" @success="onSuccess"></ChannelEdit>
</template>
<style lang="scss" scoped></style>
10、文章管理
1. 封装接口
import request from '@/utils/request'
export const articleGetChannelsService = () => request.get('/my/cate/list')
export const articleAddChannelService = (data) => request.post('/my/cate/add', data)
export const articleEditChannelService = (data) => request.put('/my/cate/info', data)
export const articleDeleteChannelService = (id) => request.delete('/my/cate/del', { params: { id } })
export const articleGetListService = (params) => request.get('/my/article/list', { params })
export const articlePublishService = (data) => request.post('/my/article/add', data)
export const articleGetDetailService = (id) => request.get('/my/article/info', { params: { id } })
export const articleEditService = (data) => request.put('/my/article/info', data)
export const articleDeleteService = (id) => request.delete('/my/article/info', { params: { id } })
2. 页面渲染
<script setup>
import ChannelSelect from './components/ChannelSelect.vue'
import ChannelSelectCustom from './components/ChannelSelectCustom.vue'
import ArticleEdit from './components/ArticleEdit.vue'
import { ref } from 'vue'
import { Edit, Delete } from '@element-plus/icons-vue'
import { articleGetListService, articleDeleteService } from '@/api/article'
import { formatTime } from '@/utils/format'
const loading = ref(false)
const cate_id = ref()
// 定义请求参数对象
const params = ref({
pagenum: 1,
pagesize: 5,
cate_id: '',
state: ''
})
// 文章列表
const articleList = ref([])
// 文章总条数
const total = ref(0)
// 基于 params 参数获取文章列表
const getArticleList = async () => {
loading.value = true
const res = await articleGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
loading.value = false
}
getArticleList()
// 处理分页逻辑:每页条数改变时触发
const handleSizeChange = (size) => {
params.value.pagenum = 1
params.value.pagesize = size
getArticleList()
}
// 处理分页逻辑:页码改变时触发
const handleCurrentChange = (page) => {
params.value.pagenum = page
getArticleList()
}
// 搜索逻辑
const onSearch = () => {
params.value.pagenum = 1
getArticleList()
}
// 重置逻辑
const onReset = () => {
params.value = {
pagenum: 1,
pagesize: 5,
cate_id: '',
state: ''
}
getArticleList()
}
// 定义编辑文章的 ref 对象
const articleEditRef = ref()
// 添加文章
const handelAddArticle = () => {
articleEditRef.value.open({})
}
// 编辑文章
const handelEditArticle = (row) => {
articleEditRef.value.open(row)
}
// 删除文章
const handelDeleteArticle = async (row) => {
if (!confirm('确定删除该文章吗?')) return
const req = await articleDeleteService(row.id)
ElMessage.success(req.data.message)
getArticleList()
}
// 监听弹窗事件,完成新增/修改/删除后重新获取数据
const onSuccess = (type) => {
if (type === 'add') {
params.value.pagenum = Math.ceil((total.value + 1) / params.value.pagesize)
}
getArticleList()
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary" @click="handelAddArticle">添加文章</el-button>
</template>
<!-- 表单区域 -->
<el-form inline>
<el-form-item label="文章分类">
<!-- vue2 => v-model :value 和 @input 的简写 -->
<!-- vue3 => v-model :modelValue 和 @update:modelValue 的简写 -->
<channel-select v-model="params.cate_id"></channel-select>
<!-- vue3 => v-model:cateId :cateId 和 @update:cateId 的简写 -->
<channel-select-custom v-model:cateId="cate_id"></channel-select-custom>
</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" @click="onSearch">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table :data="articleList" v-loading="loading">
<el-table-column prop="title" label="文章标题">
<template #default="{ row }">
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column prop="cate_name" label="分类"></el-table-column>
<el-table-column prop="pub_date" label="发表时间">
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column prop="state" label="状态"></el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" circle plain :icon="Edit" @click="handelEditArticle(row)"></el-button>
<el-button type="danger" circle plain :icon="Delete" @click="handelDeleteArticle(row)"></el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="params.pagenum"
v-model:page-size="params.pagesize"
:page-sizes="[2, 3, 5, 10]"
:background="true"
layout="jumper, total, sizes, prev, pager, next"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 添加编辑的抽屉 -->
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
11、个人中心
1. 封装接口
export const userGetInfoService = () => request.get('/my/userinfo')
export const userUpdateInfoService = ({ id, nickname, email }) => {
return request({
url: '/my/userinfo',
method: 'put',
data: {
id,
nickname,
email
}
})
}
export const userUploadAvatarService = (avatar) => request.patch('/my/update/avatar', { avatar })
export const userUpdatePassService = ({ old_pwd, new_pwd, re_pwd }) => request.patch('/my/updatepwd', { old_pwd, new_pwd, re_pwd })
2. 基本资料
<script setup>
import { useUserStore } from '@/stores'
import { ref } from 'vue'
import { userUpdateInfoService } from '@/api/user'
// 从 userStore 中获取用户信息并解构(只需初始值所以可以解构)
const {
user: { username, nickname, email, id },
getUser
} = useUserStore()
// 定义表单数据并初始化
const userInfo = ref({ username, nickname, email, id })
// 定义表单引用
const formRef = ref()
// 表单校验规则
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' }
]
}
const onSubmit = async () => {
const valid = await formRef.value.validate()
if (valid) {
await userUpdateInfoService(userInfo.value)
await getUser()
ElMessage.success('修改成功')
}
}
</script>
<template>
<page-container title="基本资料">
<el-row>
<el-col :span="12">
<el-form :model="userInfo" :rules="rules" ref="formRef" label-width="100px" size="large">
<el-form-item label="登录名称">
<el-input v-model="userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item label="用户昵称" prop="nickname">
<el-input v-model="userInfo.nickname"></el-input>
</el-form-item>
<el-form-item label="用户邮箱" prop="email">
<el-input v-model="userInfo.email"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">提交修改</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</page-container>
</template>
3. 更换头像
<script setup>
import { ref } from 'vue'
import { Plus, Upload } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores'
import { userUploadAvatarService } from '@/api/user'
const userStore = useUserStore()
const imgUrl = ref(userStore.user.user_pic)
const uploadRef = ref()
// 上传图片
const onUploadFile = (file) => {
const reader = new FileReader()
reader.readAsDataURL(file.raw)
reader.onload = () => {
imgUrl.value = reader.result
}
}
// 上传头像
const onUpdateAvatar = async () => {
await userUploadAvatarService(imgUrl.value)
await userStore.getUser()
ElMessage({ type: 'success', message: '更换头像成功' })
}
</script>
<template>
<page-container title="更换头像">
<el-row>
<el-col :span="12">
<el-upload ref="uploadRef" class="avatar-uploader" :auto-upload="false" :show-file-list="false" :on-change="onUploadFile">
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<img v-else src="@/assets/avatar.jpg" width="278" />
</el-upload>
<br />
<el-button @click="uploadRef.$el.querySelector('input').click()" type="primary" :icon="Plus" size="large">选择图片</el-button>
<el-button type="success" :icon="Upload" size="large" @click="onUpdateAvatar"> 上传头像 </el-button>
</el-col>
</el-row>
</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>
4. 修改密码
<script setup>
import { ref } from 'vue'
import { userUpdatePassService } from '@/api/user'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores'
// 表单数据
const pwdForm = ref({
old_pwd: '',
new_pwd: '',
re_pwd: ''
})
// 校验规则
const checkOldSame = (rule, value, cb) => {
if (value === pwdForm.value.old_pwd) {
cb(new Error('原密码和新密码不能一样!'))
} else {
cb()
}
}
const checkNewSame = (rule, value, cb) => {
if (value !== pwdForm.value.new_pwd) {
cb(new Error('新密码和确认再次输入的新密码不一样!'))
} else {
cb()
}
}
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: checkOldSame, trigger: 'blur' }
],
// 确认新密码
re_pwd: [
{ required: true, message: '请再次确认新密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码长度必须是6-15位的非空字符串',
trigger: 'blur'
},
{ validator: checkNewSame, trigger: 'blur' }
]
}
const formRef = ref()
const router = useRouter()
const userStore = useUserStore()
const onSubmit = async () => {
const valid = await formRef.value.validate()
if (valid) {
await userUpdatePassService(pwdForm.value)
ElMessage({ type: 'success', message: '更换密码成功' })
userStore.clearToken()
userStore.clearUser()
router.push('/login')
}
}
const onReset = () => {
formRef.value.resetFields()
}
</script>
<template>
<page-container title="重置密码">
<el-row>
<el-col :span="12">
<el-form :model="pwdForm" :rules="rules" ref="formRef" label-width="100px" size="large">
<el-form-item label="原密码" prop="old_pwd">
<el-input v-model="pwdForm.old_pwd" type="password" show-password></el-input>
</el-form-item>
<el-form-item label="新密码" prop="new_pwd">
<el-input v-model="pwdForm.new_pwd" type="password" show-password></el-input>
</el-form-item>
<el-form-item label="确认新密码" prop="re_pwd">
<el-input v-model="pwdForm.re_pwd" type="password" show-password></el-input>
</el-form-item>
<el-form-item>
<el-button @click="onSubmit" type="primary">修改密码</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</page-container>
</template>