Vue 3

一、Vue3的介绍

1、Vue3的优势

1. 更容易维护

  • 组合式API:分散式维护转为集中式维护,更易封装复用
  • 更好的TypeScript支持

2. 更快的速度

  • 重写diff算法
  • 模版编译优化
  • 更高效的组件初始化

3. 更小的体积

  • 良好的TreeShaking
  • 按需引入

4. 更优的数据响应式

  • Proxy

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'

// mount(视图) 挂载到 #app 节点上(id为app的盒子)
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/createdsetup组件实例创建之前被调用
beforeMountonBeforeMount在挂载开始之前被调用
mountedonMounted在挂载完成之后被调用
beforeUpdateonBeforeUpdate在更新开始之前被调用
updatedonUpdated在更新完成之后被调用
beforeUnmountonBeforeUnmount在卸载组件之前被调用
unmountedonUnmounted在卸载组件之后被调用

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

  • 开启 defineModel
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
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
  • main.js
import { createApp } from 'vue'
// 导包
import { createPinia } from 'pinia'
import App from './App.vue'

// 创建 pinia 实例
const pinia = createPinia()

// pinia 挂载到 #app 节点上(id为app的盒子)
// createApp(App).use(pinia).mount('#app')
const app = createApp(App)
app.use(pinia)  // pinia 插件的安装和配置
app.mount('#app') // 视图的挂载

2、定义store

  • @/store/counter.js
import { defineStore } from "pinia";
import { computed, ref } from "vue";

// 定义 store (counter:仓库唯一标识),并导出
export const useCounterStore = defineStore('counter', () => {
  // 定义 state 声明数据
  const count = ref(10)
  // action 声明操作数据的方法
  const addCount = () => count.value++
  const subCount = () => count.value--
  // getters 声明基于数据派生的计算属性
  const doubleCount = computed(() => count.value * 2)
  return{
    count,
    doubleCount,
    addCount,
    subCount
  }
})

3、组件使用store

  • @/App.vue
<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异步实现

  • src/store/channel.js
import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";

// 定义 store (channel:仓库唯一标识),并导出
export const useChannelStore = defineStore('channel', () => {
  // 定义 state 声明数据
  const channelList = ref([])
  // action 声明操作数据的方法
  const getList = async () => {
    // 支持异步
    const { data: { data } } = await axios.get('http://geek.itheima.net/v1_0/channels')
    channelList.value = data.channels
    console.log(channelList.value)
  }
  // getters 声明基于数据派生的计算属性

  return{
    channelList,
    getList
  }
})
  • src/App.vue
<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>
  • 使用 storeToRefs 解构响应式数据
<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'
// 导入 pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

import App from './App.vue'

// 创建 pinia 实例
const pinia = createPinia()
// 将插件挂载到 pinia 实例上
pinia.use(piniaPluginPersistedstate)

createApp(App).use(pinia).mount('#app')

3. 开启持久化

import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";

// 定义 store (channel:仓库唯一标识),并导出
export const useChannelStore = defineStore('channel', () => {
  // 定义 state 声明数据
  const channelList = ref([])
  // action 声明操作数据的方法
  const getList = async () => {
    // 支持异步
    const { data: { data } } = await axios.get('http://geek.itheima.net/v1_0/channels')
    channelList.value = data.channels
    console.log(channelList.value)
  }
  // getters 声明基于数据派生的计算属性

  return{
    channelList,
    getList
  }
},{
  // 开启当前模块数据的持久化
  persist: true,
})

4. 自定义持久化属性

import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";

// 定义 store (channel:仓库唯一标识),并导出
export const useChannelStore = defineStore('channel', () => {
  // 定义 state 声明数据
  const channelList = ref([])
  // action 声明操作数据的方法
  const getList = async () => {
    // 支持异步
    const { data: { data } } = await axios.get('http://geek.itheima.net/v1_0/channels')
    channelList.value = data.channels
    console.log(channelList.value)
  }
  // getters 声明基于数据派生的计算属性

  return{
    channelList,
    getList
  }
},{
  persist: {
    // 自定义存储的 key
    key: 'erer-channel',
    // 自定义需要被持久化的对象
    paths: ['channelList']
  }
})

五、项目案例

1、创建项目

1. 安装方式

npm install -g pnpm

2. 创建项目

pnpm create vue
npmyarnpnpm
npm installyarnpnpm install
npm install axiosyarn add axiospnpm add axios
npm install axios -Dyarn add axios -Dpnpm add axios -D
npm uninstall axiosyarn remove axiospnpm remove axios
npm run devyarn devpnpm dev

3. VScode配置

{
    "workbench.iconTheme": "vscode-icons",
    "editor.tabSize": 4,
    "emmet.triggerExpansionOnTab": true,
    // ESlint 插件 + Vscode 配置 实现自动格式化修复
    "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
  • .husky/pre-commit 文件修改
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# npm test
pnpm lint

6. 暂存区 eslint 校验

  • 安装 lint-staged 包
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"
    ]
  }
}
  • .husky/pre-commit 文件修改
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# npm test
# pnpm lint
pnpm lint-staged

2、路由配置

1. 新建页面

path文件功能组件名路由级别
/loginviews/login/LoginPage.vue登录&注册LoginPage一级路由
/views/layout/LayoutContainer.vue布局架子LayoutContainer一级路由
├─ /article/manageviews/article/ArticleManage.vue文章管理ArticleManage二级路由
├─ /article/channelviews/article/ArticleChannel.vue频道管理ArticleChannel二级路由
├─ /user/profileviews/user/UserProfile.vue个人详情UserProfile二级路由
├─ /user/avatarviews/user/UserAvatar.vue更换头UserAvatar二级路由
├─ /user/passwordviews/user/UserPassword.vue重置密码UserPassword二级路由

2. 创建路由实例

  • src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'

// createRouter:创建路由实例
const router = createRouter({
  // 配置 history 模式(History模式:createWebHistory,Hash模式:createWebHashHistory(带#号的路由))
  // import.meta.env.BASE_URL 路径前缀: vite.config.js 中 base 配置项
  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') }
      ]
    }
  ]
})

// 登陆访问拦截 => 默认是直接放行
// 返回值为 undefined/true 放行
// 返回值为 false 跳转回 from 的地址页面
// 具体路径或者路径对象则会拦截到对应地址
router.beforeEach((to) => {
  // 如果没有 token 同时访问非登陆页面则拦截到登陆页面
  const userStore = useUserStore()
  if (!userStore.token && to.path !== '/login') return '/login'
  return true
})

export default router

3. 路由入口

  • 一级路由:src/App.vue
<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. 安装

# 安装 Element Plus
pnpm install element-plus
# 安装 unplugin-vue-components 和 unplugin-auto-import 插件
pnpm add -D unplugin-vue-components unplugin-auto-import
# 安装 element-plus 图标库
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'

// 引入 Element Plus 配置
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    // 自动导入 Element Plus 插件
    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('')
    // 登陆获取 token
    const setToken = (newToken) => {
      token.value = newToken
    }
    // 退出清空 token
    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
  • 插件挂载到 pinia 实例
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import '@/assets/main.scss'
// 导入 pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const app = createApp(App)
// 将插件挂载到 pinia 实例上
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('')
    // 登陆获取 token
    const setToken = (newToken) => {
      token.value = newToken
    }
    // 退出清空 token
    const removeToken = () => {
      token.value = ''
    }

    return {
      token,
      setToken,
      removeToken
    }
  },
  {
    persist: true
  }
)

4. Pinia 独立维护

  • src/stores/index.js
import { createPinia } from 'pinia'
// 导入 pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia
  • src/main.js
import { createApp } from 'vue'

import App from './App.vue'
import router from './router'
// 导入 pinia
import pinia from '@/stores/index'
import '@/assets/main.scss'

const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

5. 仓库统一导出

  • src/stores/index.js
import { createPinia } from 'pinia'
// 导入 pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia

// import { useUserStore } from './modules/user'
// export { useUserStore }
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) {
      // 请求头带上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) => {
    // 处理401错误: 权限不足或者token失效,跳转登陆页面
    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'

// createRouter:创建路由实例
const router = createRouter({
  // 配置 history 模式(History模式:createWebHistory,Hash模式:createWebHashHistory(带#号的路由))
  // import.meta.env.BASE_URL 路径前缀: vite.config.js 中 base 配置项
  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') }
      ]
    }
  ]
})

// 登陆访问拦截 => 默认是直接放行
// 返回值为 undefined/true 放行
// 返回值为 false 跳转回 from 的地址页面
// 具体路径或者路径对象则会拦截到对应地址
router.beforeEach((to) => {
  // 如果没有 token 同时访问非登陆页面则拦截到登陆页面
  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('')
    // 登陆获取 token
    const setToken = (newToken) => {
      token.value = newToken
    }
    // 退出清空 token
    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. 文章分类组件

  • V-model
<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>
  • v-model:cateId
<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. 封装接口

  • src/api/article.js
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')

// 添加文章分类(post请求可进行直接传参)
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 } })

// 获取文章列表(get请求需要将参数封装到对象中)
export const articleGetListService = (params) => request.get('/my/article/list', { params })

// 添加文章:data需要一个foemData格式的对象
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>
  • 22
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值