zs 1 year ago
parent
commit
50890a24d4

+ 11 - 0
package-lock.json

@@ -13,6 +13,7 @@
         "axios": "^1.6.7",
         "element-plus": "^2.5.6",
         "lodash-es": "^4.17.21",
+        "path-browserify": "^1.0.1",
         "pinia": "^2.1.7",
         "vue": "^3.4.15",
         "vue-i18n": "^9.9.1",
@@ -2762,6 +2763,11 @@
         "node": ">=6"
       }
     },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
+    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
@@ -5686,6 +5692,11 @@
         "callsites": "^3.0.0"
       }
     },
+    "path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
+    },
     "path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",

+ 1 - 0
package.json

@@ -16,6 +16,7 @@
     "axios": "^1.6.7",
     "element-plus": "^2.5.6",
     "lodash-es": "^4.17.21",
+    "path-browserify": "^1.0.1",
     "pinia": "^2.1.7",
     "vue": "^3.4.15",
     "vue-i18n": "^9.9.1",

BIN
src/assets/logo.png


+ 1 - 0
src/lang/package/zh-cn.js

@@ -2,6 +2,7 @@ export default {
   // 路由国际化
   route: {
     dashboard: '首页',
+    login: '账号登录',
     document: '项目文档'
   },
   // 登录页面国际化

+ 34 - 35
src/layout/index.vue

@@ -1,22 +1,28 @@
 <template>
-  <el-container class="main">
-    <el-header :style="{ padding: 0 }"> <component :is="cHeader"></component></el-header>
-    <el-container>
-      <el-aside width="200px" :style="{ 'background-color': '#242f42' }"><component :is="cAside"></component></el-aside>
-      <el-main>
-        <div class="content-box" :class="{ 'content-collapse': collapse }">
-          <el-col :span="24" class="content">
-            <transition name="move" mode="out-in">
-              <el-row>
-                <component :is="cBreadcrumb" :breadcrumbTitle="route.meta.title"></component>
-                <el-col :span="24" class="container" :style="{ padding: '10px 0 0 0' }"><router-view :style="operaViewStyle"></router-view></el-col>
-              </el-row>
-            </transition>
-            <el-backtop target=".content"></el-backtop>
-          </el-col>
-        </div>
-      </el-main>
-    </el-container>
+  <el-container class="layout-container-demo">
+    <el-aside width="210px" :style="{ 'background-color': '#242f42' }">
+      <component :is="cAside"></component>
+    </el-aside>
+    <el-main>
+      <div class="content-box">
+        <el-col :span="24" class="content">
+          <transition name="move" mode="out-in">
+            <el-row>
+              <el-col :span="24" class="header">
+                <component :is="cHeader"></component>
+              </el-col>
+              <el-col :span="24" class="crumb">
+                <component :is="cBreadcrumb"></component>
+              </el-col>
+              <el-col :span="24" class="container" :style="{ padding: '10px' }">
+                <router-view :style="testInfo"></router-view>
+              </el-col>
+            </el-row>
+          </transition>
+          <el-backtop target=".content"></el-backtop>
+        </el-col>
+      </div>
+    </el-main>
   </el-container>
 </template>
 
@@ -25,14 +31,7 @@
 import cHeader from './parts/Header.vue'
 import cAside from './parts/Sidebar.vue'
 import cBreadcrumb from './parts/breadcrumb.vue'
-// import { useRoute } from 'vue-router'
-// import { ref } from 'vue'
-
-// 路由
-const route = useRoute()
-// const breadcrumbTitle: Ref<any> = ref();
-let collapse = ref(false)
-const operaViewStyle = ref({
+const testInfo = ref({
   height: '85vh',
   background: '#ffffff',
   'overflow-x': 'hidden',
@@ -41,19 +40,19 @@ const operaViewStyle = ref({
   padding: '10px'
 })
 </script>
+
 <style scoped lang="scss">
-.main {
-  background-color: #f0f0f0;
+.layout-container-demo {
+  height: 100%;
+  width: 100%;
+
   .el-header {
-    border-bottom: 1px solid;
-  }
-  .el-aside {
-    height: 93vh;
-    overflow-x: auto;
-    overflow-y: auto;
+    padding: 0;
   }
+
   .el-main {
-    padding: 10px;
+    padding: 0;
+    background-color: #f0f0f0;
   }
 }
 </style>

+ 44 - 50
src/layout/parts/Header.vue

@@ -2,35 +2,42 @@
   <div id="Header">
     <el-row>
       <el-col :span="24" class="main">
-        <el-col :span="24" class="main header">
-          <el-col :span="24" class="one">
-            <el-col :span="12" class="left">
-              <span>
-                <i v-if="!collapse" class="el-icon-s-fold"></i>
-                <i v-else class="el-icon-s-unfold"></i>
-              </span>
-              <span>{{ siteInfo.zhTitle }}-管理中心</span>
-            </el-col>
-            <el-col :span="12" class="right">
-              <span>
-                <el-icon><UserFilled /></el-icon>
-                {{ user && user._id ? user.nick_name : '游客' }}
-              </span>
-              <el-button type="danger" size="small" @click="logout">退出登录</el-button>
-            </el-col>
-          </el-col>
-        </el-col>
+        <div class="left">
+          <el-breadcrumb separator="/">
+            <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
+            <el-breadcrumb-item :to="{ path: '/' }">用户管理</el-breadcrumb-item>
+            <el-breadcrumb-item>普通用户</el-breadcrumb-item>
+          </el-breadcrumb>
+        </div>
+        <div class="right">
+          <el-dropdown>
+            <el-icon style="margin-right: 8px; margin-top: 1px">
+              <setting />
+            </el-icon>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item>View</el-dropdown-item>
+                <el-dropdown-item>Add</el-dropdown-item>
+                <el-dropdown-item>Delete</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+          <span>
+            <el-icon>
+              <UserFilled />
+            </el-icon>
+            {{ user && user._id ? user.nick_name : '游客' }}
+          </span>
+        </div>
       </el-col>
     </el-row>
   </div>
 </template>
 
 <script setup>
-import { siteInfo } from '@/layout/site'
 import { UserStore } from '@/store/user'
 const userStore = UserStore()
 const user = computed(() => userStore.user)
-let collapse = ref(false)
 const route = useRoute()
 // 退出登录
 const logout = () => {
@@ -40,36 +47,23 @@ const logout = () => {
 </script>
 <style scoped lang="scss">
 .main {
-  background-color: #242f42;
-  .one {
-    height: 60px;
-    border-bottom: 1px solid #f1f1f1;
-    padding: 0 10px;
-    display: flex;
-    .left {
-      line-height: 60px;
-      span {
-        display: inline-block;
-        margin: 0 10px;
-        font-size: 22px;
-        color: #ffffff;
-        font-weight: bold;
-        font-family: cursive;
-      }
-    }
-    .right {
-      text-align: right;
-      padding: 16px 0;
-      span {
-        padding: 0 8px;
-        color: #ffffff;
-        position: relative;
-        top: 1px;
-      }
-      .el-icon {
-        top: 2px;
-      }
-    }
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 20px;
+  height: 50px;
+  background: #ffffff;
+
+  .left {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .right {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
   }
 }
 </style>

+ 26 - 2
src/layout/parts/Sidebar.vue

@@ -4,6 +4,10 @@
     <el-row class="sidebar">
       <el-col :span="24" class="main">
         <el-col :span="24" class="first">
+          <img :src="siteInfo.logo_url" class="logo-image" />
+          <span class="logo-title"> {{ siteInfo.zhTitle }}</span>
+        </el-col>
+        <el-col :span="24" class="second">
           <el-menu
             class="sidebar-el-menu"
             :default-active="onRoutes"
@@ -56,7 +60,7 @@
 </template>
 
 <script setup>
-import { menuInfo } from '@/layout/site'
+import { siteInfo, menuInfo } from '@/layout/site'
 import { UserStore } from '@/store/user'
 
 const route = useRoute()
@@ -90,12 +94,32 @@ watch(
   width: 0;
 }
 
-.sidebar > ul {
+.sidebar>ul {
   height: 100%;
 }
 
 .main {
   .first {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 50px;
+    background-color: var(--sidebar-logo-background);
+
+    .logo-image {
+      width: 20px;
+      height: 20px;
+    }
+
+    .logo-title {
+      margin-left: 10px;
+      font-size: 14px;
+      font-weight: bold;
+      color: white;
+    }
+  }
+
+  .second {
     padding: 0 2px 0 0;
 
     .el-menu {

+ 437 - 14
src/layout/parts/breadcrumb.vue

@@ -1,22 +1,445 @@
 <template>
-  <div id="breadcrumb">
-    <el-row>
-      <el-col :span="24" class="crumbs">
-        <el-breadcrumb separator="/">
-          <el-breadcrumb-item> <i class="el-icon-s-grid"></i> {{ breadcrumbTitle }} </el-breadcrumb-item>
-        </el-breadcrumb>
-      </el-col>
-    </el-row>
+  <div class="tags-container">
+    <el-scrollbar class="scroll-container" :vertical="false" @wheel.prevent="handleScroll">
+      <router-link
+        ref="tagRef"
+        v-for="tag in visitedViews"
+        :key="tag.fullPath"
+        :class="'tags-item ' + (isActive(tag) ? 'active' : '')"
+        :to="{ path: tag.path, query: tag.query }"
+        @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
+        @contextmenu.prevent="openContentMenu(tag, $event)"
+      >
+        {{ translateRouteTitle(tag.title) }}
+        <Close class="close-icon" v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)" />
+      </router-link>
+    </el-scrollbar>
+    <!-- tag标签操作菜单 -->
+    <ul v-show="contentMenuVisible" class="contextmenu" :style="{ left: left + 'px', top: top + 'px' }">
+      <li @click="refreshSelectedTag(selectedTag)">
+        <svg-icon icon-class="refresh" />
+        刷新
+      </li>
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
+        <svg-icon icon-class="close" />
+        关闭
+      </li>
+      <li @click="closeOtherTags">
+        <svg-icon icon-class="close_other" />
+        关闭其它
+      </li>
+      <li v-if="!isFirstView()" @click="closeLeftTags">
+        <svg-icon icon-class="close_left" />
+        关闭左侧
+      </li>
+      <li v-if="!isLastView()" @click="closeRightTags">
+        <svg-icon icon-class="close_right" />
+        关闭右侧
+      </li>
+      <li @click="closeAllTags(selectedTag)">
+        <svg-icon icon-class="close_all" />
+        关闭所有
+      </li>
+    </ul>
   </div>
 </template>
 
 <script setup>
-// #region 参数传递
-const props = defineProps({
-  breadcrumbTitle: { type: String, default: () => '' }
+import { ref, watch, onMounted, getCurrentInstance, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { storeToRefs } from 'pinia'
+import { resolve } from 'path-browserify'
+import { translateRouteTitle } from '@/utils/i18n'
+
+import { usePermissionStore, useTagsViewStore, useSettingsStore, useAppStore } from '@/store'
+
+const { proxy } = getCurrentInstance()
+const router = useRouter()
+const route = useRoute()
+
+const permissionStore = usePermissionStore()
+const tagsViewStore = useTagsViewStore()
+const appStore = useAppStore()
+
+const { visitedViews } = storeToRefs(tagsViewStore)
+const settingsStore = useSettingsStore()
+const layout = computed(() => settingsStore.layout)
+
+const selectedTag = ref({
+  path: '',
+  fullPath: '',
+  name: '',
+  title: '',
+  affix: false,
+  keepAlive: false
 })
-const { breadcrumbTitle } = toRefs(props)
 
-// #endregion
+const affixTags = ref([])
+const left = ref(0)
+const top = ref(0)
+
+watch(
+  route,
+  () => {
+    addTags()
+    moveToCurrentTag()
+  },
+  {
+    immediate: true //初始化立即执行
+  }
+)
+
+const contentMenuVisible = ref(false) // 右键菜单是否显示
+watch(contentMenuVisible, (value) => {
+  if (value) {
+    document.body.addEventListener('click', closeContentMenu)
+  } else {
+    document.body.removeEventListener('click', closeContentMenu)
+  }
+})
+
+/**
+ * 过滤出需要固定的标签
+ */
+function filterAffixTags(routes, basePath = '/') {
+  let tags = []
+  routes.forEach((route) => {
+    const tagPath = resolve(basePath, route.path)
+    if (route.meta?.affix) {
+      tags.push({
+        path: tagPath,
+        fullPath: tagPath,
+        name: String(route.name),
+        title: route.meta?.title || 'no-name',
+        affix: route.meta?.affix,
+        keepAlive: route.meta?.keepAlive
+      })
+    }
+    if (route.children) {
+      const tempTags = filterAffixTags(route.children, basePath + route.path)
+      if (tempTags.length >= 1) {
+        tags = [...tags, ...tempTags]
+      }
+    }
+  })
+  return tags
+}
+
+function initTags() {
+  const tags = filterAffixTags(permissionStore.routes)
+  affixTags.value = tags
+  for (const tag of tags) {
+    // Must have tag name
+    if (tag.name) {
+      tagsViewStore.addVisitedView(tag)
+    }
+  }
+}
+
+function addTags() {
+  if (route.meta.title) {
+    tagsViewStore.addView({
+      name: route.name,
+      title: route.meta.title,
+      path: route.path,
+      fullPath: route.fullPath,
+      affix: route.meta?.affix,
+      keepAlive: route.meta?.keepAlive
+    })
+  }
+}
+
+function moveToCurrentTag() {
+  // 使用 nextTick() 的目的是确保在更新 tagsView 组件之前,scrollPaneRef 对象已经滚动到了正确的位置。
+  nextTick(() => {
+    for (const tag of visitedViews.value) {
+      if (tag.path === route.path) {
+        if (tag.fullPath !== route.fullPath) {
+          tagsViewStore.updateVisitedView({
+            name: route.name,
+            title: route.meta.title || '',
+            path: route.path,
+            fullPath: route.fullPath,
+            affix: route.meta?.affix,
+            keepAlive: route.meta?.keepAlive
+          })
+        }
+      }
+    }
+  })
+}
+
+function isActive(tag) {
+  return tag.path === route.path
+}
+
+function isAffix(tag) {
+  return tag?.affix
+}
+
+function isFirstView() {
+  try {
+    return selectedTag.value.path === '/dashboard' || selectedTag.value.fullPath === tagsViewStore.visitedViews[1].fullPath
+  } catch (err) {
+    return false
+  }
+}
+
+function isLastView() {
+  try {
+    return selectedTag.value.fullPath === tagsViewStore.visitedViews[tagsViewStore.visitedViews.length - 1].fullPath
+  } catch (err) {
+    return false
+  }
+}
+
+function refreshSelectedTag(view) {
+  tagsViewStore.delCachedView(view)
+  const { fullPath } = view
+  nextTick(() => {
+    router.replace({ path: '/redirect' + fullPath })
+  })
+}
+
+function toLastView(visitedViews, view) {
+  const latestView = visitedViews.slice(-1)[0]
+  if (latestView && latestView.fullPath) {
+    router.push(latestView.fullPath)
+  } else {
+    // now the default is to redirect to the home page if there is no tags-view,
+    // you can adjust it according to your needs.
+    if (view?.name === 'Dashboard') {
+      // to reload home page
+      router.replace({ path: '/redirect' + view.fullPath })
+    } else {
+      router.push('/')
+    }
+  }
+}
+
+function closeSelectedTag(view) {
+  tagsViewStore.delView(view).then((res) => {
+    if (isActive(view)) {
+      toLastView(res.visitedViews, view)
+    }
+  })
+}
+
+function closeLeftTags() {
+  tagsViewStore.delLeftViews(selectedTag.value).then((res) => {
+    if (!res.visitedViews.find((item) => item.path === route.path)) {
+      toLastView(res.visitedViews)
+    }
+  })
+}
+function closeRightTags() {
+  tagsViewStore.delRightViews(selectedTag.value).then((res) => {
+    if (!res.visitedViews.find((item) => item.path === route.path)) {
+      toLastView(res.visitedViews)
+    }
+  })
+}
+
+function closeOtherTags() {
+  router.push(selectedTag.value)
+  tagsViewStore.delOtherViews(selectedTag.value).then(() => {
+    moveToCurrentTag()
+  })
+}
+
+function closeAllTags(view) {
+  tagsViewStore.delAllViews().then((res) => {
+    toLastView(res.visitedViews, view)
+  })
+}
+
+/**
+ * 打开右键菜单
+ */
+function openContentMenu(tag, e) {
+  const menuMinWidth = 105
+
+  const offsetLeft = proxy?.$el.getBoundingClientRect().left // container margin left
+  const offsetWidth = proxy?.$el.offsetWidth // container width
+  const maxLeft = offsetWidth - menuMinWidth // left boundary
+  const l = e.clientX - offsetLeft + 15 // 15: margin right
+
+  if (l > maxLeft) {
+    left.value = maxLeft
+  } else {
+    left.value = l
+  }
+
+  // 混合模式下,需要减去顶部菜单(fixed)的高度
+  if (layout.value === 'mix') {
+    top.value = e.clientY - 50
+  } else {
+    top.value = e.clientY
+  }
+
+  contentMenuVisible.value = true
+  selectedTag.value = tag
+}
+
+/**
+ * 关闭右键菜单
+ */
+function closeContentMenu() {
+  contentMenuVisible.value = false
+}
+
+/**
+ * 滚动事件
+ */
+function handleScroll() {
+  closeContentMenu()
+}
+
+function findOutermostParent(tree, findName) {
+  let parentMap = {}
+
+  function buildParentMap(node, parent) {
+    parentMap[node.name] = parent
+
+    if (node.children) {
+      for (let i = 0; i < node.children.length; i++) {
+        buildParentMap(node.children[i], node)
+      }
+    }
+  }
+
+  for (let i = 0; i < tree.length; i++) {
+    buildParentMap(tree[i], null)
+  }
+
+  let currentNode = parentMap[findName]
+  while (currentNode) {
+    if (!parentMap[currentNode.name]) {
+      return currentNode
+    }
+    currentNode = parentMap[currentNode.name]
+  }
+
+  return null
+}
+
+const againActiveTop = (newVal) => {
+  if (layout.value !== 'mix') return
+  const parent = findOutermostParent(permissionStore.routes, newVal)
+  if (appStore.activeTopMenu !== parent.path) {
+    appStore.activeTopMenu(parent.path)
+  }
+}
+// 如果是混合模式,更改selectedTag,需要对应高亮的activeTop
+watch(
+  () => route.name,
+  (newVal) => {
+    if (newVal) {
+      againActiveTop(newVal)
+    }
+  },
+  {
+    deep: true
+  }
+)
+onMounted(() => {
+  initTags()
+})
 </script>
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.tags-container {
+  width: 100%;
+  height: 35px;
+  background-color: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-light);
+  box-shadow: 0 1px 1px var(--el-box-shadow-light);
+
+  .tags-item {
+    display: inline-block;
+    padding: 3px 8px;
+    margin: 4px 0 0 5px;
+    font-size: 12px;
+    cursor: pointer;
+    border: 1px solid var(--el-border-color-light);
+    text-decoration: none;
+
+    &:hover {
+      color: var(--el-color-primary);
+    }
+
+    &:first-of-type {
+      margin-left: 15px;
+    }
+
+    &:last-of-type {
+      margin-right: 15px;
+    }
+
+    .close-icon {
+      width: 1em;
+      height: 1em;
+      color: #000;
+      border-radius: 50%;
+
+      &:hover {
+        color: #fff;
+        background-color: var(--el-color-primary);
+      }
+    }
+
+    &.active {
+      color: #fff;
+      background-color: var(--el-color-primary);
+
+      &::before {
+        display: inline-block;
+        width: 8px;
+        height: 8px;
+        margin-right: 5px;
+        background: #fff;
+        border-radius: 50%;
+      }
+      .close-icon {
+        color: #fff;
+      }
+      .close-icon:hover {
+        color: var(--el-color-primary);
+        background-color: var(--el-fill-color-light);
+      }
+    }
+  }
+}
+
+.contextmenu {
+  position: absolute;
+  z-index: 99;
+  font-size: 12px;
+  background: var(--el-bg-color-overlay);
+  border-radius: 4px;
+  box-shadow: var(--el-box-shadow-light);
+
+  li {
+    padding: 8px 16px;
+    cursor: pointer;
+
+    &:hover {
+      background: var(--el-fill-color-light);
+    }
+  }
+}
+
+.scroll-container {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+  white-space: nowrap;
+
+  .el-scrollbar__bar {
+    bottom: 0;
+  }
+
+  .el-scrollbar__wrap {
+    height: 49px;
+  }
+}
+</style>

+ 160 - 152
src/layout/site.js

@@ -13,157 +13,165 @@ export const menuInfo = {
     textColor: '#ffffff'
   },
   menuList: [
-    // {
-    //   _id: '65a479b4969adc42d8feea73',
-    //   name: '首页',
-    //   order_num: 1,
-    //   path: '/homeIndex',
-    //   component: '/home/index',
-    //   type: '1',
-    //   is_use: '0',
-    //   for_platform: '0'
-    // },
-    // {
-    //   _id: '65a479b4969adc42d8feea71',
-    //   name: '系统设置',
-    //   order_num: 2,
-    //   type: '0',
-    //   is_use: '0',
-    //   for_platform: '0',
-    //   children: [
-    //     {
-    //       _id: '65a479b4969adc42d8feea75',
-    //       name: '菜单设置',
-    //       parent_id: '65a479b4969adc42d8feea71',
-    //       order_num: 1,
-    //       path: '/system/menus',
-    //       component: '/system/menus/index',
-    //       type: '1',
-    //       is_use: '0',
-    //       for_platform: '0',
-    //       parent_name: '系统设置'
-    //     },
-    //     {
-    //       _id: '65a479b4969adc42d8feea76',
-    //       name: '角色管理',
-    //       parent_id: '65a479b4969adc42d8feea71',
-    //       order_num: 2,
-    //       path: '/system/role',
-    //       component: '/system/role/index',
-    //       type: '1',
-    //       is_use: '0',
-    //       for_platform: '0',
-    //       parent_name: '系统设置'
-    //     },
-    //     {
-    //       _id: '65a479b4969adc42d8feea77',
-    //       name: '字典管理',
-    //       parent_id: '65a479b4969adc42d8feea71',
-    //       order_num: 3,
-    //       path: '/system/dict',
-    //       component: '/system/dict/index',
-    //       type: '1',
-    //       is_use: '0',
-    //       for_platform: '0',
-    //       parent_name: '系统设置'
-    //     },
-    //     {
-    //       _id: '65a479b4969adc42d8feea78',
-    //       name: '字典数据',
-    //       parent_id: '65a479b4969adc42d8feea71',
-    //       order_num: 4,
-    //       path: '/system/dictData',
-    //       component: '/system/dictData/index',
-    //       type: '2',
-    //       is_use: '0',
-    //       for_platform: '0',
-    //       parent_name: '系统设置'
-    //     }
-    //   ]
-    // },
-    // {
-    //   _id: '65a479b4969adc42d8feea72',
-    //   name: '用户管理',
-    //   order_num: 3,
-    //   type: '0',
-    //   is_use: '0',
-    //   for_platform: '0',
-    //   children: [
-    //     {
-    //       _id: '65a479b4969adc42d8feea7a',
-    //       name: '管理员用户',
-    //       parent_id: '65a479b4969adc42d8feea72',
-    //       order_num: 1,
-    //       path: '/user/admin',
-    //       component: '/user/admin/index',
-    //       type: '1',
-    //       is_use: '0',
-    //       for_platform: '0',
-    //       parent_name: '用户管理'
-    //     },
-    //     {
-    //       _id: '65a479b4969adc42d8feea7b',
-    //       name: '平台用户',
-    //       parent_id: '65a479b4969adc42d8feea72',
-    //       order_num: 2,
-    //       path: '/user/user',
-    //       component: '/user/user/index',
-    //       type: '1',
-    //       is_use: '0',
-    //       for_platform: '0',
-    //       parent_name: '用户管理'
-    //     }
-    //   ]
-    // },
-    // {
-    //   _id: '65a479b4969adc42d8feea7e',
-    //   name: '菜品管理',
-    //   order_num: 5,
-    //   path: '/menu',
-    //   component: '/menu/index',
-    //   type: '1',
-    //   is_use: '0',
-    //   for_platform: '0'
-    // },
-    // {
-    //   _id: '65a479b4969adc42d8feea7e',
-    //   name: '安排管理',
-    //   order_num: 6,
-    //   path: '/arrange',
-    //   component: '/arrange/index',
-    //   type: '1',
-    //   is_use: '0',
-    //   for_platform: '0'
-    // },
-    // {
-    //   _id: '65a479b4969adc42d8feea7f',
-    //   name: '订单管理',
-    //   order_num: 7,
-    //   path: '/order',
-    //   component: '/order/index',
-    //   type: '1',
-    //   is_use: '0',
-    //   for_platform: '0'
-    // },
-    // {
-    //   _id: '65a479b4969adc42d8feea80',
-    //   name: '新闻管理',
-    //   order_num: 8,
-    //   path: '/news',
-    //   component: '/news/index',
-    //   type: '1',
-    //   is_use: '0',
-    //   for_platform: '0'
-    // },
-    // {
-    //   _id: '65a479b4969adc42d8feea7c',
-    //   name: '修改密码',
-    //   order_num: 999,
-    //   path: '/acccount/updatepd',
-    //   component: '/acccount/updatepd/index',
-    //   type: '1',
-    //   is_use: '0',
-    //   for_platform: '0'
-    // }
+    {
+      _id: '65b7633cf546132d55d63958',
+      name: '首页',
+      order_num: 1,
+      path: '/',
+      component: '/home/index',
+      type: '1',
+      is_use: '0'
+    },
+    {
+      _id: '65b7633cf546132d55d63956',
+      name: '系统设置',
+      order_num: 2,
+      type: '0',
+      is_use: '0',
+      children: [
+        {
+          _id: '65b7633cf546132d55d6395a',
+          name: '菜单设置',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 1,
+          path: '/system/menus',
+          component: '/system/menus/index',
+          type: '1',
+          is_use: '0',
+          parent_name: '系统设置'
+        },
+        {
+          _id: '65b7633cf546132d55d6395b',
+          name: '角色管理',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 2,
+          path: '/system/role',
+          component: '/system/role/index',
+          type: '1',
+          is_use: '0',
+          parent_name: '系统设置'
+        },
+        {
+          _id: '65b7633cf546132d55d6395c',
+          name: '字典管理',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 3,
+          path: '/system/dict',
+          component: '/system/dict/index',
+          type: '1',
+          is_use: '0',
+          parent_name: '系统设置'
+        },
+        {
+          _id: '65b7633cf546132d55d6395d',
+          name: '字典数据',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 4,
+          path: '/system/dictData',
+          component: '/system/dictData/index',
+          type: '2',
+          is_use: '0',
+          parent_name: '系统设置'
+        },
+        {
+          _id: '65b7633cf546132d55d6395e',
+          name: '平台设置',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 5,
+          path: '/system/config',
+          component: '/system/config/index',
+          type: '1',
+          is_use: '0',
+          parent_name: '系统设置'
+        },
+        {
+          _id: '65b88ca947ad35b235c046a9',
+          name: '模块设置',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 6,
+          path: '/system/module',
+          component: '/system/module/index',
+          type: '1',
+          is_use: '0',
+          parent_name: '系统设置'
+        }
+      ]
+    },
+    {
+      _id: '65b7633cf546132d55d63957',
+      name: '用户管理',
+      order_num: 3,
+      type: '0',
+      is_use: '0',
+      children: [
+        {
+          _id: '65b7633cf546132d55d63960',
+          name: '管理员用户',
+          parent_id: '65b7633cf546132d55d63957',
+          order_num: 1,
+          path: '/user/admin',
+          component: '/user/admin/index',
+          type: '1',
+          is_use: '0',
+          parent_name: '用户管理'
+        },
+        {
+          _id: '65b7633cf546132d55d63961',
+          name: '平台用户',
+          parent_id: '65b7633cf546132d55d63957',
+          order_num: 2,
+          path: '/user/user',
+          component: '/user/user/index',
+          type: '1',
+          is_use: '0',
+          parent_name: '用户管理'
+        }
+      ]
+    },
+    {
+      _id: '65b7633cf546132d55d63963',
+      name: '公证员管理',
+      order_num: 5,
+      path: '/personnel',
+      component: '/personnel/index',
+      type: '1',
+      is_use: '0'
+    },
+    {
+      _id: '65b7633cf546132d55d63964',
+      name: '申办流程管理',
+      order_num: 6,
+      path: '/path',
+      component: '/path/index',
+      type: '1',
+      is_use: '0'
+    },
+    {
+      _id: '65b7633cf546132d55d63965',
+      name: '业务管理',
+      order_num: 7,
+      path: '/business',
+      component: '/business/index',
+      type: '1',
+      is_use: '0'
+    },
+    {
+      _id: '65b7633cf546132d55d63966',
+      name: '申请记录管理',
+      order_num: 8,
+      path: '/apply',
+      component: '/apply/index',
+      type: '1',
+      is_use: '0'
+    },
+    {
+      _id: '65b7633cf546132d55d63962',
+      name: '修改密码',
+      order_num: 999,
+      path: '/acccount/updatepd',
+      component: '/acccount/updatepd/index',
+      type: '1',
+      is_use: '0'
+    }
   ]
 }

+ 56 - 4
src/router/index.js

@@ -1,9 +1,53 @@
 import { createRouter, createWebHistory } from 'vue-router'
 export const homeIndex = () => import('@/views/home/index.vue')
+export const Layout = () => import('@/layout/index.vue')
 
+const system = [
+  {
+    path: '/system/menus',
+    meta: { title: '菜单管理' },
+    component: () => import('@/views/system/menus/index.vue')
+  },
+  {
+    path: '/system/role',
+    meta: { title: '角色管理' },
+    component: () => import('@/views/system/role/index.vue')
+  },
+  {
+    path: '/system/dict',
+    meta: { title: '字典管理' },
+    component: () => import('@/views/system/dict/index.vue')
+  },
+  {
+    path: '/system/dictData',
+    meta: { title: '字典数据管理' },
+    component: () => import('@/views/system/dictData/index.vue')
+  },
+  {
+    path: '/system/config',
+    meta: { title: '平台设置' },
+    component: () => import('@/views/system/config/index.vue')
+  },
+  {
+    path: '/system/module',
+    meta: { title: '模块设置' },
+    component: () => import('@/views/system/module/index.vue')
+  }
+]
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
+    {
+      path: '/redirect',
+      component: Layout,
+      meta: { hidden: true },
+      children: [
+        {
+          path: '/redirect/:path(.*)',
+          component: () => import('@/views/redirect/index.vue')
+        }
+      ]
+    },
     {
       path: '/login',
       name: 'login',
@@ -12,14 +56,22 @@ const router = createRouter({
     },
     {
       path: '/',
-      meta: { title: '系统首页' },
-      component: () => import('@/layout/index.vue'),
+      meta: { title: '首页' },
+      component: Layout,
+      redirect: '/dashboard',
       children: [
         {
           path: '/',
-          meta: { title: '系统首页' },
+          name: 'dashboard',
+          meta: {
+            title: '首页',
+            affix: true,
+            keepAlive: true,
+            alwaysShow: false
+          },
           component: () => import('@/views/home/index.vue')
-        }
+        },
+        ...system
       ]
     }
   ]

+ 13 - 0
src/store/api/menu.js

@@ -0,0 +1,13 @@
+import { defineStore } from 'pinia'
+import { get, omit } from 'lodash'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+const axios = new AxiosWrapper()
+export const MenuStore = defineStore('menu', () => {
+  const listRoutes = async (payload) => {
+    const type = get(payload, 'type')
+    const np = omit(payload, 'type')
+    const res = await axios.$post(`/menu/${type}`, np)
+    return res
+  }
+  return { listRoutes }
+})

+ 3 - 4
src/store/index.js

@@ -8,8 +8,7 @@ export function setupStore(app) {
 }
 
 export * from './modules/app'
-// export * from './modules/permission'
-// export * from './modules/settings'
-// export * from './modules/tagsView'
-// export * from './modules/user'
+export * from './modules/permission'
+export * from './modules/settings'
+export * from './modules/tagsView'
 export { store }

+ 121 - 0
src/store/modules/permission.js

@@ -0,0 +1,121 @@
+// import { router } from '@/router'
+import { store } from '@/store'
+const modules = import.meta.glob('../../views/**/**.vue')
+const Layout = () => import('@/layout/index.vue')
+
+/**
+ * Use meta.role to determine if the current user has permission
+ *
+ * @param roles 用户角色集合
+ * @param route 路由
+ * @returns
+ */
+const hasPermission = (roles, route) => {
+  if (route.meta && route.meta.roles) {
+    // 角色【超级管理员】拥有所有权限,忽略校验
+    if (roles.includes('ROOT')) {
+      return true
+    }
+    return roles.some((role) => {
+      if (route.meta?.roles) {
+        return route.meta.roles.includes(role)
+      }
+    })
+  }
+  return false
+}
+
+/**
+ * 递归过滤有权限的异步(动态)路由
+ *
+ * @param routes 接口返回的异步(动态)路由
+ * @param roles 用户角色集合
+ * @returns 返回用户有权限的异步(动态)路由
+ */
+const filterAsyncRoutes = (routes, roles) => {
+  const asyncRoutes = []
+
+  routes.forEach((route) => {
+    const tmpRoute = { ...route } // ES6扩展运算符复制新对象
+    if (!route.name) {
+      tmpRoute.name = route.path
+    }
+    // 判断用户(角色)是否有该路由的访问权限
+    if (hasPermission(roles, tmpRoute)) {
+      if (tmpRoute.component?.toString() == 'Layout') {
+        tmpRoute.component = Layout
+      } else {
+        const component = modules[`../../views/${tmpRoute.component}.vue`]
+        if (component) {
+          tmpRoute.component = component
+        } else {
+          tmpRoute.component = modules[`../../views/error-page/404.vue`]
+        }
+      }
+
+      if (tmpRoute.children) {
+        tmpRoute.children = filterAsyncRoutes(tmpRoute.children, roles)
+      }
+
+      asyncRoutes.push(tmpRoute)
+    }
+  })
+
+  return asyncRoutes
+}
+
+// setup
+export const usePermissionStore = defineStore('permission', () => {
+  // state
+  const routes = ref([])
+
+  // actions
+  function setRoutes(newRoutes) {
+    console.log(newRoutes);
+    // routes.value = router.concat(newRoutes)
+  }
+  /**
+   * 生成动态路由
+   *
+   * @param roles 用户角色集合
+   * @returns
+   */
+  function generateRoutes(roles) {
+    console.log(roles);
+    // return new Promise((resolve, reject) => {
+    //   // 接口获取所有路由
+    //   MenuStore.listRoutes
+    //     .then(({ data: asyncRoutes }) => {
+    //       // 根据角色获取有访问权限的路由
+    //       const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
+    //       setRoutes(accessedRoutes)
+    //       resolve(accessedRoutes)
+    //     })
+    //     .catch((error) => {
+    //       reject(error)
+    //     })
+    // })
+  }
+  /**
+   * 获取与激活的顶部菜单项相关的混合模式左侧菜单集合
+   */
+  const mixLeftMenus = ref([])
+  function setMixLeftMenus(topMenuPath) {
+    const matchedItem = routes.value.find((item) => item.path === topMenuPath)
+    if (matchedItem && matchedItem.children) {
+      mixLeftMenus.value = matchedItem.children
+    }
+  }
+  return {
+    routes,
+    setRoutes,
+    generateRoutes,
+    mixLeftMenus,
+    setMixLeftMenus
+  }
+})
+
+// 非setup
+export function usePermissionStoreHook() {
+  return usePermissionStore(store)
+}

+ 16 - 2
src/store/modules/settings.js

@@ -1,10 +1,13 @@
 import { defineStore } from 'pinia'
 import defaultSettings from '@/settings'
+import { useStorageAsync } from '@vueuse/core'
 
 export const useSettingsStore = defineStore('setting', () => {
   const title = defaultSettings.title
   const version = defaultSettings.version
-  const tagsView = useStorage('tagsView', defaultSettings.tagsView)
+
+  const tagsView = useStorageAsync('tagsView', defaultSettings.tagsView)
+
   const showSettings = ref(defaultSettings.showSettings)
   const sidebarLogo = ref(defaultSettings.sidebarLogo)
   const fixedHeader = useStorage('fixedHeader', defaultSettings.fixedHeader)
@@ -15,8 +18,19 @@ export const useSettingsStore = defineStore('setting', () => {
   // Whether to enable watermark
   const watermark = useStorage('watermark', defaultSettings.watermark)
 
+  const settingsMap = {
+    showSettings,
+    fixedHeader,
+    tagsView,
+    sidebarLogo,
+    layout,
+    themeColor,
+    theme,
+    watermark: watermark.value
+  }
+
   function changeSetting({ key, value }) {
-    const setting = key
+    const setting = settingsMap[key]
     if (setting !== undefined) {
       setting.value = value
       if (key === 'theme') {

+ 207 - 0
src/store/modules/tagsView.js

@@ -0,0 +1,207 @@
+export const useTagsViewStore = defineStore('tagsView', () => {
+  const visitedViews = ref([])
+  const cachedViews = ref([])
+
+  /**
+   * 添加已访问视图到已访问视图列表中
+   */
+  function addVisitedView(view) {
+    // 如果已经存在于已访问的视图列表中,则不再添加
+    if (visitedViews.value.some((v) => v.path === view.path)) {
+      return
+    }
+    // 如果视图是固定的(affix),则在已访问的视图列表的开头添加
+    if (view.affix) {
+      visitedViews.value.unshift(view)
+    } else {
+      // 如果视图不是固定的,则在已访问的视图列表的末尾添加
+      visitedViews.value.push(view)
+    }
+  }
+
+  /**
+   * 添加缓存视图到缓存视图列表中
+   */
+  function addCachedView(view) {
+    const viewName = view.name
+    // 如果缓存视图名称已经存在于缓存视图列表中,则不再添加
+    if (cachedViews.value.includes(viewName)) {
+      return
+    }
+
+    // 如果视图需要缓存(keepAlive),则将其路由名称添加到缓存视图列表中
+    if (view.keepAlive) {
+      cachedViews.value.push(viewName)
+    }
+  }
+
+  /**
+   * 从已访问视图列表中删除指定的视图
+   */
+  function delVisitedView(view) {
+    return new Promise((resolve) => {
+      for (const [i, v] of visitedViews.value.entries()) {
+        // 找到与指定视图路径匹配的视图,在已访问视图列表中删除该视图
+        if (v.path === view.path) {
+          visitedViews.value.splice(i, 1)
+          break
+        }
+      }
+      resolve([...visitedViews.value])
+    })
+  }
+
+  function delCachedView(view) {
+    const viewName = view.name
+    return new Promise((resolve) => {
+      const index = cachedViews.value.indexOf(viewName)
+      index > -1 && cachedViews.value.splice(index, 1)
+      resolve([...cachedViews.value])
+    })
+  }
+
+  function delOtherVisitedViews(view) {
+    return new Promise((resolve) => {
+      visitedViews.value = visitedViews.value.filter((v) => {
+        return v?.affix || v.path === view.path
+      })
+      resolve([...visitedViews.value])
+    })
+  }
+
+  function delOtherCachedViews(view) {
+    const viewName = view.name
+    return new Promise((resolve) => {
+      const index = cachedViews.value.indexOf(viewName)
+      if (index > -1) {
+        cachedViews.value = cachedViews.value.slice(index, index + 1)
+      } else {
+        // if index = -1, there is no cached tags
+        cachedViews.value = []
+      }
+      resolve([...cachedViews.value])
+    })
+  }
+
+  function updateVisitedView(view) {
+    for (let v of visitedViews.value) {
+      if (v.path === view.path) {
+        v = Object.assign(v, view)
+        break
+      }
+    }
+  }
+
+  function addView(view) {
+    addVisitedView(view)
+    addCachedView(view)
+  }
+
+  function delView(view) {
+    return new Promise((resolve) => {
+      delVisitedView(view)
+      delCachedView(view)
+      resolve({
+        visitedViews: [...visitedViews.value],
+        cachedViews: [...cachedViews.value]
+      })
+    })
+  }
+
+  function delOtherViews(view) {
+    return new Promise((resolve) => {
+      delOtherVisitedViews(view)
+      delOtherCachedViews(view)
+      resolve({
+        visitedViews: [...visitedViews.value],
+        cachedViews: [...cachedViews.value]
+      })
+    })
+  }
+
+  function delLeftViews(view) {
+    return new Promise((resolve) => {
+      const currIndex = visitedViews.value.findIndex((v) => v.path === view.path)
+      if (currIndex === -1) {
+        return
+      }
+      visitedViews.value = visitedViews.value.filter((item, index) => {
+        if (index >= currIndex || item?.affix) {
+          return true
+        }
+
+        const cacheIndex = cachedViews.value.indexOf(item.name)
+        if (cacheIndex > -1) {
+          cachedViews.value.splice(cacheIndex, 1)
+        }
+        return false
+      })
+      resolve({
+        visitedViews: [...visitedViews.value]
+      })
+    })
+  }
+  function delRightViews(view) {
+    return new Promise((resolve) => {
+      const currIndex = visitedViews.value.findIndex((v) => v.path === view.path)
+      if (currIndex === -1) {
+        return
+      }
+      visitedViews.value = visitedViews.value.filter((item, index) => {
+        if (index <= currIndex || item?.affix) {
+          return true
+        }
+      })
+      resolve({
+        visitedViews: [...visitedViews.value]
+      })
+    })
+  }
+
+  function delAllViews() {
+    return new Promise((resolve) => {
+      const affixTags = visitedViews.value.filter((tag) => tag?.affix)
+      visitedViews.value = affixTags
+      cachedViews.value = []
+      resolve({
+        visitedViews: [...visitedViews.value],
+        cachedViews: [...cachedViews.value]
+      })
+    })
+  }
+
+  function delAllVisitedViews() {
+    return new Promise((resolve) => {
+      const affixTags = visitedViews.value.filter((tag) => tag?.affix)
+      visitedViews.value = affixTags
+      resolve([...visitedViews.value])
+    })
+  }
+
+  function delAllCachedViews() {
+    return new Promise((resolve) => {
+      cachedViews.value = []
+      resolve([...cachedViews.value])
+    })
+  }
+
+  return {
+    visitedViews,
+    cachedViews,
+    addVisitedView,
+    addCachedView,
+    delVisitedView,
+    delCachedView,
+    delOtherVisitedViews,
+    delOtherCachedViews,
+    updateVisitedView,
+    addView,
+    delView,
+    delOtherViews,
+    delLeftViews,
+    delRightViews,
+    delAllViews,
+    delAllVisitedViews,
+    delAllCachedViews
+  }
+})

+ 16 - 5
src/views/home/index.vue

@@ -1,9 +1,20 @@
 <template>
-  <div id="index" style="">
-    <p>index</p>
+  <div id="index">
+    <el-row>
+      <el-col :span="24" class="main animate__animated animate__backInRight" v-loading="loading">
+        <el-col :span="24" class="one"> 首页 </el-col>
+      </el-col>
+    </el-row>
   </div>
 </template>
 
-<script setup></script>
-
-<style scoped></style>
+<script setup>
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+</script>
+<style scoped lang="scss"></style>

+ 14 - 0
src/views/redirect/index.vue

@@ -0,0 +1,14 @@
+<template>
+  <div></div>
+</template>
+
+<script setup>
+import { useRoute, useRouter } from 'vue-router'
+
+const route = useRoute()
+const router = useRouter()
+const { params, query } = route
+const { path } = params
+
+router.replace({ path: '/' + path, query })
+</script>

+ 20 - 0
src/views/system/config/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div id="index">
+    <el-row>
+      <el-col :span="24" class="main animate__animated animate__backInRight" v-loading="loading">
+        <el-col :span="24" class="one"> 系统设置 </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+</script>
+<style scoped lang="scss"></style>

+ 20 - 0
src/views/system/dict/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div id="index">
+    <el-row>
+      <el-col :span="24" class="main animate__animated animate__backInRight" v-loading="loading">
+        <el-col :span="24" class="one"> 字典表 </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+</script>
+<style scoped lang="scss"></style>

+ 20 - 0
src/views/system/dictData/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div id="index">
+    <el-row>
+      <el-col :span="24" class="main animate__animated animate__backInRight" v-loading="loading">
+        <el-col :span="24" class="one"> 字典数据 </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+</script>
+<style scoped lang="scss"></style>

+ 20 - 0
src/views/system/menus/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div id="index">
+    <el-row>
+      <el-col :span="24" class="main animate__animated animate__backInRight" v-loading="loading">
+        <el-col :span="24" class="one"> 菜单 </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+</script>
+<style scoped lang="scss"></style>

+ 20 - 0
src/views/system/module/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div id="index">
+    <el-row>
+      <el-col :span="24" class="main animate__animated animate__backInRight" v-loading="loading">
+        <el-col :span="24" class="one"> 模块 </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+</script>
+<style scoped lang="scss"></style>

+ 20 - 0
src/views/system/role/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div id="index">
+    <el-row>
+      <el-col :span="24" class="main animate__animated animate__backInRight" v-loading="loading">
+        <el-col :span="24" class="one"> 角色 </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+</script>
+<style scoped lang="scss"></style>