https://gitee.com/szxio/zx-vue-next
本项目是使用 vue3 来开发的后台管理系统模板。页面简单大方,使用悬浮式的风格,将菜单栏,顶部面包屑,中间操作区域等合理划分,功能丰富,支持主题颜色自定义,一键开启黑色主题,浅色、深色菜单动态切换等。路由采用动态路由,依托若依后端接口,拥有强大的权限管理功能。对若依感兴趣的点此跳转,希望各位小伙伴能够在学习本项目的过程中或多或少的有所收获。
如果感觉对你有所帮助,请点击 Star,感谢支持。
本文档同步至以下网站:
本项目后端借用了若依的后台框架,在她的基础上稍作了修改。
可以在本地启动本项目中的 java 代码。再启动前端查看效果。
若依启动成功截图
npm install
npm run dev
输入默认的账号密码
账号:admin
密码:admin123
使用 vite 来创建我们的工程
npm create vite@latest
或者
yarn create vite
然后按照提示操作即可!
官方文档:https://element-plus.gitee.io/zh-CN/
安装
cnpm install element-plus --save
引入
// main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')
实现组件自动导入
npm install -D unplugin-vue-components unplugin-auto-import
然后把下列代码插入到你的 Vite
的配置文件中
// vite.config.ts
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
// ...
plugins: [
// ...
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
安装
# NPM
$ npm install @element-plus/icons-vue
# Yarn
$ yarn add @element-plus/icons-vue
# pnpm
$ pnpm install @element-plus/icons-vue
注册所有组件
// main.ts
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
引入中文包:import lang from 'element-plus/lib/locale/lang/zh-cn'
,然后设置全局语言即可
import lang from 'element-plus/lib/locale/lang/zh-cn'
import ElementPlus from 'element-plus'
const app = createApp(App)
app.use(ElementPlus, {
locale: lang,
})
app.mount('#app')
官方文档:https://router.vuejs.org/zh/
安装
npm install vue-router@4
新建测试路由
// 1.从vue-router导出两个方法使用
import {createRouter, createWebHashHistory} from 'vue-router';
// 2.声明菜单数组
const routes = [
{
path: '/',
component: import("../view/home/home.vue")
},
{
path: '/about',
component: import("../view/about/about.vue")
},
]
// 3. 创建路由实例并传递 `routes` 配置
// 你可以在这里输入更多的配置,但我们在这里
// 暂时保持简单
const router = createRouter({
// 4. 内部提供了 history 模式的实现。为了简单起见,我们在这里使用 hash 模式。
history: createWebHashHistory(),
routes, // `routes: routes` 的缩写
})
// 导出路由
export default router;
引入
// main.ts
import router from "./router/index"
const app = createApp(App)
app.use(router)
app.mount('#app')
修改App.vue
<template>
<router-view/>
template>
首先安装依赖
npm install @types/node
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
//设置路径别名
const alias = {
'@': resolve(__dirname, './src'),
'*': resolve(''),
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue()
],
resolve: {alias},
})
安装
npm i --save-dev prettier
然后再根目录新建 .prettierrc
文件,内容如下
{
"printWidth": 180,
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"trailingComma": "es5"
}
然后以 webStorm 工具为例,使用 prettier
格式效果
npm install pinia
添加 src/stores/index.ts
import { createPinia } from 'pinia'
// 创建
const pinia = createPinia()
// 导出
export default pinia
引入
// main.ts
import {createApp} from 'vue'
import App from './App.vue'
// ...
import pinia from "./stores/index"
const app = createApp(App)
// ...
app.use(pinia)
app.mount('#app')
新建 src/store/routesList.ts
import { defineStore } from 'pinia'
// 第一个参数是应用程序中 store 的唯一 id
export const routesList = defineStore('routesList', {
state: () => ({
routesList: [],
}),
actions: {
// 设置路由集合
async setRouterList(data: any) {
this.routesList = data
},
},
})
然后再前置路由守卫中调用方法保存
import { routesList } from '../stores/routesList'
// 路由加载前
router.beforeEach(async (to, from, next) => {
const routerList = routesList()
await routerList.setRouterList(routes[0].children)
next()
})
新建 src/layout/menu/Menu.vue
<template>
<el-menu default-active="2" class="el-menu-vertical-demo" @open="handleOpen" @close="handleClose">
<template v-for="item in state.routerList" :key="item.path">
<el-sub-menu :index="item.path" v-if="item.children && item.children.length > 0" :key="item.path">
<template #title>
<span>{{ item.name }}span>
template>
<sub-menu :chil="item.children" />
el-sub-menu>
<template v-else>
<el-menu-item :index="item.path" :key="item.path">
<span>{{ item.name }}span>
el-menu-item>
template>
template>
el-menu>
template>
<script lang="ts" setup>
import { useRouter, useRoute } from 'vue-router'
import { Document, Menu as IconMenu, Location, Setting } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import { routesList } from '../../stores/routesList'
import SubMenu from './SubMenu.vue'
const state = reactive({
router: useRouter(),
routerList: routesList().routesList,
})
const handleOpen = (key: string, keyPath: string[]) => {
// console.log(key, keyPath)
}
const handleClose = (key: string, keyPath: string[]) => {
// console.log(key, keyPath)
}
script>
<style>
.el-menu {
border-right: 0;
width: 200px;
}
style>
新建 src/layout/menu/SubMenu.vue
<template>
<template v-for="val in props.chil">
<el-sub-menu :index="val.path" :key="val.path" v-if="val.children && val.children.length > 0">
<template #title>
<span>{{ val.name }}span>
template>
<sub-menu :chil="val.children" />
el-sub-menu>
<template v-else>
<el-menu-item :index="val.path" :key="val.path">
<span>{{ val.name }}span>
el-menu-item>
template>
template>
template>
<script lang="ts" setup>
import { defineProps } from 'vue'
const props = defineProps(['chil'])
script>
效果展示
首先设置两个角色:admin、common,分别表示管理员和普通用户。
创建 src/stores/userInfo.ts
,暂时写死一个用户数据
import { defineStore } from 'pinia'
// 第一个参数是应用程序中 store 的唯一 id
export const userInfo = defineStore('userInfo', {
state: () => ({
// 用户名称
userName: 'admin',
// 用户id
userId: 'zx-001',
// 用户权限 admin:管理员,common:普通用户
roles: ['common'],
// 用户头像
portrait:
'https://img2.baidu.com/it/u=1978192862,2048448374&fm=253&fmt=auto&app=138&f=JPEG?w=504&h=500',
}),
actions: {
// 设置用户信息
setUserInfo(info: any) {},
},
})
创建 src/stores/routesList.ts
文件,存放路由集合信息
import { defineStore } from 'pinia'
// 第一个参数是应用程序中 store 的唯一 id
export const routesList = defineStore('routesList', {
state: () => ({
routesList: [],
}),
actions: {
// 设置路由集合
async setRouterList(data: any) {
this.routesList = data
},
},
})
创建 src/router/routes.ts
菜单数据文件,其中 meta 里面有 roles 数组,表示只要用户的角色在这里就显示当前菜单
// src/router/routes.ts
import Layout from '../layout/index.vue'
import Parent from '../layout/routerview/Parent.vue'
/**
* meta 属性意义
* roles 设置那些权限可见。admin:管理员,common:普通职工
*/
export default [
{
path: '/',
name: 'router.home',
component: Layout,
redirect: '/home',
children: [
{
path: '/home',
meta: {
roles: ['admin', 'common'],
},
name: 'router.home',
component: () => import('../view/home/home.vue'),
},
{
path: '/about',
meta: {
roles: ['admin', 'common'],
},
name: 'router.about',
component: () => import('../view/about/about.vue'),
},
{
path: '/order',
meta: {
roles: ['admin', 'common'],
},
name: 'router.order',
component: Parent,
children: [
{
path: 'list',
meta: {
roles: ['admin', 'common'],
},
name: 'router.order_list',
component: () => import('../view/order/list.vue'),
},
{
path: 'stock',
meta: {
roles: ['admin', 'common'],
},
name: 'router.order_stock',
component: Parent,
children: [
{
path: 'price',
meta: {
roles: ['admin', 'common'],
},
name: 'router.order_stock_price',
component: () => import('../view/order/price.vue'),
},
],
},
],
},
{
path: '/system',
name: 'router.system',
meta: {
roles: ['admin'],
},
component: Parent,
children: [
{
path: 'menu',
meta: {
roles: ['admin'],
},
name: 'router.system_menu',
component: () => import('../view/system/menu.vue'),
},
{
path: 'role',
meta: {
roles: ['admin'],
},
name: 'router.system_role',
component: () => import('../view/system/role.vue'),
},
{
path: 'user',
meta: {
roles: ['admin'],
},
name: 'router.system_user',
component: () => import('../view/system/user.vue'),
},
{
path: 'dept',
meta: {
roles: ['admin'],
},
name: 'router.system_dept',
component: () => import('../view/system/dept.vue'),
},
],
},
],
},
]
修改 src/router/index.ts
文件如下
// 1.从vue-router导出两个方法使用
import { createRouter, createWebHashHistory } from 'vue-router'
import { routesList } from '../stores/routesList'
import routes from './routes'
// 3. 创建路由实例并传递 `routes` 配置
// 你可以在这里输入更多的配置,但我们在这里
// 暂时保持简单
const router = createRouter({
// 4. 内部提供了 history 模式的实现。为了简单起见,我们在这里使用 hash 模式。
history: createWebHashHistory(),
routes, // `routes: routes` 的缩写
})
// 路由加载前
router.beforeEach(async (to, from, next) => {
const routerList = routesList()
await routerList.setRouterList(routes[0].children)
next()
})
// 导出路由
export default router
添加 src/router/filterRouter.ts
文件,待会会用到这里面的方法
/**
* 判断路由 `meta.roles` 中是否包含当前登录用户权限字段
* @param roles 用户权限标识,在 userInfos(用户信息)的 roles(登录页登录时缓存到浏览器)数组
* @param route 当前循环时的路由项
* @returns 返回对比后有权限的路由项
*/
export function hasRoles(roles: any, route: any) {
if (route.meta && route.meta.roles)
return roles.some((role: any) => route.meta.roles.includes(role))
else return true
}
/**
* 获取当前用户权限标识去比对路由表,设置递归过滤有权限的路由
* @param routes 当前路由 children
* @param roles 用户权限标识,在 userInfos(用户信息)的 roles(登录页登录时缓存到浏览器)数组
* @returns 返回有权限的路由数组 `meta.roles` 中控制
*/
export function setFilterHasRolesMenu(routes: any, roles: any) {
const menu: any = []
routes.forEach((route: any) => {
const item = { ...route }
if (hasRoles(roles, item)) {
if (item.children) {
item.children = setFilterHasRolesMenu(item.children, roles)
}
menu.push(item)
}
})
return menu
}
/**
* 路由扁平化方法
* @param routes
*/
export function flatten(routes: any) {
return routes.reduce(
(arr: any, old: any) => arr.concat([old], flatten(old.children || [])),
[]
)
}
在 src/layout/menu/Menu.vue
组件中添加如下逻辑,首先进入页面触发 onBeforeMount,在该生命周期中调用 getRouterListByRole 方法获取菜单数据,getRouterListByRole 方法中又调用 setFilterHasRolesMenu 方法,来实现根据权限获取不同菜单
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import { onBeforeMount, reactive } from 'vue'
import { routesList } from '../../stores/routesList'
import { userInfo } from '../../stores/userInfo'
import SubMenu from './SubMenu.vue'
import { Menu as IconMenu } from '@element-plus/icons-vue'
import { setFilterHasRolesMenu } from '../../router/filterRouter'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const state = reactive({
routerList: [], // 路由数据
active: useRoute().path, // 根据路由默认选中菜单
})
onBeforeMount(() => {
// 获取当前的组件信息
let routerList = getRouterListByRole()
routerList.forEach((item: any) => {
if (item.children) {
resolvePath(item.path, item.children)
}
})
// 赋值
state.routerList = routerList
})
// 递归遍历深层菜单的路径
const resolvePath = (parentPath: string, children: Array<any>) => {
children.forEach((item: any) => {
item.path = parentPath + '/' + item.path
if (item.children) {
resolvePath(item.path, item.children)
}
})
}
// 路由更新时更新菜单选中
onBeforeRouteUpdate((to) => {
state.active = to.path
})
// 根据用户权限获取菜单数据
const getRouterListByRole = () => {
const roles = userInfo().roles
const routerList = JSON.parse(JSON.stringify(routesList().routesList))
return setFilterHasRolesMenu(routerList, roles)
}
效果显示
普通用户没有系统管理菜单
管理员可以看到系统管理
首先添加下面代码,作用是可以将内层的菜单设置为全路径
onBeforeMount(() => {
// 获取当前的组件信息
let routerList = JSON.parse(JSON.stringify(routesList().routesList))
routerList.forEach((item: any) => {
if (item.children) {
resolvePath(item.path, item.children)
}
})
// 赋值
state.routerList = routerList
})
// 递归遍历深层菜单的路径
const resolvePath = (parentPath: string, children: Array<any>) => {
children.forEach((item: any) => {
item.path = parentPath + '/' + item.path
if (item.children) {
resolvePath(item.path, item.children)
}
})
}
然后开启 Menu 组件的 router 模式即可
完整代码
<template>
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="(item, index) in state.breadcrumbList"
:key="index"
>
<span class="breadcrumb-text">{{ item.name }}span>
el-breadcrumb-item>
el-breadcrumb>
template>
<script lang="ts" setup>
import { watch, ref, reactive } from 'vue'
// 引入路由
import { useRoute } from 'vue-router'
const state = reactive({
breadcrumbList: [],
route: useRoute(),
})
// 初始化面包屑
const initBreadcrumbList = () => {
// route.matched 可以获取当前路由的完整路由表
state.breadcrumbList = state.route.matched.slice(1)
}
// 监听路由变化
watch(
state.route,
() => {
initBreadcrumbList()
},
{ deep: true, immediate: true }
)
script>
效果
<template>
<div class="full">
<el-icon :size="20" @click="handleFullScreen" class="icon-color">
<FullScreen />
el-icon>
div>
template>
<script setup>
import { reactive, computed } from 'vue'
const state = reactive({
fullscreen: false,
})
const handleFullScreen = () => {
let element = document.documentElement
// 判断是否已经是全屏
// 如果是全屏,退出
if (state.fullscreen) {
if (document.exitFullscreen) {
document.exitFullscreen()
} else if (document.webkitCancelFullScreen) {
document.webkitCancelFullScreen()
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
}
} else {
// 否则,进入全屏
if (element.requestFullscreen) {
element.requestFullscreen()
} else if (element.webkitRequestFullScreen) {
element.webkitRequestFullScreen()
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen()
} else if (element.msRequestFullscreen) {
// IE11
element.msRequestFullscreen()
}
}
// 改变当前全屏状态
state.fullscreen = !state.fullscreen
}
script>
<style scoped>
.full {
width: 35px;
height: 35px;
background: var(--el-color-primary-light-9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon-color {
color: var(--el-color-primary);
}
style>
添加一个初始化配置文件 src/stores/styleconfig.ts
import { defineStore } from 'pinia'
let state = {
// 默认 primary 主题颜色
primary: '#752bec',
// 白色背景
bgWhite: '#ffffff',
}
// 从缓存中读取预设的样式配置
const config = localStorage.getItem('styleConfig')
if (config) {
state = JSON.parse(config)
}
// 第一个参数是应用程序中 store 的唯一 id
export const styleConfig = defineStore('styleConfig', {
state: () => state,
})
新建一个工具文件 src/utils/theme.ts
import { ElMessage } from 'element-plus';
/**
* hex颜色转rgb颜色
* @param str 颜色值字符串
* @returns 返回处理后的颜色值
*/
export function hexToRgb(str: any) {
let hexs: any = '';
let reg = /^\#?[0-9A-Fa-f]{6}$/;
if (!reg.test(str)) return ElMessage.warning('输入错误的hex');
str = str.replace('#', '');
hexs = str.match(/../g);
for (let i = 0; i < 3; i++) hexs[i] = parseInt(hexs[i], 16);
return hexs;
}
/**
* rgb颜色转Hex颜色
* @param r 代表红色
* @param g 代表绿色
* @param b 代表蓝色
* @returns 返回处理后的颜色值
*/
export function rgbToHex(r: any, g: any, b: any) {
let reg = /^\d{1,3}$/;
if (!reg.test(r) || !reg.test(g) || !reg.test(b)) return ElMessage.warning('输入错误的rgb颜色值');
let hexs = [r.toString(16), g.toString(16), b.toString(16)];
for (let i = 0; i < 3; i++) if (hexs[i].length == 1) hexs[i] = `0${hexs[i]}`;
return `#${hexs.join('')}`;
}
/**
* 加深颜色值
* @param color 颜色值字符串
* @param level 加深的程度,限0-1之间
* @returns 返回处理后的颜色值
*/
export function getDarkColor(color: string, level: number) {
let reg = /^\#?[0-9A-Fa-f]{6}$/;
if (!reg.test(color)) return ElMessage.warning('输入错误的hex颜色值');
let rgb = hexToRgb(color);
for (let i = 0; i < 3; i++) rgb[i] = Math.floor(rgb[i] * (1 - level));
return rgbToHex(rgb[0], rgb[1], rgb[2]);
}
/**
* 变浅颜色值
* @param color 颜色值字符串
* @param level 加深的程度,限0-1之间
* @returns 返回处理后的颜色值
*/
export function getLightColor(color: string, level: number) {
let reg = /^\#?[0-9A-Fa-f]{6}$/;
if (!reg.test(color)) return ElMessage.warning('输入错误的hex颜色值');
let rgb = hexToRgb(color);
for (let i = 0; i < 3; i++) rgb[i] = Math.floor((255 - rgb[i]) * level + rgb[i]);
return rgbToHex(rgb[0], rgb[1], rgb[2]);
}
然后再新建一个配置文件 src/config/styleSetting.ts
import { styleConfig } from '../stores/styleconfig'
import { getLightColor } from '../utils/theme'
//设置主题颜色
export const setPrimaryColor = (color = '') => {
const el = document.documentElement
// 设置主题颜色变量
el.style.setProperty('--el-color-primary', color || styleConfig().primary)
// 颜色变浅
for (let i = 1; i <= 9; i++) {
el.style.setProperty(
`--el-color-primary-light-${i}`,
`${getLightColor(color || styleConfig().primary, i / 10)}`
)
}
}
// 设置主背景颜色
export const setBgWhite = (color = '') => {
const el = document.documentElement
el.style.setProperty('--el-color-bg-white', color || styleConfig().bgWhite)
}
// 页面加载时默认执行所有方法
const setStyle = () => {
setPrimaryColor()
setBgWhite()
}
export default setStyle
然后在 main.ts
中引入 styleSetting.ts
import setStyle from "./config/styleSetting";
app.use(setStyle)
然后在 css 中需要设置主颜色时,直接使用变量来代替颜色值,例如下面是设置菜单选中时的颜色
>>> .el-menu-item.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
transition: 0.5s;
}
>>> .el-menu-item.is-active:after {
content: '';
width: 5px;
height: 100%;
background-color: var(--el-color-primary);
position: absolute;
left: 0;
transition: 0.5s;
}
默认显示的主题颜色
然后写一个设置主题颜色的方法,来实时的更新 --el-color-primary
值,点击保存后把配置保存在缓存中
<template>
<el-icon :size="20" class="icon-color" @click="state.isShow = true">
<Brush />
el-icon>
<el-drawer
v-model="state.isShow"
title="主题设置"
direction="rtl"
size="380px"
:before-close="beforeClose"
>
<template #default>
<el-form
:model="state.config"
label-width="100px"
class="content"
label-position="left"
>
<el-form-item label="主题颜色">
<el-color-picker
v-model="state.config.primary"
@change="changePrimary"
/>
el-form-item>
el-form>
template>
<template #footer>
<div style="flex: auto">
<el-button @click="state.isShow = false">关闭el-button>
<el-button type="primary" @click="confirmClick">保存el-button>
div>
template>
el-drawer>
template>
<script lang="ts" setup>
import { reactive } from 'vue'
import { setPrimaryColor } from '../../config/styleSetting'
import { styleConfig } from '../../stores/styleconfig'
const state = reactive({
// 是否显示右侧设置框
isShow: false,
// 主题配置对象
config: {
// 默认 primary 主题颜色
primary: styleConfig().primary,
// 白色背景
bgWhite: styleConfig().bgWhite,
},
})
// 关闭设置框
const beforeClose = () => {
state.isShow = false
}
//修改主题色
const changePrimary = (color: string) => {
setPrimaryColor(color)
}
// 保存配置
const confirmClick = () => {
localStorage.setItem('styleConfig', JSON.stringify(state.config))
window.location.reload()
}
script>
<style scoped>
.icon-color {
width: 35px;
height: 35px;
background: var(--el-color-primary-light-9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-color-primary);
}
.content {
border-top: 1px solid var(--el-color-primary-light-6);
padding-top: 15px;
}
style>
修改一个颜色后,页面整体颜色都会发生变化
引入指定版本的 "vue-i18n": "^9.1.10"
,否则高版本会报错
npm install [email protected]
新建文件夹 src/i18n
,这个文件夹下新建如下文件:
内容分别如下
// pages/en.ts
export default {
login: {
login: 'login',
userName: 'userName',
password: 'password'
}
}
// pages/zh.ts
export default {
login: {
login: '登录',
userName: '用户名',
password: '密码'
}
}
// router/en.ts
export default {
router: {
title: 'ZX-SYSTEM',
home: 'home',
about: 'about',
order: 'mall management',
order_list: 'orderList',
order_stock: 'inventory',
order_stock_price: 'price',
system: 'system management',
system_menu: 'menu',
system_role: 'role',
system_user: 'user',
system_dept: 'department',
},
}
// router/en.ts
export default {
router: {
title: 'ZX-管理系统',
home: "首页",
about: "关于我",
order: "商城管理",
order_list: "订单列表",
order_stock: "库存管理",
order_stock_price: "价格管理",
system: "系统管理",
system_menu: "菜单管理",
system_role: "角色管理",
system_user: "用户管理",
system_dept: "部门管理",
}
}
然后在 index.ts 里面整合
// index.ts
import {createI18n} from 'vue-i18n'
import pagesEn from "./pages/en"
import pagesZh from "./pages/zh"
import layoutEn from "./router/en"
import layoutZh from "./router/zh"
/**
* ./pages 表示各个页面的国际化
* ./router 表示左侧菜单的国际化
*/
const messages = {
en: {
...pagesEn,
...layoutEn
},
zh: {
...pagesZh,
...layoutZh
},
}
const language = (navigator.language || 'en').toLocaleLowerCase() // 这是获取浏览器的语言
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('lang') || language.split('-')[0] || 'en', // 首先从缓存里拿,没有的话就用浏览器语言,
fallbackLocale: 'en', // 设置备用语言
messages,
})
export default i18n
在 main.ts
中引入
import i18n from "./i18n/index"
const app = createApp(App)
app.use(i18n)
app.mount('#app')
使用也非常简单,根据前缀不同,会自动的显示不同的语言
在页面中使用
<template>
<div>
<div>{{ t('login.userName') }}div>
div>
template>
<script lang="ts" setup>
import {useI18n} from 'vue-i18n'
const {t} = useI18n()
script>
在菜单中使用,修改 name 的值,不能写死为固定的中文名,而是改成国际化文件对应的属性
{
path: 'stock',
meta: {
roles: ['admin', 'common'],
},
name: 'router.order_stock',
component: Parent,
children: [
{
path: 'price',
meta: {
roles: ['admin', 'common'],
},
name: 'router.order_stock_price',
component: () => import('../view/order/price.vue'),
},
],
},
然后在组件中使用 t 转义
<template v-else>
<el-menu-item :index="item.path" :key="item.path">
<el-icon>
<icon-menu />
el-icon>
<span>{{ t(item.name) }}span>
el-menu-item>
template>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
然后可以添加一个方法,来切换中英文显示
<template>
<div class="full" @click="taggerLang">
<div class="icon-color">
{{ state.lang === 'zh' ? '中' : 'en' }}
div>
div>
template>
<script setup>
import {reactive, computed, onMounted} from 'vue'
const state = reactive({
lang: 'zh',
})
onMounted(() => {
const lang = localStorage.getItem("lang")
if (!lang || lang === 'zh') {
state.lang = 'zh'
} else {
state.lang = 'en'
}
})
const taggerLang = () => {
if (state.lang === 'zh') {
state.lang = 'en'
} else {
state.lang = 'zh'
}
localStorage.setItem("lang",state.lang)
window.location.reload()
}
script>
英文界面
中文界面
模拟实现从后端直接获取路由进行菜单展示
首先新建:src/api/testrouter/index.ts,这个文件用来模拟后端接口返回数据
// 管理员看到的菜单
export const adminRouter = () => {
return {
code: 200,
msg: '成功',
data: {
rows: [
{
path: '/home',
meta: {
roles: ['admin', 'common'],
title: 'router.home',
},
name: 'router.home',
component: 'view/home/home.vue',
},
{
path: '/about',
meta: {
roles: ['admin', 'common'],
title: 'router.about',
},
name: 'router.about',
component: 'view/about/about',
},
{
path: '/order',
meta: {
roles: ['admin', 'common'],
title: 'router.order',
},
name: 'router.order',
component: 'layout/routerview/Parent',
children: [
{
path: '/order/list',
meta: {
roles: ['admin', 'common'],
title: 'router.order_list',
},
name: 'router.order_list',
component: 'view/order/list',
},
{
path: '/order/stock',
meta: {
roles: ['admin', 'common'],
title: 'router.order_stock',
},
name: 'router.order_stock',
component: 'layout/routerview/Parent',
children: [
{
path: '/order/price',
meta: {
roles: ['admin', 'common'],
title: 'router.order_stock_price',
},
name: 'router.order_stock_price',
component: 'view/order/price',
},
],
},
],
},
{
path: '/system',
name: 'router.system',
meta: {
roles: ['admin'],
title: 'router.system',
},
component: 'layout/routerview/Parent',
children: [
{
path: '/system/menu',
meta: {
roles: ['admin'],
title: 'router.system_menu',
},
name: 'router.system_menu',
component: 'view/system/menu',
},
{
path: '/system/role',
meta: {
roles: ['admin'],
title: 'router.system_role',
},
name: 'router.system_role',
component: 'view/system/role',
},
{
path: '/system/user',
meta: {
roles: ['admin'],
title: 'router.system_user',
},
name: 'router.system_user',
component: 'view/system/user',
},
{
path: '/system/dept',
meta: {
roles: ['admin'],
title: 'router.system_dept',
},
name: 'router.system_dept',
component: 'view/system/dept',
},
],
},
],
},
}
}
// 普通用户看到的菜单
export const commonRouter = () => {
return {
code: 200,
msg: '成功',
data: {
rows: [
{
path: '/home',
meta: {
roles: ['admin', 'common'],
title: 'router.home',
},
name: 'router.home',
component: 'view/home/home.vue',
},
{
path: '/about',
meta: {
roles: ['admin', 'common'],
title: 'router.about',
},
name: 'router.about',
component: 'view/about/about',
},
{
path: '/order',
meta: {
roles: ['admin', 'common'],
title: 'router.order',
},
name: 'router.order',
component: 'layout/routerview/Parent',
children: [
{
path: '/order/list',
meta: {
roles: ['admin', 'common'],
title: 'router.order_list',
},
name: 'router.order_list',
component: 'view/order/list',
},
{
path: '/order/stock',
meta: {
roles: ['admin', 'common'],
title: 'router.order_stock',
},
name: 'router.order_stock',
component: 'layout/routerview/Parent',
children: [
{
path: '/order/price',
meta: {
roles: ['admin', 'common'],
title: 'router.order_stock_price',
},
name: 'router.order_stock_price',
component: 'view/order/price',
},
],
},
],
},
],
},
}
}
新建:src/api/menu/index.ts,这个里面来获取上面接口的返回值
import { adminRouter, commonRouter } from '../testrouter'
import { userInfo } from '../../stores/userInfo'
/**
* 模拟获取后端返回的路由集合
* @returns {Promise}
*/
export const getRouterListFun = () => {
const username = userInfo().getUserInfo().userName
return new Promise((resolve, reject) => {
if (username === 'admin') {
resolve(adminRouter())
} else {
resolve(commonRouter())
}
})
}
接着新建模拟登录接口:src/api/login/index.ts
export const getUerInfoFun = (parames: any) => {
return new Promise((resolve, reject) => {
// 获取登录表单传递过来的参数
console.log(parames)
resolve({
code: 200,
msg: '成功',
data: {
info: {
userName: 'admin',
photo:
'https://img2.baidu.com/it/u=1978192862,2048448374&fm=253&fmt=auto&app=138&f=JPEG?w=504&h=500',
time: new Date().getTime(),
roles: ['admin'],
authBtnList: ['btn.add', 'btn.del', 'btn.edit', 'btn.link'],
token: '123456',
},
},
})
})
}
新建 src/router/backEnd.ts,用于处理后端返回的数据
import { RouteRecordRaw } from 'vue-router'
import { Session } from '../utils/storage'
import { getRouterListFun } from '../api/menu'
import { useRequestOldRoutes } from '../stores/requestOldRoutes'
import { dynamicRoutes, notFoundAndNoPower } from './routes'
import { formatFlatteningRoutes, formatTwoStageRoutes, router } from './index'
import { routesList } from '../stores/routesList'
import { useTagsViewRoutes } from '../stores/tagsViewRoutes'
const layouModules: any = import.meta.glob('../layout/routerView/*.{vue,tsx}')
const viewsModules: any = import.meta.glob('../view/**/*.{vue,tsx}')
// 后端控制路由
/**
* 获取目录下的 .vue、.tsx 全部文件
* key是组件的地址,value为 component 函数
* @method import.meta.glob
* @link 参考:https://cn.vitejs.dev/guide/features.html#json
*/
const dynamicViewsModules: Record<string, Function> = Object.assign(
{},
{ ...layouModules },
{ ...viewsModules }
)
/**
* 后端控制路由:初始化方法,防止刷新时路由丢失
* @method NextLoading 界面 loading 动画开始执行
* @method useUserInfo().setUserInfos() 触发初始化用户信息 pinia
* @method useRequestOldRoutes().setRequestOldRoutes() 存储接口原始路由(未处理component),根据需求选择使用
* @method setAddRoute 添加动态路由
* @method setFilterMenuAndCacheTagsViewRoutes 设置路由到 vuex routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
*/
export async function initBackEndControlRoutes() {
// 无 token 停止执行下一步
if (!Session.get('token')) return false
// 获取路由菜单数据
const res: any = await getRouterListFun()
// 存储接口原始路由(未处理component),根据需求选择使用
await useRequestOldRoutes().setRequestOldRoutes(
JSON.parse(JSON.stringify(res.data.rows))
)
// 清空路由,避免出错
dynamicRoutes[0].children = []
// 处理路由(component),替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由
dynamicRoutes[0].children = await backEndComponent(res.data.rows)
// 添加动态路由
await setAddRoute()
// 设置路由到 vuex routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
await setFilterMenuAndCacheTagsViewRoutes()
}
/**
* 设置路由到 vuex routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
* @description 用于左侧菜单、横向菜单的显示
* @description 用于 tagsView、菜单搜索中:未过滤隐藏的(isHide)
*/
export function setFilterMenuAndCacheTagsViewRoutes() {
// 保存处理后的数据
routesList().setRouterList(dynamicRoutes[0].children)
setCacheTagsViewRoutes()
}
/**
* 缓存多级嵌套数组处理后的一维数组
* @description 用于 tagsView、菜单搜索中:未过滤隐藏的(isHide)
*/
export function setCacheTagsViewRoutes() {
const storesTagsView = useTagsViewRoutes()
storesTagsView.setTagsViewRoutes(
formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes))[0].children
)
}
/**
* 处理路由格式及添加捕获所有路由或 404 Not found 路由
* @description 替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由
* @returns 返回替换后的路由数组
*/
export function setFilterRouteEnd() {
let filterRouteEnd: any = formatTwoStageRoutes(
formatFlatteningRoutes(dynamicRoutes)
)
filterRouteEnd[0].children = [
...filterRouteEnd[0].children,
...notFoundAndNoPower,
]
return filterRouteEnd
}
/**
* 添加动态路由
* @method router.addRoute
* @description 此处循环为 dynamicRoutes(/@/router/route)第一个顶级 children 的路由一维数组,非多级嵌套
* @link 参考:https://next.router.vuejs.org/zh/api/#addroute
*/
export async function setAddRoute() {
await setFilterRouteEnd().forEach((route: RouteRecordRaw) => {
router.addRoute(route)
})
}
/**
* 后端路由 component 转换
* @param routes 后端返回的路由表数组
* @returns 返回处理成函数后的 component
*/
export function backEndComponent(routes: any) {
if (!routes) return
return routes.map((item: any) => {
if (item.component)
item.component = dynamicImport(
dynamicViewsModules,
item.component as string
)
item.children && backEndComponent(item.children)
return item
})
}
/**
* 后端路由 component 转换函数
* @param dynamicViewsModules 获取目录下的 .vue、.tsx 全部文件
* @param component 当前要处理项 component
* @returns 返回处理成函数后的 component
*/
export function dynamicImport(
dynamicViewsModules: Record<string, Function>,
component: string
) {
const keys = Object.keys(dynamicViewsModules)
const matchKeys = keys.filter((key) => {
const k = key.replace(/..\//, '')
return k.startsWith(`${component}`) || k.startsWith(`/${component}`)
})
if (matchKeys?.length === 1) {
const matchKey = matchKeys[0]
return dynamicViewsModules[matchKey]
}
if (matchKeys?.length > 1) {
return false
}
}
上面的代码中引用了下面的文件
src/stores/requestOldRoutes.ts
import { defineStore } from 'pinia'
/**
* 后端返回原始路由(未处理时)
* @methods setCacheKeepAlive 设置接口原始路由数据
*/
export const useRequestOldRoutes = defineStore('useRequestOldRoutes', {
state: () => ({
requestOldRoutes: [],
}),
actions: {
async setRequestOldRoutes(routes: any) {
this.requestOldRoutes = routes
},
},
})
src/stores/routesList.ts
import { defineStore } from 'pinia'
// 第一个参数是应用程序中 store 的唯一 id
export const routesList = defineStore('routesList', {
state: () => ({
routesList: [],
}),
actions: {
// 设置路由集合
setRouterList(data: any) {
this.routesList = data
},
},
})
src/stores/tagsViewRoutes.ts
import { defineStore } from 'pinia'
import { Session } from '../utils/storage'
/**
* TagsView 路由列表
* @methods setTagsViewRoutes 设置 TagsView 路由列表
* @methods setCurrenFullscreen 设置开启/关闭全屏时的 boolean 状态
*/
export const useTagsViewRoutes = defineStore('tagsViewRoutes', {
state: (): any => ({
tagsViewRoutes: [],
isTagsViewCurrenFull: false,
}),
actions: {
async setTagsViewRoutes(data: Array<string>) {
this.tagsViewRoutes = data
},
setCurrenFullscreen(bool: Boolean) {
Session.set('isTagsViewCurrenFull', bool)
this.isTagsViewCurrenFull = bool
},
},
})
然后改写 src/router/routes.ts,分成三个部分导出
import Layout from '../layout/index.vue'
// 动态路由
export const dynamicRoutes = [
{
path: '/',
name: '/',
component: Layout,
redirect: '/home',
meta: {
isKeepAlive: true,
title: '首页',
},
children: [],
},
]
// 定义404,401等路由
export const notFoundAndNoPower = [
{
path: '/:path(.*)*',
name: 'notFound',
component: () => import('@/view/error/404.vue'),
meta: {
title: '404',
isHide: true,
},
},
{
path: '/401',
name: 'noPower',
component: () => import('@/view/error/401.vue'),
meta: {
title: '404',
isHide: true,
},
},
]
/**
* 定义静态路由(默认路由)
*/
export const staticRoutes = [
{
path: '/login',
name: 'router.login',
component: () => import('../view/login/index.vue'),
meta: {
title: 'router.login',
},
},
]
修改 src/router/index.ts,默认只加载一个 staticRoutes
// 1.从vue-router导出两个方法使用
import { createRouter, createWebHashHistory, useRouter } from 'vue-router'
import { routesList } from '../stores/routesList'
import { staticRoutes } from './routes'
import { Session } from '../utils/storage'
import { initBackEndControlRoutes } from './backEnd'
export const router = createRouter({
// 4. 内部提供了 history 模式的实现。为了简单起见,我们在这里使用 hash 模式。
history: createWebHashHistory(),
routes: staticRoutes, // 加载静态路由
})
/**
* 路由多级嵌套数组处理成一维数组
* @param arr 传入路由菜单数据数组
* @returns 返回处理后的一维路由菜单数组
*/
export function formatFlatteningRoutes(arr: any) {
if (arr.length <= 0) return false
for (let i = 0; i < arr.length; i++) {
if (arr[i].children) {
arr = arr.slice(0, i + 1).concat(arr[i].children, arr.slice(i + 1))
}
}
return arr
}
/**
* 一维数组处理成多级嵌套数组(只保留二级:也就是二级以上全部处理成只有二级,keep-alive 支持二级缓存)
* @description isKeepAlive 处理 `name` 值,进行缓存。顶级关闭,全部不缓存
* @link 参考:https://v3.cn.vuejs.org/api/built-in-components.html#keep-alive
* @param arr 处理后的一维路由菜单数组
* @returns 返回将一维数组重新处理成 `定义动态路由(dynamicRoutes)` 的格式
*/
export function formatTwoStageRoutes(arr: any) {
if (arr.length <= 0) return false
const newArr: any = []
const cacheList: Array<string> = []
arr.forEach((v: any) => {
if (v.path === '/') {
newArr.push({
component: v.component,
name: v.name,
path: v.path,
redirect: v.redirect,
meta: v.meta,
children: [],
})
} else {
// 判断是否是动态路由(xx/:id/:name),用于 tagsView 等中使用
if (v.path.indexOf('/:') > -1) {
v.meta['isDynamic'] = true
v.meta['isDynamicPath'] = v.path
}
newArr[0].children.push({ ...v })
}
})
return newArr
}
// 路由加载前
router.beforeEach(async (to, from, next) => {
const token = Session.get('token')
if (to.path === '/login' && !token) {
next()
} else {
if (!token) {
next(
`/login?redirect=${to.path}¶ms=${JSON.stringify(
to.query ? to.query : to.params
)}`
)
Session.clear()
} else if (token && to.path === '/login') {
next('/home')
} else {
// 判断pinia中是否有路由信息
if (routesList().routesList.length === 0) {
// 后端控制路由:路由数据初始化,防止刷新时丢失
await initBackEndControlRoutes()
// 动态添加路由:防止非首页刷新时跳转回首页的问题
next({ ...to, replace: true })
} else {
next()
}
}
}
})
// 导出路由
export default router
最后添加登录页面
<template>
<div>
<el-button type="primary" @click="login">登录el-button>
div>
template>
<script lang="ts" setup>
import { reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getUerInfoFun } from '../../api/login'
import { userInfo } from '../../stores/userInfo'
import { initBackEndControlRoutes } from '../../router/backEnd'
const state = reactive({
router: useRouter(),
route: useRoute(),
loginForm: {
username: 'admin',
password: '123456',
code: 1234,
},
})
const login = () => {
// 获取用户信息
getUerInfoFun(state.loginForm).then(async (res: any) => {
// 保存用户基本信息
await userInfo().setUserInfos(res.data.info)
// 获取路由信息
await initBackEndControlRoutes()
// 进行路由跳转
siginSuccess()
})
}
const siginSuccess = () => {
// 跳转到上次关闭的页面
if (state.route.query?.redirect) {
state.router.push({
path: <string>state.route.query?.redirect,
query:
Object.keys(<string>state.route.query?.params).length > 0
? JSON.parse(<string>state.route.query?.params)
: '',
})
} else {
// 跳转到首页
state.router.push('/')
}
}
script>
<template>
<span>{{ userInfo().getUserInfo().userName }}span>
<el-dropdown>
<div class="user-img">
<img :src="userInfo().getUserInfo().photo" />
div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="logOut"
>{{ t('router.log_out') }}
el-dropdown-item>
el-dropdown-menu>
template>
el-dropdown>
template>
<script setup>
import { Session } from '@/utils/storage'
import { userInfo } from '@/stores/userInfo'
import { useI18n } from 'vue-i18n'
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const state = reactive({
router: useRouter(),
})
const logOut = () => {
Session.clear()
state.router.push({
path: '/login',
})
}
script>
<style scoped lang="scss">
.user-img {
width: 44px;
height: 44px;
border-radius: 50px;
border: 2px var(--el-color-primary) solid;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
border-radius: 50px;
}
}
style>
首先我们直接写死一个用户名为 test 的用户来登录
查看菜单,没有系统管理的菜单
然后再用 admin 登录
修改后,重新登录查看菜单
首先创建 scss 变量文件 primary.module.scss
$primary-color: var(--el-color-primary);
:export {
primaryColor: $primary-color
}
需要注意的是,在 vite
创建的项目中,如果你想在 js
里引用 scss
文件,需要在后缀前加上 .module
。
然后再 js 中引入,html 中直接使用对应的变量即可
<el-table
:header-cell-style="{ background: exCss.primaryColor, color: '#fff' }"
:data="state.tableData"
stripe
style="width: 100%"
>
<el-table-column prop="date" label="Date" width="180" />
<el-table-column prop="name" label="Name" width="180" />
<el-table-column prop="address" label="Address" />
el-table>
import exCss from '@/style/module/primary.module.scss'
我这里做了一个表头的背景色跟着主题色变化的功能
npm install -D unplugin-vue-components unplugin-auto-import
// vite.config.ts
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import {ElementPlusResolver} from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
// Auto import functions from Vue, e.g. ref, reactive, toRef...
// 自动导入 Vue 相关函数,如:ref, reactive, toRef 等
imports: ['vue'],
// Auto import functions from Element Plus, e.g. ElMessage, ElMessageBox... (with style)
// 自动导入 Element Plus 相关函数,如:ElMessage, ElMessageBox... (带样式)
resolvers: [
ElementPlusResolver(),
],
}),
Components({
resolvers: [
// 自动导入 Element Plus 组件
ElementPlusResolver(),
],
}),
]
})
重启项目后会自动生成两个文件
其中 auto-import.d.ts 文件里面声明了所有可以自动引入的 Api
设置完成后,在页面使用 reactive,ref,onMounted 等函数时,无需从 vue 中导出,可以直接使用。示例如下
在使用过程中也通过 webStorm 可以看到改函数的来源
<template>
<div>
count:{{ state.total }}
<el-button @click="add">添加el-button>
div>
template>
<script setup>
const state = reactive({
total: 0
})
const add = () => {
state.total += 1
}
script>
效果在页面中可以正常显示,控制台也没有报错
首先添加css样式文件 src/style/loading.scss
.loading-next {
position: absolute;
display: flex;
width: 100vw;
height: 100vh;
z-index: 99999;
&::before {
content: "";
width: 100%;
height: 100%;
background-color: black;
opacity: 0.6;
z-index: -1;
}
}
.loading-next .loading-next-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.loading-next .loading-next-box-warp {
width: 80px;
height: 80px;
}
.loading-next .loading-next-box-warp .loading-next-box-item {
width: 33.333333%;
height: 33.333333%;
background: var(--el-color-primary);
float: left;
animation: loading-next-animation 1.2s infinite ease;
border-radius: 1px;
}
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(7) {
animation-delay: 0s;
}
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(4),
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(8) {
animation-delay: 0.1s;
}
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(1),
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(5),
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(9) {
animation-delay: 0.2s;
}
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(2),
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(6) {
animation-delay: 0.3s;
}
.loading-next .loading-next-box-warp .loading-next-box-item:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes loading-next-animation {
0%,
70%,
100% {
transform: scale3D(1, 1, 1);
}
35% {
transform: scale3D(0, 0, 1);
}
}
添加 src/utils/loading.ts
import { nextTick } from 'vue'
import '../style/loading.scss'
/**
* 页面全局 Loading
* @method start 创建 loading
* @method done 移除 loading
*/
export const zxLoading = {
// 创建 loading
show: () => {
const bodys: Element = document.body
const div = <HTMLElement>document.createElement('div')
div.setAttribute('class', 'loading-next')
const htmls = `
`
div.innerHTML = htmls
bodys.insertBefore(div, bodys.childNodes[0])
},
// 移除 loading
hidden: () => {
nextTick(() => {
const el = <HTMLElement>document.querySelector('.loading-next')
el?.parentNode?.removeChild(el)
})
},
}
使用
import { zxLoading } from '@/utils/loading'
zxLoading.show()
setTimeout(()=>{
zxLoading.hidden()
},2000)
<template>
<div class="err-text">
<div>404div>
div>
template>
<style scoped>
.err-text {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 70px;
/*设置动画名称,多少时间内完成,infinite:无限循环播放*/
animation: zoom 0.7s infinite;
/*开启反向动画*/
animation-direction: alternate;
}
@keyframes zoom {
0% {
font-size: 70px;
color: rgba(242, 80, 80, 0.96);
}
100% {
font-size: 120px;
color: #8935ea;
}
}
style>
效果展示