htc 1 Minggu lalu
melakukan
588a5191bd
100 mengubah file dengan 20597 tambahan dan 0 penghapusan
  1. 3 0
      .browserslistrc
  2. 16 0
      .editorconfig
  3. 2 0
      .env.development
  4. 3 0
      .env.production
  5. 26 0
      .eslintrc.js
  6. 22 0
      .prettierrc
  7. 3 0
      .vscode/settings.json
  8. 3 0
      babel.config.js
  9. 20 0
      index.html
  10. 15517 0
      package-lock.json
  11. 67 0
      package.json
  12. TEMPAT SAMPAH
      public/1.png
  13. TEMPAT SAMPAH
      public/favicon.ico
  14. TEMPAT SAMPAH
      public/wechat.jpg
  15. 55 0
      src/App.vue
  16. 567 0
      src/assets/css/app.less
  17. 101 0
      src/assets/css/header.less
  18. 161 0
      src/assets/css/setting.less
  19. 1 0
      src/assets/icons/iconfont/iconfont.js
  20. 1 0
      src/assets/icons/svg/button.svg
  21. 1 0
      src/assets/icons/svg/cascader.svg
  22. 1 0
      src/assets/icons/svg/checkbox.svg
  23. 1 0
      src/assets/icons/svg/color.svg
  24. 1 0
      src/assets/icons/svg/component.svg
  25. 1 0
      src/assets/icons/svg/date-range.svg
  26. 1 0
      src/assets/icons/svg/date.svg
  27. 1 0
      src/assets/icons/svg/earth.svg
  28. 1 0
      src/assets/icons/svg/extfullscreen.svg
  29. 1 0
      src/assets/icons/svg/fanyiline.svg
  30. 1 0
      src/assets/icons/svg/fullscreen2.svg
  31. 1 0
      src/assets/icons/svg/gitee.svg
  32. 1 0
      src/assets/icons/svg/indent.svg
  33. 1 0
      src/assets/icons/svg/input.svg
  34. 1 0
      src/assets/icons/svg/morevertical.svg
  35. 1 0
      src/assets/icons/svg/number.svg
  36. 1 0
      src/assets/icons/svg/outdent.svg
  37. 1 0
      src/assets/icons/svg/password.svg
  38. 1 0
      src/assets/icons/svg/radio.svg
  39. 1 0
      src/assets/icons/svg/rate.svg
  40. 1 0
      src/assets/icons/svg/rich-text.svg
  41. 1 0
      src/assets/icons/svg/row.svg
  42. 1 0
      src/assets/icons/svg/select.svg
  43. 1 0
      src/assets/icons/svg/slider.svg
  44. 1 0
      src/assets/icons/svg/switch.svg
  45. 1 0
      src/assets/icons/svg/table.svg
  46. 1 0
      src/assets/icons/svg/textarea.svg
  47. 1 0
      src/assets/icons/svg/time-range.svg
  48. 1 0
      src/assets/icons/svg/time.svg
  49. 1 0
      src/assets/icons/svg/tuichuquanping.svg
  50. 1 0
      src/assets/icons/svg/upload.svg
  51. TEMPAT SAMPAH
      src/assets/images/logo.png
  52. TEMPAT SAMPAH
      src/assets/images/user.png
  53. 32 0
      src/assets/theme/base.less
  54. 935 0
      src/assets/theme/index.less
  55. 97 0
      src/assets/theme/mobile.less
  56. 4 0
      src/components/base/svg-icon/index.ts
  57. 38 0
      src/components/base/svg-icon/index.vue
  58. 5 0
      src/components/ren-dept-tree/index.ts
  59. 113 0
      src/components/ren-dept-tree/src/ren-dept-tree.vue
  60. 4 0
      src/components/ren-radio-group/index.ts
  61. 24 0
      src/components/ren-radio-group/src/ren-radio-group.vue
  62. 5 0
      src/components/ren-region-tree/index.ts
  63. 131 0
      src/components/ren-region-tree/src/ren-region-tree.vue
  64. 4 0
      src/components/ren-select/index.ts
  65. 25 0
      src/components/ren-select/src/ren-select.vue
  66. 82 0
      src/components/wang-editor/index.vue
  67. 41 0
      src/constants/app.ts
  68. 13 0
      src/constants/cacheKey.ts
  69. 16 0
      src/constants/config.ts
  70. 143 0
      src/constants/enum.ts
  71. 246 0
      src/hooks/useView.ts
  72. 20 0
      src/layout/fullscreen-layout.vue
  73. 63 0
      src/layout/header/base-header.vue
  74. 34 0
      src/layout/header/breadcrumb.vue
  75. 39 0
      src/layout/header/collapse-sidebar-btn.vue
  76. 93 0
      src/layout/header/expand.vue
  77. 64 0
      src/layout/header/header-mix-nav-menus.vue
  78. 35 0
      src/layout/header/logo.vue
  79. 80 0
      src/layout/index.vue
  80. 3 0
      src/layout/layout.vue
  81. 162 0
      src/layout/sidebar/base-sidebar.vue
  82. 39 0
      src/layout/sidebar/mobile-sidebar.vue
  83. 51 0
      src/layout/sidebar/sidebar-menus-items.vue
  84. 47 0
      src/layout/view/base-view.vue
  85. 155 0
      src/layout/view/tabs.vue
  86. 34 0
      src/main.ts
  87. 52 0
      src/router/base.ts
  88. 187 0
      src/router/index.ts
  89. 65 0
      src/service/baseService.ts
  90. 83 0
      src/store/index.ts
  91. 6 0
      src/types/env.d.ts
  92. 3 0
      src/types/index.d.ts
  93. 176 0
      src/types/interface.ts
  94. 29 0
      src/types/shims.d.ts
  95. 71 0
      src/utils/cache.ts
  96. 6 0
      src/utils/emits.ts
  97. 90 0
      src/utils/http.ts
  98. 181 0
      src/utils/router.ts
  99. 173 0
      src/utils/theme.ts
  100. 0 0
      src/utils/utils.ts

+ 3 - 0
.browserslistrc

@@ -0,0 +1,3 @@
+> 1%
+last 2 versions
+not dead

+ 16 - 0
.editorconfig

@@ -0,0 +1,16 @@
+# http://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab

+ 2 - 0
.env.development

@@ -0,0 +1,2 @@
+NODE_ENV=development
+VITE_APP_API=http://192.168.2.254:8080/ssyjManage-admin

+ 3 - 0
.env.production

@@ -0,0 +1,3 @@
+NODE_ENV=production
+VITE_APP_API=http://192.168.2.254:8080/ssyjManage-admin
+

+ 26 - 0
.eslintrc.js

@@ -0,0 +1,26 @@
+module.exports = {
+  root: true,
+  env: {
+    node: true,
+    "vue/setup-compiler-macros": true
+  },
+  extends: [
+    "plugin:vue/vue3-essential",
+    "eslint:recommended",
+    "@vue/typescript/recommended",
+    "@vue/prettier"
+  ],
+  parserOptions: {
+    ecmaVersion: 2020,
+    ecmaFeatures: {
+      jsx: true
+    }
+  },
+  rules: {
+    "no-console": "off",
+    "no-debugger": "off",
+    "@typescript-eslint/no-explicit-any": ["off"],
+    "@typescript-eslint/no-var-requires": 0,
+    "vue/multi-word-component-names": "off"
+  }
+};

+ 22 - 0
.prettierrc

@@ -0,0 +1,22 @@
+{
+  "singleQuote": false,
+  "semi": true,
+  "trailingComma": "none",
+  "printWidth": 100,
+  "arrowParens": "always",
+  "tabWidth": 2,
+  "endOfLine": "auto",
+  "overrides": [
+    {
+      "files": ".prettierrc",
+      "options": { "parser": "json" }
+    },
+    {
+      "files": "*.vue",
+      "options": {
+        "parser": "vue",
+        "printWidth": 300
+      }
+    }
+  ]
+}

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+    "liveServer.settings.port": 5501
+}

+ 3 - 0
babel.config.js

@@ -0,0 +1,3 @@
+module.exports = {
+  presets: ["@vue/cli-plugin-babel/preset"],
+};

+ 20 - 0
index.html

@@ -0,0 +1,20 @@
+<!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>ringzle开发平台</title>
+    <script>
+      //全局钩子
+      window.SITE_CONFIG = {
+        //api
+        apiURL: "<%=apiURL%>"
+      };
+    </script>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="./src/main.ts"></script>
+  </body>
+</html>

File diff ditekan karena terlalu besar
+ 15517 - 0
package-lock.json


+ 67 - 0
package.json

@@ -0,0 +1,67 @@
+{
+  "name": "ssyjManage-vue",
+  "version": "5.4.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "npm run build:prod",
+    "build:prod": "vue-tsc --noEmit && vite build --mode production",
+    "serve": "npm run build && vite preview",
+    "lint": "eslint \"src/**/*.{vue,ts}\" --fix"
+  },
+  "gitHooks": {
+    "pre-commit": "lint-staged"
+  },
+  "lint-staged": {
+    "src/**/*.{ts,vue}": [
+      "eslint --fix",
+      "git add"
+    ]
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "2.3.1",
+    "@vueuse/core": "9.1.1",
+    "@wangeditor/editor": "5.1.1",
+    "@wangeditor/editor-for-vue": "^5.1.12",
+    "axios": "1.6.0",
+    "classnames": "^2.3.1",
+    "core-js": "^3.14.0",
+    "echarts": "^5.2.2",
+    "element-plus": "2.7.6",
+    "lodash": "^4.17.21",
+    "mitt": "^2.1.0",
+    "nprogress": "^0.2.0",
+    "pinia": "2.1.7",
+    "qs": "^6.10.1",
+    "quill": "^1.3.7",
+    "vue": "^3.4.31",
+    "vue-echarts": "^6.0.0",
+    "vue-router": "4.2.5"
+  },
+  "devDependencies": {
+    "@types/lodash": "^4.14.172",
+    "@types/nprogress": "^0.2.0",
+    "@types/qs": "^6.9.6",
+    "@types/sortablejs": "^1.10.6",
+    "@typescript-eslint/eslint-plugin": "^5.23.0",
+    "@typescript-eslint/parser": "^5.23.0",
+    "@vitejs/plugin-vue": "5.0.5",
+    "@vue/compiler-sfc": "^3.4.31",
+    "@vue/eslint-config-prettier": "^7.0.0",
+    "@vue/eslint-config-typescript": "^10.0.0",
+    "eslint": "^8.13.0",
+    "eslint-plugin-vue": "^8.6.0",
+    "less": "^4.1.1",
+    "less-loader": "^10.0.0",
+    "lint-staged": "^11.0.0",
+    "prettier": "^2.6.2",
+    "sass": "^1.50.1",
+    "typescript": "^4.6.3",
+    "vite": "5.2.11",
+    "vite-plugin-html": "^3.2.2",
+    "vite-plugin-svg-icons": "2.0.1",
+    "vite-tsconfig-paths": "3.4.0",
+    "vue-tsc": "^2.0.16"
+  }
+}

TEMPAT SAMPAH
public/1.png


TEMPAT SAMPAH
public/favicon.ico


TEMPAT SAMPAH
public/wechat.jpg


+ 55 - 0
src/App.vue

@@ -0,0 +1,55 @@
+<script lang="ts">
+import "@/assets/css/app.less";
+import "@/assets/theme/index.less";
+import "@/assets/theme/mobile.less";
+import FullscreenLayout from "@/layout/fullscreen-layout.vue";
+import Layout from "@/layout/index.vue";
+import { ElConfigProvider } from "element-plus";
+import { defineComponent, onMounted, reactive, watch } from "vue";
+import { useRoute } from "vue-router";
+import { useAppStore } from "@/store";
+import app from "./constants/app";
+import { EPageLayoutEnum, EThemeColor, EThemeSetting } from "./constants/enum";
+import { IObject } from "./types/interface";
+import { getThemeConfigCache, setThemeColor, updateTheme } from "./utils/theme";
+
+export default defineComponent({
+  name: "App",
+  components: { Layout, FullscreenLayout, [ElConfigProvider.name]: ElConfigProvider },
+  setup() {
+    const store = useAppStore();
+    const route = useRoute();
+    const state = reactive({
+      layout: location.href.includes("pop=true") ? EPageLayoutEnum.fullscreen : EPageLayoutEnum.page
+    });
+    onMounted(() => {
+      //读取主题色缓存
+      const themeCache = getThemeConfigCache();
+      const themeColor = themeCache[EThemeSetting.ThemeColor];
+      setThemeColor(EThemeColor.ThemeColor, themeColor);
+      updateTheme(themeColor);
+    });
+    watch(
+      () => [route.path, route.query, route.fullPath],
+      ([path, query, fullPath]) => {
+        store.updateState({ activeTabName: fullPath });
+        state.layout = app.fullscreenPages.includes(path as string) || (query as IObject)["pop"] ? EPageLayoutEnum.fullscreen : EPageLayoutEnum.page;
+      }
+    );
+    return {
+      store,
+      state,
+      pageTag: EPageLayoutEnum.page
+    };
+  }
+});
+</script>
+<template>
+  <el-config-provider>
+    <div v-if="!store.state.appIsRender" v-loading="true" :element-loading-fullscreen="true" :element-loading-lock="true" style="width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 99999; background: #fff"></div>
+    <template v-if="store.state.appIsReady">
+      <layout v-if="state.layout === pageTag"> </layout>
+      <fullscreen-layout v-else></fullscreen-layout>
+    </template>
+  </el-config-provider>
+</template>

+ 567 - 0
src/assets/css/app.less

@@ -0,0 +1,567 @@
+@import "../theme/base.less";
+
+*,
+:after,
+:before {
+  box-sizing: border-box;
+}
+html,
+body {
+  margin: 0;
+  padding: 0;
+  color: #595959;
+  font-size: 14px;
+  font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
+    "微软雅黑", Arial, sans-serif;
+  color: #595959;
+  background: #f0f2f5;
+
+  //字体
+  .text {
+    &-2 {
+      color: #8c8c8c;
+    }
+  }
+  .text-center {
+    text-align: center;
+  }
+
+  a {
+    color: @--color-primary;
+    text-decoration: none;
+    &:focus,
+    &:hover {
+      color: @--color-primary;
+    }
+  }
+}
+
+.iconfont {
+  cursor: pointer;
+  font-style: normal;
+  font-weight: 400;
+  font-variant: normal;
+  text-transform: none;
+  line-height: 1;
+  vertical-align: text-bottom;
+  display: inline-block;
+  fill: currentColor;
+  width: 17px;
+  height: 17px;
+}
+.icon-svg {
+  width: 1em;
+  height: 1em;
+  fill: currentColor;
+  vertical-align: middle;
+}
+
+.el-badge__content {
+  height: 16px;
+  line-height: 16px;
+  padding: 0 5px;
+  border: none;
+  background: #ff4d4f !important;
+}
+
+.ele-badge-static {
+  line-height: 0;
+}
+
+.ele-badge-static .el-badge__content {
+  position: static;
+  transform: none;
+}
+
+//alert
+.ele-alert-border.is-light.el-alert--warning {
+  border: 1px solid #faad144d !important;
+}
+
+.el-alert--warning.is-light {
+  background-color: #fff7e8 !important;
+  color: #faad14 !important;
+}
+.ele-alert-border.is-light .el-alert__title {
+  color: #262626 !important;
+  font-size: 14px !important;
+}
+.el-alert__content {
+  padding: 0;
+}
+//menu
+.el-menu-item a,
+.el-menu-item span,
+.el-sub-menu > .el-sub-menu__title a,
+.el-sub-menu > .el-sub-menu__title span {
+  color: @dark-text;
+  text-decoration: none;
+  margin-left: 5px;
+  display: inline-flex;
+  width: 100%;
+}
+.rr-sidebar-menu.el-menu--horizontal > .el-menu-item {
+  padding: 0 12px;
+  height: 50px;
+  line-height: 50px;
+}
+.rr-sidebar-menu-pop-dark,
+.rr-sidebar-menu-pop-light {
+  box-shadow: none !important;
+  border-width: 0 !important;
+}
+.el-sub-menu__icon-arrow {
+  font-weight: bold;
+}
+
+//pop
+.el-popper.is-dark a {
+  color: #fff;
+  text-decoration: none;
+}
+.el-popover.el-popper {
+  max-height: 300px;
+  overflow: auto;
+}
+
+//表格
+.el-table thead {
+  color: #303133 !important;
+  th {
+    background-color: #f5f7fa !important;
+  }
+}
+.el-table__fixed-right::before {
+  background: transparent !important; //element-plus表格高度动态计算bug,强制下划线不显示颜色
+}
+
+.el-form--inline .el-form-item{
+  margin-right: 16px !important;
+}
+
+//分页
+.el-pagination {
+  margin-top: 15px !important;
+  justify-content: right;
+}
+
+//tinymce
+.tox-tinymce-aux {
+  z-index: 3000 !important;
+}
+
+//弹窗popover
+.popover-pop {
+  padding: 10px 0 5px 5px !important;
+  &-body {
+    max-height: 255px;
+    overflow: auto;
+  }
+}
+
+//弹窗
+.rr-dialog {
+  min-width: 600px;
+}
+
+.rr {
+  display: flex;
+  flex-direction: column;
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+
+  &-loading {
+    z-index: 9999;
+  }
+
+  //全屏页面渲染
+  &-fullscreen {
+    width: 100vw;
+
+    &.new-pop-window > div {
+      padding: 15px;
+      margin: 15px;
+      background: #fff;
+      border-radius: 4px;
+    }
+  }
+
+  &-error {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background: #fff;
+    z-index: 1200;
+  }
+
+  &-drawer {
+    .el-drawer__header {
+      color: #595959;
+      font-size: 15px;
+      margin-bottom: 0;
+      padding: 13px 16px;
+      border-bottom: 1px solid #f4f4f4;
+    }
+    .el-drawer__body {
+      padding: 15px;
+      overflow: auto;
+    }
+  }
+
+  //顶部
+  &-header {
+    background: #fff;
+    padding: 0 !important;
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    z-index: 200;
+    &-ctx {
+      display: flex;
+      height: 50px;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+      &-logo {
+        display: flex;
+        color: #ffffffe6;
+        background-color: #191a23;
+        font-size: 19px;
+        font-weight: 500;
+        letter-spacing: 1.5px;
+        width: 230px;
+        height: 50px;
+        overflow: hidden;
+        white-space: nowrap;
+        justify-content: center;
+        font-family: Avenir, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue,
+          Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol,
+          Noto Color Emoji;
+        align-items: center;
+        position: relative;
+        transition: width 0.3s;
+        padding: 0 15px;
+
+        &-img {
+          width: 32px;
+          height: 32px;
+          display: inline-block;
+          flex-shrink: 0;
+          &-wrap {
+            display: flex;
+            &.enabled-logo {
+              &-false {
+                display: none;
+              }
+            }
+          }
+        }
+        &-line {
+          display: inline-block;
+          width: 10px;
+          height: 1px;
+        }
+        &-text {
+          display: inline-block;
+          line-height: 1;
+          overflow: hidden;
+          text-transform: uppercase;
+          font-weight: 700;
+          font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei,
+            "微软雅黑", Arial, sans-serif;
+        }
+      }
+    }
+  }
+  &-body {
+    flex: 1;
+    display: flex;
+    overflow: hidden;
+  }
+  //左侧侧边栏
+  &-sidebar {
+    margin-top: 50px;
+    width: 230px !important;
+    min-height: calc(100vh - 50px);
+    overflow-x: hidden !important;
+    transition: width 0.3s;
+    z-index: 120;
+    scrollbar-width: none;
+
+    &-menu {
+      transition: width 0.3s;
+      overflow: hidden;
+      &.el-menu--horizontal {
+        border-bottom: none !important;
+      }
+      .el-menu-item {
+        transition: none !important;
+      }
+    }
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+
+    .el-menu {
+      width: 230px !important;
+      border-right: 0 !important;
+      &-item {
+        height: 45px;
+        line-height: 45px;
+        margin: 2px 0;
+      }
+      &-item,
+      .el-sub-menu__title {
+        background: transparent !important;
+        &:focus {
+          background: transparent !important;
+        }
+      }
+      &-item,
+      .el-sub-menu__title,
+      &-item-group__title {
+        font-size: 14px;
+      }
+      .el-sub-menu {
+        .el-sub-menu__title {
+          i {
+            color: inherit !important;
+          }
+        }
+      }
+
+      .el-menu-item,
+      .el-sub-menu .el-sub-menu__title {
+        margin: 0;
+        height: 48px;
+        line-height: 48px;
+      }
+      .el-sub-menu {
+        .el-menu-item {
+          height: 45px;
+          line-height: 45px;
+          margin: 2px 0;
+        }
+      }
+
+      .el-menu-item [class^="el-icon"],
+      .el-sub-menu > .el-sub-menu__title [class^="el-icon"] {
+        font-size: 17px;
+        margin-right: 0;
+        width: auto;
+      }
+
+      .el-menu-item a,
+      .el-menu-item span,
+      .el-sub-menu > .el-sub-menu__title a,
+      .el-sub-menu > .el-sub-menu__title span {
+        margin-left: 10px;
+        > a {
+          margin-left: 0;
+        }
+      }
+    }
+  }
+  //页面内容区域外层
+  &-view {
+    flex: 1;
+    display: flex !important;
+    flex-direction: column;
+    padding: 0 !important;
+    border-top: 1px solid #f4f4f4 !important;
+    &-container {
+      margin-top: 50px;
+    }
+    &-wrap {
+      display: flex;
+      flex-direction: column;
+    }
+    &-ctx {
+      margin-top: 39px;
+      padding: 15px !important;
+      flex: 1;
+      //页面内容区域
+      &-card {
+        min-height: calc(100% - 5px);
+        border-width: 0 !important;
+      }
+    }
+    //页面内容顶部tab标签栏
+    &-tab {
+      background: #fff;
+      width: 100%;
+      height: 39px;
+      box-sizing: border-box;
+      position: relative;
+      overflow: hidden;
+
+      &__header {
+        &:hover {
+          background: inherit !important;
+        }
+      }
+
+      &-wrap {
+        position: fixed;
+        top: 50px;
+        left: 230px;
+        right: 0;
+        display: flex;
+        background: #fff;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+        z-index: 100;
+        transition: left 0.3s;
+      }
+
+      &-ops {
+        width: 40px;
+        flex-shrink: 0;
+        background: #fff;
+        display: flex !important;
+        align-items: center;
+        justify-content: center;
+        border-left: 1px solid #f4f4f4;
+        cursor: pointer;
+        text-align: center;
+        color: #8c8c8c !important;
+        font-weight: 400 !important;
+        font-size: 16px !important;
+        margin-right: 5px; //element-plus el-dropdown自动定位bug bottom-end指令不生效,临时采用偏移5px
+      }
+
+      .el-tabs__active-bar {
+        height: 0;
+      }
+
+      .el-tabs__nav {
+        &-prev,
+        &-next {
+          .el-icon {
+            display: none;
+          }
+        }
+        .el-tabs__item {
+          padding: 0 15px !important;
+          border-right: 1px solid #f4f4f4;
+          user-select: none;
+          color: #8c8c8c;
+          &:hover {
+            color: #262626;
+            background-color: rgba(0, 0, 0, 0.02);
+          }
+          .is-icon-close {
+            transition: none !important;
+            &:hover {
+              color: #fff;
+              background-color: #ff4d4f;
+            }
+          }
+          &::before {
+            content: "";
+            width: 9px;
+            height: 9px;
+            margin-right: 8px;
+            display: inline-block;
+            background-color: #ddd;
+            border-radius: 50%;
+          }
+
+          &.is-active {
+            color: @primary-bg-light;
+            background-color: @primary-bg-light !important;
+            &:before {
+              background-color: @primary-bg-light;
+            }
+          }
+          &:nth-child(2) {
+            &::before {
+              content: none;
+            }
+          }
+        }
+      }
+
+      .el-tabs__nav-wrap {
+        padding: 0px 39px 0 40px !important;
+        &::before,
+        &::after {
+          width: 40px;
+          height: 40px;
+          line-height: 44px;
+          text-align: center;
+          box-sizing: border-box;
+          font-size: 16px;
+          color: #8c8c8c;
+          transition: background-color 0.2s;
+          position: absolute;
+          top: 0;
+          left: 0;
+          font-family: element-icons !important;
+          font-style: normal;
+          font-weight: 400;
+          font-variant: normal;
+          text-transform: none;
+          -webkit-font-smoothing: antialiased;
+          -moz-osx-font-smoothing: grayscale;
+          cursor: not-allowed;
+        }
+        &::before {
+          content: url('data:image/svg+xml;charset=utf-8,<svg width="16" height="16" color="rgb(140 140 140)" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" data-v-042ca774=""><path fill="currentColor" d="M609.408 149.376L277.76 489.6a32 32 0 000 44.672l331.648 340.352a29.12 29.12 0 0041.728 0 30.592 30.592 0 000-42.752L339.264 511.936l311.872-319.872a30.592 30.592 0 000-42.688 29.12 29.12 0 00-41.728 0z"></path></svg>');
+          border-right: 1px solid #f4f4f4;
+        }
+        &::after {
+          content: url('data:image/svg+xml;charset=utf-8,<svg width="16" height="16" color="rgb(140 140 140)" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" data-v-042ca774=""><path fill="currentColor" d="M340.864 149.312a30.592 30.592 0 000 42.752L652.736 512 340.864 831.872a30.592 30.592 0 000 42.752 29.12 29.12 0 0041.728 0L714.24 534.336a32 32 0 000-44.672L382.592 149.376a29.12 29.12 0 00-41.728 0z"></path></svg>');
+          right: 0;
+          left: auto;
+          bottom: auto;
+          height: auto;
+          background-color: transparent;
+          border-left: 1px solid #f4f4f4;
+        }
+      }
+
+      .el-tabs__nav-next,
+      .el-tabs__nav-prev {
+        width: 40px;
+        height: 40px;
+        line-height: 40px;
+        text-align: center;
+        box-sizing: border-box;
+        font-size: 16px;
+        color: #8c8c8c;
+        transition: background-color 0.2s;
+        z-index: 10;
+
+        i {
+          vertical-align: middle;
+          margin-top: -4px;
+        }
+
+        &:hover {
+          background: rgba(0, 0, 0, 0.02);
+        }
+      }
+
+      .el-tabs__nav-prev {
+        border-right: 1px solid #f4f4f4;
+      }
+    }
+  }
+}
+.ql-toolbar.ql-snow{
+  width: 100% !important;
+}
+
+.el-form--inline {
+  .el-form-item {
+    & > .el-input, .el-cascader, .el-select, .el-date-editor, .el-autocomplete {
+      min-width: 200px;
+    }
+  }
+}

+ 101 - 0
src/assets/css/header.less

@@ -0,0 +1,101 @@
+.rr-header-ctx {
+  [class^="el-icon"] {
+    font-size: 18px;
+    width: auto;
+    margin-right: 0;
+  }
+  .rr-header-right {
+    display: flex;
+    flex: 1;
+    justify-content: space-between;
+    overflow: hidden;
+    align-items: center;
+    > div {
+      height: 100%;
+    }
+    &-items {
+      display: flex;
+      padding: 0 8px 0 0;
+
+      > div {
+        padding: 0 12px;
+        height: 50px;
+        line-height: 56px;
+        cursor: pointer;
+      }
+      &-icon {
+        height: 50px;
+        line-height: 56px;
+        display: inline-block;
+      }
+      .el-badge {
+        line-height: normal;
+      }
+      .el-dropdown {
+        vertical-align: inherit;
+        .el-icon {
+          .icon {
+            vertical-align: bottom;
+          }
+        }
+      }
+    }
+    &-left {
+      display: flex;
+      overflow: hidden;
+      align-items: center;
+      flex: 1;
+      box-sizing: border-box;
+
+      &-br {
+        padding: 0 10px;
+        overflow-x: auto;
+        overflow-y: hidden;
+        flex: 1;
+        .el-breadcrumb {
+          white-space: nowrap;
+        }
+        .el-breadcrumb__inner,
+        .el-breadcrumb__inner a,
+        .el-breadcrumb__item:last-child .el-breadcrumb__inner,
+        .el-breadcrumb__item:last-child .el-breadcrumb__inner:hover,
+        .el-breadcrumb__item:last-child .el-breadcrumb__inner a,
+        .el-breadcrumb__item:last-child .el-breadcrumb__inner a:hover {
+          color: #8c8c8c;
+        }
+        .el-breadcrumb__item {
+          float: none !important;
+          display: inline-block;
+        }
+        .el-breadcrumb__inner.is-link {
+          color: #595959;
+          font-weight: 500;
+        }
+      }
+    }
+
+    .el-space__item {
+      &:last-child {
+        flex-shrink: 0;
+      }
+    }
+
+    .rr-sidebar-menu.el-menu--horizontal {
+      display: flex;
+      span {
+        width: inherit;
+      }
+      .el-sub-menu {
+        .el-sub-menu__icon-arrow {
+          margin-left: 3px;
+          margin-top: 0;
+        }
+        .el-sub-menu__title {
+          padding: 0 10px 0 12px;
+          height: 50px;
+          line-height: 50px;
+        }
+      }
+    }
+  }
+}

+ 161 - 0
src/assets/css/setting.less

@@ -0,0 +1,161 @@
+.rr-setting {
+  padding: 20px;
+  .el-divider {
+    margin: 20px 0;
+  }
+  &-wrap {
+    .el-drawer__header {
+      color: #595959;
+      font-size: 15px;
+      margin-bottom: 0;
+      padding: 13px 16px;
+      border-bottom: 1px solid #f4f4f4;
+    }
+    .el-drawer__body {
+      overflow: auto;
+      padding: 0;
+    }
+  }
+  &-title {
+    font-size: 13px;
+  }
+  // 主题
+  .rr-theme {
+    .card {
+      width: 50px;
+      height: 35px;
+      border-radius: 3px;
+      margin: 0 20px 20px 0;
+      background-color: #f5f7fa;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+      display: inline-block;
+      vertical-align: top;
+      position: relative;
+      cursor: pointer;
+      //侧边栏
+      &.side {
+        &::before {
+          content: "";
+          width: 15px;
+          height: 100%;
+          background-color: #fff;
+          border-top-left-radius: 3px;
+          border-bottom-left-radius: 3px;
+          display: inline-block;
+          vertical-align: top;
+        }
+        &.dark {
+          &::before {
+            background-color: #2e3549;
+          }
+        }
+      }
+      //顶栏
+      &.header {
+        &::before {
+          content: "";
+          border-top-left-radius: 3px;
+          border-bottom-left-radius: 3px;
+          display: inline-block;
+          vertical-align: top;
+          width: 100%;
+          height: 10px;
+          background-color: #fff;
+          border-bottom-left-radius: 0;
+          border-top-right-radius: 3px;
+        }
+        &.light {
+          &::before {
+            width: 100%;
+            height: 10px;
+            background-color: #fff;
+            border-bottom-left-radius: 0;
+            border-top-right-radius: 3px;
+          }
+        }
+        &.dark {
+          &::before {
+            background-color: #2e3549;
+          }
+        }
+        &.primary {
+          &::before {
+            background-color: #409eff;
+          }
+        }
+      }
+
+      &.mix {
+        background-color: #2e3549;
+        &.dark {
+          &::before {
+            background-color: #f0f2f5;
+            width: 35px;
+            height: 25px;
+            position: absolute;
+            bottom: 0;
+            right: 0;
+            border-top-left-radius: 0;
+            border-bottom-left-radius: 0;
+            border-bottom-right-radius: 3px;
+          }
+        }
+      }
+
+      &.side,
+      &.header,
+      &.mix {
+        &.active {
+          &::after {
+            content: "";
+            width: 6px;
+            height: 6px;
+            border-radius: 50%;
+            background-color: #19be6b;
+            position: absolute;
+            left: 50%;
+            bottom: -15px;
+            margin-left: -3px;
+          }
+        }
+      }
+    }
+    //主色调
+    .color {
+      width: 20px;
+      height: 20px;
+      margin: 8px 8px 0 0;
+      border-radius: 2px;
+      display: inline-block;
+      box-shadow: 0 1px 3px rgba(0 0 0, 0.1);
+      vertical-align: top;
+      position: relative;
+      cursor: pointer;
+      &.active {
+        &::after {
+          content: url('data:image/svg+xml;charset=utf-8,<svg width="14" height="14" color="rgb(255 255 255)" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" data-v-042ca774=""><path fill="currentColor" d="M406.656 706.944L195.84 496.256a32 32 0 10-45.248 45.248l256 256 512-512a32 32 0 00-45.248-45.248L406.592 706.944z"></path></svg>');
+          font-family: element-icons !important;
+          -webkit-font-smoothing: antialiased;
+          -moz-osx-font-smoothing: grayscale;
+          position: absolute;
+          top: 50%;
+          left: 50%;
+          margin: -7px 0 0 -7px;
+          font-size: 14px;
+          color: #fff;
+        }
+      }
+    }
+  }
+  .rr-theme,
+  .rr-other {
+    width: 100%;
+    > .el-space__item {
+      width: 100%;
+    }
+  }
+  .rr-switch {
+    justify-content: space-between;
+    width: 100%;
+  }
+}

File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/iconfont/iconfont.js


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/button.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/cascader.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/checkbox.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/color.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/component.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/date-range.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/date.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/earth.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/extfullscreen.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/fanyiline.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/fullscreen2.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/gitee.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/indent.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/input.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/morevertical.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/number.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/outdent.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/password.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/radio.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/rate.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/rich-text.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/row.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/select.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/slider.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/switch.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/table.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/textarea.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/time-range.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/time.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/tuichuquanping.svg


File diff ditekan karena terlalu besar
+ 1 - 0
src/assets/icons/svg/upload.svg


TEMPAT SAMPAH
src/assets/images/logo.png


TEMPAT SAMPAH
src/assets/images/user.png


+ 32 - 0
src/assets/theme/base.less

@@ -0,0 +1,32 @@
+//定义基础色
+
+//主色
+
+body {
+  --color-primary: #409eff;
+  --color-primary-light: rgb(64 158 255 / 8%);
+}
+
+@--color-primary:~ 'var(--color-primary)';
+@--color-primary-light:~ 'var(--color-primary-light)';
+
+@text: #595959;
+@text-2: #8c8c8c;
+
+//导航菜单
+@dark-text: rgb(255 255 255 / 66%);
+@dark-text-active: #eee;
+@dark-bg: #263238;
+@dark-bg-active: @--color-primary;
+
+@light-text: @text;
+@light-text-active: @--color-primary;
+@light-bg: #fff;
+@light-bg-active: @--color-primary-light;
+
+@primary-text: rgb(255 255 255 / 66%);
+@primary-text-2: rgb(255 255 255 / 65%);
+@primary-text-active: #fff;
+@primary-bg: @--color-primary;
+@primary-bg-light: @--color-primary-light;
+@primary-bg-active: @--color-primary-light;

+ 935 - 0
src/assets/theme/index.less

@@ -0,0 +1,935 @@
+@import "./base.less";
+
+//主题样式
+
+//=================
+.el-menu--vertical.rr-sidebar-menu-pop-light,
+.el-menu--vertical.rr-sidebar-menu-pop-dark {
+  border-radius: 4px !important;
+  box-shadow: none !important;
+  .el-menu.el-menu--popup {
+    min-width: 160px;
+    border-radius: 4px !important;
+  }
+  .el-menu-item,
+  .el-sub-menu__title {
+    height: 45px;
+    line-height: 45px;
+  }
+  .is-active {
+    &.el-menu-item {
+      border: 0 !important;
+    }
+  }
+}
+//深色侧边栏
+.ui-sidebar-dark .rr-sidebar,
+.rr-sidebar-menu-pop-dark {
+  background: @dark-bg !important;
+  box-shadow: 0 4px 4px rgba(0, 21, 41, 0.35);
+  .el-menu {
+    background: @dark-bg !important;
+    .el-menu-item,
+    .el-sub-menu__title {
+      &:hover {
+        i,
+        a {
+          color: @dark-text-active !important;
+        }
+      }
+      i,
+      a {
+        color: @dark-text !important;
+      }
+      &:not(.is-active):hover {
+        background: inherit !important;
+      }
+    }
+    .is-active {
+      &.el-menu-item {
+        border-right: none !important;
+        background: @dark-bg-active !important;
+      }
+      &.el-menu-item,
+      > .el-sub-menu__title:first-child {
+        i,
+        a {
+          color: @dark-text-active !important;
+        }
+      }
+    }
+  }
+}
+//浅色侧边栏
+.ui-sidebar-light .rr-sidebar,
+.rr-sidebar-menu-pop-light {
+  background: @light-bg !important;
+  box-shadow: 0 4px 4px rgba(0, 21, 41, 0.25);
+  .el-menu {
+    background: @light-bg !important;
+    .el-menu-item,
+    .el-sub-menu__title {
+      &:hover {
+        i,
+        a {
+          color: @light-text-active !important;
+        }
+      }
+      i,
+      a {
+        color: @light-text !important;
+      }
+      &:not(.is-active):hover {
+        background: inherit !important;
+      }
+    }
+    .is-active {
+      &.el-menu-item {
+        border-right: 2px solid @light-text-active !important;
+        background: @light-bg-active !important;
+      }
+      &.el-menu-item,
+      > .el-sub-menu__title:first-child {
+        i,
+        a {
+          color: @light-text-active !important;
+        }
+      }
+    }
+  }
+}
+
+//================================
+.el-menu--horizontal.rr-sidebar-menu-pop-light,
+.el-menu--horizontal.rr-sidebar-menu-pop-dark {
+  border-radius: 4px !important;
+  box-shadow: none !important;
+  background-color: @light-bg !important;
+  border: none !important;
+  margin-top: -5px;
+  margin-left: 0;
+  .el-popper {
+    border: 0 !important;
+  }
+  .el-menu--horizontal {
+    margin-left: -5px;
+  }
+
+  .el-menu.el-menu--popup {
+    min-width: 160px;
+    box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2) !important;
+  }
+
+  .el-menu-item,
+  .el-sub-menu__title {
+    height: 45px;
+    line-height: 45px;
+  }
+  .is-active {
+    &.el-menu-item {
+      border: 0 !important;
+    }
+  }
+}
+//浅色顶栏
+.ui-topHeader-light {
+  .rr-header-ctx {
+    box-shadow: 0 1px 1px #f1f1f1;
+    &-logo {
+      background: @light-bg !important;
+      color: #000000bf;
+    }
+  }
+  &.ui-sidebar-dark {
+    .rr-header-ctx {
+      box-shadow: 0 1px 3px rgb(0 0 0 / 8%);
+    }
+  }
+  .rr-header-right {
+    background: @light-bg !important;
+    .rr-header-right-items {
+      * {
+        color: @light-text !important;
+      }
+      > div {
+        &:hover {
+          color: #262626 !important;
+          background: rgba(0, 0, 0, 0.1) !important;
+        }
+      }
+      .el-badge__content {
+        color: #fff !important;
+      }
+    }
+    .rr-sidebar-menu {
+      &.el-menu {
+        background: @light-bg !important;
+        .el-menu-item,
+        .el-sub-menu__title {
+          &:hover {
+            background: rgba(0, 0, 0, 0.1) !important;
+            i,
+            a {
+              color: @light-text-active !important;
+            }
+          }
+          i,
+          a {
+            color: @light-text !important;
+          }
+
+          i:not(.el-sub-menu__icon-arrow) {
+            width: 17px !important;
+            height: 17px !important;
+            margin-right: 0 !important;
+            margin-top: -4px;
+            line-height: 17px;
+          }
+          span {
+            margin-right: 0;
+          }
+        }
+        .is-active {
+          &.el-menu-item {
+            border-bottom: 2px solid @light-text-active !important;
+            background: @light-bg !important;
+          }
+          &.el-menu-item,
+          .el-sub-menu__title {
+            i,
+            a {
+              color: @light-text-active !important;
+            }
+            &:hover {
+              background: rgba(0, 0, 0, 0.1) !important;
+            }
+          }
+          &.isLink {
+            border-bottom: 0 !important;
+            i,
+            a {
+              color: @light-text !important;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+//深色顶栏
+.ui-topHeader-dark {
+  .rr-header-ctx {
+    &-logo {
+      background: @dark-bg !important;
+    }
+  }
+  .rr-header-right {
+    background: @dark-bg !important;
+    .rr-header-right-items {
+      * {
+        color: @dark-text !important;
+        &:hover {
+          color: @dark-text-active !important;
+        }
+      }
+      .el-badge__content {
+        color: #fff !important;
+      }
+    }
+    .rr-sidebar-menu {
+      &.el-menu {
+        background: @dark-bg !important;
+        .el-menu-item,
+        .el-sub-menu__title {
+          &:hover {
+            background: @dark-bg !important;
+            i,
+            a {
+              color: @dark-text-active !important;
+            }
+          }
+          i,
+          a {
+            color: @dark-text !important;
+          }
+          &:not(.is-active):hover {
+            background: inherit !important;
+          }
+          i:not(.el-sub-menu__icon-arrow) {
+            width: 17px !important;
+            height: 17px !important;
+            margin-right: 0 !important;
+            margin-top: -4px;
+            line-height: 17px;
+          }
+          span {
+            margin-right: 0;
+          }
+        }
+        .is-active {
+          &.el-menu-item {
+            border-bottom: 2px solid @dark-text-active !important;
+            background: @dark-bg !important;
+          }
+          &.el-menu-item,
+          .el-sub-menu__title {
+            border-bottom: 2px solid @dark-text-active !important;
+            i,
+            a {
+              color: @dark-text-active !important;
+            }
+          }
+          &.isLink {
+            border-bottom: 0 !important;
+            i,
+            a {
+              color: @dark-text !important;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+//主题色
+.ui-topHeader-primary {
+  .rr-header-ctx {
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
+    position: relative;
+    z-index: 102;
+  }
+  .rr-header-ctx-logo {
+    background: @primary-bg !important;
+  }
+  .rr-header-right {
+    background: @primary-bg !important;
+    .rr-header-right-items,
+    .rr-header-right-left-br {
+      div,
+      span,
+      svg,
+      i {
+        color: @primary-text !important;
+        &:hover {
+          color: @primary-text-active !important;
+        }
+      }
+      > div:not(.el-breadcrumb) {
+        &:hover {
+          color: #262626 !important;
+          background: rgba(0, 0, 0, 0.1) !important;
+        }
+      }
+      .el-badge__content {
+        color: #fff !important;
+      }
+      .el-breadcrumb {
+        .el-breadcrumb__item {
+          &:not(:first-child) {
+            * {
+              color: @primary-text-2 !important;
+              font-weight: 400 !important;
+            }
+          }
+        }
+      }
+    }
+    .rr-sidebar-menu {
+      &.el-menu {
+        background: @primary-bg !important;
+        .el-menu-item,
+        .el-sub-menu__title {
+          &:hover,
+          &:focus {
+            background: rgba(0, 0, 0, 0.1) !important;
+            i,
+            a {
+              color: @primary-text-active !important;
+            }
+          }
+          i,
+          a {
+            color: @primary-text !important;
+          }
+          i:not(.el-sub-menu__icon-arrow) {
+            width: 17px !important;
+            height: 17px !important;
+            margin-right: 0 !important;
+            margin-top: -4px;
+            line-height: 17px;
+          }
+          span {
+            margin-right: 0;
+          }
+        }
+        .is-active {
+          &.el-menu-item {
+            border-bottom: 2px solid @primary-text-active !important;
+          }
+          &.el-menu-item,
+          .el-sub-menu__title {
+            border-bottom: 2px solid @primary-text-active !important;
+            i,
+            a {
+              color: @primary-text-active !important;
+            }
+            &:hover {
+              background: rgba(0, 0, 0, 0.1) !important;
+            }
+          }
+          &.isLink {
+            border-bottom: 0 !important;
+            i,
+            a {
+              color: @primary-text !important;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+//=============
+//导航模式
+.ui-navLayout-left {
+  &.ui-sidebar-light {
+    .rr-sidebar {
+      box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.08);
+      z-index: 101;
+    }
+  }
+}
+
+.ui-navLayout-top {
+  &.ui-topHeader-light {
+    .rr-header-right {
+      border-bottom: none !important;
+    }
+  }
+  .rr-header-ctx-logo {
+    max-width: inherit !important;
+    &-text {
+      max-width: inherit !important;
+      overflow: inherit !important;
+    }
+  }
+  .rr-view-tab-wrap {
+    left: 0 !important;
+  }
+}
+
+.ui-navLayout-mix {
+  .rr-header-ctx-logo {
+    max-width: inherit !important;
+    &-text {
+      max-width: inherit !important;
+      overflow: inherit !important;
+    }
+  }
+  &.ui-sidebar-light {
+    .rr-sidebar {
+      box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.08);
+      z-index: 101;
+    }
+  }
+  .rr-sidebar {
+    box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.08);
+    z-index: 101;
+  }
+  .rr-header-right-left-br {
+    padding: 0 !important;
+  }
+}
+
+//========
+//内容不铺满
+.ui-contentFull-false {
+  .rr-view-ctx {
+    width: 1200px !important;
+    margin-left: auto;
+    margin-right: auto;
+  }
+}
+
+//=======
+//tab标签栏开关
+.ui-openTabsPage {
+  &-false {
+    .rr-view-ctx {
+      margin-top: 0;
+    }
+  }
+}
+
+//=======
+//logo自动
+//导航模式在顶部时logo自动要取消
+.ui-logoAuto-true,
+.ui-navLayout-top {
+  .rr-header-ctx-logo {
+    width: inherit !important;
+    padding: 0 15px 0 20px;
+    box-shadow: none !important;
+  }
+  &.ui-topHeader-primary .rr-header-ctx-logo {
+    background: @primary-bg !important;
+    color: #ffffffd9 !important;
+  }
+  &.ui-topHeader-dark .rr-header-ctx-logo {
+    background: @dark-bg !important;
+    color: #ffffffd9 !important;
+  }
+  &.ui-topHeader-light .rr-header-ctx-logo {
+    background: @light-bg !important;
+    color: #000000bf;
+    box-shadow: 1px 0 3px rgba(0, 0, 0, 0.08);
+  }
+}
+
+//侧边栏多彩图标
+.ui-colorIcon-true {
+  .rr-sidebar {
+    .el-menu {
+      .el-sub-menu__title,
+      .el-menu-item,
+      .isLink {
+        margin-left: -5px !important;
+      }
+      li {
+        [class^="el-icon"] {
+          &:first-child {
+            flex-shrink: 0;
+            width: 28px;
+            height: 28px;
+            line-height: 28px;
+            font-size: 14px;
+            background-color: rgb(97, 178, 252);
+            border-radius: 50%;
+            text-align: center;
+            color: rgb(255, 255, 255) !important;
+
+            .iconfont {
+              width: 14px;
+              height: 14px;
+            }
+          }
+        }
+        &:nth-child(2n) {
+          [class^="el-icon"] {
+            &:first-child {
+              background-color: rgb(125, 215, 51);
+            }
+          }
+        }
+        &:nth-child(3) {
+          [class^="el-icon"] {
+            &:first-child {
+              background-color: rgb(50, 162, 212);
+            }
+          }
+        }
+        &:nth-child(4) {
+          [class^="el-icon"] {
+            &:first-child {
+              background-color: rgb(115, 131, 207);
+            }
+          }
+        }
+        &:nth-child(5) {
+          [class^="el-icon"] {
+            &:first-child {
+              background-color: rgb(245, 104, 111);
+            }
+          }
+        }
+        &:nth-child(6) {
+          [class^="el-icon"] {
+            &:first-child {
+              background-color: rgb(43, 204, 206);
+            }
+          }
+        }
+        &:nth-child(7) {
+          [class^="el-icon"] {
+            &:first-child {
+              background-color: rgb(125, 215, 51);
+            }
+          }
+        }
+        &:nth-child(8) {
+          [class^="el-icon"] {
+            &:first-child {
+              background-color: rgb(250, 173, 20);
+            }
+          }
+        }
+      }
+      //--
+      .el-sub-menu {
+        .el-menu {
+          li,
+          .el-sub-menu__title {
+            [class^="el-icon"] {
+              &:first-child:not(.el-sub-menu__icon-arrow) {
+                width: 8px;
+                height: 8px;
+                line-height: 8px;
+                font-size: 30px;
+                overflow: hidden;
+                border-radius: 50%;
+                margin: 0 0 0 10px;
+                background: @dark-text !important;
+                color: @dark-text !important;
+                &:before {
+                  content: "";
+                  margin-left: -11px;
+                  font-family: element-icons !important;
+                }
+              }
+            }
+          }
+        }
+      }
+      .el-menu-item,
+      .el-sub-menu.is-active .el-sub-menu__title {
+        i:first-child {
+          color: #fff !important;
+        }
+      }
+    }
+  }
+  &.ui-sidebar-light {
+    .rr-sidebar {
+      .el-sub-menu .el-menu {
+        .el-sub-menu {
+          .el-sub-menu__title {
+            [class^="el-icon"] {
+              &:first-child:not(.el-sub-menu__icon-arrow) {
+                color: @light-text !important;
+                opacity: 0.25;
+              }
+            }
+            &:hover {
+              [class^="el-icon"] {
+                &:first-child:not(.el-sub-menu__icon-arrow) {
+                  color: @light-text-active !important;
+                  opacity: 0.25;
+                }
+              }
+            }
+          }
+          &.is-active .el-sub-menu__title [class^="el-icon"] {
+            &:first-child:not(.el-sub-menu__icon-arrow) {
+              color: @light-text-active !important;
+              opacity: 1;
+            }
+          }
+        }
+        .el-menu-item {
+          [class^="el-icon"] {
+            &:first-child:not(.el-sub-menu__icon-arrow) {
+              background: @light-text !important;
+              color: @light-text !important;
+              opacity: 0.25;
+            }
+          }
+          &.is-active,
+          &:hover {
+            [class^="el-icon"] {
+              &:first-child:not(.el-sub-menu__icon-arrow) {
+                background: @light-text-active !important;
+                color: @light-text-active !important;
+                opacity: 1;
+              }
+            }
+          }
+          &:hover:not(.is-active) {
+            [class^="el-icon"] {
+              &:first-child:not(.el-sub-menu__icon-arrow) {
+                opacity: 0.2;
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+  &.ui-sidebar-dark {
+    .rr-sidebar {
+      .el-sub-menu {
+        .el-sub-menu.is-opened {
+          &.is-active {
+            .el-sub-menu__title {
+              [class^="el-icon"] {
+                &:first-child:not(.el-sub-menu__icon-arrow) {
+                  background: @dark-text-active !important;
+                  color: @dark-text-active !important;
+                  opacity: 1;
+                }
+              }
+            }
+          }
+        }
+        .el-menu .el-menu-item,
+        .el-sub-menu.is-opened .el-sub-menu__title {
+          [class^="el-icon"] {
+            &:first-child:not(.el-sub-menu__icon-arrow) {
+              background: @dark-text !important;
+              color: @dark-text !important;
+              opacity: 0.85;
+            }
+          }
+          &.is-active,
+          &:hover {
+            [class^="el-icon"] {
+              &:first-child:not(.el-sub-menu__icon-arrow) {
+                background: @dark-text-active !important;
+                color: @dark-text-active !important;
+                opacity: 1;
+              }
+            }
+          }
+          &:hover:not(.is-active) {
+            [class^="el-icon"] {
+              &:first-child:not(.el-sub-menu__icon-arrow) {
+                opacity: 1;
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+//侧边栏收缩状态
+.rr.ui-sidebarCollapse {
+  &-true {
+    .rr-view-tab-wrap {
+      left: 60px;
+    }
+    .rr-header-ctx-logo-line {
+      width: 0;
+    }
+    .enabled-logo-false {
+      display: flex;
+    }
+    &.ui-logoAuto {
+      &-false {
+        .rr-header-ctx-logo {
+          width: 60px !important;
+          &-text {
+            display: none;
+          }
+        }
+      }
+      &-true {
+        .enabled-logo-false {
+          display: none;
+        }
+        .rr-header-ctx-logo-line {
+          width: 10px;
+        }
+      }
+    }
+    &.ui-navLayout-top {
+      //导航模式为顶部时自动展开logo状态
+      .rr-header-ctx-logo {
+        width: inherit !important;
+        padding: 0 15px 0 20px;
+        box-shadow: none !important;
+        &-text {
+          display: block;
+        }
+      }
+      .enabled-logo-false {
+        display: none;
+      }
+    }
+    .rr-sidebar:not(.rr-sidebar-mobile) {
+      width: 60px !important;
+      .el-menu {
+        width: 60px !important;
+      }
+      // 收起效果
+      .rr-sidebar-menu {
+        .el-menu-item,
+        .el-sub-menu__title,
+        .el-sub-menu {
+          a,
+          .el-menu {
+            display: none;
+          }
+        }
+      }
+    }
+  }
+  &-false {
+    .rr-header-ctx-logo {
+      &-text {
+        display: block;
+        overflow: hidden;
+      }
+    }
+  }
+}
+
+//tabStyle
+.ui-tabStyle-default {
+  .rr-view-tab {
+    .el-tabs__item {
+      border-right: none !important;
+      padding: 0 15px 0 !important;
+      &.is-active {
+        color: @--color-primary !important;
+      }
+      &:before {
+        content: none;
+      }
+      &:after {
+        content: "";
+        height: 3px;
+        width: 0;
+        background-color: @--color-primary !important;
+        position: absolute;
+        bottom: 0;
+        left: 0;
+      }
+      &.is-active:after,
+      &:hover:after {
+        width: 100%;
+      }
+    }
+    .el-tabs__nav-wrap {
+      &:before,
+      &:after,
+      .el-tabs__nav-next,
+      .el-tabs__nav-prev {
+        height: 40px;
+        line-height: 44px;
+      }
+    }
+  }
+}
+.ui-tabStyle-dot {
+  .rr-view-tab-wrap {
+    .rr-view-tab {
+      .el-tabs__item {
+        &.is-active {
+          color: @--color-primary !important;
+          &:before {
+            background-color: @--color-primary !important;
+          }
+        }
+      }
+    }
+  }
+}
+.ui-tabStyle-card {
+  .rr-view-tab-wrap {
+    background: transparent !important;
+    box-shadow: none !important;
+    padding-top: 10px;
+    .rr-view-tab {
+      height: 30px;
+      background: transparent !important;
+      &-ops {
+        border-radius: 4px;
+        height: 30px;
+        line-height: 30px;
+        width: 30px;
+        background-color: #fff;
+        margin-right: 10px;
+        .el-icon--right {
+          margin-left: 0;
+        }
+      }
+      .el-tabs__item {
+        margin-left: 8px;
+        padding: 0 15px 0 !important;
+        border-radius: 4px;
+        height: 30px;
+        line-height: 30px;
+        background-color: #fff;
+        &:nth-child(2) {
+          margin-left: 0;
+          padding: 0 15px !important;
+        }
+        &.is-active {
+          background-color: @--color-primary !important;
+          color: #fff;
+        }
+        &:before {
+          content: none;
+        }
+        &:after {
+          content: none;
+        }
+      }
+      .el-tabs__nav-wrap {
+        &:before,
+        &:after,
+        .el-tabs__nav-next,
+        .el-tabs__nav-prev {
+          height: 30px;
+          line-height: 30px;
+          background: #eff2f5 !important;
+        }
+        .el-tabs__nav-next,
+        .el-tabs__nav-prev {
+          &:hover {
+            background: transparent !important;
+          }
+        }
+      }
+    }
+  }
+}
+
+//外链
+.rr-sidebar-menu.el-menu .el-menu-item.is-active.isLink {
+  background: inherit !important;
+}
+
+//不同语言下的差异
+[lang="en-US"] {
+  .rr-header-ctx-logo-text {
+    letter-spacing: 0px !important;
+  }
+}
+
+@media screen and (min-width: 768px) {
+  :not(html):not(body)::-webkit-scrollbar {
+    width: 8px;
+    height: 8px;
+    background: transparent;
+  }
+
+  :not(html):not(body)::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  :not(html):not(body)::-webkit-scrollbar-thumb {
+    border-radius: 4px;
+    background-color: hsla(0, 0%, 54.9%, 0.3);
+  }
+
+  :not(html):not(body)::-webkit-scrollbar-thumb:hover {
+    background-color: hsla(0, 0%, 54.9%, 0.5);
+  }
+
+  .ele-scrollbar-mini::-webkit-scrollbar {
+    width: 6px;
+    height: 6px;
+  }
+
+  .ele-scrollbar-mini::-webkit-scrollbar-thumb {
+    border-radius: 3px;
+  }
+
+  .ele-scrollbar-hide::-webkit-scrollbar {
+    width: 0;
+    height: 0;
+  }
+}

+ 97 - 0
src/assets/theme/mobile.less

@@ -0,0 +1,97 @@
+@import "./base.less";
+
+@media only screen and (max-width: 768px) {
+  .rr-header-action {
+    display: flex !important;
+  }
+  .show-xs-only {
+    display: block !important;
+  }
+}
+@media only screen and (min-width: 768px) {
+}
+@media only screen and (min-width: 768px) and (max-width: 992px) {
+}
+@media only screen and (max-width: 992px) {
+}
+@media only screen and (min-width: 992px) {
+}
+@media only screen and (min-width: 992px) and (max-width: 1200px) {
+}
+@media only screen and (max-width: 1200px) {
+}
+@media only screen and (min-width: 1200px) {
+}
+@media only screen and (min-width: 1200px) and (max-width: 1920px) {
+}
+@media only screen and (max-width: 1920px) {
+}
+@media only screen and (min-width: 1920px) {
+}
+
+//
+.ui-mobile {
+  .rr-view-tab-wrap {
+    left: 0 !important;
+    transition: left 0s !important;
+  }
+  .rr-header-ctx-logo-img-wrap {
+    display: none !important;
+  }
+}
+
+.rr-sidebar-mobile {
+  z-index: 9999 !important;
+  &-inner {
+    margin: 0;
+    padding: 0;
+    scrollbar-width: none;
+    &::-webkit-scrollbar {
+      display: none;
+    }
+  }
+}
+.ui-sidebar-light .rr-sidebar-mobile {
+  .el-drawer__body,
+  .rr-header-ctx-logo-mobile {
+    background: @light-bg !important;
+  }
+  .rr-header-ctx-logo-mobile {
+    color: @light-text !important;
+  }
+}
+.ui-sidebar-dark .rr-sidebar-mobile {
+  .el-drawer__body,
+  .rr-header-ctx-logo-mobile {
+    background: @dark-bg !important;
+  }
+  .rr-header-ctx-logo-mobile {
+    color: @dark-text !important;
+  }
+}
+.ui-sidebarCollapse-true,
+.ui-sidebarCollapse-false {
+  .rr-sidebar-mobile {
+    width: initial !important;
+    .el-menu.rr-sidebar-menu {
+      width: 230px !important;
+      .el-menu-item,
+      .el-sub-menu__title {
+        a {
+          display: inline-block !important;
+        }
+      }
+    }
+    .rr-header-ctx-logo.rr-header-ctx-logo-mobile {
+      width: auto !important;
+      .rr-header-ctx-logo-text {
+        display: inline-block !important;
+      }
+    }
+
+    .el-drawer,
+    .el-drawer__body {
+      box-shadow: none !important;
+    }
+  }
+}

+ 4 - 0
src/components/base/svg-icon/index.ts

@@ -0,0 +1,4 @@
+import { withInstall } from "@/utils/utils";
+import SvgIcon from "./index.vue";
+
+export default withInstall(SvgIcon);

+ 38 - 0
src/components/base/svg-icon/index.vue

@@ -0,0 +1,38 @@
+<template>
+  <svg aria-hidden="true" :class="`iconfont ${className}`" :style="`width:${width};height:${height};color:${color};${style}`">
+    <use :xlink:href="symbolId" />
+  </svg>
+</template>
+<script lang="ts">
+import { computed, defineComponent } from "vue";
+
+/**
+ * 自定义svg图标,可自行将svg图标下载后存放在/src/assets/icons/svg目录下
+ * `使用方法:<svg-icon name="earth" color="red"></svg-icon>`
+ */
+export default defineComponent({
+  name: "SvgIcon",
+  props: {
+    prefix: {
+      type: String,
+      default: "icon"
+    },
+    name: {
+      type: String,
+      required: true
+    },
+    color: {
+      type: String,
+      default: ""
+    },
+    width: String,
+    height: String,
+    className: { type: String, default: "" },
+    style: { type: String, default: "" }
+  },
+  setup(props) {
+    const symbolId = computed(() => `#${props.prefix}-${props.name.replace("icon-", "")}`);
+    return { symbolId };
+  }
+});
+</script>

+ 5 - 0
src/components/ren-dept-tree/index.ts

@@ -0,0 +1,5 @@
+import { withInstall } from "@/utils/utils";
+import RenDeptTree from "./src/ren-dept-tree.vue";
+
+RenDeptTree.name = "RenDeptTree";
+export default withInstall(RenDeptTree);

+ 113 - 0
src/components/ren-dept-tree/src/ren-dept-tree.vue

@@ -0,0 +1,113 @@
+<template>
+  <div>
+    <el-input v-model="showDeptName" :placeholder="placeholder" @click="deptDialog">
+      <template v-slot:append>
+        <el-button icon="search" @click="deptDialog"></el-button>
+      </template>
+    </el-input>
+    <el-dialog v-model="visibleDept" width="30%" :modal="false" :title="placeholder" :close-on-click-modal="false" :close-on-press-escape="false">
+      <el-form size="small" :inline="true">
+        <el-form-item label="关键字:">
+          <el-input v-model="filterText" :style="{ width: '150px' }"></el-input>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="default">查询</el-button>
+        </el-form-item>
+      </el-form>
+      <el-tree class="filter-tree" :data="deptList" :default-expanded-keys="expandedKeys" :props="{ label: 'name', children: 'children' }" :expand-on-click-node="false" :filter-node-method="filterNode" :highlight-current="true" node-key="id" ref="treeRef"> </el-tree>
+      <template v-slot:footer>
+        <el-button type="default" @click="cancelHandle()">取消</el-button>
+        <el-button v-if="query" type="info" @click="clearHandle()">清除</el-button>
+        <el-button type="primary" @click="commitHandle()">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+<script lang="ts" setup>
+import { nextTick, ref, watch } from "vue";
+import { IObject } from "@/types/interface";
+import baseService from "@/service/baseService";
+import { ElMessage } from "element-plus";
+
+const filterText = ref("");
+const visibleDept = ref(false);
+const deptList = ref<any[]>([]);
+const showDeptName = ref("");
+const expandedKeys = ref<any[]>([]);
+const treeRef = ref();
+
+const props = defineProps({
+  modelValue: String,
+  deptName: String,
+  query: Boolean,
+  placeholder: String
+});
+
+watch(
+  () => filterText.value,
+  (val) => {
+    treeRef.value.filter(val);
+  }
+);
+
+watch(
+  () => props.deptName,
+  (val) => {
+    showDeptName.value = val as string;
+  }
+);
+
+const deptDialog = () => {
+  expandedKeys.value = [];
+  visibleDept.value = true;
+  getDeptList(props.modelValue);
+};
+
+const filterNode = (value: string, data: IObject) => {
+  if (!value) return true;
+  return data.name.indexOf(value) !== -1;
+};
+
+const getDeptList = (id?: string) => {
+  return baseService.get("/sys/dept/list").then((res) => {
+    deptList.value = res.data;
+    nextTick(() => {
+      if (id) {
+        treeRef.value.setCurrentKey(id);
+        expandedKeys.value = [id];
+      }
+    });
+  });
+};
+
+const cancelHandle = () => {
+  visibleDept.value = false;
+  deptList.value = [];
+  filterText.value = "";
+};
+
+const emit = defineEmits(["update:modelValue", "update:deptName"]);
+
+const clearHandle = () => {
+  emit("update:modelValue", "");
+  emit("update:deptName", "");
+  showDeptName.value = "";
+  visibleDept.value = false;
+  deptList.value = [];
+  filterText.value = "";
+};
+
+const commitHandle = () => {
+  const node = treeRef.value.getCurrentNode();
+  if (!node) {
+    ElMessage.error("请选择部门");
+    return;
+  }
+  emit("update:modelValue", node.id);
+  emit("update:deptName", node.name);
+  showDeptName.value = node.name;
+  visibleDept.value = false;
+  deptList.value = [];
+  filterText.value = "";
+};
+</script>

+ 4 - 0
src/components/ren-radio-group/index.ts

@@ -0,0 +1,4 @@
+import { withInstall } from "@/utils/utils";
+import RenRadioGroup from "./src/ren-radio-group.vue";
+
+export default withInstall(RenRadioGroup);

+ 24 - 0
src/components/ren-radio-group/src/ren-radio-group.vue

@@ -0,0 +1,24 @@
+<template>
+  <el-radio-group v-model="value" @change="$emit('update:modelValue', $event)">
+    <el-radio :label="data.dictValue" v-for="data in dataList" :key="data.dictValue">{{ data.dictLabel }}</el-radio>
+  </el-radio-group>
+</template>
+<script lang="ts">
+import { getDictDataList } from "@/utils/utils";
+import { computed, defineComponent } from "vue";
+import { useAppStore } from "@/store";
+export default defineComponent({
+  name: "RenRadioGroup",
+  props: {
+    modelValue: [Number, String],
+    dictType: String
+  },
+  setup(props) {
+    const store = useAppStore();
+    return {
+      value: computed(() => `${props.modelValue}`),
+      dataList: getDictDataList(store.state.dicts, props.dictType)
+    };
+  }
+});
+</script>

+ 5 - 0
src/components/ren-region-tree/index.ts

@@ -0,0 +1,5 @@
+import { withInstall } from "@/utils/utils";
+import RenRegionTree from "./src/ren-region-tree.vue";
+
+RenRegionTree.name = "RenRegionTree";
+export default withInstall(RenRegionTree);

+ 131 - 0
src/components/ren-region-tree/src/ren-region-tree.vue

@@ -0,0 +1,131 @@
+<template>
+  <div class="ren-region">
+    <el-input v-model="showName" :placeholder="placeholder" @click="treeDialog">
+      <template v-slot:append>
+        <el-button icon="search" @click="treeDialog"></el-button>
+      </template>
+    </el-input>
+    <el-dialog v-model="visibleTree" width="360px" :modal="false" :title="placeholder" :close-on-click-modal="false" :close-on-press-escape="false">
+      <el-form size="small" :inline="true">
+        <el-form-item label="关键字">
+          <el-input v-model="filterText" :style="{ width: '150px' }"></el-input>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="default">查询</el-button>
+        </el-form-item>
+      </el-form>
+      <el-tree class="filter-tree" :data="dataList" :default-expanded-keys="expandedKeys" :props="{ label: 'name', children: 'children' }" :expand-on-click-node="false" :filter-node-method="filterNode" :highlight-current="true" node-key="id" ref="treeRef"> </el-tree>
+      <template v-slot:footer>
+        <el-button type="default" @click="cancelHandle()">取消</el-button>
+        <el-button type="info" @click="clearHandle()">清除</el-button>
+        <el-button type="primary" @click="commitHandle()">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, ref, watch } from "vue";
+import { treeDataTranslate } from "@/utils/utils";
+import { IObject } from "@/types/interface";
+import baseService from "@/service/baseService";
+import { ElMessage } from "element-plus";
+
+const filterText = ref("");
+const visibleTree = ref(false);
+const dataList = ref<any[]>([]);
+const showName = ref("");
+const expandedKeys = ref<any[]>([]);
+const treeRef = ref();
+
+const props = defineProps({
+  modelValue: [Number, String],
+  parentName: String,
+  placeholder: String
+});
+
+watch(
+  () => filterText.value,
+  (val) => {
+    treeRef.value.filter(val);
+  }
+);
+
+watch(
+  () => props.parentName,
+  (val) => {
+    showName.value = val as string;
+  }
+);
+
+const treeDialog = () => {
+  expandedKeys.value = [];
+  if (treeRef.value) {
+    treeRef.value.setCurrentKey(null);
+  }
+  visibleTree.value = true;
+  getDataList(props.modelValue);
+};
+
+const filterNode = (value: string, data: IObject) => {
+  if (!value) return true;
+  return data.name.indexOf(value) !== -1;
+};
+
+const getDataList = (id: any) => {
+  return baseService.get("/sys/region/tree").then((res) => {
+    dataList.value = treeDataTranslate(res.data);
+    nextTick(() => {
+      treeRef.value.setCurrentKey(id);
+      expandedKeys.value = [id];
+    });
+  });
+};
+
+const cancelHandle = () => {
+  visibleTree.value = false;
+  dataList.value = [];
+  filterText.value = "";
+};
+
+const emit = defineEmits(["update:modelValue", "update:parentName"]);
+
+const clearHandle = () => {
+  emit("update:modelValue", "0");
+  emit("update:parentName", "");
+  showName.value = "";
+  visibleTree.value = false;
+  dataList.value = [];
+  filterText.value = "";
+};
+
+const commitHandle = () => {
+  const node = treeRef.value.getCurrentNode();
+  if (!node) {
+    ElMessage.error("请选择");
+    return;
+  }
+  emit("update:modelValue", node.id);
+  emit("update:parentName", node.name);
+
+  showName.value = node.name;
+  visibleTree.value = false;
+  dataList.value = [];
+  filterText.value = "";
+};
+</script>
+
+<style lang="less" scoped>
+.ren-region {
+  .filter-tree {
+    max-height: 230px;
+    overflow: auto;
+  }
+  .el-dialog__body {
+    padding: 0 0 0 20px;
+  }
+  .el-dialog__footer {
+    padding: 10px 20px 8px 20px;
+  }
+}
+</style>

+ 4 - 0
src/components/ren-select/index.ts

@@ -0,0 +1,4 @@
+import { withInstall } from "@/utils/utils";
+import RenSelect from "./src/ren-select.vue";
+
+export default withInstall(RenSelect);

+ 25 - 0
src/components/ren-select/src/ren-select.vue

@@ -0,0 +1,25 @@
+<template>
+  <el-select v-model="value" @change="$emit('update:modelValue', $event)" :placeholder="placeholder" clearable>
+    <el-option :label="data.dictLabel" v-for="data in dataList" :key="data.dictValue" :value="data.dictValue">{{ data.dictLabel }}</el-option>
+  </el-select>
+</template>
+<script lang="ts">
+import { computed, defineComponent } from "vue";
+import { getDictDataList } from "@/utils/utils";
+import { useAppStore } from "@/store";
+export default defineComponent({
+  name: "RenSelect",
+  props: {
+    modelValue: [Number, String],
+    dictType: String,
+    placeholder: String
+  },
+  setup(props) {
+    const store = useAppStore();
+    return {
+      value: computed(() => `${props.modelValue}`),
+      dataList: getDictDataList(store.state.dicts, props.dictType)
+    };
+  }
+});
+</script>

+ 82 - 0
src/components/wang-editor/index.vue

@@ -0,0 +1,82 @@
+<template>
+  <div style="border: 1px solid #ccc; z-index: 100">
+    <!-- 工具栏 -->
+    <Toolbar :editor="editorRef" :mode="mode" style="border-bottom: 1px solid #ccc" />
+    <!-- 编辑器 -->
+    <Editor :model-value="modelValue" :style="style" :disabled="disabled" :default-config="editorConfig" :mode="mode" @onCreated="handleCreated" @onChange="handleChange" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import "@wangeditor/editor/dist/css/style.css";
+import { onBeforeUnmount, shallowRef } from "vue";
+import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
+import { IDomEditor, IEditorConfig } from "@wangeditor/editor";
+import app from "@/constants/app";
+import { getToken } from "@/utils/cache";
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    required: true
+  },
+  mode: {
+    type: String,
+    default: "default" // 可选值:[default | simple]
+  },
+  placeholder: {
+    type: String,
+    default: ""
+  },
+  style: {
+    type: String,
+    default: "height: 300px;"
+  },
+  disabled: {
+    type: Boolean,
+    default: false
+  }
+});
+
+// 编辑器实例,必须用 shallowRef
+const editorRef = shallowRef();
+
+type InsertFnType = (url: string, alt: string, href: string) => void;
+
+// 编辑器配置
+const editorConfig: Partial<IEditorConfig> = {
+  placeholder: props.placeholder,
+  readOnly: props.disabled,
+  MENU_CONF: {
+    uploadImage: {
+      server: `${app.api}/sys/oss/upload?token=${getToken()}`, // 上传地址
+      fieldName: "file",
+      // 自定义插入图片
+      customInsert(res: any, insertFn: InsertFnType) {
+        // res 即服务端的返回结果
+        // 从 res 中找到 url alt href ,然后插图图片
+        insertFn(res.data.src, "", "");
+      }
+    }
+  }
+};
+
+// 组件销毁时,也及时销毁编辑器
+onBeforeUnmount(() => {
+  const editor = editorRef.value;
+  if (editor == null) {
+    return;
+  }
+  editor.destroy();
+});
+
+const handleCreated = (editor: IDomEditor) => {
+  editorRef.value = editor;
+};
+
+// 编辑器change事件触发
+const emit = defineEmits(["update:modelValue"]);
+const handleChange = (editor: IDomEditor) => {
+  emit("update:modelValue", editor.getHtml());
+};
+</script>

+ 41 - 0
src/constants/app.ts

@@ -0,0 +1,41 @@
+import { getValueByKeys } from "@/utils/utils";
+import appPack from "../../package.json";
+/**
+ * app系统配置
+ */
+export default {
+  /**
+   * 系统版本号,自动读取package.json中的version字段
+   */
+  version: appPack.version,
+
+  /**
+   * 系统默认语言
+   */
+  defaultLang: "zh-CN",
+
+  /**
+   * api请求地址,这里读取env环境变量中的VITE_APP_API,优先使用全局变量window.SITE_CONFIG.apiURL钩子,支持在index.html中配置
+   */
+  api: getValueByKeys(window, "SITE_CONFIG.apiURL") || import.meta.env.VITE_APP_API,
+
+  /**
+   * 启用logo图标,logo尺寸32*32,存放路径@/assets/images/logo.png
+   */
+  enabledLogo: false,
+
+  /**
+   * 开启页面缓存
+   */
+  enabledKeepAlive: true,
+
+  /**
+   * 网络请求超时时间,单位毫秒
+   */
+  requestTimeout: 30000,
+
+  /**
+   * 全屏渲染的页面
+   */
+  fullscreenPages: ["/login"]
+};

+ 13 - 0
src/constants/cacheKey.ts

@@ -0,0 +1,13 @@
+/**
+ * token值
+ */
+export const CacheToken = "CacheToken";
+
+/**
+ * 语言
+ */
+export const CacheLang = "CacheLang";
+/**
+ * 主题
+ */
+export const CacheTheme = "CacheTheme";

+ 16 - 0
src/constants/config.ts

@@ -0,0 +1,16 @@
+/**
+ * 主题设置默认值
+ */
+export const themeSetting = {
+  sidebar: "dark",
+  topHeader: "primary",
+  themeColor: "#17B3A3",
+  navLayout: "left",
+  contentFull: true,
+  logoAuto: false,
+  colorIcon: false,
+  sidebarUniOpened: true,
+  openTabsPage: true,
+  tabStyle: "default",
+  sidebarCollapse: false
+};

+ 143 - 0
src/constants/enum.ts

@@ -0,0 +1,143 @@
+/**
+ * 页面渲染布局
+ */
+export enum EPageLayoutEnum {
+  "page",
+  "fullscreen"
+}
+
+/**
+ * 导航模式
+ */
+export enum ESidebarLayoutEnum {
+  /**
+   * 左侧导航
+   */
+  Left = "left",
+  /**
+   * 顶部导航
+   */
+  Top = "top",
+  /**
+   * 混合导航
+   */
+  Mix = "mix"
+}
+
+/**
+ * 主题设置
+ */
+export enum EThemeSetting {
+  /**
+   * 侧边栏风格
+   */
+  Sidebar = "sidebar",
+  /**
+   * 顶部风格
+   */
+  TopHeader = "topHeader",
+  /**
+   * 主题色
+   */
+  ThemeColor = "themeColor",
+  //---
+  /**
+   * 布局模式
+   */
+  NavLayout = "navLayout",
+  /**
+   * 内容是否铺满
+   */
+  ContentFull = "contentFull",
+  //---
+  /**
+   * logo宽度自动
+   */
+  LogoAuto = "logoAuto",
+  /**
+   * 多彩图标
+   */
+  ColorIcon = "colorIcon",
+  /**
+   * 侧边栏排他展开
+   */
+  SidebarUniOpened = "sidebarUniOpened",
+  /**
+   * 开启tab标签页
+   */
+  OpenTabsPage = "openTabsPage",
+  /**
+   * tab标签风格
+   */
+  TabStyle = "tabStyle",
+  //---
+  /**
+   * 侧边栏展开收起
+   */
+  SidebarCollapse = "sidebarCollapse"
+}
+
+/**
+ * 系统框架事件枚举
+ */
+export enum EMitt {
+  /**
+   * 全局加载
+   */
+  OnLoading = "onLoading",
+  /**
+   * 切换左侧侧边栏
+   */
+  OnSwitchLeftSidebar = "onSwitchLeftSidebar",
+  /**
+   * 推送菜单到tab标签页
+   */
+  OnPushMenuToTabs = "onPushMenuToTabs",
+  /**
+   * 设置主题
+   */
+  OnSetTheme = "onSetTheme",
+  /**
+   * 设置侧边栏排他展开
+   */
+  OnSetThemeNotUniqueOpened = "onSetTheme_not_uniqueOpened",
+  /**
+   * 设置开启标签页
+   */
+  OnSetThemeTabsPage = "onSetTheme_tabsPage",
+  /**
+   * 设置导航模式
+   */
+  OnSetNavLayout = "onSetNavLayout",
+  /**
+   * 刷新tab标签页
+   */
+  OnReloadTabPage = "onReloadTabPage",
+
+  //
+  /**
+   * 移动端打开侧边栏
+   */
+  OnMobileOpenSidebar = "onMobileOpenSidebar",
+
+  //
+  /**
+   * 混合导航选中顶部主菜单
+   */
+  OnSelectHeaderNavMenusByMixNav = "onSelectHeaderNavMenusByMixNav",
+
+  /**
+   * 关闭当前tab页
+   */
+  OnCloseCurrTab = "onCloseCurrTab"
+}
+
+/**
+ * 主题是key
+ */
+export enum EThemeColor {
+  /**
+   * 主题色
+   */
+  ThemeColor = "--color-primary"
+}

+ 246 - 0
src/hooks/useView.ts

@@ -0,0 +1,246 @@
+import app from "@/constants/app";
+import { EMitt, EThemeSetting } from "@/constants/enum";
+import { IObject, IViewHooks, IViewHooksOptions } from "@/types/interface";
+import { registerDynamicToRouterAndNext } from "@/router";
+import baseService from "@/service/baseService";
+import { getToken } from "@/utils/cache";
+import emits from "@/utils/emits";
+import { getThemeConfigCacheByKey } from "@/utils/theme";
+import { checkPermission, getDictLabel } from "@/utils/utils";
+import qs from "qs";
+import { onActivated, onMounted } from "vue";
+import { useRouter, useRoute } from "vue-router";
+import { useAppStore } from "@/store";
+import { ElMessage, ElMessageBox } from "element-plus";
+
+/**
+ * 通用视图业务逻辑(列表/增删改查基本业务)
+ * @param props 自定义通用业务state
+ * @returns 返回响应式自定义state和通用方法
+ */
+const useView = (props: IViewHooksOptions | IObject): IViewHooks => {
+  const router = useRouter();
+  const route = useRoute();
+  const store = useAppStore();
+  const defaultOptions: IViewHooksOptions = {
+    createdIsNeed: true,
+    activatedIsNeed: false,
+    getDataListURL: "",
+    getDataListIsPage: false,
+    deleteURL: "",
+    deleteIsBatch: false,
+    deleteIsBatchKey: "id",
+    exportURL: "",
+    dataForm: {},
+    dataList: [],
+    order: "",
+    orderField: "",
+    page: 1,
+    limit: 10,
+    total: 0,
+    dataListLoading: false,
+    dataListSelections: [],
+    elTable: {}
+  };
+  const mergeDefaultStateToPageState = (options: IObject, props: IObject): IViewHooksOptions => {
+    for (const key in options) {
+      if (!Object.getOwnPropertyDescriptor(props, key)) {
+        props[key] = options[key];
+      }
+    }
+    return props;
+  };
+  const state = mergeDefaultStateToPageState(defaultOptions, props);
+  onMounted(() => {
+    if (state.createdIsNeed && !state.activatedIsNeed) {
+      viewFns.query();
+    }
+  });
+  onActivated(() => {
+    if (store.state.closedTabs.includes(store.state.activeTabName)) {
+      //如果当前打开的tab页面是之前已经关闭过的会存在keep-alive缓存
+      //这里采用临时刷新页面解决方案
+      //待vue官方开放缓存策略后再行实现 https://github.com/vuejs/vue-next/pull/4339   https://github.com/vuejs/rfcs/pull/284
+
+      const closedTabs = store.state.closedTabs;
+      store.updateState({
+        closedTabs: closedTabs.filter((x: string) => x !== store.state.activeTabName)
+      });
+      emits.emit(EMitt.OnReloadTabPage);
+    }
+
+    if (state.activatedIsNeed) {
+      viewFns.query();
+    }
+  });
+
+  //
+  const rejectFns = {
+    hasPermission(key: string) {
+      return checkPermission(store.state.permissions as string[], key);
+    },
+    getDictLabel(dictType: string, dictValue: number) {
+      return getDictLabel(store.state.dicts, dictType, dictValue);
+    }
+  };
+
+  //
+  const viewFns = {
+    // 获取数据列表
+    query() {
+      if (!state.getDataListURL) {
+        return;
+      }
+      state.dataListLoading = true;
+      baseService
+        .get(state.getDataListURL, {
+          order: state.order,
+          orderField: state.orderField,
+          page: state.getDataListIsPage ? state.page : null,
+          limit: state.getDataListIsPage ? state.limit : null,
+          ...state.dataForm
+        })
+        .then((res) => {
+          state.dataListLoading = false;
+          state.dataList = state.getDataListIsPage ? res.data.list : res.data;
+          state.total = state.getDataListIsPage ? res.data.total : 0;
+        })
+        .catch(() => {
+          state.dataListLoading = false;
+        });
+    },
+    // 多选
+    dataListSelectionChangeHandle(val: IObject[]) {
+      state.dataListSelections = val;
+    },
+    // 排序
+    dataListSortChangeHandle(data: IObject) {
+      if (!data.order || !data.prop) {
+        state.order = "";
+        state.orderField = "";
+        return false;
+      }
+      state.order = data.order.replace(/ending$/, "");
+      state.orderField = data.prop.replace(/([A-Z])/g, "_$1").toLowerCase();
+      viewFns.query();
+    },
+    // 分页, 每页条数
+    pageSizeChangeHandle(val: number) {
+      state.page = 1;
+      state.limit = val;
+      viewFns.query();
+    },
+    // 分页, 当前页
+    pageCurrentChangeHandle(val: number) {
+      state.page = val;
+      viewFns.query();
+    },
+    //搜索
+    getDataList() {
+      state.page = 1;
+      viewFns.query();
+    },
+    // 删除
+    deleteHandle(id?: string): Promise<any> {
+      return new Promise((resolve, reject) => {
+        if (
+          state.deleteIsBatch &&
+          !id &&
+          state.dataListSelections &&
+          state.dataListSelections.length <= 0
+        ) {
+          ElMessage.warning({
+            message: "请选择操作项",
+            duration: 500
+          });
+          return;
+        }
+        ElMessageBox.confirm("确定进行[删除]操作?", "提示", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        })
+          .then(() => {
+            baseService
+              .delete(
+                `${state.deleteURL}${state.deleteIsBatch ? "" : "/" + id}`,
+                state.deleteIsBatch
+                  ? id
+                    ? [id]
+                    : state.dataListSelections
+                    ? state.dataListSelections.map(
+                        (item: IObject) => state.deleteIsBatchKey && item[state.deleteIsBatchKey]
+                      )
+                    : {}
+                  : {}
+              )
+              .then((res) => {
+                ElMessage.success({
+                  message: "成功",
+                  duration: 500,
+                  onClose: () => {
+                    viewFns.query();
+                    resolve(true);
+                  }
+                });
+              });
+          })
+          .catch(() => {
+            //
+          });
+      });
+    },
+    // 导出
+    exportHandle() {
+      window.location.href = `${app.api}${state.exportURL}?${qs.stringify({
+        ...state.dataForm,
+        token: getToken()
+      })}`;
+      // baseService.download(state.exportURL, { ...state.dataForm, token: getToken() });
+    },
+    //关闭当前窗口
+    closeCurrentTab() {
+      if (getThemeConfigCacheByKey(EThemeSetting.OpenTabsPage)) {
+        emits.emit(EMitt.OnCloseCurrTab);
+      } else {
+        router.replace("/home");
+      }
+    },
+    // 处理流程路由
+    handleFlowRoute(data: IObject) {
+      const routeParams = {
+        path: `/flow/task-form`,
+        query: {
+          taskId: data.taskId,
+          processInstanceId: data.processInstanceId,
+          processDefinitionId: data.processDefinitionId,
+          showType: "taskHandle",
+          _mt: `${route.meta.title} - ${data.processDefinitionName}`
+        }
+      };
+      registerDynamicToRouterAndNext(routeParams);
+    },
+    // 查看流程详情
+    flowDetailRoute(data: IObject) {
+      const routeParams = {
+        path: `/flow/task-form`,
+        query: {
+          taskId: data.taskId,
+          processInstanceId: data.processInstanceId,
+          processDefinitionId: data.processDefinitionId,
+          showType: "detail",
+          _mt: `${route.meta.title} - ${data.processDefinitionName}`
+        }
+      };
+      registerDynamicToRouterAndNext(routeParams);
+    }
+  };
+
+  //
+  return {
+    ...viewFns,
+    ...rejectFns
+  };
+};
+
+export default useView;

+ 20 - 0
src/layout/fullscreen-layout.vue

@@ -0,0 +1,20 @@
+<script lang="ts">
+import { defineComponent } from "vue";
+import { useRoute } from "vue-router";
+
+/**
+ * 全屏布局
+ */
+export default defineComponent({
+  name: "FullScreenLayout",
+  setup() {
+    const route = useRoute();
+    return { route };
+  }
+});
+</script>
+<template>
+  <div :class="`rr-fullscreen ${route.query.pop ? 'new-pop-window' : ''}`">
+    <router-view />
+  </div>
+</template>

+ 63 - 0
src/layout/header/base-header.vue

@@ -0,0 +1,63 @@
+<script lang="ts">
+import logo from "@/assets/images/logo.png";
+import { EMitt, ESidebarLayoutEnum, EThemeSetting } from "@/constants/enum";
+import emits from "@/utils/emits";
+import { getThemeConfigCacheByKey } from "@/utils/theme";
+import { defineComponent, reactive } from "vue";
+import { useAppStore } from "@/store";
+import BaseSidebar from "../sidebar/base-sidebar.vue";
+import Breadcrumb from "./breadcrumb.vue";
+import CollapseSidebarBtn from "./collapse-sidebar-btn.vue";
+import Expand from "./expand.vue";
+import HeaderMixNavMenus from "./header-mix-nav-menus.vue";
+import Logo from "./logo.vue";
+import "@/assets/css/header.less";
+
+/**
+ * 顶部主区域
+ */
+export default defineComponent({
+  name: "Header",
+  components: { BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, Logo },
+  setup() {
+    const store = useAppStore();
+    const state = reactive({
+      sidebarLayout: getThemeConfigCacheByKey(EThemeSetting.NavLayout)
+    });
+    emits.on(EMitt.OnSetNavLayout, (vl) => {
+      state.sidebarLayout = vl;
+    });
+    const onRefresh = () => {
+      emits.emit(EMitt.OnReloadTabPage);
+    };
+    return { store, state, onRefresh, logo, ESidebarLayoutEnum };
+  }
+});
+</script>
+<template>
+  <div class="rr-header-ctx">
+    <div class="rr-header-ctx-logo hidden-xs-only">
+      <logo :logoUrl="logo" logoName="ringzle系统"></logo>
+    </div>
+    <div class="rr-header-right">
+      <div class="rr-header-right-left">
+        <div class="rr-header-right-items rr-header-action" :style="`display:${state.sidebarLayout === ESidebarLayoutEnum.Top ? 'none' : ''}`">
+          <collapse-sidebar-btn></collapse-sidebar-btn>
+          <div @click="onRefresh" style="cursor: pointer">
+            <div class="el-badge">
+              <el-icon><refresh-right /></el-icon>
+            </div>
+          </div>
+        </div>
+        <div class="rr-header-right-left-br ele-scrollbar-hide hidden-xs-only">
+          <base-sidebar v-if="state.sidebarLayout === ESidebarLayoutEnum.Top" mode="horizontal" :router="true"></base-sidebar>
+          <header-mix-nav-menus v-else-if="state.sidebarLayout === ESidebarLayoutEnum.Mix"></header-mix-nav-menus>
+          <breadcrumb v-else></breadcrumb>
+        </div>
+      </div>
+      <div style="flex-shrink: 0">
+        <expand :userName="store.state.user.username"></expand>
+      </div>
+    </div>
+  </div>
+</template>

+ 34 - 0
src/layout/header/breadcrumb.vue

@@ -0,0 +1,34 @@
+<script lang="ts">
+import { IObject } from "@/types/interface";
+import { getValueByKeys } from "@/utils/utils";
+import { defineComponent, ref, watch } from "vue";
+import { RouteLocationMatched, useRouter } from "vue-router";
+
+/**
+ * 顶部面包屑
+ */
+export default defineComponent({
+  name: "Breadcrumb",
+  setup() {
+    const router = useRouter();
+    const breadcrumbs = ref<IObject[]>([]);
+    const { currentRoute } = router;
+    const firstRoute = (router.options.routes[0] || {}) as RouteLocationMatched;
+    const home: RouteLocationMatched = firstRoute.children && firstRoute.children.length > 0 ? (firstRoute.children[0] as RouteLocationMatched) : firstRoute;
+    watch(
+      () => currentRoute.value,
+      () => {
+        breadcrumbs.value = currentRoute.value.path !== home.path ? getValueByKeys(currentRoute.value, "meta.matched", []) : [];
+      }
+    );
+
+    return { breadcrumbs, currentRoute, home };
+  }
+});
+</script>
+<template>
+  <el-breadcrumb separator="/" style="padding-top: 4px">
+    <el-breadcrumb-item :to="{ path: home.path }"> 主页 </el-breadcrumb-item>
+    <el-breadcrumb-item v-for="x in breadcrumbs" :key="x.path">{{ currentRoute.query._mt || x.title || "" }} </el-breadcrumb-item>
+  </el-breadcrumb>
+</template>

+ 39 - 0
src/layout/header/collapse-sidebar-btn.vue

@@ -0,0 +1,39 @@
+<script lang="ts">
+import SvgIcon from "@/components/base/svg-icon";
+import { EMitt, EThemeSetting } from "@/constants/enum";
+import emits from "@/utils/emits";
+import { getThemeConfigCacheByKey, setThemeConfigToCache } from "@/utils/theme";
+import { defineComponent, reactive } from "vue";
+
+/**
+ * PC和移动端下的侧边栏展开收起按钮
+ */
+export default defineComponent({
+  name: "CollapseSidebarBtn",
+  components: { SvgIcon },
+  setup() {
+    const state = reactive({
+      collapseSidebar: getThemeConfigCacheByKey(EThemeSetting.SidebarCollapse)
+    });
+    const onClickSidebarSwitcher = () => {
+      const key = EThemeSetting.SidebarCollapse;
+      state.collapseSidebar = !state.collapseSidebar;
+      emits.emit(EMitt.OnSwitchLeftSidebar);
+      emits.emit(EMitt.OnSetTheme, [key, key + "-" + state.collapseSidebar]);
+      setThemeConfigToCache(key, state.collapseSidebar);
+    };
+    const onClickSidebarSwitcherByMobile = () => {
+      emits.emit(EMitt.OnMobileOpenSidebar);
+    };
+    return { state, onClickSidebarSwitcher, onClickSidebarSwitcherByMobile };
+  }
+});
+</script>
+<template>
+  <div class="hidden-xs-only" @click="onClickSidebarSwitcher">
+    <svg-icon :name="state.collapseSidebar ? 'indent' : 'outdent'"></svg-icon>
+  </div>
+  <div class="hidden-sm-and-up show-xs-only" @click="onClickSidebarSwitcherByMobile">
+    <svg-icon name="icon-indent"></svg-icon>
+  </div>
+</template>

+ 93 - 0
src/layout/header/expand.vue

@@ -0,0 +1,93 @@
+<script lang="ts">
+import SvgIcon from "@/components/base/svg-icon";
+import baseService from "@/service/baseService";
+import { useFullscreen } from "@vueuse/core";
+import { defineComponent } from "vue";
+import { useRouter } from "vue-router";
+import { useAppStore } from "@/store";
+import userLogo from "@/assets/images/user.png";
+import "@/assets/css/header.less";
+import { ElMessageBox } from "element-plus";
+
+interface IExpand {
+  userName?: string;
+}
+
+/**
+ * 顶部右侧扩展区域
+ */
+export default defineComponent({
+  name: "Expand",
+  components: { SvgIcon },
+  props: {
+    userName: String
+  },
+  setup(props: IExpand) {
+    const router = useRouter();
+    const store = useAppStore();
+    const { isFullscreen, toggle } = useFullscreen();
+
+    const onClickUserMenus = (path: string) => {
+      if (path === "/login") {
+        ElMessageBox.confirm("确定进行[退出]操作?", "提示", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        })
+          .then(() => {
+            baseService.post("/logout").finally(() => {
+              router.push(path);
+            });
+          })
+          .catch(() => {
+            //
+          });
+      } else {
+        router.push(path);
+      }
+    };
+    return {
+      props,
+      store,
+      isFullscreen,
+      userLogo,
+      onClickUserMenus,
+      toggle
+    };
+  }
+});
+</script>
+<template>
+  <div class="rr-header-right-items">
+    <div>
+      <a href="" target="_blank">
+        <svg-icon name="icon-earth"></svg-icon>
+      </a>
+    </div>
+    <div>
+      <a href="" target="_blank">
+        <svg-icon name="icon-gitee"></svg-icon>
+      </a>
+    </div>
+    <div @click="toggle" class="hidden-xs-only">
+      <span>
+        <svg-icon :name="isFullscreen ? 'tuichuquanping' : 'fullscreen2'"></svg-icon>
+      </span>
+    </div>
+    <div style="display: flex; justify-content: center; align-items: center">
+      <img :src="userLogo" :alt="props.userName" style="width: 30px; height: 30px; border-radius: 50%; margin-top: 3px; margin-right: 5px" />
+      <el-dropdown @command="onClickUserMenus">
+        <template #dropdown>
+          <el-dropdown-menu>
+            <el-dropdown-item icon="lock" command="/user/password"> 修改密码 </el-dropdown-item>
+            <el-dropdown-item icon="switch-button" divided command="/login"> 退出登录 </el-dropdown-item>
+          </el-dropdown-menu>
+        </template>
+        <span class="el-dropdown-link" style="display: flex">
+          {{ props.userName }}
+          <el-icon class="el-icon--right" style="font-size: 14px"><arrow-down /></el-icon>
+        </span>
+      </el-dropdown>
+    </div>
+  </div>
+</template>

+ 64 - 0
src/layout/header/header-mix-nav-menus.vue

@@ -0,0 +1,64 @@
+<script lang="ts">
+import { EMitt, ESidebarLayoutEnum, EThemeSetting } from "@/constants/enum";
+import emits from "@/utils/emits";
+import { getThemeConfigCacheByKey } from "@/utils/theme";
+import { getValueByKeys } from "@/utils/utils";
+import { computed, defineComponent, reactive, watch } from "vue";
+import { RouteRecordRaw, useRoute, useRouter } from "vue-router";
+import { useAppStore } from "@/store";
+import BaseSidebar from "../sidebar/base-sidebar.vue";
+
+/**
+ * 顶部导航菜单,混合布局模式下用到
+ */
+export default defineComponent({
+  name: "HeaderMixNavMenus",
+  components: { BaseSidebar },
+  setup() {
+    const store = useAppStore();
+    const router = useRouter();
+    const route = useRoute();
+    const routers = router.options.routes;
+    const state = reactive({
+      currRoute: getValueByKeys(getValueByKeys(router.currentRoute.value.meta, "matched", [])[0], "path", "")
+    });
+    watch(
+      () => route.path,
+      () => {
+        if (getThemeConfigCacheByKey(EThemeSetting.NavLayout) === ESidebarLayoutEnum.Mix) {
+          const matchedRoute = getValueByKeys(getValueByKeys(router.currentRoute.value.meta, "matched", [])[0], "path", "");
+          if (matchedRoute) {
+            state.currRoute = matchedRoute;
+            emits.emit(EMitt.OnSelectHeaderNavMenusByMixNav, matchedRoute);
+          }
+        }
+      }
+    );
+    const topHeaderMenus = computed(() => {
+      const rs: any[] = [];
+      store.state.routes.forEach((x: RouteRecordRaw) => {
+        rs.push({
+          path: x.path,
+          children: [],
+          meta: x.meta ? x.meta : {}
+        });
+      });
+      return rs;
+    });
+    const onSelect = (path: string) => {
+      const curr = routers.find((x: RouteRecordRaw) => x.path === path);
+
+      if (!curr?.children?.length) {
+        router.push(path);
+      } else {
+        state.currRoute = path;
+        emits.emit(EMitt.OnSelectHeaderNavMenusByMixNav, path);
+      }
+    };
+    return { state, topHeaderMenus, onSelect };
+  }
+});
+</script>
+<template>
+  <base-sidebar mode="horizontal" :menus="topHeaderMenus" :router="false" :currRoute="state.currRoute" :is-mobile="false" :onSelect="onSelect"></base-sidebar>
+</template>

+ 35 - 0
src/layout/header/logo.vue

@@ -0,0 +1,35 @@
+<script lang="ts">
+import app from "@/constants/app";
+import { defineComponent } from "vue";
+interface ILogo {
+  logoUrl?: string;
+  logoName?: string;
+}
+
+/**
+ * 顶部logo
+ */
+export default defineComponent({
+  name: "Logo",
+  props: {
+    logoUrl: String,
+    logoName: {
+      type: String,
+      default: "logo"
+    }
+  },
+  setup(props: ILogo) {
+    return { props, app };
+  }
+});
+</script>
+
+<template>
+  <span :class="`rr-header-ctx-logo-img-wrap ${'enabled-logo-' + app.enabledLogo}`">
+    <!-- 支持显示图片logo或者产品名称缩写,二选一模式,通过注释开启功能,app.enabledLogo控制正常模式下图片logo是否显示,如果有图片logo,收起状态会强制显示图片logo -->
+    <!-- <img :src="props.logoUrl" class="rr-header-ctx-logo-img" :alt="props.logoName" /> -->
+    <span>ringzle</span>
+    <span class="rr-header-ctx-logo-line"></span>
+  </span>
+  <span class="rr-header-ctx-logo-text">{{ props.logoName }}</span>
+</template>

+ 80 - 0
src/layout/index.vue

@@ -0,0 +1,80 @@
+<script lang="ts">
+import { EMitt, ESidebarLayoutEnum, EThemeSetting } from "@/constants/enum";
+import emits from "@/utils/emits";
+import { getThemeConfigCache, getThemeConfigCacheByKey, getThemeConfigToClass } from "@/utils/theme";
+import { getValueByKeys } from "@/utils/utils";
+import { useMediaQuery } from "@vueuse/core";
+import { computed, defineComponent, reactive } from "vue";
+import { RouteRecordRaw, useRouter } from "vue-router";
+import { useAppStore } from "@/store";
+import BaseHeader from "./header/base-header.vue";
+import BaseSidebar from "./sidebar/base-sidebar.vue";
+import MobileSidebar from "./sidebar/mobile-sidebar.vue";
+import BaseView from "./view/base-view.vue";
+
+/**
+ * 多标签页布局
+ */
+export default defineComponent({
+  name: "Layout",
+  components: { BaseView, BaseHeader, BaseSidebar, MobileSidebar },
+  setup() {
+    const isMobile = useMediaQuery("(max-width: 768px)");
+    const themeCache = getThemeConfigCache();
+    const sidebarLayoutCache = getThemeConfigCacheByKey(EThemeSetting.NavLayout, themeCache);
+    const router = useRouter();
+    const store = useAppStore();
+    const state = reactive({
+      isShowNav: sidebarLayoutCache !== ESidebarLayoutEnum.Top,
+      sidebarLayout: sidebarLayoutCache,
+      themeClass: getThemeConfigToClass(themeCache),
+      loading: false,
+      mixLayoutRoutes: router.options.routes.find((x: RouteRecordRaw) => x.path === "/")?.children ?? ([] as RouteRecordRaw[])
+    });
+    const containerClassNames = computed(() =>
+      Object.values(state.themeClass)
+        .concat(isMobile.value ? ["ui-mobile"] : [])
+        .join(" ")
+    );
+    emits.on(EMitt.OnSelectHeaderNavMenusByMixNav, (path) => {
+      state.mixLayoutRoutes = store.state.routes.find((x: RouteRecordRaw) => x.path === path)?.children ?? [];
+    });
+    emits.on(EMitt.OnSetTheme, ([type, value]) => {
+      state.themeClass[type] = "ui-" + value;
+    });
+    emits.on(EMitt.OnSetNavLayout, (vl) => {
+      state.sidebarLayout = vl;
+      state.isShowNav = vl !== ESidebarLayoutEnum.Top;
+      if (vl === ESidebarLayoutEnum.Mix) {
+        const currRoute = getValueByKeys(getValueByKeys(router.currentRoute.value.meta, "matched", [])[0], "path", "");
+        state.mixLayoutRoutes = store.state.routes.find((x: RouteRecordRaw) => x.path === currRoute)?.children ?? [];
+      }
+    });
+    emits.on(EMitt.OnLoading, (vl) => {
+      state.loading = vl;
+    });
+    return { state, ESidebarLayoutEnum, containerClassNames };
+  }
+});
+</script>
+<template>
+  <el-container :class="`rr ${containerClassNames}`" v-loading="state.loading" element-loading-background="#0000" element-loading-lock="true" element-loading-custom-class="rr-loading">
+    <el-header class="rr-header" height="50px">
+      <base-header></base-header>
+    </el-header>
+    <el-container class="rr-body">
+      <el-aside v-if="state.isShowNav" class="rr-sidebar hidden-xs-only" width="auto">
+        <base-sidebar v-if="state.sidebarLayout === ESidebarLayoutEnum.Left" :router="true" mode="vertical" :is-mobile="false"></base-sidebar>
+        <base-sidebar v-else :menus="state.mixLayoutRoutes" :router="true" mode="vertical" :is-mobile="false"></base-sidebar>
+      </el-aside>
+      <div class="rr-sidebar rr-sidebar-mobile hidden-sm-and-up show-xs-only">
+        <mobile-sidebar></mobile-sidebar>
+      </div>
+      <el-container class="rr-view-container">
+        <el-main class="rr-view">
+          <base-view></base-view>
+        </el-main>
+      </el-container>
+    </el-container>
+  </el-container>
+</template>

+ 3 - 0
src/layout/layout.vue

@@ -0,0 +1,3 @@
+<template>
+  <router-view></router-view>
+</template>

+ 162 - 0
src/layout/sidebar/base-sidebar.vue

@@ -0,0 +1,162 @@
+<script lang="ts">
+import { themeSetting } from "@/constants/config";
+import { EMitt, EThemeSetting } from "@/constants/enum";
+import { IObject } from "@/types/interface";
+import Layout from "@/layout/layout.vue";
+import emits from "@/utils/emits";
+import { toValidRoutes } from "@/utils/router";
+import { getThemeConfigCacheByKey } from "@/utils/theme";
+import { useWindowSize } from "@vueuse/core";
+import { defineComponent, onMounted, reactive, ref, watch } from "vue";
+import { RouteRecordRaw, useRoute, useRouter } from "vue-router";
+import { useAppStore } from "@/store";
+import SidebarMenusItems from "./sidebar-menus-items.vue";
+import { getValueByKeys } from "@/utils/utils";
+
+/**
+ * 侧边栏导航菜单
+ */
+export default defineComponent({
+  name: "BaseSidebar",
+  components: { SidebarMenusItems },
+  props: {
+    mode: { type: String, default: "vertical" },
+    menus: Array,
+    currRoute: String,
+    router: Boolean,
+    onSelect: Function,
+    isMobile: Boolean
+  },
+  setup(props) {
+    const route = useRoute();
+    const router = useRouter();
+    const win = useWindowSize();
+    const store = useAppStore();
+    const defaultMenus = toValidRoutes((props.menus ?? store.state.routes) as RouteRecordRaw[]);
+    const getPopClassName = () => {
+      const sidebarCache = getThemeConfigCacheByKey(EThemeSetting.Sidebar);
+      return `rr-sidebar-menu-pop-${props.mode === "vertical" && sidebarCache === "dark" ? "dark" : "light"}`;
+    };
+    const state = reactive({
+      collapseSidebar: getThemeConfigCacheByKey(EThemeSetting.SidebarCollapse),
+      uniqueOpened: themeSetting.sidebarUniOpened,
+      windowWidth: win.width || 800,
+      hiddenIndex: -1,
+      rawMenus: defaultMenus,
+      menus: defaultMenus,
+      popClassName: getPopClassName(),
+      currRoute: props.currRoute ?? route.path
+    });
+    const elm = ref({} as IObject);
+    const li = ref({
+      widths: [] as number[]
+    });
+    const initComputeSidebarLayout = (width: number) => {
+      if (props.mode === "horizontal") {
+        //存储水平布局元素信息
+        const el = elm.value.$el;
+        const lis = el.querySelectorAll("li");
+        li.value.widths = [];
+        lis.forEach((x: Element) => {
+          li.value.widths.push(x.getBoundingClientRect().width);
+        });
+        computeSidebarLayout(width);
+      }
+    };
+
+    //
+    onMounted(() => {
+      initComputeSidebarLayout(state.windowWidth);
+    });
+    watch(
+      () => props.menus,
+      (vl) => {
+        const ms = toValidRoutes((vl ? vl : store.state.routes) as RouteRecordRaw[]);
+        state.menus = ms;
+        state.rawMenus = ms;
+      }
+    );
+    watch(
+      () => store.state.routes,
+      (vl) => {
+        const ms = toValidRoutes(vl as RouteRecordRaw[]);
+        state.rawMenus = ms;
+        state.menus = ms;
+      }
+    );
+    emits.on(EMitt.OnSwitchLeftSidebar, () => {
+      state.collapseSidebar = !state.collapseSidebar;
+    });
+    emits.on(EMitt.OnSetThemeNotUniqueOpened, (vl) => {
+      state.uniqueOpened = vl;
+    });
+    emits.on(EMitt.OnSetTheme, ([vl]) => {
+      if (vl === EThemeSetting.Sidebar) {
+        state.popClassName = getPopClassName();
+      }
+    });
+    watch(
+      () => route.path,
+      (vl) => {
+        const matchedRoute = getValueByKeys(getValueByKeys(router.currentRoute.value.meta, "matched", [])[0], "path", "");
+        if (!route.query.pop && matchedRoute) {
+          setTimeout(() => {
+            state.currRoute = vl;
+          }, 10);
+        }
+      }
+    );
+    watch(
+      () => state.windowWidth,
+      (vl) => {
+        computeSidebarLayout(vl);
+      }
+    );
+
+    const computeSidebarLayout = (windowWidth: number) => {
+      if (props.mode === "horizontal" && windowWidth > 768 && elm.value.$el) {
+        //菜单水平方向菜单过长,采用折叠效果
+        const width = elm.value.$el.parentNode.getBoundingClientRect().width;
+        let liWidth = 0;
+        let index = -1;
+        for (let i = 0; i < li.value.widths.length; i++) {
+          liWidth += li.value.widths[i];
+          if (liWidth > width) {
+            index = i - 1;
+            break;
+          }
+        }
+
+        state.hiddenIndex = index;
+        state.menus =
+          index > -1
+            ? state.rawMenus.slice(0, index).concat({
+                path: "/__more",
+                component: Layout,
+                meta: { title: "更多菜单", icon: false, isMore: true },
+                children: state.rawMenus.slice(index)
+              })
+            : state.rawMenus;
+      }
+    };
+
+    return { elm, props, state };
+  }
+});
+</script>
+
+<template>
+  <el-menu
+    ref="elm"
+    :default-active="props.currRoute ?? state.currRoute"
+    :mode="props.mode"
+    :collapse="props.isMobile ? false : props.mode === 'vertical' && state.collapseSidebar"
+    :router="props.router"
+    :unique-opened="state.uniqueOpened"
+    :onSelect="props.onSelect"
+    :collapse-transition="false"
+    class="rr-sidebar-menu"
+  >
+    <sidebar-menus-items :className="state.popClassName" :menus="state.menus" :hiddenIndex="state.hiddenIndex"></sidebar-menus-items>
+  </el-menu>
+</template>

+ 39 - 0
src/layout/sidebar/mobile-sidebar.vue

@@ -0,0 +1,39 @@
+<script lang="ts">
+import { EMitt } from "@/constants/enum";
+import emits from "@/utils/emits";
+import { defineComponent, reactive } from "vue";
+import BaseSidebar from "./base-sidebar.vue";
+import Logo from "../header/logo.vue";
+import logoUrl from "@/assets/images/logo.png";
+
+/**
+ * 移动端侧边栏菜单
+ */
+export default defineComponent({
+  name: "MobileSidebar",
+  components: { BaseSidebar, Logo },
+  setup() {
+    const state = reactive({
+      show: true
+    });
+    emits.on(EMitt.OnMobileOpenSidebar, () => {
+      state.show = true;
+    });
+    const onSelect = () => {
+      state.show = false;
+    };
+    return { state, onSelect, logoUrl };
+  }
+});
+</script>
+
+<template>
+  <el-drawer v-model="state.show" :append-to-body="false" size="230" :withHeader="false" direction="ltr" class="rr-setting-wrap">
+    <div class="rr-header-ctx-logo rr-header-ctx-logo-mobile">
+      <logo :logoUrl="logoUrl" logoName="ringzle"></logo>
+    </div>
+    <div class="rr-sidebar-mobile-inner" style="overflow: auto; height: calc(100vh - 50px); width: initial !important">
+      <base-sidebar :router="true" mode="vertical" :isMobile="true" :onSelect="onSelect"></base-sidebar>
+    </div>
+  </el-drawer>
+</template>

+ 51 - 0
src/layout/sidebar/sidebar-menus-items.vue

@@ -0,0 +1,51 @@
+<script lang="ts">
+import { defineComponent, PropType } from "vue";
+import classNames from "classnames";
+import SvgIcon from "@/components/base/svg-icon";
+import { RouteRecordRaw } from "vue-router";
+
+export default defineComponent({
+  name: "SidebarMenusItems",
+  components: { SvgIcon },
+  props: {
+    menus: Array as PropType<RouteRecordRaw[]>,
+    hiddenIndex: Number,
+    className: String
+  },
+  setup(props) {
+    const getStyle = (index: number): string => {
+      const styles: Array<any> = [];
+      const isHidden = props.hiddenIndex ? props.hiddenIndex > -1 && index > props.hiddenIndex : false;
+      styles.push("display:" + (isHidden ? "none" : "block"));
+      return styles.join(";");
+    };
+    return { props, classNames, getStyle };
+  }
+});
+</script>
+<template>
+  <template v-for="(x, index) in props.menus || []" :key="x.path">
+    <el-sub-menu v-if="x.children && x.children.length > 0" :index="x.path" :popper-class="props.className" :class="classNames({ isMore: x.meta?.isMore })" :style="getStyle(index)">
+      <template #title>
+        <el-icon v-if="x.meta?.icon !== false">
+          <svg-icon :name="`${x.meta?.icon || 'icon-file-fill'}`"></svg-icon>
+        </el-icon>
+        <span>
+          <a>{{ x.meta?.title }}</a>
+        </span>
+      </template>
+      <sidebar-menus-items :menus="x.children"></sidebar-menus-items>
+    </el-sub-menu>
+    <el-menu-item v-else :index="x.meta?.isNewPage ? x.path : x.path" :class="classNames({ isLink: !!x.meta?.isNewPage, isMore: x.meta?.isMore })" :style="getStyle(index)">
+      <template #title>
+        <a v-if="x.meta?.isNewPage" :href="`${x.meta.url}`" target="_blank" rel="opener">
+          {{ x.meta.title }}
+        </a>
+        <a v-else>{{ x.meta?.title }}</a>
+      </template>
+      <el-icon v-if="x.meta?.icon !== false">
+        <svg-icon :name="`${x.meta?.icon || 'icon-file-fill'}`"></svg-icon>
+      </el-icon>
+    </el-menu-item>
+  </template>
+</template>

+ 47 - 0
src/layout/view/base-view.vue

@@ -0,0 +1,47 @@
+<script lang="ts">
+import app from "@/constants/app";
+import { EMitt, EThemeSetting } from "@/constants/enum";
+import emits from "@/utils/emits";
+import { getThemeConfigCacheByKey } from "@/utils/theme";
+import { defineComponent, reactive, ref } from "vue";
+import { useRoute } from "vue-router";
+import { useAppStore } from "@/store";
+import Tabs from "./tabs.vue";
+
+/**
+ * 业务内容视图框架
+ */
+export default defineComponent({
+  name: "View",
+  components: { Tabs },
+  setup() {
+    const store = useAppStore();
+    const route = useRoute();
+    const state = reactive({
+      openTabsPage: getThemeConfigCacheByKey(EThemeSetting.OpenTabsPage)
+    });
+    const routerKeys = ref({} as any);
+    emits.on(EMitt.OnSetThemeTabsPage, (vl) => {
+      state.openTabsPage = vl;
+    });
+    emits.on(EMitt.OnReloadTabPage, () => {
+      routerKeys.value[route.fullPath] = new Date().getTime();
+    });
+    return { state, store, enabledKeepAlive: app.enabledKeepAlive, routerKeys };
+  }
+});
+</script>
+
+<template>
+  <tabs v-if="state.openTabsPage" :tabs="store.state.tabs" :activeTabName="store.state.activeTabName"></tabs>
+  <div class="rr-view-ctx">
+    <el-card shadow="never" class="rr-view-ctx-card">
+      <router-view v-slot="{ Component }">
+        <keep-alive v-if="enabledKeepAlive">
+          <component :is="Component" :key="routerKeys[$route.fullPath] || $route.fullPath" />
+        </keep-alive>
+        <component :is="Component" v-if="!enabledKeepAlive" />
+      </router-view>
+    </el-card>
+  </div>
+</template>

+ 155 - 0
src/layout/view/tabs.vue

@@ -0,0 +1,155 @@
+<script lang="ts">
+import SvgIcon from "@/components/base/svg-icon";
+import { EMitt } from "@/constants/enum";
+import { IObject } from "@/types/interface";
+import emits from "@/utils/emits";
+import { arrayToObject } from "@/utils/utils";
+import { ElMessage } from "element-plus";
+import { findIndex } from "lodash";
+import { defineComponent, reactive, watch } from "vue";
+import { RouteLocationMatched, useRouter } from "vue-router";
+import { useAppStore } from "@/store";
+
+/**
+ * tab标签页
+ */
+export default defineComponent({
+  name: "Tabs",
+  components: { SvgIcon },
+  props: {
+    tabs: Array,
+    activeTabName: String
+  },
+  setup(props) {
+    const ops = [
+      { label: "关闭当前标签页", value: 5, icon: "close" },
+      { label: "关闭其他标签页", value: 1, icon: "close" },
+      { label: "关闭全部标签页", value: 4, icon: "circle-close" }
+    ];
+    const router = useRouter();
+    const store = useAppStore();
+    const firstRoute = (router.options.routes[0] || {}) as RouteLocationMatched;
+    const home: RouteLocationMatched = firstRoute.children && firstRoute.children.length > 0 ? (firstRoute.children[0] as RouteLocationMatched) : firstRoute;
+    const defaultTab = { label: "", value: home.path };
+    const state = reactive({
+      activeTabName: props.activeTabName || defaultTab.value,
+      tabs: (props.tabs && props.tabs.length ? props.tabs : [defaultTab]) as IObject[]
+    });
+    watch(
+      () => state.tabs,
+      (res) => {
+        store.updateState({ tabs: res });
+      },
+      { deep: true }
+    );
+    emits.on(EMitt.OnPushMenuToTabs, (route) => {
+      const path: string = route.value;
+      if (path.includes("/error")) {
+        return;
+      }
+      const tabKeys: IObject<number> = arrayToObject(state.tabs, "value", () => 1);
+      if (!tabKeys[path]) {
+        state.tabs.push(route);
+      }
+      if (state.activeTabName !== path) {
+        state.activeTabName = path;
+      }
+    });
+    emits.on(EMitt.OnCloseCurrTab, () => {
+      onClose(5);
+    });
+    const onTabClick = (tab: any) => {
+      tab.props.name && router.push(tab.props.name);
+    };
+    const onTabRemove = (targetName: string) => {
+      const index = findIndex(state.tabs, (x) => x.value === targetName);
+      if (state.tabs.length > 1) {
+        updateClosedTabs([...store.state.closedTabs, targetName], false);
+        if (state.activeTabName === targetName) {
+          const toIndex = index === 0 ? index + 1 : index - 1;
+          state.activeTabName = state.tabs[toIndex].value;
+          router.push(state.activeTabName);
+        }
+        state.tabs.splice(index, 1);
+      } else {
+        ElMessage({ type: "error", message: "只剩下一个标签页,不支持关闭", offset: 0 });
+      }
+    };
+    const updateClosedTabs = (closedTabs: any[], isTransform = true) => {
+      if (isTransform) {
+        closedTabs = closedTabs.map((x) => x.value);
+      }
+      store.updateState({ closedTabs });
+    };
+    const onClose = (value: number) => {
+      let index = null;
+      const rawTabs = state.tabs;
+      switch (value) {
+        case 1:
+          //其他
+          state.tabs = state.tabs.filter((x) => [home.path, state.activeTabName].includes(x.value));
+          updateClosedTabs(rawTabs.filter((x) => ![home.path, state.activeTabName].includes(x.value)));
+          break;
+        case 2:
+          //右侧
+          index = findIndex(state.tabs, (x) => x.value === state.activeTabName);
+          state.tabs.splice(index + 1, state.tabs.length - (index + 1));
+          updateClosedTabs(rawTabs.slice(index + 1));
+          break;
+        case 3:
+          //左侧
+          index = findIndex(state.tabs, (x) => x.value === state.activeTabName);
+          state.tabs.splice(1, index - 1);
+          updateClosedTabs(rawTabs.slice(1, index - 1));
+          break;
+        case 4:
+          //全部
+          state.tabs = [defaultTab];
+          state.activeTabName = defaultTab.value;
+          updateClosedTabs(rawTabs);
+          router.push(state.activeTabName);
+          break;
+        case 5:
+          //当前
+          if (state.activeTabName !== defaultTab.value) {
+            updateClosedTabs([...store.state.closedTabs, state.activeTabName], false);
+            index = findIndex(state.tabs, (x) => x.value === state.activeTabName);
+            state.tabs.splice(index, 1);
+            state.activeTabName = state.tabs[state.tabs.length - 1].value;
+            router.push(state.activeTabName);
+          }
+          break;
+        default:
+          break;
+      }
+    };
+    return { state, onTabClick, onTabRemove, home, onClose, ops };
+  }
+});
+</script>
+<template>
+  <div class="rr-view-tab-wrap">
+    <el-tabs class="rr-view-tab" v-model="state.activeTabName" @tab-click="onTabClick" @tab-remove="onTabRemove">
+      <el-tab-pane :name="home.path" :closable="false">
+        <template #label>
+          <!-- 文字主页和图标主页tab -->
+          <!-- {{ t("ui.router.pageHome") }} -->
+          <svg-icon name="home"></svg-icon>
+        </template>
+      </el-tab-pane>
+      <el-tab-pane v-for="x in state.tabs.slice(1)" :key="x.value" :label="x.label" :name="x.value" :closable="true"></el-tab-pane>
+    </el-tabs>
+    <el-dropdown trigger="click" placement="bottom-end" class="rr-view-tab-ops" @command="onClose">
+      <template #dropdown>
+        <el-dropdown-menu>
+          <el-dropdown-item v-for="x in ops" :key="x.value" :icon="x.icon" :command="x.value">
+            {{ x.label }}
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </template>
+      <span class="el-dropdown-link">
+        <el-icon class="el-icon--right"><arrow-down /></el-icon>
+      </span>
+    </el-dropdown>
+  </div>
+</template>

+ 34 - 0
src/main.ts

@@ -0,0 +1,34 @@
+import "@/assets/icons/iconfont/iconfont.js";
+import RenDeptTree from "@/components/ren-dept-tree";
+import RenRadioGroup from "@/components/ren-radio-group";
+import RenRegionTree from "@/components/ren-region-tree";
+import RenSelect from "@/components/ren-select";
+import ElementPlus from "element-plus";
+import "element-plus/theme-chalk/display.css";
+import "element-plus/theme-chalk/index.css";
+import locale from "element-plus/es/locale/lang/zh-cn";
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import App from "./App.vue";
+import router from "./router";
+import * as ElementPlusIcons from "@element-plus/icons-vue";
+
+import axios from "axios";
+import "virtual:svg-icons-register";
+
+const app = createApp(App);
+Object.keys(ElementPlusIcons).forEach((iconName) => {
+  app.component(iconName, ElementPlusIcons[iconName as keyof typeof ElementPlusIcons]);
+});
+
+app
+  .use(createPinia())
+  .use(router)
+  .use(RenRadioGroup)
+  .use(RenSelect)
+  .use(RenDeptTree)
+  .use(RenRegionTree)
+  .use(ElementPlus, { size: "default", locale: locale })
+  .mount("#app");
+
+window.axios = axios;

+ 52 - 0
src/router/base.ts

@@ -0,0 +1,52 @@
+import Layout from "@/layout/layout.vue";
+import Error from "@/views/error.vue";
+import { RouteRecordRaw } from "vue-router";
+import Login from "@/views/login.vue";
+import Iframe from "@/views/iframe.vue";
+
+/**
+ * 框架基础路由
+ */
+const routes: Array<RouteRecordRaw> = [
+  {
+    path: "/",
+    component: Layout,
+    redirect: "/home",
+    meta: { title: "工作台", icon: "icon-desktop" },
+    children: [
+      {
+        path: "/home",
+        component: () => import("@/views/home.vue"),
+        meta: { title: "主页", icon: "icon-home" }
+      }
+    ]
+  },
+  {
+    path: "/login",
+    component: Login,
+    meta: { title: "登录", isNavigationMenu: false }
+  },
+  {
+    path: "/user/password",
+    component: () => import("@/views/sys/user-update-password.vue"),
+    meta: { title: "修改密码", requiresAuth: true, isNavigationMenu: false }
+  },
+  {
+    path: "/iframe/:id?",
+    component: Iframe,
+    meta: { title: "iframe", isNavigationMenu: false }
+  },
+  {
+    path: "/error",
+    name: "error",
+    component: Error,
+    meta: { title: "错误页面", isNavigationMenu: false }
+  },
+  {
+    path: "/:path(.*)*",
+    redirect: { path: "/error", query: { to: 404 }, replace: true },
+    meta: { isNavigationMenu: false }
+  }
+];
+
+export default routes;

+ 187 - 0
src/router/index.ts

@@ -0,0 +1,187 @@
+import { IObject } from "@/types/interface";
+import { useAppStore } from "@/store";
+import { getToken } from "@/utils/cache";
+import { getBaseRouteToMeta, registerToRouter } from "@/utils/router";
+import NProgress from "nprogress";
+import "nprogress/nprogress.css";
+import {
+  createRouter,
+  createWebHashHistory,
+  RouteLocationNormalized,
+  RouteRecordRaw
+} from "vue-router";
+import baseRoutes from "./base";
+import emits from "@/utils/emits";
+import { EMitt } from "@/constants/enum";
+
+interface dynamicRouteParams {
+  path: string;
+  query?: IObject;
+  mete?: IObject;
+}
+
+NProgress.configure({ showSpinner: false });
+
+const router = createRouter({
+  history: createWebHashHistory(), //createWebHashHistory() hash模式
+  routes: baseRoutes
+});
+
+// 路由加载前
+router.beforeEach((to, from, next) => {
+  //外链
+  if (to.meta.isNewPage) {
+    if (to.query.pop !== "true") {
+      next(undefined);
+      return false;
+    }
+  }
+
+  const store = useAppStore();
+
+  //token
+  const token = getToken();
+  const isPop = to.query.pop === "true"; //新窗口打开内页
+  NProgress.start();
+  if (to.path !== "/login") {
+    if (store.state.routes.length) {
+      if (to.name === "error") {
+        const isMatched = autoRegisterDynamicToRouterAndNext(to);
+        if (!isMatched) {
+          store.updateState({ appIsRender: true, appIsReady: true });
+          next();
+        }
+      } else {
+        if (!to.query.pop) {
+          const routeMeta: IObject = store.state.routeToMeta[to.path];
+          emits.emit(EMitt.OnPushMenuToTabs, {
+            label: to.query._mt || routeMeta.title || to.path,
+            value: to.fullPath,
+            mete: routeMeta
+          });
+        }
+        store.updateState({ appIsRender: true, appIsReady: true });
+        next();
+      }
+    } else {
+      if (token) {
+        store.initApp().then((res: Array<RouteRecordRaw>) => {
+          const mergeRoute = baseRoutes.concat(res);
+          router.options.routes = mergeRoute;
+          registerToRouter(router, mergeRoute);
+          if (!to.matched.length) {
+            registerDynamicToRouterAndNext({ path: to.path, query: to.query });
+          }
+          store.updateState({
+            appIsReady: true,
+            routes: mergeRoute,
+            routeToMeta: { ...store.state.routeToMeta, ...getBaseRouteToMeta(baseRoutes) }
+          });
+          setTimeout(() => {
+            store.updateState({ appIsRender: true, appIsLogin: true });
+          }, 600);
+          next({ ...to, replace: true });
+        });
+      } else {
+        if (isPop) {
+          if (!to.matched.length) {
+            registerDynamicToRouterAndNext({ path: to.path, query: to.query });
+            store.updateState({ appIsRender: true, appIsReady: true });
+            next(to.fullPath);
+          } else {
+            store.updateState({ appIsRender: true, appIsReady: true });
+            if (to.meta.requiresAuth) {
+              next("/login");
+            } else {
+              next();
+            }
+          }
+        } else {
+          next("/login");
+        }
+      }
+    }
+  } else {
+    store.updateState({ appIsReady: true, appIsRender: true });
+    next();
+  }
+});
+
+// 路由加载后
+router.afterEach(() => {
+  NProgress.done();
+});
+
+/**
+ * 获取系统视图路径映射
+ * @returns
+ */
+export const getSysRouteMap = (): IObject => {
+  return import.meta.glob("/src/views/**/*.vue");
+};
+
+/**
+ * 根据路由path转换为系统视图组件路径
+ * @param path
+ * @returns
+ */
+export const toSysViewComponentPath = (path: string): string => {
+  path = path.replace("_", "-");
+  return `/src/views${path}.vue`;
+};
+/**
+ * 自动注册路由
+ * @param to
+ * @returns
+ */
+const autoRegisterDynamicToRouterAndNext = (to: RouteLocationNormalized): boolean => {
+  if (to.redirectedFrom) {
+    const path = to.redirectedFrom.path;
+    const component = matchedSysRouteComponent(path);
+    if (component) {
+      registerToRouter(router, [
+        {
+          path: path,
+          name: path,
+          component,
+          redirect: ""
+        }
+      ]);
+      router.push(to.redirectedFrom);
+      return true;
+    }
+  }
+  return false;
+};
+
+/**
+ * 寻找视图组件
+ * @param path
+ * @returns
+ */
+const matchedSysRouteComponent = (path: string): any => {
+  const sysRouteMap = getSysRouteMap();
+  const component = sysRouteMap[toSysViewComponentPath(path)];
+  if (!component) {
+    console.error("实时注册动态路由失败,未找到组件路径", path);
+  }
+  return component;
+};
+
+/**
+ * 实时注册动态路由并直接跳转过去
+ * @param route
+ */
+export const registerDynamicToRouterAndNext = (route: dynamicRouteParams): void => {
+  const component = matchedSysRouteComponent(route.path);
+  const newRoute: RouteRecordRaw = {
+    path: route.path,
+    name: route.path,
+    component,
+    redirect: !component ? { path: "/error", query: { to: 404 }, replace: true } : ""
+  };
+  registerToRouter(router, [newRoute]);
+  router.push(route);
+};
+
+export default router;

+ 65 - 0
src/service/baseService.ts

@@ -0,0 +1,65 @@
+import { IHttpResponse, IObject } from "@/types/interface";
+import http from "../utils/http";
+
+/**
+ * 常用CRUD
+ */
+export default {
+  /**
+   * 删除
+   * @param path
+   * @param params
+   * @returns
+   */
+  delete(path: string, params: IObject): Promise<IHttpResponse> {
+    return http({
+      url: path,
+      data: params,
+      method: "DELETE"
+    });
+  },
+  get(path: string, params?: IObject, headers?: IObject): Promise<IHttpResponse> {
+    return new Promise((resolve, reject) => {
+      http({
+        url: path,
+        params,
+        headers,
+        method: "GET"
+      })
+        .then(resolve)
+        .catch((error) => {
+          if (error !== "-999") {
+            reject(error);
+          }
+        });
+    });
+  },
+  put(path: string, params?: IObject, headers?: IObject): Promise<IHttpResponse> {
+    return http({
+      url: path,
+      data: params,
+      headers: {
+        "Content-Type": "application/json;charset=UTF-8",
+        ...headers
+      },
+      method: "PUT"
+    });
+  },
+  /**
+   * 通用post方法
+   * @param path
+   * @param body
+   * @returns
+   */
+  post(path: string, body?: IObject, headers?: IObject): Promise<IHttpResponse> {
+    return http({
+      url: path,
+      method: "post",
+      headers: {
+        "Content-Type": "application/json;charset=UTF-8",
+        ...headers
+      },
+      data: body
+    });
+  }
+};

+ 83 - 0
src/store/index.ts

@@ -0,0 +1,83 @@
+import { CacheToken } from "@/constants/cacheKey";
+import { IObject } from "@/types/interface";
+import { getSysRouteMap } from "@/router";
+import baseService from "@/service/baseService";
+import { removeCache } from "@/utils/cache";
+import { mergeServerRoute } from "@/utils/router";
+import { defineStore } from "pinia";
+
+export const useAppStore = defineStore("useAppStore", {
+  state: () => ({
+    state: {
+      appIsLogin: false, //是否登录
+      appIsReady: false, //app数据是否就绪
+      appIsRender: false, //app是否开始渲染内容
+      permissions: [], //权限集合
+      user: {
+        createDate: "",
+        deptId: "",
+        deptName: "",
+        email: "",
+        gender: 0,
+        headUrl: "",
+        id: "",
+        mobile: "",
+        postIdList: "",
+        realName: "",
+        roleIdList: "",
+        status: 0,
+        superAdmin: 0,
+        username: ""
+      }, //用户信息
+      dicts: [], //字典
+      routes: [], //最终的路由集合
+      menus: [], //菜单集合
+      routeToMeta: {}, //url对应标题meta信息
+      tabs: [], //tab标签页集合
+      activeTabName: "", //tab当前焦点页
+      closedTabs: [] //存储已经关闭过的tab
+    } as IObject
+  }),
+  actions: {
+    updateState(data: IObject) {
+      Object.keys(data).forEach((x: string) => {
+        this.state[x] = data[x];
+      });
+    },
+    initApp() {
+      return Promise.all([
+        baseService.get("/sys/menu/nav"), //加载菜单
+        baseService.get("/sys/menu/permissions"), //加载权限
+        baseService.get("/sys/user/info"), //加载用户信息
+        baseService.get("/sys/dict/type/all") //加载字典
+      ]).then(([menus, permissions, user, dicts]) => {
+        if (user.code !== 0) {
+          console.error("初始化用户数据错误", user.msg);
+        }
+        const [routes, routeToMeta] = mergeServerRoute(menus.data || [], getSysRouteMap());
+        this.updateState({
+          permissions: permissions.data || [],
+          user: user.data || {},
+          dicts: dicts.data || [],
+          routeToMeta: routeToMeta || {},
+          menus: []
+        });
+        return routes;
+      });
+    },
+    //退出
+    logout() {
+      removeCache(CacheToken, true);
+      this.updateState({
+        appIsLogin: false,
+        permissions: [],
+        user: {},
+        dicts: [],
+        menus: [],
+        routes: [],
+        tabs: [],
+        activeTabName: ""
+      });
+    }
+  }
+});

+ 6 - 0
src/types/env.d.ts

@@ -0,0 +1,6 @@
+interface ImportMetaEnv {
+  /**
+   * api接口环境
+   */
+  VITE_APP_API: string;
+}

+ 3 - 0
src/types/index.d.ts

@@ -0,0 +1,3 @@
+declare interface Window {
+  axios: any;
+}

+ 176 - 0
src/types/interface.ts

@@ -0,0 +1,176 @@
+export interface IFunction<T = any> {
+  (x?: any): T;
+}
+
+export interface IObject<T = any> {
+  [key: string]: T;
+}
+
+export interface IHttpResponse {
+  code: number;
+  msg: string;
+  data: any;
+}
+
+/**
+ * 菜单
+ */
+export interface IServerMenus {
+  createDate: string;
+  icon: string | boolean;
+  id: string;
+  name: string;
+  parentName: string;
+  permissions: string;
+  pid: string;
+  sort: number;
+  type: number;
+  url: string;
+  openStyle: number;
+  redirect?: string;
+  children?: IServerMenus[];
+}
+
+export interface ICacheOptions {
+  /**
+   * 是否取值后立即删除缓存
+   */
+  isDelete?: boolean;
+  /**
+   * 是否采用JSON格式化缓存值
+   */
+  isParse?: boolean;
+  /**
+   * 是否采用会话缓存介质
+   */
+  isSessionStorage?: boolean;
+}
+
+export interface IViewHooksOptions {
+  //  设置属性
+  /**
+   * 此页面是否在创建时,调用查询数据列表接口?
+   */
+  createdIsNeed?: boolean;
+  /**
+   * 此页面是否在激活(进入)时,调用查询数据列表接口?
+   */
+  activatedIsNeed?: boolean;
+  /**
+   * 数据列表接口,API地址
+   */
+  getDataListURL?: string;
+  /**
+   * 数据列表接口,是否需要分页?
+   */
+  getDataListIsPage?: boolean;
+  /**
+   * 删除接口,API地址
+   */
+  deleteURL?: "";
+  /**
+   * 删除接口,是否需要批量?
+   */
+  deleteIsBatch?: boolean;
+  /**
+   * 删除接口,批量状态下由那个key进行标记操作?比如:pid,uid...
+   */
+  deleteIsBatchKey?: string;
+  /**
+   * 导出接口,API地址
+   */
+  exportURL?: string;
+
+  /**
+   * 查询条件
+   */
+  dataForm?: IObject;
+  /**
+   * 数据列表
+   */
+  dataList?: IObject[];
+  /**
+   * 排序,asc/desc
+   */
+  order?: string;
+  /**
+   * 排序,字段
+   */
+  orderField?: string;
+  /**
+   * 当前页码
+   */
+  page?: number;
+  /**
+   * 每页数
+   */
+  limit?: number;
+  /**
+   * 总条数
+   */
+  total?: number;
+  /**
+   * 数据列表,loading状态
+   */
+  dataListLoading?: boolean;
+  /**
+   * 数据列表,多选项
+   */
+  dataListSelections?: IObject[];
+  elTable?: IObject;
+}
+
+export interface IViewHooks extends IViewHooksOptions, IObject {
+  /**
+   * 检查权限
+   */
+  hasPermission: (key: string) => boolean;
+  /**
+   * 获取字典名称
+   */
+  getDictLabel: (dictType: string, dictValue: number) => string | number;
+  /**
+   * 查询列表记录
+   */
+  query: () => void;
+  /**
+   * 列表多选事件
+   */
+  dataListSelectionChangeHandle: (list: IObject[]) => void;
+  /**
+   * 列表排序事件
+   */
+  dataListSortChangeHandle: (sort: IObject) => void;
+  /**
+   * 列表切换每页显示数量事件
+   */
+  pageSizeChangeHandle: (pageSize: number) => void;
+  /**
+   * 列表分页事件
+   */
+  pageCurrentChangeHandle: (pageIndex: number) => void;
+  /**
+   * 列表搜索事件
+   */
+  getDataList: () => void;
+  /**
+   * 列表删除事件
+   */
+  deleteHandle: (id?: string) => Promise<any>;
+  /**
+   * 列表导出事件
+   */
+  exportHandle: () => void;
+  /**
+   * 关闭当前tab页
+   */
+  closeCurrentTab: () => void;
+  /**
+   * 处理流程
+   */
+  handleFlowRoute: (e: IObject) => void;
+  /**
+   * 查看流程详情
+   */
+  flowDetailRoute: (e: IObject) => void;
+}

+ 29 - 0
src/types/shims.d.ts

@@ -0,0 +1,29 @@
+/* eslint-disable */
+declare module "*.vue" {
+  import type { DefineComponent } from "vue";
+  const component: DefineComponent<{}, {}, any>;
+  export default component;
+}
+
+declare module "*.svg";
+declare module "*.png";
+declare module "*.jpg";
+declare module "*.jpeg";
+declare module "*.gif";
+declare module "*.bmp";
+declare module "*.tiff";
+declare module "*.gif";
+
+declare module "*.less";
+
+declare global {
+  interface ImportMeta {
+    env: Record<string, unknown>;
+    globEager<T = unknown>(globPath: string): Record<string, T>;
+  }
+}
+
+declare module "virtual:*" {
+  const result: any;
+  export default result;
+}

+ 71 - 0
src/utils/cache.ts

@@ -0,0 +1,71 @@
+import { CacheToken } from "@/constants/cacheKey";
+import { ICacheOptions } from "@/types/interface";
+import { isNullOrUndefined } from "./utils";
+
+const fix = "v1@";
+
+/**
+ * 存储介质适配器
+ * @param isSessionStorage
+ * @returns
+ */
+const cacheAdapter = (isSessionStorage?: boolean) => {
+  return isSessionStorage ? sessionStorage : localStorage;
+};
+
+/**
+ * 取缓存值
+ * @param {*} key
+ * @param {*} options
+ */
+export const getCache = (key: string, options?: ICacheOptions, defaultValue?: unknown): any => {
+  key = fix + key;
+  options = { isParse: true, isDelete: false, ...options };
+  try {
+    const value = cacheAdapter(options.isSessionStorage).getItem(key);
+    if (options.isDelete) {
+      cacheAdapter(options.isSessionStorage).removeItem(key);
+    }
+    return isNullOrUndefined(value)
+      ? defaultValue
+      : options.isParse
+      ? value
+        ? JSON.parse(value)
+        : defaultValue
+      : value;
+  } catch (error) {
+    console.error("getCache", error);
+    return defaultValue;
+  }
+};
+
+/**
+ * 设置缓存值
+ * @param {*} key
+ * @param {*} value
+ */
+export const setCache = (
+  key: string,
+  value: string | Record<string, unknown> | Array<any>[],
+  isSessionStorage?: boolean
+): void => {
+  key = fix + key;
+  cacheAdapter(isSessionStorage).setItem(
+    key,
+    typeof value === "object" ? JSON.stringify(value) : value
+  );
+};
+
+/**
+ * 清除缓存
+ * @param key
+ * @param isSessionStorage
+ */
+export const removeCache = (key: string, isSessionStorage?: boolean): void => {
+  key = fix + key;
+  cacheAdapter(isSessionStorage).removeItem(key);
+};
+
+export const getToken = (): string => {
+  return getCache(CacheToken, { isSessionStorage: true }, {})["token"];
+};

+ 6 - 0
src/utils/emits.ts

@@ -0,0 +1,6 @@
+import mitt, { Emitter } from "mitt";
+
+/**
+ * 事件总线
+ */
+export default mitt() as Emitter;

+ 90 - 0
src/utils/http.ts

@@ -0,0 +1,90 @@
+import app from "@/constants/app";
+import { IHttpResponse, IObject } from "@/types/interface";
+import router from "@/router";
+import axios, { AxiosRequestConfig } from "axios";
+import qs from "qs";
+import { getToken } from "./cache";
+import { getValueByKeys } from "./utils";
+import { ElMessage } from "element-plus";
+
+const http = axios.create({
+  baseURL: app.api,
+  timeout: app.requestTimeout
+});
+
+http.interceptors.request.use(
+  function (config: any) {
+    config.headers["X-Requested-With"] = "XMLHttpRequest";
+    config.headers["Request-Start"] = new Date().getTime();
+    const token = getToken();
+    if (token) {
+      config.headers["token"] = token;
+    }
+    if (config.method?.toUpperCase() === "GET") {
+      config.params = { ...config.params, _t: new Date().getTime() };
+    }
+    if (Object.values(config.headers).includes("application/x-www-form-urlencoded")) {
+      config.data = qs.stringify(config.data);
+    }
+    return config;
+  },
+  function (error) {
+    return Promise.reject(error);
+  }
+);
+http.interceptors.response.use(
+  (response) => {
+    // 响应成功
+    if (response.data.code === 0) {
+      return response;
+    }
+
+    // 错误提示
+    ElMessage.error(response.data.msg);
+
+    if (response.data.code === 401) {
+      //自定义业务状态码
+      redirectLogin();
+    }
+
+    return Promise.reject(new Error(response.data.msg || "Error"));
+  },
+  (error) => {
+    const status = getValueByKeys(error, "response.status", 500);
+    const httpCodeLabel: IObject<string> = {
+      400: "请求参数错误",
+      401: "未授权,请登录",
+      403: "拒绝访问",
+      404: `请求地址出错: ${getValueByKeys(error, "response.config.url", "")}`,
+      408: "请求超时",
+      500: "API接口报500错误",
+      501: "服务未实现",
+      502: "网关错误",
+      503: "服务不可用",
+      504: "网关超时",
+      505: "HTTP版本不受支持"
+    };
+    if (error && error.response) {
+      console.error("请求错误", error.response.data);
+    }
+    if (status === 401) {
+      redirectLogin();
+    }
+    return Promise.reject(new Error(httpCodeLabel[status] || "接口错误"));
+  }
+);
+
+const redirectLogin = () => {
+  router.replace("/login");
+  return;
+};
+
+export default (o: AxiosRequestConfig): Promise<IHttpResponse> => {
+  return new Promise((resolve, reject) => {
+    http(o)
+      .then((res) => {
+        return resolve(res.data);
+      })
+      .catch(reject);
+  });
+};

+ 181 - 0
src/utils/router.ts

@@ -0,0 +1,181 @@
+import app from "@/constants/app";
+import Layout from "@/layout/layout.vue";
+import { toSysViewComponentPath } from "@/router";
+import { IObject, IServerMenus } from "@/types/interface";
+import Iframe from "@/views/iframe.vue";
+import { Router, RouteRecordNormalized, RouteRecordRaw } from "vue-router";
+import { getValueByKeys, isExternalLink } from "./utils";
+
+/**
+ * 合并本地路由和服务端菜单,追加isIframe和isNewPage参数到meta中
+ * @param serverRoutes
+ * @param sysRouteMap
+ * @returns
+ */
+export const mergeServerRoute = (
+  serverRoutes: IServerMenus[],
+  sysRouteMap: IObject,
+  matched: IObject[] = []
+): [RouteRecordRaw[], IObject] => {
+  const rs: RouteRecordRaw[] = [];
+  let routeToMeta: IObject = {};
+  serverRoutes.forEach((x: IServerMenus) => {
+    const [path, meta] = mergeRouteToOpenStyle(x.url, x);
+    const viewComponent = sysRouteMap[toSysViewComponentPath(path)];
+    const isNotMatchComponent =
+      !viewComponent && !meta.isIframe && !meta.isNewPage && !(x.children && x.children.length);
+    const r: RouteRecordRaw = {
+      path,
+      name: path,
+      component: meta.isIframe ? Iframe : x.children && x.children.length ? Layout : viewComponent
+    };
+    r.meta = {
+      title: x.name,
+      icon: x.icon,
+      openStyle: x.openStyle,
+      id: x.id,
+      url: x.url,
+      matched: [...matched, { path, title: x.name }],
+      ...meta
+    };
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    r.redirect =
+      x.redirect ||
+      (isNotMatchComponent ? { path: "/error", query: { to: 404 }, replace: true } : "");
+    if (path) {
+      routeToMeta[path] = r.meta;
+    }
+    if (x.children && x.children.length) {
+      const childrenRoutes = mergeServerRoute(
+        x.children,
+        sysRouteMap,
+        getValueByKeys(r.meta, "matched", [])
+      );
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      r.children = childrenRoutes[0];
+      routeToMeta = { ...routeToMeta, ...childrenRoutes[1] };
+    }
+    rs.push(r);
+  });
+  return [rs, routeToMeta];
+};
+
+/**
+ * 重置路由
+ * @param router
+ * @param routes
+ */
+export const resetRoute = (router: Router, routes: RouteRecordRaw[]): void => {
+  routes.forEach((route: any) => {
+    const { name } = route;
+    router.hasRoute(name) && router.removeRoute(name);
+  });
+};
+
+/**
+ * 路由转换成对象格式
+ * @param routes
+ * @returns
+ */
+export const routesToObject = (routes: any[]): IObject<RouteRecordNormalized> => {
+  const rs: IObject<RouteRecordNormalized> = {};
+  const loop = (ms: any[]) => {
+    ms.forEach((x: RouteRecordNormalized): void => {
+      rs[x.path] = x;
+      if (x.children && x.children.length) {
+        loop(x.children);
+      }
+    });
+  };
+  loop(routes);
+  return rs;
+};
+
+/**
+ * 转化为有效的导航路由
+ * @param routes
+ * @returns
+ */
+export const toValidRoutes = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
+  const rs: RouteRecordRaw[] = [];
+  routes.forEach((x: RouteRecordRaw) => {
+    if (x.meta && x.meta.isNavigationMenu !== false) {
+      if (x.children && x.children.length) {
+        x.children = toValidRoutes(x.children);
+      }
+      rs.push(x);
+    }
+  });
+  return rs;
+};
+
+/**
+ * 注册路由,keep-alive不支持多级路由,这里将多级路由转化为1级路由
+ * @param router
+ * @param rs
+ */
+export const registerToRouter = (router: Router, rs: RouteRecordRaw[]): void => {
+  rs.forEach((x: RouteRecordRaw) => {
+    if (!router.hasRoute(x.path)) {
+      if (x.children && x.children.length) {
+        router.addRoute({ ...x, children: [] });
+        registerToRouter(router, x.children);
+      } else {
+        router.addRoute(x);
+      }
+    }
+  });
+};
+
+export const mergeRouteToOpenStyle = (url: string, item: IServerMenus): [string, IObject] => {
+  url = url || `/iframe/${item.id}`;
+  let meta: IObject = {};
+  const toRoutePath = (url: string): string => {
+    return (url = !/^\//g.test(url) ? "/" + url : url);
+  };
+  //生成变量路由数据
+  const renderVariableHook = (url: string): string => {
+    return url.replace("{{ApiUrl}}", app.api);
+  };
+  if (item.openStyle === 1) {
+    //外部
+    if (isExternalLink(url)) {
+      url = renderVariableHook(url);
+      meta = { url, isNewPage: true };
+      url = `/webview/${item.id}`; //虚拟无效地址
+    } else {
+      url = toRoutePath(url);
+      meta = { url: `/#${url}?pop=true`, isNewPage: true };
+    }
+  } else {
+    //内部
+    if (isExternalLink(url)) {
+      url = renderVariableHook(url);
+      meta = { url, isIframe: true };
+      url = `/iframe/${item.id}`;
+    } else {
+      url = toRoutePath(url);
+    }
+  }
+  return [url, meta];
+};
+
+/**
+ *
+ * @param routes 获取基础路由url和meta数据
+ * @returns
+ */
+export const getBaseRouteToMeta = (routes: RouteRecordRaw[]): IObject => {
+  let routeToMeta: IObject = {};
+  routes.forEach((x) => {
+    if (x.path && x.meta) {
+      routeToMeta[x.path] = { ...x.meta, openStyle: 0, id: x.path, url: x.path };
+    }
+    if (x.children && x.children.length) {
+      routeToMeta = { ...routeToMeta, ...getBaseRouteToMeta(x.children) };
+    }
+  });
+  return routeToMeta;
+};

+ 173 - 0
src/utils/theme.ts

@@ -0,0 +1,173 @@
+import { CacheTheme } from "@/constants/cacheKey";
+import { themeSetting } from "@/constants/config";
+import { EMitt, EThemeSetting } from "@/constants/enum";
+import { IFunction, IObject } from "@/types/interface";
+import { getCache, setCache } from "./cache";
+import emits from "./emits";
+import chalkCss from "element-plus/theme-chalk/index.css?inline";
+
+/**
+ * 取主题设置缓存
+ * @returns
+ */
+export const getThemeConfigCache = (): IObject => {
+  const cache = getCache(CacheTheme, {}, {});
+  return { ...themeSetting, ...cache };
+};
+
+/**
+ * 取主题设置缓存
+ * @param key
+ * @param config
+ * @returns
+ */
+export const getThemeConfigCacheByKey = (
+  key: EThemeSetting,
+  config?: IObject
+): string | boolean | number => {
+  config = config || getCache(CacheTheme, {}, {});
+  return config ? config[key] ?? themeSetting[key] : themeSetting[key];
+};
+
+/**
+ * 生成主题设置样式名称
+ * @param config
+ * @returns
+ */
+export const getThemeConfigToClass = (config: IObject = {}): IObject<string> => {
+  const cl: IObject<string> = {};
+  Object.keys(config).forEach((x) => {
+    cl[x] = `ui-${x}-${config[x]}`;
+  });
+  return cl;
+};
+
+/**
+ * 主题设置到缓存
+ * @param key
+ * @param value
+ */
+export const setThemeConfigToCache = (
+  key: EThemeSetting,
+  value: string | boolean | number
+): void => {
+  const theme = getCache(CacheTheme, {}, {});
+  setCache(CacheTheme, { ...theme, [key]: value });
+};
+
+/**
+ * 设置主题色
+ * @param key
+ * @param value
+ */
+export const setThemeColor = (key: string, value: string): void => {
+  const elm = window.document.querySelector("body");
+  if (elm) {
+    elm.style.setProperty(key, value);
+    elm.style.setProperty(key + "-light", value + "14");
+  }
+};
+
+/**
+ * 生成主题色
+ * @param theme
+ * @returns
+ */
+export const getThemeCluster = (theme: string): string[] => {
+  const tintColor = (color: string, tint: number) => {
+    let red: any = parseInt(color.slice(0, 2), 16);
+    let green: any = parseInt(color.slice(2, 4), 16);
+    let blue: any = parseInt(color.slice(4, 6), 16);
+
+    if (tint === 0) {
+      // when primary color is in its rgb space
+      return [red, green, blue].join(",");
+    } else {
+      red += Math.round(tint * (255 - red));
+      green += Math.round(tint * (255 - green));
+      blue += Math.round(tint * (255 - blue));
+
+      red = red.toString(16);
+      green = green.toString(16);
+      blue = blue.toString(16);
+
+      return `#${red}${green}${blue}`;
+    }
+  };
+
+  const shadeColor = (color: string, shade: number): string => {
+    let red: any = parseInt(color.slice(0, 2), 16);
+    let green: any = parseInt(color.slice(2, 4), 16);
+    let blue: any = parseInt(color.slice(4, 6), 16);
+
+    red = Math.round((1 - shade) * red);
+    green = Math.round((1 - shade) * green);
+    blue = Math.round((1 - shade) * blue);
+
+    red = red.toString(16);
+    green = green.toString(16);
+    blue = blue.toString(16);
+
+    return `#${red}${green}${blue}`;
+  };
+
+  const clusters = [theme];
+  for (let i = 0; i <= 9; i++) {
+    clusters.push(tintColor(theme, Number((i / 10).toFixed(2))));
+  }
+  clusters.push(shadeColor(theme, 0.1));
+  return clusters;
+};
+
+/**
+ * 获取主题css
+ * @param url
+ * @param callback
+ * @param variable
+ */
+export const getCSSString = (callback: IFunction, variable?: string): void => {
+  if (variable) {
+    (window as any)[variable] = chalkCss;
+  }
+  callback(chalkCss);
+};
+
+export const updateStyle = (style: string, oldCluster: string[], newCluster: string[]): string => {
+  let newStyle = style;
+  oldCluster.forEach((color, index) => {
+    newStyle = newStyle.replace(new RegExp(color, "ig"), newCluster[index]);
+  });
+  return newStyle;
+};
+
+/**
+ * 更新主题色
+ * @param themeColor
+ * @param val
+ * @returns
+ */
+export const updateTheme = (val: string): void => {
+  emits.emit(EMitt.OnLoading, true);
+  const head = document.getElementsByTagName("head")[0];
+  const themeCluster = getThemeCluster(val.replace("#", ""));
+  const getHandler = (variable: string, id: string) => {
+    return () => {
+      const originalCluster = getThemeCluster("#409eff".replace("#", ""));
+      const newStyle = updateStyle((window as any)[variable], originalCluster, themeCluster);
+      let styleTag = document.getElementById(id);
+      if (!styleTag) {
+        styleTag = document.createElement("style");
+        styleTag.setAttribute("id", id);
+        head.appendChild(styleTag);
+      }
+      styleTag.innerText = newStyle;
+      emits.emit(EMitt.OnLoading, false);
+    };
+  };
+  const chalkHandler = getHandler("__chalk", "chalk-style");
+  if (!(window as any)["__chalk"]) {
+    getCSSString(chalkHandler, "__chalk");
+  } else {
+    chalkHandler();
+  }
+};

+ 0 - 0
src/utils/utils.ts


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini