lrf 5 months ago
commit
4b12024bb8
96 changed files with 32064 additions and 0 deletions
  1. 10 0
      .env.development
  2. 10 0
      .env.production
  3. 288 0
      .eslintrc-auto-import.json
  4. 33 0
      .eslintrc.cjs
  5. 30 0
      .gitignore
  6. 8 0
      .prettierrc.json
  7. 35 0
      README.md
  8. 27 0
      index.html
  9. 10 0
      jsconfig.json
  10. 11131 0
      package-lock.json
  11. 47 0
      package.json
  12. 4069 0
      pnpm-lock.yaml
  13. BIN
      public/favicon.ico
  14. 21 0
      src/App.vue
  15. 1 0
      src/assets/icons/close.svg
  16. 1 0
      src/assets/icons/close_all.svg
  17. 1 0
      src/assets/icons/close_left.svg
  18. 1 0
      src/assets/icons/close_other.svg
  19. 1 0
      src/assets/icons/close_right.svg
  20. 1 0
      src/assets/icons/language.svg
  21. 1 0
      src/assets/icons/refresh.svg
  22. BIN
      src/assets/images/401.gif
  23. BIN
      src/assets/images/404.png
  24. BIN
      src/assets/images/404_cloud.png
  25. BIN
      src/assets/images/login-bg.jpg
  26. BIN
      src/assets/logo.png
  27. 1 0
      src/assets/logo.svg
  28. 93 0
      src/components/Breadcrumb/index.vue
  29. 32 0
      src/components/LangSelect/index.vue
  30. 43 0
      src/components/SvgIcon/index.vue
  31. 63 0
      src/components/WangEditor/index.vue
  32. 40 0
      src/components/custom/custom-button-bar.vue
  33. 193 0
      src/components/custom/custom-form.vue
  34. 150 0
      src/components/custom/custom-search-bar.vue
  35. 121 0
      src/components/custom/custom-table.vue
  36. 7 0
      src/components/index.js
  37. 32 0
      src/lang/index.js
  38. 8 0
      src/lang/package/en.js
  39. 23 0
      src/lang/package/en/common.js
  40. 8 0
      src/lang/package/zh-cn.js
  41. 28 0
      src/lang/package/zh-cn/common.js
  42. 10 0
      src/lang/package/zh-cn/login.js
  43. 6 0
      src/lang/package/zh-cn/navbar.js
  44. 59 0
      src/layout/index.vue
  45. 77 0
      src/layout/parts/Header.vue
  46. 103 0
      src/layout/parts/Sidebar.vue
  47. 387 0
      src/layout/parts/Tagsbar.vue
  48. 42 0
      src/layout/parts/sidebar/items.vue
  49. 177 0
      src/layout/site.js
  50. 29 0
      src/main.js
  51. 100 0
      src/router/guard.js
  52. 80 0
      src/router/index.js
  53. 47 0
      src/router/modules/system.js
  54. 36 0
      src/router/modules/user.js
  55. 16 0
      src/settings.js
  56. 25 0
      src/store/api/login.js
  57. 40 0
      src/store/api/platform/demand.js
  58. 40 0
      src/store/api/platform/match.js
  59. 40 0
      src/store/api/platform/news.js
  60. 40 0
      src/store/api/system/dictData.js
  61. 40 0
      src/store/api/system/dictType.js
  62. 40 0
      src/store/api/system/menus.js
  63. 40 0
      src/store/api/system/role.js
  64. 40 0
      src/store/api/user/admin.js
  65. 12 0
      src/store/index.js
  66. 97 0
      src/store/modules/app.js
  67. 206 0
      src/store/modules/tagsView.js
  68. 23 0
      src/store/user.js
  69. 140 0
      src/utils/axios-wrapper.js
  70. 24 0
      src/utils/checkResult.js
  71. 11536 0
      src/utils/city.js
  72. 29 0
      src/utils/directives.js
  73. 26 0
      src/utils/file.js
  74. 35 0
      src/utils/util-methods.js
  75. 11 0
      src/utils/variable.js
  76. 20 0
      src/views/account/index.vue
  77. 105 0
      src/views/error-page/401.vue
  78. 256 0
      src/views/error-page/404.vue
  79. 20 0
      src/views/home/index.vue
  80. 146 0
      src/views/login/index.vue
  81. 14 0
      src/views/redirect/index.vue
  82. 20 0
      src/views/system/config/index.vue
  83. 138 0
      src/views/system/dict/index.vue
  84. 118 0
      src/views/system/dictData/index.vue
  85. 109 0
      src/views/system/menus/index.vue
  86. 27 0
      src/views/system/menus/parts/menu-info.vue
  87. 69 0
      src/views/system/menus/parts/menu-table.vue
  88. 38 0
      src/views/system/menus/parts/parts/func.vue
  89. 98 0
      src/views/system/menus/parts/parts/info.vue
  90. 132 0
      src/views/system/role/index.vue
  91. 105 0
      src/views/system/role/parts/form.vue
  92. 50 0
      src/views/system/role/parts/table.vue
  93. 9 0
      src/views/test/index.vue
  94. 183 0
      src/views/user/admin/index.vue
  95. 20 0
      src/views/user/user/index.vue
  96. 166 0
      vite.config.js

+ 10 - 0
.env.development

@@ -0,0 +1,10 @@
+## 开发环境
+NODE_ENV='development'
+
+# 应用端口
+VITE_APP_PORT = 3000
+
+# 代理前缀
+VITE_APP_BASE_API = '/ts/frame/api'
+
+VITE_APP_HOST = "http://192.168.1.197"

+ 10 - 0
.env.production

@@ -0,0 +1,10 @@
+## 生产环境
+NODE_ENV='production'
+
+# 应用端口
+VITE_APP_PORT = 3000
+
+# 代理前缀
+VITE_APP_BASE_API = '/prod-api'
+
+VITE_APP_HOST = "https://broadcast.waityou24.cn"

+ 288 - 0
.eslintrc-auto-import.json

@@ -0,0 +1,288 @@
+{
+  "globals": {
+    "Component": true,
+    "ComponentPublicInstance": true,
+    "ComputedRef": true,
+    "EffectScope": true,
+    "ElMessage": true,
+    "ElMessageBox": true,
+    "ElNotification": true,
+    "InjectionKey": true,
+    "PropType": true,
+    "Ref": true,
+    "VNode": true,
+    "asyncComputed": true,
+    "autoResetRef": true,
+    "computed": true,
+    "computedAsync": true,
+    "computedEager": true,
+    "computedInject": true,
+    "computedWithControl": true,
+    "controlledComputed": true,
+    "controlledRef": true,
+    "createApp": true,
+    "createEventHook": true,
+    "createGlobalState": true,
+    "createInjectionState": true,
+    "createReactiveFn": true,
+    "createReusableTemplate": true,
+    "createSharedComposable": true,
+    "createTemplatePromise": true,
+    "createUnrefFn": true,
+    "customRef": true,
+    "debouncedRef": true,
+    "debouncedWatch": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "eagerComputed": true,
+    "effectScope": true,
+    "extendRef": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "h": true,
+    "ignorableWatch": true,
+    "inject": true,
+    "isDefined": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "makeDestructurable": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onClickOutside": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onKeyStroke": true,
+    "onLongPress": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onStartTyping": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "pausableWatch": true,
+    "provide": true,
+    "reactify": true,
+    "reactifyObject": true,
+    "reactive": true,
+    "reactiveComputed": true,
+    "reactiveOmit": true,
+    "reactivePick": true,
+    "readonly": true,
+    "ref": true,
+    "refAutoReset": true,
+    "refDebounced": true,
+    "refDefault": true,
+    "refThrottled": true,
+    "refWithControl": true,
+    "resolveComponent": true,
+    "resolveRef": true,
+    "resolveUnref": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "syncRef": true,
+    "syncRefs": true,
+    "templateRef": true,
+    "throttledRef": true,
+    "throttledWatch": true,
+    "toRaw": true,
+    "toReactive": true,
+    "toRef": true,
+    "toRefs": true,
+    "toValue": true,
+    "triggerRef": true,
+    "tryOnBeforeMount": true,
+    "tryOnBeforeUnmount": true,
+    "tryOnMounted": true,
+    "tryOnScopeDispose": true,
+    "tryOnUnmounted": true,
+    "unref": true,
+    "unrefElement": true,
+    "until": true,
+    "useActiveElement": true,
+    "useAnimate": true,
+    "useArrayDifference": true,
+    "useArrayEvery": true,
+    "useArrayFilter": true,
+    "useArrayFind": true,
+    "useArrayFindIndex": true,
+    "useArrayFindLast": true,
+    "useArrayIncludes": true,
+    "useArrayJoin": true,
+    "useArrayMap": true,
+    "useArrayReduce": true,
+    "useArraySome": true,
+    "useArrayUnique": true,
+    "useAsyncQueue": true,
+    "useAsyncState": true,
+    "useAttrs": true,
+    "useBase64": true,
+    "useBattery": true,
+    "useBluetooth": true,
+    "useBreakpoints": true,
+    "useBroadcastChannel": true,
+    "useBrowserLocation": true,
+    "useCached": true,
+    "useClipboard": true,
+    "useCloned": true,
+    "useColorMode": true,
+    "useConfirmDialog": true,
+    "useCounter": true,
+    "useCssModule": true,
+    "useCssVar": true,
+    "useCssVars": true,
+    "useCurrentElement": true,
+    "useCycleList": true,
+    "useDark": true,
+    "useDateFormat": true,
+    "useDebounce": true,
+    "useDebounceFn": true,
+    "useDebouncedRefHistory": true,
+    "useDeviceMotion": true,
+    "useDeviceOrientation": true,
+    "useDevicePixelRatio": true,
+    "useDevicesList": true,
+    "useDisplayMedia": true,
+    "useDocumentVisibility": true,
+    "useDraggable": true,
+    "useDropZone": true,
+    "useElementBounding": true,
+    "useElementByPoint": true,
+    "useElementHover": true,
+    "useElementSize": true,
+    "useElementVisibility": true,
+    "useEventBus": true,
+    "useEventListener": true,
+    "useEventSource": true,
+    "useEyeDropper": true,
+    "useFavicon": true,
+    "useFetch": true,
+    "useFileDialog": true,
+    "useFileSystemAccess": true,
+    "useFocus": true,
+    "useFocusWithin": true,
+    "useFps": true,
+    "useFullscreen": true,
+    "useGamepad": true,
+    "useGeolocation": true,
+    "useIdle": true,
+    "useImage": true,
+    "useInfiniteScroll": true,
+    "useIntersectionObserver": true,
+    "useInterval": true,
+    "useIntervalFn": true,
+    "useKeyModifier": true,
+    "useLastChanged": true,
+    "useLocalStorage": true,
+    "useMagicKeys": true,
+    "useManualRefHistory": true,
+    "useMediaControls": true,
+    "useMediaQuery": true,
+    "useMemoize": true,
+    "useMemory": true,
+    "useMounted": true,
+    "useMouse": true,
+    "useMouseInElement": true,
+    "useMousePressed": true,
+    "useMutationObserver": true,
+    "useNavigatorLanguage": true,
+    "useNetwork": true,
+    "useNow": true,
+    "useObjectUrl": true,
+    "useOffsetPagination": true,
+    "useOnline": true,
+    "usePageLeave": true,
+    "useParallax": true,
+    "useParentElement": true,
+    "usePerformanceObserver": true,
+    "usePermission": true,
+    "usePointer": true,
+    "usePointerLock": true,
+    "usePointerSwipe": true,
+    "usePreferredColorScheme": true,
+    "usePreferredContrast": true,
+    "usePreferredDark": true,
+    "usePreferredLanguages": true,
+    "usePreferredReducedMotion": true,
+    "usePrevious": true,
+    "useRafFn": true,
+    "useRefHistory": true,
+    "useResizeObserver": true,
+    "useScreenOrientation": true,
+    "useScreenSafeArea": true,
+    "useScriptTag": true,
+    "useScroll": true,
+    "useScrollLock": true,
+    "useSessionStorage": true,
+    "useShare": true,
+    "useSlots": true,
+    "useSorted": true,
+    "useSpeechRecognition": true,
+    "useSpeechSynthesis": true,
+    "useStepper": true,
+    "useStorage": true,
+    "useStorageAsync": true,
+    "useStyleTag": true,
+    "useSupported": true,
+    "useSwipe": true,
+    "useTemplateRefsList": true,
+    "useTextDirection": true,
+    "useTextSelection": true,
+    "useTextareaAutosize": true,
+    "useThrottle": true,
+    "useThrottleFn": true,
+    "useThrottledRefHistory": true,
+    "useTimeAgo": true,
+    "useTimeout": true,
+    "useTimeoutFn": true,
+    "useTimeoutPoll": true,
+    "useTimestamp": true,
+    "useTitle": true,
+    "useToNumber": true,
+    "useToString": true,
+    "useToggle": true,
+    "useTransition": true,
+    "useUrlSearchParams": true,
+    "useUserMedia": true,
+    "useVModel": true,
+    "useVModels": true,
+    "useVibrate": true,
+    "useVirtualList": true,
+    "useWakeLock": true,
+    "useWebNotification": true,
+    "useWebSocket": true,
+    "useWebWorker": true,
+    "useWebWorkerFn": true,
+    "useWindowFocus": true,
+    "useWindowScroll": true,
+    "useWindowSize": true,
+    "watch": true,
+    "watchArray": true,
+    "watchAtMost": true,
+    "watchDebounced": true,
+    "watchDeep": true,
+    "watchEffect": true,
+    "watchIgnorable": true,
+    "watchImmediate": true,
+    "watchOnce": true,
+    "watchPausable": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true,
+    "watchThrottled": true,
+    "watchTriggerable": true,
+    "watchWithFilter": true,
+    "whenever": true,
+    "defineStore": true,
+    "useRoute": true,
+    "useRouter": true,
+    "useI18n": true 
+  }
+}

+ 33 - 0
.eslintrc.cjs

@@ -0,0 +1,33 @@
+/* eslint-env node */
+require('@rushstack/eslint-patch/modern-module-resolution')
+
+module.exports = {
+  root: true,
+  extends: [
+    'plugin:vue/vue3-essential',
+    'eslint:recommended',
+    '@vue/eslint-config-prettier/skip-formatting',
+    './.eslintrc-auto-import.json'
+  ],
+  parserOptions: {
+    ecmaVersion: 'latest'
+  },
+  rules: {
+    'vue/multi-word-component-names': 0,
+    'max-len': [
+      'warn',
+      {
+        code: 200
+      }
+    ],
+    'prettier/prettier': [
+      'warn',
+      {
+        singleQuote: true,
+        bracketSpacing: true,
+        jsxBracketSameLine: true,
+        printWidth: 200
+      }
+    ]
+  }
+}

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo

+ 8 - 0
.prettierrc.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/prettierrc",
+  "semi": false,
+  "tabWidth": 2,
+  "singleQuote": true,
+  "printWidth": 100,
+  "trailingComma": "none"
+}

+ 35 - 0
README.md

@@ -0,0 +1,35 @@
+# web-template-vue3-js
+
+This template should help get you started developing with Vue 3 in Vite.
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vitejs.dev/config/).
+
+## Project Setup
+
+```sh
+pnpm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+pnpm dev
+```
+
+### Compile and Minify for Production
+
+```sh
+pnpm build
+```
+
+### Lint with [ESLint](https://eslint.org/)
+
+```sh
+pnpm lint
+```

+ 27 - 0
index.html

@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>项目名</title>
+  </head>
+  <body>
+    <div id="app" class="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>
+<style>
+  html,
+  body,
+  #app {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+  }
+</style>
+</body>
+</html>

+ 10 - 0
jsconfig.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "exclude": ["node_modules", "dist"],
+  "include": ["src/**/*.vue","**/*.js"]
+}

File diff suppressed because it is too large
+ 11131 - 0
package-lock.json


+ 47 - 0
package.json

@@ -0,0 +1,47 @@
+{
+  "name": "web-template-vue3-js",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite --mode development",
+    "build": "vite build --mode production",
+    "preview": "vite preview",
+    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
+    "format": "prettier --write src/"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.3.1",
+    "@vueuse/core": "^10.7.2",
+    "@vueuse/integrations": "^10.9.0",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/editor-for-vue": "5.1.10",
+    "axios": "^1.6.7",
+    "dayjs": "^1.11.13",
+    "element-plus": "^2.5.6",
+    "lodash-es": "^4.17.21",
+    "nprogress": "^0.2.0",
+    "path-browserify": "^1.0.1",
+    "path-to-regexp": "^6.2.1",
+    "pinia": "^2.1.7",
+    "universal-cookie": "^7.1.0",
+    "vue": "^3.4.15",
+    "vue-i18n": "^9.9.1",
+    "vue-router": "^4.2.5"
+  },
+  "devDependencies": {
+    "@rushstack/eslint-patch": "^1.3.3",
+    "@vitejs/plugin-vue": "^5.0.3",
+    "@vue/eslint-config-prettier": "^8.0.0",
+    "eslint": "^8.49.0",
+    "eslint-plugin-vue": "^9.17.0",
+    "prettier": "^3.0.3",
+    "sass": "^1.71.0",
+    "unplugin-auto-import": "^0.17.5",
+    "unplugin-icons": "^0.18.5",
+    "unplugin-vue-components": "^0.26.0",
+    "vite": "^5.0.11",
+    "vite-plugin-inspect": "^0.8.3",
+    "vite-plugin-svg-icons": "^2.0.1"
+  }
+}

File diff suppressed because it is too large
+ 4069 - 0
pnpm-lock.yaml


BIN
public/favicon.ico


+ 21 - 0
src/App.vue

@@ -0,0 +1,21 @@
+<script setup>
+import { useAppStore } from '@/store/modules/app'
+
+const appStore = useAppStore()
+</script>
+
+<template>
+  <el-config-provider :locale="appStore.locale" :size="appStore.size">
+    <router-view />
+  </el-config-provider>
+</template>
+
+<style lang="scss">
+body {
+  margin: 0;
+}
+.w_1200 {
+  width: 1200px;
+  margin: 0 auto;
+}
+</style>

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

File diff suppressed because it is too large
+ 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>

File diff suppressed because it is too large
+ 1 - 0
src/assets/icons/close_right.svg


File diff suppressed because it is too large
+ 1 - 0
src/assets/icons/language.svg


File diff suppressed because it is too large
+ 1 - 0
src/assets/icons/refresh.svg


BIN
src/assets/images/401.gif


BIN
src/assets/images/404.png


BIN
src/assets/images/404_cloud.png


BIN
src/assets/images/login-bg.jpg


BIN
src/assets/logo.png


+ 1 - 0
src/assets/logo.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

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

@@ -0,0 +1,93 @@
+<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)]">
+          {{ get(item, 'meta.title') }}
+        </span>
+        <a v-else @click.prevent="handleLink(item)">
+          {{ get(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 { get } from 'lodash-es'
+
+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: '首页' } }].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() === 'home'.toLocaleLowerCase()
+}
+
+function handleLink(item) {
+  const { redirect, path, meta } = item
+  if (meta.type == '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-us') {
+    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-us'" command="en-us"> 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>

+ 63 - 0
src/components/WangEditor/index.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="editor-wrapper">
+    <!-- 工具栏 -->
+    <Toolbar id="toolbar-container" :editor="editorRef" :default-config="toolbarConfig" :mode="mode" />
+    <!-- 编辑器 -->
+    <Editor style="height: 350px" id="editor-container" v-model="modelValue" :default-config="editorConfig" :mode="mode" @on-change="handleChange" @on-created="handleCreated" />
+  </div>
+</template>
+
+<script setup>
+import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+
+// API 引用
+import { uploadFileApi } from '@/utils/file'
+
+const props = defineProps({
+  modelValue: {
+    type: [String],
+    default: ''
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const modelValue = useVModel(props, 'modelValue', emit)
+
+const editorRef = shallowRef() // 编辑器实例,必须用 shallowRef
+const mode = ref('default') // 编辑器模式
+const toolbarConfig = ref({}) // 工具条配置
+// 编辑器配置
+const editorConfig = ref({
+  placeholder: '请输入内容...',
+  MENU_CONF: {
+    uploadImage: {
+      // 自定义图片上传
+      async customUpload(file, insertFn) {
+        uploadFileApi(file).then((response) => {
+          const { errcode, uri } = response.data
+          const url = `${import.meta.env.VITE_APP_HOST}${uri}`
+          if (errcode == 0) insertFn(url)
+        })
+      }
+    }
+  }
+})
+
+const handleCreated = (editor) => {
+  editorRef.value = editor // 记录 editor 实例,重要!
+}
+
+function handleChange(editor) {
+  modelValue.value = editor.getHtml()
+}
+
+// 组件销毁时,也及时销毁编辑器
+onBeforeUnmount(() => {
+  const editor = editorRef.value
+  if (editor == null) return
+  editor.destroy()
+})
+</script>
+
+<style src="@wangeditor/editor/dist/css/style.css"></style>

+ 40 - 0
src/components/custom/custom-button-bar.vue

@@ -0,0 +1,40 @@
+<template>
+  <el-row style="padding: 5px">
+    <el-col :span="24" style="text-align: right">
+      <template v-for="b in fields" :key="b.method">
+        <el-button v-method="b.method" :type="getType(b)" @click="toClick(b)" :disabled="getDisabled(b)">{{ b.label }}</el-button>
+      </template>
+    </el-col>
+  </el-row>
+</template>
+
+<script setup>
+import { get, isFunction } from 'lodash-es'
+const { t } = useI18n()
+const props = defineProps({
+  fields: { type: Array, default: () => [] }
+})
+const emits = defineEmits([])
+const getType = (field) => {
+  return get(field, 'type', 'primary')
+}
+const toClick = (field) => {
+  const method = get(field, 'method')
+  if (!method) {
+    ElMessage({ type: 'error', message: t('common.no_method') })
+    return
+  }
+  emits(method)
+}
+const getDisabled = (field) => {
+  const disabled = get(field, 'disabled')
+  if (!disabled) return false
+  if (isFunction(disabled)) return disabled(field)
+  return disabled
+}
+</script>
+<style scoped>
+.el-button {
+  margin-top: 5px;
+}
+</style>

+ 193 - 0
src/components/custom/custom-form.vue

@@ -0,0 +1,193 @@
+<template>
+  <div id="custom-form">
+    <el-form ref="formRef" :model="form" :rules="rules" :label-width="labelWidth" class="form" @submit.prevent :disabled="disabled">
+      <el-col :span="span" v-for="(item, index) in fields" :key="index">
+        <el-form-item v-if="display(item)" :key="`form-field-${item.model}`" :label="getField('label', item)" :prop="item.model" :required="item.required">
+          <template v-if="item.custom">
+            <slot :name="item.model" v-bind="{ item }"></slot>
+          </template>
+          <template v-else>
+            <template v-if="item.type === 'textarea'">
+              <el-input
+                clearable
+                v-model="form[item.model]"
+                :type="item.type"
+                :placeholder="getField('placeholder', item)"
+                v-bind="item.options"
+                @change="dataChange(item.model)"
+                show-word-limit
+              ></el-input>
+            </template>
+            <template v-else-if="item.type === 'numbers'">
+              <el-input-number v-model="form[item.model]" :placeholder="getField('placeholder', item)" @change="dataChange(item.model)" style="width: 100%" />
+            </template>
+            <template v-else-if="item.type === 'radio'">
+              <el-radio-group v-model="form[item.model]" :type="item.type" v-bind="item.options" @change="dataChange(item.model)">
+                <slot :name="item.model" v-bind="{ item }"></slot>
+              </el-radio-group>
+            </template>
+            <template v-else-if="item.type === 'checkbox'">
+              <el-checkbox-group v-model="form[item.model]" :type="item.type" v-bind="item.options">
+                <slot :name="item.model" v-bind="{ item }"></slot>
+              </el-checkbox-group>
+            </template>
+            <template v-else-if="item.type === 'select'">
+              <el-select
+                clearable
+                filterable
+                allow-create
+                default-first-option
+                v-model="form[item.model]"
+                :type="item.type"
+                :placeholder="getField('selectplaceholder', item)"
+                v-bind="item.options"
+                @change="dataChange(item.model)"
+                style="width: 100%"
+              >
+                <slot :name="item.model" v-bind="{ item }"></slot>
+              </el-select>
+            </template>
+            <template v-else-if="item.type === 'selectMany'">
+              <el-select
+                filterable
+                clearable
+                multiple
+                collapse-tags
+                v-model="form[item.model]"
+                :type="item.type"
+                :placeholder="getField('selectplaceholder', item)"
+                v-bind="item.options"
+                @change="dataChange(item.model)"
+                style="width: 100%"
+              >
+                <slot :name="item.model" v-bind="{ item }"></slot>
+              </el-select>
+            </template>
+            <template v-else-if="item.type === `year` || item.type == 'month' || item.type == 'date' || item.type == 'daterange' || item.type == 'datetime' || item.type == 'datetimerange'">
+              <el-date-picker
+                v-model="form[item.model]"
+                :type="item.type"
+                :placeholder="getField('selectplaceholder', item)"
+                :format="getDateFormat(item.type)"
+                :value-format="getDateFormat(item.type)"
+                v-bind="item.options"
+                @change="dataChange(item.model)"
+                range-separator="至"
+                style="width: 100%"
+              >
+              </el-date-picker>
+            </template>
+            <template v-else-if="item.type === `time`">
+              <el-time-picker
+                v-model="form[item.model]"
+                :placeholder="getField('selectplaceholder', item)"
+                :format="getDateFormat(item.type)"
+                :value-format="getDateFormat(item.type)"
+                v-bind="item.options"
+                @change="dataChange(item.model)"
+                style="width: 100%"
+              >
+              </el-time-picker>
+            </template>
+            <template v-else-if="item.type === `inputnumber`">
+              <el-input-number v-model="form[item.model]" :placeholder="getField('placeholder', item)" v-bind="item.options" @change="dataChange(item.model)" style="width: 100%"></el-input-number>
+            </template>
+            <template v-else>
+              <el-input
+                clearable
+                v-model="form[item.model]"
+                :type="getField('type', item)"
+                :placeholder="getField('placeholder', item)"
+                :show-password="getField('type', item) === 'password'"
+                v-bind="item.options"
+                @change="dataChange(item.model)"
+              ></el-input>
+            </template>
+          </template>
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" label="" class="btn" v-if="useSave">
+        <slot name="submit">
+          <el-button type="primary" @click="save(formRef)">{{ submitText || submitTextDefault }}</el-button>
+        </slot>
+      </el-col>
+    </el-form>
+  </div>
+</template>
+
+<script setup>
+import { get, isFunction } from 'lodash-es'
+const { t } = useI18n()
+const submitTextDefault = t('common.save')
+const props = defineProps({
+  modelValue: { type: Object },
+  rules: { type: Array, default: () => {} },
+  labelWidth: { type: String, default: 'auto' },
+  disabled: { type: Boolean, default: false },
+  fields: { type: Array, default: () => [] },
+  submitText: { type: String },
+  useSave: { type: Boolean, default: true },
+  span: { type: Number, default: 24 } // 限制两侧的距离,24就是整行全用
+})
+const emits = defineEmits(['update:modelValue', 'dataChange', 'save'])
+const formRef = ref()
+const form = computed({
+  get() {
+    return props.modelValue
+  },
+  set(value) {
+    console.log(value)
+    emits('update:modelValue', value)
+  }
+})
+const save = async (formEl) => {
+  if (!formEl) return
+  await formEl.validate((valid, fields) => {
+    if (valid) {
+      emits('save', form.value)
+    } else {
+      console.log('error submit!', fields)
+    }
+  })
+}
+const getField = (item, data) => {
+  let res = get(data, item, null)
+  if (item === 'type') res = res === null ? `text` : res
+  if (item === 'placeholder') res = res === null ? `请输入${data.label}` : res
+  if (item === `selectplaceholder`) res = res === null ? `请选择${data.label}` : res
+  if (item === 'required') res = res === null ? false : res
+  if (item === `error`) res = res === null ? `${data.label}错误` : res
+  return res
+}
+const dataChange = (model) => {
+  const value = form.value[model]
+  emits('dataChange', { model, value })
+}
+const display = (field) => {
+  let dis = get(field, `display`)
+  if (!isFunction(dis)) return true
+  else {
+    return dis(field, form)
+  }
+}
+const getDateFormat = (e) => {
+  if (e === 'year') return 'YYYY'
+  if (e === 'month') return 'MM'
+  if (e === 'date') return 'YYYY-MM-DD'
+  if (e === 'daterange') return 'YYYY-MM-DD'
+  if (e === 'datetime') return 'YYYY-MM-DD HH:mm:ss'
+  if (e === 'datetimerange') return 'YYYY-MM-DD HH:mm:ss'
+  if (e === 'time') return 'HH:mm:ss'
+}
+</script>
+<style scoped>
+.btn {
+  text-align: center;
+}
+
+.form {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+}
+</style>

+ 150 - 0
src/components/custom/custom-search-bar.vue

@@ -0,0 +1,150 @@
+<template>
+  <div id="custom-search-bar">
+    <el-form ref="formRef" :model="form" :label-width="labelWidth" class="form" @submit.prevent :inline="true">
+      <el-form-item v-for="item in fields" :key="`form-field-${item.model}`" :label="getField('label', item)" :prop="item.model" :required="item.required">
+        <template v-if="item.custom">
+          <slot :name="item.model" v-bind="{ item }"></slot>
+        </template>
+        <template v-else>
+          <template v-if="item.type === 'numbers'">
+            <el-input-number v-model="form[item.model]" :placeholder="getField('placeholder', item)" @change="dataChange(item.model)" style="width: 100%" />
+          </template>
+          <template v-else-if="item.type === 'radio'">
+            <el-radio-group v-model="form[item.model]" :type="item.type" v-bind="item.options" @change="dataChange(item.model)">
+              <slot :name="item.model" v-bind="{ item }"></slot>
+            </el-radio-group>
+          </template>
+          <template v-else-if="item.type === 'checkbox'">
+            <el-checkbox-group v-model="form[item.model]" :type="item.type" v-bind="item.options">
+              <slot :name="item.model" v-bind="{ item }"></slot>
+            </el-checkbox-group>
+          </template>
+          <template v-else-if="item.type === 'select'">
+            <el-select
+              clearable
+              filterable
+              allow-create
+              default-first-option
+              v-model="form[item.model]"
+              :type="item.type"
+              :placeholder="getField('selectplaceholder', item)"
+              v-bind="item.options"
+              @change="dataChange(item.model)"
+              style="width: 100%; min-width: 200px"
+            >
+              <slot :name="item.model" v-bind="{ item }"></slot>
+            </el-select>
+          </template>
+          <template v-else-if="item.type === 'selectMany'">
+            <el-select
+              filterable
+              clearable
+              multiple
+              collapse-tags
+              v-model="form[item.model]"
+              :type="item.type"
+              :placeholder="getField('selectplaceholder', item)"
+              v-bind="item.options"
+              @change="dataChange(item.model)"
+              style="width: 100%"
+            >
+              <slot :name="item.model" v-bind="{ item }"></slot>
+            </el-select>
+          </template>
+          <template v-else-if="item.type === `year` || item.type == 'month' || item.type == 'date' || item.type == 'daterange' || item.type == 'datetime' || item.type == 'datetimerange'">
+            <el-date-picker
+              v-model="form[item.model]"
+              :type="item.type"
+              :placeholder="getField('selectplaceholder', item)"
+              :format="getDateFormat(item.type)"
+              :value-format="getDateFormat(item.type)"
+              v-bind="item.options"
+              @change="dataChange(item.model)"
+              range-separator="至"
+              style="width: 100%"
+            >
+            </el-date-picker>
+          </template>
+          <template v-else-if="item.type === `time`">
+            <el-time-picker
+              v-model="form[item.model]"
+              :placeholder="getField('selectplaceholder', item)"
+              :format="getDateFormat(item.type)"
+              :value-format="getDateFormat(item.type)"
+              v-bind="item.options"
+              @change="dataChange(item.model)"
+              style="width: 100%"
+            >
+            </el-time-picker>
+          </template>
+          <template v-else-if="item.type === `inputnumber`">
+            <el-input-number v-model="form[item.model]" :placeholder="getField('placeholder', item)" v-bind="item.options" @change="dataChange(item.model)" style="width: 100%"></el-input-number>
+          </template>
+          <template v-else>
+            <el-input
+              clearable
+              v-model="form[item.model]"
+              :type="getField('type', item)"
+              :placeholder="getField('placeholder', item)"
+              :show-password="getField('type', item) === 'password'"
+              v-bind="item.options"
+              @change="dataChange(item.model)"
+            ></el-input>
+          </template>
+        </template>
+      </el-form-item>
+      <el-form-item v-if="fields.length > 0">
+        <el-button type="primary" @click="toClick">{{ $t('common.search') }}</el-button>
+        <el-button type="default" @click="toReset">{{ $t('common.reset') }}</el-button>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script setup>
+import { get } from 'lodash-es'
+const props = defineProps({
+  modelValue: { type: Object },
+  fields: { type: Array, default: () => [] }
+})
+const emits = defineEmits(['update:modelValue', 'dataChange', 'search'])
+const formRef = ref()
+const form = computed({
+  get() {
+    return props.modelValue
+  },
+  set(value) {
+    console.log(value)
+    emits('update:modelValue', value)
+  }
+})
+const toClick = () => {
+  emits('search')
+}
+const toReset = () => {
+  emits('reset')
+}
+const getField = (item, data) => {
+  let res = get(data, item, null)
+  if (item === 'type') res = res === null ? `text` : res
+  if (item === 'placeholder') res = res === null ? `请输入${data.label}` : res
+  if (item === `selectplaceholder`) res = res === null ? `请选择${data.label}` : res
+  if (item === 'required') res = res === null ? false : res
+  if (item === `error`) res = res === null ? `${data.label}错误` : res
+  return res
+}
+const dataChange = (model) => {
+  const value = form.value[model]
+  emits('dataChange', { model, value })
+}
+const getDateFormat = (e) => {
+  if (e === 'year') return 'YYYY'
+  if (e === 'month') return 'MM'
+  if (e === 'date') return 'YYYY-MM-DD'
+  if (e === 'daterange') return 'YYYY-MM-DD'
+  if (e === 'datetime') return 'YYYY-MM-DD HH:mm:ss'
+  if (e === 'datetimerange') return 'YYYY-MM-DD HH:mm:ss'
+  if (e === 'time') return 'HH:mm:ss'
+}
+</script>
+<style scoped></style>

+ 121 - 0
src/components/custom/custom-table.vue

@@ -0,0 +1,121 @@
+<template>
+  <el-row>
+    <el-col>
+      <el-table :data="data" border :height="height" @selection-change="toSelect">
+        <el-table-column type="selection" width="55" v-if="select"> </el-table-column>
+        <template v-for="f in fields" :key="f.model">
+          <el-table-column v-if="f.custom" :label="f.label" :prop="f.model" align="center" v-bind="f.options">
+            <template v-slot="{ row }">
+              <slot :name="f.model" v-bind="{ f, row }"></slot>
+            </template>
+          </el-table-column>
+          <el-table-column v-else :label="f.label" :prop="f.model" align="center" :formatter="toFormatter"></el-table-column>
+        </template>
+        <el-table-column :label="$t('common.opera')" align="center" v-if="opera.length > 0">
+          <template v-slot="{ row, $index }">
+            <slot v-bind="{ row }">
+              <template v-for="f in opera">
+                <template v-if="display(f, row)">
+                  <el-link :key="f.method" :type="f.type || 'primary'" size="small" :underline="false" class="link" v-method="f.method" @click="handleOpera(f, row, $index)">
+                    {{ f.label }}
+                  </el-link>
+                </template>
+              </template>
+            </slot>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-col>
+  </el-row>
+  <el-row justify="end">
+    <el-pagination
+      background
+      layout="total, prev, pager, next"
+      :page-sizes="[10, 20, 50, 100, 200]"
+      :total="total"
+      :page-size="limit"
+      v-model:current-page="currentPage"
+      @current-change="changePage"
+      @size-change="sizeChange"
+    >
+    </el-pagination>
+  </el-row>
+</template>
+<script setup>
+import { isFunction, get, isString, cloneDeep } from 'lodash-es'
+const props = defineProps({
+  data: { type: Array, default: () => [] },
+  height: { type: String, default: '60vh' },
+  fields: { type: Array, default: () => [] },
+  opera: { type: Array, default: () => [] },
+  total: { type: Number, default: 0 },
+  limit: { type: Number, default: 10 },
+  select: { type: Boolean, default: false }
+})
+const emit = defineEmits(['query', 'toSelect'])
+const toSelect = (val) => {
+  emit(`toSelect`, val)
+}
+
+const handleOpera = (field, data, index) => {
+  let { method, confirm = false, methodZh, label, confirmWord } = cloneDeep(field)
+  if (isFunction(methodZh)) methodZh = methodZh(data)
+  else if (isString(methodZh)) {
+    methodZh = label
+  }
+  if (confirm) {
+    let word = methodZh ? methodZh : `您确认${label}该数据?`
+    if (confirmWord) word = confirmWord
+    ElMessageBox.confirm(word, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+      .then(() => {
+        emit(method, cloneDeep(data), index)
+      })
+      .catch(() => {})
+  } else emit(method, cloneDeep(data), index)
+}
+
+/**
+ * 根据field中的 format函数 格式化该单元格数据
+ * @param {Object} row 本行数据
+ * @param {Object} column 本列实例
+ * @param {any} cellValue 该单元格原数据
+ */
+const toFormatter = (row, column, cellValue) => {
+  // 先找到field
+  const fields = get(props, 'fields')
+  if (!fields) return cellValue
+  let this_field = fields.find((fil) => fil.model === column.property)
+  if (!this_field) return cellValue
+  // 再找field中format函数
+  let format = get(this_field, `format`, false)
+  if (!format) return cellValue
+  if (isFunction(format)) {
+    const formatResult = format(cellValue, row, this_field)
+    return formatResult
+  }
+}
+const display = (field, row) => {
+  let display = get(field, `display`, true)
+  if (display === true) return true
+  else {
+    let res = display(row)
+    return res
+  }
+}
+const currentPage = ref(1)
+// 分页
+const changePage = (page = currentPage.value) => {
+  emit('query', { skip: (page - 1) * props.limit, limit: props.limit })
+}
+</script>
+<style scoped>
+.el-row {
+  padding-top: 20px;
+}
+.link {
+  padding: 0 5px 0 0;
+}
+.page {
+  margin: 10px 0 0 0;
+}
+</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)
+  }
+}

+ 32 - 0
src/lang/index.js

@@ -0,0 +1,32 @@
+import { createI18n } from 'vue-i18n'
+import { useCookies } from '@vueuse/integrations/useCookies'
+import defaultSettings from '@/settings'
+const cookies = useCookies()
+// 本地语言包
+// tm('key') 取ref对象 再用toRaw转换成普通对象
+// t('key.target')取值
+import enLocale from './package/en'
+import zhCnLocale from './package/zh-cn'
+const messages = {
+  'zh-cn': {
+    ...zhCnLocale
+  },
+  'en-us': {
+    ...enLocale
+  }
+}
+
+let lang = cookies.get('locale')
+if (!lang) {
+  lang = defaultSettings.language
+  cookies.set('locale', defaultSettings.language, { path: '/' })
+}
+
+const i18n = createI18n({
+  legacy: false,
+  locale: lang,
+  messages: messages,
+  globalInjection: true
+})
+
+export default i18n

+ 8 - 0
src/lang/package/en.js

@@ -0,0 +1,8 @@
+const allModules = import.meta.glob('./en/*.js', { eager: true })
+const keys = Object.keys(allModules)
+const packages = {}
+for (const key of keys) {
+  const name = key.substring(key.lastIndexOf('/') + 1, key.lastIndexOf('.js'))
+  packages[name] = allModules[key].default
+}
+export default packages

+ 23 - 0
src/lang/package/en/common.js

@@ -0,0 +1,23 @@
+export default {
+  opera: 'opera',
+  add: 'add',
+  update: 'update',
+  delete: 'delete',
+  delete_confirm: 'Are you sure you want to delete this data?',
+  search: 'search',
+  view: 'view',
+  save: 'save',
+  submit: 'submit',
+  is_use_abled: 'abled',
+  is_use_disabled: 'disabled',
+  yes: 'yes',
+  no: 'no',
+  no_method: 'The function is not yet open!',
+  warning: 'warning',
+  confirm: 'confirm',
+  cancel: 'cancel',
+  user_confirm: 'User confirmation',
+  re_login: 'Login again',
+  opera_success: 'opera success',
+  opera_fail: 'opera fail'
+}

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

@@ -0,0 +1,8 @@
+const allModules = import.meta.glob('./zh-cn/*.js', { eager: true })
+const keys = Object.keys(allModules)
+const packages = {}
+for (const key of keys) {
+  const name = key.substring(key.lastIndexOf('/') + 1, key.lastIndexOf('.js'))
+  packages[name] = allModules[key].default
+}
+export default packages

+ 28 - 0
src/lang/package/zh-cn/common.js

@@ -0,0 +1,28 @@
+export default {
+  opera: '操作',
+  back: '返回',
+  add: '添加',
+  update: '修改',
+  delete: '删除',
+  exam: '审核',
+  dict: '字典数据',
+  sign: '报名管理',
+  delete_confirm: '您确定删除该数据?',
+  search: '查询',
+  reset: '重置',
+  view: '查看',
+  save: '保存',
+  submit: '提交',
+  is_use_abled: '启用',
+  is_use_disabled: '禁用',
+  yes: '是',
+  no: '否',
+  no_method: '功能暂未开放',
+  warning: '注意',
+  confirm: '确定',
+  cancel: '取消',
+  user_confirm: '用户确认',
+  re_login: '重新登录',
+  opera_success: '操作成功',
+  opera_fail: '操作失败'
+}

+ 10 - 0
src/lang/package/zh-cn/login.js

@@ -0,0 +1,10 @@
+// 登录页面国际化
+export default {
+  title: '框架名',
+  username: '用户名',
+  password: '密码',
+  login: '登 录',
+  captchaCode: '验证码',
+  placeholder1: '请输入用户名',
+  placeholder2: '请输入密码'
+}

+ 6 - 0
src/lang/package/zh-cn/navbar.js

@@ -0,0 +1,6 @@
+// 导航栏国际化
+export default {
+  dashboard: '首页',
+  logout: '注销',
+  my: '个人中心'
+}

+ 59 - 0
src/layout/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <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="tagsBar"></component>
+              </el-col>
+              {{ pagesLoading }}
+              <el-col :span="24" class="container" :style="{ padding: '10px' }">
+                <router-view :style="viewStyle"></router-view>
+              </el-col>
+            </el-row>
+          </transition>
+          <el-backtop target=".content"></el-backtop>
+        </el-col>
+      </div>
+    </el-main>
+  </el-container>
+</template>
+
+<script setup>
+// 组件
+import cHeader from './parts/Header.vue'
+import cAside from './parts/Sidebar.vue'
+import tagsBar from './parts/Tagsbar.vue'
+const viewStyle = ref({
+  height: '85vh',
+  background: '#ffffff',
+  'overflow-x': 'hidden',
+  'overflow-y': 'auto',
+  border: '1px solid #f0f0f0',
+  padding: '10px'
+})
+</script>
+
+<style scoped lang="scss">
+.layout-container-demo {
+  height: 100%;
+  width: 100%;
+
+  .el-header {
+    padding: 0;
+  }
+
+  .el-main {
+    padding: 0;
+    background-color: #f0f0f0;
+  }
+}
+</style>

+ 77 - 0
src/layout/parts/Header.vue

@@ -0,0 +1,77 @@
+<template>
+  <div id="Header">
+    <el-row>
+      <el-col :span="24" class="main">
+        <div class="left">
+          <Breadcrumb></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>{{ $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 { UserStore } from '@/store/user'
+import { useTagsViewStore } from '@/store'
+const userStore = UserStore()
+const tagsViewStore = useTagsViewStore()
+const user = computed(() => userStore.user)
+const router = useRouter()
+// 退出登录
+const logout = () => {
+  userStore.logOut()
+  tagsViewStore.delAllViews()
+  router.push('/login')
+}
+</script>
+<style scoped lang="scss">
+.main {
+  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>

+ 103 - 0
src/layout/parts/Sidebar.vue

@@ -0,0 +1,103 @@
+<!-- eslint-disable vue/no-deprecated-slot-attribute -->
+<template>
+  <div id="Sidebar">
+    <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="#304156" text-color="#bfcbd9" active-text-color="#409eff">
+            <menu-item :items="items"></menu-item>
+          </el-menu>
+        </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+import { siteInfo } 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 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 {
+  width: 0;
+}
+
+.sidebar > ul {
+  height: 100%;
+}
+
+.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 {
+      border-right: none;
+    }
+
+    .iconfont {
+      font-size: 18px;
+      margin: 0 5px 0 0;
+    }
+  }
+}
+</style>

+ 387 - 0
src/layout/parts/Tagsbar.vue

@@ -0,0 +1,387 @@
+<template>
+  <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)">
+        {{ get(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>
+import { ref, watch, onMounted, getCurrentInstance, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import defaultSettings from '@/settings'
+import { storeToRefs } from 'pinia'
+import { resolve } from 'path-browserify'
+import { useTagsViewStore, useAppStore } from '@/store'
+import {get} from 'lodash-es'
+
+const { proxy } = getCurrentInstance()
+const router = useRouter()
+const route = useRoute()
+
+const tagsViewStore = useTagsViewStore()
+const appStore = useAppStore()
+
+const { visitedViews } = storeToRefs(tagsViewStore)
+const layout = computed(() => defaultSettings.layout)
+const selectedTag = ref({
+  path: '',
+  fullPath: '',
+  name: '',
+  title: '',
+  affix: false,
+  keepAlive: false
+})
+
+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([])
+  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 === 'home') {
+      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()
+}
+onMounted(() => {
+  initTags()
+})
+</script>
+<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>

+ 42 - 0
src/layout/parts/sidebar/items.vue

@@ -0,0 +1,42 @@
+<template>
+  <template v-for="item in items" :key="item._id">
+    <template v-if="item.type === '0'">
+      <el-sub-menu :index="item._id" :key="item._id">
+        <template #title>
+          <component class="icon" :is="item.icon"></component>
+          <span>{{ get(item, 'name') }}</span>
+        </template>
+        <menu-item :items="item.children"></menu-item>
+      </el-sub-menu>
+    </template>
+    <template v-else-if="item.type === '1'">
+      <el-menu-item :index="item.path" :key="item.path">
+        <component class="icon" :is="item.icon"></component>
+        <span>{{ get(item, 'name') }}</span>
+      </el-menu-item>
+    </template>
+  </template>
+</template>
+
+<script setup>
+import { get } from 'lodash-es'
+const props = defineProps({
+  items: { type: Array, default: () => [] }
+})
+</script>
+<script>
+export default {
+  name: 'menuItem'
+}
+</script>
+<style scoped lang="scss">
+.icon {
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  overflow: hidden;
+  vertical-align: -0.15em;
+  outline: none;
+  margin-right: 5px;
+}
+</style>

+ 177 - 0
src/layout/site.js

@@ -0,0 +1,177 @@
+// 网站基本设置
+export const siteInfo = {
+  display: false,
+  zhTitle: '新一代信息技术孵化平台',
+  logo_url: '/src/assets/logo.png'
+}
+// 菜单设置
+export const menuInfo = {
+  info: {
+    display: false,
+    mode: 'horizontal',
+    backColor: '#242f42',
+    textColor: '#ffffff'
+  },
+  menuList: [
+    {
+      _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'
+    }
+  ]
+}

+ 29 - 0
src/main.js

@@ -0,0 +1,29 @@
+import { createApp } from 'vue'
+import { setupStore } from '@/store'
+
+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 { InitVariable } from './utils/variable'
+// 组件
+import globalComponents from '@/components'
+// 指令
+import { InitDirective } from './utils/directives'
+const app = createApp(App)
+globalComponents(app)
+setupStore(app)
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
+app.use(i18n).use(router).mount('#app')
+InitCheckResult(app)
+InitVariable(app)
+InitDirective(app)

+ 100 - 0
src/router/guard.js

@@ -0,0 +1,100 @@
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { UserStore } from '@/store/user'
+import { cloneDeep, isArray, omit } from 'lodash-es'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+import { ElMessageBox } from 'element-plus'
+import i18n from '@/lang'
+const whiteList = ['/redirect', '/login', '/401', '/404']
+NProgress.configure({ showSpinner: false }) // 进度条
+// 获取用户信息,返回菜单
+const getUserMeta = async (token) => {
+  const userStore = UserStore()
+  const axios = new AxiosWrapper()
+  const result = await axios.$get(`/token/tokenView`, null, {
+    headers: {
+      token: token
+    }
+  })
+  if (result.errcode == 0) {
+    userStore.setUser(result.data)
+    const resetMenusResult = resetMenus(result.data.menus)
+    const storeMenus = toRaw(userStore.menus)
+    if (storeMenus.length <= 0) {
+      userStore.setMenus(resetMenusResult)
+    }
+    return { menus: result.data.menus, errcode: 0 }
+  }
+  return { errmsg: result.errmsg, errcode: result.errcode }
+}
+
+const resetMenus = (menus) => {
+  if (!isArray(menus) || menus.length <= 0) return []
+  const cMenus = cloneDeep(menus)
+  const result = []
+  for (const m of cMenus) {
+    const mid = omit(m, ['is_use', 'order_num', 'in_admin_frame'])
+    const { children } = mid
+    if (children) mid.children = resetMenus(children)
+    result.push(mid)
+  }
+  return result
+}
+
+// 注册前置守卫
+export const registerBeforeRouter = async (router) => {
+  router.beforeEach(async (to, from, next) => {
+    NProgress.start() //开启进度条
+    const token = localStorage.getItem('token')
+    NProgress.inc()
+    if (whiteList.includes(to.path)) {
+      next()
+      return
+    }
+    if (token) {
+      NProgress.inc()
+      if (to.path === '/login') next()
+      NProgress.inc()
+      const { menus, errcode, errmsg } = await getUserMeta(token)
+      // 登录信息有问题
+      if (errcode != 0) {
+        if (errcode.includes('401')) {
+          await ElMessageBox.alert(errmsg, i18n.global.t('common.user_confirm'), {
+            confirmButtonText: i18n.global.t('common.re_login'),
+            type: 'error'
+          })
+          next('/login')
+          return
+        } else {
+          await ElMessageBox.alert(errmsg, i18n.global.t('common.user_confirm'), {
+            confirmButtonText: i18n.global.t('common.re_login'),
+            type: 'error'
+          })
+          location.reload()
+        }
+      }
+      // 菜单格式不正确
+      if (!menus) {
+        next('/401')
+        return
+      }
+      NProgress.inc()
+      // 注册了直接进入
+      next()
+    } else {
+      next('/login')
+    }
+  })
+}
+
+// 注册路由后置守卫
+
+export const registerAfterRouter = async (router) => {
+  router.afterEach(async (to, form) => {
+    NProgress.done() //完成进度条
+    if (to.path === '/login') {
+      return
+    }
+    // 请求该页面的权限
+  })
+}

+ 80 - 0
src/router/index.js

@@ -0,0 +1,80 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { registerBeforeRouter, registerAfterRouter } from './guard'
+export const homeIndex = () => import('@/views/home/index.vue')
+export const Layout = () => import('@/layout/index.vue')
+import { routes as systemRoutes } from './modules/system'
+import { routes as userRoutes } from './modules/user'
+// 静态路由
+export const constantRoutes = [
+  {
+    path: '/redirect',
+    component: Layout,
+    meta: { hidden: true },
+    children: [
+      {
+        path: '/redirect/:path(.*)',
+        component: () => import('@/views/redirect/index.vue')
+      }
+    ]
+  },
+  {
+    path: '/login',
+    name: 'Login',
+    meta: { title: '系统登录' },
+    component: () => import('@/views/login/index.vue')
+  },
+  {
+    path: '/',
+    name: 'Layout',
+    component: Layout,
+    children: [
+      ...systemRoutes,
+      ...userRoutes,
+      {
+        path: '/',
+        name: 'home',
+        meta: {
+          title: '首页',
+          affix: true,
+          keepAlive: true,
+          alwaysShow: false
+        },
+        component: () => import('@/views/home/index.vue')
+      },
+      {
+        path: '401',
+        component: () => import('@/views/error-page/401.vue'),
+        meta: { hidden: true }
+      },
+      {
+        path: '404',
+        component: () => import('@/views/error-page/404.vue'),
+        meta: { hidden: true }
+      },
+      {
+        path: '/acccount',
+        name: 'acccount',
+        meta: {
+          title: '账号管理',
+          affix: true,
+          keepAlive: true,
+          alwaysShow: false
+        },
+        component: () => import('@/views/account/index.vue')
+      }
+    ]
+  }
+]
+
+/**
+ * 创建路由
+ */
+const router = createRouter({
+  history: createWebHistory(),
+  routes: constantRoutes,
+  // 刷新时,滚动条位置还原
+  scrollBehavior: () => ({ left: 0, top: 0 })
+})
+registerBeforeRouter(router)
+registerAfterRouter(router)
+export default router

+ 47 - 0
src/router/modules/system.js

@@ -0,0 +1,47 @@
+export const routes = [
+  {
+    path: '/system',
+    name: 'system',
+    meta: {
+      title: '系统设置',
+      affix: true,
+      keepAlive: true,
+      alwaysShow: false
+    },
+    children: [
+      {
+        path: '/system/menus',
+        name: 'system_menus',
+        meta: {
+          title: '目录设置',
+          affix: true,
+          keepAlive: true,
+          alwaysShow: false
+        },
+        component: () => import('@/views/system/menus/index.vue')
+      },
+      {
+        path: '/system/role',
+        name: 'system_role',
+        meta: {
+          title: '角色管理',
+          affix: true,
+          keepAlive: true,
+          alwaysShow: false
+        },
+        component: () => import('@/views/system/role/index.vue')
+      },
+      {
+        path: '/system/dict',
+        name: 'system_dict',
+        meta: {
+          title: '字典管理',
+          affix: true,
+          keepAlive: true,
+          alwaysShow: false
+        },
+        component: () => import('@/views/system/dict/index.vue')
+      }
+    ]
+  }
+]

+ 36 - 0
src/router/modules/user.js

@@ -0,0 +1,36 @@
+export const routes = [
+  {
+    path: '/user',
+    name: 'user',
+    meta: {
+      title: '用户管理',
+      affix: true,
+      keepAlive: true,
+      alwaysShow: false
+    },
+    children: [
+      {
+        path: '/user/admin',
+        name: 'user_admin',
+        meta: {
+          title: '管理员用户',
+          affix: true,
+          keepAlive: true,
+          alwaysShow: false
+        },
+        component: () => import('@/views/user/admin/index.vue')
+      },
+      {
+        path: '/user/user',
+        name: 'user_user',
+        meta: {
+          title: '平台用户',
+          affix: true,
+          keepAlive: true,
+          alwaysShow: false
+        },
+        component: () => import('@/views/user/user/index.vue')
+      }
+    ]
+  }
+]

+ 16 - 0
src/settings.js

@@ -0,0 +1,16 @@
+const defaultSettings = {
+  title: 'admin-template',
+  version: 'v0.0.1',
+  showSettings: true,
+  tagsView: true,
+  fixedHeader: false,
+  sidebarLogo: true,
+  layout: 'left',
+  theme: 'light',
+  size: 'default',
+  language: 'zh-cn',
+  themeColor: '#409EFF',
+  watermark: { enabled: false, content: '福瑞科技' }
+}
+
+export default defaultSettings

+ 25 - 0
src/store/api/login.js

@@ -0,0 +1,25 @@
+import { defineStore } from 'pinia'
+import { get, omit } from 'lodash-es'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+const axios = new AxiosWrapper()
+export const LoginStore = defineStore('login', () => {
+  const login = async (payload) => {
+    const type = get(payload, 'type')
+    const np = omit(payload, 'type')
+    const res = await axios.$post(`/login/${type}`, np)
+    return res
+  }
+  const rp = async (payload) => {
+    const type = get(payload, 'type')
+    const np = omit(payload, 'type')
+    const res = await axios.$post(`/login/updatePwd/${type}`, np)
+    return res
+  }
+  const rpNoNewPassword = async (payload) => {
+    const type = get(payload, 'type')
+    const np = omit(payload, 'type')
+    const res = await axios.$post(`/login/resetPwd/${type}`, np)
+    return res
+  }
+  return { login, rp, rpNoNewPassword }
+})

+ 40 - 0
src/store/api/platform/demand.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/demand'
+const axios = new AxiosWrapper()
+
+export const DemandStore = defineStore('demand', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 40 - 0
src/store/api/platform/match.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/match'
+const axios = new AxiosWrapper()
+
+export const MatchStore = defineStore('match', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 40 - 0
src/store/api/platform/news.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/news'
+const axios = new AxiosWrapper()
+
+export const NewsStore = defineStore('news', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 40 - 0
src/store/api/system/dictData.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/dictData'
+const axios = new AxiosWrapper()
+
+export const DictDataStore = defineStore('dictData', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 40 - 0
src/store/api/system/dictType.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/dictType'
+const axios = new AxiosWrapper()
+
+export const DictTypeStore = defineStore('dictType', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 40 - 0
src/store/api/system/menus.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/menus'
+const axios = new AxiosWrapper()
+
+export const MenusStore = defineStore('menus', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 40 - 0
src/store/api/system/role.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/role'
+const axios = new AxiosWrapper()
+
+export const RoleStore = defineStore('role', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 40 - 0
src/store/api/user/admin.js

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { AxiosWrapper } from '@/utils/axios-wrapper'
+import { get } from 'lodash-es'
+const url = '/admin'
+const axios = new AxiosWrapper()
+
+export const AdminStore = defineStore('admin', () => {
+  const query = async ({ skip = 0, limit = undefined, ...info } = {}) => {
+    let cond = {}
+    if (skip) cond.skip = skip
+    if (limit) cond.limit = limit
+    cond = { ...cond, ...info }
+    const res = await axios.$get(`${url}`, cond)
+    return res
+  }
+  const fetch = async (payload) => {
+    const res = await axios.$get(`${url}/${payload}`)
+    return res
+  }
+  const create = async (payload) => {
+    const res = await axios.$post(`${url}`, payload)
+    return res
+  }
+  const update = async (payload) => {
+    const id = get(payload, 'id', get(payload, '_id'))
+    const res = await axios.$post(`${url}/${id}`, payload)
+    return res
+  }
+  const del = async (payload) => {
+    const res = await axios.$delete(`${url}/${payload}`)
+    return res
+  }
+  return {
+    query,
+    fetch,
+    create,
+    update,
+    del
+  }
+})

+ 12 - 0
src/store/index.js

@@ -0,0 +1,12 @@
+import { createPinia } from 'pinia'
+
+const store = createPinia()
+
+// 全局注册 store
+export function setupStore(app) {
+  app.use(store)
+}
+
+export * from './modules/app'
+export * from './modules/tagsView'
+export { store }

+ 97 - 0
src/store/modules/app.js

@@ -0,0 +1,97 @@
+import defaultSettings from '@/settings'
+import { useStorage } from '@vueuse/core'
+import { useCookies } from '@vueuse/integrations/useCookies'
+// 导入 Element Plus 中英文语言包
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+import en from 'element-plus/es/locale/lang/en'
+const cookies = useCookies()
+
+// setup
+export const useAppStore = defineStore('app', () => {
+  // state
+  const device = useStorage('device', 'desktop')
+  const size = useStorage('size', defaultSettings.size)
+  let lang = cookies.get('locale')
+  if (!lang) {
+    lang = defaultSettings.language
+    cookies.set('locale', defaultSettings.language, { path: '/' })
+  }
+  const language = useStorage('language', lang)
+  const sidebarStatus = useStorage('sidebarStatus', 'closed')
+
+  const sidebar = reactive({
+    opened: sidebarStatus.value !== 'closed',
+    withoutAnimation: false
+  })
+  const activeTopMenuPath = useStorage('activeTopMenuPath', '')
+  /**
+   * 根据语言标识读取对应的语言包
+   */
+  const locale = computed(() => {
+    if (language?.value == 'en-us') {
+      return en
+    } else {
+      return zhCn
+    }
+  })
+
+  // actions
+  function toggleSidebar() {
+    sidebar.opened = !sidebar.opened
+    if (sidebar.opened) {
+      sidebarStatus.value = 'opened'
+    } else {
+      sidebarStatus.value = 'closed'
+    }
+  }
+
+  function closeSideBar() {
+    sidebar.opened = false
+    sidebarStatus.value = 'closed'
+  }
+
+  function openSideBar() {
+    sidebar.opened = true
+    sidebarStatus.value = 'opened'
+  }
+
+  function toggleDevice(val) {
+    device.value = val
+  }
+
+  function changeSize(val) {
+    size.value = val
+  }
+  /**
+   * 切换语言
+   *
+   * @param val
+   */
+  function changeLanguage(val) {
+    language.value = val
+    // cookie切换
+    cookies.set('locale', val, { path: '/' })
+  }
+  /**
+   * 混合模式顶部切换
+   */
+  function activeTopMenu(val) {
+    activeTopMenuPath.value = val
+  }
+
+  return {
+    device,
+    sidebar,
+    language,
+    locale,
+    size,
+    activeTopMenu,
+    toggleDevice,
+    changeSize,
+    changeLanguage,
+    toggleSidebar,
+    closeSideBar,
+    openSideBar,
+    activeTopMenuPath
+  }
+})

+ 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: 'home', path: '/', title: '首页' }])
+  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
+  }
+})

+ 23 - 0
src/store/user.js

@@ -0,0 +1,23 @@
+import { defineStore } from 'pinia'
+export const UserStore = defineStore('user', () => {
+  const user = ref({})
+  const menus = ref([])
+  /**
+   * 将用户信息存起来;用户信息是在路由变更时,路由前置守卫去服务进行兑换而来
+   * @param {String} payload token串
+   */
+  const setUser = (payload) => {
+    user.value = payload
+  }
+  /**
+   * 清除用户信息并删除token
+   */
+  const logOut = () => {
+    user.value = {}
+    localStorage.removeItem('token')
+  }
+  const setMenus = (payload) => {
+    menus.value = payload
+  }
+  return { user, setUser, logOut, menus, setMenus }
+})

+ 140 - 0
src/utils/axios-wrapper.js

@@ -0,0 +1,140 @@
+/* eslint-disable no-console */
+/* eslint-disable no-param-reassign */
+
+import { get, isObject } from 'lodash-es'
+import Axios from 'axios'
+import { trimData, isNullOrUndefined } from './util-methods'
+import router from '@/router'
+import i18n from '@/lang'
+let currentRequests = 0
+
+export class AxiosWrapper {
+  constructor({ baseUrl = import.meta.env.VITE_APP_BASE_API, unwrap = true } = {}) {
+    this.baseUrl = baseUrl
+    this.unwrap = unwrap
+  }
+  baseUrl
+  unwrap
+
+  // 替换uri中的参数变量
+  static merge(uri, query) {
+    if (!uri.includes(':')) {
+      return uri
+    }
+    const keys = []
+    const regexp = /\/:([a-z0-9_]+)/gi
+    let res
+    // eslint-disable-next-line no-cond-assign
+    while ((res = regexp.exec(uri)) != null) {
+      keys.push(res[1])
+    }
+    keys.forEach((key) => {
+      const val = get(query, key)
+      if (!isNullOrUndefined(val)) {
+        uri = uri.replace(`:${key}`, `${val}`)
+      }
+    })
+    return uri
+  }
+
+  $get(uri, query, options) {
+    return this.$request(uri, undefined, query, options)
+  }
+
+  $post(uri, data = {}, query, options) {
+    return this.$request(uri, data, query, options)
+  }
+  $delete(uri, data = {}, query, options = {}) {
+    options = { ...options, method: 'delete' }
+    return this.$request(uri, data, query, options)
+  }
+  async $request(uri, data, query, options) {
+    if (query && isObject(query)) {
+      const keys = Object.keys(query)
+      for (const key of keys) {
+        const val = get(query, key)
+        if (val === '') {
+          delete query[key]
+        }
+      }
+    }
+    if (isObject(query) && isObject(options)) {
+      options = { ...options, params: query, method: 'get' }
+    } else if (isObject(query) && !query.params) {
+      options = { params: query }
+    } else if (isObject(query) && query.params) {
+      options = query
+    }
+    if (!options) options = {}
+    if (options.params) options.params = trimData(options.params, null, null)
+    const params = get(options, 'params')
+    const url = AxiosWrapper.merge(uri, params)
+    currentRequests += 1
+    // Indicator.open({
+    //   spinnerType: 'fading-circle',
+    // });
+    try {
+      let returnData
+      const axios = Axios.create({
+        baseURL: this.baseUrl,
+        withCredentials: true
+      })
+      // if (util.token && util.token !== null) axios.defaults.headers.common.Authorization = util.token;
+      const token = localStorage.getItem('token')
+      const apiToken = localStorage.getItem('apiToken')
+      if (token) axios.defaults.headers.common['token'] = token
+      if (apiToken) axios.defaults.headers.common['api-token'] = apiToken
+      const res = await axios.request({
+        method: isNullOrUndefined(data) ? 'get' : 'post',
+        url,
+        data,
+        responseType: 'json',
+        ...options
+      })
+      const returnRes = res.data
+      const { errcode, errmsg, details } = returnRes
+      if (errcode) {
+        console.warn(`[${uri}] fail: ${errcode}-${errmsg} ${details}`)
+        if (errcode != 0) {
+          if (errcode.includes('401')) {
+            ElMessageBox.alert(errmsg, i18n.global.t('common.user_confirm'), {
+              confirmButtonText: i18n.global.t('common.re_login'),
+              type: 'error',
+              callback: (act) => {
+                router.replace('/login')
+              }
+            })
+          }
+        }
+        return returnRes
+      }
+      // unwrap data
+      if (this.unwrap) {
+        returnData = returnRes
+      }
+      // 处理apiToken
+      const { apiToken: at, ...others } = returnData
+      if (at) localStorage.setItem('apiToken', at)
+      return others
+    } catch (err) {
+      let errmsg = '接口请求失败,请稍后重试'
+      if (err.response) {
+        const { status } = err.response
+        if (status === 401) errmsg = '用户认证失败,请重新登录'
+        if (status === 403) errmsg = '当前用户不允许执行该操作'
+      }
+      console.error(
+        `[AxiosWrapper] 接口请求失败: ${err.config && err.config.url} - 
+        ${err.message}`
+      )
+      return { errcode: 400, errmsg, details: err.message }
+    } finally {
+      /* eslint-disable */
+      currentRequests -= 1
+      if (currentRequests <= 0) {
+        currentRequests = 0
+        // Indicator.close();
+      }
+    }
+  }
+}

+ 24 - 0
src/utils/checkResult.js

@@ -0,0 +1,24 @@
+import { isFunction, isString } from 'lodash-es'
+import { ElMessage } from 'element-plus'
+import i18n from '@/lang'
+export const checkResult = (res, okText, errText) => {
+  const { errcode = 0, errmsg } = res || {}
+  if (errcode == 0) {
+    if (isFunction(okText)) {
+      return okText()
+    }
+    if (isString(okText)) ElMessage.success(okText)
+    else if (okText) ElMessage.success(i18n.global.t('common.opera_success'))
+    return true
+  }
+  if (isFunction(errText)) {
+    return errText()
+  }
+  ElMessage.error(errText || errmsg)
+  // Message({ message: _errText || errmsg, duration: 60000 });
+  return false
+}
+
+export const InitCheckResult = (app) => {
+  app.provide('$checkRes', checkResult)
+}

File diff suppressed because it is too large
+ 11536 - 0
src/utils/city.js


+ 29 - 0
src/utils/directives.js

@@ -0,0 +1,29 @@
+import { UserStore } from '@/store/user'
+import { get, isArray } from 'lodash-es'
+import router from '@/router'
+const InitDirective = (app) => {
+  app.directive('method', {
+    mounted(el, binding) {
+      const { user } = UserStore()
+      const { value: code } = binding
+      const rUser = toRaw(user)
+      // 超级管理员不进行检查
+      if (get(rUser, 'role') === 'Admin' && get(rUser, 'is_super') === '0') return
+      const roleCode = get(rUser, 'role_code')
+      // 需要判断roleCode中是否有这个权限.但是权限又需要路由拼接起来,最少也得有上层级组合
+      const cr = router.currentRoute.value
+      // 拼接当前路由和层级的name
+      const rArr = cr.matched.filter((f) => f.name !== 'Layout').map((i) => i.name)
+      rArr.push(code)
+      const thisMethodCode = `${rArr.join('.')}`
+      if (!isArray(roleCode)) {
+        el.parentNode.removeChild(el)
+      }
+      if (!roleCode.includes(thisMethodCode)) {
+        el.parentNode.removeChild(el)
+      }
+    }
+  })
+}
+
+export { InitDirective }

+ 26 - 0
src/utils/file.js

@@ -0,0 +1,26 @@
+import Axios from 'axios'
+/**
+ * 上传文件
+ *
+ * @param file
+ */
+export async function uploadFileApi(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  const axios = Axios.create({
+    baseURL: '/files',
+    withCredentials: true
+  })
+  const token = localStorage.getItem('token')
+  const apiToken = localStorage.getItem('apiToken')
+  if (token) axios.defaults.headers.common['token'] = token
+  if (apiToken) axios.defaults.headers.common['api-token'] = apiToken
+  return await axios.request({
+    url: '/web/template/upload',
+    method: 'post',
+    data: formData,
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+}

+ 35 - 0
src/utils/util-methods.js

@@ -0,0 +1,35 @@
+import { isUndefined, isString, isNumber, isBoolean } from 'lodash-es'
+export const trimData = (data, exclude = [], include) => {
+  if (data === null || data === undefined) {
+    return data
+  }
+
+  for (const key in data) {
+    if (isUndefined(data[key]) || (exclude && exclude.indexOf(key) !== -1)) delete data[key]
+    if (include && include.indexOf(key) === -1) delete data[key]
+  }
+  return data
+}
+// 检查email格式
+export const isEmail = function (val) {
+  // 允许汉字、英文字母、数字
+  return /^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(val)
+}
+
+// 是否为空
+export const isNullOrUndefined = function (val) {
+  return val === null || val === undefined
+}
+
+// 转换为boolean
+export const toBoolean = function (val) {
+  if (isString(val) && (val.toLowerCase() === 'true' || val.toLowerCase() !== '0')) {
+    val = true
+  } else if (isNumber(val) && val !== 0) {
+    val = true
+  } else {
+    val = isBoolean(val) && val
+  }
+
+  return val
+}

+ 11 - 0
src/utils/variable.js

@@ -0,0 +1,11 @@
+const variable = {
+  limit: 10
+}
+
+export const InitVariable = (app) => {
+  for (const key in variable) {
+    if (Object.hasOwnProperty.call(variable, key)) {
+      app.provide([key], variable[key])
+    }
+  }
+}

+ 20 - 0
src/views/account/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>

+ 105 - 0
src/views/error-page/401.vue

@@ -0,0 +1,105 @@
+<script setup>
+import { reactive, toRefs } from 'vue'
+import { useRouter } from 'vue-router'
+
+import { defineComponent } from 'vue'
+import { ArrowLeft } from '@element-plus/icons-vue'
+defineComponent({
+  name: 'Page401'
+})
+
+const state = reactive({
+  errGif: new URL(`../../assets/images/401.gif`, import.meta.url).href,
+
+  ewizardClap: 'https://wpimg.wallstcn.com/007ef517-bafd-4066-aae4-6883632d9646',
+  dialogVisible: false
+})
+
+const { errGif, ewizardClap, dialogVisible } = toRefs(state)
+
+const router = useRouter()
+
+function back() {
+  router.back()
+}
+</script>
+
+<template>
+  <div class="errPage-container">
+    <el-button :icon="ArrowLeft" class="pan-back-btn" @click="back"> 返回 </el-button>
+    <el-row>
+      <el-col :span="12">
+        <h1 class="text-jumbo text-ginormous">Oops!</h1>
+        gif来源<a href="https://zh.airbnb.com/" target="_blank">airbnb</a> 页面
+        <h2>你没有权限去该页面</h2>
+        <h6>如有不满请联系你领导</h6>
+        <ul class="list-unstyled">
+          <li>或者你可以去:</li>
+          <li class="link-type">
+            <router-link to="/dashboard"> 回首页 </router-link>
+          </li>
+          <li class="link-type">
+            <a href="https://www.taobao.com/">随便看看</a>
+          </li>
+          <li>
+            <a href="#" @click.prevent="dialogVisible = true">点我看图</a>
+          </li>
+        </ul>
+      </el-col>
+      <el-col :span="12">
+        <img :src="errGif" width="313" height="428" alt="Girl has dropped her ice cream." />
+      </el-col>
+    </el-row>
+    <el-dialog v-model="dialogVisible" title="随便看">
+      <img :src="ewizardClap" class="pan-img" />
+    </el-dialog>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.errPage-container {
+  width: 800px;
+  max-width: 100%;
+  margin: 100px auto;
+
+  .pan-back-btn {
+    color: #fff;
+    background: #008489;
+    border: none !important;
+  }
+
+  .pan-gif {
+    display: block;
+    margin: 0 auto;
+  }
+
+  .pan-img {
+    display: block;
+    width: 100%;
+    margin: 0 auto;
+  }
+
+  .text-jumbo {
+    font-size: 60px;
+    font-weight: 700;
+    color: #484848;
+  }
+
+  .list-unstyled {
+    font-size: 14px;
+
+    li {
+      padding-bottom: 5px;
+    }
+
+    a {
+      color: #008489;
+      text-decoration: none;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+  }
+}
+</style>

+ 256 - 0
src/views/error-page/404.vue

@@ -0,0 +1,256 @@
+<!-- setup 无法设置组件名称,组件名称keepAlive必须 -->
+<script>
+export default {
+  name: 'Page404'
+}
+</script>
+
+<script setup lang="ts">
+function message() {
+  return 'The webmaster said that you can not enter this page...'
+}
+</script>
+
+<template>
+  <div class="wscn-http404-container">
+    <div class="wscn-http404">
+      <div class="pic-404">
+        <img class="pic-404__parent" src="@/assets/images/404.png" alt="404" />
+        <img class="pic-404__child left" src="@/assets/images/404_cloud.png" alt="404" />
+        <img class="pic-404__child mid" src="@/assets/images/404_cloud.png" alt="404" />
+        <img class="pic-404__child right" src="@/assets/images/404_cloud.png" alt="404" />
+      </div>
+      <div class="bullshit">
+        <div class="bullshit__oops">OOPS!</div>
+        <div class="bullshit__info">
+          All rights reserved
+          <a style="color: #20a0ff" href="https://wallstreetcn.com" target="_blank">wallstreetcn</a>
+        </div>
+        <div class="bullshit__headline">{{ message() }}</div>
+        <div class="bullshit__info">Please check that the URL you entered is correct, or click the button below to return to the homepage.</div>
+        <a href="" class="bullshit__return-home">Back to home</a>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.wscn-http404-container {
+  position: absolute;
+  top: 40%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+}
+
+.wscn-http404 {
+  position: relative;
+  width: 1200px;
+  padding: 0 50px;
+  overflow: hidden;
+
+  .pic-404 {
+    position: relative;
+    float: left;
+    width: 600px;
+    overflow: hidden;
+
+    &__parent {
+      width: 100%;
+    }
+
+    &__child {
+      position: absolute;
+
+      &.left {
+        top: 17px;
+        left: 220px;
+        width: 80px;
+        opacity: 0;
+        animation-name: cloudLeft;
+        animation-duration: 2s;
+        animation-timing-function: linear;
+        animation-delay: 1s;
+        animation-fill-mode: forwards;
+      }
+
+      &.mid {
+        top: 10px;
+        left: 420px;
+        width: 46px;
+        opacity: 0;
+        animation-name: cloudMid;
+        animation-duration: 2s;
+        animation-timing-function: linear;
+        animation-delay: 1.2s;
+        animation-fill-mode: forwards;
+      }
+
+      &.right {
+        top: 100px;
+        left: 500px;
+        width: 62px;
+        opacity: 0;
+        animation-name: cloudRight;
+        animation-duration: 2s;
+        animation-timing-function: linear;
+        animation-delay: 1s;
+        animation-fill-mode: forwards;
+      }
+
+      @keyframes cloudLeft {
+        0% {
+          top: 17px;
+          left: 220px;
+          opacity: 0;
+        }
+
+        20% {
+          top: 33px;
+          left: 188px;
+          opacity: 1;
+        }
+
+        80% {
+          top: 81px;
+          left: 92px;
+          opacity: 1;
+        }
+
+        100% {
+          top: 97px;
+          left: 60px;
+          opacity: 0;
+        }
+      }
+
+      @keyframes cloudMid {
+        0% {
+          top: 10px;
+          left: 420px;
+          opacity: 0;
+        }
+
+        20% {
+          top: 40px;
+          left: 360px;
+          opacity: 1;
+        }
+
+        70% {
+          top: 130px;
+          left: 180px;
+          opacity: 1;
+        }
+
+        100% {
+          top: 160px;
+          left: 120px;
+          opacity: 0;
+        }
+      }
+
+      @keyframes cloudRight {
+        0% {
+          top: 100px;
+          left: 500px;
+          opacity: 0;
+        }
+
+        20% {
+          top: 120px;
+          left: 460px;
+          opacity: 1;
+        }
+
+        80% {
+          top: 180px;
+          left: 340px;
+          opacity: 1;
+        }
+
+        100% {
+          top: 200px;
+          left: 300px;
+          opacity: 0;
+        }
+      }
+    }
+  }
+
+  .bullshit {
+    position: relative;
+    float: left;
+    width: 300px;
+    padding: 30px 0;
+    overflow: hidden;
+
+    &__oops {
+      margin-bottom: 20px;
+      font-size: 32px;
+      font-weight: bold;
+      line-height: 40px;
+      color: #1482f0;
+      opacity: 0;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-fill-mode: forwards;
+    }
+
+    &__headline {
+      margin-bottom: 10px;
+      font-size: 20px;
+      font-weight: bold;
+      line-height: 24px;
+      color: #222;
+      opacity: 0;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-delay: 0.1s;
+      animation-fill-mode: forwards;
+    }
+
+    &__info {
+      margin-bottom: 30px;
+      font-size: 13px;
+      line-height: 21px;
+      color: grey;
+      opacity: 0;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-delay: 0.2s;
+      animation-fill-mode: forwards;
+    }
+
+    &__return-home {
+      display: block;
+      float: left;
+      width: 110px;
+      height: 36px;
+      font-size: 14px;
+      line-height: 36px;
+      color: #fff;
+      text-align: center;
+      cursor: pointer;
+      background: #1482f0;
+      border-radius: 100px;
+      opacity: 0;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-delay: 0.3s;
+      animation-fill-mode: forwards;
+    }
+
+    @keyframes slideUp {
+      0% {
+        opacity: 0;
+        transform: translateY(60px);
+      }
+
+      100% {
+        opacity: 1;
+        transform: translateY(0);
+      }
+    }
+  }
+}
+</style>

+ 20 - 0
src/views/home/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>

+ 146 - 0
src/views/login/index.vue

@@ -0,0 +1,146 @@
+<template>
+  <div class="login-container">
+    <!-- 顶部 -->
+    <div class="lang">
+      <LangSelect class="cursor-pointer"></LangSelect>
+    </div>
+    <!-- 登录表单 -->
+    <el-card class="card">
+      <div class="text">
+        <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="$t('login.placeholder1')">
+            <template #prefix>
+              <el-icon>
+                <User />
+              </el-icon>
+            </template>
+          </el-input>
+        </el-form-item>
+        <!-- 密码 -->
+        <el-form-item prop="password">
+          <el-input v-model="loginData.password" size="large" type="password" show-password
+            :placeholder="$t('login.placeholder2')">
+            <template #prefix>
+              <el-icon>
+                <Unlock />
+              </el-icon>
+            </template>
+          </el-input>
+        </el-form-item>
+        <!-- 登录按钮 -->
+        <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 { LoginStore } from '@/store/api/login'
+const router = useRouter()
+const loginStore = LoginStore()
+const loading = ref(false) // 按钮loading
+const loginFormRef = ref({}) // 登录表单ref
+const loginData = ref({
+  account: 'admin',
+  password: '1qaz2wsx',
+  type: 'Admin'
+})
+const loginRules = computed(() => { })
+const toLogin = async (data) => {
+  const res = await loginStore.login(data)
+  if (res.errcode == '0') {
+    ElMessage({ message: `登录成功`, type: 'success' })
+    localStorage.setItem('token', res.data)
+    // 路由
+    router.push({ path: '/' })
+  }
+  loading.value = false
+
+}
+/**
+ * 登录
+ */
+function handleLogin() {
+  loginFormRef.value.validate((valid) => {
+    if (valid) {
+      loading.value = true
+      toLogin(loginData.value)
+    }
+  })
+}
+</script>
+<style scoped lang="scss">
+.login-container {
+  overflow-y: auto;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  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;
+  }
+
+  .card {
+    width: 400px;
+    height: 450px;
+    border-radius: 15px;
+    padding: 20px;
+    box-shadow: var(--el-box-shadow-light);
+    background: transparent;
+
+    .text {
+      text-align: center;
+    }
+
+    .button {
+      width: 100%;
+    }
+  }
+}
+
+.el-form-item {
+  background: var(--el-input-bg-color);
+  border: 1px solid var(--el-border-color);
+  border-radius: 5px;
+}
+
+:deep(.el-input) {
+  padding: 5px;
+
+  .el-input__wrapper {
+    padding: 0;
+    background-color: transparent;
+    box-shadow: none;
+
+    &.is-focus,
+    &:hover {
+      box-shadow: none !important;
+    }
+
+    input:-webkit-autofill {
+      /* 通过延时渲染背景色变相去除背景颜色 */
+      transition: background-color 1000s ease-in-out 0s;
+    }
+  }
+}
+</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>

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

@@ -0,0 +1,138 @@
+<template>
+  <div class="main animate__animated animate__backInRight" v-loading="loading">
+    <custom-search-bar :fields="fields.filter((f) => f.isSearch)" v-model="searchForm" @search="search"
+      @reset="toReset"></custom-search-bar>
+    <custom-button-bar :fields="buttonFields" @add="toAdd"></custom-button-bar>
+    <custom-table :data="data" :fields="fields" @query="search" :total="total" :opera="opera" @dict="toDict"
+      @edit="toEdit" @delete="toDelete">
+      <template #is_use="{ row }">
+        <el-tag v-if="row.is_use == '0'" type="success">启用</el-tag>
+        <el-tag v-else type="info">禁用</el-tag>
+      </template>
+    </custom-table>
+    <el-dialog v-model="dialog.show" :title="dialog.title" :destroy-on-close="false" @close="toClose" :top="dialog.top">
+      <el-row>
+        <el-col :span="24" v-if="dialog.type == '1'">
+          <custom-form v-model="form" :fields="formFields" :rules="rules" @save="toSave">
+            <template #is_use>
+              <el-radio v-for="i in isUseList" :key="i._id" :label="i.value">{{ i.label }}</el-radio>
+            </template>
+          </custom-form>
+        </el-col>
+        <el-col :span="24" v-if="dialog.type == '2'">
+          <dictData></dictData>
+        </el-col>
+      </el-row>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import dictData from '@/views/system/dictData/index.vue'
+const $checkRes = inject('$checkRes')
+import { cloneDeep, get } from 'lodash-es'
+const { t } = useI18n()
+// 接口
+import { DictTypeStore } from '@/store/api/system/dictType'
+import { DictDataStore } from '@/store/api/system/dictData'
+const store = DictTypeStore()
+const dictDataStore = DictDataStore()
+const data = ref([])
+const searchForm = ref({})
+const fields = [
+  { label: '字典名称', model: 'title', isSearch: true },
+  { label: '编码', model: 'code', isSearch: true },
+  { label: '是否启用', model: 'is_use', format: (i) => getDict(i), custom: true },
+  { label: '备注', model: 'remark' }
+]
+const opera = [
+  { label: t('common.dict'), method: 'dict' },
+  { label: t('common.update'), method: 'edit' },
+  { label: t('common.delete'), method: 'delete', confirm: true, type: 'danger' }
+]
+const buttonFields = [{ label: t('common.add'), method: 'add' }]
+let skip = 0
+let limit = inject('limit')
+const total = ref(20)
+const isUseList = ref([])
+// 加载中
+const loading = ref(false)
+const formFields = [
+  { label: '字典名称', model: 'title' },
+  { label: '编码', model: 'code' },
+  { label: '是否启用', model: 'is_use', type: 'radio' },
+  { label: '备注', model: 'remark', type: 'textarea' }
+]
+const rules = reactive({ title: [{ required: true, message: '请输入字典名称', trigger: 'blur' }], code: [{ required: true, message: '请输入编码', trigger: 'blur' }] })
+const dialog = ref({ type: '1', show: false, title: '字典类型', top: '15vh' })
+const form = ref({})
+// 请求
+onMounted(async () => {
+  loading.value = true
+  await searchOther()
+  await search({ skip, limit })
+  loading.value = false
+})
+
+const searchOther = async () => {
+  const result = await dictDataStore.query({ code: 'isUse', is_use: '0' })
+  if ($checkRes(result)) isUseList.value = result.data
+}
+const search = async (query = { skip: 0, limit }) => {
+  const info = { skip: query.skip, limit: query.limit, ...searchForm.value }
+  const res = await store.query(info)
+  if (res.errcode == '0') {
+    data.value = res.data.data
+    total.value = res.data.total
+  }
+}
+// 字典数据转换
+const getDict = (data) => {
+  const res = isUseList.value.find((f) => f.value == data)
+  return get(res, 'label')
+}
+// 添加
+const toAdd = () => {
+  dialog.value = { type: '1', show: true, title: '新增字典类型', top: '15vh' }
+}
+// 字典数据
+const toDict = (data) => {
+  form.value = data
+  dialog.value = { type: '2', show: true, title: `【${data.title}】 ` + '字典数据', top: '5vh' }
+}
+// 修改
+const toEdit = (data) => {
+  form.value = data
+  dialog.value = { type: '1', show: true, title:'修改字典类型', top: '15vh' }
+}
+// 删除
+const toDelete = async (data) => {
+  const res = await store.del(data._id)
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+  }
+}
+const toSave = async () => {
+  const data = cloneDeep(form.value)
+  let res
+  if (get(data, '_id')) res = await store.update(data)
+  else res = await store.create(data)
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+    toClose()
+  }
+}
+// 重置
+const toReset = async () => {
+  searchForm.value = {}
+  await search({ skip, limit })
+}
+const toClose = () => {
+  form.value = {}
+  dialog.value = { show: false }
+}
+// provide
+provide('isUseList', isUseList)
+provide('codeInfo', form)
+</script>
+<style scoped lang="scss"></style>

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

@@ -0,0 +1,118 @@
+<template>
+  <div class="main animate__animated animate__backInRight" v-loading="loading">
+    <custom-search-bar :fields="fields.filter((f) => f.isSearch)" v-model="searchForm" @search="search"
+      @reset="toReset"></custom-search-bar>
+    <custom-button-bar :fields="buttonFields" @add="toAdd"></custom-button-bar>
+    <custom-table :data="data" :fields="fields" @query="search" :total="total" :opera="opera" @edit="toEdit"
+      @delete="toDelete">
+      <template #is_use="{ row }">
+        <el-tag v-if="row.is_use == '0'" type="success">启用</el-tag>
+        <el-tag v-else type="info">禁用</el-tag>
+      </template>
+    </custom-table>
+    <el-dialog v-model="dialog" :title="$t('pages.dictData.dialogTitle')" :destroy-on-close="false" @close="toClose"
+      width="30%">
+      <custom-form v-model="form" :fields="formFields" :rules="rules" @save="toSave">
+        <template #is_use>
+          <el-radio v-for="i in isUseList" :key="i._id" :label="i.value">{{ i.label }}</el-radio>
+        </template>
+      </custom-form>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+const $checkRes = inject('$checkRes')
+import { cloneDeep, get } from 'lodash-es'
+const { t } = useI18n()
+// 接口
+import { DictDataStore } from '@/store/api/system/dictData'
+const store = DictDataStore()
+const data = ref([])
+const searchForm = ref({})
+const fields = [
+  { label: '数据显示值', model: 'label', isSearch: true },
+  { label: '数据选择值', model: 'value' },
+  { label: '排序', model: 'sort' },
+  { label: '是否启用', model: 'is_use', format: (i) => getDict(i), custom: true }
+]
+const opera = [
+  { label: t('common.update'), method: 'edit' },
+  { label: t('common.delete'), method: 'delete', confirm: true, type: 'danger' }
+]
+const buttonFields = [{ label: t('common.add'), method: 'add' }]
+let skip = 0
+let limit = inject('limit')
+const total = ref(20)
+const isUseList = inject('isUseList')
+const codeInfo = inject('codeInfo')
+// 加载中
+const loading = ref(false)
+const formFields = [
+  { label: '数据显示值', model: 'label' },
+  { label: '数据选择值', model: 'value' },
+  { label: '排序', model: 'sort', type: 'number' },
+  { label: '是否启用', model: 'is_use', type: 'radio' }
+]
+const rules = reactive({
+  label: [{ required: true, message: '请输入数据显示值', trigger: 'blur' }],
+  value: [{ required: true, message: '请输入数据选择值', trigger: 'blur' }]
+})
+const dialog = ref(false)
+const form = ref({})
+// 请求
+onMounted(async () => {
+  loading.value = true
+  await search({ skip, limit })
+  loading.value = false
+})
+const search = async (query = { skip: 0, limit }) => {
+  const info = { skip: query.skip, limit: query.limit, ...searchForm.value, code: codeInfo.value.code }
+  const res = await store.query(info)
+  if (res.errcode == '0') {
+    data.value = res.data
+    total.value = res.total
+  }
+}
+// 字典数据转换
+const getDict = (data) => {
+  const res = isUseList.value.find((f) => f.value == data)
+  return get(res, 'label')
+}
+// 添加
+const toAdd = () => {
+  dialog.value = true
+}
+// 修改
+const toEdit = (data) => {
+  form.value = data
+  dialog.value = true
+}
+// 删除
+const toDelete = async (data) => {
+  const res = await store.del(data._id)
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+  }
+}
+const toSave = async () => {
+  const data = cloneDeep(form.value)
+  let res
+  if (get(data, '_id')) res = await store.update(data)
+  else res = await store.create({ ...data, code: codeInfo.value.code })
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+    toClose()
+  }
+}
+// 重置
+const toReset = async () => {
+  searchForm.value = {}
+  await search({ skip, limit })
+}
+const toClose = () => {
+  form.value = {}
+  dialog.value = false
+}
+</script>
+<style scoped lang="scss"></style>

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

@@ -0,0 +1,109 @@
+<template>
+  <div class="main animate__animated animate__backInRight">
+    <el-row>
+      <el-col :span="24" style="text-align: right; padding: 10px">
+        <el-button type="primary" size="small" @click="toAdd()">{{ $t('common.add') }}</el-button>
+      </el-col>
+      <el-col :span="24">
+        <menu-table></menu-table>
+      </el-col>
+    </el-row>
+    <el-dialog v-model="dialog" title="目录信息" :destroy-on-close="false" @close="toClose">
+      <menu-info></menu-info>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import menuTable from './parts/menu-table.vue'
+import menuInfo from './parts/menu-info.vue'
+import { cloneDeep, get, omit } from 'lodash-es'
+import { MenusStore } from '@/store/api/system/menus'
+import { DictDataStore } from '@/store/api/system/dictData'
+import { useI18n } from 'vue-i18n'
+const { t } = useI18n()
+const store = MenusStore()
+const dictDataStore = DictDataStore()
+const $checkRes = inject('$checkRes')
+const dialog = ref(false)
+const list = ref([])
+const form = ref({})
+const typeList = [
+  { label: '目录', value: '0' },
+  { label: '菜单', value: '1' },
+  { label: '子页面', value: '2' }
+]
+const iconList = ref([])
+onMounted(async () => {
+  await searchOther()
+  await search()
+})
+const searchOther = async () => {
+  const result = await dictDataStore.query({ code: 'icon', is_use: '0' })
+  if ($checkRes(result)) iconList.value = result.data
+}
+// #region 接口函数
+const search = async () => {
+  const res = await store.query()
+  if ($checkRes(res)) {
+    list.value = res.data
+  }
+}
+const toSave = async () => {
+  const data = cloneDeep(omit(form.value, ['children', 'parent_name']))
+  let res
+  if (get(data, '_id')) {
+    res = await store.update(data)
+  } else res = await store.create(data)
+  if ($checkRes(res, true)) {
+    search()
+    toClose()
+  }
+}
+const toDelete = async (row) => {
+  ElMessageBox.confirm(t('common.delete_confirm'), t('common.warning'), {
+    confirmButtonText: t('common.confirm'),
+    cancelButtonText: t('common.cancel'),
+    type: 'warning'
+  }).then(async () => {
+    const res = await store.del(row._id)
+    if ($checkRes(res, true)) {
+      search()
+    }
+  })
+}
+// #endregion
+
+// #region 工具函数
+const toAddNext = (row) => {
+  const obj = { parent_id: row._id, is_use: '0' }
+  form.value = obj
+  dialog.value = true
+}
+const toUpdate = (row) => {
+  form.value = cloneDeep(row)
+  dialog.value = true
+}
+
+const toAdd = () => {
+  dialog.value = true
+  form.value = { is_use: '0' }
+}
+const toClose = () => {
+  form.value = {}
+  dialog.value = false
+}
+// #endregion
+
+// provide
+provide('menuTree', list)
+provide('form', form)
+provide('typeList', typeList)
+provide('toUpdate', toUpdate)
+provide('toAddNext', toAddNext)
+provide('toDelete', toDelete)
+provide('toSave', toSave)
+provide('iconList', iconList)
+</script>
+
+<style scoped></style>

+ 27 - 0
src/views/system/menus/parts/menu-info.vue

@@ -0,0 +1,27 @@
+<template>
+  <el-tabs v-model="tab" type="card">
+    <el-tab-pane label="基本信息" name="basic">
+      <info></info>
+    </el-tab-pane>
+
+    <el-tab-pane label="功能列表" name="config" v-if="form.type && form.type !== '0'">
+      <!-- 不在功能列表的功能不能被使用 -->
+      <func></func>
+    </el-tab-pane>
+  </el-tabs>
+  <el-row type="flex" justify="space-around" style="margin-top: 10px">
+    <el-col :span="6" style="text-align: center">
+      <el-button @click="toSave" size="small" type="primary">{{ $t('common.save') }}</el-button>
+    </el-col>
+  </el-row>
+</template>
+
+<script setup>
+import info from './parts/info.vue'
+import func from './parts/func.vue'
+const form = inject('form')
+const tab = ref('basic')
+const toSave = inject('toSave')
+</script>
+
+<style scoped></style>

+ 69 - 0
src/views/system/menus/parts/menu-table.vue

@@ -0,0 +1,69 @@
+<template>
+  <div id="menu-table">
+    <el-table :data="data" row-key="_id" border>
+      <el-table-column align="center" label="顺序" sortable prop="order_num" width="80"></el-table-column>
+      <el-table-column align="center" label="图标" width="80">
+        <template #default="{ row }"><span :class="['iconfont', row.icon]"></span></template>
+      </el-table-column>
+      <el-table-column align="center" label="菜单名称" prop="name"></el-table-column>
+      <el-table-column align="center" label="路由名称" prop="route_name"></el-table-column>
+      <el-table-column align="center" label="父级菜单" prop="parent_name"></el-table-column>
+
+      <el-table-column align="center" label="路由地址" prop="path"></el-table-column>
+      <el-table-column align="center" label="组件地址" prop="component"></el-table-column>
+      <el-table-column align="center" label="菜单类型" prop="type" width="100">
+        <template #default="{ row }">{{ getType(row) }} </template>
+      </el-table-column>
+      <el-table-column align="center" label="状态" prop="is_use" width="80">
+        <template #default="{ row }">
+          <el-tag v-if="row.is_use === '0'" type="success"> {{ getStatus(row) }} </el-tag>
+          <el-tag v-else type="info"> {{ getStatus(row) }} </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="备注" prop="remark" width="100"> </el-table-column>
+      <el-table-column align="center" :label="$t('common.opera')">
+        <template #default="{ row }">
+          <el-link :underline="false" type="primary" size="mini" @click="toUpdate(row)" style="margin-right: 10px">{{
+            $t('common.update') }}</el-link>
+          <el-link :underline="false" type="primary" size="mini" @click="toAddNext(row)" style="margin-right: 10px">
+            添加下一级
+          </el-link>
+          <el-link v-if="row.is_default !== '0'" :underline="false" type="danger" size="mini" @click="toDelete(row)">
+            {{ $t('common.delete') }}
+          </el-link>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script setup>
+const data = inject('menuTree', [])
+const typeList = inject('typeList', [])
+const toUpdate = inject('toUpdate')
+const toAddNext = inject('toAddNext')
+const toDelete = inject('toDelete')
+const { t } = useI18n()
+const getType = (row) => {
+  let word = ''
+  const r = typeList.find((f) => f.value === row.type)
+  if (r) word = r.label
+  return word
+}
+const getStatus = (row) => {
+  let word = ''
+  switch (row.is_use) {
+    case '0':
+      word = t('common.is_use_abled')
+      break
+    case '1':
+      word = t('common.is_use_disabled')
+      break
+
+    default:
+      break
+  }
+  return word
+}
+</script>
+<style scoped></style>

+ 38 - 0
src/views/system/menus/parts/parts/func.vue

@@ -0,0 +1,38 @@
+<template>
+  <el-row>
+    <el-col :span="24" style="text-align: right; margin: 10px 0">
+      <el-button size="mini" type="primary" @click="toAddConfig()">添加功能</el-button>
+    </el-col>
+    <el-col :span="24">
+      <el-table :data="form.config">
+        <el-table-column align="center" label="功能说明">
+          <template #default="{ row }">
+            <el-input v-model="row.zh"></el-input>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="功能编码">
+          <template #default="{ row }">
+            <el-input v-model="row.code"></el-input>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" :label="$t('common.opera')">
+          <template #default="{ $index }">
+            <el-button size="mini" type="danger" @click="deleteConfig($index)">{{ $t('common.delete') }}</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-col>
+  </el-row>
+</template>
+
+<script setup>
+const form = inject('form')
+const toAddConfig = () => {
+  if (!form.value.config) form.value.config = []
+  form.value.config.push({})
+}
+const deleteConfig = (index) => {
+  form.value.config.splice(index, 1)
+}
+</script>
+<style scoped></style>

+ 98 - 0
src/views/system/menus/parts/parts/info.vue

@@ -0,0 +1,98 @@
+<template>
+  <el-form label-position="left" label-width="100px">
+    <!-- 开发模式下显示该字段.即非开发模式下.所有的菜单都是非默认的 -->
+    <el-form-item v-if="is_dev" label="是否默认">
+      <el-radio-group v-model="form.is_default">
+        <el-radio label="0">{{ $t('common.yes') }}</el-radio>
+        <el-radio label="1">{{ $t('common.no') }}</el-radio>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item label="菜单名称">
+      <el-input v-model="form.name" placeholder="请填写菜单名称"></el-input>
+    </el-form-item>
+    <el-form-item label="路由名称">
+      <el-input v-model="form.route_name" placeholder="请填写路由名称"></el-input>
+    </el-form-item>
+    <el-form-item label="菜单类型">
+      <el-select v-model="form.type" placeholder="请选择菜单类型">
+        <el-option v-for="(i, index) in typeList" :key="`t${index}`" :label="i.label" :value="i.value"></el-option>
+      </el-select>
+    </el-form-item>
+    <el-form-item label="父级菜单">
+      <el-select v-model="form.parent_id" placeholder="" :disabled="true">
+        <el-option v-for="(i, index) in getOneDimensionList()" :key="`m${index}`" :label="i.name" :value="i._id"></el-option>
+      </el-select>
+    </el-form-item>
+    <template v-if="form.type === '1' || form.type === '2'">
+      <el-form-item label="路由地址">
+        <el-input v-model="form.path" placeholder="请填写路由地址"></el-input>
+      </el-form-item>
+      <el-form-item label="组件地址">
+        <el-input v-model="form.component" placeholder="请填写组件地址"></el-input>
+      </el-form-item>
+    </template>
+    <el-form-item label="顺序">
+      <el-input-number v-model="form.order_num"></el-input-number>
+    </el-form-item>
+    <el-form-item label="图标">
+      <el-select v-model="form.icon" clearable filterable placeholder="请选择图标">
+        <el-option v-for="item in iconList" :key="item.label" :label="item.label" :value="item.label">
+          <div>
+            <component class="icon" :is="item.label"></component>
+            <span style="font-size: 12px">{{ item.label }}</span>
+          </div>
+        </el-option>
+      </el-select>
+    </el-form-item>
+    <el-form-item label="状态">
+      <el-radio-group v-model="form.is_use">
+        <el-radio label="0">{{ $t('common.is_use_abled') }}</el-radio>
+        <el-radio label="1">{{ $t('common.is_use_disabled') }}</el-radio>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item label="备注">
+      <el-input v-model="form.remark" placeholder="请输入备注" type="textarea" :autosize="{ minRows: 5, maxRows: 5 }"></el-input>
+    </el-form-item>
+  </el-form>
+</template>
+
+<script setup>
+import { cloneDeep } from 'lodash-es'
+const menuTree = inject('menuTree')
+const typeList = inject('typeList')
+const form = inject('form')
+const iconList = inject('iconList')
+const is_dev = import.meta.env.MODE === 'development'
+
+// #region 整理数组
+const getOneDimensionList = () => {
+  let dup = cloneDeep(menuTree.value)
+  let arr = getAllChild(dup)
+  return arr
+}
+
+const getAllChild = (children) => {
+  let arr = []
+  for (const i of children) {
+    const { children, ...others } = i
+    arr.push(others)
+    if (children) {
+      const marr = getAllChild(children)
+      arr.push(...marr)
+    }
+  }
+  return arr
+}
+// #endregion
+</script>
+<style scoped lang="scss">
+.icon {
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  overflow: hidden;
+  vertical-align: -0.15em;
+  outline: none;
+  margin-right: 5px;
+}
+</style>

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

@@ -0,0 +1,132 @@
+<template>
+  <div class="main animate__animated animate__backInRight" v-loading="loading">
+    <el-row style="padding: 5px">
+      <el-col :span="24" style="text-align: right">
+        <el-button type="primary" @click="toAdd" v-method="'add'">{{ $t('common.add') }}</el-button>
+      </el-col>
+    </el-row>
+    <el-row>
+      <el-col :span="24">
+        <role-table></role-table>
+      </el-col>
+    </el-row>
+    <el-row justify="end" style="margin-top: 10px; height: 5vh">
+      <el-pagination background layout="total, prev, pager, next" :page-size="limit" :total="total" v-model:current-page="currentPage" @current-change="changePage" />
+    </el-row>
+    <el-dialog v-model="dialog" :title="$t('pages.role.dialogTitle')" :destroy-on-close="true" @close="toClose">
+      <role-form></role-form>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import roleTable from './parts/table.vue'
+import roleForm from './parts/form.vue'
+import { RoleStore } from '@/store/api/system/role'
+import { MenusStore } from '@/store/api/system/menus'
+import { cloneDeep, get } from 'lodash-es'
+const store = RoleStore()
+const menuStore = MenusStore()
+
+const $checkRes = inject('$checkRes')
+const limit = inject('limit')
+const currentPage = ref(1)
+onMounted(() => {
+  searchMenus()
+  search({ skip: 0, limit })
+})
+const menuList = ref([])
+const searchMenus = async () => {
+  const res = await menuStore.query()
+  if ($checkRes(res)) {
+    menuList.value = res.data
+  }
+}
+
+const data = ref([])
+const total = ref(0)
+const search = async (e = { skip: 0, limit }) => {
+  const { skip, limit } = e
+  let info = { limit: limit, skip: skip }
+  let res = await store.query(info)
+  if ($checkRes(res)) {
+    data.value = res.data.data
+    total.value = res.data.total
+  }
+}
+
+const form = ref({})
+const dialog = ref(false)
+
+const toEdit = async (data) => {
+  let res = await store.fetch(data._id)
+  if ($checkRes(res)) {
+    form.value = res.data.data
+    dialog.value = true
+  }
+}
+const toDelete = async (data) => {
+  ElMessageBox.confirm('您确定删除该数据?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(async () => {
+    const res = await store.del(data._id)
+    if ($checkRes(res, true)) {
+      search({ skip: 0, limit })
+    }
+  })
+}
+const onSubmit = async () => {
+  const data = cloneDeep(form.value)
+  console.log(data)
+  let res
+  if (get(data, '_id')) res = await store.update(data)
+  else res = await store.create(data)
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+    toClose()
+  }
+}
+const changeUse = async (data) => {
+  let is_use = '1'
+  if (data.is_use === '1') is_use = '0'
+  const res = await store.update({ _id: data._id, is_use })
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+  }
+}
+const toAdd = () => {
+  dialog.value = true
+  form.value = { is_use: '0', menu: ['home'] }
+}
+const toClose = () => {
+  dialog.value = false
+  form.value = { menu: {} }
+}
+// 加载中
+const loading = ref(false)
+// 请求
+onMounted(async () => {
+  loading.value = true
+  loading.value = false
+})
+
+const changePage = (page = currentPage.value) => {
+  const obj = { skip: (page - 1) * limit, limit: limit }
+  search(obj)
+}
+
+// provide
+provide('data', data)
+provide('total', total)
+provide('menuList', menuList)
+provide('form', form)
+provide('dialog', dialog)
+provide('toAdd', toAdd)
+provide('toEdit', toEdit)
+provide('onSubmit', onSubmit)
+provide('changeUse', changeUse)
+provide('toDelete', toDelete)
+</script>
+<style scoped lang="scss"></style>

+ 105 - 0
src/views/system/role/parts/form.vue

@@ -0,0 +1,105 @@
+<template>
+  <el-form label-position="left" label-width="100px">
+    <el-form-item label="角色名称">
+      <el-input v-model="form.name" placeholder="请填写角色名称"></el-input>
+    </el-form-item>
+    <el-form-item label="角色代码">
+      <el-input v-model="form.code" placeholder="请填写角色代码"></el-input>
+    </el-form-item>
+    <el-form-item label="是否启用">
+      <el-radio-group v-model="form.is_use">
+        <el-radio label="0">{{ $t('common.is_use_abled') }}</el-radio>
+        <el-radio label="1">{{ $t('common.is_use_disabled') }}</el-radio>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item label="简介">
+      <el-input v-model="form.brief" type="textarea" :rows="3"></el-input>
+    </el-form-item>
+    <el-form-item label="权限配置">
+      <el-tree ref="roleTree" :data="getMenus()" node-key="code" default-expand-all show-checkbox @check-change="seletNode">
+        <template #default="{ data }">
+          <span>{{ data.name }}</span>
+        </template>
+      </el-tree>
+    </el-form-item>
+    <el-row justify="center" style="text-align: center">
+      <el-col :span="6">
+        <el-button @click="onSubmit" type="primary">{{ $t('common.save') }}</el-button>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+
+<script setup>
+const form = inject('form')
+const menuList = inject('menuList')
+const onSubmit = inject('onSubmit')
+const roleTree = ref()
+const getMenus = () => {
+  const list = toRaw(menuList.value)
+  const result = dealMenu(list)
+  return result
+}
+onMounted(() => {
+  const treeSelected = toRaw(form.value.menu)
+  for (const key of treeSelected) {
+    roleTree.value.setChecked(key, true)
+  }
+  console.log('init')
+})
+/**
+ * 处理菜单:将目录,菜单,子页面,权限统一转成 {name,code}形式,code为唯一值
+ * 目录,菜单,子页面:
+ *  route_name是唯一的,所以使用route_name作为code
+ * 权限:
+ *  code并不是唯一,但是加上route_name就是唯一的
+ * @param {Array} list 菜单列表
+ * @param {Array} route_names 将上级的route_name放入此数组中,在转为权限数据时,拼接成code
+ */
+const dealMenu = (list, route_names = []) => {
+  const result = []
+  for (const i of list) {
+    const { name, config = [], children = [], route_name } = i
+    const thisRouteNameArr = [...route_names, route_name]
+    const obj = { name, code: thisRouteNameArr.join('.') }
+    if (route_name === 'home') obj.disabled = true
+    const nextList = []
+    // 先处理该页面配置
+    if (config.length >= 0) {
+      // 如果有配置: 菜单,子页面两种情况,都统一处理
+      for (const c of config) {
+        const codeRouteNameArr = [...thisRouteNameArr]
+        codeRouteNameArr.push(c.code)
+        const cobj = { name: c.zh, code: codeRouteNameArr.join('.') }
+        nextList.push(cobj)
+      }
+    }
+    // 再处理菜单/子页面
+    if (children.length > 0) {
+      const nextRouteNames = [...thisRouteNameArr]
+      const midResult = dealMenu(children, nextRouteNames)
+      nextList.push(...midResult)
+    }
+    obj.children = nextList
+    result.push(obj)
+  }
+  return result
+}
+
+/**
+ * 选择节点
+ */
+const seletNode = () => {
+  if (!form.value.menu) {
+    form.value.menu = []
+  }
+  const selected = roleTree.value.getCheckedNodes(false, true)
+  const result = []
+  for (const s of selected) {
+    const rs = toRaw(s)
+    result.push(rs.code)
+  }
+  form.value.menu = result
+}
+</script>
+<style scoped></style>

+ 50 - 0
src/views/system/role/parts/table.vue

@@ -0,0 +1,50 @@
+<template>
+  <el-table :data="data" row-key="_id" border height="70vh">
+    <el-table-column align="center" label="角色名称" prop="name"></el-table-column>
+    <el-table-column align="center" label="角色代码" prop="code"></el-table-column>
+    <el-table-column align="center" label="是否启用" prop="is_use">
+      <template #default="{ row }">
+        <el-tag v-if="row.is_use === '0'" type="success"> {{ getStatus(row) }} </el-tag>
+        <el-tag v-else type="info"> {{ getStatus(row) }} </el-tag>
+      </template>
+    </el-table-column>
+    <el-table-column align="center" :label="$t('common.opera')">
+      <template #default="{ row }">
+        <el-link v-method="`update`" :underline="false" type="primary" size="mini" @click="toEdit(row)" style="margin-right: 10px">{{ $t('common.update') }}</el-link>
+        <el-link v-method="`toAbled`" :underline="false" v-if="row.is_use === '1'" type="success" size="mini" @click="changeUse(row)" style="margin-right: 10px">
+          {{ $t('common.is_use_abled') }}
+        </el-link>
+        <el-link v-method="`toDisabled`" :underline="false" v-if="row.is_use === '0'" type="warning" size="mini" @click="changeUse(row)" style="margin-right: 10px">
+          {{ $t('common.is_use_disabled') }}
+        </el-link>
+        <el-link v-method="`delete`" v-if="row.is_default !== '0'" :underline="false" type="danger" size="mini" @click="toDelete(row)">
+          {{ $t('common.delete') }}
+        </el-link>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+
+<script setup>
+const data = inject('data')
+const toEdit = inject('toEdit')
+const toDelete = inject('toDelete')
+const changeUse = inject('changeUse')
+const { t } = useI18n()
+const getStatus = (row) => {
+  let word = ''
+  switch (row.is_use) {
+    case '0':
+      word = t('common.is_use_abled')
+      break
+    case '1':
+      word = t('common.is_use_disabled')
+      break
+
+    default:
+      break
+  }
+  return word
+}
+</script>
+<style scoped></style>

+ 9 - 0
src/views/test/index.vue

@@ -0,0 +1,9 @@
+<template>
+  <div id="index">
+    <p><router-link to="/">返回</router-link></p>
+  </div>
+</template>
+
+<script setup></script>
+
+<style scoped></style>

+ 183 - 0
src/views/user/admin/index.vue

@@ -0,0 +1,183 @@
+<template>
+  <div class="main animate__animated animate__backInRight">
+    <custom-search-bar v-model="searchForm" :fields="fields.filter((f) => f.filter)" @search="search"
+      @reset="toReset"></custom-search-bar>
+    <custom-button-bar :fields="buttonFields" @add="toAdd"></custom-button-bar>
+    <custom-table :data="data" :fields="fields" @search="search" :total="total" :opera="opera" @edit="toEdit"
+      @changeUse="toChangeUse" @delete="toDelete" @rp="toResetPwd">
+      <template #is_use="{ row }">
+        <el-tag v-if="row.is_use == '0'" type="success">启用</el-tag>
+        <el-tag v-else type="info">禁用</el-tag>
+      </template>
+    </custom-table>
+    <el-dialog v-model="dialog" title="用户信息" :destroy-on-close="false" @close="toClose">
+      <custom-form v-model="form" :fields="formFields" @save="toSave">
+        <template #is_use>
+          <el-radio v-for="i in isUseList" :key="i._id" :label="i.value">{{ i.label }}</el-radio>
+        </template>
+        <template #role>
+          <el-option v-for="i in roleList" :key="i._id" :label="i.name" :value="i.code"></el-option>
+        </template>
+      </custom-form>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { AdminStore } from '@/store/api/user/admin'
+import { LoginStore } from '@/store/api/login'
+import { RoleStore } from '@/store/api/system/role'
+import { DictDataStore } from '@/store/api/system/dictData'
+import { cloneDeep, get } from 'lodash-es'
+const $checkRes = inject('$checkRes')
+const store = AdminStore()
+const loginStore = LoginStore()
+const dictDataStore = DictDataStore()
+const roleStore = RoleStore()
+const { t } = useI18n()
+const loading = ref(false)
+let skip = 0
+let limit = inject('limit')
+const data = ref([])
+const total = ref(0)
+onMounted(async () => {
+  loading.value = true
+  await searchOther()
+  await search({ skip, limit })
+  loading.value = false
+})
+
+const fields = [
+  { label: '账号', model: 'account', filter: true },
+  { label: '名称', model: 'nick_name' },
+  { label: '角色', model: 'role', format: (i) => getRole(i) },
+  { label: '是否是超级管理员', model: 'is_super', format: (i) => (i === '0' ? t('common.yes') : t('common.no')) },
+  { label: '是否启用', model: 'is_use', format: (i) => getDict(i), custom: true }
+]
+const opera = [
+  { label: t('common.update'), method: 'edit', display: (i) => i.is_super !== '0' },
+  // { label: t('pages.admin.bind'), method: 'bind' },
+  { label: '重置密码', method: 'rp', type: 'warning', confirm: true, confirmWord: t('pages.admin.rpConfirm') },
+  {
+    label: t('common.is_use_disabled'),
+    method: 'changeUse',
+    type: 'warning',
+    confirm: true,
+    confirmWord: '您确定要禁用该用户?',
+    display: (i) => i.is_use === '0' && i.is_super !== '0'
+  },
+  {
+    label: t('common.is_use_abled'),
+    method: 'changeUse',
+    type: 'success',
+    confirm: true,
+    confirmWord: '您确定要启用该用户?',
+    display: (i) => i.is_use === '1' && i.is_super !== '0'
+  },
+  { label: t('common.delete'), method: 'delete', confirm: true, type: 'danger', display: (i) => i.is_super !== '0' }
+]
+const buttonFields = [{ label: t('common.add'), method: 'add' }]
+const searchForm = ref({})
+const search = async (query = { skip: 0, limit }) => {
+  const info = { skip: query.skip, limit: query.limit, ...searchForm.value }
+  const res = await store.query(info)
+  if (res.errcode == '0') {
+    data.value = res.data.data
+    total.value = res.data.total
+  }
+}
+const isUseList = ref([])
+const roleList = ref([])
+const searchOther = async () => {
+  const result = await dictDataStore.query({ code: 'isUse', is_use: '0' })
+  if ($checkRes(result)) {
+    isUseList.value = result.data.data
+  }
+  const roleResult = await roleStore.query()
+  if ($checkRes(roleResult)) {
+    roleList.value = roleResult.data.data
+  }
+}
+
+const toDelete = async (data) => {
+  const res = await store.del(data._id)
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+  }
+}
+const userType = 'Admin'
+const toResetPwd = async (data) => {
+  const res = await loginStore.rpNoNewPassword({ type: userType, _id: data._id })
+  if ($checkRes(res, true)) {
+    ElMessageBox.confirm(`新密码为:${res.data}`, '请确认', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    })
+  }
+}
+const toChangeUse = async (data) => {
+  const udata = { _id: data._id, is_use: data.is_use === '0' ? '1' : '0' }
+  const res = await store.update(udata)
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+  }
+}
+const dialog = ref(false)
+const form = ref({})
+const defaultForm = { is_use: '0' }
+
+const formFields = ref([])
+const formFieldsForCreate = [
+  { label: '账号', model: 'account' },
+  { label: '名称', model: 'nick_name' },
+  { label: '密码', model: 'password', type: 'password' },
+  { label: '是否启用', model: 'is_use', type: 'radio' }
+]
+const formFieldsForUpdate = [
+  { label: '账号', model: 'account' },
+  { label: '名称', model: 'nick_name' },
+  { label: '是否启用', model: 'is_use', type: 'radio' }
+]
+const roleField = { label: '角色', model: 'role', type: 'select' }
+const toAdd = () => {
+  formFields.value = formFieldsForCreate
+  formFields.value.push(roleField)
+  form.value = cloneDeep(defaultForm)
+  dialog.value = true
+}
+const toEdit = (data) => {
+  formFields.value = cloneDeep(formFieldsForUpdate)
+  form.value = data
+  if (data.is_super !== '0') formFields.value.push(roleField)
+  dialog.value = true
+}
+const toSave = async () => {
+  const data = cloneDeep(form.value)
+  let res
+  if (get(data, '_id')) res = await store.update(data)
+  else res = await store.create(data)
+  if ($checkRes(res, true)) {
+    search({ skip: 0, limit })
+    toClose()
+  }
+}
+const getRole = (data) => {
+  const res = roleList.value.find((f) => f.code === data)
+  return get(res, 'name')
+}
+const getDict = (data) => {
+  const res = isUseList.value.find((f) => f.value == data)
+  return get(res, 'label')
+}
+const toClose = () => {
+  form.value = {}
+  dialog.value = false
+}
+// 重置
+const toReset = async () => {
+  searchForm.value = {}
+  await search({ skip, limit })
+}
+</script>
+<style scoped></style>

+ 20 - 0
src/views/user/user/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>

+ 166 - 0
vite.config.js

@@ -0,0 +1,166 @@
+import AutoImport from 'unplugin-auto-import/vite'
+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'
+import path from 'path'
+// eslint-disable-next-line no-undef
+const pathSrc = path.resolve(__dirname, 'src')
+// https://vitejs.dev/config/
+export default defineConfig(({ mode }) => {
+  // eslint-disable-next-line no-undef
+  const env = loadEnv(mode, process.cwd())
+  return {
+    server: {
+      // 允许IP访问
+      host: '0.0.0.0',
+      // 应用端口 (默认:3000)
+      port: Number(env.VITE_APP_PORT),
+      // 运行是否自动打开浏览器
+      open: true,
+      proxy: {
+        '/files': {
+          target: 'http://192.168.1.197', // https://broadcast.waityou24.cn
+          changeOrigin: true
+        },
+        /**
+         * env.VITE_APP_BASE_API: /dev-api
+         */
+        [env.VITE_APP_BASE_API]: {
+          changeOrigin: true,
+          target: 'http://192.168.1.197:9700'
+        }
+      }
+    },
+    resolve: {
+      alias: {
+        '@': pathSrc
+      }
+    },
+    css: {
+      // CSS 预处理器
+      preprocessorOptions: {
+        // 定义全局 SCSS 变量
+        scss: {
+          javascriptEnabled: true
+          // additionalData: `
+          //   @use "@/styles/variables.scss" as *;
+          // `
+        }
+      }
+    },
+    plugins: [
+      vue(),
+      // // 自动导入参考: https://github.com/sxzz/element-plus-best-practices/blob/main/vite.config.ts
+      AutoImport({
+        // 自动导入 Vue 相关函数,如:ref, reactive, toRef 等
+        imports: ['vue', '@vueuse/core', 'pinia', 'vue-router', 'vue-i18n'],
+        // 自动导入 Element Plus 相关函数,如:ElMessage, ElMessageBox... (带样式)
+        resolvers: [ElementPlusResolver(), IconsResolver({})],
+        eslintrc: {
+          enabled: false,
+          filepath: './.eslintrc-auto-import.json',
+          globalsPropValue: true
+        },
+        vueTemplate: true
+        // 配置文件生成位置(false:关闭自动生成)
+        //dts: false,
+        // dts: path.resolve(pathSrc, 'auto-imports.d.ts')
+      }),
+      Components({
+        resolvers: [
+          // 自动导入 Element Plus 组件
+          ElementPlusResolver(),
+          // 自动注册图标组件
+          IconsResolver({ enabledCollections: ['ep'] })
+        ],
+        // 指定自定义组件位置(默认:src/components)
+        dirs: ['src/components', 'src/**/components']
+        // 配置文件位置 (false:关闭自动生成)
+        //dts: false,
+        // dts: path.resolve(pathSrc, 'components.d.ts')
+      }),
+      Icons({
+        autoInstall: true
+      }),
+      createSvgIconsPlugin({
+        // 指定需要缓存的图标文件夹
+        iconDirs: [path.resolve(pathSrc, 'assets/icons')],
+        // 指定symbolId格式
+        symbolId: 'icon-[dir]-[name]'
+      }),
+      Inspect()
+    ],
+    // 预加载项目必需的组件
+    optimizeDeps: {
+      include: [
+        'vue',
+        'vue-router',
+        'pinia',
+        'axios',
+        'element-plus/es/components/form/style/css',
+        'element-plus/es/components/form-item/style/css',
+        'element-plus/es/components/button/style/css',
+        'element-plus/es/components/input/style/css',
+        'element-plus/es/components/input-number/style/css',
+        'element-plus/es/components/switch/style/css',
+        'element-plus/es/components/upload/style/css',
+        'element-plus/es/components/menu/style/css',
+        'element-plus/es/components/col/style/css',
+        'element-plus/es/components/icon/style/css',
+        'element-plus/es/components/row/style/css',
+        'element-plus/es/components/tag/style/css',
+        'element-plus/es/components/dialog/style/css',
+        'element-plus/es/components/loading/style/css',
+        'element-plus/es/components/radio/style/css',
+        'element-plus/es/components/radio-group/style/css',
+        'element-plus/es/components/popover/style/css',
+        'element-plus/es/components/scrollbar/style/css',
+        'element-plus/es/components/tooltip/style/css',
+        'element-plus/es/components/dropdown/style/css',
+        'element-plus/es/components/dropdown-menu/style/css',
+        'element-plus/es/components/dropdown-item/style/css',
+        'element-plus/es/components/sub-menu/style/css',
+        'element-plus/es/components/menu-item/style/css',
+        'element-plus/es/components/divider/style/css',
+        'element-plus/es/components/card/style/css',
+        'element-plus/es/components/link/style/css',
+        'element-plus/es/components/breadcrumb/style/css',
+        'element-plus/es/components/breadcrumb-item/style/css',
+        'element-plus/es/components/table/style/css',
+        'element-plus/es/components/tree-select/style/css',
+        'element-plus/es/components/table-column/style/css',
+        'element-plus/es/components/select/style/css',
+        'element-plus/es/components/option/style/css',
+        'element-plus/es/components/pagination/style/css',
+        'element-plus/es/components/tree/style/css',
+        'element-plus/es/components/alert/style/css',
+        'element-plus/es/components/radio-button/style/css',
+        'element-plus/es/components/checkbox-group/style/css',
+        'element-plus/es/components/checkbox/style/css',
+        'element-plus/es/components/tabs/style/css',
+        'element-plus/es/components/tab-pane/style/css',
+        'element-plus/es/components/rate/style/css',
+        'element-plus/es/components/date-picker/style/css',
+        'element-plus/es/components/notification/style/css',
+        'element-plus/es/components/image/style/css',
+        'element-plus/es/components/statistic/style/css',
+        'element-plus/es/components/watermark/style/css',
+        'element-plus/es/components/config-provider/style/css',
+        'vue-i18n',
+        'element-plus/es/components/text/style/css',
+        '@vueuse/core'
+        // 'sortablejs',
+        // 'path-to-regexp',
+        // 'echarts',
+        // '@wangeditor/editor',
+        // '@wangeditor/editor-for-vue',
+        // 'path-browserify'
+      ]
+    }
+  }
+})