[toc]
shuyx-admin-ui项目笔记
介绍
shuyx-admin-ui 是一个后台前端解决方案。它使用目前主流的前端技术栈,采用的技术栈为 Vue3 + Vite4 + Element Plus + Pinia + Vue Router4 等当前主流前端框架。
shuyx-admin-ui类似一个脚手架,可以帮助我们快速搭建企业级后台前端页面。
目前shuyx-admin-ui项目已经完成了基本的功能。下面将介绍这个项目的一些内容。
shuyx-admin-ui项目使用的技术栈完整清单如下所示
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来搭建一个空白工程。
搭建空白工程的步骤如下。
# 1. 创建一个新的Vue3空白工程
npm init vue@latest
# 2. 输入各种项目信息
# ...........
# 3. 进入项目目录
cd shuyx-admin-ui
# 4. 启动项目
npm run serve
项目目录
shuyx-admin-ui项目已经为你创建好了一个完整的目录框架,并预留了涵盖中后台开发的各类功能,下面是整个项目的目录结构。
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页面中添加如下全局样式。
<!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
本地环境配置文件的配置项如下
# 本地环境
VITE_ENV = development
# 标题
VITE_APP_TITLE = SHUYX ADMIN UI
# 接口路径首地址
VITE_APP_API_BASEURL = /api
# 本地端口
VITE_APP_PORT = 31000
.env.production
生产环境配置文件的配置项如下
# 生产环境
VITE_ENV = production
# 标题
VITE_APP_TITLE = SHUYX ADMIN UI
# 接口路径首地址
VITE_APP_API_BASEURL = /api
# 本地端口
VITE_APP_PORT = 31000
桌面端和移动端适配
shuyx-admin-ui 项目主要是使用 element-plus 组件库中的响应式布局来进行桌面端和移动端的适配。
登录/注册
登录页如图所示
移动端的登录页如图所示
注册页如图所示
移动端的注册页如图所示
布局
登录后,页面的布局如图所示。主要由布局容器区域+侧边栏区域+头部区域+主体区域组成。其中主体区域会根据侧边栏选择的菜单页面的不同而不同。
布局容器
布局容器是用于包裹整个布局的页面,并在其中引入侧边栏页面+头部页面(头部导航和标签栏)+主体页面。
LayoutView.vue页面的代码如下所示
<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>
代码解析
- 布局页面最外层el-container容器标签,设置宽高都是100%。
- 左边区域设置高100%,宽auto自适应。之所以宽度设置为auto,是因为侧边栏会折叠,宽度会变化。
- 右边区域设置高度100%,宽度auto,由于左边区域的宽度会随时变化,因此右边区域的宽度也要随之变化。
- 右边头部导航栏区域设置宽度100%,高度auto。
- 右边头部区域引入了导航栏组件和标签栏tagview组件。
- 右边主区域设置宽度100%,高度auto。
主体区域
主体区域用于展示各个业务页面。通过点击侧边栏菜单,主体区域来加载不同的业务页面。
核心代码如下
<!--主区域高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标题 + 菜单 三部分组成。
并且侧边栏区域还有四种状态:
- 当桌面端的时候,展开和折叠状态。
- 当移动端的时候,展开和折叠状态。
桌面端侧边栏展开状态
桌面端侧边栏折叠状态
移动端侧边栏展开状态
移动端侧边栏折叠状态(此时侧边栏隐藏)
侧边栏容器(即侧边栏页面本身)
侧边栏容器页面AsideView.vue相关代码如下。
- 其中 headerStore.js 文件用于存储侧边栏的当前状态,即是展开状态还是折叠状态。
- 其中 mobileStore.js 文件用于判断当前设备信息,即当前设备是移动端还是桌面端。
- 其中 menuStore.js 文件用于存储侧边栏的菜单数据。
<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文件代码如下所示。该文件用于存储侧边栏的当前状态,即是展开还是折叠。
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 文件代码如下所示。用于判断当前设备信息,即当前设备是移动端还是桌面端。
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 文件 - 侧边栏菜单和动态路由数据
menuStore.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})
menuRouter.js文件 - 将后台菜单数据转换为动态路由
menuStore.js 文件中调用了 buildRouter 方法。buildRouter 方法定义在 utils 目录的 menuRouter.js文件中。
buildRouter 方法主要用于将后台传过来的用户菜单信息转换为 vue-router 可用的动态路由数组。
menuRouter.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标题。
<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 文件用于存储标题栏中的菜单数据。当点击侧边栏的菜单的时候,会将被点击的菜单数据添加到标题栏中。
<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 文件代码如下所示。用于存储标签栏数据。并提供添加和删除标签栏数据的方法。
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)两部分组成。
头部导航栏
头部导航栏主要包括:折叠图标 + 面包屑 + 用户信息 + 设置菜单按钮等
- 折叠图标:当点击折叠图标的时候,侧边栏会进行伸展和折叠。
- 面包屑导航:通过监控当前路由,并且当前路径的具体信息。通过面包屑组件展示。
- 用户信息:包括用户的头像和昵称相关信息。
- 设置按钮:点击按钮弹出设置菜单,菜单包括:全屏展示,全局刷新,退出系统等功能。
HeaderView.vue 相关代码如下所示
- 其中 userStore.js 用于存储登录用户信息。
- 其中 headerStore.js 用于存储侧边栏折叠展开状态。当点击折叠图标的时候会调用 changeExpand 方法。从而改变这个状态。
- 面包屑是通过获取当前路由的具体信息。并将其展示在面包屑组件中。
<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 文件如下所示
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文件代码如下所示。该文件用于存储侧边栏的当前状态,即是展开还是折叠。
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实现逻辑
- 定义一个tagViewList数组。这个数组存储路由信息,并且数组中的路由不能重复。
- 当点击侧边栏的菜单的时候,将菜单对应的路由信息添加到tagViewList数组中。
- 如果当前页面的路由路径 等于 数组中的某个路由路径。那么对于的快捷标签颜色变蓝。其他快捷标签颜色变白。
- 当要关闭某个快捷标签的时候,相当于把tagViewList数组中的对应菜单路由信息从数组中删除。如果关闭的是当前路径的快捷标签,那么默认路由到tagViewList数组中最后一个路径。即最后一个快捷标签颜色变蓝。
TagsView.vue相关代码
- 其中 tagViewStore.js 文件用于存储标题栏中的菜单数据。
- 若当前页面路由等于数据中的某个路由路径,则表示该路由被选中,则该路由对应的标签颜色变蓝。其他标签颜色变白。
- 当标签被关闭时,会从tagViewList数组中删除该标签对应的路由信息。并且会默认路由到tagViewList数组中最后一个标签对应的路由路径。
<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 文件代码如下所示。用于存储标签栏数据。并提供添加和删除标签栏数据的方法。
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 文件。它主要进行用户数据的存储,并提供多个处理用户数据的方法。
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页面。
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 文件代码如下所示
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 可用的动态路由数组。
/**
* 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
示例 转换前的用户可访问菜单信息如下
{
"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": []
}
]
}
示例 转换后的动态路由信息如下
[
{
"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请求流程如下
- 页面点击某个按钮,从而发起Http请求
- 调用统一管理的业务请求文件中的方法;
- 使用封装的 request.js 发送请求;
- 获取服务端返回的响应数据;
- 处理响应数据。
axios相关的代码主要分为 axios封装 + 具体的业务请求 + 发送请求
axios封装
axios封装部分主要是设置axios的全局设置。例如request 拦截器和response 拦截器等。
/api/request.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 用户请求文件举例。
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 代码如下所示
<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服务器之间设置一个代理服务器,将前端发送的请求转发到后端服务器,从而绕过浏览器的同源策略限制。
解决方案主要通过以下几个部分配合实现:
- 环境配置文件
在.env.development和.env.production文件中,设置统一的API基础路径。从而确保在不同环境下,前端请求都通过/api前缀发送,方便后续的代理配置。
# 接口路径首地址
VITE_APP_API_BASEURL = /api
- Vite配置文件中的代理设置
在vite.config.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替换为空字符串
}
}
}
}
})
跨域工作流程
跨域工作流程如下
- 前端页面发送请求
- 由于环境配置文件设置了基础API路径为 /api。因此请求路径会加上 /api 前缀。
- Vite开发服务器拦截所有以/api开头的请求
- Vite开发服务器根据代理配置,将请求转发到目标服务器
http://127.0.0.1:38080
,并自动去掉URL中的/api前缀 - 后端处理请求并返回响应。
例如
- 前端发送的请求:
http://localhost:31000/api/shuyx-user/user/pagelist
- 通过代理转发后:
http://localhost:38080/shuyx-user/user/pagelist
生成环境中的跨域处理
需要注意的是,上述通过Vite代理解决跨域的方案仅适用于开发环境。
在生产环境中,跨域问题通常有以下几种解决方案:
- 后端CORS配置:后端服务器配置CORS(跨域资源共享),允许特定来源的请求访问资源
- Nginx反向代理:在生产环境中部署Nginx服务器,通过Nginx的反向代理功能。将前端请求通过Nginx服务器转发到后端服务器,类似于开发环境中的Vite代理
- 同域名部署:将前端应用和后端API服务部署在同一个域名和端口下,从根本上避免跨域问题
在实际项目中,我们通常会根据具体的部署环境和安全要求,选择合适的跨域解决方案。
记住账号功能
功能介绍
用户登录时若勾选“记住账号”功能选项,则将登录名和密码(加密后)存入本地cookie。下次登录页面加载时自动获取保存好的账号和密码(需解密),回写到登录输入框中。
实现思路
- 当用户再次进入到登录页面的时候,会先判断cookie中是否存在相关键值对。若存在,则把键值对的数据赋值到输入框中。若不存在,则无动作。
- 当用户点击登录按钮的时候。
- 若用户已点击记住账号选项框。则把用户输入的账号,密码(加密)存储到cookie中。
- 若用户没有点击记住账号选项。则把cookie中的对应键值对删除。
相关代码
// 引入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
如何新增页面
- 在views目录中自定义一个页面。
- 在 /router/index.js 文件中新增一个路由。路由指向新增自定义页面
- 如果该页面中需要调用Http请求,你还需要先在 api 目录中创建相关请求文件。
- 然后再自定义页面中引入相关请求文件。并将其与页面中的元素进行绑定。
运行和打包
我们可以通过下面的命令对项目进行运行和打包
# 运行项目
npm run dev
# 打包项目
npm run build