1.导入依赖
package.json
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "^0.27.2",
"core-js": "^3.8.3",
"echarts": "^5.4.3",
"element-plus": "^2.2.17",
"js-cookie": "^3.0.1",
"less": "^4.1.3",
"less-loader": "^11.0.0",
"nprogress": "^0.2.0",
"particles.vue3": "1.22.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"save": "^2.9.0",
"screenfull": "^5.1.0",
"style-resources-loader": "^1.5.0",
"svg-sprite-loader": "^6.0.11",
"dayjs": "^1.11.10",
"normalize.css": "^8.0.1",
"qs": "^6.11.2",
"vue": "^3.2.37",
"vue-axios": "^3.4.1",
"vue-router": "^4.1.5"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"scss": "^0.2.4",
"mockjs": "^1.1.0",
"svg-sprite-loader": "^6.0.11",
"unplugin-auto-import": "^0.11.2",
"unplugin-vue-components": "^0.22.7"
},
2.全局声明
main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 路由
import router from '@/router';
app.use(router);
//添加路由守卫
import "@/permission"
// pinia
import pinia from '@/store'
app.use(pinia)
// element-plus
import ElementPlus from 'element-plus'; //引入Element-ui
import zhCN from "element-plus/dist/locale/zh-cn.mjs" //引入中文
import 'element-plus/dist/index.css'
app.use(ElementPlus, { locale: zhCN })
//elementplus的icon图标引入
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 导入svgicon
import SvgIcon from "@/assets/icons/index"
SvgIcon(app)
//导入normalize.css
import "normalize.css"
//消除默认样式
import "@/assets/styles/base.scss";
// 动态背景 particles
import Particles from "particles.vue3";
//引入mock
require("@/mock") //引入mock数据,关闭则注释该行
//导入时间格式化插件
import dayjs from "dayjs"
app.use(dayjs)//可以全局使用dayjs
//防抖
const debounce = (fn, delay) => {
let timer = null;
return function () {
let context = this;
let args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
}
}
const _ResizeObserver = window.ResizeObserver;
window.ResizeObserver = class ResizeObserver extends _ResizeObserver {
constructor(callback) {
callback = debounce(callback, 16);
super(callback);
}
}
// 分页组件
import Pagination from '@/components/Pagination/index.vue'
// 全局组件挂载
app.component('Pagination', Pagination)
import { parseTime, resetForm, addDateRange, handleTree, selectDictLabel, selectDictLabels } from '@/utils/ruoyi'
// 全局方法挂载
app.config.globalProperties.parseTime = parseTime
app.config.globalProperties.resetForm = resetForm
app.config.globalProperties.handleTree = handleTree
app.config.globalProperties.addDateRange = addDateRange
app.config.globalProperties.selectDictLabel = selectDictLabel
app.config.globalProperties.selectDictLabels = selectDictLabels
app.mount('#app')
3.修改index.html
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>
<%= htmlWebpackPlugin.options.title %>
</title>
</head>
<style>
#loader-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999999;
}
#loader {
display: block;
position: relative;
left: 50%;
top: 50%;
width: 120px;
height: 120px;
margin: -75px 0 0 -75px;
border-radius: 50%;
border: 3px solid transparent;
/* COLOR 1 */
border-top-color: #FFF;
-webkit-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-ms-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-moz-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-o-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
animation: spin 2s linear infinite;
/* Chrome, Firefox 16+, IE 10+, Opera */
z-index: 1001;
}
#loader:before {
content: "";
position: absolute;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
border-radius: 50%;
border: 3px solid transparent;
/* COLOR 2 */
border-top-color: #FFF;
-webkit-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-moz-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-o-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-ms-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
animation: spin 3s linear infinite;
/* Chrome, Firefox 16+, IE 10+, Opera */
}
#loader:after {
content: "";
position: absolute;
top: 15px;
left: 15px;
right: 15px;
bottom: 15px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: #FFF;
/* COLOR 3 */
-moz-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-o-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-ms-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-webkit-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
animation: spin 1.5s linear infinite;
/* Chrome, Firefox 16+, IE 10+, Opera */
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(0deg);
/* IE 9 */
transform: rotate(0deg);
/* Firefox 16+, IE 10+, Opera */
}
100% {
-webkit-transform: rotate(360deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(360deg);
/* IE 9 */
transform: rotate(360deg);
/* Firefox 16+, IE 10+, Opera */
}
}
@keyframes spin {
0% {
-webkit-transform: rotate(0deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(0deg);
/* IE 9 */
transform: rotate(0deg);
/* Firefox 16+, IE 10+, Opera */
}
100% {
-webkit-transform: rotate(360deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(360deg);
/* IE 9 */
transform: rotate(360deg);
/* Firefox 16+, IE 10+, Opera */
}
}
#loader-wrapper .loader-section {
position: fixed;
top: 0;
width: 51%;
height: 100%;
background: #1890FF;
/* Old browsers */
z-index: 1000;
-webkit-transform: translateX(0);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateX(0);
/* IE 9 */
transform: translateX(0);
/* Firefox 16+, IE 10+, Opera */
}
#loader-wrapper .loader-section.section-left {
left: 0;
}
#loader-wrapper .loader-section.section-right {
right: 0;
}
/* Loaded */
.loaded #loader-wrapper .loader-section.section-left {
-webkit-transform: translateX(-100%);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateX(-100%);
/* IE 9 */
transform: translateX(-100%);
/* Firefox 16+, IE 10+, Opera */
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader-wrapper .loader-section.section-right {
-webkit-transform: translateX(100%);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateX(100%);
/* IE 9 */
transform: translateX(100%);
/* Firefox 16+, IE 10+, Opera */
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader {
opacity: 0;
-webkit-transition: all 0.3s ease-out;
transition: all 0.3s ease-out;
}
.loaded #loader-wrapper {
visibility: hidden;
-webkit-transform: translateY(-100%);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateY(-100%);
/* IE 9 */
transform: translateY(-100%);
/* Firefox 16+, IE 10+, Opera */
-webkit-transition: all 0.3s 1s ease-out;
transition: all 0.3s 1s ease-out;
}
/* JavaScript Turned Off */
.no-js #loader-wrapper {
display: none;
}
.no-js h1 {
color: #222222;
}
#loader-wrapper .load_title {
font-family: 'Open Sans';
color: #FFF;
font-size: 14px;
width: 100%;
text-align: center;
z-index: 9999999999999;
position: absolute;
top: 60%;
opacity: 1;
line-height: 30px;
}
#loader-wrapper .load_title span {
font-weight: normal;
font-style: italic;
font-size: 14px;
color: #FFF;
opacity: 0.5;
}
/* 滚动条优化 start */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f6f6f6;
border-radius: 2px;
}
::-webkit-scrollbar-thumb {
background: #cdcdcd;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #747474;
}
::-webkit-scrollbar-corner {
background: #f6f6f6;
}
/* 滚动条优化 end */
</style>
<body>
<div id="app">
<div id="loader-wrapper">
<div id="loader"></div>
<div class="loader-section section-left"></div>
<div class="loader-section section-right"></div>
<div class="load_title">正在加载 后台管理系统 请耐心等待...
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
4.修改App.vue
<template>
<div>
<router-view></router-view>
</div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>
5.新建路由
router/index.js
import { createRouter, createWebHistory } from 'vue-router';
export const routes = [
{
path: "/Login",
name: "Login",
component: () => import("@/views/Login/Index.vue"),
meta: {
hide: true
}
},
{
path: "/404",
name: "404",
component: () => import("@/views/404/Index.vue"),
meta: {
hide: true
}
}
]
const router = createRouter({
routes: routes,
history: createWebHistory()
})
export default router;
6.request封装
utils/request.js
import axios from "axios"
import { useUserStore } from "@/store"
import { tansParams } from '@/utils/ruoyi'
import { ElMessage, ElMessageBox } from "element-plus"
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
// axios中请求配置有baseURL选项,表示请求URL公共部分
// baseURL: process.env.VUE_APP_BASE_URL,
// 超时
timeout: 10000
})
//请求拦截器
service.interceptors.request.use(
config => {
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false
const hasToken = useUserStore().token
if (hasToken && !isToken) {
config.headers['Authorization'] = 'Bearer ' + hasToken // 让每个请求携带自定义token 请根据实际情况自行修改
}
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?' + tansParams(config.params);
url = url.slice(0, -1);
config.params = {};
config.url = url;
}
return config
},
error => {
return Promise.reject(new Error(error))
})
//响应拦截器
service.interceptors.response.use(res => {
// 未设置状态码则默认成功状态
const code = res.data.code || 200;
// 获取错误信息
const msg = res.data.msg
if (code === 401) {
ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' })
.then(() => {
useUserStore().Logout().then(() => {
location.href = '/';
})
})
.catch(() => {
});
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
}
else if (code === 500) {
ElMessage({ message: msg, type: 'error' })
return Promise.reject(new Error(msg))
} else if (code === 601) {
ElMessage({ message: msg, type: 'warning' })
return Promise.reject(new Error(msg))
} else if (code !== 200) {
ElNotification.error({ title: msg })
return Promise.reject('error')
} else {
return Promise.resolve(res.data)
}
},
error => {
console.log('err' + error)
let { message } = error;
if (message == "Network Error") {
message = "后端接口连接异常";
} else if (message.includes("timeout")) {
message = "系统接口请求超时";
} else if (message.includes("Request failed with status code")) {
message = "系统接口" + message.substr(message.length - 3) + "异常";
}
ElMessage({ message: message, type: 'error', duration: 5 * 1000 })
return Promise.reject(error)
}
)
export default service
7.登录页面
views/Lgoin/Index.vue
<template>
<div class="login_body">
<Particles id="tsparticles" :options="particles_login"> </Particles>
<div class="login_form">
<h1 class="login_form_title">后台管理系统</h1>
<el-form
ref="loginRef"
:model="loginForm"
:rules="loginRules"
label-width="auto"
label-position="top"
status-icon
size="large"
>
<div class="login_form_item">
<el-form-item label="用户名:" prop="username">
<el-input v-model="loginForm.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码:" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
/>
</el-form-item>
<div class="login_form_button">
<el-button type="primary" @click="handleLogin">登录</el-button>
</div>
</div>
</el-form>
</div>
<!-- 底部 -->
<div class="el-login-footer">
<span>Copyright © 2018-2023 tayo.com All Rights Reserved.</span>
</div>
</div>
</template>
<script setup>
import { ref, getCurrentInstance, watch } from "vue";
import { particles_login } from "@/utils/particles-config.js";
import {useUserStore} from "@/store";
import { useRoute, useRouter } from "vue-router";
const userStore = useUserStore();
const route = useRoute();
const router = useRouter();
const { proxy } = getCurrentInstance();
const redirect = ref(undefined);
const loginForm = ref({
username: "admin",
password: "admin123",
});
const loginRules = ref({
username: { required: true, message: "请输入用户名", trigger: "change" },
password: { required: true, message: "请输入密码", trigger: "change" },
});
watch(
route,
(newRoute) => {
redirect.value = newRoute.query && newRoute.query.redirect;
},
{ immediate: true }
);
const handleLogin = () => {
proxy.$refs.loginRef.validate((valid) => {
if (valid) {
// 调用action的登录方法
userStore
.Login(loginForm.value)
.then(() => {
router.push({ path: redirect.value || "/" });
})
.catch(() => {
router.push("/Login")
});
}
});
};
</script>
<style lang="less" scoped>
.login_body {
height: 100vh;
width: 100vw;
background-size: cover;
background-repeat: no-repeat;
background-image: url("../../assets/images/login-background.png");
position: relative;
display: flex;
justify-content: center;
align-items: center;
.login_form {
z-index: 2;
position: absolute;
width: 35vw;
padding: 30px 15px;
background: #fff;
border-radius: 5px;
box-shadow: 0 2px 12px 0 black;
.login_form_title {
margin-bottom: 13px;
text-align: center;
}
.login_form_item {
margin: 0 16px;
}
.login_form_button {
width: 100%;
.el-button {
width: 100%;
}
}
}
}
.el-login-footer {
height: 40px;
line-height: 40px;
position: fixed;
bottom: 0;
width: 100%;
text-align: center;
color: #fff;
font-family: Arial;
font-size: 12px;
letter-spacing: 1px;
}
</style>
状态管理和发送请求
store/modules/user.js
import { defineStore } from 'pinia';
import { login, logout } from "@/api/login"
import { getCookies, setCookies, removeCookies } from "@/utils/cookie.js";
// 用户模块
export const useUserStore = defineStore('user', {
state: () => ({
token: getCookies("token"),
username: "",
avatar: ""
}),
actions: {
// 登录
Login(userInfo) {
const username = userInfo.username.trim()
const password = userInfo.password
return new Promise((resolve, reject) => {
login({ username, password }).then((res) => {
setCookies("token", res.data.token)
this.token = res.data.token
resolve(res)
}).catch((err) => {
reject(err)
});
})
},
// 获取用户信息
GetInfo() {
return new Promise((resolve, reject) => {
getInfo().then(res => {
const user = res.data.user
this.username = user.userName
this.avatar = avatar;
resolve(res)
}).catch(error => {
reject(error)
})
})
},
// 退出系统
Logout() {
return new Promise((resolve, reject) => {
logout().then(() => {
this.token = ''
removeCookies(token)
resolve()
}).catch(error => {
reject(error)
})
})
},
}
})
8.前置路由拦截
permission.js
import { useUserStore, usePermissionStore } from '@/store'
import { start, close } from "@/utils/nprogress.js";
import router from "@/router"
import { setRouterPackag } from "@/utils/user"
// 记录路由
let hasRoles = true
// 白名单(不需要登录就可以访问的名单)
const whiteList = ['/Login'];
router.beforeEach(async (to, from, next) => {
start();
//获取token
const token = useUserStore().token;
//判断是否登录
if (token) {
//如果等录还去登录页,直接返回首页
if (to.path === '/Login') {
next({ path: '/' })
} else {
//调用获取信息的action,重新获取动态路由
await usePermissionStore().SetDynamicRoute()
//拿到动态添加的路由
let routes = usePermissionStore().menuList
if (hasRoles) {
//重新添加动态路由
setRouterPackag(routes);
hasRoles = false
next({ ...to, replace: true })
} else {
next()
}
}
} else {
//如果没有登录,如果要去登录页,直接放行
if (whiteList.includes(to.path)) {
next()
} else {
next(`/Login`)
}
}
})
router.afterEach(() => {
close();
});
router.onError((err) => {
close();
});
utils/user.js
import Layout from "@/layout/Index.vue";
import router from "@/router";
// 添加动态路由
export const setRouterPackag = (list = []) => {
const routers = getRoutes(JSON.parse(JSON.stringify(list)));
if (routers && routers.length > 0) {
for (let i = 0; i < routers.length; i++) {
if (isRouter(routers[i])) {
router.addRoute(routers[i]);
}
}
}
// 404页面
const notPath = { path: "/:catchAll(.*)", redirect: "/404" };
if (isRouter(notPath)) {
router.addRoute(notPath);
}
};
// 处理获取到路由的参数
const getRoutes = (list) => {
for (let i = 0; i < list.length; i++) {
if (list[i].component === "Layout") {
list[i].component = Layout;
} else if (list[i].component) {
list[i].component = import(`@/views${list[i].component}.vue`)
}
if (list[i].children && list[i].children.length > 0) {
getRoutes(list[i].children)
}
}
return list
};
// 判断路由是否有重复值
const isRouter = (item = {}) => {
const routers = router.getRoutes();
if (item && Object.keys(item).length > 0) {
const index = routers.findIndex((x) => x.path === item.path);
if (index !== -1) {
return false;
}
return true;
}
return false;
};
mock.js
const Mock = require('mockjs')
const Random = Mock.Random//获取随机数
//定义返回数据格式
let Result = {
code: 200,
msg: "操作成功",
data: null
}
var getQuery = (url, name) => {
const index = url.indexOf("?");
if (index !== -1) {
const queryStrArr = url.substr(index + 1).split("&");
for (var i = 0; i < queryStrArr.length; i++) {
const itemArr = queryStrArr[i].split("=");
if (itemArr[0] === name) {
return itemArr[1];
}
}
}
return null;
};
//登录操作处理
Mock.mock(/\login/, "post", (data) => {
const username = getQuery(data.url, "username");
const password = getQuery(data.url, "password");
Result.data = {
token: Random.string(32),
username,
password
}
return Result
})
//获取用户信息
Mock.mock("/getInfo", "get", () => {
Result.data = {
user: {
avatar: "@/assets/images/profile.jpg",
username: "杨"
}
}
return Result
})
//退出
Mock.mock("/logout", "get", () => {
return Result
})
//获取路由信息
Mock.mock("/getRouters", "get", () => {
Result.data = [
{
path: "/",
name: "Home",
redirect: "/Home",
component: "Layout",
children: [
{
path: "/Home",
component: "/Home/Index",
meta: {
title: "首页",
icon: "HomeFilled",
affix: true
},
},
{
path: "/System",
redirect: "/System/User",
meta: {
title: "系统管理",
icon: "Operation",
},
children: [
{
path: "/System/User",
name: "UserIndex",
component: "/System/User/Index",
meta: {
title: "用户管理",
icon: "UserFilled",
},
},
{
path: "/System/Role",
name: "RoleIndex",
component: "/System/Role/Index",
meta: {
title: "角色管理",
icon: "Memo",
},
},
{
path: "/System/Menu",
name: "MenuIndex",
component: "/System/Menu/Index",
meta: {
title: "菜单管理",
icon: "Menu",
},
},
],
},
{
path: "/food",
redirect: "/food/Dish",
meta: {
title: "饭堂管理",
icon: "Tools",
},
children: [
{
path: "/food/ChefCommission",
name: "ChefCommission",
component: "/food/ChefCommission",
meta: {
title: "日菜谱管理",
icon: "Memo",
},
},
{
path: "/food/Dish",
name: "Dish",
component: "/food/Dish",
meta: {
title: "小炒管理",
icon: "Memo",
},
},
{
path: "/food/Menu",
name: "Menu",
component: "/food/Menu",
meta: {
title: "菜谱管理",
icon: "Memo",
},
},
{
path: "/food/Notice",
name: "Notice",
component: "/food/Notice",
meta: {
title: "公告管理",
icon: "Memo",
},
},
{
path: "/food/Order",
name: "Order",
component: "/food/Order",
meta: {
title: "订单管理",
icon: "Memo",
},
},
{
path: "/food/WorkingMeal",
name: "WorkingMeal",
component: "/food/WorkingMeal",
meta: {
title: "工作餐管理",
icon: "Memo",
},
},
{
path: "/food/Comment",
name: "Comment",
component: "/food/Comment",
meta: {
title: "评论管理",
icon: "Memo",
},
},
]
},
],
}
]
return Result
})
Mock.mock("/getMenuList", "get", () => {
Result.data = [
{
id: 1,
created: "2021-01-15T18:58:18",
updated: "2021-01-15T18:58:20",
statu: 1,
parentId: 0,
name: "系统管理",
path: "",
perms: "sys:manage",
component: "",
type: 0,
icon: "el-icon-s-operation",
ordernum: 1,
children: [
{
id: 2,
created: "2021-01-15T19:03:45",
updated: "2021-01-15T19:03:48",
statu: 1,
parentId: 1,
name: "用户管理",
path: "/sys/users",
perms: "sys:user:list",
component: "sys/User",
type: 1,
icon: "el-icon-s-custom",
ordernum: 1,
children: [
{
id: 9,
created: "2021-01-17T21:48:32",
updated: null,
statu: 1,
parentId: 2,
name: "添加用户",
path: null,
perms: "sys:user:save",
component: null,
type: 2,
icon: null,
ordernum: 1,
children: [],
},
{
id: 10,
created: "2021-01-17T21:49:03",
updated: "2021-01-17T21:53:04",
statu: 1,
parentId: 2,
name: "修改用户",
path: null,
perms: "sys:user:update",
component: null,
type: 2,
icon: null,
ordernum: 2,
children: [],
},
{
id: 11,
created: "2021-01-17T21:49:21",
updated: null,
statu: 1,
parentId: 2,
name: "删除用户",
path: null,
perms: "sys:user:delete",
component: null,
type: 2,
icon: null,
ordernum: 3,
children: [],
},
{
id: 12,
created: "2021-01-17T21:49:58",
updated: null,
statu: 1,
parentId: 2,
name: "分配角色",
path: null,
perms: "sys:user:role",
component: null,
type: 2,
icon: null,
ordernum: 4,
children: [],
},
{
id: 13,
created: "2021-01-17T21:50:36",
updated: null,
statu: 1,
parentId: 2,
name: "重置密码",
path: null,
perms: "sys:user:repass",
component: null,
type: 2,
icon: null,
ordernum: 5,
children: [],
},
],
},
{
id: 3,
created: "2021-01-15T19:03:45",
updated: "2021-01-15T19:03:48",
statu: 1,
parentId: 1,
name: "角色管理",
path: "/sys/roles",
perms: "sys:role:list",
component: "sys/Role",
type: 1,
icon: "el-icon-rank",
ordernum: 2,
children: [],
},
],
},
{
id: 5,
created: "2021-01-15T19:06:11",
updated: null,
statu: 1,
parentId: 0,
name: "系统工具",
path: "",
perms: "sys:tools",
component: null,
type: 0,
icon: "el-icon-s-tools",
ordernum: 2,
children: [
{
id: 6,
created: "2021-01-15T19:07:18",
updated: "2021-01-18T16:32:13",
statu: 1,
parentId: 5,
name: "数字字典",
path: "/sys/dicts",
perms: "sys:dict:list",
component: "sys/Dict",
type: 1,
icon: "el-icon-s-order",
ordernum: 1,
children: [],
},
],
},
]
return Result
})
9.动态面包屑
TagsView/index.vue
<template>
<div class="tags-view-container">
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper">
<router-link
v-for="tag in visitedViews"
:key="tag.path"
tag="span"
:class="isActive(tag) ? 'active' : ''"
:to="{ path: tag.path }"
class="tags-view-item"
:style="activeStyle(tag)"
@contextmenu.prevent="openMenu(tag, $event)"
>
{{ tag.title }}
<span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
<close
class="el-icon-close"
style="width: 1em; height: 1em; vertical-align: middle"
/>
</span>
</router-link>
</scroll-pane>
<ul
v-show="visible"
:style="{ left: left + 'px', top: top + 'px' }"
class="contextmenu"
>
<li @click="closeAllTags(selectedTag)">
<circle-close style="width: 1em; height: 1em" /> 全部关闭
</li>
</ul>
</div>
</template>
<script setup>
import ScrollPane from "./ScrollPane.vue";
import { ref, computed, watch, getCurrentInstance } from "vue";
import { useTagsViewStore, useStore } from "@/store";
import { useRoute, useRouter } from "vue-router";
const visible = ref(false);
const top = ref(0);
const left = ref(0);
const selectedTag = ref({});
const affixTags = ref([]);
const { proxy } = getCurrentInstance();
const route = useRoute();
const router = useRouter();
const visitedViews = computed(() => useTagsViewStore().visitedViews);
const theme = useStore().theme;
watch(route, () => {
addTags();
});
watch(visible, (value) => {
if (value) {
document.body.addEventListener("click", closeMenu);
} else {
document.body.removeEventListener("click", closeMenu);
}
});
function addTags() {
const { name } = route;
if (name) {
useTagsViewStore().addView(route);
if (route.meta.link) {
useTagsViewStore().addIframeView(route);
}
}
return false;
}
function isActive(r) {
return r.path === route.path;
}
function activeStyle(tag) {
if (!isActive(tag)) return {};
return {
"background-color": theme,
"border-color": theme,
};
}
function isAffix(tag) {
return tag.meta && tag.meta.affix;
}
function closeSelectedTag(view) {
useTagsViewStore()
.delView(view)
.then(({ visitedViews }) => {
if (isActive(view)) {
toLastView(visitedViews, view);
}
});
}
function toLastView(visitedViews, view) {
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 === "Home") {
// to reload home page
router.replace({ path: "/redirect" + view.fullPath });
} else {
router.push("/");
}
}
}
function openMenu(tag, e) {
top.value = e.clientY;
left.value = e.clientX;
visible.value = true;
selectedTag.value = tag;
}
function closeMenu() {
visible.value = false;
}
function closeAllTags(view) {
useTagsViewStore()
.delAllViews()
.then(({ visitedViews }) => {
if (affixTags.value.some((tag) => tag.path === route.path)) {
return;
}
toLastView(visitedViews, view);
});
}
</script>
<style lang='scss' scoped>
.tags-view-container {
height: 34px;
width: 100%;
background: #fff;
z-index: 3000;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
position: relative;
overflow: hidden;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
&:first-of-type {
margin-left: 15px;
}
&:last-of-type {
margin-right: 15px;
}
&.active {
background-color: #42b983;
color: #fff;
border-color: #42b983;
&::before {
content: "";
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 2px;
}
}
}
}
.contextmenu {
margin: 15px;
background: #fff;
position: fixed;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
white-space: nowrap;
li {
margin: 0;
padding: 8px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
</style>
<style lang="scss">
//reset element css of el-icon-close
.tags-view-wrapper {
.tags-view-item {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
width: 12px !important;
height: 12px !important;
}
}
}
}
</style>
ScrollPane.vue
<template>
<el-scrollbar :vertical="false" class="scroll-container">
<slot />
</el-scrollbar>
</template>
store/tagsView.js
import { defineStore } from "pinia";
export const useTagsViewStore = defineStore("tagsView", {
state: () => ({
visitedViews: [],
}),
actions: {
addView(view) {
this.addVisitedView(view)
},
addVisitedView(view) {
if (this.visitedViews.some(v => v.path === view.path)) return
this.visitedViews.push(
Object.assign({}, view, {
title: view.meta.title || 'no-name'
})
)
},
delView(view) {
return new Promise(resolve => {
this.delVisitedView(view)
resolve({
visitedViews: [...this.visitedViews],
})
})
},
delVisitedView(view) {
return new Promise(resolve => {
for (const [i, v] of this.visitedViews.entries()) {
if (v.path === view.path) {
this.visitedViews.splice(i, 1)
break
}
}
resolve([...this.visitedViews])
})
},
delAllViews(view) {
return new Promise(resolve => {
this.delAllVisitedViews(view)
resolve({
visitedViews: [...this.visitedViews],
})
})
},
delAllVisitedViews(view) {
return new Promise(resolve => {
const affixTags = this.visitedViews.filter(tag => tag.meta.affix)
this.visitedViews = affixTags
resolve([...this.visitedViews])
})
},
}
});
全屏
npm install screenfull@5.1.0 save
<template>
<div @click="handleFullScreen" id="screenFul">
<svg-icon :icon="icon ? 'exit-fullscreen' : 'fullscreen'"></svg-icon>
</div>
</template>
<script setup>
import screenfull from 'screenfull'
import { ref, onMounted, onBeforeMount } from 'vue'
const icon = ref(screenfull.isFullscreen)
const handleFullScreen = () => {
if (screenfull.isEnabled) {
screenfull.toggle()
}
}
const changeIcon = () => {
icon.value = screenfull.isFullscreen
}
onMounted(() => {
screenfull.on('change', changeIcon)
})
onBeforeMount(() => {
screenfull.off('change')
})
</script>
<style lang="scss" scoped></style>
导入SvgIcon
components/SvgIcon/index.vue
<template>
<svg class="svg-icon" aria-hidden="true">
<use :xlink:href="iconName"></use>
</svg>
</template>
<script setup>
import { defineProps, computed } from 'vue'
const props = defineProps({
icon: {
type: String,
required: true
}
})
const iconName = computed(() => {
return `#icon-${props.icon}`
})
</script>
<style lang="scss" scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
</style>
icons/index.js
import SvgIcon from '@/components/SvgIcon'
const svgRequired = require.context('./svg', false, /\.svg$/)
svgRequired.keys().forEach((item) => svgRequired(item))
export default (app) => {
app.component('svg-icon', SvgIcon)
}
main.js中声明
// 导入svgicon
import SvgIcon from "@/icons/index"
SvgIcon(app)
安装svgloader依赖
npm install svg-sprite-loader --save-dev
在vue.config.js中配置
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
const webpack = require('webpack')
module.exports = defineConfig({
chainWebpack(config) {
// 设置 svg-sprite-loader
// config 为 webpack 配置对象
// config.module 表示创建一个具名规则,以后用来修改规则
config.module
// 规则
.rule('svg')
// 忽略
.exclude.add(resolve('src/icons'))
// 结束
.end()
// config.module 表示创建一个具名规则,以后用来修改规则
config.module
// 规则
.rule('icons')
// 正则,解析 .svg 格式文件
.test(/\.svg$/)
// 解析的文件
.include.add(resolve('src/icons'))
// 结束
.end()
// 新增了一个解析的loader
.use('svg-sprite-loader')
// 具体的loader
.loader('svg-sprite-loader')
// loader 的配置
.options({
symbolId: 'icon-[name]'
})
// 结束
.end()
config
.plugin('ignore')
.use(new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn$/))
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
})
.end()
},
}