vue3-admin-template项目说明

项目说明

  Vue3-admin-template是一个后台前端解决方案,基于最新(2022年)的前端技术构建,可以直接基于此项目进行二次开发。

技术栈

  1. 前端框架:Vue3
  2. UI 组件库:Element Plus
  3. 路由管理:Vue Router v4
  4. 状态管理:Pinia
  5. 网络管理:axios
  6. 前端开发与构建工具:Vite v2
  7. 编程语言:TypeScript

初始化项目

新建项目

  打开终端输入:

1
npm create vite@latest

  依次输入项目名称、框架即可。
vite创建工程
  这里选择vue + ts

启动项目

安装依赖包

1
npm install

运行

1
npm run dev

精简项目

  • index.html

  将<title>更改为vue3-admin-template

  • src->App.vue

  删除<script><template><style>中的内容。

1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts">

</script>

<template>

</template>

<style>

</style>
  • src->components->HelloWorld.vue

  删除该组件。

项目实现——极简版

路由配置

安装

1
npm install vue-router@4

新建登录页面

  • src->views->login->index.vue
1
2
3
4
5
6
7
8
9
<template>
登录
</template>

<script setup lang="ts">
</script>

<style>
</style>

  views文件夹通常存放的是页面级文件。

新建首页页面

  • src->views->dashbord->index.vue
1
2
3
4
5
6
7
8
9
<template>
首页
</template>

<script setup lang="ts">
</script>

<style>
</style>

新建全局布局系统

  新建src->layout->components文件夹。
  新建src->layout->index.vue

1
2
3
4
5
6
7
8
9
10
<template>
<!-- 路由显示 -->
<router-view/>
</template>

<script setup lang="ts">
</script>

<style>
</style>

新建路由配置文件

  新建src->router->index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'

// 导入全局布局组件
import Layout from '@/layout/index.vue'

export const constantRoutes: Array<RouteRecordRaw> | any = [
// 登录路由
{
path: '/login',
// 引入登录页面
component: () => import('@/views/login/index.vue'),
hidden: true
},
// 首页路由
{
path: '/',
// 使用全局组件布局
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
// 引入首页页面
component: () => import('@/views/dashboard/index.vue'),
name: 'Dashboard',
meta: { title: '首页', noCache: true, icon: 'dashboard', affix: true }
}
]
}
]

const router = createRouter({
history: createWebHashHistory(),
scrollBehavior: () => ({ top: 0 }),
routes: constantRoutes
})

export default router

  这里需要设置src文件夹别名为@
  增加tsconfig.jsoncompilerOptions键值对:

1
2
3
4
5
6
7
// 设置 @ 路径
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}

  修改vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// 导入path
const path = require('path');

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
// 配置路径别名
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

  如果Vscode编译器报错,需要安装依赖包:

1
npm i @types/node --D

  注:如果使用了Vite 3版本,且在package.json中使用了"type": "module"的配置,则将导入path改为import形式:

1
import path from 'path'

修改App.vue

1
2
3
4
5
6
7
8
9
10
<template>
<div id="app">
<!-- 路由显示 -->
<router-view/>
</div>
</template>

<style>

</style>

修改main.ts文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { createApp } from 'vue'

// 引入Vue App
import App from './App.vue'

// 引入路由
import router from './router'

// 创建Vue3实例
const app = createApp(App)

// 使用路由
app.use(router)
// 挂载到根组件上
app.mount('#app')

  实际效果图:
路由项目
  如图所示,在地址栏输入/login/即可切换路由。

配置初始路由为登录页

  添加src->permission.ts

1
2
3
4
5
6
7
import router from '@/router/index'

router.beforeEach((to, from, next) => {
const username = localStorage.getItem('username')
if (to.path !== '/login' && !username) next({ path: '/login' })
else next()
})

  当路由指向不是登录页且没有用户名信息时,将路由强制跳转至登录页,否则放行。

  • main.ts
1
import '@/permission'

  注:这里只是简单示例,没有添加实际的业务需求。下同。

其他配置

ELement UI Plus配置

  安装

1
npm install element-plus --save

  配置

  • main.ts
1
2
3
4
5
6
7
8
9
10
11
12
// 引入element-ui组件
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'dayjs/locale/zh-cn' // 中文
import locale from 'element-plus/lib/locale/lang/zh-cn' // 中文

// 创建Vue3实例
const app = createApp(App)
// 使用Element UI Plus
app.use(ElementPlus, { locale })
// 挂载到根组件上
app.mount('#app')

scss配置

  安装

1
npm i sass --save-dev

ESLint配置

安装

1
2
npm install eslint --save-dev
npm init @eslint/config

  这里使用的配置选项为:
ESLint配置选项

  注:如果使用了Vite 3版本,且在package.json中使用了"type": "module"的配置,则会在项目生成.eslintrc.cjs文件。
  如果勾选了TypeScript配置,则需要在.eslintrc.cjs文件中设置TypeScript的配置文件: Linting with Type Information

1
2
3
parserOptions: {
project: ['./tsconfig.json']
}

更新rules

  详见Github项目地址中的.eslintrc.js文件。仅供参考。
  在Vscode编译器中安装ESLint插件,并打开配置文件settings(ctrl+shift+p),增加以下配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
// eslint配置
"eslint.validate": [
"javascript",
"javascriptreact",
"vue",
"typescript"
],
"eslint.format.enable": true,
"eslint.alwaysShowStatus": true,
"editor.codeActionsOnSave": {
"source.fixAll": true,
"eslint.autoFixOnSave" : true,
}

  注:如果eslint没有运行,可以打开Vscode编译器右下角的ESlint终端查找错误。
  注:eslint-plugin-vue v9.0.0以上移除了vue/name-property-casing规则,如果在rules中配置了需要将其删除。vue/name-property-casing

登录页面

  • src->views->login->index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<template>
<div class="login-container">
<!-- 登录表单 -->
<el-form
ref="loginFormRef"
:model="loginInfo.loginForm"
class="login-form"
>
<!-- username表单选项 -->
<el-form-item prop="username">
<!-- username输入框 -->
<el-input
v-model="loginInfo.loginForm.username"
placeholder="用户名"
/>
</el-form-item>

<!-- password表单选项 -->
<el-form-item prop="password">
<!-- password输入框 -->
<el-input
v-model="loginInfo.loginForm.password"
placeholder="密码"
/>
</el-form-item>

<!-- 登录按钮 -->
<el-button
type="primary"
style="width:100%;margin-bottom:30px;font-size:18px"
@click.prevent="handleLogin(loginFormRef)"
>登录</el-button>
</el-form>
</div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'

import { ElMessage } from 'element-plus'
import type { ElForm } from 'element-plus'

// 表单引用 固定格式
type FormInstance = InstanceType<typeof ElForm>
const loginFormRef = ref<FormInstance>()

const router = useRouter()

// 登录页面数据
const loginInfo = reactive({
loginForm: {
username: '',
password: ''
}
})

// 处理登录事件
// 这里需要传入表单的ref
const handleLogin = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
if (loginInfo.loginForm.username.trim() !== '' && loginInfo.loginForm.password.trim() !== '') {
localStorage.setItem('username', loginInfo.loginForm.username)
// 跳转至首页
router.push({ path: '/dashboard' || '/' })
ElMessage.success('登录成功')
} else {
ElMessage.error('请输入正确的用户名和密码')
}
} else {
ElMessage.error('请输入正确的用户名和密码')
return false
}
})
}
</script>

<style lang="scss" scoped>
.login-container {
min-height: 100%;
width: 100%;
overflow: hidden;

background-color: white;

.login-form {
// 表单靠右居中
position: absolute;
right: 10%;
top: 35%;

width: 400px;
max-width: 100%;
padding: 20px 20px 0;
margin: 0 auto;
overflow: hidden;

// 添加外边框
border-radius: 10px;
border: 2px solid #008B93;
}
}
</style>

登录页
  这里只做了简单的表单验证,如果成功则跳转至首页,并在浏览器中记录用户名信息。

全局布局组件系统

全局布局组件
  全局布局文件整体分为侧边栏、导航栏、页面主体区域和状态栏四个部分。具体位置见上图所示。
  在src目录下新建layout文件夹,用于存放全局布局文件。然后在layout文件夹下,新建components文件夹和index.vue文件。其中components文件夹存放各个区域的布局文件,index.vue为出口文件。
  在components文件夹下,分别新建侧边栏布局文件夹Sidebar文件夹(并在文件夹内新建出口文件index.vue),主体区域布局文件AppMain.vue、导航栏布局文件Narvar.vue和状态栏布局文件Footerbar.vue

  layout的出口文件src->layout->index.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<!-- 全局布局组件 -->
<div :class="classObj" class="app-wrapper">
<!-- 侧边栏组件 -->
<Sidebar class="sidebar-container"/>

<!-- 主体区域 -->
<div class="main-container">
<div>
<!-- 导航栏 -->
<navbar class="navbar-container"/>
</div>

<!-- 页面主体区域 -->
<app-main />

<div class="footerbar-container">
<Footerbar />
</div>
</div>
</div>
</template>
1
2
3
4
import AppMain from './components/AppMain.vue'
import Navbar from './components/Navbar.vue'
import Sidebar from './components/Sidebar/index.vue'
import Footerbar from './components/Footerbar.vue'

  这里只显示布局文件和脚本文件,样式文件详见Github项目地址。下同。

主体区域布局文件

  • src->layout->components->AppMain.vue
1
2
3
4
5
6
<template>
<section class="app-main">
<!-- 路由显示 -->
<router-view/>
</section>
</template>

  这里存放路由配置文件中的具体路由显示。

状态栏布局文件

  • src->layout->components->Footerbar.vue
1
2
3
4
5
6
7
8
9
<template>
<!-- 状态栏组件 -->
<div class="footerbar">
<!-- 右下角状态栏 -->
<div class="right-menu">
<span class="right-menu-item hover-effect">状态栏</span>
</div>
</div>
</template>

  这里只做了简单的显示,可根据实际需求添加相应功能。

导航栏布局文件

  • src->layout->components->Narbar.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<div class="navbar">
<!-- 右上角菜单栏 -->
<div class="right-menu">
<span class="right-menu-item">欢迎你 {{ loginInfo.username }}</span>

<!-- 下拉框组件 -->
<el-dropdown class="avatar-container right-menu-item hover-effect">
<span class="avatar-wrapper">
<img src="@/assets/user.png" class="user-avatar">
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<!-- 下拉框菜单 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item divided @click="logout">
<span style="display:block;">退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { ArrowDown } from '@element-plus/icons-vue'
import { reactive } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()

const loginInfo = reactive({
username: window.localStorage.getItem('username')
})

const logout = () => {
window.localStorage.removeItem('username')
router.push('/login')
}

  这里只做了简单的信息显示和退出登录功能。

菜单栏组件系统

菜单栏组件
  菜单栏组件主要分为两部分,上面为项目图标和项目名称,下面为各个菜单组成的滚动条。

图标组件

  在Sidebar文件夹下新建Logo.vue文件:

  • src->layout->components->Sidebar->Logo.vue
1
2
3
4
5
6
7
8
9
10
<template>
<div class="sidebar-logo-container">
<!-- 跳转到首页 -->
<router-link key="collapse" class="sidebar-logo-link" to="/">
<!-- 图标 + 标题 -->
<img v-if="logoInfo.logo" :src="logoInfo.logo" class="sidebar-logo">
<h1 class="sidebar-title">{{ logoInfo.title }} </h1>
</router-link>
</div>
</template>

  这里使用<router-link>进行包裹区域,可以实现点击图标/标题跳转至首页。

1
2
3
4
5
6
7
8
9
10
<script setup lang="ts">
import { reactive } from 'vue'

const logoInfo = reactive({
title: 'vue3 admin template',
// vite获取静态资源路径
logo: new URL('../../../assets/logo.png', import.meta.url).href
})

</script>

  注意vite获取静态资源文件的方式。

菜单路由组件系统

  在Sidebar文件夹下,新建菜单项组件:SidebarItem.vue,菜单内容组件Item.vue和判断菜单类型组件Link.vue
  本项目主要包含3种路由类型:单级路由,多级路由和外部链接路由。因此,首选更改之前的路由配置文件:

  • src->router->index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'

// 导入全局布局组件
import Layout from '@/layout/index.vue'

export const constantRoutes: Array<RouteRecordRaw> | any = [
// 登录路由
{
path: '/login',
// 引入登录页面
component: () => import('@/views/login/index.vue'),
hidden: true
},
// 首页路由
{
path: '/',
// 使用全局组件布局
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
// 引入首页页面
component: () => import('@/views/dashboard/index.vue'),
name: 'Dashboard',
meta: { title: '首页', noCache: true, icon: 'dashboard', affix: true }
}
]
},
// 嵌套路由
{
path: '/nested',
component: Layout,
redirect: '/nested/menu1/menu1-1',
name: 'Nested',
meta: {
title: '嵌套路由',
icon: 'nested'
},
children: [
{
path: 'menu1',
component: () => import('@/views/nested/menu1/index.vue'), // Parent router-view
name: 'Menu1',
meta: { title: 'Menu 1' },
redirect: '/nested/menu1/menu1-1',
children: [
{
path: 'menu1-1',
component: () => import('@/views/nested/menu1/menu1-1/index.vue'),
name: 'Menu1-1',
meta: { title: 'Menu 1-1' }
},
{
path: 'menu1-2',
component: () => import('@/views/nested/menu1/menu1-2/index.vue'),
name: 'Menu1-2',
redirect: '/nested/menu1/menu1-2/menu1-2-1',
meta: { title: 'Menu 1-2' },
children: [
{
path: 'menu1-2-1',
component: () => import('@/views/nested/menu1/menu1-2/menu1-2-1/index.vue'),
name: 'Menu1-2-1',
meta: { title: 'Menu 1-2-1' }
},
{
path: 'menu1-2-2',
component: () => import('@/views/nested/menu1/menu1-2/menu1-2-2/index.vue'),
name: 'Menu1-2-2',
meta: { title: 'Menu 1-2-2' }
}
]
},
{
path: 'menu1-3',
component: () => import('@/views/nested/menu1/menu1-3/index.vue'),
name: 'Menu1-3',
meta: { title: 'Menu 1-3' }
}
]
},
{
path: 'menu2',
name: 'Menu2',
component: () => import('@/views/nested/menu2/index.vue'),
meta: { title: 'Menu 2' }
}
]
},
// 外部链接
{
path: '/github-link',
component: Layout,
children: [
{
path: 'https://github.com/Cxx0822/vue3-admin-template',
meta: { title: 'github', icon: 'github' }
}
]
}
]

const router = createRouter({
history: createWebHashHistory(),
scrollBehavior: () => ({ top: 0 }),
routes: constantRoutes
})

export default router
  • src->layout->components->Sidebar->index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div class="sidebar-container">
<!-- 左上角图标 -->
<logo />
<!-- 菜单滚动条区域 -->
<el-scrollbar wrap-class="scrollbar-wrapper">
<!-- 菜单栏 -->
<el-menu
:background-color="variables.menuBg"
:text-color="variables.menuText"
:unique-opened="false"
:active-text-color="variables.menuActiveText"
:collapse-transition="false"
mode="vertical"
>
<!-- 菜单项 -->
<sidebar-item v-for="route in constantRoutes" :key="route.path" :item="route" :base-path="route.path" />
</el-menu>
</el-scrollbar>
</div>
</template>
1
2
3
4
5
6
7
8
<script setup lang="ts">
import Logo from './Logo.vue'
import SidebarItem from './SidebarItem.vue'
import variables from '@/styles/variables.module.scss'

import { constantRoutes } from '@/router/index'

</script>

  菜单路由部分首先使用el-scrollbar滚动条组件包裹所有的el-menu组件,然后使用v-for指令添加路由配置文件的所有路由。

  • src->layout->components->Sidebar->SidebarItem.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<template>
<div v-if="!item.hidden">
<!-- 只有单个child -->
<template
v-if="hasOneShowingChild(item.children,item)
&& (!sidebarInfo.onlyOneChild.children
|| sidebarInfo.onlyOneChild.noShowingChildren)
&& !item.alwaysShow">
<!-- 判断是路由还是链接 -->
<app-link v-if="sidebarInfo.onlyOneChild.meta" :to="resolvePath(sidebarInfo.onlyOneChild.path)">
<!-- el-menu菜单项 -->
<el-menu-item :index="resolvePath(sidebarInfo.onlyOneChild.path)" :class="{'submenu-title-noDropdown':!props.isNest}">
<!-- 菜单内容 -->
<item
:icon="sidebarInfo.onlyOneChild.meta.icon || (item.meta && item.meta.icon)"
:title="sidebarInfo.onlyOneChild.meta.title" />
</el-menu-item>
</app-link>
</template>

<!-- 有多个children -->
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
<template v-slot:title>
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
</template>
<!-- 循环生成组件 -->
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-sub-menu>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<script setup lang="ts">
// vite 源码中设定了不允许在客户端代码中访问内置模块代码
// 使用 path-browserify 代替 path 模块
import path from 'path-browserify'
import Item from './Item.vue'
import AppLink from './Link.vue'

import { defineProps, reactive } from 'vue'

const props = defineProps({
// route object
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
})

const sidebarInfo = reactive({
onlyOneChild: null as any
})

const hasOneShowingChild = (children:Array<any> = [], parent:any) => {
const showingChildren = children.filter(item => {
if (item.hidden) {
return false
} else {
// Temp set(will be used if only has one showing child)
sidebarInfo.onlyOneChild = item
return true
}
})

// When there is only one child router, the child router is displayed by default
if (showingChildren.length === 1) {
return true
}

// Show parent if there are no child router to display
if (showingChildren.length === 0) {
sidebarInfo.onlyOneChild = { ...parent, path: '', noShowingChildren: true }
return true
}

return false
}

/**
* @param {string} path
* @returns {Boolean}
*/
const isExternal = (path:string):boolean => {
return /^(https?:|mailto:|tel:)/.test(path)
}

const resolvePath = (routePath:string) => {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
return path.resolve(props.basePath, routePath)
}
</script>

  在菜单项组件中,首先判断路由是否包含子路由,如果包含,则会循环遍历所有的子路由,否则会根据路由的性质判断是否是外部链接。

  • src->layout->components->Sidebar->Link.vue
1
2
3
4
5
6
<template>
<!-- 动态组件 is的值是哪个组件的名称就显示哪个组件 -->
<component :is="type" v-bind="linkProps(props.to)">
<slot />
</component>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup lang="ts">
import { computed, defineProps } from 'vue'

const props = defineProps({
to: {
type: String,
required: true
}
})

/**
* @param {string} path
* @returns {Boolean}
*/
// 判断是否是外部链接
const isExternal = (path:string):boolean => {
return /^(https?:|mailto:|tel:)/.test(path)
}

const isExternalValue = computed(() => isExternal(props.to))

// 决定组件类型
const type = computed(() => {
if (isExternalValue.value) {
return 'a'
}
return 'router-link'
})

const linkProps = (to:string) => {
if (isExternalValue.value) {
return {
href: to,
target: '_blank',
rel: 'noopener'
}
}
return {
to: to
}
}
</script>
  • src->layout->components->Sidebar->Item.vue
1
2
3
4
5
6
7
<template>
<div class="item-div">
<!-- 图标/图片 + 标题 -->
<img v-if="props.icon" :src="getImageUrl(props.icon)">
<span>{{ props.title }}</span>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup lang="ts">
import { defineProps } from 'vue'

const props = defineProps({
icon: {
type: String,
default: ''
},
title: {
type: String,
default: ''
}
})

// 获取图片路径
const getImageUrl = (icon:string):string => {
return '../../src/assets/' + icon + '.png'
}

</script>

  最后每个菜单内容由图标+标题组成。

项目实现——标准版

  在标准版中,会增加以下功能:

  1. 由于Element UI Plus系统本身提供的图标并不多,而且png图片会存在一定的失真,因此本项目使用svg管理所有的图标系统。
  2. 目前一些状态信息是存放在浏览器的localStorage中,安全性较低,本项目使用pinia管理项目中所有的信息系统。
  3. 优化全局布局组件系统,扩展功能。

全局图标系统

安装

1
npm install svg-sprite-loader -D

Svg图标组件

  在src文件夹下新建icons文件夹,用来处理全局图标系统。然后新建index.vue文件和svg文件夹,其中svg文件夹下存放所有的.svg图标文件。

  • src->icons->index.vue
1
2
3
4
5
<template>
<svg :class="svgClass" aria-hidden="true">
<use :xlink:href="iconName" />
</svg>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts">
import { computed, defineProps } from 'vue'

const props = defineProps({
iconClass: {
type: String,
required: true
},
className: {
type: String,
default: ''
}
})

const iconName = computed(() => `#icon-${props.iconClass}`)
const svgClass = computed(() => {
if (props.className) {
return 'svg-icon ' + props.className
} else {
return 'svg-icon'
}
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<style lang="scss" scoped>
.svg-icon {
/* 调整大小 */
width: 1.3em;
height: 1.3em;
vertical-align: -0.3em;
fill: currentColor;
overflow: hidden;
}

.svg-external-icon {
background-color: currentColor;
mask-size: cover!important;
display: inline-block;
}
</style>

vite配置

  在src文件夹下新建plugins,用于存放项目中的插件系统,然后新建svgBuilder.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import { Plugin } from 'vite'
import { readFileSync, readdirSync, PathLike } from 'fs'

// 参考:https://github.com/JetBrains/svg-sprite-loader/issues/434

let idPerfix = ''
const svgTitle = /<svg([^>+].*?)>/
const clearHeightWidth = /(width|height)="([^>+].*?)"/g

const hasViewBox = /(viewBox="[^>+].*?")/g

const clearReturn = /(\r)|(\n)/g

function findSvgFile(dir:PathLike): string[] {
const svgRes = []
const dirents = readdirSync(dir, {
withFileTypes: true
})
for (const dirent of dirents) {
if (dirent.isDirectory()) {
svgRes.push(...findSvgFile(dir + dirent.name + '/'))
} else {
const svg = readFileSync(dir + dirent.name)
.toString()
.replace(clearReturn, '')
.replace(svgTitle, ($1, $2) => {
// console.log(++i)
// console.log(dirent.name)
let width = 0
let height = 0
let content = $2.replace(
clearHeightWidth,
(s1:unknown, s2:string, s3:number) => {
if (s2 === 'width') {
width = s3
} else if (s2 === 'height') {
height = s3
}
return ''
}
)
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`
}
return `<symbol id="${idPerfix}-${dirent.name.replace(
'.svg',
''
)}" ${content}>`
})
.replace('</svg>', '</symbol>')
svgRes.push(svg)
}
}
return svgRes
}

export const svgBuilder = (
path: string,
perfix = 'icon'
): Plugin | null => {
if (path === '') return null
idPerfix = perfix
const res = findSvgFile(path)
// console.log(res.length)
// const res = []
return {
name: 'svg-transform',
transformIndexHtml(html): string {
return html.replace(
'<body>',
`
<body>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">
${res.join('')}
</svg>
`
)
}
}
}

  并在vite.config.ts中配置:

1
2
3
4
5
6
// 引入svgBuilder插件
import { svgBuilder } from './src/plugins/svgBuilder'

export default defineConfig({
plugins: [svgBuilder('./src/icons/svg/')], // 一次性添加所有的svg 无需在单独导入
})

  这里如果提示不能找到该ts文件,需要在tsconfig.node.json中添加该文件:

1
"include": ["vite.config.ts", "./src/plugins/svgBuilder.ts"]

main.ts配置

  最后将其配置到main.ts中,注册组件:

1
2
3
4
// 引入svg
import svgIcon from '@/icons/index.vue'
// 注册svg组件
app.component('svg-icon', svgIcon)

使用

  例如,在全局布局组件系统中的菜单栏组件所有的图标改成svg的形式:

  • src->layout->components->sidebar->item.vue
1
2
3
4
5
6
7
8
<template>
<div class="item-div">
<!-- 图标/图片 + 标题 -->
<i v-if="(props.icon).includes('el-icon')" class="sub-el-icon"></i>
<svg-icon v-else :icon-class="props.icon"></svg-icon>
<span>{{ props.title }}</span>
</div>
</template>

  这里主要通过路由配置文件中每个路由的icon参数来选择相应的图标。(默认已经将svg图标放在src->icons->svg中。)
  注1:如果不能看到效果,需要重启项目。
  注2:iconfont图标库

全局状态系统

安装

1
npm install pinia

main.ts配置

1
2
3
import { createPinia } from 'pinia'
// 使用Pinia
app.use(createPinia())

使用

  在src目录下新建store文件夹,存放所有的状态管理文件。
  pinia样例文件:

  • src->store->example.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineStore } from 'pinia'

// 规范写法:use[name]Store
export const useExampleStore = defineStore({
// store
id: 'example',
state: () => ({
key: 'value' as string,
}),
// getters
getters: {
newKey() {
this.key = newValue
}
},
// actions
actions: {
functionName(newValue:string) {
this.key = newValue
}
}
})

全局布局组件系统优化

增加菜单栏显示/隐藏功能

菜单栏显示和隐藏
  该功能主要是实现点击切换按钮时,菜单栏会只显示图标部分,隐藏所有的文字部分,再次点击时,会恢复初始状态,且菜单栏宽度保持自适应状态。

状态管理设置

  首先在src->store文件夹中,新建app.ts,用于存放当前设备的状态。

  • src->store->app.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { defineStore } from 'pinia'
// npm install js-cookie --save
import Cookies from 'js-cookie'

export const useAppStore = defineStore({
id: 'app',
state: () => ({
sidebar: {
// !!:转为布尔值
// 保存当前菜单栏显示和隐藏的状态
opened: Cookies.get('sidebarStatus') ? !!Cookies.get('sidebarStatus') : true as boolean,
withoutAnimation: false as boolean
},
device: 'desktop' as string
}),

actions: {
// 控制菜单栏显示和隐藏的切换
toggleSideBar() {
this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = false
if (this.sidebar.opened) {
Cookies.set('sidebarStatus', 1)
} else {
Cookies.set('sidebarStatus', 0)
}
}
}
})

切换组件实现

  然后在src文件夹中新建components文件夹,该文件夹主要存放非页面级的组件。
  在该文件夹下新建控制菜单栏显示切换的图标组件:

  • src->components->Hamburger->index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<!-- svg图标 -->
<svg
:class="{'is-active':props.isActive}"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
>
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
</svg>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
isActive: {
type: Boolean,
default: false
}
})

// 获取父组件函数
const emit = defineEmits(['toggleClick'])

// 切换按钮
const toggleClick = () => {
emit('toggleClick')
}

</script>

导航栏组件调整

  Hamburger组件主要是存放在导航栏组件的左侧,菜单栏组件的右侧,因此在父组件导航栏组件中,引入该图标组件:

  • src->layout->components->Narbar.vue
1
2
3
4
5
6
<!-- 控制菜单栏的显示 -->
<hamburger
id="hamburger-container"
:is-active="sidebar.opened"
class="hamburger-container"
@toggleClick="toggleSideBar" />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Hamburger from '@/components/Hamburger/index.vue'

import { computed, reactive } from 'vue'

import { useAppStore } from '@/store/app'

const appStore = useAppStore()

const sidebar = computed(() => appStore.sidebar)

// 侧边栏控制
const toggleSideBar = () => {
appStore.toggleSideBar()
}

  注:这里只显示和之前代码有变化的部分,下同。

菜单栏组件调整

  首先将Logo.vue组件设置为根据collapse状态控制是否显示标题:

  • src->layout->components->Sidebar->Logo.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}">
<!-- 跳转到首页 -->
<transition name="sidebarLogoFade">
<!-- 根据collapse控制Logo组件,即是否显示标题 -->
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logoInfo.logo" :src="logoInfo.logo" class="sidebar-logo">
<h1 v-else class="sidebar-title">{{ logoInfo.title }} </h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logoInfo.logo" :src="logoInfo.logo" class="sidebar-logo">
<h1 class="sidebar-title">{{ logoInfo.title }} </h1>
</router-link>
</transition>
</div>
</template>
1
2
3
4
5
6
7
8
import { defineProps, reactive } from 'vue'

defineProps({
collapse: {
type: Boolean,
require: true
}
})

  使用v-ifv-else指令,结合传入的collapse状态值,实时切换是否显示标题文字。
  然后修改Logo.vue的父组件index.vue,将状态管理中的sidebar值实时传递给Logo.vue组件:

1
2
<!-- 左上角图标 -->
<logo :collapse="isCollapse"/>
1
2
3
4
5
6
7
import { computed } from 'vue'

import { useAppStore } from '@/store/app'

const appStore = useAppStore()

const isCollapse = computed(() => !appStore.sidebar.opened)

  菜单选项组件的显示和隐藏主要通过样式来控制(src->style->sidebar.module.scss)。

增加导航栏面包屑功能

导航栏面包屑
  该功能主要实现在导航栏上实时显示当前路由的地址,如果是嵌套路由则会根据嵌套规则显示完整的路由地址,并且点击相应的路由标题时,会切换到相应的界面。

面包屑组件实现

  在src->compnents目录下新建Hamburger->index.vue组件,实现面包屑功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<!-- 面包屑组件 -->
<el-breadcrumb-item v-for="(item,index) in breadcrumbInfo.levelList" :key="item.path">
<span
v-if="item.redirect==='noRedirect'||index==breadcrumbInfo.levelList.length-1"
class="no-redirect">
{{ item.meta.title }}</span>
<a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<script setup lang="ts">
// npm install path-to-regexp
import * as pathToRegexp from 'path-to-regexp'

import { reactive, watch } from 'vue'

import { useRoute, useRouter, RouteRecordRaw } from 'vue-router'

const route = useRoute()
const router = useRouter()

const breadcrumbInfo = reactive({
levelList: [] as Array<RouteRecordRaw> | any
})

// 监测当前的路由变化
watch(route, () => {
// if you go to the redirect page, do not update the breadcrumbs
if (route.path.startsWith('/redirect/')) {
return
}
// 更新面包屑路由
getBreadcrumb()
})

const getBreadcrumb = () => {
// 只显示含有 meta.title 属性的路由
let matched:Array<any> = route.matched.filter(item => item.meta && item.meta.title)

const first = matched[0]

// 如果不是首页,则加上首页的面包屑
if (!isDashboard(first)) {
matched = [{ path: '/dashboard', meta: { title: '首页' }}].concat(matched)
}

// 再次过滤 满足指定条件
breadcrumbInfo.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
}

// 判断是否是首页路由
const isDashboard = (route:RouteRecordRaw) => {
const name = route && route.name as string
if (!name) {
return false
}
return name.trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
}

// 点击面包屑跳转路由
const handleLink = (item:RouteRecordRaw) => {
const { redirect, path } = item
// 如果存在 redirect 属性(此时是嵌套路由) 则直接跳转至该路由
if (redirect) {
router.push(redirect as string)
return
}
// 跳转至编译后的路由
router.push(pathCompile(path))
}

// 编译路由
const pathCompile = (path:string) => {
// 解决带有参数的路由跳转问题
// To solve this problem https://github.com/PanJiaChen/vue-element-admin/issues/561
const { params } = route
const toPath = pathToRegexp.compile(path)
return toPath(params)
}

// 初始化刷新路由
getBreadcrumb()
</script>

  其主要原理就是实时监测当前路由的变化,并根据当前路由的参数遍历生成面包屑路由,并将首页路由添加至面包屑开始位置,通过点击面包屑中显示的路由标题,实现路由的切换。

导航栏组件调整

  最后将其添加至父组件导航栏组件中:

  • src->components->Narbar.vue
1
<breadcrumb id="breadcrumb-container" class="breadcrumb-container" />
1
import Breadcrumb from '@/components/Breadcrumb/index.vue'

增加标签栏导航功能

标签栏导航
  该功能主要实现在导航栏上保存已经访问过的路由,点击相应标签即可切换至相应路由,并且可以实现关闭其他、全部关闭路由等功能。

路由标签状态管理

  首先在src->store文件夹中,新建tagsViews.ts,用于存放已访问的路由和缓存的路由,并实现添加路由、删除路由等功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import { defineStore } from 'pinia'
import { RouteRecordRaw, RouteRecordName, RouteLocationNormalizedLoaded } from 'vue-router'

interface TagsViewsIF extends RouteLocationNormalizedLoaded {
title?: string
meta: {
title?: string,
noCache?: boolean,
affix?: boolean
}
}

interface AllViewsTF {
visitedViews: Array<TagsViewsIF>,
cachedViews: Array<RouteRecordName | null | undefined>
}

// 标签栏导航
export const useTagsViewStore = defineStore({
id: 'tagsView',
state: () => ({
// 定义数据类型
// 用户访问过的页面
visitedViews: [] as Array<TagsViewsIF>,
// 实际keep-alive的路由
cachedViews: [] as Array<RouteRecordName | null | undefined>
}),
actions: {
// 增加导航路由
addView(view:TagsViewsIF) {
this.addVisitedView(view)
this.addCachedView(view)
},
addVisitedView(view:TagsViewsIF) {
if (this.visitedViews.some(v => v.path === view.path)) return

this.visitedViews.push(
Object.assign({}, view, { title: view.meta.title || 'no-name' })
)
},
addCachedView(view:TagsViewsIF) {
if (this.cachedViews.includes(view.name)) return
if (!view.meta.noCache) {
this.cachedViews.push(view.name)
}
},
// 删除导航路由
delView(view: TagsViewsIF) {
return new Promise<AllViewsTF>(resolve => {
this.delVisitedView(view)
this.delCachedView(view)

resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews]
})
})
},
delVisitedView(view: TagsViewsIF) {
return new Promise<Array<TagsViewsIF>>(resolve => {
for (const [i, v] of this.visitedViews.entries()) {
if (v.path === view.path) {
this.visitedViews.splice(i, 1)
break
}
}

resolve([...this.visitedViews])
})
},
delCachedView(view: TagsViewsIF) {
return new Promise<Array<RouteRecordName | null | undefined>>(resolve => {
const index = this.cachedViews.indexOf(view.name)
index > -1 && this.cachedViews.splice(index, 1)

resolve([...this.cachedViews])
})
},
delOthersViews(view: TagsViewsIF) {
return new Promise<AllViewsTF>(resolve => {
this.delOthersVisitedViews(view)
this.delOthersCachedViews(view)

resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews]
})
})
},
delOthersVisitedViews(view: TagsViewsIF) {
return new Promise<Array<TagsViewsIF>>(resolve => {
this.visitedViews = this.visitedViews.filter((v:any) => {
return v.meta.affix || v.path === view.path
})

resolve([...this.visitedViews])
})
},
delOthersCachedViews(view: TagsViewsIF) {
return new Promise<Array<RouteRecordName | null | undefined>>(resolve => {
const index = this.cachedViews.indexOf(view.name)
if (index > -1) {
this.cachedViews = this.cachedViews.slice(index, index + 1)
} else {
// if index = -1, there is no cached tags
this.cachedViews = []
}

resolve([...this.cachedViews])
})
},
// 删除所有
delAllViews() {
return new Promise<AllViewsTF>(resolve => {
this.delAllVisitedViews()
this.delAllCachedViews()

resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews]
})
})
},
delAllVisitedViews() {
return new Promise<Array<TagsViewsIF>>(resolve => {
const affixTags = this.visitedViews.filter((tag:any) => tag.meta.affix)
this.visitedViews = affixTags

resolve([...this.visitedViews])
})
},
delAllCachedViews() {
return new Promise<Array<RouteRecordName | null | undefined>>(resolve => {
this.cachedViews = []

resolve([...this.cachedViews])
})
},
// 更新导航视图
updateVisitedView(view: TagsViewsIF) {
for (let v of this.visitedViews) {
if (v.path === view.path) {
v = Object.assign(v, view)
break
}
}
}
}
})

标签组件实现

  在src->layout->components下新建TagsView文件夹,并在该文件夹下新建index.vueScrollPane.vue

  • src->layout->components->TagsView->ScrollPane.vue
1
2
3
4
5
6
7
8
9
<template>
<el-scrollbar
ref="scrollContainerRef"
:vertical="false"
class="scroll-container"
@wheel.native.prevent="handleScroll">
<slot />
</el-scrollbar>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts">
import { computed } from '@vue/reactivity'
import { ref } from 'vue'
import type { ElScrollbar } from 'element-plus'

const scrollContainerRef:any = ref<InstanceType<typeof ElScrollbar>>()

const scrollWrapper = computed(() => {
return scrollContainerRef.value.$refs.wrap$
})

// 处理鼠标滚动事件
const handleScroll = (e:any) => {
// 超出范围时 滚动向左向右平移
const eventDelta = e.wheelDelta || -e.deltaY * 40
scrollWrapper.value.scrollLeft -= eventDelta / 4
}
</script>
  • src->layout->components->TagsView->index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
<div id="tags-view-container" class="tags-view-container">
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
<!-- :to: 点击会跳转至相应路由 -->
<!-- click.middle.native: 按下鼠标中键关闭标签导航 -->
<router-link
v-for="tag in visitedViews"
ref="routerLinkRef"
:key="tag.path"
:class="isActive(tag) ? 'active' : '' "
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
class="tags-view-item"
@click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : '' "
@contextmenu.prevent.native="openMenu(tag, $event)"
>
{{ tag.title }}
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
</router-link>
</scroll-pane>
<!-- 下拉选择框 -->
<ul
v-show="tagsViewInfo.visible"
:style="{ left: tagsViewInfo.left + 'px',top: tagsViewInfo.top + 'px' }"
class="contextmenu">
<li @click="refreshSelectedTag(tagsViewInfo.selectedTag)">刷新</li>
<li v-if="!isAffix(tagsViewInfo.selectedTag)" @click="closeSelectedTag(tagsViewInfo.selectedTag)">关闭</li>
<li @click="closeOthersTags">关闭其他</li>
<li @click="closeAllTags(tagsViewInfo.selectedTag)">关闭所有</li>
</ul>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
<script setup lang="ts">
import ScrollPane from './ScrollPane.vue'
import { nextTick, onMounted, reactive, ref, watch, getCurrentInstance, ComponentInternalInstance } from 'vue'
import { computed } from '@vue/reactivity'

import { useRoute, useRouter, RouteRecordRaw, RouteRecordName, RouteLocationNormalizedLoaded } from 'vue-router'
import { useTagsViewStore } from '@/store/tagsViews'

import { constantRoutes } from '@/router/index'

// 使用 path-browserify 代替 path 模块
import path from 'path-browserify'

const route = useRoute()
const router = useRouter()

const tagsViewStore = useTagsViewStore()

const routerLinkRef = ref<any>([])
const scrollPaneRef = ref()

// 获取当前实例
// setup执行时 组件还没有创建 无法直接获取实例
const { proxy } = getCurrentInstance() as ComponentInternalInstance

interface TagsViewsIF extends RouteLocationNormalizedLoaded {
title?: string
meta: {
title?: string,
noCache?: boolean,
affix?: boolean
}
}

const tagsViewInfo = reactive({
visible: false,
top: 0,
left: 0,
selectedTag: {} as TagsViewsIF,
affixTags: [] as Array<TagsViewsIF>
})

// 访问过的路由
const visitedViews = computed(() => {
return tagsViewStore.visitedViews
})

const routes = computed(() => {
return constantRoutes
})

// 监视路由变化
watch(route, () => {
addTags()
})

// 监视 tagsViewInfo 对象的 visible 值
watch(() => tagsViewInfo.visible, (newValue) => {
if (newValue) {
document.body.addEventListener('click', closeMenu)
} else {
document.body.removeEventListener('click', closeMenu)
}
})

// 初始化
onMounted(() => {
initTags()
addTags()
})

// 初始化标签
const initTags = () => {
// 获取需要展示在标签导航中的路由(meta 的 affix 属性值为 true)
const affixTags = tagsViewInfo.affixTags = filterAffixTags(routes.value)
for (const tag of affixTags) {
// Must have tag name
if (tag.name) {
// 存储初始化的标签
tagsViewStore.addVisitedView(tag)
}
}
}

// 将当前路由存储至标签中
const addTags = () => {
const { name } = route
if (name) {
tagsViewStore.addView(route)
}
return false
}

// 判断当前路由是否处于激活状态
const isActive = (currentRoute:TagsViewsIF) => {
return currentRoute.path === route.path
}

// 判断标签是否固定
const isAffix = (tag:TagsViewsIF) => {
return tag.meta && tag.meta.affix
}

// 过滤路由中含有 meta.affix 属性值的
const filterAffixTags = (routes:RouteRecordRaw[], basePath = '/') => {
let tags:Array<any> = []
routes.forEach(route => {
// 如果路由有 meta 属性值,且 meta 的 affix 属性值为 true
if (route.meta && route.meta.affix) {
const tagPath = path.resolve(basePath, route.path)
// 添加至 标签数组 中
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name as string,
meta: { ...route.meta }
})
}
// 如果路由有子路由
if (route.children) {
const tempTags = filterAffixTags(route.children, route.path)
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags]
}
}
})
return tags
}

// 刷新当前路由
const refreshSelectedTag = (view: TagsViewsIF) => {
tagsViewStore.delCachedView(view).then(() => {
const { fullPath } = view
nextTick(() => {
router.replace({
path: fullPath
})
})
})
}

// 关闭当前路由
const closeSelectedTag = (view: TagsViewsIF) => {
tagsViewStore.delView(view).then(({ visitedViews }) => {
if (isActive(view)) {
toLastView(visitedViews, view)
}
})
}

// 关闭其他路由
const closeOthersTags = () => {
router.push(tagsViewInfo.selectedTag)
tagsViewStore.delOthersViews(tagsViewInfo.selectedTag)
}

// 关闭所有路由
const closeAllTags = (view: TagsViewsIF) => {
tagsViewStore.delAllViews().then(({ visitedViews }) => {
if (tagsViewInfo.affixTags.some(tag => tag.path === view.path)) {
return
}
toLastView(visitedViews, view)
})
}

// 定位最后一次访问的路由
const toLastView = (visitedViews:Array<TagsViewsIF>, view: TagsViewsIF) => {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
router.push(latestView.fullPath)
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
if (view.name === 'Dashboard') {
// to reload home page
router.replace({ path: '/redirect' + view.fullPath })
} else {
router.push('/')
}
}
}

// 右击打开标签菜单
const openMenu = (tag:RouteLocationNormalizedLoaded, e:PointerEvent) => {
const menuMinWidth = 105
const offsetLeft = (proxy as any).$el.getBoundingClientRect().left // container margin left
const offsetWidth = (proxy as any).$el.offsetWidth // container width
const maxLeft = offsetWidth - menuMinWidth // left boundary
const left = e.clientX - offsetLeft + 15 // 15: margin right

if (left > maxLeft) {
tagsViewInfo.left = maxLeft
} else {
tagsViewInfo.left = left
}

tagsViewInfo.top = e.clientY
tagsViewInfo.visible = true
tagsViewInfo.selectedTag = tag
}

// 关闭下拉菜单
const closeMenu = () => {
tagsViewInfo.visible = false
}

// 处理滚动事件
const handleScroll = () => {
// 鼠标滚动时 关闭下拉菜单显示
closeMenu()
}
</script>

  其主要原理就是首先将访问过的路由依次显示在滚动条组件中,然后通过鼠标右击事件弹出功能菜单,并通过定义在tagsViewStore里面的功能实现相应的功能。

全局布局组件调整

  最后将其添加至父组件全局布局组件中:

  • src->layout->index.vue
1
2
3
4
5
<div>
<!-- 导航栏 -->
<navbar />
<tags-view/>
</div>
1
import TagsView from './components/TagsView/index.vue'

项目实现——完整版

全局网络请求管理系统

  本项目使用Axios作为网络请求库。

安装

1
npm install axios

封装拦截器

  在src/utils文件夹下新建request.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

const service = axios.create({
// URL地址
baseURL: process.env.VUE_APP_BASE_API,
// 连接时间
timeout: 5000
})

// 请求拦截器
service.interceptors.request.use(
(config: AxiosRequestConfig) => {
// const userStore = useUserStore()
// // 如果有token 则加上token值
// if (userStore.token) {
// config.headers['X-Token'] = getToken()
// }
return config
},

(error) => {
return Promise.reject(error)
}
)

// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 如果状态码不是20000
// 根据实际的后端接口确定状态码
if (res.code !== 20000) {
ElMessage({
message: res.message || 'Error',
type: 'error',
duration: 5 * 1000
})
return Promise.reject(new Error(res.message || 'Error'))
} else {
// 正确则返回数据
return res
}
},
(error) => {
ElMessage({
message: error.message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)

export default service

  首先配置Axios的根URL地址,由于项目在开发的过程中,开发环境和生产环境的服务器地址不同,因此需要在在项目中创建.env.development.env.production文件,配置不同的根URL地址:

  .env.development

1
2
3
4
5
# just a flag
ENV = 'development'

# base api
VUE_APP_BASE_API = 'http://127.0.0.1:8080'

  .env.production

1
2
3
4
5
# just a flag
ENV = 'production'

# base api
VUE_APP_BASE_API = 'http://10.0.0.0:8080'

  其中development为开发环境,production为生产环境。

  请求拦截器主要处理用户登录时Token的保存业务。
  响应拦截器主要处理获取服务器数据的业务。
  注:响应拦截器的的状态码需要根据实际后端业务设计来编写。如果没有,可以去掉该判断。

封装后端接口

  在项目文件夹下创建API文件夹src->api,并根据业务需求创建example.ts文件:

  src->api->example.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import request from '@/utils/request'

// get
export const functionName1 = ():any =>
request({
url: '/example',
method: 'get'
})

// post
export const functionName2 = (param:any):any =>
request({
url: '/example',
method: 'post',
params: { param }
})

// post
export const functionName3 = (param: any):any =>
request({
url: '/example',
method: 'post',
data: param
})

使用api

  在具体的业务文件中,调用该接口即可。注意由于Axios返回的是Promise对象,因此最好使用asyncawait来调用。

1
2
3
4
5
import { functionName1 } from '@/api/example'

async getFunctionName1() {
const baselineListRes = await functionName1()
}

参考资料

  1. Vite + Vue3 初体验 —— Vite 篇
谢谢老板!
-------------本文结束感谢您的阅读给个五星好评吧~~-------------