Skip to content
🗂️ 文章分类: 个人项目  
🏷️ 文章标签: 个人项目  
📝 文章创建时间: 2024-01-04
🔥 文章最后更新时间:2025-09-11

[toc]

shuyx-admin-ui项目笔记

shuyxadminui_20250910164439547.png

介绍

shuyx-admin-ui 是一个后台前端解决方案。它使用目前主流的前端技术栈,采用的技术栈为 Vue3 + Vite4 + Element Plus + Pinia + Vue Router4 等当前主流前端框架。

shuyx-admin-ui类似一个脚手架,可以帮助我们快速搭建企业级后台前端页面。

目前shuyx-admin-ui项目已经完成了基本的功能。下面将介绍这个项目的一些内容。

shuyx-admin-ui项目使用的技术栈完整清单如下所示

sh
axios: "^1.6.3",              ## axios 版本为1.6.3 用于http请求
crypto-js: "^4.2.0",          ## crypto-js 版本为4.2.0 用于加密解密
echarts: "^5.5.0",            ## echarts 版本为5.5.0 用于图表展示
element-plus: "^2.6.3",       ## element-plus 版本为2.6.3 用于UI组件库
js-cookie: "^3.0.5",          ## js-cookie 版本为3.0.5 用于操作cookie
pinia: "^2.1.7",              ## pinia 版本为2.1.7 用于状态管理
pinia-plugin-persistedstate: "^3.2.1",  ## pinia-plugin-persistedstate 版本为3.2.1 用于pinia状态管理的持久化插件
vue: "^3.4.21",               ## vue js框架 版本为3.4.21
vue-router: "^4.2.5",         ## vue-router 版本为4.2.5 用于路由管理
vite: "^5.0.10",              ## vite 版本为5.0.10 用于项目打包构建
vite-plugin-mock: "^3.0.2"    ## vite-plugin-mock 版本为3.0.2 用于mock数据

上手准备

由于shuyx-admin-ui这个项目本质上采用的技术栈为 Vue3 + Vite4 + Element Plus + Pinia + Vue Router4 等当前主流前端框架。

本地开发环境准备

本地环境需要安装 Node.js 和 Git。

然后你可以选择直接从Github中下载shuyx-admin-ui的项目的源代码。或者自己使用Vue3的官方脚手架Vue CLI来搭建一个空白工程。

搭建空白工程的步骤如下。

bash
# 1. 创建一个新的Vue3空白工程
npm init vue@latest

# 2. 输入各种项目信息
# ...........

# 3. 进入项目目录
cd shuyx-admin-ui

# 4. 启动项目
npm run serve

项目目录

shuyx-admin-ui项目已经为你创建好了一个完整的目录框架,并预留了涵盖中后台开发的各类功能,下面是整个项目的目录结构。

sh
shuyx-admin-ui               # 项目根目录
├── public                     # 公共资源目录
   │── favicon.ico            # favicon图标
├── src                        # 源代码src目录
   ├── api                    # http请求目录
   |   ├── request.js         # axios封装文件
   |   ├── xxx.service.js     # 某业务的请求文件
   ├── assets                 # 静态资源目录,存放各个图片、字体等静态资源
   ├── components             # 公共组件目录,存放可复用的公共组件
   ├── layout                 # layout组件目录,存放用于主体布局的组件
   |   ├── Aside              # 侧边栏目录,用于存放侧边栏的组件
   |   ├── Header             # 头部目录,用于存放头部的组件
   |   └── LayoutView.vue     # 整体布局页面
   ├── mock                   # mock 目录,存放本地的模拟数据
   |   └── xxx.mock.js        # 某业务的mock数据文件
   ├── router                 # 路由目录,用于配置路由规则
   |   └── router.js          # 主路由文件
   ├── stores                 # store管理目录,用于存储全局数据
   |   └── xxxStore.js        # 某业务的store文件
   ├── utils                  # 公共方法目录,用于存放公共方法
   ├── views                  # views目录,用于存放除布局组件和公共组件之外的所有页面
   ├── App.vue                # 入口组件
   └── main.js                # 入口js文件,用于初始化页面并加载组件等
├── .env.development           # 本地环境配置
├── .env.production            # 生产环境配置
├── .gitignore                 # git 忽略文件
├── index.html                 # 入口html页面文件
├── vite.config.js             # vite 配置文件
├── README.md                  # 项目介绍文件
├── push.sh                    # 上传脚本,用于快速上传脚本到远程git仓库中
└── package.json               # package.json

全局样式

为了保证后续样式的统一,因此需要先设置好全局样式。

当新建好前端工程后,在index.html页面中添加如下全局样式。

html
<!DOCTYPE html>
<html lang="en">
  <!-- .....省略部分 -->
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
<style>
/* 添加全局样式 */
#app, body, html {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
}
</style>

index.html页面中的<div id="app"></div>元素是根元素。提前设置全局样式,主要是方便后续开发。

本地/生产环境配置

该项目主要分为本地/生产环境配置。主要目的是在不同环境下有不同的配置。

  • 当我们执行npm run dev命令时,会自动加载.env.development本地环境配置文件。
  • 当我们执行npm run build命令时,会自动加载.env.production生产环境配置文件。

.env.development本地环境配置文件的配置项如下

sh
# 本地环境
VITE_ENV = development
# 标题
VITE_APP_TITLE = SHUYX ADMIN UI
# 接口路径首地址
VITE_APP_API_BASEURL = /api
# 本地端口
VITE_APP_PORT = 31000

.env.production生产环境配置文件的配置项如下

sh
# 生产环境
VITE_ENV = production
# 标题
VITE_APP_TITLE = SHUYX ADMIN UI
# 接口路径首地址
VITE_APP_API_BASEURL = /api
# 本地端口
VITE_APP_PORT = 31000

桌面端和移动端适配

shuyx-admin-ui 项目主要是使用 element-plus 组件库中的响应式布局来进行桌面端和移动端的适配。

具体参考 element-plus 响应式布局

登录/注册

登录页如图所示 shuyxadminui_20250910164009606.png

移动端的登录页如图所示 shuyxadminui_20250910225725.png

注册页如图所示 shuyxadminui_20250910170612460.png

移动端的注册页如图所示 shuyxadminui_20250910225852.png

布局

登录后,页面的布局如图所示。主要由布局容器区域+侧边栏区域+头部区域+主体区域组成。其中主体区域会根据侧边栏选择的菜单页面的不同而不同。

shuyxadminui_20250911225429.png

布局容器

布局容器是用于包裹整个布局的页面,并在其中引入侧边栏页面+头部页面(头部导航和标签栏)+主体页面。

LayoutView.vue页面的代码如下所示

html
<script setup>
// 引入各个组件和文件
import Header from './Header/HeaderView.vue'
import Aside from './Aside/AsideView.vue'
import TagsView from "./Header/TagsView.vue"
import { useMobileStore } from '@/stores/mobileStore'
import { onMounted, onUnmounted } from 'vue'

// onMounted生命周期:根组件挂载时启动监听
// 调用useMobileStore的handleResize方法监听窗口大小变化,判断是否移动端,
onMounted(() => {
  window.addEventListener('resize', useMobileStore().handleResize)
  useMobileStore().handleResize() // 初始化立即执行一次
})

// onUnmounted生命周期:根组件卸载时停止监听
onUnmounted(() => {
  window.removeEventListener('resize', useMobileStore().handleResize)
})

</script>
<template>
  <!--最外层容器高宽都为100%-->
  <el-container style="width: 100%;height: 100%;">
    <!--左边区域高100%,宽auto自适应,宽度会随着侧边栏的折叠而变化-->
    <el-aside style="padding: 0px;width: auto;height: 100%;">
      <!-- 侧边栏组件 -->
      <Aside></Aside>
    </el-aside>
    <!--右边区域高100%,宽auto自适应-->
    <el-container style="height: 100%;width: auto;">
      <!--头部区域高auto自适应,宽100%-->
      <el-header style="padding: 0px;width: 100%;height:auto;">
        <!--头部导航栏组件-->
        <Header></Header>
        <!--标签栏tagview组件-->
        <TagsView></TagsView>
      </el-header>
      <!--主区域高auto自适应,宽100%,背景色灰白-->
      <el-main style="padding: 10px;width: 100%;height: auto;background-color: #f6f8f9;">
        <!--通过router-view标签来显示嵌套路由的内容-->
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<style scoped>
</style>

代码解析

  1. 布局页面最外层el-container容器标签,设置宽高都是100%。
  2. 左边区域设置高100%,宽auto自适应。之所以宽度设置为auto,是因为侧边栏会折叠,宽度会变化。
  3. 右边区域设置高度100%,宽度auto,由于左边区域的宽度会随时变化,因此右边区域的宽度也要随之变化。
    1. 右边头部导航栏区域设置宽度100%,高度auto。
    2. 右边头部区域引入了导航栏组件和标签栏tagview组件。
    3. 右边主区域设置宽度100%,高度auto。

主体区域

主体区域用于展示各个业务页面。通过点击侧边栏菜单,主体区域来加载不同的业务页面。

shuyxadminui_20250911225646.png

核心代码如下

html
<!--主区域高auto自适应,宽100%,背景色灰白-->
<el-main style="padding: 10px;width: 100%;height: auto;background-color: #f6f8f9;">
  <!--通过router-view标签来显示嵌套路由对应的页面-->
  <router-view />
</el-main>
  • 通过<router-view />标签来显示路由对应的页面。
  • 注意:主体区域的背景色通常是灰白色。从而与element-plus的各个组件的白色进行区分。

侧边栏区域

侧边栏区域由侧边栏容器(即侧边栏整体页面) + logo标题 + 菜单 三部分组成。

shuyxadminui_20250910234611.png

并且侧边栏区域还有四种状态:

  • 当桌面端的时候,展开和折叠状态。
  • 当移动端的时候,展开和折叠状态。

桌面端侧边栏展开状态 shuyxadminui_20250911000022.png

桌面端侧边栏折叠状态 shuyxadminui_20250911000710.png

移动端侧边栏展开状态 shuyxadminui_20250911000930.png

移动端侧边栏折叠状态(此时侧边栏隐藏) shuyxadminui_20250911001107.png

侧边栏容器(即侧边栏页面本身)

侧边栏容器页面AsideView.vue相关代码如下。

  • 其中 headerStore.js 文件用于存储侧边栏的当前状态,即是展开状态还是折叠状态。
  • 其中 mobileStore.js 文件用于判断当前设备信息,即当前设备是移动端还是桌面端。
  • 其中 menuStore.js 文件用于存储侧边栏的菜单数据。
html
<template>
  <!-- 移动端使用抽屉作为侧边栏 -->
  <template v-if="isMobile">
    <!-- 此处的div标签必须存在,否则更改.el-drawer__body样式无法生效 -->
     <div>
       <!-- v-model属性控制抽屉是否展开,用sideIsExpand变量控制。size="auto" 表示抽屉宽度自适应 -->
        <el-drawer
          v-model="sideIsExpand"
          direction="ltr"
          :with-header="false"
          size="auto"
          :before-close="handleDrawerClose"
          style="background-color: #304156;"
        > 
          <el-scrollbar style="background-color: #304156;height:100%;">
            <!-- collapse属性用于控制侧边栏菜单是否展开。在移动端的情况下默认为false -->
            <el-menu
              style="border: none;"
              mode="vertical"
              :collapse="false"
              :router="true"
              background-color="#304156"
              active-text-color="#ffd04b"
              text-color="#bfcbd9"
            > 
              <!-- 侧边栏logo -->
              <LogoTitle />
              <!-- 侧边栏菜单-->
              <SidebarItem v-for="route in sideMenuList" :key="route.path" :item="route" />
            </el-menu>
          </el-scrollbar>
          
        </el-drawer>
      </div>
  </template>
  <!-- PC端使用侧边菜单作为侧边栏 -->
  <template v-else>
    <el-scrollbar style="background-color: #304156;height:100%">
      <!-- collapse属性用于控制侧边栏菜单是否展开。桌面端的情况下通过sideIsExpand变量控制-->
      <el-menu
        style="border: none;"
        mode="vertical"
        :collapse="sideIsExpand"
        :router="true"
        background-color="#304156"
        active-text-color="#ffd04b"
        text-color="#bfcbd9"
      > 
        <!-- 侧边栏logo -->
        <LogoTitle />
        <!-- 侧边栏菜单-->
        <SidebarItem v-for="route in sideMenuList" :key="route.path" :item="route" />
      </el-menu>
    </el-scrollbar>
  </template>
</template>
<script setup>
import { computed } from 'vue'
//导入headerStore
import { useHeaderStore } from '@/stores/headerStore'
//导入MenuStore
import { useMenuStore } from '@/stores/menuStore'
//导入移动端store
import { useMobileStore } from '@/stores/mobileStore'
import LogoTitle from './LogoTitle.vue'
import SidebarItem from './SidebarItem.vue'

// 获取移动端变量,判断是否是移动端
const isMobile = computed(() => {
  return useMobileStore().isMobile
})

// 获取HeaderStore中的sideIsExpand状态。赋值给sideIsExpand。计算属性,才会实时更新
const sideIsExpand = computed(() => {
  return useHeaderStore().sideIsExpand
})

// 抽屉关闭时更新折叠状态
const handleDrawerClose = () => {
  useHeaderStore().changeSideExpand()
}

//获取侧边栏菜单信息
let sideMenuList = useMenuStore().sideBarMenuInfo

</script>
<style scoped>
/* 使用深度选择器穿透作用域,移除抽屉内容区域默认内边距 */
::v-deep(.el-drawer__body) {
  padding: 0px;
}
</style>

headerStore.js文件 - 侧边栏折叠展开状态

headerStore.js文件代码如下所示。该文件用于存储侧边栏的当前状态,即是展开还是折叠。

js
import { ref } from 'vue'
import { defineStore } from 'pinia'

//导航栏信息store
export const useHeaderStore = defineStore('headerStore', () => {
  //侧边栏是否伸展
  const sideIsExpand = ref(false)
  //改变侧边栏伸展方法
  function changeSideExpand(){
    sideIsExpand.value = !sideIsExpand.value
  }
  return {sideIsExpand,changeSideExpand}
})

mobileStore.js 文件 - 判断设备为移动端/桌面端

mobileStore.js 文件代码如下所示。用于判断当前设备信息,即当前设备是移动端还是桌面端。

js
import { ref } from 'vue'
import { defineStore } from 'pinia'
//移动端信息store
export const useMobileStore = defineStore('mobileStore', () => {
  // 全局状态:是否为移动端(窗口宽度<768px是移动端)
  const isMobile = ref(window.innerWidth < 768)
   // 窗口变化监听函数
  const handleResize = () => {
    isMobile.value = window.innerWidth < 768
  }
  return {isMobile, handleResize}
})

menuStore.js 文件代码如下所示。用于存储侧边栏的菜单数据和动态路由数据。

js
import { ref,computed } from 'vue'
import { defineStore } from 'pinia'
import buildRouter from "@/utils/menuRouter"
import {constantRoutes} from "@/router"
//菜单信息store
export const useMenuStore = defineStore('menuStore', () => {
  //菜单信息
  const menuInfo = ref([])
  //设置菜单信息
  function setMenuInfo(obj){
    menuInfo.value = obj
  }
  //设置菜单信息
  function getMenuInfo(){
    return menuInfo.value
  }
  //动态路由信息
  const dynamicRouteInfo = computed(() => {
    //buildRouter方法,将后台传过来的用户菜单信息转换为vue-router可用的动态路由数组
    let dynamicRoute = []
    dynamicRoute = buildRouter(menuInfo.value)
    return dynamicRoute
  })
  //侧边栏菜单信息,侧边栏菜单就是常规路由+动态路由
  const sideBarMenuInfo = computed(()=>{
    let sideBarMenu = []
    sideBarMenu = constantRoutes.concat(dynamicRouteInfo.value)
    return sideBarMenu
  })
  return {menuInfo,dynamicRouteInfo,sideBarMenuInfo,setMenuInfo,getMenuInfo}
},{persist: true})

menuStore.js 文件中调用了 buildRouter 方法。buildRouter 方法定义在 utils 目录的 menuRouter.js文件中。

buildRouter 方法主要用于将后台传过来的用户菜单信息转换为 vue-router 可用的动态路由数组。

menuRouter.js文件代码如下所示。

js
/**
 * menuRouter.js文件主要用于将后台传过来的用户菜单信息,转换为可以被vue-router使用的动态路由信息。
 */

//导入Layout视图组件
const LayoutView = () => import("@/layouts/LayoutView.vue");

//全局获取views目录中所有的.vue文件信息
const modules = import.meta.glob('@/views/**/*.vue')

// loadView方法:用于把数据库中存储的菜单页面信息转换为实际的.vue文件信息
// 例如 数据库中某个菜单A的页面信息为 /src/views/system/user/UserView.vue 。
// 步骤如下:
// 0. 菜单A的页面信息作为参数传入到loadView方法中。
// 1. 然后开始遍历 modules对象(即遍历views目录中所有的.vue文件信息对象)
// 2. 在遍历过程中,会把/src/views/system/user/UserView.vue 与 所有的.vue文件进行匹配。
// 3. 如果匹配上了,说明找到了菜单A的对应.vue文件。并将菜单A对应的.vue文件信息返回。
// 注意:这里需要提前规定。在数据库中的菜单页面信息必须是该页面在前端工程中的具体路径。例如/src/views/system/user/UserView.vue
const loadView = (view) => {
  let res = undefined;
  for (let path in modules) {
    if (path === view) {
      res = modules[path];
    }
  }
  return res;
}

// 构建动态路由数组。用于把后台传过来的用户菜单信息,转换为可以被vue-router使用的路由信息。
function buildRouter(userMenuInfo){
    //路由数组
    let router = []
    //遍历后台传过来的用户菜单信息
    userMenuInfo.forEach(menuObj => {
      //新建一个router元素
      let routerObj = {
        name: undefined,  //路由名称
        path: undefined,  //路由路径
        component: undefined, //路由对应的组件
        icon: undefined,  //图标
        isLink: false,   //是否外链
        hidden: false,    //路由是否隐藏
        children:[]       //路由的子路由数组
      }
      routerObj.name = menuObj.menuName
      routerObj.path = menuObj.menuPath
      routerObj.icon = menuObj.icon
      //菜单默认是不隐藏的。如果菜单可见为1,表示该菜单不可以在侧边栏展示,需要隐藏。
      if(menuObj.visible == 1){
        routerObj.hidden = true
      }
      //如果菜单类型为0(目录),并且该目录是一级目录(parentId为0),则该router元素中的component为LayoutView
      //如果菜单类型为0(目录),并且该目录不是一级目录(parentId不为0),则该router元素中的component为undefined
      //如果菜单类型不为0(非目录),则该router元素中的component为菜单页面匹配的.vue文件信息
      if(menuObj.menuType == 0 && menuObj.parentId == 0){
        routerObj.component = LayoutView
      }else if(menuObj.menuType == 0 && menuObj.parentId > 0){
        routerObj.component = undefined
      }else{
        routerObj.component = loadView(menuObj.menuPage)
      }
      //isLink属性默认为false。如果菜单是外链(为1),设置isLink属性为true。
      if(menuObj.isLink == 1){
        routerObj.isLink = true
      }
      //如果菜单有子菜单,递归访问buildRouter方法,传入子菜单对象
      if(menuObj.children && menuObj.children.length){
        routerObj.children = buildRouter(menuObj.children)
      }
      //如果菜单没有子菜单,就把路由元素添加到路由数组中
      router.push(routerObj)
    })
    //最后返回路由数组,这个路由数组就是可以被vue-router使用的路由信息
    return router
}

//导出
export default buildRouter

侧边栏logo标题

logo标题组件 LogoTitle.vue 相关代码如下所示,用于展示侧边栏的logo标题。

html
<template>
  <el-menu-item >
      <img style="height:30px;width:30px;" :src="logoImg" alt="logo"/>
      <span style="font-weight:bold;font-size: 14px;color:#fff;padding: 10px;">SHUYX ADMIN UI</span>
  </el-menu-item>
</template>
<script setup>
import logoImg from '@/assets/logo.png'
</script>
<style scoped>
</style>

菜单

菜单组件功能如下

  • 接收全局路由(静态路由+动态路由)数据,数据是树形结构。然后通过递归调用自身的方式渲染出所有的菜单。
  • 当用户点击菜单的时候,会将具体的路由信息添加到标签栏tagview数组中。

菜单组件 SidebarItem.vue 代码如下

  • 其中 tagViewStore.js 文件用于存储标题栏中的菜单数据。当点击侧边栏的菜单的时候,会将被点击的菜单数据添加到标题栏中。
html
<template>
  <!--路由是否显示。此处最外层标签不要用div,用div会导致文字无法完全隐藏-->
  <template v-if="!route.hidden">
    <!--若有子路由,即目录-->
    <template v-if="hasOneShowingChild(route)">
      <el-sub-menu :index="route.path" teleported>
        <template #title>
          <el-icon ><component :is="route.icon"></component></el-icon>
          <span>{{ route.name }}</span>
        </template>
        <!--递归使用 SidebarItem组件-->
        <sidebar-item
          v-for="child in route.children"
          :key="child.path"
          :item="child"
        />
      </el-sub-menu>
    </template>
    <!--若无子路由,即菜单-->
    <template v-else>
      <!--非外链菜单-->
      <el-menu-item :index="route.path" @click="clickMenuItem(route)" v-if="!route.isLink">
        <template #title>
          <el-icon ><component :is="route.icon"></component></el-icon>
          <span>{{ route.name }}</span>
        </template>
      </el-menu-item>
      <!--外链菜单-->
      <el-menu-item @click="toLink(route.path)" v-else>
        <template #title>
          <el-icon ><component :is="route.icon"></component></el-icon>
          <span>{{ route.name }}</span>
        </template>
      </el-menu-item>
    </template>
  </template>
</template>

<script setup>
import {ref} from "vue"
import SidebarItem from './SidebarItem.vue'
//导入tagViewStore,获取tagview菜单信息
import { useTagViewStore } from '@/stores/tagViewStore'

const props = defineProps({
  item: Object
})
const route = ref(props.item)

//判断当前路由是否有子路由
//有子路由返回true,无子路由返回false
function hasOneShowingChild(route) {
  if(route.children == undefined){
    return false
  }else{
    if(route.children.length == 0){
      return false
    }
    return true
  }
}

//点击菜单,将菜单信息添加到tagview中
function clickMenuItem(obj) {
  useTagViewStore().addTagViewMenuInfo(obj)
}

//若菜单是外链,点击菜单跳转外部页面
function toLink(path){
  window.open(path, "_blank");
}

</script>

tagViewStore.js 文件 - 标签栏数据

tagViewStore.js 文件代码如下所示。用于存储标签栏数据。并提供添加和删除标签栏数据的方法。

js
import { ref} from 'vue'
import { defineStore } from 'pinia'

//tagViewStore
export let useTagViewStore = defineStore('tagViewStore', () => {
   //tagview菜单信息,主要用于存储tagview中展示的菜单
  let tagViewMenuInfo = ref([])
  //添加tagview菜单信息
  function addTagViewMenuInfo(obj){
    let item = {
      path:obj.path,
      name:obj.name
    }
    //判断新加入tag是否已经存在tag数组中,若不存在则加入数组
    if (!tagViewMenuInfo.value.some(obj => obj.path === item.path && obj.name === item.name)) {
      tagViewMenuInfo.value.push(item)
    }
  }
  //删除tagview菜单信息
  function deleteTagViewMenuInfo(index){
    return tagViewMenuInfo.value.splice(index, 1)
  }
  return {tagViewMenuInfo,addTagViewMenuInfo,deleteTagViewMenuInfo}
},{persist: true})

头部区域

头部区域由 头部导航栏 + 标签栏(tagView)两部分组成。

shuyxadminui_20250911111019182.png

头部导航栏

头部导航栏主要包括:折叠图标 + 面包屑 + 用户信息 + 设置菜单按钮等

  • 折叠图标:当点击折叠图标的时候,侧边栏会进行伸展和折叠。
  • 面包屑导航:通过监控当前路由,并且当前路径的具体信息。通过面包屑组件展示。
  • 用户信息:包括用户的头像和昵称相关信息。
  • 设置按钮:点击按钮弹出设置菜单,菜单包括:全屏展示,全局刷新,退出系统等功能。

HeaderView.vue 相关代码如下所示

  • 其中 userStore.js 用于存储登录用户信息。
  • 其中 headerStore.js 用于存储侧边栏折叠展开状态。当点击折叠图标的时候会调用 changeExpand 方法。从而改变这个状态。
  • 面包屑是通过获取当前路由的具体信息。并将其展示在面包屑组件中。
html
<template>
    <!-- 外层容器-->
    <div style="display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; background: #ffffff; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border-bottom: 1px solid #f0f2f5;">
        <!-- 左侧区域-->
        <div style="display: flex;align-items: center;gap: 16px;">
            <!-- 折叠图标按钮 -->
            <div>
                <el-icon @click="changeExpand()" :size="24" style="color: #606266; cursor: pointer; transition: color 0.2s;"
                  v-if="sideIsExpand" >
                  <Expand/>
                </el-icon>
                <el-icon @click="changeExpand()" :size="24" style="color: #606266; cursor: pointer; transition: color 0.2s;"
                  v-else>
                  <Fold/>
                </el-icon>
            </div>
            <!-- 面包屑-->
            <el-breadcrumb separator="/" style="padding: 0; color: #606266;">
                <el-breadcrumb-item v-for="(item, index) in breadCrumbList":key="item.path"
                  :style="{ fontWeight: index === breadCrumbList.length - 1 ? '500' : 'normal' }" >
                    <span>{{ item.name }}</span>
                </el-breadcrumb-item>
            </el-breadcrumb>
        </div>

        <!-- 右侧区域-->
        <div style="display: flex;align-items: center;gap: 16px;">
            <!-- 头像:圆形+阴影 -->
            <el-avatar :src="avatarImg" style="width: 32px;height: 32px;border-radius: 50%;box-shadow: 0 0 4px rgba(0,0,0,0.1);"/>
            <!-- 用户名:调整颜色和字体 -->
            <span style="color: #606266; font-size: 14px;">{{userStore.userInfo.userName}}</span>
            <!-- 设置下拉:优化图标样式 -->
            <el-dropdown trigger="click" style="color: #606266; transition: color 0.2s;">
                <el-icon :size="20" style="cursor: pointer;">
                  <Setting/>
                </el-icon>
                <template #dropdown>
                    <el-dropdown-menu>
                        <el-dropdown-item style="font-size: 14px;">全屏展示</el-dropdown-item>
                        <el-dropdown-item style="font-size: 14px;">全局刷新</el-dropdown-item>
                        <el-dropdown-item @click="logout" style="font-size: 14px;">退出系统</el-dropdown-item>
                    </el-dropdown-menu>
                </template>
            </el-dropdown>
        </div>
    </div>
</template>

<script setup>
import { ref,watch,computed } from 'vue';
import defalutAvatarImg from '@/assets/avatar.jpg'
import APIResources from '@/api/login.service'
//route
import { useRoute } from "vue-router"
let route = useRoute()
//router
import router from "@/router"
//UserStore
import { useUserStore } from '@/stores/userStore'
let userStore = useUserStore()
//HeaderStore
import { useHeaderStore } from '@/stores/headerStore'

//用户头像
let avatarImg = ref(undefined)
if(userStore.userInfo.avatar != null){
    // 远程的用户头像
    // avatarImg.value = 'http://localhost:39000/user-avatar-bucket/'+userStore.userInfo.avatar

    // 本地的用户头像
    avatarImg.value = defalutAvatarImg
}else{
    avatarImg.value = defalutAvatarImg
}

// 获取HeaderStore中的sideIsExpand状态。赋值给变量sideIsExpand
//sideIsExpand需要设置为计算属性,才会实时更新
const sideIsExpand = computed(() => {
  return useHeaderStore().sideIsExpand
})

// 点击方法,修改HeaderStore中的sideIsExpand状态
let changeExpand = () => {
    useHeaderStore().changeSideExpand() 
}

//监控当前路由,组成面包屑导航列表
let breadCrumbList = ref([])
watch(() => route.matched, (newVal) => {
    breadCrumbList.value = newVal
},{immediate:true,deep:true})

//退出登录操作
function logout(){
    //调用登出接口
    APIResources.logout().then(() => {
        //清除所有本地缓存数据,包括token
        window.localStorage.clear()
        router.push({ path: '/login' })
    });
}

</script>
<style scoped>
/* 折叠图标悬停效果 */
.el-icon:hover {
  color: #409eff !important;
}

/* 下拉菜单样式优化 */
.el-dropdown-menu__item {
  padding: 8px 16px;
}
</style>

userStore.js 文件 - 已登录用户信息

userStore.js 文件如下所示

js

import { ref } from 'vue'
import { defineStore } from 'pinia'

//用户信息store
export const useUserStore = defineStore('userStore', () => {
  //用户基础信息
  let userInfo = ref({
      userId: undefined,
      orgId: undefined,
      positionId: undefined,
      userName: undefined,
      gender: undefined,
      birthday: undefined,
      avatar: undefined,
      email: undefined,
      phone: undefined,
      status: undefined,
      createTime: undefined,
      updateTime: undefined
  })
  //设置用户信息
  function setUserInfo(obj){
    userInfo.value = obj
  }
  return {userInfo,setUserInfo}
},{persist: true})

headerStore.js文件 - 侧边栏折叠展开状态

headerStore.js文件代码如下所示。该文件用于存储侧边栏的当前状态,即是展开还是折叠。

js
import { ref } from 'vue'
import { defineStore } from 'pinia'

//导航栏信息store
export const useHeaderStore = defineStore('headerStore', () => {
  //侧边栏是否伸展
  const sideIsExpand = ref(false)
  //改变侧边栏伸展方法
  function changeSideExpand(){
    sideIsExpand.value = !sideIsExpand.value
  }
  return {sideIsExpand,changeSideExpand}
})

标签栏(tagview)

什么是tagview?

tagview其实就是快捷标签,当用户每点击一次侧边栏的菜单的时候,在tagview区域就出现一个代表该菜单的快捷标签。用户可以直接点击不同的快捷标签来切换菜单。

tagview实现逻辑

  1. 定义一个tagViewList数组。这个数组存储路由信息,并且数组中的路由不能重复。
  2. 当点击侧边栏的菜单的时候,将菜单对应的路由信息添加到tagViewList数组中。
  3. 如果当前页面的路由路径 等于 数组中的某个路由路径。那么对于的快捷标签颜色变蓝。其他快捷标签颜色变白。
  4. 当要关闭某个快捷标签的时候,相当于把tagViewList数组中的对应菜单路由信息从数组中删除。如果关闭的是当前路径的快捷标签,那么默认路由到tagViewList数组中最后一个路径。即最后一个快捷标签颜色变蓝。

TagsView.vue相关代码

  • 其中 tagViewStore.js 文件用于存储标题栏中的菜单数据。
  • 若当前页面路由等于数据中的某个路由路径,则表示该路由被选中,则该路由对应的标签颜色变蓝。其他标签颜色变白。
  • 当标签被关闭时,会从tagViewList数组中删除该标签对应的路由信息。并且会默认路由到tagViewList数组中最后一个标签对应的路由路径。
html
<template>
  <el-scrollbar style="height: auto;padding:2px;border: 0.2px solid rgb(241, 242, 243);">
    <div class="scrollbar-flex-content">
      <el-tag
        v-for="(item,index) in tagViewList"
        :key="item.path"
        style="margin: 2px"
        @close="handleClose(index,item)"
        @click="handClick(item)"
        :effect="currentPath === item.path ? 'dark': 'plain'"
        closable
      >
        {{ item.name }}
      </el-tag>
    </div>
  </el-scrollbar>
</template>

<script setup>
import { computed,ref,watch } from 'vue'
import { useRouter } from "vue-router"
let router = useRouter()

//获取tagView列表数据
import { useTagViewStore } from '@/stores/tagViewStore'
let tagViewList = computed(() => {
  return useTagViewStore().tagViewMenuInfo
})

//监控当前路由path,获取currentPath
let currentPath = ref(null)
watch(() => router.currentRoute.value.fullPath, (to) => {
  currentPath.value = to
},{immediate:true,deep:true})

//关闭tag
function handleClose(index,item) {
  //判断删除的tag是不是当前path的tag
  if(item.path == currentPath.value){
    //如果是,则跳转到最后一个tag路由上
    let length = tagViewList.value.length
    router.push(tagViewList.value[length-1].path)
  }
  //删除tag
  useTagViewStore().deleteTagViewMenuInfo(index)
}

//点击tag,跳转到这个tag路由上
function handClick(item){
  router.push(item.path)
}

</script>

<style scoped>
.scrollbar-flex-content {
  display: flex;
}

/* 标签悬停效果 */
.el-tag:hover {
  transform: translateY(-1px);  /* 微上移动画 */
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);  /* 添加阴影 */
}
</style>

tagViewStore.js 文件 - 标签栏数据

tagViewStore.js 文件代码如下所示。用于存储标签栏数据。并提供添加和删除标签栏数据的方法。

js
import { ref} from 'vue'
import { defineStore } from 'pinia'

//tagViewStore
export let useTagViewStore = defineStore('tagViewStore', () => {
   //tagview菜单信息,主要用于存储tagview中展示的菜单
  let tagViewMenuInfo = ref([])
  //添加tagview菜单信息
  function addTagViewMenuInfo(obj){
    let item = {
      path:obj.path,
      name:obj.name
    }
    //判断新加入tag是否已经存在tag数组中,若不存在则加入数组
    if (!tagViewMenuInfo.value.some(obj => obj.path === item.path && obj.name === item.name)) {
      tagViewMenuInfo.value.push(item)
    }
  }
  //删除tagview菜单信息
  function deleteTagViewMenuInfo(index){
    return tagViewMenuInfo.value.splice(index, 1)
  }
  return {tagViewMenuInfo,addTagViewMenuInfo,deleteTagViewMenuInfo}
},{persist: true})

Stores 数据处理

shuyx-admin-ui 项目中使用的 pinia 进行数据处理。

数据处理文件主要存放在 /stores 目录下。

例如 /stores/userStore.js 文件。它主要进行用户数据的存储,并提供多个处理用户数据的方法。

js
import { ref } from 'vue'
import { defineStore } from 'pinia'

//用户信息store
export const useUserStore = defineStore('userStore', () => {
  //用户基础信息
  const userInfo = ref({})
  //设置用户信息
  function setUserInfo(obj){
    userInfo.value = obj
  }
  //获取用户信息
  function getUserInfo(){
    return userInfo.value
  }
  return {userInfo,setUserInfo,getUserInfo}
},{persist: true})

路由

项目的路由文件存放在 /router 目录下。

路由主要分为常规路由+动态路由两部分组成。

  • 常规路由也是静态路由,任何用户都能够访问常规路由中对应的页面。
  • 动态路由就是根据用户权限来动态生成的。例如,当用户登录成功后,后端会返回了该用户可以访问的一些菜单信息数据。前端会将这些菜单信息转换为动态路由,并添加到整个路由当中。

全局路由 = 静态路由 + 动态路由

全局路由文件 index.js

全局路由文件中的代码主要是把动态路由和静态路由组合在一起,形成一个全局路由。

/router/index.js 文件代码如下所示

  • 每当页面刷新,路由都会重新初始化,前置守卫beforeEach会被执行一次。因此需要在beforeEach方法中获取menuStore.js文件中的动态路由数据,并把动态路由的数据加载到全局路由中。
  • 404页面的路由需要手动添加到全局路由数组中的最后。目的是页面路径匹配不到任何路由时,需要主动跳转到404页面。
js
import { createRouter, createWebHistory } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
// 布局页面(包含侧边栏,头部导航栏)。 除了注册登录等少数页面,其他页面都会嵌套在布局页面的main区域中展示。
import LayoutView from '@/layouts/LayoutView.vue'
//常规路由,任何用户都可访问的路由
export const constantRoutes = [
  {
    path: '/',
    name: "/",
    redirect: '/login', //根路由默认重定向到/login路由
    hidden: true
  },
  {
    path: '/login',
    name: "登录",
    component: () => import('@/views/login/LoginView.vue'),
    hidden: true
  },
  {
    path: '/register',
    name: "注册",
    component: () => import('@/views/register/RegisterView.vue'),
    hidden: true
  },
  {
    path: '/home',
    name: "首页",
    icon:"HomeFilled",
    component: LayoutView,
    hidden: false,
    children: [
      {
        path: '/home/index',
        name: '工作台',
        icon:"UserFilled",
        component: () => import('@/views/home/HomeView.vue')
      }
    ]
  },
  {
    path: '/demo',
    name: "组件",
    icon:"HomeFilled",
    component: LayoutView,
    hidden: false,
    children: [
      {
        path: '/demo/echart',
        name: '图表',
        icon:"UserFilled",
        component: () => import('@/views/demo/EchartDemoView.vue')
      },
      {
        path: '/demo/icon',
        name: '图标',
        icon:"UserFilled",
        component: () => import('@/views/demo/IconDemoView.vue')
      }
    ]
  },
  {
    path: '/other',
    name: "其他页面",
    icon:"Tools",
    component: LayoutView,
    hidden: false,
    children: [
      {
        path: '/other/about',
        name: '关于系统',
        icon:"InfoFilled",
        component: () => import('@/views/other/AboutView.vue')
      },
      {
        path: '/other/404',
        name: '404页面',
        icon:"WarnTriangleFilled",
        component: () => import('@/views/other/404View.vue')
      },
      {
        path: '/other/403',
        name: '403页面',
        icon:"WarnTriangleFilled",
        component: () => import('@/views/other/403View.vue')
      },
      {
        path: '/other/500',
        name: '500页面',
        icon:"WarnTriangleFilled",
        component: () => import('@/views/other/500View.vue')
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes
})

//是否已加载过动态路由
let isAddDynamicRouter = false
/**
 * router路由前置守卫,刷新页面后会重新执行一次beforeEach方法
 * 1. 如果直接访问登录,注册,找回密码等不需要登录的页面,则直接放行
 * 2. 获取token,如果token获取不到,则表示尚未登录,重新访问login
 * 3. 如果访问的是其他页面,则判断是否已经添加动态路由。若没有添加,则添加动态路由。
 */
router.beforeEach(async (to,from,next) => {
  //白名单页面
  let whiteMenu = ['/login','/register','/home']
  //若访问的是白名单页面,直接放行。
  if (whiteMenu.includes(to.path)) {
    return next()
  }
  // 如果本地存储中的token无法获取,表示用户尚未登录了。直接跳转到login页面,重新登录
  let token = window.localStorage.getItem("shuyxWebsiteToken")
  if(!token){
    return next("/login")
  }
  //判断是否添加了动态路由
  if(!isAddDynamicRouter){
    //获取menuStore中存储的动态路由信息
    let dynamicRoute = useMenuStore().dynamicRouteInfo
    if (dynamicRoute.length > 0) {
        //手动添加动态路由
        await dynamicRoute.forEach(obj =>{
            router.addRoute(obj)
        })
    }

    //最后手动404添加,若路由路径无法匹配,就会自动匹配到404页面
    router.addRoute({
        path: '/:catchAll(.*)',
        name: '404',
        component: () => import('@/views/other/404View.vue')
    })

    isAddDynamicRouter = true
    //中断此次的路由,重新进行下一次路由。即重新执行beforeEach。重新触发路由匹配
    next({ ...to, replace: true })
  }else{
    //放行当前路由
    next()
  }
})

export default router

动态路由

动态路由的形成

  • 当用户登录成功之后,后端会调用相关接口。从而获取该用户可以访问的一些菜单数据。前端会将这些菜单数据转换为vue-router可识别的动态路由数据,并通过 menuStore.js 文件进行持久化存储。
  • 在 /router/index.js 全局路由文件中,会读取menuStore.js 文件中存储的动态路由数据,并结合静态路由数据一起合并到全局路由之中。

之所以要持久化动态路由数据。主要是因为当页面刷新的时候,路由会被重新初始化。此时的路由只有静态路由,没有动态路由。因此每次刷新页面都需要再次把动态路由加载到全局路由当中。

另外之所以持久化用户菜单信息,而不是直接保存转换后的动态路由数据。是因为pinia只能持久化字符串数据,而动态路由数据中的component属性的非字符串数据无法被持久化。所以才选择持久化用户菜单信息,后续再转换为动态路由数据。

/store/menuStore.js 文件代码如下所示

js
import { ref,computed } from 'vue'
import { defineStore } from 'pinia'
import buildRouter from "@/utils/menuRouter"
import {constantRoutes} from "@/router"
//菜单信息store
export const useMenuStore = defineStore('menuStore', () => {
  //菜单信息
  const menuInfo = ref([])
  //设置菜单信息
  function setMenuInfo(obj){
    menuInfo.value = obj
  }
  //设置菜单信息
  function getMenuInfo(){
    return menuInfo.value
  }
  //动态路由信息
  const dynamicRouteInfo = computed(() => {
    //buildRouter方法,将后台传过来的用户菜单信息转换为vue-router可用的动态路由数组
    let dynamicRoute = []
    dynamicRoute = buildRouter(menuInfo.value)
    return dynamicRoute
  })
  //侧边栏菜单信息,侧边栏菜单就是常规路由+动态路由
  const sideBarMenuInfo = computed(()=>{
    let sideBarMenu = []
    sideBarMenu = constantRoutes.concat(dynamicRouteInfo.value)
    return sideBarMenu
  })
  return {menuInfo,dynamicRouteInfo,sideBarMenuInfo,setMenuInfo,getMenuInfo}
},{persist: true})

后台菜单数据转换为动态路由

/utils/menuRouter.js 文件代码如下所示

  • buildRouter 方法主要用于将后台传过来的用户菜单信息转换为 vue-router 可用的动态路由数组。
js
/**
 * menuRouter.js文件主要用于将后台传过来的用户菜单信息,转换为可以被vue-router使用的动态路由信息。
 */

//导入Layout视图组件
const LayoutView = () => import("@/layouts/LayoutView.vue");

//全局获取views目录中所有的.vue文件信息
const modules = import.meta.glob('@/views/**/*.vue')

// loadView方法:用于把数据库中存储的菜单页面信息转换为实际的.vue文件信息
// 例如 数据库中某个菜单A的页面信息为 /src/views/system/user/UserView.vue 。
// 步骤如下:
// 0. 菜单A的页面信息作为参数传入到loadView方法中。
// 1. 然后开始遍历 modules对象(即遍历views目录中所有的.vue文件信息对象)
// 2. 在遍历过程中,会把/src/views/system/user/UserView.vue 与 所有的.vue文件进行匹配。
// 3. 如果匹配上了,说明找到了菜单A的对应.vue文件。并将菜单A对应的.vue文件信息返回。
// 注意:这里需要提前规定。在数据库中的菜单页面信息必须是该页面在前端工程中的具体路径。例如/src/views/system/user/UserView.vue
const loadView = (view) => {
  let res = undefined;
  for (let path in modules) {
    if (path === view) {
      res = modules[path];
    }
  }
  return res;
}

// 构建动态路由数组。用于把后台传过来的用户菜单信息,转换为可以被vue-router使用的路由信息。
function buildRouter(userMenuInfo){
    //路由数组
    let router = []
    //遍历后台传过来的用户菜单信息
    userMenuInfo.forEach(menuObj => {
      //新建一个router元素
      let routerObj = {
        name: undefined,  //路由名称
        path: undefined,  //路由路径
        component: undefined, //路由对应的组件
        icon: undefined,  //图标
        isLink: false,   //是否外链
        hidden: false,    //路由是否隐藏
        children:[]       //路由的子路由数组
      }
      routerObj.name = menuObj.menuName
      routerObj.path = menuObj.menuPath
      routerObj.icon = menuObj.icon
      //菜单默认是不隐藏的。如果菜单可见为1,表示该菜单不可以在侧边栏展示,需要隐藏。
      if(menuObj.visible == 1){
        routerObj.hidden = true
      }
      //如果菜单类型为0(目录),并且该目录是一级目录(parentId为0),则该router元素中的component为LayoutView
      //如果菜单类型为0(目录),并且该目录不是一级目录(parentId不为0),则该router元素中的component为undefined
      //如果菜单类型不为0(非目录),则该router元素中的component为菜单页面匹配的.vue文件信息
      if(menuObj.menuType == 0 && menuObj.parentId == 0){
        routerObj.component = LayoutView
      }else if(menuObj.menuType == 0 && menuObj.parentId > 0){
        routerObj.component = undefined
      }else{
        routerObj.component = loadView(menuObj.menuPage)
      }
      //isLink属性默认为false。如果菜单是外链(为1),设置isLink属性为true。
      if(menuObj.isLink == 1){
        routerObj.isLink = true
      }
      //如果菜单有子菜单,递归访问buildRouter方法,传入子菜单对象
      if(menuObj.children && menuObj.children.length){
        routerObj.children = buildRouter(menuObj.children)
      }
      //如果菜单没有子菜单,就把路由元素添加到路由数组中
      router.push(routerObj)
    })
    //最后返回路由数组,这个路由数组就是可以被vue-router使用的路由信息
    return router
}

//导出
export default buildRouter

示例 转换前的用户可访问菜单信息如下

js
{
    "menuId": 2,
    "menuName": "系统管理",
    "parentId": 0,
    "menuPath": "/system",
    "menuPage": null,
    "menuType": 0,
    "visible": 0,
    "isLink": 0,
    "icon": "Setting",
    "children": [
        {
            "menuId": 8,
            "menuName": "组织机构管理",
            "parentId": 2,
            "menuPath": "/system/org",
            "menuPage": "/src/views/system/org/OrgView.vue",
            "menuType": 1,
            "visible": 0,
            "isLink": 0,
            "icon": "OfficeBuilding",
            "children": []
        },
        {
            "menuId": 9,
            "menuName": "职位管理",
            "parentId": 2,
            "menuPath": "/system/position",
            "menuPage": "/src/views/system/position/PositionView.vue",
            "menuType": 1,
            "visible": 0,
            "isLink": 0,
            "icon": "Avatar",
            "children": []
        },
        {
            "menuId": 10,
            "menuName": "角色管理",
            "parentId": 2,
            "menuPath": "/system/role",
            "menuPage": "/src/views/system/role/RoleView.vue",
            "menuType": 1,  
            "visible": 0,
            "isLink": 0,
            "icon": "Avatar",
            "children": []
        },
        {
            "menuId": 3,
            "menuName": "用户管理",
            "parentId": 2,
            "menuPath": "/system/user",
            "menuPage": "/src/views/system/user/UserView.vue",
            "menuType": 1,
            "visible": 0,
            "isLink": 0,
            "icon": "UserFilled",
            "children": []
        },
        {
            "menuId": 4,
            "menuName": "菜单管理",
            "parentId": 2,
            "menuPath": "/system/menu",
            "menuPage": "/src/views/system/menu/MenuView.vue",
            "menuType": 1,
            "visible": 0,
            "isLink": 0,
            "icon": "Collection",
            "children": []
        }
    ]
}

示例 转换后的动态路由信息如下

js
[
    {
        "name": "系统管理",
        "path": "/system",
        "icon": "Setting",
        "isLink": false,
        "hidden": false,
        "component": () => import('@/views/layout/LayoutView.vue'),
        "children": [
            {
                "name": "组织机构管理",
                "path": "/system/org",
                "icon": "OfficeBuilding",
                "isLink": false,
                "hidden": false,
                "component": () => import("/src/views/system/org/OrgView.vue"),
                "children": []
            },
            {
                "name": "职位管理",
                "path": "/system/position",
                "icon": "Avatar",
                "isLink": false,
                "hidden": false,
                "component": () => import("/src/views/system/position/PositionView.vue"),
                "children": []
            },
            {
                "name": "角色管理",
                "path": "/system/role",
                "icon": "Avatar",
                "isLink": false,
                "hidden": false,
                "component": () => import("/src/views/system/role/RoleView.vue"),
                "children": []
            },
            {
                "name": "用户管理",
                "path": "/system/user",
                "icon": "UserFilled",
                "isLink": false,
                "hidden": false,
                "component": () => import("/src/views/system/user/UserView.vue"),
                "children": []
            },
            {
                "name": "菜单管理",
                "path": "/system/menu",
                "icon": "Collection",
                "isLink": false,
                "hidden": false,
                "component": () => import("/src/views/system/menu/MenuView.vue"),
                "children": []
            }
        ]
    }
]

Http请求

shuyx-admin-ui项目主要是通过axios来发起Http请求调用后台接口。

Http请求流程如下

  1. 页面点击某个按钮,从而发起Http请求
  2. 调用统一管理的业务请求文件中的方法;
  3. 使用封装的 request.js 发送请求;
  4. 获取服务端返回的响应数据;
  5. 处理响应数据。

axios相关的代码主要分为 axios封装 + 具体的业务请求 + 发送请求

axios封装

axios封装部分主要是设置axios的全局设置。例如request 拦截器和response 拦截器等。

/api/request.js 文件代码如下所示

js
/**
 * request.js 是对 axios 进行封装。
 * */
import axios from 'axios' //引入axios
import { ElNotification, ElMessageBox, ElMessage } from 'element-plus' //引入element-plus的消息通知
import router from '@/router' //引入vue-router

//接口根路径
let baseurl = import.meta.env.VITE_APP_API_BASEURL

// 创建axios实例
const axiosService = axios.create({
  baseURL: baseurl
})

// axios request 拦截器
axiosService.interceptors.request.use(
  //请求成功的时候
  (config) => {
    //将本地存储中的token加入到请求头Authorization中
    let token = window.localStorage.getItem('shuyxWebsiteToken')
    if (token) {
      config.headers['Authorization'] = token
    }
    Object.assign(config.headers)
    return config
  },
  //请求失败的时候
  (error) => {
    return Promise.reject(error)
  }
)

// axios response 拦截器
axiosService.interceptors.response.use(
  //当响应成功的时候。返回响应内容中的data数据
  (response) => {
    //如果响应成功,但是业务办理失败
    if(response.data.code != undefined && response.data.code != 200){
      ElMessage.error('ERROR_Code: ' + response.data.code + ',ERROR_Message: ' + response.data.message)
    }
    //响应成功且业务办理成功。
    return response.data
  },
  //当响应失败的时候,根据不同的失败状态,进行不同的动作
  (error) => {
    if (error.response) {
      if (error.response.status == 404) {
        ElNotification.error({
          title: '请求错误',
          message: 'Status:404,正在请求不存在的服务器记录!'
        })
      } else if (error.response.status == 500) {
        ElNotification.error({
          title: '请求错误',
          message: error.response.data.message || 'Status:500,服务器发生错误!'
        })
      } else if (error.response.status == 401) {
        ElMessageBox.confirm(
          '当前用户已被登出或无权限访问当前资源,请尝试重新登录后再操作。',
          '无权限访问',
          {
            type: 'error',
            closeOnClickModal: false,
            center: true,
            confirmButtonText: '重新登录'
          }
        ).then(() => {
          router.push({ path: '/login' })
        })
      } else if (error.response.status == 503) {
        ElNotification.error({
          title: '服务不可用。',
          message: error.response.data.message || 'Status:503,服务不可用。!'
        })
      } else {
        ElNotification.error({
          title: '请求错误',
          message: error.message || `Response Status:${error.response.status},未知错误!`
        })
      }
    } else {
      ElNotification.error({
        title: '请求错误',
        message: '请求服务器无响应!'
      })
    }
    return Promise.reject(error.response)
  }
)

export default axiosService

具体的业务请求

对我来说通常将某个业务请求文件命名为业务名称.service.js

以 /api/user.service.js 用户请求文件举例。

js
import axiosService from '@/api/request';		//引入 request.js 中的axiosService

// 针对特定资源,创建资源访问对象
const APIResources = {
    //根据token获取用户信息
    getUserInfoByToken(){
        return axiosService.request({
            url: '/shuyx-user/user/userInfo',
            method: 'GET',
            headers: { 'Content-Type': 'application/json' }
        })
    },
    //查询全部用户
    pagelist(queryData,pageData) {
        return axiosService.request({
            url: '/shuyx-user/user/pagelist',
            method: 'POST',
            data: Object.assign({},queryData,pageData), //assign方法可以把两个对象合并
            headers: { 'Content-Type': 'application/json' }
        })
    },
    //添加用户
    addUser(queryData) {
        return axiosService.request({
            url: '/shuyx-user/user/addUser',
            method: 'POST',
            data: queryData,
            headers: { 'Content-Type': 'application/json' }
        })
    },
    //查询用户
    selectById(queryData) {
        return axiosService.request({
            url: '/shuyx-user/user/selectById',
            method: 'GET',
            params: queryData,
            headers: { 'Content-Type': 'multipart/form-data' }
        })
    },
    //更新用户
    updateUser(queryData) {
        return axiosService.request({
            url: '/shuyx-user/user/updateUser',
            method: 'POST',
            data: queryData,
            headers: { 'Content-Type': 'application/json' }
        })
    },
    //更新用户密码
    updateUserPassword(queryData) {
        return axiosService.request({
            url: '/shuyx-user/user/updateUserPassword',
            method: 'POST',
            data: queryData,
            headers: { 'Content-Type': 'multipart/form-data' }
        })
    },
    //删除用户
    deleteUser(queryData) {
        return axiosService.request({
            url: '/shuyx-user/user/deleteUser',
            method: 'DELETE',
            params: queryData,
            headers: { 'Content-Type': 'multipart/form-data' }
        })
    }
  }
  
export default APIResources

页面中发送请求

下面以用户页面举例。

/views/user/UserView.vue 代码如下所示

html
<script setup>
//导入用户请求文件
import APIResources from '@/api/user.service.js'
//表单对象
const queryform = ref({
  userName: undefined,
  status: undefined,
  phone: undefined
})
//分页配置对象
const pageData = ref({
  pageNum: 1,
  pageSize: 10,
  pageSizes: [10, 50, 100],
  total: 0
})

//搜索操作
function search() {
  //调用分页查询接口
  APIResources.pagelist(queryform.value, pageData.value).then((res) => {
    //打印接口返回数据
    console.log("res",res)
  })
}

//编辑操作
function toEdit(userId) {
    //调用接口
    APIResources.selectById({ userId }).then((res) => {
        console.log("res",res)
    })
}
//删除操作
function deleteUser(userId) {
    //调用接口
    APIResources.deleteUser({ userId }).then(() => {
       console.log("res",res)
    })
}
</script>

跨域问题

什么是跨域问题?

跨域问题是前端开发中常见的网络安全限制问题。由于浏览器的同源策略限制,当一个网页通过浏览器向另一个服务器请求资源时,如果两个服务器的域名、端口或协议任一不同,浏览器就会认为这是一个跨域请求,并可能阻止该请求或限制响应的使用。

简单来说,当我们的前端项目(如shuyx-admin-ui)运行在http://localhost:31000上,而后端API服务运行在http://localhost:38080上时,由于端口不同,就会产生跨域问题。

解决方案

shuyx-admin-ui项目采用了**Vite代理(Proxy)**的方式来解决开发环境中的跨域问题。

这种方式的原理是:通过在前端开发服务器和后端API服务器之间设置一个代理服务器,将前端发送的请求转发到后端服务器,从而绕过浏览器的同源策略限制。

解决方案主要通过以下几个部分配合实现:

  1. 环境配置文件

在.env.development和.env.production文件中,设置统一的API基础路径。从而确保在不同环境下,前端请求都通过/api前缀发送,方便后续的代理配置。

bash
# 接口路径首地址
VITE_APP_API_BASEURL = /api
  1. Vite配置文件中的代理设置

在vite.config.js文件中,配置了代理转发规则:

js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => {
  //此处process报错,不影响使用
  //获取环境配置文件的信息
  const env = loadEnv(mode, process.cwd(), "")
  return {
    // ...省略其他配置
    // 服务相关配置
    server: {
      host: '127.0.0.1',
      port: env.VITE_APP_PORT,  // 读取环境配置文件中的端口
      proxy: {
        // 当接口路径是/api开头的时候,将请求转发到如下地址
        '/api': {
          target: "http://127.0.0.1:38080", // 后端API服务器地址
          changeOrigin: true,             // 设置跨域
          rewrite: (p) => p.replace(/^\/api/, '')  // 将接口路径中的/api替换为空字符串
        }
      }
    }
  }
})

跨域工作流程

跨域工作流程如下

  1. 前端页面发送请求
  2. 由于环境配置文件设置了基础API路径为 /api。因此请求路径会加上 /api 前缀。
  3. Vite开发服务器拦截所有以/api开头的请求
  4. Vite开发服务器根据代理配置,将请求转发到目标服务器 http://127.0.0.1:38080,并自动去掉URL中的/api前缀
  5. 后端处理请求并返回响应。

例如

  • 前端发送的请求:http://localhost:31000/api/shuyx-user/user/pagelist
  • 通过代理转发后:http://localhost:38080/shuyx-user/user/pagelist

生成环境中的跨域处理

需要注意的是,上述通过Vite代理解决跨域的方案仅适用于开发环境。

在生产环境中,跨域问题通常有以下几种解决方案:

  1. 后端CORS配置:后端服务器配置CORS(跨域资源共享),允许特定来源的请求访问资源
  2. Nginx反向代理:在生产环境中部署Nginx服务器,通过Nginx的反向代理功能。将前端请求通过Nginx服务器转发到后端服务器,类似于开发环境中的Vite代理
  3. 同域名部署:将前端应用和后端API服务部署在同一个域名和端口下,从根本上避免跨域问题

在实际项目中,我们通常会根据具体的部署环境和安全要求,选择合适的跨域解决方案。

记住账号功能

shuyxadminui_20250911153534982.png

功能介绍

用户登录时若勾选“记住账号”功能选项,则将登录名和密码(加密后)存入本地cookie。下次登录页面加载时自动获取保存好的账号和密码(需解密),回写到登录输入框中。

实现思路

  • 当用户再次进入到登录页面的时候,会先判断cookie中是否存在相关键值对。若存在,则把键值对的数据赋值到输入框中。若不存在,则无动作。
  • 当用户点击登录按钮的时候。
    • 若用户已点击记住账号选项框。则把用户输入的账号,密码(加密)存储到cookie中。
    • 若用户没有点击记住账号选项。则把cookie中的对应键值对删除。

相关代码

js
// 引入CryptoJS加密解密库
import CryptoJS from 'crypto-js'
// 引入Cookies
import Cookies from 'js-cookie'

//页面加载=========
onMounted(()=>{
  //获取cookie中的账号信息
  let a = Cookies.get('shuyxAccountInfo-cookie');
  if(a){
    let accountinfo = JSON.parse(a);
    //解密密码
    let b = CryptoJS.AES.decrypt(accountinfo.passWord, 'my_secret_key').toString(CryptoJS.enc.Utf8)
    loginform.value.userName = accountinfo.userName
    loginform.value.passWord = b
    isRemember.value = true
  }
})

//登录相关=====================
let loginform = ref({
  userName: undefined,
  passWord: undefined,
  verifyCode: undefined
})
function onSubmit() {
  //调用登录接口
  LoginAPIResources.login(loginform.value)
    .then((res) => {
      //。。。登录成功操作
    })
    .finally(() => {
      remeberAccount()
    })
}

//记住账号相关 =================
let isRemember = ref(false)
function remeberAccount(){
  if(isRemember.value){
    //记住账号
    let a = {
      userName: loginform.value.userName,
      passWord: CryptoJS.AES.encrypt(loginform.value.passWord, 'my_secret_key').toString(),  //对密码进行加密
    }
    //cookie保存登录信息,保存7天
    Cookies.set('shuyxAccountInfo-cookie',JSON.stringify(a), { expires: 7 });
  }else{
    //不记住账号,如果cookie中保存了账号信息,那么需要删除
    Cookies.remove("shuyxAccountInfo-cookie")
  }
}

Mock 模拟数据

shuyx-admin-ui项目使用 vite-plugin-mock 进行mock数据。

mock相关的文件都在 /mock 目录中。通常会把mock文件命名为 业务模块名 + .mock.js 。

例如:用户模块的mock文件就命名为 user.mock.js

如何新增页面

  1. 在views目录中自定义一个页面。
  2. 在 /router/index.js 文件中新增一个路由。路由指向新增自定义页面
  3. 如果该页面中需要调用Http请求,你还需要先在 api 目录中创建相关请求文件。
  4. 然后再自定义页面中引入相关请求文件。并将其与页面中的元素进行绑定。

运行和打包

我们可以通过下面的命令对项目进行运行和打包

sh
# 运行项目
npm run dev

# 打包项目
npm run build

Released under the MIT License.