尚品汇vue3后台管理系统
1.导入scss
src/styles/index.scss,并在main.js中导入
src/styles/variable.scss
//框架默认主题色
$base-color-default: #0187fb;
//默认层级
$base-z-index: 999;
//横向布局纵向布局时菜单背景色
$base-menu-background: #21252b;
并在vue.config.js中导入
css: {
loaderOptions: {
scss: {
additionalData: `
@import '@/styles/variable.scss';
`,
},
},
}
2.集成axios
utils/request.js
import axios from "axios"
import {useUserStore} from "@/store"
import ElMessage from "element-plus";
import router from "@/router";
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,
// baseURL: "http://localhost:8080",
// 超时
timeout: 10000
})
//请求拦截器
service.interceptors.request.use(
config => {
const token = useUserStore().token
if (token) {
config.headers['Authorization'] = 'Bearer ' + token // 让每个请求携带自定义token 请根据实际情况自行修改
}
return config
},
error => {
ElMessage.error(error.message)
return Promise.reject(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 === 404) {
ElMessage({message: msg, type: 'error'})
return Promise.reject(new Error(msg))
} 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) {
ElMessage({message: msg, type: 'error'})
return Promise.reject('error')
} else {
return Promise.resolve(res.data)
}
},
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
3.封装请求方法
api/login.js
import request from '@/utils/request'
/**
* 登录
*/
export function login(params) {
return request({
url: '/login',
method: 'post',
params
})
}
/**
* 获取用户登录信息
*/
export function getUserInfo() {
return request({
url: '/info',
method: 'get'
})
}
/**
* 退出登录
*/
export function logout() {
return request({
url: '/logout',
method: 'post'
})
}
4.状态管理
store/modules/user.js
import {defineStore} from 'pinia';
import {login, getUserInfo, logout} from "@/api/login"
import {useAuthStore} from "@/store";
// 用户模块
export const useUserStore = defineStore('user', {
state: () => ({
token: "",
userInfo:null
}),
actions: {
//登录
Login(user) {
const username = user.username.trim()
const password = user.password
return new Promise((resolve, reject) => {
login({username, password}).then(res => {
console.log(res)
this.token = res.data.token
this.GetUserAction()
resolve(res)
}).catch((err) => {
reject(err)
});
})
},
//获取用户信息
GetUserAction() {
return new Promise((resolve, reject) => {
getUserInfo().then((res) => {
console.log(res.data)
const { avatar, buttons, username, roles, routes } = res.data
// 存储用户信息
this.userInfo = { avatar, username }
// 存储用户权限信息
const authStore = useAuthStore()
authStore.setAuth({ buttons, roles, routes })
resolve(res)
}).catch((err) => {
reject(err)
});
})
},
// 退出登录
Logout() {
return new Promise((resolve, reject) => {
logout().then((res) => {
this.token = ''
this.userInfo = null
window.localStorage.clear()
resolve()
}).catch((err) => {
reject(err)
})
})
},
}
})
5.登录页面
<template>
<div class="login_body">
<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 {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) {
userStore.Login(loginForm.value).then((res) => {
ElMessage.success("登录成功")
router.push({ path: redirect.value || "/" });
}).catch(() => {
ElMessage.error("登录失败,请重新登录")
router.push("/login")
});
}
});
};
</script>
<style lang="scss" 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>
6.路由
router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { staticRoutes } from './constantRoutes'
const router = createRouter({
routes: staticRoutes,
history: createWebHistory()
})
export default router;
静态路由 router/staticRoutes.js
/**
* @description 静态路由
*/
export const staticRoutes = [
{
path: "/login",
name: 'login',
meta: {
isHide: true,
},
component: () => import("@/views/login.vue"),
},
{
path: '/404',
name: '404',
meta: {
isHide: true,
},
component: () => import("@/views/404/Index.vue"),
},
{
path: '/',
name: 'LAYOUT',
component: () => import('@/layout/index.vue'),
redirect: "/index",
meta: {
title: '首页',
icon: 'HomeFilled',
},
children: [
{
path: '/index',
name: 'Index',
component: () => import('@/views/index.vue'),
meta: {
title: '首页',
icon: 'HomeFilled',
affix: true,
},
},
],
},
{
path: '/data-screen',
name: 'DataScreen',
component: () => import('@/views/data-screen/index.vue'),
meta: {
icon: 'DataLine',
title: '数据大屏',
},
},
// 此路由防止控制台出现No match found for location with path的警告
{
path: '/:catchAll(.*)',
meta: {
isHide: true,
},
component: () => import("@/views/404/Index.vue"), //这个是我自己的路径
},
]
/**
* @description 路由未找到
*/
export const notFoundRouter = {
path: '/:pathMatch(.*)*',
name: 'notFound',
redirect: '404',
}
动态路由 router/
前置路由拦截
import {useUserStore, useAuthStore} from "@/store";
import router from "@/router"
import {dynamicRoutes} from '@/router/dynamicRoutes'
import {notFoundRouter, staticRoutes} from '@/router/constantRoutes'
const whiteList = ['/login', '/register'];
/**
* @description 路由拦截
*/
router.beforeEach(async (to, from, next) => {
const token = useUserStore().token
if (token) {
if (to.path === "/login") {
next(from.fullPath)
} else {
// 如果没有菜单列表,就重新请求菜单列表并添加动态路由
const authStore = useAuthStore()
authStore.setRouteName(to.name)
if (!authStore.authRouterList.length) {
await initDynamicRouter()
return next({...to, replace: true})
}
next()
}
} else {
if (whiteList.includes(to.path)) {
next()
} else {
// next("/login")
next({path: "/login", query: {redirect: to.fullPath}})
}
}
})
获取动态路由和菜单
/**
* @description 获取动态路由
*/
const initDynamicRouter = async () => {
const authStore = useAuthStore()
const userStore = useUserStore()
// 1.请求用户信息,携带路由权限信息,在登录的时候已经获取了
await userStore.GetUserAction()
try {
// 判断当前用户有没有菜单权限,没有的话跳转登录
if (!authStore.authRouterList.length) {
router.replace("/login")
return Promise.reject('No permission')
}
// 2.过滤路由
const routerList = filterAsyncRoutes(
dynamicRoutes,
authStore.authRouterList,
)
// 3.添加动态路由
routerList.forEach((route) => {
router.addRoute(route)
})
// 4.添加notFound路由
router.addRoute(notFoundRouter)
// 5.处理subMenu数据,静态路由和动态路由拼接,过滤isHide=true的路由
const menuList = getMenuList([...staticRoutes, ...routerList,])
authStore.setAuthMenuList(menuList)
} catch (error) {
// 当按钮 || 菜单请求出错时,重定向到登陆页
window.localStorage.clear()
router.replace("/login")
return Promise.reject(error)
}
}
/**
* @description 路由过滤
*/
function filterAsyncRoutes(dynamicRoutes, authRouterList) {
return dynamicRoutes.filter((route) => {
// 1.如果route的name在routeNames中没有, 直接过滤掉
if (!authRouterList.includes(route.name))
return false
// 2.如果当前route还有子路由(也就是有children), 需要对子路由也进行权限过滤
if (route.children && route.children.length > 0) {
route.children = filterAsyncRoutes(route.children, authRouterList)
}
return true
})
}
/**
* @description menu过滤
*/
function getMenuList(menuList) {
const newMenuList = JSON.parse(JSON.stringify(menuList))
return newMenuList.filter((item) => {
item.children?.length && (item.children = getMenuList(item.children))
return !item.meta?.isHide
})
}
7.按钮权限
指令实现
从pinia
中拿到按钮权限列表authButtonList
。这里会有两种方式,有可能一个按钮有一种权限,有可能是多权限,单权限直接根据includes
,多权限通过循环判断,如果有权限就行渲染,无权限就直接remove
这个元素。
src/directives/index.js
import auth from './modules/auth'
const directivesList = {
// Custom directives
auth,
}
const directives = {
install: function (app) {
Object.keys(directivesList).forEach((key) => {
// 注册所有自定义指令
app.directive(key, directivesList[key])
})
},
}
export default directives
src/directives/modules/auth.js
/**
* v-auth
* 按钮权限指令
*/
import { useAuthStore } from '@/store'
const auth = {
mounted(el, binding) {
const { value } = binding
const authStore = useAuthStore()
const currentPageRoles = authStore.authButtonList ?? []
if (value instanceof Array && value.length) {
const hasPermission = value.every((item) =>
currentPageRoles.includes(item),
)
if (!hasPermission) el.remove()
} else {
if (!currentPageRoles.includes(value)) el.remove()
}
},
}
export default auth
在main.js中声明注册
//按钮权限指令实现 v-auth
import directives from '@/directives/index'
app.use(directives)
hooks实现
src/hooks/useAuthButton.js
import { computed } from 'vue'
import { useAuthStore } from '@/store/modules/auth'
/**
* @description 页面按钮权限
* */
export const useAuthButtons = () => {
const authStore = useAuthStore()
const authButtons = authStore.authButtonList || []
// 当前页按钮权限列表
const BUTTONS = computed(() => {
const currentPageAuthButton = {}
authButtons.forEach((item) => (currentPageAuthButton[item] = true))
return currentPageAuthButton
})
return {
BUTTONS,
}
}
组件实现
src/components/Auth
8.Layout布局
src/layouts/index.vue
<template>
<div class="layout-admin-wrapper">
<div class="layout-container-vertical fixed">
<!-- SubMenu -->
<LayoutSideBar />
<div class="layout-main" :class="{ 'is-collapse': collapse }">
<!-- Header -->
<div
class="layout-header fixed-header"
:class="{ 'is-collapse': collapse }"
>
<LayoutNavBar />
<LayoutTabsBar />
</div>
<div class="app-main-container">
<!-- Main -->
<LayoutMain />
<!-- Footer -->
<LayoutFooter />
</div>
</div>
</div>
<!-- 主题切换 -->
<ThemeDrawer />
</div>
</template>
<script setup>
import { onBeforeUnmount, computed, ref } from 'vue'
import { useSettingsStore } from '@/store'
import { useDebounceFn } from '@vueuse/core'
import LayoutFooter from './Footer/index.vue'
import LayoutMain from './Main/index.vue'
import LayoutSideBar from './SideBar/index.vue'
import LayoutNavBar from './NavBar/index.vue'
import LayoutTabsBar from './TabsBar/index.vue'
import ThemeDrawer from './NavBar/components/ThemeDrawer/index.vue'
const settingsStore = useSettingsStore()
const collapse = computed(() => settingsStore.collapse)
// 监听窗口大小变化,折叠侧边栏
const screenWidth = ref(0)
const listeningWindow = useDebounceFn(() => {
screenWidth.value = document.body.clientWidth
if (!collapse.value && screenWidth.value < 1200)
settingsStore.changeCollapse()
if (collapse.value && screenWidth.value > 1200)
settingsStore.changeCollapse()
}, 100)
window.addEventListener('resize', listeningWindow, false)
onBeforeUnmount(() => {
window.removeEventListener('resize', listeningWindow)
})
</script>
<style lang="scss" scoped>
@mixin fix-header {
position: fixed;
top: 0;
right: 0;
z-index: $base-z-index - 2;
width: calc(100% - $base-left-menu-width);
}
.layout-admin-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
.layout-container-vertical {
&.fixed {
padding-top: calc(#{$base-top-bar-height} + #{$base-tabs-bar-height});
}
.layout-main {
min-height: 100%;
margin-left: $base-left-menu-width;
&.is-collapse {
margin-left: $base-left-menu-width-min;
border-right: 0;
}
.layout-header {
box-shadow: 0 1px 4px rgb(0 21 41 / 8%);
&.fixed-header {
@include fix-header;
}
&.is-collapse {
width: calc(100% - $base-left-menu-width-min);
}
}
.app-main-container {
padding: 20px;
}
}
}
}
</style>
设置的状态管理
src/store/modules/setting.js
import {defineStore} from 'pinia';
// 用户模块
export const useSettingsStore = defineStore('setting', {
state: () => ({
collapse: false,
refresh: false, // 刷新页面
themeConfig: {
primary: "#409EFF",
isDark: false,
},
}),
actions: {
changeCollapse() {
this.collapse = !this.collapse
},
setRefresh() {
this.refresh = !this.refresh
},
setThemeConfig(themeConfig) {
this.themeConfig = themeConfig
},
}
})
SideBar
src/layouts/SideBar/index.vue
<template>
<div class="layout-sidebar-container" :class="{ 'is-collapse': collapse }">
<Logo/>
<el-scrollbar>
<el-menu
background-color="#001529"
text-color="hsla(0,0%,100%,.65)"
active-text-color="#fff"
:defaultActive="activeMenu"
:collapse="collapse"
:unique-opened="true"
>
<sub-menu :menuList="menuList"></sub-menu>
</el-menu>
</el-scrollbar>
</div>
</template>
<script setup>
import Logo from '../Logo/index.vue'
import SubMenu from './components/SubMenu/index.vue'
import { useAuthStore ,useSettingsStore} from '@/store'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
const settingsStore = useSettingsStore()
const authStore = useAuthStore()
const route = useRoute()
const collapse = computed(() => settingsStore.collapse)
const themeConfig = computed(() => settingsStore.themeConfig)
const menuList = computed(() => authStore.authMenuList)
const activeMenu = computed(() =>
route.meta.activeMenu ? (route.meta.activeMenu) : route.path,
)
</script>
<style lang="scss" scoped>
@mixin active {
&:hover {
color: $base-color-white;
}
&.is-active {
color: $base-color-white;
background-color: var(--el-color-primary) !important;
}
}
.layout-sidebar-container {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: $base-z-index;
width: $base-left-menu-width;
height: 100vh;
background: $base-menu-background;
box-shadow: 2px 0 6px rgb(0 21 41 / 35%);
transition: width $base-transition-time;
&.is-collapse {
width: $base-left-menu-width-min;
border-right: 0;
}
:deep(.el-scrollbar__wrap) {
overflow-x: hidden;
.el-menu {
border: 0;
}
.el-menu-item,
.el-submenu__title {
height: $base-menu-item-height;
overflow: hidden;
line-height: $base-menu-item-height;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.el-menu-item {
@include active;
}
}
}
</style>
src/layouts/SideBar/components/SubMenu/index.vue
<template>
<template v-for="subItem in menuList" :key="subItem.path">
<el-sub-menu
v-if="subItem.children && subItem.children.length > 1"
:index="subItem.path"
>
<template #title>
<el-icon>
<component :is="subItem.meta.icon"></component>
</el-icon>
<span>{{ subItem.meta.title }}</span>
</template>
<!-- 有children递归本次组件 -->
<sub-menu :menuList="subItem.children" />
</el-sub-menu>
<el-menu-item
v-else-if="subItem.children && subItem.children.length == 1"
:index="subItem.children[0].path"
@click="handleClickMenu(subItem.children[0])"
>
<el-icon>
<component :is="subItem.children[0].meta.icon"></component>
</el-icon>
<template #title>
<span>{{ subItem.children[0].meta.title }}</span>
</template>
</el-menu-item>
<el-menu-item
v-else
:index="subItem.path"
@click="handleClickMenu(subItem)"
>
<el-icon>
<component :is="subItem.meta.icon"></component>
</el-icon>
<template #title>
<span>{{ subItem.meta.title }}</span>
</template>
</el-menu-item>
</template>
</template>
<script>
import { Menu } from '@element-plus/icons-vue'
import { defineComponent } from 'vue'
import { useRouter } from 'vue-router'
export default defineComponent({
name: 'SubMenu',
props: {
menuList: {
type: Menu.MenuOptions,
default: () => {
return []
},
},
},
setup() {
const router = useRouter()
function handleClickMenu(subItem) {
// 跳转外部链接
if (subItem.meta.isLink) return window.open(subItem.meta.isLink, '_blank')
router.push(subItem.path)
}
return {
handleClickMenu,
}
},
})
</script>
Logo
src/layouts/Logo/index.vue
<template>
<div class="logo-container flex-center">
<a href="/">
<img class="logo" alt="logo" :src="logo" />
<h1 class="title" v-if="!collapse">大冶美食后台管理</h1>
</a>
</div>
</template>
<script setup>
import logo from "@/assets/images/logo.png"
import { computed } from 'vue'
import { useSettingsStore } from '@/store'
const settingsStore = useSettingsStore()
const collapse = computed(() => settingsStore.collapse)
</script>
<style scoped lang="scss">
.logo-container {
position: relative;
display: flex;
justify-content: center;
// padding-left: 24px;
height: 60px;
overflow: hidden;
line-height: 60px;
background: transparent;
.title {
display: inline-block;
margin-left: 12px;
font-family: Avenir, 'Helvetica Neue', Arial, Helvetica, sans-serif;
font-size: 20px;
font-weight: 600;
color: #fff;
vertical-align: middle;
}
.logo {
display: inline-block;
width: 32px;
height: 32px;
vertical-align: middle;
}
}
</style>
Main
src/layouts/Main/index.vue
<template>
<!--<section class="app-mian-height">
<router-view v-slot="{ Component, route }" v-if="isShow">
<transition appear name="fade-transform" mode="out-in">
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</section>-->
<section class="app-mian-height">
<router-view></router-view>
</section>
</template>
<script setup>
import {nextTick, ref, watch} from 'vue'
import {useSettingsStore} from '@/store'
import {useRoute} from 'vue-router'
const route = useRoute()
const settingsStore = useSettingsStore()
const isShow = ref(true)
watch(
() => settingsStore.refresh,
() => {
isShow.value = false
nextTick(() => {
isShow.value = true
})
},
)
</script>
<style lang="scss" scoped>
.app-mian-height {
min-height: $base-app-main-height;
// padding: 20px;
background-color: inherit;
}
</style>
NavBar
src/layouts/NavBar/index.vue
<template>
<div class="nav-bar-container">
<el-row :gutter="15">
<el-col :sm="12" :md="12" :lg="12" :xl="12">
<div class="left-panel">
<el-icon class="fold-unfold" @click="handleCollapse">
<component :is="collapse ? 'Expand' : 'Fold'"></component>
</el-icon>
<Breadcrumb/>
</div>
</el-col>
<el-col :sm="12" :md="12" :lg="12" :xl="12">
<div class="right-panel">
<Refresh/>
<ScreenFull/>
<Settings/>
<User/>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup>
import {computed} from 'vue'
import {useSettingsStore} from '@/store'
import User from './components/User/index.vue'
import Breadcrumb from './components/Breadcrumb/index.vue'
import Refresh from './components/Refresh/index.vue'
import ScreenFull from './components/ScreeFull/index.vue'
import Settings from './components/Settings/index.vue'
const settingsStore = useSettingsStore()
const collapse = computed(() => settingsStore.collapse)
function handleCollapse() {
settingsStore.changeCollapse()
}
</script>
<style lang="scss" scoped>
.nav-bar-container {
position: relative;
height: $base-nav-bar-height;
padding-right: $base-padding;
padding-left: $base-padding;
overflow: hidden;
user-select: none;
background: $base-color-white;
box-shadow: $base-box-shadow;
.left-panel {
display: flex;
align-items: center;
justify-items: center;
height: 60px;
.fold-unfold {
font-size: 18px;
color: $base-color-gray;
cursor: pointer;
}
}
.right-panel {
display: flex;
align-content: center;
align-items: center;
justify-content: flex-end;
height: $base-nav-bar-height;
}
}
</style>
面包屑
src/layouts/NavBar/components/Breadcrumb/index.vue
<template>
<el-breadcrumb class="app-breadcrumb" separator-icon="ArrowRight">
<transition-group name="breadcrumb" mode="out-in">
<el-breadcrumb-item v-for="(item, index) in matched" :key="item.path">
<el-icon size="14">
<component :is="item.meta.icon"></component>
</el-icon>
<span
v-if="item.redirect === 'noRedirect' || index == matched.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>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const router = useRouter()
const handleLink = (item) => {
router.push({
path: item.path,
})
}
const matched = computed(() =>
route.matched.filter(
(item) =>
item.meta &&
item.meta.title &&
item.meta.breadcrumb !== false &&
item.children.length !== 1,
),
)
</script>
<style lang="scss" scoped>
.app-breadcrumb {
margin-left: 20px;
}
:deep(.el-breadcrumb__inner) {
display: flex;
> i {
margin-right: 3px;
}
}
</style>
刷新按钮
src/layouts/NavBar/components/Refresh/index.vue
<template>
<div class="btn">
<el-tooltip content="刷新">
<el-button circle @click="onRefresh">
<IconifyIcon icon="ri:refresh-line" height="16" />
</el-button>
</el-tooltip>
</div>
</template>
<script setup>
import { useSettingsStore } from '@/store'
import { IconifyIcon } from '@/components/IconifyIcon'
const settingsStore = useSettingsStore()
const onRefresh = () => {
settingsStore.setRefresh()
}
</script>
<style lang="scss" scoped>
.btn {
margin-right: 20px;
cursor: pointer;
transition: all 0.3s;
}
</style>
IconifyIcon
src/components/IconifyIcon/src/IconifyIcon.js
import { h, defineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
export default defineComponent({
name: 'IconifyIconOnline',
components: { IconifyIcon },
props: {
icon: {
type: String,
default: '',
},
},
render() {
const attrs = this.$attrs
return h(
IconifyIcon,
{
icon: `${this.icon}`,
style: attrs?.style
? Object.assign(attrs.style, { outline: 'none' })
: { outline: 'none' },
...attrs,
},
{
default: () => [],
},
)
},
})
src/components/IconifyIcon/index.js
import IconifyIcon from './src/IconifyIcon'
export { IconifyIcon }
全屏
src/layouts/NavBar/components/ScreeFull/index.vue
<template>
<div class="m-screenful">
<el-tooltip
effect="dark"
:content="!isFullscreen ? '全屏' : '收起'"
placement="bottom"
>
<el-button circle @click="toggle">
<IconifyIcon
v-if="!isFullscreen"
icon="fluent:full-screen-maximize-24-filled"
height="16"
/>
<IconifyIcon
v-else
icon="fluent:full-screen-minimize-24-filled"
height="18"
/>
</el-button>
</el-tooltip>
</div>
</template>
<script setup>
import { useFullscreen } from '@vueuse/core'
const { toggle, isFullscreen } = useFullscreen()
</script>
<style lang="scss" scoped>
.m-screenful {
padding-right: 20px;
cursor: pointer;
transition: all 0.3s;
}
</style>
设置
src/layouts/NavBar/components/Settings/index.vue
<template>
<div class="btn">
<el-tooltip effect="dark" content="系统设置">
<el-button circle @click="onSetting">
<IconifyIcon icon="ep:setting" height="16" />
</el-button>
</el-tooltip>
</div>
</template>
<script setup>
import mittBus from '@/utils/mittBus'
const onSetting = () => {
// 采用事件监听的方式打开ThemeDrawer
mittBus.emit('openThemeDrawer')
}
</script>
<style scoped lang="scss">
.btn {
margin-right: 20px;
cursor: pointer;
transition: all 0.3s;
}
</style>
全局时间监听
src/utils/mittBus.js
/**
* @Description: 全局事件监听
*/
import mitt from 'mitt'
const mittBus = mitt()
export default mittBus
主题颜色
src/layouts/NavBar/components/ThemeDrawer/index.vue
<template>
<el-drawer title="主题设置" v-model="drawerVisible" size="300px">
<el-divider class="divider" content-position="center">全局主题</el-divider>
<div class="theme-item">
<span>主题颜色</span>
<el-color-picker
v-model="themeConfig.primary"
:predefine="colorList"
@change="changePrimary"
/>
</div>
<div class="theme-item">
<span>暗黑模式</span>
<SwitchDark />
</div>
</el-drawer>
</template>
<script setup setup>
import { ref, computed } from 'vue'
import mittBus from '@/utils/mittBus'
import { DEFAULT_PRIMARY } from '@/config/config'
import { useSettingsStore } from '@/store'
import { useTheme } from '@/hooks/useTheme'
const { changePrimary } = useTheme()
// 预定义主题颜色
const colorList = [
DEFAULT_PRIMARY,
'#DAA96E',
'#0C819F',
'#722ed1',
'#27ae60',
'#ff5c93',
'#e74c3c',
'#fd726d',
'#f39c12',
'#9b59b6',
]
const settingsStore = useSettingsStore()
const themeConfig = computed(() => settingsStore.themeConfig)
// 打开主题设置
const drawerVisible = ref(false)
mittBus.on('openThemeDrawer', () => {
drawerVisible.value = true
})
</script>
<style lang="scss" scoped>
.theme-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 14px 0;
}
</style>
src/layouts/NavBar/components/ThemeDrawer/components/SwitchDark.vue
<template>
<el-switch
v-model="themeConfig.isDark"
@change="onAddDarkChange"
inline-prompt
:active-icon="Sunny"
:inactive-icon="Moon"
/>
</template>
<script setup name="SwitchDark">
import { computed } from 'vue'
import { useSettingsStore } from '@/store'
import { Sunny, Moon } from '@element-plus/icons-vue'
import { useTheme } from '@/hooks/useTheme'
const settingsStore = useSettingsStore()
const { switchDark } = useTheme()
const themeConfig = computed(() => settingsStore.themeConfig)
const onAddDarkChange = () => {
switchDark()
}
</script>
src/utils/color.js
import { ElMessage } from 'element-plus'
/**
* hex颜色转rgb颜色
* @param str 颜色值字符串
* @returns 返回处理后的颜色值
*/
export function hexToRgb(str) {
let hexs = ''
const 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, g, b) {
const reg = /^\d{1,3}$/
if (!reg.test(r) || !reg.test(g) || !reg.test(b))
return ElMessage.warning('输入错误的rgb颜色值')
const 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, level) {
const reg = /^\#?[0-9A-Fa-f]{6}$/
if (!reg.test(color)) return ElMessage.warning('输入错误的hex颜色值')
const rgb = hexToRgb(color)
for (let i = 0; i < 3; i++)
rgb[i] = Math.round(20.5 * level + rgb[i] * (1 - level))
return rgbToHex(rgb[0], rgb[1], rgb[2])
}
/**
* 变浅颜色值
* @param color 颜色值字符串
* @param level 加深的程度,限0-1之间
* @returns 返回处理后的颜色值
*/
export function getLightColor(color, level) {
const reg = /^\#?[0-9A-Fa-f]{6}$/
if (!reg.test(color)) return ElMessage.warning('输入错误的hex颜色值')
const rgb = hexToRgb(color)
for (let i = 0; i < 3; i++) {
rgb[i] = Math.round(255 * level + rgb[i] * (1 - level))
}
console.log(rgb)
return rgbToHex(rgb[0], rgb[1], rgb[2])
}
src/hooks/useTheme.js
import { computed } from 'vue'
import { useSettingsStore } from '@/store'
import { DEFAULT_PRIMARY } from '../config/config'
import { ElMessage } from 'element-plus'
import { getLightColor, getDarkColor } from '@/utils/color'
export const useTheme = () => {
const settingsStore = useSettingsStore()
const themeConfig = computed(() => settingsStore.themeConfig)
// 切换暗黑模式
const switchDark = () => {
const body = document.documentElement
if (themeConfig.value.isDark) body.setAttribute('class', 'dark')
else body.setAttribute('class', '')
changePrimary(themeConfig.value.primary)
}
// 修改主题颜色
const changePrimary = (val) => {
if (!val) {
val = DEFAULT_PRIMARY
ElMessage({
type: 'success',
message: `主题颜色已重置为 ${DEFAULT_PRIMARY}`,
})
}
settingsStore.setThemeConfig({ ...themeConfig.value, primary: val })
document.documentElement.style.setProperty(
'--el-color-primary',
themeConfig.value.primary,
)
// 颜色加深或变浅
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
themeConfig.value.isDark
? `${getDarkColor(themeConfig.value.primary, i / 10)}`
: `${getLightColor(themeConfig.value.primary, i / 10)}`,
)
}
}
// 初始化主题
const initTheme = () => {
switchDark()
// changePrimary(themeConfig.value.primary)
}
return {
initTheme,
switchDark,
changePrimary,
}
}
用户退出
src/layouts/NavBar/components/User/index.vue
<template>
<el-dropdown @visible-change="onChange" @command="handleCommand">
<div class="avatar-dropdown">
<img class="user-avatar" :src="avatar" alt="" />
<div class="user-name">{{ username }}</div>
<el-icon class="up-down">
<component :is="visible ? 'ArrowUp' : 'ArrowDown'" />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="changePassword">
<svg-icon icon="password" size="16px" />
<span>修改密码</span>
</el-dropdown-item>
<el-dropdown-item command="logout">
<svg-icon icon="logout" size="16px" />
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import avatar from "@/assets/images/avatar.jpg"
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore,useAuthStore,useTabsBarStore } from '@/store'
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { LOGIN_URL } from '@/config/config'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const authStore = useAuthStore()
const tabsBar = useTabsBarStore()
const { username } = userStore.userInfo || {}
const visible = ref(false)
function onChange(show) {
visible.value = show
}
function handleCommand(command) {
if (command === 'logout') {
logout()
}
if (command === 'changePassword') {
changePassword()
}
}
// 退出登陆
function logout() {
ElMessageBox.confirm('您确定要退出吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
await userStore.Logout()
userStore.$reset()
authStore.$reset()
tabsBar.$reset()
window.localStorage.clear()
router.push({ path: LOGIN_URL, query: { redirect: route.fullPath } })
ElMessage.success('退出登录成功!')
})
}
//修改密码
function changePassword(){}
</script>
<style lang="scss" scoped>
.avatar-dropdown {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
justify-items: center;
height: 50px;
padding: 0;
.user-avatar {
width: 40px;
height: 40px;
cursor: pointer;
}
.user-name {
position: relative;
margin: 0 6px;
cursor: pointer;
}
}
</style>
TabsBar
src/layouts/TabsBar/index.vue
<template>
<div class="tabs-bar-container">
<div class="tabs-content">
<el-tabs
type="card"
v-model="activeTabsValue"
@tab-click="tabClick"
@tab-remove="removeTab"
>
<el-tab-pane
v-for="item in visitedViews"
type="card"
:key="item.path"
:path="item.path"
:label="item.title"
:name="item.path"
:closable="!(item.meta && item.meta.affix)"
>
<template #label>
<el-icon
size="16"
class="tabs-icon"
v-if="item.meta && item.meta.icon"
>
<component :is="item.meta.icon"></component>
</el-icon>
{{ item.title }}
</template>
</el-tab-pane>
</el-tabs>
</div>
<div class="tabs-action">
<el-dropdown trigger="hover">
<el-icon color="rgba(0, 0, 0, 0.65)" :size="20">
<Menu />
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="closeCurrentTab">
<el-icon :size="14"><FolderRemove /></el-icon>
关闭当前
</el-dropdown-item>
<el-dropdown-item @click="closeOtherTab">
<el-icon :size="14"><Close /></el-icon>
关闭其他
</el-dropdown-item>
<el-dropdown-item @click="closeAllTab">
<el-icon :size="14"><FolderDelete /></el-icon>
关闭所有
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted, watch } from 'vue'
import { useTabsBarStore } from '@/store'
import { useRoute, useRouter } from 'vue-router'
import { TabsPaneContext, TabPaneName } from 'element-plus'
import path from 'path-browserify'
import { useAuthStore } from '@/store'
const tabsBarStore = useTabsBarStore()
const authStore = useAuthStore()
const routes = computed(() => authStore.authMenuList)
const visitedViews = computed(
() => tabsBarStore.visitedViews,
)
const route = useRoute()
const router = useRouter()
let affixTags = ref([])
// 添加当前路由
const addTags = () => {
const { name } = route
if (name === 'Login') {
return
}
if (name) {
tabsBarStore.addView(route)
}
return false
}
function filterAffixTags(routes, basePath = '/') {
let tags = []
routes.forEach((route) => {
if (route.meta && route.meta.affix) {
// 获取 path
const tagPath = path.resolve(basePath, route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta },
})
}
if (route.children) {
const tempTags = filterAffixTags(route.children, route.path)
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags]
}
}
})
return tags
}
/**
* @description: 拿到需要固定的路由表,添加进 store
* @author: codeBo
*/
const initTags = () => {
let routesNew = routes.value
let affixTag = (affixTags.value = filterAffixTags(routesNew))
for (const tag of affixTag) {
if (tag.name) {
tabsBarStore.addVisitedView(tag)
}
}
}
onMounted(() => {
initTags()
addTags()
})
watch(route, () => {
addTags()
})
const activeTabsValue = computed({
get: () => {
return tabsBarStore.activeTabsValue
},
set: (val) => {
tabsBarStore.setTabsMenuValue(val)
},
})
// 删除以后切换到下一个
function toLastView(activeTabPath) {
let index = visitedViews.value.findIndex(
(item) => item.path === activeTabPath,
)
const nextTab =
visitedViews.value[index + 1] || visitedViews.value[index - 1]
if (!nextTab) return
router.push(nextTab.path)
tabsBarStore.addVisitedView(nextTab)
}
// 点击事件
const tabClick = (tabItem) => {
let path = tabItem.props.name
router.push(path)
}
const isActive = (path) => {
return path === route.path
}
const removeTab = async (activeTabPath) => {
if (isActive(activeTabPath )) {
toLastView(activeTabPath )
}
await tabsBarStore.delView(activeTabPath)
}
// 按钮事件
const closeCurrentTab = () => {
tabsBarStore.toLastView(route.path)
tabsBarStore.delView(route.path)
}
const closeOtherTab = () => {
tabsBarStore.delOtherViews(route.path)
}
const closeAllTab = async () => {
tabsBarStore.delAllViews()
tabsBarStore.goHome()
}
</script>
<style lang="scss" scoped>
.tabs-action {
padding-bottom: 5px;
cursor: pointer;
:deep(.el-icon) {
transition: all 0.3s;
}
:deep(.el-icon):hover {
color: $base-color-default;
transition: all 0.3s;
transform: rotate(90deg);
}
}
.tabs-bar-container {
position: relative;
box-sizing: border-box;
display: flex;
align-content: center;
align-items: flex-end;
justify-content: space-between;
height: $base-tabs-bar-height;
padding-right: $base-padding;
padding-left: $base-padding;
user-select: none;
background: $base-color-white;
border-top: 1px solid #f6f6f6;
.tabs-content {
width: calc(100% - 0px);
}
:deep(.el-tabs--card) {
height: $base-tag-item-height;
.el-tabs__nav-next,
.el-tabs__nav-prev {
height: $base-tag-item-height;
line-height: $base-tag-item-height;
}
.el-tabs__header {
margin: 0;
border-bottom: 0;
}
.el-tabs__nav {
border: 0;
}
.tabs-icon {
top: 3px;
font-size: 15px;
}
.el-tabs__item {
box-sizing: border-box;
height: $base-tag-item-height;
line-height: $base-tag-item-height;
border: none;
border-radius: $base-border-radius;
transition: padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !important;
}
.el-tabs__item.is-active {
background-color: #e8f4ff;
-webkit-mask: url('@/assets/images/tabbar-bg.png');
mask: url('@/assets/images/tabbar-bg.png');
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.el-tabs__item:not(.is_active):hover {
background-color: #f6f8f9;
-webkit-mask: url('@/assets/images/tabbar-bg.png');
mask: url('@/assets/images/tabbar-bg.png');
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
}
}
</style>
src/store/modules/tabsBar.js
import {defineStore} from 'pinia';
import router from '@/router'
export const useTabsBarStore = defineStore('tabsBar', {
state: () => ({
activeTabsValue: '/index',
visitedViews: [], // 选中过的路由表
cachedViews: [], // 使用 keepAlive 时的缓存
}),
actions: {
setTabsMenuValue(val) {
this.activeTabsValue = val
},
addView(view) {
this.addVisitedView(view)
},
removeView(routes) {
return new Promise((resolve) => {
this.visitedViews = this.visitedViews.filter(
(item) => !routes.includes((item).path),
)
resolve(null)
})
},
addVisitedView(view) {
this.setTabsMenuValue(view.path)
if (this.visitedViews.some((v) => v.path === view.path) || !view.meta)
return
this.visitedViews.push(
Object.assign({}, view, {
title: view.meta.title || 'no-name',
}),
)
if (view.meta.keepAlive && view.name) {
this.cachedViews.push(view.name)
}
},
delView(activeTabPath) {
return new Promise((resolve) => {
this.delVisitedView(activeTabPath)
this.delCachedView(activeTabPath)
resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews],
})
})
},
toLastView(activeTabPath) {
const index = this.visitedViews.findIndex(
(item) => item.path === activeTabPath,
)
const nextTab =
this.visitedViews[index + 1] || this.visitedViews[index - 1]
if (!nextTab) return
router.push(nextTab.path)
this.addVisitedView(nextTab)
},
delVisitedView(path) {
return new Promise((resolve) => {
this.visitedViews = this.visitedViews.filter((v) => {
if (!v.meta) return
return v.path !== path || v.meta.affix
})
this.cachedViews = this.cachedViews.filter((v) => {
return v.path !== path || v.meta.affix
})
resolve([...this.visitedViews])
})
},
delCachedView(view) {
return new Promise((resolve) => {
const index = this.cachedViews.indexOf(view.name)
index > -1 && this.cachedViews.splice(index, 1)
resolve([...this.cachedViews])
})
},
clearVisitedView() {
this.delAllViews()
},
delAllViews() {
this.visitedViews = this.visitedViews.filter(
(v) => v.meta && v.meta.affix,
)
this.cachedViews = this.visitedViews.filter((v) => v.meta && v.meta.affix)
},
delOtherViews(path) {
this.visitedViews = this.visitedViews.filter((item) => {
return item.path === path || (item.meta && item.meta.affix)
})
this.cachedViews = this.visitedViews.filter((item) => {
return item.path === path || (item.meta && item.meta.affix)
})
},
goHome() {
this.activeTabsValue = '/index'
router.push({path: '/index'})
},
updateVisitedView(view) {
for (let v of this.visitedViews) {
if (v.path === view.path) {
v = Object.assign(v, view)
break
}
}
}
}
})
footer
src/layouts/Footer/index.vue
<template>
<div class="layout-footer-container">
<svg-icon name="copyright" size="16px" color="rgba(0, 0, 0, 0.45)" />
<span>Copyright © 2003-{{ fullYear }} tayo.com All Rights Reserved.</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const fullYear = ref(new Date().getFullYear())
</script>
<style lang="scss" scoped>
.layout-footer-container {
display: flex;
align-items: center;
justify-content: center;
min-height: $base-app-footer-height;
padding: 0 20px;
color: rgb(0 0 0 / 45%);
background: var(--el-color-white);
border-top: 1px dashed #dcdfe6;
}
</style>
9.页面实现
首页
src/views/home/index.vue
<template>
<div class="home container">
<el-card shadow="hover">
<div class="page-header">
<el-avatar :size="60" :src="userInfo?.avatar" />
<div class="page-header-tip">
<p class="page-header-tip-title">
{{ timeFix() }}{{ userInfo?.username }},{{ welcome() }}
</p>
<p class="page-header-tip-desc">大冶美食后台管理系统</p>
</div>
</div>
</el-card>
<div class="welcome">
<SvgIcon name="welcome" size="400px" />
</div>
</div>
</template>
<script setup>
import avatar from "@/assets/images/avatar.jpg"
import { useUserStore } from '@/store'
import { timeFix, welcome } from '@/utils/index'
const userStore = useUserStore()
const userInfo = userStore.userInfo
</script>
<style lang="scss" scoped>
.home {
height: 100%;
.page-header {
display: flex;
align-items: center;
.page-header-tip {
flex: 1;
margin-left: 20px;
}
.page-header-tip-title {
margin-bottom: 12px;
font-size: 20px;
font-weight: 700;
color: #3c4a54;
}
.page-header-tip-desc {
min-height: 20px;
font-size: 14px;
color: #808695;
}
}
.welcome {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
}
</style>