尚品汇vue3后台管理系统

尚品汇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>
  • 16
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值