后台管理系统

Vue3后台管理系统开发

配置vite.config.js

引入时可以使用'@'代表/src

 export default defineConfig({
    plugins: [vue()],
    //设置/src的别名为@
    resolve:{
      alias:[{
         find:"@",
         replacement:'/src'
      }]
    }
  })
  

创建路由

创建router/index.js

import { createRouter,createWebHashHistory } from "vue-router";
import Main from '@/views/Main.vue'
import Login from "../views/Login.vue";
import No404 from "../views/404.vue";
// 定制路由规则
const routes=[
  {
    path:'/',
    component:Main,
    name:'Main',
    redirect:'/login',
  },
  {
    path:'/login',
    name:'login',
    component:Login
  },{
    path:'/404',
    name:'404',
    component:No404
  }
]
// 创建路由
const router=createRouter({
  history:createWebHashHistory(),
  routes,
})
// 导出路由
export default router
  1. 在main.js中引入并使用


  import router from './router'
  app.use(router)
  1. 在合适的地方设置路由出口router-view

  <router-view></router-view>

引入Element-Plus组件库

不考虑打包后的文件大小,使用完整导入

在main.js中引入并使用

  import ElementPlus from 'element-plus'
  import 'element-plus/dist/index.css'
  app.use(ElementPlus)

导入所有图标并进行全局注册。

  
  import * as ElementPlusIconsVue from '@element-plus/icons-vue'
  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
  }

编写Main.vue的结构

新建components/CommonAside.vue组件并在Main组件中引入

使用静态数据list数组

新建components/CommonHead.vue组件并在Main组件中引入

顶部面包屑导航,文字穿透效果

  • :deep() - Vue 3的深度选择器,用于穿透scoped样式

  • !important - 提高样式优先级,确保生效

这种效果常用于:

  • 面包屑导航中的可点击项

  • 透明背景上的文字链接

  • 需要突出显示的可交互文本元素

 <el-breadcrumb separator="/" class="bread">
          <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
        </el-breadcrumb>

  /* 文字穿透效果 */
  :deep(.bread span){
    color: #fff !important;
    cursor: pointer !important;
  }

使用pinia

pinia实现数据共享

安装pinia包

npm i pinia

在main.js中导入使用

import { createPinia } from 'pinia'
const pinia=createPinia()
app.use(pinia)

新建src/stores/index.js

import { defineStore } from "pinia";
import { ref } from "vue";
function initState(){}
export const useAllDataStore=defineStore('allData',()=>{
  const state=ref(initState())
  return {
    state
  }
})

完成侧边栏菜单的收起和展开

  1. v-show和v-if的区别

  • v-if:​

条件为false时,元素从DOM中完全移除

切换时会触发组件的创建和销毁

运行时条件很少改变时使用

​​​​​​

  • v-show:

条件为false时,元素通过display: none隐藏

组件始终存在,只控制显示

需要频繁切换显示状态时使用

菜单展开是需要频繁切换的,故使用v-show

在pinia中定义变量iscollapse便于多个组件共享

        在组件中使用iscollapse时用计算属性包裹,维持其响应式。

const iscollapse=computed(()=>{
    return store.state.iscollapse
  })
  const width=computed(()=>{
    return iscollapse.value?'64px':'180px'
  })

        计算属性默认简写时里面的数据是只读的,需要修改应加setter属性。

给按钮加函数实现侧边栏菜单的收起和展开

home组件

使用mock.js制造假数据

安装包

npm i mockjs

新建/src/api.mockData/home.js  存假数据并导出

新建/src/api/mock.js存放mock的代码

import Mock from "mockjs";
import homeApi from "./mockDate/home";

// Mock.mock(1.拦截的路径(正则表达式) 2.请求方法  3.制造的假数据)
Mock.mock(/api\/home\/etTableData/,'get',homeApi.getTableData)

在main.js中引入

import '@/api/mock.js'

在Home.vue中发送请求

使用axios

安装axios

npm i axios

发请求(使用mockjs后会拦截,即请求发不出去,但能得到mock的假数据)

import axios from 'axios';
  axios({
    url:'api/home/getTableData',
    method:'get'
  }).then(res=>{
    console.log(res);

当没有真正后端接口的时候使用mock拦截,如果有了后端接口,把main.js中的import '@/api/mock.js'引入删掉即可

添加拦截器

简化代码,统一管理

新建src/utils/request.js

import axios from "axios";
import { ElMessage } from "element-plus";

const service=axios.create()
const NETWORK_ERROR='网络错误....'
// 添加请求拦截器
service.interceptors.request.use(function (config) {
    // 在发送请求之前做些什么
    return config;
  }, function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  });

// 添加响应拦截器
service.interceptors.response.use(function (res) {
  const {code,data,msg}=res.data
  if(code===200){
    return data
  }
  else {
    ElMessage.error(msg||NETWORK_ERROR)
    return Promise.reject(msg||NETWORK_ERROR)
  }
  });

  export default service

Home.vue中请求改变写法

import { onMounted, ref } from 'vue';
import { getTableDataAPI } from '../api/data';
 const getTableData=async()=>{
    const res=await getTableDataAPI()
    // console.log(res.tableData);
    tableData.value=res.tableData
  }
onMounted(async()=>{
    getTableData()
  })

使用echarts绘制柱状图、饼图

获取echarts

npm install echarts

Home组件中引入echarts
import * as echarts from 'echarts';
写容器结构

       <div class="graph">
        <el-card>
          <div ref="userEchart" style="height:240px"></div>
        </el-card>
        <el-card>
          <div ref="viedeoEchart" style="height:240px"></div>
        </el-card>
       </div>
绘制图表的配置


 //这个是折线图和柱状图 两个图表共用的
const xOptions = reactive({
      // 图例文字颜色
      textStyle: {
        color: "#333",
      },
      legend: {},
      grid: {
        left: "20%",
      },
      // 提示框
      tooltip: {
        trigger: "axis",
      },
      xAxis: {
        type: "category", // 类目轴
        data: [],
        axisLine: {
          lineStyle: {
            color: "#17b3a3",
          },
        },
        axisLabel: {
          interval: 0,
          color: "#333",
        },
      },
      yAxis: [
        {
          type: "value",
          axisLine: {
            lineStyle: {
              color: "#17b3a3",
            },
          },
        },
      ],
      color: ["#2ec7c9", "#b6a2de", "#5ab1ef", "#ffb980", "#d87a80", "#8d98b3"],
      series: [],
})



公共配置
//饼图
const pieOptions = reactive({
  tooltip: {
    trigger: "item",
  },
  legend: {},
  color: [
    "#0f78f4",
    "#dd536b",
    "#9462e5",
    "#a6a6a6",
    "#e1bb22",
    "#39c362",
    "#3ed1cf",
  ],
  series: []
})
从mockjs中获取数据,渲染折线图

横坐标是类目型的,因此需要在 xAxis 中指定对应的值;

而纵坐标是数值型的,可以根据 series 中的 data,自动生成对应的坐标范围。

//获取页面中的DOM结构
  const echart=ref(null)
  const userEchart=ref(null)
  const viedeoEchart=ref(null)
const getChartData=async()=>{
    const {orderData,videoData,userData}=await getChartDataAPI()
    // 渲染第一个折线图
    // 横坐标轴的每个值
    xOptions.xAxis.data=orderData.date
    // 坐标轴上每个点的值
    xOptions.series=Object.keys(orderData.data[0]).map(val=>{
      return{
        name:val,
        data:orderData.data.map(item=>item[val]),
        type:'line'
      }
    })
    //图表初始化
    const oneEchart=echarts.init(echart.value)
    //将配置好的 xOptions 应用到图表实例,图表会根据配置渲染出来
    oneEchart.setOption(xOptions)

    // 渲染柱状图
    xOptions.xAxis.data=userData.map(item=>item.date)
    xOptions.series=[
      {
        name:'新增用户',
        data:userData.map(item=>item.new),
        type:'bar'
      },
      {
        name:'活跃用户',
        data:userData.map(item=>item.active),
        type:'bar'
      },
    ]
    const tweEchart=echarts.init(userEchart.value)
    tweEchart.setOption(xOptions)
    // 渲染饼图
    pieOptions.series=[
      {
        data:videoData,
        type:'pie'
      }
    ]
    const threeEchart=echarts.init(viedeoEchart.value)
    threeEchart.setOption(pieOptions)
  }

Object.keys() 静态方法返回一个由给定对象自身的可枚举的字符串键属性名组成的数组。

map() 方法创建一个新数组,这个新数组由原数组中的每个元素都调用一次提供的函数后的返回值组成。

type:'line' 设置为折线图

type:'bar'  柱状图

type:'pie'  饼图

饼图的配置和折线图、柱状图略有不同,不再需要配置坐标轴,而是把数据名称和值都写在系列中。

优化:使图表跟随页面变化适应大小

监听页面的变化

const observer=ref(null)

//下面的代码依旧写在getChartData()函数内
// 监听页面变化
    observer.value=new ResizeObserver(()=>{
      oneEchart.resize()
      twoEchart.resize()
      threeEchart.resize()
    })

    if(echart.value){
      observer.value.observe(echart.value)
    }

new ResizeObserver()创建并返回一个新的 ResizeObserver 对象。

ResizeObserver.observe()用于监听指定的 Element 或 SVGElement

User组件

使用getUserData数据渲染表格时,注意对sex额外处理

因为sex的数据是0(女)和1(男)所以不能直接渲染

const getUserData=async()=>{
    let data=await getUserDataAPI()
    console.log(data);
    tableData.value=data.list.map(item=>{
      return {
        ...item,
        sexLabel:item.sex===1?'男':'女'
      }
    })
  }

筛选、分页功能

给getUserDataAPI传递参数

export const getUserDataAPI=(data)=>{
  return request.get('/home/getUserData',{
    params:data
  })
}

每次改变传递的参数后都重新调用一次函数,拉取符合条件的数据

//传递的参数是对象
const config=reactive({
    name:'',
    total:0,
    page:1
  })  
const handleSearch=()=>{
    config.name=keyWord
    getUserData()
  }
  const handleChange=(page)=>{
    config.page=page
    getUserData()
  }

删除

使用插槽语法,# 是 v-slot: 的简写,="scope" 表示接收插槽的 props 数据

scope 是一个对象,包含当前行的数据和方法,其中:

  • scope.row - 当前行的数据对象

  • scope.$index - 当前行的索引

  • scope.column - 当前列的信息

scope.row得到当前行的数据对象,作为参数传递

<template #="scope">
        <el-button link type="primary" size="small">
          编辑
        </el-button>
        <el-button link type="danger" size="small" @click="handleClear(scope.row)">删除</el-button>
      </template>

逻辑写完后重新调用getUserData()刷新数据

const handleClear=(val)=>{
    ElMessageBox.confirm('你确定要删除吗?').then(async()=>{
      await clearUserDataAPI({id:val.id})
      ElMessage({
        showClose:true,
        message:'删除成功',
        type:'success'
      })

      getUserData()
    })
  }

提交(模态框的确定按钮)和取消

调用onSubmit()提交时先验证所有输入是否满足制定的规则,

判断是新增还是编辑

编写函数对生日的日期进行格式化,对不足10的月、日补0

用正则表达式判断生日的日期是否符合xxxx-xx-xx的格式(formUser.birth=/^\d{4}-\d{2}-\d{2}$/),不符合就调用格式化的函数

调用新增用户的接口函数,进行新增

重新调用getUserData()刷新数据

成功了就把所有输入框重置(清空)并关闭dialog

失败则用ElMessage进行错误提示

调用handleCancel ()函数时也到重置表单

const onSubmit=()=>{
  // 先校验表单的所有输入是否符合制定的规则,返回的是布尔值
  userForm.value.validate(async(vaild)=>{
    let res=null
    // 格式化生日的日期
    formUser.birth=/^\d{4}-\d{2}-\d{2}$/.test(formUser.birth)?formUser.birth:timeFormat(formUser.birth)
    if(vaild){
      if(action.value==='add'){
        res=await addUserAPI(formUser)
        console.log(formUser);
        
      }else{
        res=await editUserApi(formUser)
      }
      if(res){
        dialogVisible.value=false
        userForm.value.resetFields()
        getUserData()
      }
    }else{
      ElMessage({
        showClose:true,
        message:'请输入正确的内容',
        type:'error'
      })
    }
    
  })
  
}



//对话框右下角的取消事件
const handleCancel = () => {
    dialogVisible.value=false
    userForm.value.resetFields()
}
}

新增:

const handleAdd = () => {
    action.value="add"
    dialogVisible.value=true
}

编辑:

将action的值改为edit

弹框显示

scope.row得到当前行的数据对象,作为参数传递

将得到的数据对象浅拷贝到弹框,注意sex要转换为字符串

直接执行拷贝会使得再次点击新增时,拷贝到的值会作为模态框的初始值。

使用vue中的nextTick(),解决此问题

nextTick()

可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。nextTick()不是立即执行,而是"等一会儿再执行"

// 编辑
const handleEdit=(val)=>{
  action.value='edit'
  dialogVisible.value=true
  nextTick(()=>{
    // 将 val.sex 强制转换为字符串类型,创建新对象,保持其他属性不变,只转换 sex 字段为字符串
    Object.assign(formUser,{...val,sex:''+val.sex})
  }) 
}

CommonTab组件

就是Main顶部的tag

添加CommonTab

侧边栏和CommonTab组件要实现数据共享,使用pinia

点击左侧的菜单,要添加相应的CommonTab,并且如果已经存在就不再添加

在stores/index.js文件中添加(pinia统一管理)

function selecMenu(val){
  if(val.name==='home'){
    state.value.currentMenu=null
  }else{
    // 判断当前点击的是否已经存在,已经存在就不再添加
    let index=state.value.tags.findIndex((item)=>item.name===val.name)
    // 不存在就添加到数组尾部
    index===-1?state.value.tags.push(val):''
  }
}

在CommonAside添加点击事件,实现点击后出现相应CommonTab

import { useAllDataStore } from '../stores';
  const store=useAllDataStore()
  const clickMenu=item=>{
    router.push(item.path)
    store.selecMenu(item)
  }

实现点击CommonTab跳转到相应页面

const handleMenu=(tag)=>{
    router.push(tag.name)    
  }
<el-tag
     v-for="tag in tags" 
     :key="tag.name" 
     :closable="tag.name!=='home'"
     :effect="(route.path).slice(1)===tag.name?'dark':'plain'"
     @click="handleMenu(tag)"
     >
      {{ tag.name }}
    </el-tag>

关闭CommonTab

点击CommonTab的叉号实现关闭CommonTab

在stores/index.js文件中添加(pinia统一管理)

function closeMenu(val){
  let index=state.value.tags.findIndex((item)=>item.name===val.name)
  state.value.tags.splice(index,1)
}

在CommonTab中

如果点击关闭的不是当前页面就直接关闭,是当前页面则还要使路由跳转到前一个页面

 // 关闭
  const handleClose=(tag)=>{
    store.closeMenu(tag)
    // 是当前页面则还要使路由跳转到前一个页面
    if(tag.name===(route.path).slice(1)){
      let index=tags.value.length
      router.push(tags.value[index-1].name)
    }
    else{
      return 
    }
  }

Login页面

请求数据并存储

根据不同的用户有不同的权限

将请求返回的menuList和token存到pinia,点击登录跳转到Home页

const loginForm=reactive({
    username:'',
    password:''
  })
  const store=useAllDataStore()
  const router=useRouter()
  const handleLogin=async()=>{
    const res=await getMenuAPI(loginForm)
    console.log(res);
    store.updateMenuList(res.menuList)
    store.state.token=res.token
    router.push('/home')
  }

动态路由

实现不同的用户有不同的权限,设置动态路由

stores/index.js中

import.meta.glob 是 Vite 提供的动态导入功能,用于批量导入模块。

function addMenu(router){
     const menu=state.value.MenuList
  //这里**代表0或多个文件夹,*代表文件。就是把views下的文件全部导入
     const module =import.meta.glob('../views/**/*.vue')
     //这个是菜单格式化后的路由数组
     const routeArr=[]
     //格式化菜单路由
     menu.forEach(item => {
            //如果菜单有children
       if(item.children){
           		//把children遍历格式化
           item.children.forEach(val => {
               let url=`../views/${val.url}.vue`
               				//这里通过url取出对应的组件
               val.component=module[url]
           })
        //需要注意的是我们只需要为item.children中的菜单添加路由,所以我们把它解构出来
             routeArr.push(...item.children)
         }else{
           let url=`../views/${item.url}.vue`
           item.component=module[url]
           routeArr.push(item)
         }
     })
     //遍历routeArr
     routeArr.forEach(item=>{
         //addRoute方法会返回一个函数,执行这个函数会把这个路由删除
         //这里我们把每一次router.addRoute添加路由的返回值收集起来,放到state中的routeList
         //addRoute第一个参数要添加子路由的路由name,第二个是一个路由记录
       state.value.routerList.push(router.addRoute('Main',item))  
     })
     
   }  

一个人多账号登入问题

先登入admin再登入xiaoxiao,会使普通账号xiaoxiao也能登入它没权限登入的页面。

加入一下代码,解决此问题

state.value.routerList=[]
     let routes=router.getRoutes()
     routes.forEach(item=>{
      if(item.name==='Main'||item.name==='login'){
        return
      }
      else{
        router.removeRoute(item.name)
      }

数据持久化处理

用watch监听,state改变就重新存储一次

  // 持久化存储
  watch(state,(newObj)=>{
    if(!newObj.token){
      return
    }else{
      localStorage.setItem('store',JSON.stringify(newObj))
    }
  },{deep:true})

在main.js中执行

import { useAllDataStore } from './stores'
const store=useAllDataStore()
store.addMenu(router,'refresh')

addMenu函数加上判断

  if(type==='refresh'){
    if(JSON.parse(localStorage.getItem('store'))){
      state.value=JSON.parse(localStorage.getItem('store'))
        //routeList保存的函数,存储的时候不能解析,其中的值就是null,这里重新赋值[]
      state.value.routerList=[]
    }else{
      return
    }
  }

退出功能


// 退出登入
function clear(){
  state.value.routerList.forEach(item=>{
    if(item){
      item()
    }
    state.value=initState()
    // 删除本地缓存
    localStorage.removeItem('store')
  })
}

添加路由守卫

访问不存在或没有权限访问的页面时统一跳转到404页面

main.js

function isRouter(to){
  return router.getRoutes().filter(item=>item.path===to.path).length>0
}

router.beforeEach((to,from)=>{
  if(to.path!=='/login'&&!store.state.token){
    return {name:'login'}
  }
  if(!isRouter(to)){
    return {name:'404'}
  }
}

面包屑导航

  const current=computed(()=>store.state.currentMenu)
 <el-breadcrumb-item :to="{ path: '/home' }">首页</el-breadcrumb-item>
 <el-breadcrumb-item v-if="current" :to="current.path">{{current.label}}</el-breadcrumb-item>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值