Prechádzať zdrojové kódy

Merge branch 'main' of http://git.cc-lotus.info/util/web-template-vue3-js

lrf 1 rok pred
rodič
commit
61f958f591

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 3784 - 44
package-lock.json


+ 4 - 1
package.json

@@ -16,6 +16,8 @@
     "axios": "^1.6.7",
     "element-plus": "^2.5.6",
     "lodash-es": "^4.17.21",
+    "path-browserify": "^1.0.1",
+    "path-to-regexp": "^6.2.1",
     "pinia": "^2.1.7",
     "vue": "^3.4.15",
     "vue-i18n": "^9.9.1",
@@ -33,6 +35,7 @@
     "unplugin-icons": "^0.18.5",
     "unplugin-vue-components": "^0.26.0",
     "vite": "^5.0.11",
-    "vite-plugin-inspect": "^0.8.3"
+    "vite-plugin-inspect": "^0.8.3",
+    "vite-plugin-svg-icons": "^2.0.1"
   }
 }

+ 1 - 0
src/assets/icons/close.svg

@@ -0,0 +1 @@
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 36 36"><path d="M19.41 18l8.29-8.29a1 1 0 0 0-1.41-1.41L18 16.59l-8.29-8.3a1 1 0 0 0-1.42 1.42l8.3 8.29l-8.3 8.29A1 1 0 1 0 9.7 27.7l8.3-8.29l8.29 8.29a1 1 0 0 0 1.41-1.41z" fill="currentColor"></path></svg>

+ 1 - 0
src/assets/icons/close_all.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 36 36"><path d="M26 17H10a1 1 0 0 0 0 2h16a1 1 0 0 0 0-2z" fill="currentColor"></path></svg>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
src/assets/icons/close_left.svg


+ 1 - 0
src/assets/icons/close_other.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 20 20"><path d="M3 5h14V3H3v2zm12 8V7H5v6h10zM3 17h14v-2H3v2z" fill="currentColor"></path></svg>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
src/assets/icons/close_right.svg


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
src/assets/icons/language.svg


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
src/assets/icons/refresh.svg


BIN
src/assets/logo.png


+ 92 - 0
src/components/Breadcrumb/index.vue

@@ -0,0 +1,92 @@
+<template>
+  <el-breadcrumb class="h-[50px] flex items-center">
+    <transition-group name="breadcrumb-transition">
+      <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
+        <span v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1" class="text-[var(--el-disabled-text-color)]">{{
+          translateRouteTitle(item.meta.title)
+        }}</span>
+        <a v-else @click.prevent="handleLink(item)">
+          {{ translateRouteTitle(item.meta.title) }}
+        </a>
+      </el-breadcrumb-item>
+    </transition-group>
+  </el-breadcrumb>
+</template>
+
+<script setup>
+import { onBeforeMount, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import { compile } from 'path-to-regexp'
+import router from '@/router'
+import { translateRouteTitle } from '@/utils/i18n'
+
+const currentRoute = useRoute()
+const pathCompile = (path) => {
+  const { params } = currentRoute
+  const toPath = compile(path)
+  return toPath(params)
+}
+
+const breadcrumbs = ref([])
+
+function getBreadcrumb() {
+  let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title)
+  const first = matched[0]
+  if (!isDashboard(first)) {
+    matched = [{ path: '/', meta: { title: 'dashboard' } }].concat(matched)
+  }
+  breadcrumbs.value = matched.filter((item) => {
+    return item.meta && item.meta.title && item.meta.breadcrumb !== false
+  })
+}
+
+function isDashboard(route) {
+  const name = route && route.name
+  if (!name) {
+    return false
+  }
+  return name.toString().trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
+}
+
+function handleLink(item) {
+  const { redirect, path, children } = item
+  if (children && children.length > 0) return
+  if (redirect) {
+    router.push(redirect).catch((err) => {
+      console.warn(err)
+    })
+    return
+  }
+  router.push(pathCompile(path)).catch((err) => {
+    console.warn(err)
+  })
+}
+
+watch(
+  () => currentRoute.path,
+  (path) => {
+    if (path.startsWith('/redirect/')) {
+      return
+    }
+    getBreadcrumb()
+  }
+)
+
+onBeforeMount(() => {
+  getBreadcrumb()
+})
+</script>
+<style lang="scss" scoped>
+.app-breadcrumb.el-breadcrumb {
+  display: inline-block;
+  margin-left: 8px;
+  font-size: 14px;
+  line-height: 50px;
+}
+
+// 覆盖 element-plus 的样式
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+  font-weight: 400 !important;
+}
+</style>

+ 32 - 0
src/components/LangSelect/index.vue

@@ -0,0 +1,32 @@
+<script setup>
+// 组件
+import { useI18n } from 'vue-i18n'
+import { useAppStore } from '@/store/modules/app'
+
+const appStore = useAppStore()
+const { locale } = useI18n()
+
+function handleLanguageChange(lang) {
+  locale.value = lang
+  appStore.changeLanguage(lang)
+  if (lang === 'en') {
+    ElMessage.success('Switch Language Successful!')
+  } else {
+    ElMessage.success('切换语言成功!')
+  }
+}
+</script>
+
+<template>
+  <el-dropdown trigger="click" @command="handleLanguageChange">
+    <div>
+      <SvgIcon icon-class="language"></SvgIcon>
+    </div>
+    <template #dropdown>
+      <el-dropdown-menu>
+        <el-dropdown-item :disabled="appStore.language === 'zh-cn'" command="zh-cn">中文</el-dropdown-item>
+        <el-dropdown-item :disabled="appStore.language === 'en'" command="en"> English</el-dropdown-item>
+      </el-dropdown-menu>
+    </template>
+  </el-dropdown>
+</template>

+ 43 - 0
src/components/SvgIcon/index.vue

@@ -0,0 +1,43 @@
+<template>
+  <svg aria-hidden="true" class="svg-icon" :style="'width:' + size + ';height:' + size">
+    <use :xlink:href="symbolId" :fill="color" />
+  </svg>
+</template>
+
+<script setup>
+const props = defineProps({
+  prefix: {
+    type: String,
+    default: 'icon'
+  },
+  iconClass: {
+    type: String,
+    required: false,
+    default: ''
+  },
+  color: {
+    type: String,
+    default: ''
+  },
+  size: {
+    type: String,
+    default: '1em'
+  }
+})
+
+const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`)
+</script>
+
+<style scoped>
+.svg-icon {
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  overflow: hidden;
+  vertical-align: -0.15em;
+  /* 因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果 */
+  outline: none;
+  fill: currentcolor;
+  /* 定义元素的颜色,currentColor是一个变量,这个变量的值就表示当前元素的color值,如果当前元素未设置color值,则从父元素继承 */
+}
+</style>

+ 7 - 0
src/components/index.js

@@ -0,0 +1,7 @@
+export default function globalComponents(app) {
+  const components = import.meta.glob('./**/**.{vue,tsx}', { eager: true }) //获取文件夹及其嵌套的多级子文件夹
+  for (let [key, value] of Object.entries(components)) {
+    const name = key.replace('./', '').split('/')[0]
+    app.component(value.default.name || name, value.default)
+  }
+}

+ 1 - 1
src/lang/index.js

@@ -16,7 +16,7 @@ const messages = {
 
 const i18n = createI18n({
   legacy: false,
-  locale: 'zh-cn',
+  locale: localStorage.getItem('language'),
   messages: messages,
   globalInjection: true
 })

+ 6 - 2
src/lang/package/en.js

@@ -1,20 +1,24 @@
 export default {
   // 路由国际化
   route: {
-    dashboard: 'Dashboard',
+    dashboard: 'home',
     document: 'Document'
   },
   // 登录页面国际化
   login: {
+    title: 'Information Technology Incubation Platform',
     username: 'Username',
     password: 'Password',
     login: 'Login',
-    captchaCode: 'Verify Code'
+    captchaCode: 'Verify Code',
+    placeholder1: 'please select your username',
+    placeholder2: 'please select your password'
   },
   // 导航栏国际化
   navbar: {
     dashboard: 'Dashboard',
     logout: 'Logout',
+    my: 'Personal Center',
     document: 'Document',
     gitee: 'Gitee'
   },

+ 14 - 2
src/lang/package/zh-cn.js

@@ -2,19 +2,31 @@ export default {
   // 路由国际化
   route: {
     dashboard: '首页',
-    document: '项目文档'
+    login: '账号登录',
+    document: '项目文档',
+    system: '系统设置',
+    menus: '菜单设置',
+    role: '角色管理',
+    dict: '字典管理',
+    dictData: '字典数据管理',
+    config: '平台设置',
+    module: '模块设置'
   },
   // 登录页面国际化
   login: {
+    title: '新一代信息技术孵化平台',
     username: '用户名',
     password: '密码',
     login: '登 录',
-    captchaCode: '验证码'
+    captchaCode: '验证码',
+    placeholder1: '请输入用户名',
+    placeholder2: '请输入密码'
   },
   // 导航栏国际化
   navbar: {
     dashboard: '首页',
     logout: '注销',
+    my: '个人中心',
     document: '项目文档',
     gitee: '码云'
   },

+ 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': '#304156' }">
+      <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>

+ 51 - 51
src/layout/parts/Header.vue

@@ -2,35 +2,35 @@
   <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">
+          <Breadcrumb></Breadcrumb>
+        </div>
+        <div class="right">
+          <LangSelect class="navbar-item"></LangSelect>
+          <el-dropdown>
+            <el-icon style="margin-right: 8px; margin-top: 1px">
+              <setting />
+            </el-icon>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item>{{ $t('navbar.my') }}</el-dropdown-item>
+                <el-dropdown-item @click="logout">{{ $t('navbar.logout') }}</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+          <span>
+            {{ 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 = userStore.user
-let collapse = ref(false)
+const user = computed(() => userStore.user)
 const router = useRouter()
 // 退出登录
 const logout = () => {
@@ -40,36 +40,36 @@ 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;
+
+    .navbar-item {
+      display: inline-block;
+      width: 30px;
+      height: 50px;
+      line-height: 50px;
+      color: var(--el-text-color);
+      text-align: center;
+      cursor: pointer;
+
+      &:hover {
+        background: rgb(0 0 0 / 10%);
       }
     }
   }
-}
-</style>
+}</style>

+ 60 - 5
src/layout/parts/Sidebar.vue

@@ -4,14 +4,18 @@
     <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">{{ $t('login.title') }}</span>
+        </el-col>
+        <el-col :span="24" class="second">
           <el-menu
             class="sidebar-el-menu"
             :default-active="onRoutes"
             unique-opened
             router
-            :background-color="styleInfo.backColor"
-            :text-color="styleInfo.textColor"
-            :active-text-color="styleInfo.activeColor"
+            background-color="#304156"
+            text-color="#bfcbd9"
+            active-text-color="#409eff"
           >
             <menu-item :items="items"></menu-item>
           </el-menu>
@@ -22,14 +26,45 @@
 </template>
 
 <script setup>
-import { menuInfo } from '@/layout/site'
+import { siteInfo, menuInfo } from '@/layout/site'
 import { UserStore } from '@/store/user'
 import menuItem from './sidebar/items.vue'
+import { useRoute } from 'vue-router'
 const route = useRoute()
+
+const onRoutes = ref(route.path)
 const userStore = UserStore()
-let onRoutes = route.path
 const styleInfo = ref(menuInfo.info)
 let items = ref(userStore.menus)
+const user = computed(() => userStore.user)
+
+const getMenu = async () => {
+  const menus = user.value.menus
+  const newMenus = [...menus]
+  items.value = newMenus
+}
+
+watch(
+  user,
+  (newVal) => {
+    if (newVal && newVal._id) {
+      if (newVal && newVal._id) getMenu()
+      else ElMessage({ message: `暂无用户信息,无法获取菜单信息`, type: 'error' })
+    }
+  },
+  {
+    deep: true
+  }
+)
+watch(
+  route,
+  (newVal) => {
+    if (newVal && newVal.path) onRoutes.value = newVal.path
+  },
+  {
+    immediate: true //初始化立即执行
+  }
+)
 </script>
 <style scoped lang="scss">
 .sidebar::-webkit-scrollbar {
@@ -42,6 +77,26 @@ let items = ref(userStore.menus)
 
 .main {
   .first {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 50px;
+    background-color: #242f42;
+
+    .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 {

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

@@ -1,22 +1,441 @@
 <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) }}
+        <SvgIcon class="close-icon" icon-class="close" v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)"></SvgIcon>
+      </router-link>
+    </el-scrollbar>
+    <!-- tag标签操作菜单 -->
+    <ul v-show="contentMenuVisible" class="contextmenu" :style="{ left: left + 'px', top: top + 'px' }">
+      <li @click="refreshSelectedTag(selectedTag)">
+        <SvgIcon icon-class="refresh"></SvgIcon>
+        刷新
+      </li>
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
+        <SvgIcon icon-class="close"></SvgIcon>
+        关闭
+      </li>
+      <li @click="closeOtherTags">
+        <SvgIcon icon-class="close_other"></SvgIcon>
+        关闭其它
+      </li>
+      <li v-if="!isFirstView()" @click="closeLeftTags">
+        <SvgIcon icon-class="close_left"></SvgIcon>
+        关闭左侧
+      </li>
+      <li v-if="!isLastView()" @click="closeRightTags">
+        <SvgIcon icon-class="close_right"></SvgIcon>
+        关闭右侧
+      </li>
+      <li @click="closeAllTags(selectedTag)">
+        <SvgIcon icon-class="close_all"></SvgIcon>
+        关闭所有
+      </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 {
+    if (view?.name === 'Dashboard') {
+      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 {
+  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;
+        content: "";
+        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 8px 0;
+    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: 'dashboard',
+      order_num: 1,
+      path: '/',
+      component: '/home/index',
+      type: '1',
+      is_use: '0'
+    },
+    {
+      _id: '65b7633cf546132d55d63956',
+      name: 'system',
+      order_num: 2,
+      type: '0',
+      is_use: '0',
+      children: [
+        {
+          _id: '65b7633cf546132d55d6395a',
+          name: 'menus',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 1,
+          path: '/system/menus',
+          component: '/system/menus/index',
+          type: '1',
+          is_use: '0',
+          parent_name: 'system'
+        },
+        {
+          _id: '65b7633cf546132d55d6395b',
+          name: 'role',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 2,
+          path: '/system/role',
+          component: '/system/role/index',
+          type: '1',
+          is_use: '0',
+          parent_name: 'system'
+        },
+        {
+          _id: '65b7633cf546132d55d6395c',
+          name: 'dict',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 3,
+          path: '/system/dict',
+          component: '/system/dict/index',
+          type: '1',
+          is_use: '0',
+          parent_name: 'system'
+        },
+        {
+          _id: '65b7633cf546132d55d6395d',
+          name: 'dictData',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 4,
+          path: '/system/dictData',
+          component: '/system/dictData/index',
+          type: '2',
+          is_use: '0',
+          parent_name: 'system'
+        },
+        {
+          _id: '65b7633cf546132d55d6395e',
+          name: 'config',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 5,
+          path: '/system/config',
+          component: '/system/config/index',
+          type: '1',
+          is_use: '0',
+          parent_name: 'system'
+        },
+        {
+          _id: '65b88ca947ad35b235c046a9',
+          name: 'module',
+          parent_id: '65b7633cf546132d55d63956',
+          order_num: 6,
+          path: '/system/module',
+          component: '/system/module/index',
+          type: '1',
+          is_use: '0',
+          parent_name: 'system'
+        }
+      ]
+    },
+    {
+      _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'
+    }
   ]
 }

+ 8 - 2
src/main.js

@@ -1,16 +1,22 @@
 import { createApp } from 'vue'
 import { setupStore } from '@/store'
-import i18n from '@/lang/index'
-import { InitCheckResult } from './utils/checkResult'
 
 import App from './App.vue'
 import router from './router'
 
 import * as ElementPlusIconsVue from '@element-plus/icons-vue'
 
+// 本地SVG图标
+import 'virtual:svg-icons-register'
 // 国际化
+import i18n from '@/lang/index'
+// 请求检查函数
+import { InitCheckResult } from './utils/checkResult'
 
+// 组件
+import globalComponents from '@/components'
 const app = createApp(App)
+globalComponents(app);
 setupStore(app)
 for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
   app.component(key, component)

+ 21 - 6
src/router/index.js

@@ -1,10 +1,22 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import { registerBeforeRouter } from './guard'
 export const homeIndex = () => import('@/views/home/index.vue')
+export const Layout = () => import('@/layout/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',
@@ -13,14 +25,17 @@ const router = createRouter({
     },
     {
       path: '/',
-      name: 'AdminFrame',
-      redirect: '/home',
-      component: () => import('@/layout/index.vue'),
+      component: Layout,
       children: [
         {
-          path: '/home',
-          name: 'home',
-          meta: { title: '系统首页' },
+          path: '/',
+          name: 'dashboard',
+          meta: {
+            title: '首页',
+            affix: true,
+            keepAlive: true,
+            alwaysShow: false
+          },
           component: () => import('@/views/home/index.vue')
         }
       ]

+ 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 }

+ 1 - 2
src/store/modules/app.js

@@ -1,5 +1,5 @@
 import defaultSettings from '@/settings'
-// import { useStorage } from '@vueuse/core'
+import { useStorage } from '@vueuse/core'
 
 // 导入 Element Plus 中英文语言包
 import zhCn from 'element-plus/es/locale/lang/zh-cn'
@@ -11,7 +11,6 @@ export const useAppStore = defineStore('app', () => {
   const device = useStorage('device', 'desktop')
   const size = useStorage('size', defaultSettings.size)
   const language = useStorage('language', defaultSettings.language)
-
   const sidebarStatus = useStorage('sidebarStatus', 'closed')
 
   const sidebar = reactive({

+ 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') {

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

@@ -0,0 +1,206 @@
+export const useTagsViewStore = defineStore('tagsView', () => {
+  const visitedViews = ref([{ affix: true, fullPath: '/', keepAlive: true, name: 'dashboard', path: '/', title: 'dashboard' }])
+  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><router-link to="/system/menus">menus</router-link></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>

+ 19 - 7
src/views/login/index.vue

@@ -1,14 +1,18 @@
 <template>
   <div class="login-container">
+    <!-- 顶部 -->
+    <div class="lang">
+      <LangSelect class="cursor-pointer"></LangSelect>
+    </div>
     <!-- 登录表单 -->
     <el-card class="card">
       <div class="text">
-        <h2>{{ siteInfo.zhTitle }}</h2>
+        <h2>{{ $t('login.title') }}</h2>
       </div>
       <el-form ref="loginFormRef" :model="loginData" :rules="loginRules" class="login-form">
         <!-- 用户名 -->
         <el-form-item prop="username">
-          <el-input v-model="loginData.account" size="large" placeholder="请输入登录账号">
+          <el-input v-model="loginData.account" size="large" :placeholder="$t('login.placeholder1')">
             <template #prefix>
               <el-icon>
                 <User />
@@ -18,7 +22,7 @@
         </el-form-item>
         <!-- 密码 -->
         <el-form-item prop="password">
-          <el-input v-model="loginData.password" size="large" type="password" show-password placeholder="请输入登录密码">
+          <el-input v-model="loginData.password" size="large" type="password" show-password :placeholder="$t('login.placeholder2')">
             <template #prefix>
               <el-icon>
                 <Unlock />
@@ -27,15 +31,13 @@
           </el-input>
         </el-form-item>
         <!-- 登录按钮 -->
-        <el-button :loading="loading" type="primary" size="large" class="button" @click.prevent="handleLogin">{{
-          $t('login.login') }} </el-button>
+        <el-button :loading="loading" type="primary" size="large" class="button" @click.prevent="handleLogin">{{ $t('login.login') }} </el-button>
       </el-form>
     </el-card>
   </div>
 </template>
 
 <script setup>
-import { siteInfo } from '@/layout/site'
 // 接口
 import { LoginStore } from '@/store/api/login'
 const router = useRouter()
@@ -47,7 +49,7 @@ const loginData = ref({
   password: '1qaz2wsx',
   type: 'Admin'
 })
-const loginRules = computed(() => { })
+const loginRules = computed(() => {})
 const toLogin = async (data) => {
   const res = await loginStore.login(data)
   if (res.errcode == '0') {
@@ -80,6 +82,16 @@ function handleLogin() {
   align-items: center;
   justify-content: center;
   background: url('@/assets/images/login-bg.jpg') no-repeat center right;
+  .lang {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    position: absolute;
+    top: 0;
+    right: 0;
+    height: 5rem;
+    padding: 0 1.25rem;
+  }
 
   .login-form {
     padding: 30px 10px;

+ 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/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>

+ 15 - 4
src/views/system/role/index.vue

@@ -1,9 +1,20 @@
 <template>
   <div id="index">
-    <p>index</p>
+    <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>

+ 7 - 0
vite.config.js

@@ -3,6 +3,7 @@ import Components from 'unplugin-vue-components/vite'
 import Icons from 'unplugin-icons/vite'
 import IconsResolver from 'unplugin-icons/resolver'
 import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
 import { defineConfig, loadEnv } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import Inspect from 'vite-plugin-inspect'
@@ -80,6 +81,12 @@ export default defineConfig(({ mode }) => {
       Icons({
         autoInstall: true
       }),
+      createSvgIconsPlugin({
+        // 指定需要缓存的图标文件夹
+        iconDirs: [path.resolve(pathSrc, 'assets/icons')],
+        // 指定symbolId格式
+        symbolId: 'icon-[dir]-[name]'
+      }),
       Inspect()
     ],
     // 预加载项目必需的组件