Browse Source

layouts + components + 工艺管理

zhuangyunsheng 2 tháng trước cách đây
mục cha
commit
d76cb9521f
100 tập tin đã thay đổi với 5737 bổ sung0 xóa
  1. 12 0
      .editorconfig
  2. 16 0
      .env.development
  3. 9 0
      .env.production
  4. 92 0
      .eslintrc-auto-import.json
  5. 59 0
      .gitignore
  6. 21 0
      LICENSE
  7. 5 0
      babel.config.js
  8. 17 0
      jsconfig.json
  9. 83 0
      package.json
  10. 11 0
      public/config.js
  11. BIN
      public/favicon.ico
  12. BIN
      public/img/404.png
  13. BIN
      public/img/background.png
  14. BIN
      public/img/logo.png
  15. 175 0
      public/index.html
  16. 81 0
      src/App.vue
  17. 11 0
      src/api/index.js
  18. 57 0
      src/api/model/auth.js
  19. 42 0
      src/api/model/common.js
  20. 96 0
      src/api/model/system.js
  21. 54 0
      src/api/model/workmanship.js
  22. BIN
      src/assets/fonts/UnidreamLED.eot
  23. BIN
      src/assets/fonts/UnidreamLED.woff
  24. 3 0
      src/assets/icons/BugFill.vue
  25. 3 0
      src/assets/icons/BugLine.vue
  26. 3 0
      src/assets/icons/Code.vue
  27. 3 0
      src/assets/icons/Download.vue
  28. 3 0
      src/assets/icons/FileExcel.vue
  29. 3 0
      src/assets/icons/FilePpt.vue
  30. 3 0
      src/assets/icons/FileWord.vue
  31. 3 0
      src/assets/icons/Nickname.vue
  32. 3 0
      src/assets/icons/Organization.vue
  33. 3 0
      src/assets/icons/Upload.vue
  34. 3 0
      src/assets/icons/ValidCode.vue
  35. 3 0
      src/assets/icons/Vue.vue
  36. 3 0
      src/assets/icons/Wechat.vue
  37. 12 0
      src/assets/icons/index.js
  38. 83 0
      src/components/scCodeEditor/index.vue
  39. 84 0
      src/components/scCropper/index.vue
  40. 70 0
      src/components/scEcharts/echarts-theme-T.js
  41. 61 0
      src/components/scEcharts/index.vue
  42. 123 0
      src/components/scFormTable/index.vue
  43. 187 0
      src/components/scIconSelect/index.vue
  44. 17 0
      src/components/scIconify/index.vue
  45. 57 0
      src/components/scImage/index.vue
  46. 60 0
      src/components/scPageHeader/index.vue
  47. 92 0
      src/components/scPasswordStrength/index.vue
  48. 86 0
      src/components/scQrCode/index.vue
  49. 91 0
      src/components/scStatusIndicator/index.vue
  50. 83 0
      src/components/scTable/helper.js
  51. 319 0
      src/components/scTable/index.vue
  52. 20 0
      src/components/scTable/renderer/cell-tag.vue
  53. 33 0
      src/components/scTable/renderer/form-radio.vue
  54. 54 0
      src/components/scTable/renderer/form-select.vue
  55. 22 0
      src/components/scTable/renderer/pager-batch-del.vue
  56. 51 0
      src/components/scTable/renderer/table-search.vue
  57. 234 0
      src/components/scTableSelect/index.vue
  58. 204 0
      src/components/scUpload/file.vue
  59. 146 0
      src/components/scUpload/fileViewer.vue
  60. 279 0
      src/components/scUpload/index.vue
  61. 45 0
      src/components/scUpload/main.js
  62. 267 0
      src/components/scUpload/multiple.vue
  63. 37 0
      src/components/scUpload/txtViewer.vue
  64. 53 0
      src/components/scUpload/videoViewer.vue
  65. 85 0
      src/components/scVideo/index.vue
  66. 17 0
      src/config/iconSelect.js
  67. 57 0
      src/config/index.js
  68. 22 0
      src/config/route.js
  69. 21 0
      src/config/select.js
  70. 39 0
      src/config/table.js
  71. 23 0
      src/config/tableSelect.js
  72. 18 0
      src/directives/auth.js
  73. 24 0
      src/directives/auths.js
  74. 19 0
      src/directives/authsAll.js
  75. 27 0
      src/directives/copy.js
  76. 22 0
      src/directives/role.js
  77. 45 0
      src/directives/time.js
  78. 50 0
      src/layout/components/NavMenu.vue
  79. 14 0
      src/layout/components/pageLoading.vue
  80. 83 0
      src/layout/components/password.vue
  81. 94 0
      src/layout/components/probar.vue
  82. 80 0
      src/layout/components/search.vue
  83. 95 0
      src/layout/components/setting.vue
  84. 84 0
      src/layout/components/sideM.vue
  85. 248 0
      src/layout/components/tags.vue
  86. 66 0
      src/layout/components/topbar.vue
  87. 110 0
      src/layout/components/userbar.vue
  88. 206 0
      src/layout/index.vue
  89. 73 0
      src/layout/other/404.vue
  90. 3 0
      src/layout/other/empty.vue
  91. 28 0
      src/locales/index.js
  92. 18 0
      src/locales/lang/en.js
  93. 18 0
      src/locales/lang/zh-cn.js
  94. 23 0
      src/main.js
  95. 161 0
      src/router/index.js
  96. 19 0
      src/router/scrollBehavior.js
  97. 19 0
      src/router/systemRouter.js
  98. 61 0
      src/scui.js
  99. 15 0
      src/store/index.js
  100. 0 0
      src/store/modules/global.js

+ 12 - 0
.editorconfig

@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = tab
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false

+ 16 - 0
.env.development

@@ -0,0 +1,16 @@
+# 本地环境
+NODE_ENV = development
+
+# 标题
+VUE_APP_TITLE = EasyDo智能生产运营平台
+
+# 接口地址
+VUE_APP_ZEROAPI_BASEURL = http://www.qdeasydo.com
+# VUE_APP_MES_BASEURL = http://www.qdeasydo.com/mes
+VUE_APP_MES_BASEURL = http://192.168.101.93:8200
+
+# 本地端口
+VUE_APP_PORT = 4400
+
+# 是否开启代理
+VUE_APP_PROXY = true

+ 9 - 0
.env.production

@@ -0,0 +1,9 @@
+# 生产环境
+NODE_ENV = production
+
+# 标题
+VUE_APP_TITLE = EasyDo智能生产运营平台
+
+# 接口地址
+VUE_APP_MES_BASEURL =
+VUE_APP_ZEROAPI_BASEURL =

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

@@ -0,0 +1,92 @@
+{
+  "globals": {
+    "Component": true,
+    "ComponentPublicInstance": true,
+    "ComputedRef": true,
+    "DirectiveBinding": true,
+    "EffectScope": true,
+    "ExtractDefaultPropTypes": true,
+    "ExtractPropTypes": true,
+    "ExtractPublicPropTypes": true,
+    "InjectionKey": true,
+    "MaybeRef": true,
+    "MaybeRefOrGetter": true,
+    "PropType": true,
+    "Ref": true,
+    "VNode": true,
+    "WritableComputedRef": true,
+    "computed": true,
+    "createApp": true,
+    "customRef": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "effectScope": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "h": true,
+    "inject": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "onWatcherCleanup": true,
+    "provide": true,
+    "reactive": true,
+    "readonly": true,
+    "ref": true,
+    "resolveComponent": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "toRaw": true,
+    "toRef": true,
+    "toRefs": true,
+    "toValue": true,
+    "triggerRef": true,
+    "unref": true,
+    "useAttrs": true,
+    "useCssModule": true,
+    "useCssVars": true,
+    "useId": true,
+    "useModel": true,
+    "useSlots": true,
+    "useTemplateRef": true,
+    "watch": true,
+    "watchEffect": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true,
+    "onBeforeRouteLeave": true,
+    "onBeforeRouteUpdate": true,
+    "useLink": true,
+    "useRoute": true,
+    "useRouter": true,
+    "ElNotification": true,
+    "ElMessage": true,
+    "ElMessageBox": true,
+    "ElInput": true,
+    "ElButton": true,
+    "createLogger": true,
+    "createNamespacedHelpers": true,
+    "createStore": true,
+    "mapActions": true,
+    "mapGetters": true,
+    "mapMutations": true,
+    "mapState": true,
+    "useStore": true
+  }
+}

+ 59 - 0
.gitignore

@@ -0,0 +1,59 @@
+
+######################################################################
+# custom 
+crm-admin.jar
+
+######################################################################
+# Build Tools
+
+.gradle
+/build/
+!gradle/wrapper/gradle-wrapper.jar
+
+target/
+!.mvn/wrapper/maven-wrapper.jar
+
+######################################################################
+# IDE
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+nbproject/private/
+build/*
+nbbuild/
+dist/
+nbdist/
+.nb-gradle/
+
+######################################################################
+# Others
+*.log
+*.xml.versionsBackup
+*.zip
+
+!*/build/*.java
+!*/build/*.html
+!*/build/*.xml
+
+node_modules
+.DS_Store
+.java-version
+package-lock.json
+logs
+
+.vscode/
+
+easydo/

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 sakuya
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 5 - 0
babel.config.js

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

+ 17 - 0
jsconfig.json

@@ -0,0 +1,17 @@
+{
+	"compilerOptions": {
+		"target": "es5",
+		"module": "esnext",
+		"baseUrl": "./",
+		"moduleResolution": "node",
+		"paths": {
+			"@/*": ["src/*"]
+		},
+		"lib": [
+			"esnext",
+			"dom",
+			"dom.iterable",
+			"scripthost"
+		]
+	}
+}

+ 83 - 0
package.json

@@ -0,0 +1,83 @@
+{
+    "name": "easydo-mes-web",
+    "version": "1.0.0",
+    "private": true,
+    "scripts": {
+        "dev": "vue-cli-service serve",
+        "build": "vue-cli-service build --report",
+        "lint": "vue-cli-service lint"
+    },
+    "dependencies": {
+        "@element-plus/icons-vue": "2.0.10",
+        "@vue-office/docx": "^1.6.2",
+        "@vue-office/excel": "^1.7.11",
+        "@vue-office/pdf": "^2.0.8",
+        "@vxe-ui/plugin-export-xlsx": "^4.0.13",
+        "@vxe-ui/plugin-render-element": "^4.0.10",
+        "axios": "1.3.4",
+        "codemirror": "5.65.5",
+        "core-js": "3.29.0",
+        "cropperjs": "1.5.13",
+        "crypto-js": "4.1.1",
+        "echarts": "5.4.1",
+        "element-plus": "^2.4.0",
+        "exceljs": "^4.4.0",
+        "jsencrypt": "^3.0.0-rc.1",
+        "moment": "^2.29.4",
+        "nprogress": "0.2.0",
+        "qrcodejs2": "0.0.2",
+        "sortablejs": "1.15.0",
+        "vue": "3.2.47",
+        "vue-i18n": "9.2.2",
+        "vue-router": "4.1.6",
+        "vuedraggable": "4.0.3",
+        "vuex": "4.1.0",
+        "vxe-pc-ui": "^4.3.78",
+        "vxe-table": "^4.10.13",
+        "xe-utils": "^3.7.0",
+        "xgplayer": "2.32.2",
+        "xgplayer-hls": "2.5.2"
+    },
+    "devDependencies": {
+        "@babel/core": "7.21.00",
+        "@babel/eslint-parser": "7.19.1",
+        "@iconify/vue": "^4.3.0",
+        "@vue/cli-plugin-babel": "5.0.8",
+        "@vue/cli-plugin-eslint": "5.0.8",
+        "@vue/cli-service": "5.0.8",
+        "eslint": "8.35.0",
+        "eslint-plugin-vue": "9.9.0",
+        "sass": "1.58.3",
+        "sass-loader": "10.1.1",
+        "unplugin-auto-import": "19.0.0",
+        "unplugin-vue-components": "^28.0.0"
+    },
+    "eslintConfig": {
+        "root": true,
+        "env": {
+            "node": true
+        },
+        "globals": {
+            "APP_CONFIG": true
+        },
+        "extends": [
+            "plugin:vue/vue3-essential"
+        ],
+        "parserOptions": {
+            "parser": "@babel/eslint-parser"
+        },
+        "rules": {
+            "indent": 0,
+            "no-tabs": 0,
+            "no-mixed-spaces-and-tabs": 0,
+            "vue/no-unused-components": 0,
+            "vue/multi-word-component-names": 0
+        }
+    },
+    "browserslist": [
+        "> 1%",
+        "last 2 versions",
+        "not dead",
+        "not ie 11"
+    ]
+}

+ 11 - 0
public/config.js

@@ -0,0 +1,11 @@
+
+// 此文件非必要,在生产环境下此文件配置可覆盖运行配置,开发环境下不起效
+// 详情见 src/config/index.js
+
+const APP_CONFIG = {
+	//标题
+	//APP_NAME: "SCUI",
+
+	//接口地址,如遇跨域需使用nginx代理
+	//API_URL: "https://www.fastmock.site/mock/5039c4361c39a7e3252c5b55971f1bd3/api"
+}

BIN
public/favicon.ico


BIN
public/img/404.png


BIN
public/img/background.png


BIN
public/img/logo.png


+ 175 - 0
public/index.html

@@ -0,0 +1,175 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <title>
+        <%= VUE_APP_TITLE %>
+    </title>
+    <script type="text/javascript">
+        document.write("<script src='config.js?" + new Date().getTime() + "'><\/script>");
+    </script>
+</head>
+
+<body data-layout="header">
+    <noscript>
+        <strong>We're sorry but <%= VUE_APP_TITLE %> doesn't work properly without JavaScript
+                enabled. Please enable it to continue.</strong>
+    </noscript>
+    <script type="text/javascript">
+        localStorage.getItem("APP_DARK") && document.documentElement.classList.add("dark");
+    </script>
+    <div id="app" class="aminui">
+        <div class="app-loading">
+            <div class="app-loading__logo">
+                <img src="img/logo.png" />
+            </div>
+            <div class="app-loading__loader"></div>
+            <div class="app-loading__title">
+                <%= VUE_APP_TITLE %>
+            </div>
+        </div>
+        <style>
+            .app-loading {
+                position: absolute;
+                top: 0px;
+                left: 0px;
+                right: 0px;
+                bottom: 0px;
+                display: flex;
+                justify-content: center;
+                align-items: center;
+                flex-direction: column;
+                background: #fff;
+            }
+
+            .app-loading__logo {
+                margin-bottom: 30px;
+            }
+
+            .app-loading__logo img {
+                width: 90px;
+                vertical-align: bottom;
+            }
+
+            .app-loading__loader {
+                box-sizing: border-box;
+                width: 35px;
+                height: 35px;
+                border: 5px solid transparent;
+                border-top-color: #000;
+                border-radius: 50%;
+                animation: .5s loader linear infinite;
+                position: relative;
+            }
+
+            .app-loading__loader:before {
+                box-sizing: border-box;
+                content: "";
+                display: block;
+                width: inherit;
+                height: inherit;
+                position: absolute;
+                top: -5px;
+                left: -5px;
+                border: 5px solid #ccc;
+                border-radius: 50%;
+                opacity: .5;
+            }
+
+            .app-loading__title {
+                font-size: 24px;
+                color: #333;
+                margin-top: 30px;
+            }
+
+            .dark .app-loading {
+                background: #222225;
+            }
+
+            .dark .app-loading__loader {
+                border-top-color: #fff;
+            }
+
+            .dark .app-loading__title {
+                color: #d0d0d0;
+            }
+
+            @keyframes loader {
+                0% {
+                    transform: rotate(0deg);
+                }
+
+                100% {
+                    transform: rotate(360deg);
+                }
+            }
+        </style>
+    </div>
+    <!-- built files will be auto injected -->
+</body>
+<div id="versionCheck"
+    style="display: none;position: absolute;z-index: 99;top:0;left:0;right:0;bottom:0;padding:40px;background:rgba(255,255,255,0.9);color: #333;">
+    <h2 style="line-height: 1;margin: 0;font-size: 24px;">当前使用的浏览器内核版本过低 :(</h2>
+    <p style="line-height: 1;margin: 0;font-size: 14px;margin-top: 20px;opacity: 0.8;">当前版本:<span
+            id="versionCheck-type">--</span> <span id="versionCheck-version">--</span></p>
+    <p style="line-height: 1;margin: 0;font-size: 14px;margin-top: 10px;opacity: 0.8;">最低版本要求:Chrome 71+、Firefox
+        65+、Safari 12+、Edge 97+。</p>
+    <p style="line-height: 1;margin: 0;font-size: 14px;margin-top: 10px;opacity: 0.8;">
+        请升级浏览器版本,或更换现代浏览器,如果你使用的是双核浏览器,请切换到极速/高速模式。</p>
+</div>
+<script type="text/javascript">
+    function getBrowerInfo() {
+        var userAgent = window.navigator.userAgent;
+        var browerInfo = {
+            type: "unknown",
+            version: "unknown",
+            userAgent: userAgent
+        };
+        if (document.documentMode) {
+            browerInfo.type = "IE";
+            browerInfo.version = document.documentMode + "";
+        } else if (indexOf(userAgent, "Firefox")) {
+            browerInfo.type = "Firefox";
+            browerInfo.version = userAgent.match(/Firefox\/([\d.]+)/)[1];
+        } else if (indexOf(userAgent, "Opera")) {
+            browerInfo.type = "Opera";
+            browerInfo.version = userAgent.match(/Opera\/([\d.]+)/)[1];
+        } else if (indexOf(userAgent, "Edg")) {
+            browerInfo.type = "Edg";
+            browerInfo.version = userAgent.match(/Edg\/([\d.]+)/)[1];
+        } else if (indexOf(userAgent, "Chrome")) {
+            browerInfo.type = "Chrome";
+            browerInfo.version = userAgent.match(/Chrome\/([\d.]+)/)[1];
+        } else if (indexOf(userAgent, "Safari")) {
+            browerInfo.type = "Safari";
+            browerInfo.version = userAgent.match(/Safari\/([\d.]+)/)[1];
+        }
+        return browerInfo;
+    }
+    function indexOf(userAgent, brower) {
+        return userAgent.indexOf(brower) > -1;
+    }
+    function isSatisfyBrower() {
+        var minVer = {
+            "Chrome": 71,
+            "Firefox": 65,
+            "Safari": 12,
+            "Edg": 97,
+            "IE": 999
+        }
+        var browerInfo = getBrowerInfo();
+        var materVer = browerInfo.version.split(".")[0];
+        return materVer >= minVer[browerInfo.type];
+    }
+    if (!isSatisfyBrower()) {
+        document.getElementById("versionCheck").style.display = "block";
+        document.getElementById("versionCheck-type").innerHTML = getBrowerInfo().type;
+        document.getElementById("versionCheck-version").innerHTML = getBrowerInfo().version;
+    }
+</script>
+
+</html>

+ 81 - 0
src/App.vue

@@ -0,0 +1,81 @@
+<template>
+	<el-config-provider :locale="locale" :size="config.size" :zIndex="config.zIndex" :button="config.button">
+		<router-view></router-view>
+	</el-config-provider>
+</template>
+
+<script>
+const debounce = (fn, delay) => {
+    let timer = null;
+    return function () {
+        let context = this;
+        let args = arguments;
+        clearTimeout(timer);
+        timer = setTimeout(function () {
+            fn.apply(context, args);
+        }, delay);
+    }
+}
+
+const _ResizeObserver = window.ResizeObserver;
+window.ResizeObserver = class ResizeObserver extends _ResizeObserver {
+    constructor(callback) {
+        const wrappedCallback = (entries, observer) => {
+            // 过滤掉已经不在文档中的元素对应的entry
+            const validEntries = entries.filter(entry => document.contains(entry.target));
+            if (validEntries.length > 0) {
+                callback(validEntries, observer);
+            }
+        };
+
+        callback = debounce(wrappedCallback, 16);
+        super(callback);
+    }
+}
+
+import colorTool from "@/utils/color";
+
+export default {
+    name: "App",
+    data() {
+        return {
+            config: {
+                size: "default",
+                zIndex: 2000,
+                button: {
+                    autoInsertSpace: false
+                }
+            }
+        }
+    },
+
+    computed: {
+        locale() {
+            return this.$i18n.messages[this.$i18n.locale].el
+        }
+    },
+
+    created() {
+        // 设置主题颜色
+        const app_color = this.$TOOL.data.get("APP_COLOR") || this.$CONFIG.COLOR;
+        if (app_color) {
+            document.documentElement.style.setProperty("--el-color-primary", app_color);
+            for (let i = 1; i <= 9; i++) {
+                document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, colorTool.lighten(app_color, i / 10));
+            }
+            for (let i = 1; i <= 9; i++) {
+                document.documentElement.style.setProperty(`--el-color-primary-dark-${i}`, colorTool.darken(app_color, i / 10));
+            }
+
+            // vxe-Table
+            document.documentElement.style.setProperty("--vxe-ui-font-primary-color", app_color);
+            document.documentElement.style.setProperty("--vxe-ui-font-primary-darken-color", app_color);
+            document.documentElement.style.setProperty("--vxe-ui-font-primary-lighten-color", app_color);
+        }
+    }
+}
+</script>
+
+<style lang="scss">
+@use "@/style/style.scss";
+</style>

+ 11 - 0
src/api/index.js

@@ -0,0 +1,11 @@
+/**
+ * @description 自动import导入所有 api 模块
+ */
+
+const files = require.context("./model", false, /\.js$/)
+const modules = {}
+files.keys().forEach((key) => {
+	modules[key.replace(/(\.\/|\.js)/g, "")] = files(key).default
+})
+
+export default modules

+ 57 - 0
src/api/model/auth.js

@@ -0,0 +1,57 @@
+import config from "@/config"
+import tool from "@/utils/tool"
+import http from "@/utils/request"
+
+export default {
+    // 登录获取TOKEN
+	token: async function (data = {}) {
+        const query = {
+            username: data.user,
+            password: tool.crypto.encrypt(data.password),
+            code: data.code,
+            uuid: data.uuid
+        }
+        return await http.post(`${config.API_URL}/mes/auth/login`, query);
+	},
+
+    // 获取登录验证码
+	codeImg: async function () {
+        return await http.get(`${config.API_URL}/mes/auth/code`);
+	},
+
+    user: {
+		name: "用户管理",
+        url: `${config.API_URL}/mes/sysUser`,
+        get: async function (data = {}) {
+            return await http.post(`${this.url}/getPage`, data);
+        },
+
+        all: async function (data = {}) {
+            return await http.post(`${this.url}/getList`, data);
+        },
+
+        add: async function (data = {}) {
+            return await http.post(`${this.url}/save`, data);
+        },
+
+        edit: async function (data = {}) {
+            return await http.post(`${this.url}/update`, data);
+        },
+
+        del: async function (data = {}) {
+            return await http.post(`${this.url}/remove`, data);
+        },
+
+        resetPass: async function (data = {}) {
+			return await http.post(`${this.url}/resetPass`, data);
+		},
+
+        updatePass: async function (data = {}) {
+			const query = {
+				oldPass: tool.crypto.encrypt(data.userPassword),
+				newPass: tool.crypto.encrypt(data.newPassword)
+			}
+			return await http.post(`${this.url}/updatePass`, query);
+		}
+    }
+}

+ 42 - 0
src/api/model/common.js

@@ -0,0 +1,42 @@
+import axios from "axios";
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    minio: {
+		name: "文件上传",
+		url: `${config.API_URL}/mes/file`,
+
+		up: async function (data, config = {}) {
+			return await http.post(`${this.url}/upload`, data, config);
+		},
+
+		rm: async function (data) {
+			return await http.post(`${this.url}/remove`, data);
+		},
+
+        download: async function (entityID, isTxt = false) { // url: string, isTxt: txt解码
+			return await http.get(`${config.API_URL}/minio${entityID}`, {}, {
+                responseType: "blob",
+                transformResponse: isTxt && [
+                    async function (data) {
+                        return await transformData(data);
+                    }
+                ] || []
+            });
+		}
+	}
+}
+
+function transformData(data) {
+    return new Promise(resolve => {
+        const reader = new FileReader();
+        reader.readAsText(data, "UTF-8");
+        reader.onload = () => {
+            if (reader.result.includes("�")) {
+                reader.readAsText(data, "GBK");
+                reader.onload = () => resolve(reader.result);
+            } else resolve(reader.result);
+        };
+    });
+}

+ 96 - 0
src/api/model/system.js

@@ -0,0 +1,96 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+	projectUser: {
+		name: "获取我的项目",
+		url: `${config.API_URL}/api/projectUserRef/getUserProjectList`,
+		get: async function () {
+			return await http.post(this.url);
+		}
+	},
+
+    menu: {
+        name: "菜单管理",
+		url: `${config.API_URL}/mes/sysMenu`,
+
+        build: async function () {
+			return await http.post(`${this.url}/build`);
+		},
+
+		get: async function (data = {}) {
+			return await http.post(`${this.url}/getList`, data);
+		},
+
+        add: async function (data = {}) {
+            return await http.post(`${this.url}/save`, data);
+        },
+
+        edit: async function (data = {}) {
+            return await http.post(`${this.url}/update`, data);
+        },
+
+        del: async function (data = {}) {
+            return await http.post(`${this.url}/remove`, data);
+        }
+    },
+
+    role: {
+        name: "角色管理",
+		url: `${config.API_URL}/mes/sysRole`,
+
+		get: async function (data = {}) {
+			return await http.post(`${this.url}/getPage`, data);
+		},
+
+        all: async function (data = {}) {
+			return await http.post(`${this.url}/getList`, data);
+		},
+
+        add: async function (data = {}) {
+            return await http.post(`${this.url}/save`, data);
+        },
+
+        edit: async function (data = {}) {
+            return await http.post(`${this.url}/update`, data);
+        },
+
+        del: async function (data = {}) {
+            return await http.post(`${this.url}/remove`, data);
+        }
+    },
+
+    roleMenu: {
+        name: "角色菜单管理",
+		url: `${config.API_URL}/mes/sysRolesMenus`,
+        
+        get: async function (data = {}) {
+			return await http.post(`${this.url}/getList`, data);
+		},
+
+        edit: async function (data = {}) {
+            return await http.post(`${this.url}/batchSave`, data);
+        }
+    },
+
+    dept: {
+        name: "部门管理",
+		url: `${config.API_URL}/mes/sysDept`,
+
+        get: async function (data = {}) {
+			return await http.post(`${this.url}/getList`, data);
+		},
+
+        add: async function (data = {}) {
+            return await http.post(`${this.url}/save`, data);
+        },
+
+        edit: async function (data = {}) {
+            return await http.post(`${this.url}/update`, data);
+        },
+
+        del: async function (data = {}) {
+            return await http.post(`${this.url}/remove`, data);
+        }
+    },
+}

+ 54 - 0
src/api/model/workmanship.js

@@ -0,0 +1,54 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    process: {
+		name: "工序管理",
+        url: `${config.API_URL}/mes/processManage`,
+        
+        get: async function (data = {}) {
+			return await http.post(`${this.url}/getPage`, data);
+		},
+
+        add: async function (data = {}) {
+            return await http.post(`${this.url}/save`, data);
+        },
+
+        edit: async function (data = {}) {
+            return await http.post(`${this.url}/update`, data);
+        },
+
+        del: async function (data = {}) {
+            return await http.post(`${this.url}/remove`, data);
+        }
+    },
+
+    route: {
+		name: "工艺路线",
+        url: `${config.API_URL}/mes/processRoute`,
+        
+        get: async function (data = {}) {
+			return await http.post(`${this.url}/getPage`, data);
+		},
+
+        add: async function (data = {}) {
+            return await http.post(`${this.url}/save`, data);
+        },
+
+        edit: async function (data = {}) {
+            return await http.post(`${this.url}/update`, data);
+        },
+
+        del: async function (data = {}) {
+            return await http.post(`${this.url}/remove`, data);
+        },
+
+        upgrade: async function (data = {}) {
+            return await http.post(`${this.url}/upgrade`, data);
+        },
+
+        regrade: async function (data = {}) {
+            return await http.post(`${this.url}/regrade`, data);
+        }
+    }
+}

BIN
src/assets/fonts/UnidreamLED.eot


BIN
src/assets/fonts/UnidreamLED.woff


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/BugFill.vue


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/BugLine.vue


+ 3 - 0
src/assets/icons/Code.vue

@@ -0,0 +1,3 @@
+<template>
+	<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M981.333333 512l-301.696 301.696-60.330666-60.330667L860.672 512l-241.365333-241.365333 60.330666-60.330667L981.333333 512zM163.328 512l241.365333 241.365333-60.330666 60.330667L42.666667 512l301.696-301.696 60.330666 60.330667L163.328 512z" p-id="4503"></path></svg>
+</template>

+ 3 - 0
src/assets/icons/Download.vue

@@ -0,0 +1,3 @@
+<template>
+	<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M554.666667 426.666667h213.333333l-256 256-256-256h213.333333V128h85.333334v298.666667z m-384 384h682.666666v-298.666667h85.333334v341.333333a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667v-341.333333h85.333334v298.666667z" p-id="26056"></path></svg>
+</template>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/FileExcel.vue


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/FilePpt.vue


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/FileWord.vue


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/Nickname.vue


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/Organization.vue


+ 3 - 0
src/assets/icons/Upload.vue

@@ -0,0 +1,3 @@
+<template>
+	<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M170.666667 810.666667h682.666666v-298.666667h85.333334v341.333333a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667v-341.333333h85.333334v298.666667z m384-426.666667v298.666667h-85.333334V384H256l256-256 256 256h-213.333333z" p-id="25917"></path></svg>
+</template>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/ValidCode.vue


+ 3 - 0
src/assets/icons/Vue.vue

@@ -0,0 +1,3 @@
+<template>
+	<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M42.666667 128h170.666666l298.666667 512 298.666667-512h170.666666L512 938.666667 42.666667 128z m369.792 0L512 298.666667l99.541333-170.666667h172.16L512 597.333333 240.298667 128h172.16z" p-id="4634"></path></svg>
+</template>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/assets/icons/Wechat.vue


+ 12 - 0
src/assets/icons/index.js

@@ -0,0 +1,12 @@
+const resultComps = {}
+let requireComponent = require.context(
+    "./", // 在当前目录下查找
+    false, // 不遍历子文件夹
+    /\.vue$/ // 正则匹配 以 .vue结尾的文件
+)
+requireComponent.keys().forEach(fileName => {
+    let comp = requireComponent(fileName)
+    resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, "$1")] = comp.default
+})
+
+export default resultComps

+ 83 - 0
src/components/scCodeEditor/index.vue

@@ -0,0 +1,83 @@
+<!--
+ * @Descripttion: 代码编辑器
+ * @version: 1.0
+ * @Author: sakuya
+ * @Date: 2022年5月20日21:46:29
+ * @LastEditors: 
+ * @LastEditTime: 
+-->
+
+<template>
+	<div class="sc-code-editor" :style="{ 'height': _height }">
+		<textarea ref="textarea" v-model="contentValue"></textarea>
+	</div>
+</template>
+
+<script>
+import CodeMirror from "codemirror";
+import "codemirror/lib/codemirror.css";
+// 主题
+import "codemirror/theme/idea.css";
+import "codemirror/theme/neat.css";
+// 功能
+import "codemirror/addon/selection/active-line";
+// 语言
+import "codemirror/mode/javascript/javascript";
+
+export default {
+    props: {
+        modelValue: { type: String, default: "" },
+        mode: { type: String, default: "javascript" },
+        height: { type: [String, Number], default: 300 },
+        options: { type: Object, default: () => {} },
+        theme: { type: String, default: "neat" },
+        readOnly: { type: Boolean, default: false }
+    },
+    data() {
+        return {
+            contentValue: this.modelValue,
+            coder: null,
+            opt: {
+                theme: this.theme, // 主题
+                styleActiveLine: true, // 高亮当前行
+                lineNumbers: true, // 行号
+                lineWrapping: false, // 自动换行
+                tabSize: 4,	// Tab缩进
+                indentUnit: 4, // 缩进单位
+                indentWithTabs : true, // 自动缩进
+                mode : this.mode, // 语言
+                readOnly: this.readOnly, // 只读
+                ...this.options
+            }
+        }
+    },
+    computed: {
+        _height() {
+            return Number(this.height) ? Number(this.height) + "px" : this.height
+        }
+    },
+    watch: {
+        modelValue(val) {
+            this.contentValue = val;
+            if (val !== this.coder.getValue()) this.coder.setValue(val);
+        }
+    },
+    mounted() {
+        this.init();
+    },
+    methods: {
+        init() {
+            this.coder = markRaw(CodeMirror.fromTextArea(this.$refs.textarea, this.opt));
+            this.coder.on("change", coder => {
+                this.contentValue = coder.getValue();
+                this.$emit("update:modelValue", this.contentValue);
+            });
+        }
+    }
+}
+</script>
+
+<style scoped>
+	.sc-code-editor {font-size: 14px;border: 1px solid #ddd;line-height: 150%;}
+	.sc-code-editor:deep(.CodeMirror)  {height: 100%;}
+</style>

+ 84 - 0
src/components/scCropper/index.vue

@@ -0,0 +1,84 @@
+<!--
+ * @Descripttion: 图像裁剪组件
+ * @version: 1.0
+ * @Date: 2021年7月24日17:05:43
+ * @LastEditTime: 2025年9月9日13:40:10
+-->
+
+<template>
+	<div class="sc-cropper">
+		<div class="sc-cropper__img">
+			<img :src="src" ref="img">
+		</div>
+		<div class="sc-cropper__preview">
+			<h4>图像预览</h4>
+			<div class="sc-cropper__preview__img" ref="preview"></div>
+		</div>
+	</div>
+</template>
+
+<script>
+import Cropper from "cropperjs";
+import "cropperjs/dist/cropper.css";
+
+export default {
+    props: {
+        src: { type: String, default: "" },
+        compress: { type: Number, default: 1 },
+        aspectRatio: { type: Number, default: NaN }
+    },
+
+    data() {
+        return {
+            crop: null
+        }
+    },
+
+    watch: {
+        aspectRatio(val) {
+            this.crop.setAspectRatio(val);
+        }
+    },
+
+    mounted() {
+        this.init();
+    },
+
+    methods: {
+        init() {
+            this.crop = new Cropper(this.$refs.img, {
+                viewMode: 2,
+                autoCropArea: 1,
+                dragMode: "move",
+                responsive: false,
+                aspectRatio: this.aspectRatio,
+                preview: this.$refs.preview
+            });
+        },
+        setAspectRatio(aspectRatio) {
+            this.crop.setAspectRatio(aspectRatio);
+        },
+        getCropData(cb, type = "image/jpeg") {
+            cb(this.crop.getCroppedCanvas().toDataURL(type, this.compress));
+        },
+        getCropBlob(cb, type = "image/jpeg") {
+            this.crop.getCroppedCanvas().toBlob(blob => cb(blob), type, this.compress);
+        },
+        getCropFile(cb, fileName = "fileName.jpg", type = "image/jpeg") {
+            this.crop.getCroppedCanvas().toBlob(blob => {
+                const file = new File([blob], fileName, { type });
+                cb(file);
+            }, type, this.compress);
+        }
+    }
+}
+</script>
+
+<style scoped>
+.sc-cropper {height:300px;}
+.sc-cropper__img {height:100%;width:400px;float: left;background: #EBEEF5;}
+.sc-cropper__img img {display: none;}
+.sc-cropper__preview {width: 120px;margin-left: 20px;float: left;}
+.sc-cropper__preview h4 {font-weight: normal;font-size: 12px;color: #999;margin-bottom: 20px;}
+.sc-cropper__preview__img {overflow: hidden;width: 120px;height: 120px;border: 1px solid #ebeef5;}
+</style>

+ 70 - 0
src/components/scEcharts/echarts-theme-T.js

@@ -0,0 +1,70 @@
+const T = {
+	"color": [
+		"#409EFF",
+		"#36CE9E",
+		"#edb00d",
+		"#f56e6a",
+		"#626c91",
+		"#9a60b4",
+		"#73c0de",
+		"#ea7ccc",
+		"#fc8452",
+		"#5470c6",
+		"#909399"
+	],
+	"grid": {
+		"left": "5%",
+		"right": "5%",
+		"bottom": "10",
+		"containLabel": true
+	},
+	"legend": {
+		"inactiveColor": "rgba(128, 128, 128, 0.4)"
+	},
+	"categoryAxis": {
+		"axisLine": {//坐标轴轴线
+			"show": true,
+			"lineStyle": {
+				"color": "#d3d2d3",
+				"width": 1
+			}
+		},
+		"axisTick": {//坐标轴刻度
+			"show": false,
+			"lineStyle": {
+				"color": "#5d5d5d"
+			}
+		},
+		"splitLine": {//坐标轴在 grid 区域中的分隔线
+			"show": false, // 默认数值轴显示,类目轴不显示。
+			"lineStyle": {
+				"color": ["#efefef"]
+			}
+		},
+		"splitArea": {
+			"show": false,
+			"areaStyle": {
+				"color": [
+					"rgba(255,255,255,0.01)",
+					"rgba(0,0,0,0.01)"
+				]
+			}
+		}
+	},
+	"valueAxis": {
+		"axisLine": {
+			"show": false,
+			"lineStyle": {
+				"color": "#5d5d5d"
+			}
+		},
+		"splitLine": {
+			"show": true,
+			"lineStyle": {
+				"color": "rgba(128,128,128,0.2)"
+			}
+		},
+	}
+}
+
+export default T

+ 61 - 0
src/components/scEcharts/index.vue

@@ -0,0 +1,61 @@
+<template>
+	<div ref="scEcharts" :style="{ height, width }"></div>
+</template>
+
+<script>
+	import * as echarts from "echarts";
+	import T from "./echarts-theme-T.js";
+	echarts.registerTheme("T", T);
+	const unwarp = (obj) => obj && (obj.__v_raw || obj.valueOf() || obj);
+
+	export default {
+		...echarts,
+		name: "scEcharts",
+		props: {
+			height: { type: String, default: "100%" },
+			width: { type: String, default: "100%" },
+			nodata: { type: Boolean, default: false },
+			option: { type: Object, default: () => {} },
+			clearCache: { type: Boolean, default: false }
+		},
+		data() {
+			return {
+				isActivat: false,
+				myChart: null
+			}
+		},
+		watch: {
+			option: {
+				deep: true,
+				handler(v) {
+					if (this.clearCache) unwarp(this.myChart).clear();
+					unwarp(this.myChart).setOption(v);
+				}
+			}
+		},
+		computed: {
+			myOptions: function() {
+				return this.option || {};
+			}
+		},
+		activated() {
+			if (!this.isActivat) nextTick(() => this.myChart.resize());
+		},
+		deactivated() {
+			this.isActivat = false;
+		},
+		mounted(){
+			this.isActivat = true;
+			nextTick(() => this.draw());
+		},
+		methods: {
+			draw() {
+				const myChart = echarts.init(this.$refs.scEcharts, "T");
+				myChart.setOption(this.myOptions);
+				myChart.on("click", "series.bar", params => this.$emit("chartClick", params));
+				this.myChart = myChart;
+				window.addEventListener("resize", () => myChart.resize());
+			}
+		}
+	}
+</script>

+ 123 - 0
src/components/scFormTable/index.vue

@@ -0,0 +1,123 @@
+<!--
+ * @Descripttion: form-table
+ * @version: 1.1
+ * @Date: 2025年11月17日12:10:06
+-->
+
+<template>
+    <el-main class="sc-form-table">
+        <vxe-grid ref="xGrid" v-bind="gridOptions" @edit-activated="$emit('editActivated', $event)">
+            <template #empty>
+                <el-empty :image-size="100" description="您还没有添加任何数据"></el-empty>
+            </template>
+            <template #row-drag-icon>
+                <sc-iconify icon="mingcute:move-line" size="1.1em"></sc-iconify>
+            </template>
+
+            <template #seq_handler="{ seq, row }">
+                <span :class="['seq', disabled && 'is-disabled']">{{ seq }}</span>
+                <vxe-button-group v-if="!disabled" circle>
+                    <vxe-button v-if="!hideAdd" icon="vxe-icon-add" status="primary" @click="rowAdd"></vxe-button>
+                    <vxe-button icon="vxe-icon-minus" status="error" @click="rowDel(row)"></vxe-button>
+                </vxe-button-group>
+            </template>
+
+            <template #folder_file="{ row, column }">
+                <el-button style="margin-top: 8px;" type="primary" link @click="showFile(row.fileList)">{{ column.params.buttonName }}</el-button>
+            </template>
+
+            <template v-for="(_, slotName) in $slots" #[slotName]="context">
+                <slot :name="slotName" v-bind="{ ...context }"></slot>
+            </template>
+        </vxe-grid>
+    </el-main>
+</template>
+
+<script setup>
+// 设置当前zIndex起始值
+import domZIndex from "dom-zindex";
+domZIndex.setCurrent(domZIndex.getMax() + 1);
+
+import XEUtils from "xe-utils";
+import scUploadFile from "@/components/scUpload/file";
+
+const $emit = defineEmits(["update:modelValue"]);
+const props = defineProps({
+    modelValue: { type: Array, default: () => [] },
+    disabled: { type: Boolean, default: false },
+    hideAdd: { type: Boolean, default: false },
+    addTemplate: { type: Object, default: () => {} },
+
+    layouts: { type: Array, default: () => [["Top", "Form"], ["Toolbar", "Table", "Bottom", "Pager"]] },
+    rowKey: { type: String, default: "id" },
+    columns: { type: Array, default: () => [] },
+    editRules: { type: Object, default: () => {} },
+    footerField: { type: Array, default: () => [] },
+    mergeFooterItems: { type: Array, default: () => [] }
+})
+
+const gridOptions = reactive({
+    id: "xGride-form-table",
+    maxHeight: 1048,
+    border: "full",
+    size: "mini",
+    align: "center",
+    rowClassName: "vxe-form-table-row",
+    data: XEUtils.clone(props.modelValue, true),
+    columns: props.columns,
+    showOverflow: true,
+    keepSource: true,
+    layouts: [...props.layouts],
+    toolbarConfig: { enabled: false },
+    formConfig: { enabled: false },
+    pagerConfig: { enabled: false },
+    rowConfig: { keyField: props.rowKey, drag: !props.disabled, resizable: true, useKey: true, isHover: true },
+    editRules: props.editRules,
+    editConfig: { enabled: !props.disabled, mode: "cell", trigger: "click", showStatus: false, showIcon: false },
+    columnConfig: { useKey: true, resizable: true },
+    resizableConfig: { isAllRowDrag: !props.disabled },
+    tooltipConfig: { enterable: true },
+    rowDragConfig: { trigger: "cell", showGuidesStatus: true },
+
+    showFooter: computed(() => props.footerField.length > 0 && props.modelValue.length > 0),
+    footerRowClassName: "vxe-table-footer-cell-required",
+    mergeFooterItems: props.mergeFooterItems,
+    footerMethod() {
+        return [
+            XEUtils.filter(XEUtils.toTreeArray(props.columns), column => !column.children).map((column, index) => {
+                if (index === 0) return "合计:";
+                if (props.footerField.includes(column.field)) return XEUtils.sum(props.modelValue, column.field);
+            })
+        ]
+    }
+})
+
+watch(() => gridOptions.data, val => {
+    xGrid.value?.recalculate()
+    $emit("update:modelValue", val)}, { deep: true });
+
+const xGrid = ref();
+const rowAdd = async () => {
+    const newRow = await xGrid.value?.createRow(props.addTemplate);
+    gridOptions.data.push(newRow);
+}
+const rowDel = row => gridOptions.data = XEUtils.filter(gridOptions.data, item => item[props.rowKey] !== row[props.rowKey]);
+
+const selectChange = async records => {
+    gridOptions.data = XEUtils.filter(gridOptions.data, item => XEUtils.find(records, row => row[props.rowKey] == item[props.rowKey]));
+    
+    const newRecords = XEUtils.filter(records, item => !XEUtils.find(gridOptions.data, row => row[props.rowKey] == item[props.rowKey]));
+    const newRows = await xGrid.value?.createRow(newRecords);
+    gridOptions.data = gridOptions.data.concat(newRows);
+}
+
+const hhhhh = (e) => {console.log(e)} 
+
+defineExpose({
+    selectChange
+})
+</script>
+
+<style scoped>
+.sc-form-table.el-main {padding: 0;background: var(--el-bg-color);}
+</style>

+ 187 - 0
src/components/scIconSelect/index.vue

@@ -0,0 +1,187 @@
+<template>
+	<div class="sc-icon-select">
+		<div class="sc-icon-select__wrapper" :class="{ 'hasValue': value }" @click="open">
+			<el-input v-model="value" :disabled="disabled" readonly>
+                <template #prefix>
+                    <sc-iconify :icon="value || 'ep:plus'"></sc-iconify>
+                </template>
+            </el-input>
+		</div>
+
+		<el-dialog v-model="dialogVisible" title="图标选择器" :width="760" destroy-on-close append-to-body>
+            <el-form @submit.prevent>
+                <el-form-item>
+                    <el-input prefix-icon="el-icon-search" v-model="searchText" placeholder="搜索" size="large" clearable />
+                </el-form-item>
+            </el-form>
+
+            <el-tabs v-model="activeName">
+                <el-tab-pane v-for="item in tabsData" :key="item.name" lazy :name="item.name">
+                    <template #label>{{ item.name }}<el-tag type="info" size="small">{{ item.icons.length }}</el-tag></template>
+
+                    <div class="sc-icon-select__list">
+                        <el-scrollbar>
+                            <ul @click="selectIcon">
+                                <template v-if="!item.icons.length">
+                                    <el-empty :image-size="100" description="未查询到相关图标" />
+                                    <sc-iconify :icon="searchText" @load="load"></sc-iconify>
+                                </template>
+                                <li :class="icon == value ? 'select-icon' : ''" v-for="icon in item.icons" :key="icon">
+                                    <span :data-icon="icon"></span>
+                                    <sc-iconify :icon="icon"></sc-iconify>
+                                </li>
+                            </ul>
+                        </el-scrollbar>
+                    </div>
+                </el-tab-pane>
+            </el-tabs>
+
+			<template #footer>
+				<el-button text @click="clear">清除</el-button>
+				<el-button @click="dialogVisible = false">取消</el-button>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+import config from "@/config/iconSelect";
+
+export default {
+    props: {
+        modelValue: { type: String, default: "" },
+        disabled: { type: Boolean, default: false }
+    },
+
+    data() {
+        return {
+            activeName: "默认",
+            tabsData: XEUtils.clone(config.icons, true),
+
+            value: "",
+            dialogVisible: false,
+            searchText: ""
+        }
+    },
+
+    watch: {
+        modelValue(val) {
+            this.value = val;
+        },
+
+        value(val) {
+            this.$emit("update:modelValue", val);
+        },
+
+        searchText(val) {
+            this.search(val);
+        }
+    },
+
+    mounted() {
+        this.value = this.modelValue;
+        if (this.modelValue) {
+            this.activeName = config.selectIcon(this.modelValue).prefix ? config.selectIcon(this.modelValue).name : "Iconify";
+            this.tabsData[2].icons.push(this.modelValue);
+        }
+    },
+
+    methods: {
+        open() {
+            if (this.disabled) return false;
+            this.dialogVisible = true;
+            this.searchText = "";
+            if (!config.selectIcon(this.value).prefix) this.tabsData[2].icons = XEUtils.uniq([...this.tabsData[2].icons, this.value]);
+            nextTick(() => document.querySelector(".select-icon")?.scrollIntoView({ block: "center" }));
+        },
+
+        selectIcon({ target: { tagName, dataset: { icon } } }) {
+            if (tagName != "SPAN") return false;
+            this.value = icon;
+            this.dialogVisible = false;
+        },
+
+        clear() {
+            this.value = "";
+            this.dialogVisible = false;
+        },
+
+        search(text) {
+            let filterData = XEUtils.clone(config.icons, true);
+            if (!config.selectIcon(this.value).prefix) filterData[2].icons = XEUtils.uniq([...filterData[2].icons, this.value]);
+
+            if (text) {
+                XEUtils.arrayEach(filterData, item => {
+                    item.icons = XEUtils.filter(item.icons, icon => icon.toLowerCase().includes(text.toLowerCase()))
+                })
+            }
+            
+            this.tabsData = filterData;
+        },
+
+        load() {
+            this.activeName = "Iconify";
+            this.tabsData[2].icons = XEUtils.uniq([...this.tabsData[2].icons, this.searchText]);
+        }
+    }
+}
+</script>
+
+<style scoped>
+.sc-icon-select, .sc-icon-select__wrapper {display: inline-flex;}
+.sc-icon-select__wrapper :deep(.el-input__wrapper) {cursor: pointer;}
+.sc-icon-select__wrapper :deep(.el-input__wrapper).is-focus {box-shadow: 0 0 0 1px var(--el-input-hover-border-color) inset;}
+.sc-icon-select__wrapper :deep(.el-input__inner) {flex-grow: 0;width: 0;}
+.sc-icon-select__wrapper .sc-iconify-icon {margin: 0;font-size: 16px;}
+.sc-icon-select__wrapper.hasValue .sc-iconify-icon {color: var(--el-text-color-regular);}
+
+.el-tag.el-tag--info {margin-left: 6px;}
+
+.sc-icon-select__list {
+  height: 270px;
+  overflow: auto;
+}
+
+.sc-icon-select__list li {
+  display: inline-block;
+  width: 80px;
+  height: 80px;
+  margin: 5px;
+  vertical-align: top;
+  transition: all 0.1s;
+  border-radius: 4px;
+  position: relative;
+}
+
+.sc-icon-select__list li span {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1;
+  cursor: pointer;
+}
+.sc-icon-select__list li i {
+  display: inline-block;
+  width: 100%;
+  height: 100%;
+  font-size: 26px;
+  color: #6d7882;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 4px;
+}
+
+.sc-icon-select__list li.select-icon,
+.sc-icon-select__list li:hover {
+  box-shadow: 0 0 1px 4px var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+.sc-icon-select__list li.select-icon i,
+.sc-icon-select__list li:hover i {
+  color: var(--el-color-primary);
+}
+</style>

+ 17 - 0
src/components/scIconify/index.vue

@@ -0,0 +1,17 @@
+<template>
+    <el-icon :style="{ fontSize: size + 'px' }" class="sc-iconify-icon" :color="color">
+        <component v-if="config.selectIcon(icon).prefix" :is="config.selectIcon(icon).prefix + icon" />
+        <Icon v-else :icon="icon" @load="$emit('load')" />
+    </el-icon>
+</template>
+
+<script setup>
+import { Icon } from "@iconify/vue";
+import config from "@/config/iconSelect";
+
+const props = defineProps({
+    icon: { type: String, default: "ep:link" },
+    size: { type: String, default: "" },
+    color: { type: String, default: "" }
+});
+</script>

+ 57 - 0
src/components/scImage/index.vue

@@ -0,0 +1,57 @@
+<!--
+ * @Descripttion: vxe-image二次封装
+ * @version: 1.1
+ * @Date: 2021年11月29日12:10:06
+ * @LastEditTime: 2023年12月22日12:02:50
+-->
+
+<template>
+    <vxe-image class="sc-image" :src="formatUrl" :show-preview="false" @click="showPreview"></vxe-image>
+</template>
+
+<script setup>
+import { VxeUI } from "vxe-pc-ui";
+import API from "@/api";
+
+const props = defineProps({
+    file: { type: Object, default: () => {} }
+})
+
+const formatUrl = computed(() => props.file.path.startsWith("/process") ? "/minio" + props.file.path : "data:image/jpeg;base64," + props.file.path);
+
+const showPreview = () => {
+    VxeUI.previewImage({
+        showDownloadButton: true,
+        urlList: [formatUrl.value],
+        downloadMethod: () => handleDownload()
+    });
+}
+
+const handleDownload = () => {
+    if (props.file.path.startsWith("/process")) {
+        API.common.minio.download(props.file.path).then(res => {
+            const a = document.createElement("a");
+            const blob = new Blob([res], { type: props.file.contentType });
+            a.download = props.file.fileName;
+            a.href = URL.createObjectURL(blob);
+            a.click();
+        });
+    } else  {
+        const binaryData = atob(props.file.path);
+        const length = binaryData.length;
+        const uint8Array = new Uint8Array(length);
+        for (let i = 0; i < length; i++) {
+            uint8Array[i] = binaryData.charCodeAt(i);
+        }
+        const a = document.createElement("a");
+        const blob = new Blob([uint8Array], { type: props.file.contentType });
+        a.download = props.file.fileName;
+        a.href = URL.createObjectURL(blob);
+        a.click();
+    }
+}
+
+defineExpose({
+    handleDownload
+})
+</script>

+ 60 - 0
src/components/scPageHeader/index.vue

@@ -0,0 +1,60 @@
+<!--
+ * @Descripttion: 数据表格组件
+ * @version: 1.10
+-->
+
+<template>
+	<div class="scPageHeader">
+        <el-page-header>
+            <template #extra>
+                <div class="page-header-extra__left">
+                    <slot name="title-prefix"></slot>
+                    <div class="extra-title">
+                        <vxe-text-ellipsis :title="$attrs.titleText || pageTitle" :content="$attrs.titleText || pageTitle"></vxe-text-ellipsis>
+                    </div>
+                    <slot name="title-suffix"></slot>
+                </div>
+
+                <div class="page-header-extra__right">
+                    <el-button v-if="$attrs.onFilter" text @click="$emit('filter')">
+                        <template #icon><sc-iconify icon="ant-design:filter-outlined"></sc-iconify></template>筛选
+                    </el-button>
+                    <el-button v-if="$attrs.onExpand" @click="$emit('expand')">
+                        <template #icon><sc-iconify icon="ant-design:swap-outlined"></sc-iconify></template>展开/折叠
+                    </el-button>
+                    <el-button v-if="$attrs.onAdd" type="primary" @click="$emit('add')">
+                        <template #icon><sc-iconify :icon="$attrs.addIcon || 'ant-design:cloud-upload-outlined'"></sc-iconify></template>{{ $attrs.addText || "新增" }}
+                    </el-button>
+                    <slot name="extra-right"></slot>
+                </div>
+            </template>
+
+            <template v-for="(_, slotName) in $slots" #[slotName]="context">
+                <slot :name="slotName" v-bind="{ ...context }"></slot>
+            </template>
+        </el-page-header>
+	</div>
+</template>
+
+<script setup>
+const pageTitle = computed(() => useRoute().meta.title);
+</script>
+
+<style lang="scss" scoped>
+.scPageHeader {padding: 20px 24px;background: #fff;}
+:deep(.el-page-header__header) .el-page-header__left {display: none;}
+:deep(.el-page-header__header) .el-page-header__extra {flex: 1;display: flex;justify-content: space-between;}
+.el-page-header :deep(.el-page-header__main) {border: none;}
+
+:deep(.el-page-header__header) .el-page-header__extra .page-header-extra__left {display: flex;}
+:deep(.el-page-header__header) .el-page-header__extra .page-header-extra__right {display: flex;}
+:deep(.el-page-header__header) .el-page-header__extra .extra-title {display: flex;max-width: 400px;line-height: 32px;font-size: 20px;font-weight: 600;color: rgba(0, 0, 0, 0.88);}
+:deep(.el-page-header__header) .el-page-header__extra .el-button.is-text {font-weight: 400;color: inherit;}
+
+@media (max-width: 992px) {
+    :deep(.el-page-header__header) {display: block;}
+    :deep(.el-page-header__header) .el-page-header__extra {display: block;}
+    :deep(.el-page-header__header) .el-page-header__extra .page-header-extra__left {margin-bottom: 10px;}
+    :deep(.el-page-header__header) .el-page-header__extra .extra-title {max-width: 100%;}
+}
+</style>

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

@@ -0,0 +1,92 @@
+<!--
+ * @Descripttion: 密码强度检测
+ * @version: 1.0
+ * @Author: sakuya
+ * @Date: 2022年6月2日15:36:01
+ * @LastEditors:
+ * @LastEditTime:
+-->
+
+<template>
+	<div class="sc-password-strength">
+		<div class="sc-password-strength-bar" :class="`sc-password-strength-level-${level}`"></div>
+	</div>
+</template>
+
+<script>
+	export default {
+		props: {
+			modelValue: { type: String, default: "" },
+		},
+		data() {
+			return {
+				level: 0
+			}
+		},
+		watch: {
+			modelValue() {
+				this.strength(this.modelValue)
+			}
+		},
+		mounted() {
+			this.strength(this.modelValue)
+		},
+		methods: {
+			strength(v){
+				var _level = 0
+				//长度
+				var has_length = v.length >= 6
+				//包含数字
+				var has_number = /\d/.test(v)
+				//包含小写英文
+				var has_lovercase = /[a-z]/.test(v)
+				//包含大写英文
+				var has_uppercase = /[A-Z]/.test(v)
+				//没有连续的字符3位
+				var no_continuity = !/(\w)\1{2}/.test(v)
+				//包含特殊字符
+				var has_special = /[`~!@#$%^&*()_+<>?:"{},./;'[\]]/.test(v)
+
+				if(v.length <= 0){
+					_level = 0
+					this.level = _level
+					return false
+				}
+				if(!has_length){
+					_level = 1
+					this.level = _level
+					return false
+				}
+				if(has_number){
+					_level += 1
+				}
+				if(has_lovercase){
+					_level += 1
+				}
+				if(has_uppercase){
+					_level += 1
+				}
+				if(no_continuity){
+					_level += 1
+				}
+				if(has_special){
+					_level += 1
+				}
+				this.level = _level
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.sc-password-strength {height: 5px;width: 100%;background: var(--el-color-info-light-5);border-radius: 5px;position: relative;margin:10px 0;}
+	.sc-password-strength:before {left: 20%;}
+	.sc-password-strength:after {right: 20%;}
+	.sc-password-strength:before, .sc-password-strength:after {position: absolute;content: "";display: block;width: 20%;height: inherit;border: 5px solid var(--el-bg-color-overlay);border-top: 0;border-bottom: 0;z-index: 1;background-color: transparent;box-sizing: border-box;}
+	.sc-password-strength-bar {position: absolute;height: inherit;width: 0%;border-radius: inherit;transition: width .5s ease-in-out,background .25s;background: transparent;}
+	.sc-password-strength-level-1 {width: 20%;background-color: var(--el-color-error);}
+	.sc-password-strength-level-2 {width: 40%;background-color: var(--el-color-error);}
+	.sc-password-strength-level-3 {width: 60%;background-color: var(--el-color-warning);}
+	.sc-password-strength-level-4 {width: 80%;background-color: var(--el-color-success);}
+	.sc-password-strength-level-5 {width: 100%;background-color: var(--el-color-success);}
+</style>

+ 86 - 0
src/components/scQrCode/index.vue

@@ -0,0 +1,86 @@
+<!--
+ * @Descripttion: 生成二维码组件
+ * @version: 1.0
+ * @Author: sakuya
+ * @Date: 2021年12月20日14:22:20
+ * @LastEditors:
+ * @LastEditTime:
+-->
+
+<template>
+	<img ref="img" />
+</template>
+
+<script>
+	import QRcode from "qrcodejs2"
+
+	export default {
+		props: {
+			text: { type: String, required: true, default: "" },
+			size: { type: Number, default: 100 },
+			logo: { type: String, default: "" },
+			logoSize: { type: Number, default: 30 },
+			logoPadding: { type: Number, default: 5 },
+			colorDark: { type: String, default: "#000" },
+			colorLight: { type: String, default: "#fff" },
+			correctLevel: { type: Number, default: 2 },
+		},
+		data() {
+			return {
+				qrcode: null
+			}
+		},
+		watch: {
+			text() {
+				this.draw()
+			}
+		},
+		mounted() {
+			this.draw()
+		},
+		methods: {
+			// 创建原始二维码DOM
+			async create() {
+				return new Promise(resolve => {
+					var element = document.createElement("div");
+					new QRcode(element, {
+						text: this.text,
+						width: this.size,
+						height: this.size,
+						colorDark: this.colorDark,
+						colorLight: this.colorLight,
+						correctLevel: this.correctLevel
+					})
+					if (element.getElementsByTagName("canvas")[0]) {
+						this.qrcode = element
+						resolve()
+					}
+				})
+			},
+			// 绘制LOGO
+			async drawLogo() {
+				return new Promise((resolve) => {
+					var logo = new Image()
+					logo.src = this.logo
+					const logoPos = (this.size - this.logoSize) / 2
+					const rectSize = this.logoSize + this.logoPadding
+					const rectPos = (this.size - rectSize) / 2
+					var ctx = this.qrcode.getElementsByTagName("canvas")[0].getContext("2d")
+					logo.onload = ()=>{
+						ctx.fillRect(rectPos, rectPos, rectSize, rectSize)
+						ctx.drawImage(logo, logoPos, logoPos, this.logoSize, this.logoSize)
+						resolve()
+					}
+				})
+			},
+			async draw() {
+				await this.create()
+                this.logo && await this.drawLogo()
+				this.$refs.img.src = this.qrcode.getElementsByTagName("canvas")[0].toDataURL("image/png")
+			}
+		}
+	}
+</script>
+
+<style>
+</style>

+ 91 - 0
src/components/scStatusIndicator/index.vue

@@ -0,0 +1,91 @@
+<!--
+ * @Descripttion: 状态指示器
+ * @version: 1.0
+ * @Author: sakuya
+ * @Date: 2021年11月11日09:30:12
+ * @LastEditors:
+ * @LastEditTime:
+-->
+
+<template>
+    <div :class="['sc-status-indicator', content && 'hasValue']">
+	    <span class="sc-state" :class="[{ 'sc-status-processing': pulse }, 'sc-state-bg--' + type]"></span>
+        {{ content }}
+    </div>
+</template>
+
+<script>
+	export default {
+		props: {
+			type: { type: String, default: "primary" },
+			pulse: { type: Boolean, default: false },
+			content: { type: String, default: "" }
+		}
+	}
+</script>
+
+<style scoped>
+.sc-status-indicator {display: flex;align-items: center;}
+.sc-state {
+  display: inline-block;
+  background: #000;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  vertical-align: middle;
+}
+.sc-status-processing {
+  position: relative;
+}
+.sc-status-processing:after {
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  background: inherit;
+  content: "";
+  animation: warn 1.2s ease-in-out infinite;
+}
+
+.sc-state-bg--offline {
+    background: rgba(0, 0, 0, 0.25);
+}
+.sc-state-bg--primary {
+  background: var(--el-color-primary);
+}
+.sc-state-bg--success {
+  background: var(--el-color-success);
+}
+.sc-state-bg--warning {
+  background: var(--el-color-warning);
+}
+.sc-state-bg--danger {
+  background: var(--el-color-danger);
+}
+.sc-state-bg--info {
+  background: var(--el-color-info);
+}
+.sc-state-bg--orange {
+    background: #fa8c16;
+}
+
+.sc-status-indicator.hasValue .sc-state {
+    margin-right: 6px;
+}
+
+@keyframes warn {
+  0% {
+    transform: scale(0.5);
+    opacity: 1;
+  }
+  30% {
+    opacity: 0.7;
+  }
+  100% {
+    transform: scale(2.5);
+    opacity: 0;
+  }
+}
+</style>

+ 83 - 0
src/components/scTable/helper.js

@@ -0,0 +1,83 @@
+import XEUtils from "xe-utils";
+
+/**
+ * 输入框配置
+ * @param field 字段
+ * @param title 标题
+ * @param config 配置
+ */
+export const mapFormItemInput = (field, title, config = {}) => ({
+    field,
+    title,
+    titlePrefix: { content: title, icon: "vxe-icon-question-circle-fill" },
+    itemRender: {
+        name: "ElInput",
+        props: { clearable: true, placeholder: `请输入${title}` }
+    },
+    ...config
+})
+
+/**
+ * 选择框配置
+ * @param field 字段
+ * @param title 标题
+ * @param config 其他配置
+ */
+export const mapFormItemSelect = (field, title, config = {}) => ({
+    field,
+    title,
+    titlePrefix: { content: title, icon: "vxe-icon-question-circle-fill" },
+    itemRender: {
+        name: "$form-select",
+        props: { popperClass: "vxe-table-slot--popper", filterable: true, clearable: true, placeholder: `请选择${title}`, ...XEUtils.get(config, "props") },
+        api: {
+            key: XEUtils.get(config, "api.key", null),
+            query: XEUtils.get(config, "api.query", {}),
+            expands: XEUtils.get(config, "api.expands", {})
+        }, 
+        ...XEUtils.omit(config, "props", "api")
+    },
+    ...config
+})
+
+/**
+ * 单选配置
+ * @param field 字段
+ * @param title 标题
+ * @param config 其他配置
+ */
+export const mapFormItemRadio = (field, title, config = {}) => ({
+    field,
+    title,
+    titlePrefix: { content: title, icon: "vxe-icon-question-circle-fill" },
+    itemRender: {
+        name: "$form-radio",
+        ...config
+    },
+    ...config
+})
+
+/**
+ * 日期配置
+ * @param field 字段
+ * @param title 标题
+ * @param config 其他配置
+ */
+export const mapFormItemDatePicker = (field, title, config = {}) => ({
+    field,
+    title,
+    titlePrefix: { content: title, icon: "vxe-icon-question-circle-fill" },
+    itemRender: {
+        name: "ElDatePicker",
+        props: {
+            startPlaceholder: "开始日期",
+            endPlaceholder: "结束日期",
+            valueFormat: "YYYY-MM-DD HH:mm:ss",
+            placeholder: `请选择${title}`,
+            defaultTime: XEUtils.get(config, "props.type")?.includes("range") ? [new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)] : new Date(2000, 1, 1, 23, 59, 59),
+            ...XEUtils.get(config, "props")
+        },
+        ...XEUtils.omit(config, "props")
+    },
+    ...config
+})

+ 319 - 0
src/components/scTable/index.vue

@@ -0,0 +1,319 @@
+<!--
+ * @Descripttion: 数据表格组件
+ * @version: 1.10
+-->
+
+<template>
+    <el-main>
+        <vxe-grid ref="xGrid" v-bind="gridOptions" @form-collapse="formCollapseEvent" @edit-activated="$emit('editActivated', $event)" @page-change="pageChangeEvent">
+            <template #queryAction>
+                <el-button type="primary" auto-insert-space @click="searchData">查询</el-button>
+                <el-button auto-insert-space @click="resetData">重置</el-button>
+            </template>
+
+            <!-- table-column / 操作 -->
+            <template v-for="(_, slotName) in $slots" #[slotName]="context">
+                <slot :name="slotName" v-bind="{ ...context, row: XEUtils.get(context, '$grid', XEUtils.get(context, '$table'))?.getData(context.rowIndex) || context.row }"></slot>
+            </template>
+        </vxe-grid>
+    </el-main>
+</template>
+
+<script setup>
+// 设置当前zIndex起始值
+import domZIndex from "dom-zindex";
+domZIndex.setCurrent(domZIndex.getMax() + 1);
+
+import XEUtils from "xe-utils";
+import store from "@/store";
+import config from "@/config/table";
+import pagerBatchDel from "./renderer/pager-batch-del";
+import { nextTick } from "vue";
+
+const props = defineProps({
+    apiObj: { type: Object, default: () => {} },
+    apiKey: { type: String, default: () => "get" },
+    rowKey: { type: String, default: "id" },
+    minHeight: { type: [String, Number], default: 144 },
+    maxHeight: { type: [String, Number] },
+    layouts: { type: Array, default: () => [["Top", "Form"], ["Toolbar", "Table", "Bottom", "Pager"]] },
+    checkedRows: { type: Array, default: () => [] },
+    /* ***************  query  *************** */ 
+    formConfig: { type: Object, default: () => {} },
+    paramsColums: { type: Array, default: () => [] },
+    /* ***************  query  *************** */ 
+    toolbarConfig: { type: Object, default: () => {} },
+    editConfig: { type: Object, default: () => {} },
+    columns: { type: Array, default: () => [] },
+    /* ***************  pager  *************** */ 
+    pagerConfig: { type: Object, default: () => {} },
+    batchDel: { type: Boolean, default: () => false },
+    /* ***************  pager  *************** */ 
+    options: { type: Object, default: () => {} }
+})
+
+const xGrid = ref();
+const selectedRows = ref([]);
+const gridOptions = ref({
+    id: "xGride-table",
+    loading: false,
+    minHeight: props.minHeight,
+    border: "full",
+    size: "mini",
+    align: "center",
+    data: [],
+    columns: XEUtils.map(props.columns, item => ({ ...item, [item.type != "seq" && "exportMethod"]: ({ row, column }) => row[column?.field] || "" })),
+    showOverflow: true,
+    keepSource: true,
+    layouts: [...props.layouts],
+    formConfig: {
+        enabled: true,
+        className: "vxe-table-query",
+        titleAlign: "right",
+        collapseStatus: true,
+        span: 6,
+        items: [
+            ...XEUtils.map(XEUtils.orderBy(XEUtils.get(props, "formConfig.items", []), "orderBy"), (formItem, formIndex) => {
+                return {
+                    ...formItem,
+                    className: ({ $grid, item, data }) => {
+                        const showItems = XEUtils.filter(XEUtils.orderBy(XEUtils.get(props, "formConfig.items", []), "orderBy"), f_item => (XEUtils.isUndefined(f_item.visible) || f_item.visible) && (XEUtils.isUndefined(f_item.visibleMethod) || f_item.visibleMethod({ data })));
+                        const index = XEUtils.findIndexOf(showItems, f_item => f_item.field == item.field);
+                        item.folding = index > 2;
+                        XEUtils.set(formItem, "folding", index > 2);
+                        return "";
+                    }
+                }
+            }), {
+                align: "right",
+                slots: { default: "queryAction" },
+                className: ({ $grid, item, data }) => {
+                    const showItems = XEUtils.filter(XEUtils.orderBy(XEUtils.get(props, "formConfig.items", []), "orderBy"), formItem => (XEUtils.isUndefined(formItem.visible) || formItem.visible) && (XEUtils.isUndefined(formItem.visibleMethod) || formItem.visibleMethod({ data })));
+                    const spanItems = (!gridOptions.value.formConfig.collapseStatus && showItems) || XEUtils.filter(showItems, f_item => !f_item.folding);
+
+                    let spanItemsSum = 0;
+                    XEUtils.arrayEach(spanItems, s_item => {
+                        const spanCount = (s_item.span || 6);
+                        if (spanCount > 24 - (spanItemsSum % 24)) spanItemsSum += 24 - (spanItemsSum % 24);
+                        spanItemsSum += spanCount;
+                    })
+                    const remainder = 24 - (spanItemsSum % 24);
+
+                    item.visible = showItems.length > 0;
+                    item.collapseNode = showItems.length > 3;
+                    item.span = remainder < 5 && 24 || remainder;
+                    return "query-action__item";
+                }
+            }
+        ],
+        ...XEUtils.omit(XEUtils.get(props, "formConfig", {}), "items")
+    },
+    toolbarConfig: {
+        enabled: false,
+        buttons: [
+            { buttonRender: { name: "$table-search" } }
+        ],
+        print: true,
+        zoom: true,
+        custom: true,
+        refresh: {
+            queryMethod: () => getData()
+        },
+        ...props.toolbarConfig
+    },
+    customConfig: {
+        mode: "default" // default, modal, drawer
+    },
+    proxyConfig: {
+        enabled: false,
+        ajax: {
+            queryAll: () => getAllData()
+        }
+    },
+    printConfig: {},
+    importConfig: {
+        // remote: true,
+        types: ["xlsx", "xls"],
+        mode: "insertBottom",
+        modes: ["insertBottom", "insertTop", "covering"],
+        // importMethod: ({ $grid, options }) => {},
+    },
+    exportConfig: {
+        types: ["xlsx"],
+        modes: XEUtils.find(props.columns, item => XEUtils.includes(config.exportExcludeFields, item.type)) && ["current", "selected", "all"] || ["current", "all"],
+    },
+    rowConfig: {
+        keyField: props.rowKey,
+        useKey: true,
+        isHover: true
+    },
+    columnConfig: {
+        useKey: true,
+        resizable: true // 列宽拖动功能
+    },
+    headerCellConfig: {
+        height: 36
+    },
+    cellConfig: {
+        height: 36
+    },
+    checkboxConfig: {
+        highlight: true,
+        range: true, // 鼠标在复选框的列内滑动选中或取消指定行
+        isShiftKey: true // 鼠标点击和 shift 键选取指定范围的行
+    },
+    tooltipConfig: {
+        enterable: true
+    },
+    expandConfig: {
+        padding: true
+    },
+    editConfig: {
+        enabled: false,
+        mode: "cell",
+        trigger: "click",
+        showStatus: true,
+        ...props.editConfig
+    },
+    pagerConfig: {
+        pageSizes: config.pageSizes,
+        layouts: config.layouts,
+        currentPage: 1,
+        pageSize: config.pageSize,
+        total: 0,
+        slots: {
+            left: params => props.batchDel && h(pagerBatchDel, { params, onSuccess: ids => table_batch_del(ids) })
+        },
+        ...props.pagerConfig
+    },
+    ...props.options
+})
+
+watch(() => xGrid.value?.getCheckboxRecords(), val => selectedRows.value = val);
+watch(() => props.options, val => XEUtils.merge(gridOptions.value, val), { deep: true });
+watch(() => gridOptions.value.data, val => {
+    if (props.columns.find(item => item.type == "checkbox" && XEUtils.get(item, "visible", true)) && props.checkedRows.length) {
+        xGrid.value?.setCheckboxRow(props.checkedRows, true);
+    }
+}, { deep: true });
+
+addEventListener("resize", () => resizeTable());
+onMounted(() => {
+    resizeTable();
+    getData();
+});
+
+const resizeTable = () => {
+    nextTick(() => {
+        if (store.state.global.ismobile) XEUtils.set(gridOptions.value, "maxHeight", 1048);
+        else XEUtils.set(gridOptions.value, "maxHeight", (props.maxHeight || xGrid.value?.$el.parentElement.offsetHeight));
+    });
+}
+
+// 获取数据
+const getData = () => {
+    nextTick(() => {
+        if (!props.apiObj) {
+            if (props.options.data && props.options.data.length > 0) {
+                gridOptions.value.pagerConfig.total = props.options.data.length;
+                return;
+            }
+
+            gridOptions.value.data = [];
+            gridOptions.value.pagerConfig.total = 0;
+            return;
+        }
+
+        gridOptions.value.loading = true;
+        const reqData = config.queryData(gridOptions.value, props.paramsColums);
+        props.apiObj[props.apiKey](reqData).then(res => {
+            const response = config.parseData(res);
+            gridOptions.value.data = response.data || [];
+            gridOptions.value.pagerConfig.total = response.total || 0;
+            gridOptions.value.loading = false;
+        }).catch(error => {
+            gridOptions.value.loading = false;
+            gridOptions.value.data = [];
+            gridOptions.value.pagerConfig.total = 0;
+        });
+    });
+}
+
+const getAllData = () => {
+    return new Promise((resolve, reject) => {
+        if (!props.apiObj) resolve([]);
+        
+        const reqData = config.queryExport(gridOptions.value, props.paramsColums);
+        props.apiObj[props.apiKey](reqData).then(res => {
+            const response = config.parseData(res);
+            resolve(response.data || [])
+        }).catch(error => reject());
+    });
+}
+
+const pageChangeEvent = ({ pageSize, currentPage }) => {
+    gridOptions.value.pagerConfig.currentPage = currentPage;
+    gridOptions.value.pagerConfig.pageSize = pageSize;
+    getData();
+}
+
+const searchData = (mode = "add") => {
+    if (mode == "add") gridOptions.value.pagerConfig.currentPage = 1;
+    gridOptions.value.pagerConfig.pageSize = config.pageSize;
+    getData();
+}
+
+const resetData = () => {
+    gridOptions.value.pagerConfig.currentPage = 1;
+    gridOptions.value.pagerConfig.pageSize = config.pageSize;
+    xGrid.value.resetForm();
+    
+    getData();
+}
+
+const formCollapseEvent = ({ collapse }) => gridOptions.value.formConfig.collapseStatus = collapse;
+
+const toggleTableLoading = value => gridOptions.value.loading = value;
+
+const toggleFormEnabled = () => gridOptions.value.formConfig.enabled = !gridOptions.value.formConfig.enabled;
+
+const toggleTableExpand = () => xGrid.value.getTreeExpandRecords().length && xGrid.value.clearTreeExpand() || xGrid.value.setAllTreeExpand(true);
+
+const toggleToolbarProps = obj => XEUtils.objectEach(obj, (value, key) => XEUtils.set(gridOptions.value.toolbarConfig, key, value));
+
+const reloadColumn = columns => xGrid.value.reloadColumn(columns);
+
+const getTableData = () => xGrid.value.getTableData();
+
+const reloadData = data => xGrid.value.reloadData(data);
+
+const table_batch_del = ids => {
+    ElMessageBox.confirm("是否确认删除已选择的数据?", "删除警告", {
+        type: "warning",
+        confirmButtonText: "确定",
+        cancelButtonText: "取消"
+    }).then(() => {
+        props.apiObj.batchDel({ deleteIdList: ids }).then(() => {
+            ElMessage.success("操作成功");
+            getData();
+        });
+    });
+}
+
+defineExpose({
+    selectedRows,
+    toggleTableLoading,
+    toggleFormEnabled,
+    toggleTableExpand,
+    toggleToolbarProps,
+    reloadColumn,
+    getTableData,
+    reloadData,
+    searchData,
+    resetData
+})
+</script>
+
+<style scoped>
+.el-main {padding: 0 12px 12px;background: var(--el-bg-color);}
+</style>

+ 20 - 0
src/components/scTable/renderer/cell-tag.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-tag v-if="modelValue" effect="plain" :type="tagType" v-bind="renderOpts.props">{{ XEUtils.get(renderOpts.options, modelValue, modelValue) }}</el-tag>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+
+const colorDic = {
+    enable: "success",
+    disable: "red"
+}
+
+const props = defineProps({
+    renderOpts: { type: Object, default: () => {} },
+    params: { type: Object, default: () => {} }
+})
+
+const modelValue = computed(() => XEUtils.get(props, "renderOpts.defaultValue", null));
+const tagType = computed(() => XEUtils.get(colorDic, modelValue.value, ""));
+</script>

+ 33 - 0
src/components/scTable/renderer/form-radio.vue

@@ -0,0 +1,33 @@
+<template>
+    <el-radio-group v-model="modelValue" v-bind="renderOpts.props" @change="compChange">
+        <el-radio v-for="(item, index) in renderOpts.options" :key="index" :label="formatOptions('label', { item, index })" :value="formatOptions('value',  { item, index })"></el-radio>
+    </el-radio-group>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+import config from "@/config/select";
+
+const props = defineProps({
+    renderOpts: { type: Object, default: () => {} },
+    params: { type: Object, default: () => {} }
+})
+
+const modelValue = ref(null);
+watch(() => props.params, val => modelValue.value = XEUtils.get(val.data, val.field), { deep: true, immediate: true });
+
+const optionProps = reactive(props.renderOpts.optionProps || config.props);
+const formatOptions = (key, { item, index }) => {
+    if (XEUtils.isFunction(optionProps[key])) return optionProps[key]({ data: item, index });
+    return XEUtils.get(item, optionProps[key]);
+}
+
+const compChange = () => props.renderOpts.events.change({ [props.params.field]: modelValue.value });
+</script>
+
+<style scoped>
+@media (max-width: 1120px) {
+    .el-radio-group {flex-wrap: nowrap;} 
+    .el-radio {margin-right: 10px;}
+}
+</style>

+ 54 - 0
src/components/scTable/renderer/form-select.vue

@@ -0,0 +1,54 @@
+<template>
+    <el-select v-model="modelValue" :loading="loading" v-bind="renderOpts.props" @change="compChange">
+        <template #label="{ label }">
+            <span v-if="renderOpts.slot && XEUtils.isString(label)">{{ XEUtils.first(label.split(" ")) }}</span>
+            <span v-else>{{ label }}</span>
+        </template>
+        <el-option v-for="(item, index) in options" :key="index" :label="formatOptions('label', { item, index }) + ' ' + formatOptions('slot', { item, index })" :value="formatOptions('value', { item, index })">
+            <span style="float: left;">{{ formatOptions('label', { item, index }) }}</span>
+            <span v-if="renderOpts.slot" :style="XEUtils.get(renderOpts, 'slot.style', {})">{{ formatOptions('slot', { item, index }) }}</span>
+        </el-option>
+    </el-select>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+import TOOL from "@/utils/tool";
+import config from "@/config/select";
+
+const props = defineProps({
+    renderOpts: { type: Object, default: () => {} },
+    params: { type: Object, default: () => {} }
+})
+
+watch(props.params.data, e => {
+    if (modelValue.value != XEUtils.get(e, props.params.field)) modelValue.value = XEUtils.get(e, props.params.field)
+}, { deep: true })
+watch(props.renderOpts.api.query, () => getRemoteData())
+
+const loading = ref(false);
+const modelValue = ref(XEUtils.get(props, "renderOpts.defaultValue", null));
+const options = ref(TOOL.data.get(props.renderOpts.storageKey) || props.renderOpts.options);
+const optionProps = reactive(props.renderOpts.optionProps);
+
+const getRemoteData = async () => {
+	loading.value = true;
+    options.value = await config.queryData(props.renderOpts.api);
+    loading.value = false;
+}
+
+const formatOptions = (key, { item, index }) => {
+    if (XEUtils.isFunction(optionProps[key])) return optionProps[key]({ data: item, index });
+    return XEUtils.get(item, optionProps[key], "");
+};
+
+const compChange = () => props.renderOpts.events.change({ [props.params.field]: modelValue.value });
+
+!options.value.length && !props.renderOpts.storageKey && getRemoteData();
+
+const storageChange = ({ key, newValue }) => {
+    if (key === props.renderOpts.storageKey) options.value = XEUtils.toStringJSON(newValue).content;
+}
+window.addEventListener("setItemEvent", storageChange);
+onUnmounted(() => window.removeEventListener("setItemEvent", storageChange));
+</script>

+ 22 - 0
src/components/scTable/renderer/pager-batch-del.vue

@@ -0,0 +1,22 @@
+<template>
+    <el-button type="danger" plain :disabled="!ids || !ids.length" @click="$emit('success', ids)">
+        <sc-iconify icon="ant-design:delete-outlined"></sc-iconify>
+    </el-button>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+
+const $emit = defineEmits(["success"]);
+const props = defineProps({
+    params: { type: Object, default: () => {} },
+    apiObj: { type: Object, default: () => {} }
+})
+
+const ids = ref([]);
+watch(() => props.params.$grid?.getCheckboxRecords(), val => ids.value = XEUtils.map(val, item => item.id))
+</script>
+
+<style scoped>
+.el-button {position: absolute;left: 0;}
+</style>

+ 51 - 0
src/components/scTable/renderer/table-search.vue

@@ -0,0 +1,51 @@
+<template>
+    <el-input v-model="tableSearch.filter" clearable placeholder="搜索当前表格" @keyup="searchInTable" @clear="searchInTable">
+        <template #prefix><sc-iconify icon="ep:search"></sc-iconify></template>
+    </el-input>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+
+const props = defineProps({
+    renderOpts: { type: Object, default: () => {} },
+    params: { type: Object, default: () => {} }
+})
+
+const tableSearch = ref({
+    filter: ""
+})
+
+const searchInTable = () => {
+    const filterName = XEUtils.toValueString(tableSearch.value.filter).trim().toLowerCase();
+    const tableData = XEUtils.clone(props.params.$grid?.getData(), true);
+    if (filterName) {
+        const filterRE = new RegExp(filterName.replace(/([.?*+^$[\]\\(){}|-])/gi, "\\$1"), "gi");
+        const searchColumns = props.params.$grid?.getColumns()?.filter(col => col.field);
+
+        const data = tableData?.map(item => {
+            searchColumns?.forEach(col => XEUtils.set(
+                item,
+                col.field,
+                props.params.$grid?.getCellLabel(item, col.field)
+            ));
+            return item;
+        })?.filter(item => searchColumns?.some(col => XEUtils.toValueString(XEUtils.get(item, col.field)).toLowerCase().includes(filterName)))?.map(row => {
+            const item = Object.assign({}, row);
+            searchColumns?.filter(col => col.type === "html").forEach(col => {
+                XEUtils.set(
+                    item,
+                    col.field,
+                    XEUtils.toValueString(XEUtils.get(item, col.field)).replace(filterRE, match => `<span class="keyword-lighten">${match}</span>`)
+                )
+            });
+            return item;
+        })
+        props.params.$grid?.reloadData(data);
+    } else props.params.$grid?.reloadData(tableData);
+}
+</script>
+
+<style scoped>
+.el-input {width: 180px;}
+</style>

+ 234 - 0
src/components/scTableSelect/index.vue

@@ -0,0 +1,234 @@
+<!--
+ * @Descripttion: 表格选择器组件
+ * @version: 1.3
+ * @Author: sakuya
+ * @Date: 2021年6月10日10:04:07
+ * @LastEditors: sakuya
+ * @LastEditTime: 2022年6月6日21:50:36
+-->
+
+<template>
+	<el-select ref="select" v-model="defaultValue" :size="size" :clearable="clearable" :multiple="multiple" :collapse-tags="collapseTags" :collapse-tags-tooltip="collapseTagsTooltip" :filterable="filterable" :placeholder="placeholder" :disabled="disabled" :filter-method="filterMethod" @remove-tag="removeTag" @visible-change="visibleChange" @clear="clear">
+		<template #empty>
+			<div class="sc-table-select__table" :style="{width: tableWidth+'px'}" v-loading="loading">
+				<div class="sc-table-select__header">
+					<slot name="header" :form="formData" :submit="formSubmit"></slot>
+				</div>
+				<el-table ref="table" :data="tableData" :height="245" :highlight-current-row="!multiple" @row-click="click" @select="select" @select-all="selectAll">
+					<el-table-column v-if="multiple" type="selection" width="45"></el-table-column>
+					<el-table-column v-else type="index" width="45">
+						<template #default="scope"><span>{{scope.$index+(currentPage - 1) * pageSize + 1}}</span></template>
+					</el-table-column>
+					<slot></slot>
+				</el-table>
+				<div class="sc-table-select__page">
+					<el-pagination small background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:currentPage="currentPage" @current-change="reload"></el-pagination>
+				</div>
+			</div>
+		</template>
+	</el-select>
+</template>
+
+<script>
+	import config from "@/config/tableSelect";
+
+	export default {
+		props: {
+			modelValue: null,
+			apiObj: { type: Object, default: () => {} },
+			params: { type: Object, default: () => {} },
+			placeholder: { type: String, default: "请选择" },
+			size: { type: String, default: "default" },
+			clearable: { type: Boolean, default: false },
+			multiple: { type: Boolean, default: false },
+			filterable: { type: Boolean, default: false },
+			collapseTags: { type: Boolean, default: false },
+			collapseTagsTooltip: { type: Boolean, default: false },
+			disabled: { type: Boolean, default: false },
+			tableWidth: {type: Number, default: 400},
+			mode: { type: String, default: "popover" },
+			props: { type: Object, default: () => {} }
+		},
+		data() {
+			return {
+				loading: false,
+				keyword: null,
+				defaultValue: [],
+				tableData: [],
+				pageSize: config.pageSize,
+				total: 0,
+				currentPage: 1,
+				defaultProps: {
+					label: config.props.label,
+					value: config.props.value,
+					page: config.request.page,
+					pageSize: config.request.pageSize,
+					keyword: config.request.keyword
+				},
+				formData: {}
+			}
+		},
+		computed: {
+
+		},
+		watch: {
+			modelValue:{
+				handler(){
+					this.defaultValue = this.modelValue
+					this.autoCurrentLabel()
+				},
+				deep: true
+			}
+		},
+		mounted() {
+			this.defaultProps = Object.assign(this.defaultProps, this.props);
+			this.defaultValue =  this.modelValue
+			this.autoCurrentLabel()
+		},
+		methods: {
+			//表格显示隐藏回调
+			visibleChange(visible){
+				if(visible){
+					this.currentPage = 1
+					this.keyword = null
+					this.formData = {}
+					this.getData()
+				}else{
+					this.autoCurrentLabel()
+				}
+			},
+			//获取表格数据
+			async getData(){
+				this.loading = true;
+				var reqData = {
+					[this.defaultProps.page]: this.currentPage,
+					[this.defaultProps.pageSize]: this.pageSize,
+					[this.defaultProps.keyword]: this.keyword
+				}
+				Object.assign(reqData, this.params, this.formData)
+				var res = await this.apiObj.get(reqData);
+				var parseData = config.parseData(res)
+				this.tableData = parseData.rows;
+				this.total = parseData.total;
+				this.loading = false;
+				//表格默认赋值
+				this.$nextTick(() => {
+					if(this.multiple){
+						this.defaultValue.forEach(row => {
+							var setrow = this.tableData.filter(item => item[this.defaultProps.value]===row[this.defaultProps.value])
+							if(setrow.length > 0){
+								this.$refs.table.toggleRowSelection(setrow[0], true);
+							}
+						})
+					}else{
+						var setrow = this.tableData.filter(item => item[this.defaultProps.value]===this.defaultValue[this.defaultProps.value])
+						this.$refs.table.setCurrentRow(setrow[0]);
+					}
+					this.$refs.table.setScrollTop(0)
+				})
+			},
+			//插糟表单提交
+			formSubmit(){
+				this.currentPage = 1
+				this.keyword = null
+				this.getData()
+			},
+			//分页刷新表格
+			reload(){
+				this.getData()
+			},
+			//自动模拟options赋值
+			autoCurrentLabel(){
+				this.$nextTick(() => {
+					if(this.multiple){
+						this.$refs.select.selected.forEach(item => {
+							item.currentLabel = item.value[this.defaultProps.label]
+						})
+					}else{
+						this.$refs.select.selectedLabel = this.defaultValue[this.defaultProps.label]
+					}
+				})
+			},
+			//表格勾选事件
+			select(rows, row){
+				var isSelect = rows.length && rows.indexOf(row) !== -1
+				if(isSelect){
+					this.defaultValue.push(row)
+				}else{
+					this.defaultValue.splice(this.defaultValue.findIndex(item => item[this.defaultProps.value] == row[this.defaultProps.value]), 1)
+				}
+				this.autoCurrentLabel()
+				this.$emit('update:modelValue', this.defaultValue);
+				this.$emit('change', this.defaultValue);
+			},
+			//表格全选事件
+			selectAll(rows){
+				var isAllSelect = rows.length > 0
+				if(isAllSelect){
+					rows.forEach(row => {
+						var isHas = this.defaultValue.find(item => item[this.defaultProps.value] == row[this.defaultProps.value])
+						if(!isHas){
+							this.defaultValue.push(row)
+						}
+					})
+				}else{
+					this.tableData.forEach(row => {
+						var isHas = this.defaultValue.find(item => item[this.defaultProps.value] == row[this.defaultProps.value])
+						if(isHas){
+							this.defaultValue.splice(this.defaultValue.findIndex(item => item[this.defaultProps.value] == row[this.defaultProps.value]), 1)
+						}
+					})
+				}
+				this.autoCurrentLabel()
+				this.$emit('update:modelValue', this.defaultValue);
+				this.$emit('change', this.defaultValue);
+			},
+			click(row){
+				if(this.multiple){
+					//处理多选点击行
+				}else{
+					this.defaultValue = row
+					this.$refs.select.blur()
+					this.autoCurrentLabel()
+					this.$emit('update:modelValue', this.defaultValue);
+					this.$emit('change', this.defaultValue);
+				}
+			},
+			//tags删除后回调
+			removeTag(tag){
+				var row = this.findRowByKey(tag[this.defaultProps.value])
+				this.$refs.table.toggleRowSelection(row, false);
+				this.$emit('update:modelValue', this.defaultValue);
+			},
+			//清空后的回调
+			clear(){
+				this.$emit('update:modelValue', this.defaultValue);
+			},
+			// 关键值查询表格数据行
+			findRowByKey (value) {
+				return this.tableData.find(item => item[this.defaultProps.value] === value)
+			},
+			filterMethod(keyword){
+				if(!keyword){
+					this.keyword = null;
+					return false;
+				}
+				this.keyword = keyword;
+				this.getData()
+			},
+			// 触发select隐藏
+			blur(){
+				this.$refs.select.blur();
+			},
+			// 触发select显示
+			focus(){
+				this.$refs.select.focus();
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.sc-table-select__table {padding:12px;}
+	.sc-table-select__page {padding-top: 12px;}
+</style>

+ 204 - 0
src/components/scUpload/file.vue

@@ -0,0 +1,204 @@
+<template>
+	<div class="sc-upload-file">
+		<el-upload ref="uploader"
+			:class="hideAdd && 'el-upload-hide-add'"
+			v-model:file-list="defaultFileList"
+			action=""
+			:accept="accept"
+			:limit="limit"
+			:drag="drag"
+			:multiple="multiple"
+			:disabled="disabled"
+			show-file-list
+			:http-request="request"
+			:before-upload="before"
+			:before-remove="beforeRemove"
+			:on-success="success"
+			:on-error="error"
+			:on-preview="handlePreview"
+			:on-exceed="handleExceed">
+			<slot>
+                <div v-if="drag" class="file-empty">
+                    <el-icon><el-icon-upload-filled /></el-icon>
+                    <h4>将文件拖到此处,或<el-text type="primary">点击上传</el-text></h4>
+                </div>
+                <vxe-button v-else status="primary" size="mini" content="点击上传" :disabled="disabled"></vxe-button>
+			</slot>
+			<template #tip>
+				<div v-if="tip" :class="['el-upload__tip', tipRequired && 'el-upload__tip-required']">{{ tip }}</div>
+			</template>
+		</el-upload>
+		<span style="display: none!important;"><el-input v-model="value"></el-input></span>
+	</div>
+
+    <file-viewer v-if="showViewer" ref="fileViewer" @closed="showViewer = false"></file-viewer>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+
+export default {
+    props: {
+        modelValue: { type: Array, default: () => [] },
+        tip: { type: String, default: "" },
+        tipRequired: { type: Boolean, default: false },
+        accept: { type: String, default: "" },
+        maxSize: { type: Number, default: 50 },
+        limit: { type: Number, default: 0 },
+        drag: { type: Boolean, default: false },
+        multiple: { type: Boolean, default: true },
+        disabled: { type: Boolean, default: false },
+        hideAdd: { type: Boolean, default: false },
+        onSuccess: { type: Function, default: () => { return true } },
+        params: { type: Object, default: () => {} }
+    },
+
+    data() {
+        return {
+            value: "",
+            defaultFileList: [],
+            showViewer: false
+        }
+    },
+
+    watch: {
+        modelValue(val) {
+            if (JSON.stringify(val) != JSON.stringify(this.formatArr(this.defaultFileList))) {
+                this.defaultFileList = val;
+                this.value = val;
+            }
+        },
+
+        defaultFileList: {
+            deep: true,
+            handler(val) {
+                this.$emit("update:modelValue", this.formatArr(val));
+                this.value = val.map(v => v.path).join();
+            }
+        }
+    },
+
+    mounted() {
+        this.defaultFileList = this.modelValue;
+        this.value = this.modelValue;
+    },
+
+    methods: {
+        // 格式化数组值
+        formatArr(arr) {
+            return arr.map(item => ({ id: item.id, name: item.name, contentType: item.contentType, path: item.path }));
+        },
+
+        before(file) {
+            const maxSize = file.size / 1024 / 1024 < this.maxSize;
+            if (!maxSize) {
+                this.$message.warning(`上传文件大小不能超过 ${this.maxSize}MB!`);
+                return false;
+            }
+        },
+
+        success(res, file) {
+            let os = this.onSuccess(res, file);
+            if (os != undefined && os == false) return false;
+            
+            file.path = res.path;
+            file.contentType = file.raw.type;
+        },
+
+        error(message) {
+            message && this.$notify.error({ title: "上传文件未成功", message });
+        },
+
+        beforeRemove(file) {
+            if (file.status == "success") {
+                return new Promise((resolve, reject) => {
+                    this.$confirm(`是否移除 ${file.name}? 此操作不可逆!`, "提示", {
+                        type: "warning",
+                        confirmButtonText: "移除"
+                    }).then(() => {
+                        this.$API.common.minio.rm(XEUtils.pick(file, "id", "path")).then(res => {
+                            this.$emit("removeSuccess");
+                            resolve();
+                        }).catch(() => reject());
+                    }).catch(() => reject());
+                });
+            }
+        },
+
+        handleExceed() {
+            this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`);
+        },
+
+        handlePreview(uploadFile) {
+            this.showViewer = true;
+            nextTick(() => this.$refs.fileViewer.init(uploadFile));
+        },
+
+        request(param) {
+            const data = new FormData();
+            data.append(param.filename, param.file);
+            XEUtils.objectEach(this.params, (value, key) => data.append(key, value));
+
+            this.$API.common.minio.up(data, {
+                onUploadProgress: e => {
+                    const percent = parseInt(((e.loaded / e.total) * 100) | 0, 10);
+                    param.onProgress({ percent });
+                },
+                transformRequest: [function (data, headers) {
+                    // 移除 Axios 可能自动设置的 Content-Type,让浏览器自动设置
+                    delete headers["Content-Type"];
+                    return data;
+                }]
+            }).then(res => {
+                if (res.code == 200) param.onSuccess({ path: res.expands.file });
+                else param.onError();
+            }).catch(() => param.onError());
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.sc-upload-file:deep(.el-upload-dragger) {border-radius: 0;}
+.el-form-item.is-error .sc-upload-file:deep(.el-upload-dragger) {border-color: var(--el-color-danger);border-radius: 0;}
+
+.sc-upload-file {
+    width: 100%;
+
+    :deep(.el-upload-list__item) {transition: none !important;}
+
+    .el-upload-hide-add {
+        :deep(.el-upload) {display: none;}
+
+        :deep(.el-upload-list) {
+            margin-top: 0;
+
+            .el-upload-list__item {
+                &:last-child {margin-bottom: 0;}
+                &:hover {background-color: transparent;}
+                .el-upload-list__item-info {width: 100%;}
+                .el-upload-list__item-status-label {display: none;}
+            }
+        }
+    }
+
+    .file-empty i {font-size: 28px;}
+    .file-empty h4 {
+        font-size: 13px;
+        font-weight: normal;
+        color: #8c939d;
+        
+        .el-text {font-size: inherit;}
+    }
+    
+    .el-upload__tip {color: #999;}
+    .el-upload__tip-required {
+        position: relative;
+        &::before {
+            content: "*";
+            margin-right: 4px;
+            color: var(--el-color-danger);
+        }
+    }
+}
+</style>

+ 146 - 0
src/components/scUpload/fileViewer.vue

@@ -0,0 +1,146 @@
+<template>
+	<div class="sc-file-viewer">
+        <el-dialog v-model="visible" fullscreen :show-close="false" @closed="$emit('closed')">
+            <div class="sc-file-viewer__header">
+                <div class="sc-file-viewer__title">
+                    {{ fileName }}
+                    <button class="el-button el-button--primary" aria-label="download" type="button" @click="downloadFile">
+                        <sc-iconify icon="ant-design:download-outlined" size="20"></sc-iconify>下载
+					</button>
+                </div>
+				
+                <button class="el-button is-link" aria-label="close" type="button" @click="visible = false">
+                    <sc-iconify icon="ant-design:close-outlined"></sc-iconify>
+                </button>
+            </div>
+            
+            <div v-loading="loading" class="sc-file-viewer__content">
+                <component :is="`vue_office_${fileTypes[fileType].split('.')[0]}`" :src="'/minio' + filePath" :options="options" @rendered="loading = false" @error="loading = false"></component>
+            </div>
+        </el-dialog>
+        
+	    <video-viewer v-if="showVideoViewer" :videoUrl="filePath" hideOnModal @close="showVideoViewer = false"></video-viewer>
+    </div>
+</template>
+
+<script>
+import { VxeUI } from "vxe-pc-ui";
+import { fileTypes, officeOptions } from "./main";
+
+import vue_office_docx from "@vue-office/docx";
+import vue_office_excel from "@vue-office/excel";
+import vue_office_pdf from "@vue-office/pdf";
+import vue_office_txt from "@/components/scUpload/txtViewer";
+import videoViewer from "@/components/scUpload/videoViewer";
+
+import "@vue-office/docx/lib/index.css";
+import "@vue-office/excel/lib/index.css";
+
+export default {
+    emits: ["closed"],
+    
+    components: {
+        vue_office_docx,
+        vue_office_excel,
+        vue_office_pdf,
+        vue_office_txt
+    },
+
+    data() {
+        return {
+            fileTypes,
+
+            visible: false,
+            loading: false,
+            fileName: null,
+            fileType: null,
+            filePath: null,
+            options: {},
+
+            showVideoViewer: false
+        }
+    },
+
+    methods: {
+        init(uploadFile) {
+            this.fileName = uploadFile.name;
+            this.filePath = uploadFile.path;
+            this.fileType = uploadFile.contentType;
+            if (!fileTypes[this.fileType]) {
+                ElMessage.warning("当前只支持预览 .png/.jpg/.map4/.avi/.txt/.docx/.pdf/.xlsx/.xls 格式文件, 文件已下载");
+                this.downloadFile();
+            } else {
+                if (fileTypes[this.fileType] == "image") {
+                    VxeUI.previewImage({
+                        showDownloadButton: true,
+                        urlList: ["/minio" + uploadFile.path],
+                        downloadMethod: () => this.downloadFile()
+                    })
+                } else if (fileTypes[this.fileType] == "video") this.showVideoViewer = true;
+                else {
+                    this.loading = true;
+                    this.visible = true;
+                    this.options = officeOptions[fileTypes[this.fileType].split(".")[0]] || {};
+                    if (fileTypes[this.fileType].includes(".")) this.options.xls = fileTypes[this.fileType].split(".")[1] == "xls";
+                }
+            }
+        },
+
+        downloadFile() {
+            this.$API.common.minio.download(this.filePath).then(res => {
+                const a = document.createElement("a");
+                const blob = new Blob([res], { type: this.fileType });
+                a.download = this.fileName;
+                a.href = URL.createObjectURL(blob);
+                a.click();
+            });
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.sc-file-viewer {
+    --el-overlay-color-lighter: gray;
+    :deep(.el-dialog) {
+        --el-dialog-bg-color: transparent;
+        padding: 0;
+
+        .el-dialog__header {padding: 0;}
+
+        .el-dialog__body {
+            height: 100%;
+            padding: 0;
+
+            .sc-file-viewer__header {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                padding: var(--el-dialog-padding-primary);
+                background: #fff;
+
+                .sc-file-viewer__title {
+                    line-height: var(--el-dialog-font-line-height);
+                    font-size: var(--el-dialog-title-font-size);
+                    color: var(--el-text-color-primary);
+
+                    button {
+                        margin-left: var(--el-dialog-padding-primary);
+
+                        svg {
+                        margin-right: 6px;
+                        }
+                    }
+                }
+
+                .el-button.is-link:hover {color: var(--el-color-primary);}
+            }
+
+            .sc-file-viewer__content {
+                height: calc(100% - 64px - 2 * var(--el-dialog-padding-primary));
+                padding: var(--el-dialog-padding-primary);
+            }
+        }
+    }
+}
+</style>

+ 279 - 0
src/components/scUpload/index.vue

@@ -0,0 +1,279 @@
+<template>
+	<div class="sc-upload" :class="{ 'sc-upload-round': round }" :style="style">
+		<div v-if="file && file.status != 'success'" class="sc-upload__uploading">
+			<div class="sc-upload__progress">
+				<el-progress :percentage="file.percentage" text-inside :stroke-width="16" />
+			</div>
+			<el-image class="image" :src="file.tempFile" fit="cover"></el-image>
+		</div>
+		<div v-if="file && file.status=='success'" class="sc-upload__img">
+            <sc-image v-if="isImage(file.contentType)" ref="imageRef" class="image" :file="file"></sc-image>
+			<sc-video v-if="isVideo(file.contentType)" :src="file.path" showMask @play="videoPlay"></sc-video>
+			
+			<div class="sc-upload__img-actions" v-if="!disabled">
+                <el-button type="primary" @click="handleDownload">
+                    <sc-iconify icon="ant-design:download-outlined"></sc-iconify>
+                </el-button>
+                <el-button type="danger" @click="handleRemove">
+                    <sc-iconify icon="ant-design:delete-outlined"></sc-iconify>
+                </el-button>
+			</div>
+		</div>
+		<el-upload v-if="!file" class="uploader" ref="uploader"
+			action=""
+			:auto-upload="cropper ? false : true"
+			:disabled="disabled"
+			:show-file-list="false"
+			:accept="accept"
+			:limit="1"
+			:drag="drag"
+			:http-request="request"
+			:on-change="change"
+			:before-upload="before"
+			:on-success="success"
+			:on-error="error"
+			:on-exceed="handleExceed">
+			<slot>
+				<div :class="['el-upload--picture-card', disabled && 'is-disabled', drag && 'is-drag']">
+					<div v-if="drag" class="file-empty">
+						<el-icon><el-icon-upload-filled /></el-icon>
+						<h4>将文件拖到此处,或<el-text type="primary">点击上传</el-text></h4>
+					</div>
+                    <div v-else class="file-empty">
+						<el-icon><component :is="icon" /></el-icon>
+						<h4 v-if="title">{{ title }}</h4>
+					</div>
+				</div>
+			</slot>
+		</el-upload>
+		<span style="display:none!important"><el-input v-model="value"></el-input></span>
+		<el-dialog v-model="cropperDialogVisible" title="剪裁" width="580" draggable destroy-on-close @closed="cropperClosed">
+			<sc-cropper ref="cropper" :src="cropperFile.tempCropperFile" :compress="compress" :aspectRatio="aspectRatio"></sc-cropper>
+			<template #footer>
+				<el-button auto-insert-space @click="cropperDialogVisible = false">取消</el-button>
+				<el-button type="primary" auto-insert-space @click="cropperSave">确定</el-button>
+			</template>
+		</el-dialog>
+	</div>
+    
+    <file-viewer v-if="showViewer" ref="fileViewer" @closed="showViewer = false"></file-viewer>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+import { VxeUI } from "vxe-pc-ui";
+import { fileTypes } from "@/components/scUpload/main";
+
+export default {
+    props: {
+        modelValue: { type: Object, default: () => {} },
+        width: { type: Number, default: 148 },
+        height: { type: Number, default: 148 },
+        title: { type: String, default: "" },
+        accept: { type: String, default: "image/gif, image/jpeg, image/png, video/mp4, video/avi" },
+        icon: { type: String, default: "el-icon-plus" },
+        maxSize: { type: Number, default: 50 },
+        drag: { type: Boolean, default: false },
+        disabled: { type: Boolean, default: false },
+        round: { type: Boolean, default: false },
+        onSuccess: { type: Function, default: () => { return true } },
+        cropper: { type: Boolean, default: false },
+        compress: { type: Number, default: 1 },
+        aspectRatio: { type: Number, default: NaN }
+    },
+
+    data() {
+        return {
+            value: "{}",
+            file: null,
+            style: {
+                width: this.width + "px",
+                height: this.height + "px"
+            },
+            cropperDialogVisible: false,
+            cropperFile: null,
+
+            showViewer: false
+        }
+    },
+
+    watch: {
+        modelValue(val) {
+            this.value = JSON.stringify(val);
+            this.newFile(val);
+        },
+
+        value(val) {
+            this.$emit("update:modelValue", JSON.parse(val));
+        }
+    },
+
+    mounted() {
+        if (this.modelValue) {
+            this.value = JSON.stringify(this.modelValue);
+            this.newFile(this.modelValue);
+        }
+    },
+    
+    methods: {
+        isImage(type) {
+            return fileTypes[type] == "image";
+        },
+
+        isVideo(type) {
+            return fileTypes[type] == "video";
+        },
+        
+        newFile(data) {
+            this.file = !XEUtils.isEmpty(data) ? { status: "success", ...data } : null;
+        },
+
+        cropperSave() {
+            this.$refs.cropper.getCropFile(file => {
+
+                file.uid = this.cropperFile.uid;
+                this.cropperFile.raw = file;
+
+                this.file = this.cropperFile;
+                this.file.tempFile = URL.createObjectURL(this.file.raw);
+                this.$refs.uploader.submit();
+
+            }, this.cropperFile.name, this.cropperFile.type);
+            this.cropperDialogVisible = false;
+        },
+
+        cropperClosed() {
+            URL.revokeObjectURL(this.cropperFile.tempCropperFile);
+            delete this.cropperFile.tempCropperFile;
+        },
+
+        handleRemove() {
+            const file = JSON.parse(this.value);
+            this.$confirm(`是否移除 ${file.name}? 此操作不可逆!`, "提示", {
+                type: "warning",
+                confirmButtonText: "移除"
+            }).then(() => {
+                this.$API.common.minio.rm(XEUtils.pick(file, "id", "path")).then(res => {
+                    this.clearFiles();
+                    this.$emit("removeSuccess");
+                }).catch(() => {});
+            }).catch(() => {});
+        },
+
+        clearFiles() {
+            URL.revokeObjectURL(this.file.tempFile);
+            this.value = "{}";
+            this.file = null;
+            this.$nextTick(() => this.$refs.uploader.clearFiles());
+        },
+
+        change(file, files) {
+            if (files.length > 1) files.splice(0, 1);
+
+            if (this.cropper && file.status == "ready") {
+                this.cropperFile = file;
+                this.cropperFile.tempCropperFile = URL.createObjectURL(file.raw);
+                this.cropperDialogVisible = true;
+                return false;
+            }
+
+            this.file = file;
+            if (file.status == "ready") file.tempFile = URL.createObjectURL(file.raw);
+        },
+
+        before(file) {
+            const maxSize = file.size / 1024 / 1024 < this.maxSize;
+            if (!maxSize) {
+                this.$message.warning(`上传文件大小不能超过 ${this.maxSize}MB!`);
+                this.clearFiles();
+                return false;
+            }
+        },
+
+        handleExceed(files) {
+            const file = files[0];
+            file.uid = Date.now();
+            this.$refs.uploader.handleStart(file);
+        },
+
+        success(res, file) {
+            // 释放内存删除blob
+            URL.revokeObjectURL(file.tempFile);
+            delete file.tempFile;
+            let os = this.onSuccess(res, file);
+            if (os != undefined && os == false) {
+                this.$nextTick(() => {
+                    this.file = null;
+                    this.value = "{}";
+                });
+                return false;
+            }
+
+            file.path = res.path;
+            file.contentType = file.raw.type;
+            this.value = JSON.stringify(XEUtils.pick(file, "name", "path", "contentType"));
+        },
+
+        error(message) {
+            this.$nextTick(() => this.clearFiles());
+            message && this.$notify.error({ title: "上传文件未成功", message });
+        },
+
+        request(param) {
+            const data = new FormData();
+            data.append(param.filename, param.file);
+            
+            this.$API.common.minio.up(data, {
+                onUploadProgress: e => {
+                    const percent = parseInt(((e.loaded / e.total) * 100) | 0, 10);
+                    param.onProgress({ percent });
+                }
+            }).then(res => {
+                if (res.code == 200) param.onSuccess({ path: res.expands.file });
+                else param.onError();
+            }).catch(() => param.onError());
+        },
+
+        videoPlay() {
+            this.showViewer = true;
+            nextTick(() => this.$refs.fileViewer.init(this.file));
+        },
+
+        handleDownload() {
+            this.$refs.imageRef.handleDownload();
+        }
+    }
+}
+</script>
+
+<style scoped>
+.el-form-item.is-error .sc-upload .el-upload--picture-card {border-color: var(--el-color-danger);}
+.sc-upload .el-upload--picture-card {width: 100%;height: 100%;border-radius: 0;}
+.sc-upload .el-upload--picture-card.is-disabled {border-color: var(--el-border-color-darker);cursor: not-allowed;}
+
+.sc-upload .uploader, .sc-upload:deep(.el-upload) {justify-content: unset;width: 100%;height: 100%;}
+.sc-upload__img {position: relative;width: 100%;height: 100%;}
+.sc-upload__img .image {width: 100%;height: 100%;border: 1px solid var(--el-border-color);cursor: pointer;}
+.sc-upload__img-actions {z-index: 120;position: absolute;top: 0;right: 0;display: none;}
+.sc-upload__img-actions .el-button {width: 25px;height: 25px;padding: 0;border-radius: 0;}
+.sc-upload__img-actions .el-button + .el-button {margin-left: 0;}
+.sc-upload__img:hover .sc-upload__img-actions {display: flex;}
+.sc-upload__uploading {position: relative;width: 100%;height: 100%;}
+.sc-upload__progress {z-index: 1;position: absolute;display: flex;justify-content: center;align-items: center;width: 100%;height: 100%;padding: 10px;background-color: var(--el-overlay-color-lighter);}
+.sc-upload__progress .el-progress {width: 100%;}
+.sc-upload__uploading .image {width: 100%;height: 100%;}
+
+.sc-upload .file-empty {display: flex;flex-direction: column;justify-content: center;align-items: center;width: 100%;height: 100%;}
+.sc-upload .file-empty i {font-size: 28px;}
+.sc-upload .file-empty h4 {margin-top: 8px;font-size: 12px;font-weight: normal;color: #8c939d;}
+.sc-upload .file-empty h4 .el-text {font-size: inherit;}
+
+.sc-upload.sc-upload-round {border-radius: 50%;overflow: hidden;}
+.sc-upload.sc-upload-round .el-upload--picture-card {border-radius: 50%;}
+.sc-upload.sc-upload-round .sc-upload__img-actions {top: auto;left: 0;right: 0;bottom: 0;}
+.sc-upload.sc-upload-round .sc-upload__img-actions span {width: 100%;}
+
+.sc-upload :deep(.el-upload-dragger) {width: 100%;height: 100%;padding: 0;border-radius: 0;}
+.sc-upload :deep(.el-upload-dragger.is-dragover) {border-width: 1px;}
+.sc-upload .el-upload--picture-card.is-drag {border: none;}
+</style>

+ 45 - 0
src/components/scUpload/main.js

@@ -0,0 +1,45 @@
+export const fileTypes = {
+    "image/gif": "image",
+    "image/jpeg": "image",
+    "image/jpg": "image",
+    "image/png": "image",
+    "video/mp4": "video",
+    "video/avi": "video",
+    "application/vnd.ms-excel": "excel.xls",
+    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "excel.xlsx",
+    "application/pdf": "pdf",
+    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
+    "text/plain": "txt"
+}
+
+export const officeOptions = {
+    excel: {
+        xls: false,       // 预览xlsx文件设为false;预览xls文件设为true
+        minColLength: 0,  // excel最少渲染多少列,如果想实现xlsx文件内容有几列,就渲染几列,可以将此值设置为0.
+        minRowLength: 0,  // excel最少渲染多少行,如果想实现根据xlsx实际函数渲染,可以将此值设置为0.
+        widthOffset: 10,  // 如果渲染出来的结果感觉单元格宽度不够,可以在默认渲染的列表宽度上再加 Npx宽
+        heightOffset: 10, // 在默认渲染的列表高度上再加 Npx高
+        beforeTransformData: workbookData => workbookData, // 底层通过exceljs获取excel文件内容,通过该钩子函数,可以对获取的excel文件内容进行修改,比如某个单元格的数据显示不正确,可以在此自行修改每个单元格的value值。
+        transformData: workbookData => workbookData // 将获取到的excel数据进行处理之后且渲染到页面之前,可通过transformData对即将渲染的数据及样式进行修改,此时每个单元格的text值就是即将渲染到页面上的内容
+    },
+    pdf: {
+        // width: 500, //number,可不传,用来控制pdf预览的宽度,默认根据文档实际宽度计算
+        httpHeaders: {}, //object, Basic authentication headers
+        password: "" //string, 加密pdf的密码
+    },
+    docx: {
+        className: "docx", //class name/prefix for default and document style classes
+        inWrapper: true, //enables rendering of wrapper around document content
+        ignoreWidth: false, //disables rendering width of page
+        ignoreHeight: false, //disables rendering height of page
+        ignoreFonts: false, //disables fonts rendering
+        breakPages: true, //enables page breaking on page breaks
+        ignoreLastRenderedPageBreak: false, //disables page breaking on lastRenderedPageBreak elements
+        experimental: false, //enables experimental features (tab stops calculation)
+        trimXmlDeclaration: true, //if true, xml declaration will be removed from xml documents before parsing
+        useBase64URL: false, //if true, images, fonts, etc. will be converted to base 64 URL, otherwise URL.createObjectURL is used
+        useMathMLPolyfill: false, //includes MathML polyfills for chrome, edge, etc.
+        showChanges: false, //enables experimental rendering of document changes (inserions/deletions)
+        debug: false //enables additional logging
+    }
+}

+ 267 - 0
src/components/scUpload/multiple.vue

@@ -0,0 +1,267 @@
+<template>
+	<div class="sc-upload-multiple">
+		<el-upload ref="uploader" list-type="picture-card"
+			v-model:file-list="defaultFileList"
+			action=""
+			:accept="accept"
+			:limit="limit"
+			:multiple="multiple"
+			:disabled="disabled"
+			show-file-list
+			:http-request="request"
+			:before-upload="before"
+			:on-success="success"
+			:on-error="error"
+			:on-exceed="handleExceed">
+			<slot>
+				<el-icon><el-icon-plus /></el-icon>
+			</slot>
+			<template #tip>
+				<div v-if="tip" class="el-upload__tip">{{ tip }}</div>
+			</template>
+			<template #file="{ file }">
+				<div class="sc-upload-list-item">
+					<el-image v-if="isImage(file.mineType)" class="el-upload-list__item-thumbnail" :src="'/api/folder/' + file.path" :preview-src-list="preview" fit="cover" preview-teleported :z-index="9999">
+						<template #placeholder>
+							<div class="sc-upload-multiple-image-slot">Loading...</div>
+						</template>
+					</el-image>
+					<sc-video v-if="isVideo(file.mineType)" :src="'/api/folder/' + file.path" showMask @play="videoPlay(file)"></sc-video>
+
+					<div v-if="!disabled && file.status == 'success'" class="sc-upload__item-actions">
+						<el-button class="download" :loading="loading" type="primary" @click="handleDownload(file)">
+                            <sc-iconify icon="ant-design:download-outlined"></sc-iconify>
+                        </el-button>
+                        <el-button type="danger" @click="handleRemove(file)">
+                            <sc-iconify icon="ant-design:delete-outlined"></sc-iconify>
+                        </el-button>
+					</div>
+					<div v-if="file.status == 'ready' || file.status == 'uploading'" class="sc-upload__item-progress">
+						<el-progress :percentage="file.percentage" text-inside :stroke-width="16" />
+					</div>
+				</div>
+			</template>
+		</el-upload>
+		<span style="display:none!important"><el-input v-model="value"></el-input></span>
+	</div>
+
+    <file-viewer v-if="showViewer" ref="fileViewer" @closed="showViewer = false"></file-viewer>
+</template>
+
+<script>
+import { fileTypes } from "./main";
+
+export default {
+    props: {
+        modelValue: { type: Array, default: () => [] },
+        accept: { type: String, default: "image/gif, image/jpeg, image/png, video/mp4, video/avi" },
+        tip: { type: String, default: "" },
+        maxSize: { type: Number, default: 50 },
+        limit: { type: Number, default: 0 },
+        multiple: { type: Boolean, default: true },
+        disabled: { type: Boolean, default: false },
+        onSuccess: { type: Function, default: () => { return true } }
+    },
+
+    data() {
+        return {
+            value: "",
+            defaultFileList: [],
+
+            loading: false,
+            showViewer: false
+        }
+    },
+
+    watch: {
+        modelValue(val) {
+            if (JSON.stringify(val) != JSON.stringify(this.formatArr(this.defaultFileList))) {
+                this.defaultFileList = val;
+                this.value = val;
+            }
+        },
+
+        defaultFileList: {
+            deep: true,
+            handler(val) {
+                this.$emit("update:modelValue", this.formatArr(val));
+                this.value = val.map(v => v.path).join(",");
+            }
+        }
+    },
+
+    computed: {
+        preview() {
+            return this.defaultFileList.map(v => "/api/folder/" + v.path);
+        }
+    },
+
+    mounted() {
+        this.defaultFileList = this.modelValue;
+        this.value = this.modelValue;
+    },
+
+    methods: {
+        isImage(type) {
+            return fileTypes[type] == "image"
+        },
+
+        isVideo(type) {
+            return fileTypes[type] == "video"
+        },
+
+        // 格式化数组值
+        formatArr(arr) {
+            return arr.map(item => ({ id: item.id, name: item.name, mineType: item.mineType, path: item.path }));
+        },
+
+        before(file) {
+            if (!this.isImage(file.type) && !this.isVideo(file.type)) {
+                this.$message.warning({ title: "上传文件警告", message: "选择的文件非图像类/视频类文件" });
+                return false;
+            }
+
+            const maxSize = file.size / 1024 / 1024 < this.maxSize;
+            if (!maxSize) {
+                this.$message.warning(`上传文件大小不能超过 ${this.maxSize}MB!`);
+                return false;
+            }
+        },
+
+        success(res, file) {
+            let os = this.onSuccess(res, file);
+            if (os != undefined && os == false) return false;
+            
+            file.name = res.fileName;
+            file.path = res.path;
+            file.mineType = res.mineType;
+        },
+
+        error(message) {
+            message && this.$notify.error({ title: "上传文件未成功", message });
+        },
+
+        beforeRemove({ id, name }) {
+            return this.$confirm(`是否移除 ${name}? 此操作不可逆!`, "提示", {
+                type: "warning",
+                confirmButtonText: "移除"
+            }).then(() => {
+                if (id) {
+                    this.$API.common.folder.rm(id).then(res => {
+                        if (res.code == 200) return true;
+                        else return false;
+                    }).catch(() => {
+                        return false;
+                    });
+                } return true;
+            }).catch(() => {
+                return false;
+            });
+        },
+        
+        handleRemove(file) {
+            this.$refs.uploader.handleRemove(file);
+        },
+
+        handleExceed() {
+            this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`);
+        },
+
+        request(param) {
+            const data = new FormData();
+            data.append(param.filename, param.file);
+            
+            this.$API.common.folder.up(data, {
+                onUploadProgress: e => {
+                    const percent = parseInt(((e.loaded / e.total) * 100) | 0, 10)
+                    param.onProgress({ percent });
+                }
+            }).then(res => {
+                if (res.code == 200) param.onSuccess({ path: res.expands.file, fileName: param.file.name, mineType: param.file.type })
+                else param.onError();
+            }).catch(() => param.onError());
+        },
+
+        videoPlay(file) {
+            this.showViewer = true;
+            nextTick(() => this.$refs.fileViewer.init(file));
+        },
+
+        handleDownload(file) {
+            this.loading = true;
+            this.$API.common.folder.download(file.path).then(res => {
+                this.loading = false;
+                const a = document.createElement("a");
+                const blob = new Blob([res], { type: file.mineType });
+                a.download = this.file.name;
+                a.href = URL.createObjectURL(blob);
+                a.click();
+            }).catch(() => this.loading = false);
+        }
+    }
+}
+</script>
+
+<style scoped>
+.el-form-item.is-error .sc-upload-multiple:deep(.el-upload--picture-card) {
+  border-color: var(--el-color-danger);
+}
+:deep(.el-upload-list__item) {
+  transition: none;
+  border-radius: 0;
+}
+.sc-upload-multiple:deep(.el-upload-list__item.el-list-leave-active) {
+  position: static !important;
+}
+.sc-upload-multiple:deep(.el-upload--picture-card) {
+  border-radius: 0;
+}
+.sc-upload-list-item {
+  width: 100%;
+  height: 100%;
+  position: relative;
+}
+.sc-upload-multiple .el-image {
+  display: block;
+}
+.sc-upload-multiple .el-image:deep(img) {
+  -webkit-user-drag: none;
+}
+.sc-upload-multiple-image-slot {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  font-size: 12px;
+}
+.sc-upload-multiple .el-upload-list__item:hover .sc-upload__item-actions {
+  display: block;
+}
+.sc-upload__item-actions {
+  position: absolute;
+  top: 0;
+  right: 0;
+  display: none;
+}
+.sc-upload__item-actions span {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 25px;
+  height: 25px;
+  cursor: pointer;
+  color: #fff;
+}
+.sc-upload__item-actions span i {
+  font-size: 12px;
+}
+.sc-upload__item-progress {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  background-color: var(--el-overlay-color-lighter);
+}
+</style>

+ 37 - 0
src/components/scUpload/txtViewer.vue

@@ -0,0 +1,37 @@
+<template>
+    <div class="vue-office-txt">
+        <div class="txt-wrapper">{{ textValue }}</div>
+    </div>
+</template>
+
+<script>
+export default {
+    props: {
+        src: { type: String, default: "" }
+    },
+
+    data() {
+        return {
+            textValue: null
+        }
+    },
+
+    mounted() {
+        this.txtDecode();
+    },
+
+    methods: {
+        txtDecode() {
+            this.$API.common.minio.download(this.src, true).then(res => {
+                this.textValue = res;
+                this.$emit("rendered");
+            }).catch(() => this.$emit("error"));
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.vue-office-txt {background: gray;padding: 0 20px 16px;}
+.vue-office-txt .txt-wrapper {padding: 30px;background: #fff;white-space: pre-wrap;}
+</style>

+ 53 - 0
src/components/scUpload/videoViewer.vue

@@ -0,0 +1,53 @@
+<template>
+	<teleport to="body">
+		<div class="el-image-viewer__wrapper" :tabindex="-1" ref="imageViewer" style="zIndex: 9999;">
+			<div class="el-image-viewer__mask" @click.self="hideOnModal && $emit('close')"></div>
+			<span class="el-image-viewer__btn el-image-viewer__close" @click="$emit('close')">
+				<el-icon><el-icon-close /></el-icon>
+			</span>
+			
+			<div class="el-image-viewer__canvas">
+				<sc-video class="el-image-viewer__img" :src="'/minio' + videoUrl" autoplay></sc-video>
+			</div>
+		</div>
+	</teleport>
+</template>
+
+<script>
+import "element-plus/es/components/image-viewer/style/css"
+
+export default {
+    emits: ["close"],
+
+    props: {
+        hideOnModal: { type: Boolean, default: false }, // 是否支持通过点击遮罩层关闭
+        hideOnEscape: { type: Boolean, default: false }, // 是否支持通过按下ESC关闭
+        videoUrl: { type: String, default: "" }
+    },
+
+    data() {
+        return {}
+    },
+
+    created() {
+        document.addEventListener("keydown", this.keydownHandler);
+    },
+
+    beforeUnmount() {
+        document.removeEventListener("keydown", this.keydownHandler);
+    },
+
+    methods: {
+        keydownHandler(e) {
+            this.hideOnEscape && e.code == "Escape" && this.$emit("close");
+            e.preventDefault();
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-image-viewer__canvas {
+    padding: 12%;
+}
+</style>

+ 85 - 0
src/components/scVideo/index.vue

@@ -0,0 +1,85 @@
+<!--
+ * @Descripttion: xgplayer二次封装
+ * @version: 1.1
+ * @Date: 2021年11月29日12:10:06
+ * @LastEditTime: 2023年12月22日12:02:50
+-->
+
+<template>
+	<div class="sc-video" ref="scVideo">
+		<div v-if="showMask" class="sc-video__start-mock" @click="$emit('play')"></div>
+	</div>
+</template>
+
+<script>
+	import Player from 'xgplayer';
+	import HlsPlayer from 'xgplayer-hls';
+
+	export default {
+		emits: ["play"],
+		props: {
+			showMask: { type: Boolean, default: false },
+			src: { type: String, required: true, default: "" },
+			autoplay: { type: Boolean, default: false },
+			controls: { type: Boolean, default: true },
+			loop: { type: Boolean, default: false },
+			isLive: { type: Boolean, default: false },
+			options: { type: Object, default: () => {} }
+		},
+
+		data() {
+			return {
+				player: null
+			}
+		},
+		watch: {
+			src(val) {
+				if (this.player.hasStart) this.player.src = val;
+				else this.player.start(val);
+			}
+		},
+		mounted() {
+			if (this.isLive) this.initHls();
+			else this.init();
+		},
+		methods: {
+			init() {
+				this.player = new Player({
+					el: this.$refs.scVideo,
+					url: this.src,
+					autoplay: this.autoplay,
+					loop: this.loop, // 循环播放
+					controls: this.controls,
+					fluid: true, // 播放器宽度跟随父元素的宽度大小变化
+					videoInit: true, // 初始化显示视频首帧
+					lang: 'zh-cn',
+                    download: true, //显示下载按钮
+					...this.options
+				});
+			},
+			initHls() {
+				this.player = new HlsPlayer({
+					el: this.$refs.scVideo,
+					url: this.src,
+					autoplay: this.autoplay,
+					loop: this.loop,
+					controls: this.controls,
+					fluid: true,
+					videoInit: true, // 初始化显示视频首帧
+					isLive: true,
+					ignores: ['time','progress'],
+					lang: 'zh-cn',
+					...this.options
+				});
+			}
+		}
+	}
+</script>
+
+<style scoped>
+.sc-video:deep(.danmu) > * {font-size: 20px;font-weight: bold;color: #fff;text-shadow: 1px 1px 0 #000, -1px -1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000;}
+.sc-video__start-mock {cursor: pointer;position: absolute;left: calc(50% - 35px);top: calc(50% - 35px);z-index: 120;width: 70px;height: 70px;}
+.sc-video:deep(.xgplayer-controls) {background-image: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.3));}
+.sc-video:deep(.xgplayer-progress-tip) {padding: 0 10px;background: rgba(0, 0, 0, 0.5);border: 0;border-radius: 25px;line-height: 25px;color: #fff;}
+.sc-video:deep(.xgplayer-enter-spinner) {width: 50px;height: 50px;}
+</style>

+ 17 - 0
src/config/iconSelect.js

@@ -0,0 +1,17 @@
+import XEUtils from "xe-utils"
+import * as elIcons from "@element-plus/icons-vue"
+import * as scIcons from "@/assets/icons"
+
+const icons = [
+    { name: "默认", prefix: "el-icon-", icons: Object.keys(elIcons) },
+    { name: "扩展", prefix: "sc-icon-", icons: Object.keys(scIcons.default) },
+    { name: "Iconify", icons: [] }
+]
+
+function selectIcon(menuIcon) {
+    const iconKey = icons[XEUtils.findKey(icons, item => XEUtils.includes(item.icons, menuIcon))]
+    return iconKey || menuIcon
+}
+
+// 图标选择器配置
+export default { icons, selectIcon }

+ 57 - 0
src/config/index.js

@@ -0,0 +1,57 @@
+const DEFAULT_CONFIG = {
+	//标题
+	APP_NAME: process.env.VUE_APP_TITLE,
+    
+	//首页地址
+	DASHBOARD_URL: "/home",
+
+	//接口地址
+	API_URL: process.env.NODE_ENV === "development" && process.env.VUE_APP_PROXY === "true" ? "" : process.env.VUE_APP_MES_BASEURL,
+
+	//请求超时
+	TIMEOUT: 30000,
+
+	//TokenName
+	TOKEN_NAME: "Authorization",
+
+	//Token前缀,注意最后有个空格,如不需要需设置空字符串
+	TOKEN_PREFIX: "",
+
+	//追加其他头
+	HEADERS: {},
+
+	//请求是否开启缓存
+	REQUEST_CACHE: false,
+
+    //布局 mingcute:layout-11-line:default | icon-park-outline:grid-three:header | mingcute:layout-line:menu
+	LAYOUT: "header",
+
+	//菜单是否折叠
+	MENU_IS_COLLAPSE: false,
+
+	//菜单是否启用手风琴效果
+	MENU_UNIQUE_OPENED: false,
+
+	//是否开启多标签
+	LAYOUT_TAGS: true,
+
+	//语言
+	LANG: "zh-cn",
+
+	//主题颜色
+	COLOR: "#1890ff",
+
+	//是否加密localStorage, 为空不加密,可填写AES(模式ECB,移位Pkcs7)加密
+	LS_ENCRYPTION: "",
+
+	//localStorageAES加密秘钥,位数建议填写8的倍数
+	LS_ENCRYPTION_key: "2XNN4K8LC0ELVWN4"
+}
+
+// 如果生产模式,就合并动态的APP_CONFIG
+// public/config.js
+if (process.env.NODE_ENV === "production") {
+	Object.assign(DEFAULT_CONFIG, APP_CONFIG)
+}
+
+export default DEFAULT_CONFIG

+ 22 - 0
src/config/route.js

@@ -0,0 +1,22 @@
+// 静态路由配置
+// 书写格式与动态路由格式一致,全部经由框架统一转换
+// 比较动态路由在meta中多加入了role角色权限,为数组类型。一个菜单是否有权限显示,取决于它以及后代菜单是否有权限。
+// routes 显示在左侧菜单中的路由(显示顺序在动态路由之前)
+// 示例如下
+
+const routes = [
+    {
+        name: "home",
+        path: "/home",
+        meta: { title: "首页", affix: true },
+        component: "home"
+    },
+    {
+        name: "userCenter",
+        path: "/usercenter",
+        meta: { title: "个人信息", hidden: true },
+        component: "userCenter"
+    }
+]
+
+export default routes;

+ 21 - 0
src/config/select.js

@@ -0,0 +1,21 @@
+// 选择器配置
+import API from "@/api";
+import config from "@/config/table";
+import XEUtils from "xe-utils";
+
+export default {
+    queryData: function ({ key, query }) {
+        if (XEUtils.isEmpty(XEUtils.get(API, key))) return [];
+        return new Promise(resolve => {
+            XEUtils.get(API, key).get(XEUtils.omit(query, val => XEUtils.isEmpty(val) && !XEUtils.isNumber(val))).then(res => {
+                const response = config.parseData(res)
+                resolve(response.data)
+            })
+        })
+    },
+    
+	props: {
+		label: "label",					// 映射label显示字段
+		value: "value"					// 映射value值字段
+	}
+}

+ 39 - 0
src/config/table.js

@@ -0,0 +1,39 @@
+// 数据表格配置
+const XEUtils = require("xe-utils");
+
+export default {
+	pageSize: 20,													                                            // 表格每一页条数
+	pageSizes: [5, 10, 15, 20, 50, 100, 200, 500, 1000],                                                        // 表格可设置的一页条数
+	layouts: ["PrevJump", "PrevPage", "Jump", "PageCount", "NextPage", "NextJump", "Sizes", "Total"],	        // 表格分页布局
+    exportExcludeFields: ["checkbox", "radio"],
+    
+    queryData: function ({ formConfig: { data }, pagerConfig: { currentPage, pageSize } }, paramsColumns) {
+        const query = { current: currentPage, size: pageSize }
+        XEUtils.arrayEach(paramsColumns, item => {
+            if (item.defaultValue) XEUtils.set(query, item.column, item.defaultValue)
+            if (!valueIsNull(data, item.field || item.column)) XEUtils.set(query, item.column, XEUtils.get(data, item.field || item.column))
+        })
+
+        return XEUtils.omit(query, val => XEUtils.isEmpty(val) && !XEUtils.isNumber(val) && !XEUtils.isBoolean(val))
+    },
+    queryExport: function ({ formConfig: { data }, pagerConfig: { total } }, paramsColumns) {
+        // 判断total
+        const query = { current: 1, size: total }
+        XEUtils.arrayEach(paramsColumns, item => {
+            if (item.defaultValue) XEUtils.set(query, item.column, item.defaultValue)
+            else if (!valueIsNull(data, item.field || item.column)) XEUtils.set(query, item.column, XEUtils.get(data, item.field || item.column))
+        })
+    
+        return XEUtils.omit(query, val => XEUtils.isEmpty(val) && !XEUtils.isNumber(val) && !XEUtils.isBoolean(val))
+    },
+    parseData: function (res) {
+        return {
+            data: res.records || res,			    // 分析数据字段结构
+            total: res.total	                    // 分析总数字段结构
+        }
+    }
+}
+
+function valueIsNull(obj, key) {
+    return XEUtils.isEmpty(XEUtils.get(obj, key)) && !XEUtils.isNumber(XEUtils.get(obj, key)) && !XEUtils.isBoolean(XEUtils.get(obj, key))
+}

+ 23 - 0
src/config/tableSelect.js

@@ -0,0 +1,23 @@
+//表格选择器配置
+
+export default {
+	pageSize: 20,						//表格每一页条数
+	parseData: function (res) {
+		return {
+			data: res.data,
+			rows: res.data.rows,		//分析行数据字段结构
+			total: res.data.total,		//分析总数字段结构
+			msg: res.message,			//分析描述字段结构
+			code: res.code				//分析状态字段结构
+		}
+	},
+	request: {
+		page: 'page',					//规定当前分页字段
+		pageSize: 'pageSize',			//规定一页条数字段
+		keyword: 'keyword'				//规定搜索字段
+	},
+	props: {
+		label: 'label',					//映射label显示字段
+		value: 'value',					//映射value值字段
+	}
+}

+ 18 - 0
src/directives/auth.js

@@ -0,0 +1,18 @@
+import { permissionAll } from '@/utils/permission'
+import tool from '@/utils/tool';
+
+/**
+ * 用户权限指令
+ * @directive 单个权限验证(v-auth="'xxx'")
+ * @directive 多个权限验证,满足一个则显示(v-auths="['xxx','xxx']")
+ * @directive 多个权限验证,全部满足则显示(v-auths-all="['xxx','xxx']")
+ */
+export default {
+	mounted (el, binding) {
+		if(permissionAll()){
+			return
+		}
+		let permissions = tool.data.get("PERMISSIONS");
+		if (!permissions.some((v) => v === binding.value)) el.parentNode.removeChild(el);
+	}
+}

+ 24 - 0
src/directives/auths.js

@@ -0,0 +1,24 @@
+import { permissionAll } from '@/utils/permission'
+import tool from '@/utils/tool';
+
+/**
+ * 用户权限指令
+ * @directive 单个权限验证(v-auth="'xxx'")
+ * @directive 多个权限验证,满足一个则显示(v-auths="['xxx','xxx']")
+ * @directive 多个权限验证,全部满足则显示(v-auths-all="['xxx','xxx']")
+ */
+export default {
+	mounted (el, binding) {
+		if(permissionAll()){
+			return
+		}
+		let permissions = tool.data.get("PERMISSIONS");
+		let flag = false;
+		permissions.map((val) => {
+			binding.value.map((v) => {
+				if (val === v) flag = true;
+			});
+		});
+		if (!flag) el.parentNode.removeChild(el);
+	}
+}

+ 19 - 0
src/directives/authsAll.js

@@ -0,0 +1,19 @@
+import { permissionAll, judementSameArr } from '@/utils/permission'
+import tool from '@/utils/tool';
+
+/**
+ * 用户权限指令
+ * @directive 单个权限验证(v-auth="'xxx'")
+ * @directive 多个权限验证,满足一个则显示(v-auths="['xxx','xxx']")
+ * @directive 多个权限验证,全部满足则显示(v-auths-all="['xxx','xxx']")
+ */
+export default {
+	mounted (el, binding) {
+		if(permissionAll()){
+			return
+		}
+		let permissions = tool.data.get("PERMISSIONS");
+		const flag = judementSameArr(binding.value, permissions);
+		if (!flag) el.parentNode.removeChild(el);
+	}
+}

+ 27 - 0
src/directives/copy.js

@@ -0,0 +1,27 @@
+export default {
+	mounted(el, binding) {
+		el.$value = binding.value
+		el.handler = () => {
+			const textarea = document.createElement('textarea')
+			textarea.readOnly = 'readonly'
+			textarea.style.position = 'absolute'
+			textarea.style.left = '-9999px'
+			textarea.value = el.$value
+			document.body.appendChild(textarea)
+			textarea.select()
+			textarea.setSelectionRange(0, textarea.value.length)
+			const result = document.execCommand('Copy')
+			if (result) {
+				ElMessage.success("复制成功")
+			}
+			document.body.removeChild(textarea)
+		}
+		el.addEventListener('click', el.handler)
+	},
+	updated(el, binding){
+		el.$value = binding.value
+	},
+	unmounted(el){
+		el.removeEventListener('click', el.handler)
+	}
+}

+ 22 - 0
src/directives/role.js

@@ -0,0 +1,22 @@
+import { rolePermission } from '@/utils/permission'
+
+export default {
+	mounted(el, binding) {
+		const { value } = binding
+		if(Array.isArray(value)){
+			let ishas = false;
+			value.forEach(item => {
+				if(rolePermission(item)){
+					ishas = true;
+				}
+			})
+			if (!ishas){
+				el.parentNode.removeChild(el)
+			}
+		}else{
+			if(!rolePermission(value)){
+				el.parentNode.removeChild(el);
+			}
+		}
+	}
+};

+ 45 - 0
src/directives/time.js

@@ -0,0 +1,45 @@
+import moment from "moment";
+import tool from "@/utils/tool";
+
+let Time = {
+	getFormateTime: function (date) {
+		let timestamp = date.includes("T") && date.includes("Z") && moment(date, "YYYY-MM-DDTHH:mm:ss[Z]").valueOf() || moment(date).valueOf();
+		let now = moment().valueOf();
+		let today = moment().startOf("day").valueOf();
+
+		let timer = (now - timestamp) / 1000;
+		let tip = "";
+
+		if (timer <= 0) {
+			tip = "刚刚";
+		} else if (Math.floor(timer / 60) <= 0) {
+			tip = "刚刚";
+		} else if (timer < 3600) {
+			tip = Math.floor(timer / 60) + "分钟前";
+		} else if (timer >= 3600 && (timestamp - today >= 0)) {
+			tip = Math.floor(timer / 3600) + "小时前";
+		} else if (timer / 86400 <= 31) {
+			tip = Math.ceil(timer / 86400) + "天前";
+		} else {
+			tip = tool.dateFormat(date, "YYYY-MM-DD");
+		}
+		return tip;
+	}
+}
+
+export default (el, binding) => {
+	let { value, modifiers } = binding
+	if (!value) {
+		return false
+	}
+
+	if (modifiers.tip) {
+		el.innerHTML = Time.getFormateTime(value)
+		el.__timeout__ = setInterval(() => {
+			el.innerHTML = Time.getFormateTime(value)
+		}, 60000)
+	} else {
+		const format = el.getAttribute("format") || undefined
+		el.innerHTML = tool.dateFormat(value, format)
+	}
+}

+ 50 - 0
src/layout/components/NavMenu.vue

@@ -0,0 +1,50 @@
+<template>
+	<template v-for="navMenu in navMenus" v-bind:key="navMenu">
+		<el-menu-item v-if="!menuChildren(navMenu).length" :index="navMenu.path">
+            <sc-iconify :icon="navMenu.meta.icon || undefined"></sc-iconify>
+			<template #title>
+                <vxe-text-ellipsis :title="navMenu.meta.title" :content="navMenu.meta.title"></vxe-text-ellipsis>
+			</template>
+		</el-menu-item>
+		<el-sub-menu v-else :index="navMenu.path">
+			<template #title>
+                <sc-iconify :icon="navMenu.meta.icon || undefined"></sc-iconify>
+                <vxe-text-ellipsis :title="navMenu.meta.title" :content="navMenu.meta.title"></vxe-text-ellipsis>
+			</template>
+			<NavMenu :navMenus="menuChildren(navMenu)"></NavMenu>
+		</el-sub-menu>
+	</template>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+
+export default {
+    name: "NavMenu",
+    props: ["navMenus"],
+    data() {
+        return {}
+    },
+
+    computed: {
+        menuIsCollapse() {
+            return this.$store.state.global.menuIsCollapse;
+        }
+    },
+
+    methods: {
+        menuChildren(item) {
+            return XEUtils.isArray(item.children) && item.children.filter(item => !item.meta.hidden) || []
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.menu-collapse-popper {
+    display: flex;
+    align-items: center;
+    margin: 1px 0;
+    line-height: 22px;
+}
+</style>

+ 14 - 0
src/layout/components/pageLoading.vue

@@ -0,0 +1,14 @@
+<template>
+    <el-main>
+        <el-card shadow="never">
+            <el-skeleton :rows="1"></el-skeleton>
+        </el-card>
+        <el-card shadow="never">
+            <el-skeleton></el-skeleton>
+        </el-card>
+    </el-main>
+</template>
+
+<style scoped>
+.el-card + .el-card {margin-top: 15px;}
+</style>

+ 83 - 0
src/layout/components/password.vue

@@ -0,0 +1,83 @@
+<template>
+    <el-dialog v-model="visible" title="修改密码" width="480" :close-on-click-modal="false" @closed="$emit('closed')">
+        <el-form ref="formRef" :model="form" :rules="rules" label-width="120">
+            <el-form-item label="当前密码" prop="userPassword">
+                <el-input v-model="form.userPassword" type="password" show-password placeholder="请输入当前密码"></el-input>
+				<div class="el-form-item-msg">必须提供当前登录用户密码才能进行更改</div>
+            </el-form-item>
+            <el-form-item label="新密码" prop="newPassword">
+                <el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码"></el-input>
+				<sc-password-strength v-model="form.newPassword"></sc-password-strength>
+            </el-form-item>
+            <el-form-item label="确认新密码" prop="confirmNewPassword">
+				<el-input v-model="form.confirmNewPassword" type="password" show-password placeholder="请再次输入新密码"></el-input>
+			</el-form-item>
+        </el-form>
+
+        <template #footer>
+            <el-button auto-insert-space @click="cancel">取消</el-button>
+            <el-button :loading="isSaving" type="primary" auto-insert-space @click="submit">确定</el-button>
+        </template>
+    </el-dialog>
+</template>
+
+<script setup>
+import API from "@/api";
+import TOOL from "@/utils/tool";
+
+const router = useRouter();
+
+const visible = ref(false);
+const isSaving = ref(false);
+const form = ref({
+    userPassword: "",
+    newPassword: "",
+    confirmNewPassword: ""
+});
+const rules = reactive({
+    userPassword: [{ required: true, message: "请输入当前密码"}],
+    newPassword: [{ required: true, message: "请输入新密码"}],
+    confirmNewPassword: [
+        { required: true, message: "请再次输入新密码" },
+        { validator: (rule, value, callback) => {
+            if (value !== form.value.newPassword) return callback(new Error("两次输入密码不一致"));
+            callback();
+        }}
+    ]
+});
+
+const open = () => visible.value = true;
+
+const formRef = ref();
+const submit = () => {
+    formRef.value.validate(valid => {
+        if (valid) {
+            isSaving.value = true;
+            API.auth.user.updatePass(form.value).then(() => {
+                ElNotification.success({
+                    title: "提示",
+                    message: "密码修改成功,请重新登录",
+                    duration: 1500
+                });
+                
+                setTimeout(() => {
+                    isSaving.value = false;
+                    TOOL.cookie.remove("MES_TOKEN");
+                    TOOL.data.remove("USER_INFO");
+                    router.replace({ path: "/login" });
+                }, 1500);
+            }).catch(() => isSaving.value = false);
+        } else {
+            return false;
+        }
+    });
+}
+
+defineExpose({
+    open
+})
+</script>
+
+<style lang="scss" scoped>
+.el-form {padding-right: 16px;}
+</style>

+ 94 - 0
src/layout/components/probar.vue

@@ -0,0 +1,94 @@
+<template>
+    <el-drawer class="pro-drawer" v-model="visible" :size="800" append-to-body @closed="$emit('closed')">
+        <template #header="{ titleId, titleClass }">
+            <span :id="titleId" :class="titleClass">当前企业:<span>{{ deptData?.name }}</span></span>
+        </template>
+
+        <el-container class="dept-content">
+            <el-aside width="300px">
+                <el-header>企业列表</el-header>
+                <el-main class="nopadding">
+                    <el-scrollbar>
+                        <el-tree node-key="id" :current-node-key="filter.deptId" :data="deptTree" :default-expanded-keys="expandedKeys" highlight-current expand-on-click-node accordion @node-click="data => filter.deptId = data.id">
+                            <template #default="{ data }">
+                                <vxe-text-ellipsis :id="data.id" :title="data.name" class="custom-tree-node" :content="data.name"></vxe-text-ellipsis>
+                            </template>
+                        </el-tree>
+                    </el-scrollbar>
+                </el-main>
+            </el-aside>
+            <el-container>
+                <el-header>项目列表
+                    <el-input v-model="filter.text" placeholder="输入项目名称" clearable></el-input>
+                </el-header>
+                <el-main class="nopadding">
+                    <el-scrollbar>
+                        <el-tree node-key="fpiId" :current-node-key="$store.state.project.projectId" :data="projects" highlight-current @node-click="data => nodeClick(data)">
+                            <template #default="{ data }">
+                                <vxe-text-ellipsis :id="data.fpiId" :title="data.projectName" class="custom-tree-node" :content="data.projectName"></vxe-text-ellipsis>
+                            </template>
+                        </el-tree>
+                    </el-scrollbar>
+                </el-main>
+            </el-container>
+        </el-container>
+	</el-drawer>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+
+export default {
+    data() {
+        return {
+            visible: false,
+
+            filter: {
+                deptId: XEUtils.toNumber(XEUtils.get(XEUtils.find(this.$TOOL.data.get("PROJECTS"), item => item.fpiId == this.$store.state.project.projectId), "belongDeptId")),
+                text: ""
+            }
+        }
+    },
+
+    computed: {
+        expandedKeys() {
+            return XEUtils.map(XEUtils.toTreeArray(XEUtils.searchTree(this.deptTree, item => item.id == this.filter.deptId)), item => item.id);
+        },
+
+        deptTree() {
+            return XEUtils.first(XEUtils.toArrayTree(this.$TOOL.data.get("DEPTS"), { parentKey: "pid" })).children;
+        },
+
+        projects() {
+            if (this.filter.text) return XEUtils.filter(this.$TOOL.data.get("PROJECTS"), item => item.projectName.includes(this.filter.text));
+            return XEUtils.filter(this.$TOOL.data.get("PROJECTS"), item => item.belongDeptId == this.deptData.id);
+        },
+
+        deptData() {
+            return XEUtils.find(this.$TOOL.data.get("DEPTS"), item => item.id == this.filter.deptId);
+        }
+    },
+
+    methods: {
+        open() {
+            this.visible = true;
+            nextTick(() => XEUtils.arrayEach(document.querySelectorAll(".el-tree-node.is-current"), el => el.scrollIntoView({ block: "center" })));
+            return this;
+        },
+
+        nodeClick(data) {
+            this.$store.commit("SET_projectId", data.fpiId);
+            this.visible = false;
+        },
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.dept-content {border-top: 1px solid var(--el-border-color-light);}
+.dept-content :deep(.el-header) {height: fit-content;padding: 10px;padding-left: 0;border: none;font-size: 14px;font-weight: bold;color: rgba(0, 0, 0, 0.85);}
+.dept-content .el-aside {display: flex;flex-direction: column;}
+.dept-content .el-aside + .el-container {padding-left: 15px;}
+.dept-content .el-aside + .el-container .el-header {flex-direction: column;align-items: flex-start;}
+.dept-content .el-aside + .el-container .el-header .el-input {width: 200px;margin-top: 10px;}
+</style>

+ 80 - 0
src/layout/components/search.vue

@@ -0,0 +1,80 @@
+<template>
+	<div class="sc-search">
+		<el-input ref="input" v-model="input" size="large" prefix-icon="el-icon-search" clearable placeholder="搜索" :trigger-on-focus="false" @input="inputChange" />
+		<div v-if="history.length" class="sc-search-history">
+			<el-tag v-for="(item, index) in history" :key="item" type="info" effect="dark" closable @click="historyClick(item)" @close="historyClose(index)">{{ item }}</el-tag>
+		</div>
+		<div class="sc-search-result">
+			<div v-if="!result.length" class="sc-search-no-result">暂无搜索结果</div>
+			<ul v-else>
+				<el-scrollbar max-height="366px">
+					<li v-for="item in result" :key="item.path" @click="to(item)">
+                        <sc-iconify :icon="navMenu.meta.icon || undefined" size="20"></sc-iconify>
+						<span>{{ menuTitle(item) }}</span>
+					</li>
+				</el-scrollbar>
+			</ul>
+		</div>
+	</div>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+
+export default {
+    data() {
+        return {
+            input: "",
+            result: [],
+            history: []
+        }
+    },
+
+    computed: {
+        menu() {
+            return XEUtils.toTreeArray((this.$TOOL.data.get("MENU") || [])).filter(item => item.path !== this.$CONFIG.DASHBOARD_URL && !item.meta?.hidden && item.component);
+        }
+    },
+
+    mounted() {
+        this.history = this.$TOOL.data.get("SEARCH_HISTORY") || [];
+        this.$refs.input.focus();
+    },
+    methods: {
+        menuTitle(item) {
+            return XEUtils.toTreeArray(XEUtils.searchTree((this.$TOOL.data.get("MENU") || []), m => item.path === m.path) || []).map(item => item.meta.title).filter(item => item).join(" - ") || item.meta.title;
+        },
+        inputChange(value) {
+            this.result = value && this.menu.filter(item => item.meta.title.toLowerCase().indexOf(value.toLowerCase()) >= 0 || item?.name.toLowerCase().indexOf(value.toLowerCase()) >= 0) || [];
+        },
+        to(item) {
+            if (!this.history.includes(this.input)) {
+                this.history.push(this.input);
+                this.$TOOL.data.set("SEARCH_HISTORY", this.history);
+            }
+
+            this.$router.push({ path: item.path });
+            this.$emit('success', true);
+        },
+        historyClick(text) {
+            this.input = text;
+            this.inputChange(text);
+        },
+        historyClose(index) {
+            this.history.splice(index, 1);
+            if (!this.history.length) this.$TOOL.data.remove("SEARCH_HISTORY");
+            else this.$TOOL.data.set("SEARCH_HISTORY", this.history);
+        }
+    }
+}
+</script>
+
+<style scoped>
+.sc-search-no-result {margin: 40px 0;text-align: center;color: #999;}
+.sc-search-history {margin-top: 10px;}
+.sc-search-history .el-tag {cursor: pointer;}
+.sc-search-result {margin-top: 15px;}
+.sc-search-result li {list-style: none;cursor: pointer;display: flex;align-items: center;height: 56px;margin-bottom: 5px;padding: 0 15px;background: var(--el-bg-color-overlay);border: 1px solid var(--el-border-color-light);border-radius: 4px;font-size: 14px;}
+.sc-search-result li .sc-iconify-icon {margin-right: 15px;}
+.sc-search-result li:hover {background: var(--el-color-primary);border-color: var(--el-color-primary);color: #fff;}
+</style>

+ 95 - 0
src/layout/components/setting.vue

@@ -0,0 +1,95 @@
+<template>
+	<el-form ref="form" label-width="120" label-position="left" style="padding:0 20px;">
+		<el-form-item :label="$t('user.nightmode')">
+			<el-switch v-model="dark"></el-switch>
+		</el-form-item>
+		<el-form-item label="主题颜色">
+			<el-color-picker v-model="colorPrimary" :predefine="colorList">></el-color-picker>
+		</el-form-item>
+		<el-divider></el-divider>
+		<el-form-item class="radio-item" label="框架布局">
+            <el-radio-group v-model="layout">
+                <el-radio value="default">
+                    <sc-iconify icon="mingcute:layout-11-line" size="70"></sc-iconify>
+                </el-radio>
+                <el-radio value="header">
+                    <sc-iconify icon="icon-park-outline:grid-three" size="65"></sc-iconify>
+                </el-radio>
+                <el-radio value="menu">
+                    <sc-iconify icon="mingcute:layout-line" size="70"></sc-iconify>
+                </el-radio>
+            </el-radio-group>
+        </el-form-item>
+		<el-form-item label="折叠菜单">
+			<el-switch v-model="menuIsCollapse"></el-switch>
+		</el-form-item>
+		<el-form-item label="标签栏">
+			<el-switch v-model="layoutTags"></el-switch>
+		</el-form-item>
+		<el-divider></el-divider>
+	</el-form>
+</template>
+
+<script>
+import { VxeUI } from "vxe-table";
+import colorTool from "@/utils/color";
+
+export default {
+    data() {
+        return {
+            layout: this.$store.state.global.layout,
+            menuIsCollapse: this.$store.state.global.menuIsCollapse,
+            layoutTags: this.$store.state.global.layoutTags,
+            dark: this.$TOOL.data.get("APP_DARK") || false,
+            colorList: ["#409EFF", "#009688", "#536dfe", "#ff5c93", "#c62f2f", "#fd726d"],
+            colorPrimary: this.$TOOL.data.get("APP_COLOR") || this.$CONFIG.COLOR || "#409EFF"
+        }
+    },
+    watch: {
+        layout(val) {
+            this.$store.commit("SET_layout", val);
+        },
+        
+        menuIsCollapse() {
+            this.$store.commit("TOGGLE_menuIsCollapse");
+        },
+
+        layoutTags() {
+            this.$store.commit("TOGGLE_layoutTags");
+        },
+
+        dark(val) {
+            if (val) {
+                document.documentElement.classList.add("dark");
+                this.$TOOL.data.set("APP_DARK", val);
+                VxeUI.setTheme("dark");
+            } else {
+                document.documentElement.classList.remove("dark");
+                this.$TOOL.data.remove("APP_DARK");
+                VxeUI.setTheme("light");
+            }
+        },
+
+        colorPrimary(val) {
+            if (!val) {
+                val = "#409EFF";
+                this.colorPrimary = "#409EFF";
+            }
+            document.documentElement.style.setProperty("--el-color-primary", val);
+            for (let i = 1; i <= 9; i++) {
+                document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, colorTool.lighten(val, i/10));
+            }
+            for (let i = 1; i <= 9; i++) {
+                document.documentElement.style.setProperty(`--el-color-primary-dark-${i}`, colorTool.darken(val, i/10));
+            }
+            this.$TOOL.data.set("APP_COLOR", val);
+        }
+    }
+}
+</script>
+
+<style scoped>
+.radio-item {flex-direction: column;}
+.radio-item .el-radio {display: flex;flex-direction: column-reverse;height: fit-content;}
+.radio-item .el-radio :deep(.el-radio__label) {padding-top: 8px;padding-left: 0;}
+</style>

+ 84 - 0
src/layout/components/sideM.vue

@@ -0,0 +1,84 @@
+<template>
+	<div class="mobile-nav-button" v-drag draggable="false" @click="showMobileNav"><sc-iconify icon="ant-design:appstore-outlined" size="22" color="#fff"></sc-iconify></div>
+
+	<el-drawer ref="mobileNavBox" title="移动端菜单" :size="240" v-model="nav" direction="ltr" :with-header="false" destroy-on-close>
+		<el-container class="mobile-nav">
+            <el-header class="logo-bar">
+				<img class="logo" src="img/logo.png">
+                <span>{{ $CONFIG.APP_NAME }}</span>
+			</el-header>
+            <el-main>
+                <el-scrollbar>
+                    <el-menu :default-active="$route.meta.active || $route.fullPath" router @select="select">
+                        <NavMenu :navMenus="menu"></NavMenu>
+                    </el-menu>
+                </el-scrollbar>
+            </el-main>
+		</el-container>
+	</el-drawer>
+</template>
+
+<script>
+import NavMenu from "./NavMenu";
+
+export default {
+    components: { NavMenu },
+    data() {
+        return {
+            nav: false
+        }
+    },
+    
+    computed: {
+        menu() {
+            return (this.$TOOL.data.get("MENU") || []).filter(item => item.path !== this.$CONFIG.DASHBOARD_URL && !item.meta?.hidden);
+        }
+    },
+
+    methods: {
+        showMobileNav(e) {
+            const isdrag = e.currentTarget.getAttribute("drag-flag");
+            if (isdrag == "true") return false;
+            else this.nav = true;
+        },
+        select() {
+            this.$refs.mobileNavBox.handleClose();
+        }
+    },
+
+    directives: {
+        drag(el) {
+            let oDiv = el; //当前元素
+            let firstTime = "", lastTime = "";
+            oDiv.onmousedown = function(e) {
+                //鼠标按下,计算当前元素距离可视区的距离
+                let disX = e.clientX - oDiv.offsetLeft;
+                let disY = e.clientY - oDiv.offsetTop;
+                document.onmousemove = function(e) {
+                    oDiv.setAttribute("drag-flag", true);
+                    firstTime = new Date().getTime();
+                    // 通过事件委托,计算移动的距离
+                    let l = e.clientX - disX;
+                    let t = e.clientY - disY;
+
+                    // 移动当前元素
+                    if (t > 0 && t < document.body.clientHeight - 50) oDiv.style.top = t + "px";
+                    if (l > 0 && l < document.body.clientWidth - 50) oDiv.style.left = l + "px";
+                }
+                document.onmouseup = function() {
+                    lastTime = new Date().getTime();
+                    if ((lastTime - firstTime) > 200) oDiv.setAttribute("drag-flag", false);
+                    document.onmousemove = null;
+                    document.onmouseup = null;
+                };
+                //return false不加的话可能导致黏连,就是拖到一个地方时div粘在鼠标上不下来,相当于onmouseup失效
+                return false;
+            }
+        }
+    }
+}
+</script>
+
+<style scoped>
+.mobile-nav-button {position: fixed;bottom:10px;left:10px;z-index: 100;display: flex;align-items: center;justify-content: center;width: 50px;height: 50px;background: var(--el-color-primary);box-shadow: 0 2px 12px 0 var(--el-color-primary);border-radius: 50%;}
+</style>

+ 248 - 0
src/layout/components/tags.vue

@@ -0,0 +1,248 @@
+<template>
+	<div class="aminui-tags">
+		<ul ref="tags">
+            <template v-for="tag in tagList" v-bind:key="tag">
+                <li :class="[isActive(tag) && 'active', tag.meta.affix && 'affix']" :title="tag.meta.title" @contextmenu.prevent="openContextMenu($event, tag)">
+                    <router-link :to="tag">
+                        <vxe-text-ellipsis :content="tag.meta.title"></vxe-text-ellipsis>
+                        <el-icon v-if="!tag.meta.affix" @click.prevent.stop='closeSelectedTag(tag)'><el-icon-close /></el-icon>
+                    </router-link>
+                </li>
+            </template>
+		</ul>
+	</div>
+
+	<transition name="el-zoom-in-top">
+		<ul v-if="contextMenuVisible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu" id="contextmenu">
+			<li @click="refreshTab()"><el-icon><el-icon-refresh /></el-icon>刷新</li>
+			<hr>
+			<li @click="closeTabs()" :class="contextMenuItem.meta.affix && 'disabled'"><el-icon><el-icon-close /></el-icon>关闭标签</li>
+			<li @click="closeOtherTabs()"><el-icon><el-icon-folder-delete /></el-icon>关闭其他标签</li>
+		</ul>
+	</transition>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+import Sortable from "sortablejs";
+
+export default {
+    name: "tags",
+    data() {
+        return {
+            contextMenuVisible: false,
+            contextMenuItem: null,
+            left: 0,
+            top: 0,
+            tagList: this.$store.state.viewTags.viewTags,
+            tipDisplayed: false
+        }
+    },
+    watch: {
+        $route(e) {
+            this.addViewTags(e);
+            // 判断标签容器是否出现滚动条
+            this.$nextTick(() => {
+                const tags = this.$refs.tags
+                if(tags && tags.scrollWidth > tags.clientWidth) {
+                    // 确保当前标签在可视范围内
+                    let targetTag = tags.querySelector(".active")
+                    targetTag.scrollIntoView()
+                    // 显示提示
+                    if (!this.tipDisplayed) {
+                        this.$msgbox({
+                            type: "warning",
+                            center: true,
+                            title: "提示",
+                            message: "当前标签数量过多,可通过鼠标滚轴滚动标签栏。关闭标签数量可减少系统性能消耗。",
+                            confirmButtonText: "知道了"
+                        })
+                        this.tipDisplayed = true
+                    }
+                }
+            })
+        },
+        contextMenuVisible(value) {
+            const cm = e => {
+                const sp = document.getElementById("contextmenu");
+                if (sp && !sp.contains(e.target)) this.closeMenu();
+            }
+            if (value) document.body.addEventListener("click", e => cm(e));
+            else document.body.removeEventListener("click", e => cm(e));
+        }
+    },
+    created() {
+        let dashboardRoute = XEUtils.findTree(this.$TOOL.data.get("MENU"), item => item.path == this.$CONFIG.DASHBOARD_URL)?.item;
+        if (dashboardRoute) {
+            dashboardRoute.fullPath = dashboardRoute.path;
+            this.addViewTags(dashboardRoute);
+            this.addViewTags(this.$route);
+        }
+    },
+    mounted() {
+        this.tagDrop();
+        this.scrollInit();
+    },
+    methods: {
+        // 标签拖拽排序
+        tagDrop() {
+            Sortable.create(this.$refs.tags, { draggable: "li", animation: 300 })
+        },
+        // 增加tag
+        addViewTags(route) {
+            if (route.name && !route.meta.fullpage) {
+                this.$store.commit("pushViewTags", route);
+                this.$store.commit("pushKeepLive", route.name);
+            }
+        },
+        //高亮tag
+        isActive(route) {
+            return route.fullPath === this.$route.fullPath;
+        },
+        // 关闭tag
+        closeSelectedTag(tag, autoPushLatestView = true) {
+            const nowTagIndex = this.tagList.findIndex(item => item.fullPath == tag.fullPath);
+            this.$store.commit("removeViewTags", tag);
+            this.$store.commit("removeIframeList", tag);
+            this.$store.commit("removeKeepLive", tag.name);
+            if (autoPushLatestView && this.isActive(tag)) {
+                const leftView = this.tagList[nowTagIndex - 1];
+                if (leftView) this.$router.push(leftView);
+                else this.$router.push("/");
+            }
+        },
+        // tag右键
+        openContextMenu(e, tag) {
+            this.contextMenuItem = tag;
+            this.contextMenuVisible = true;
+            this.left = e.clientX + 1;
+            this.top = e.clientY + 1;
+
+            // FIX 右键菜单边缘化位置处理
+            this.$nextTick(() => {
+                let sp = document.getElementById("contextmenu");
+                if(document.body.offsetWidth - e.clientX < sp.offsetWidth) {
+                    this.left = document.body.offsetWidth - sp.offsetWidth + 1;
+                    this.top = e.clientY + 1;
+                }
+            })
+        },
+        // 关闭右键菜单
+        closeMenu() {
+            this.contextMenuItem = null;
+            this.contextMenuVisible = false;
+        },
+        // TAB 刷新
+        refreshTab() {
+            this.contextMenuVisible = false;
+            const nowTag = this.contextMenuItem;
+            //判断是否当前路由,否的话跳转
+            if (this.$route.fullPath !== nowTag.fullPath) {
+                this.$router.push({
+                    path: nowTag.fullPath,
+                    query: nowTag.query
+                })
+            }
+            
+            this.$store.commit("refreshIframe", nowTag);
+            setTimeout(() => {
+                this.$store.commit("removeKeepLive", nowTag.name);
+                this.$store.commit("setRouteShow", false);
+                this.$nextTick(() => {
+                    this.$store.commit("pushKeepLive", nowTag.name);
+                    this.$store.commit("setRouteShow", true);
+                })
+            }, 0);
+        },
+        // TAB 关闭
+        closeTabs(){
+            const nowTag = this.contextMenuItem;
+            if (!nowTag.meta.affix) {
+                this.closeSelectedTag(nowTag);
+                this.contextMenuVisible = false;
+            }
+        },
+        // TAB 关闭其他
+        closeOtherTabs() {
+            const nowTag = this.contextMenuItem;
+            // 判断是否当前路由,否的话跳转
+            if (this.$route.fullPath != nowTag.fullPath) {
+                this.$router.push({
+                    path: nowTag.fullPath,
+                    query: nowTag.query
+                })
+            }
+
+            [...this.tagList].forEach(tag => {
+                if(tag?.meta?.affix || nowTag.fullPath == tag.fullPath) return true;
+                else this.closeSelectedTag(tag, false);
+            })
+            this.contextMenuVisible = false;
+        },
+        // 横向滚动
+        scrollInit() {
+            const scrollDiv = this.$refs.tags;
+            scrollDiv.addEventListener("mousewheel", handler, false) || scrollDiv.addEventListener("DOMMouseScroll", handler, false)
+            function handler(event) {
+                const detail = event.wheelDelta || event.detail;
+                //火狐上滚键值-3 下滚键值3,其他内核上滚键值120 下滚键值-120
+                const moveForwardStep = 1;
+                const moveBackStep = -1;
+                let step = 0;
+                if (detail == 3 ||  detail < 0 && detail != -3) {
+                    step = moveForwardStep * 50;
+                } else {
+                    step = moveBackStep * 50;
+                }
+                scrollDiv.scrollLeft += step;
+            }
+        }
+    }
+}
+</script>
+
+<style>
+	.contextmenu {
+		position: fixed;
+		width: 200px;
+		margin:0;
+		border-radius: 0px;
+		background: var(--el-bg-color-overlay);
+		border: 1px solid var(--el-border-color-light);
+		box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
+		z-index: 3000;
+		list-style-type: none;
+		padding: 10px 0;
+	}
+	.contextmenu hr {
+		margin:5px 0;
+		border: none;
+		height: 1px;
+		font-size: 0px;
+		background-color: var(--el-border-color-light);
+	}
+	.contextmenu li {
+		display: flex;
+		align-items: center;
+		margin:0;
+		cursor: pointer;
+		line-height: 30px;
+		padding: 0 17px;
+		color: #606266;
+	}
+	.contextmenu li i {
+		font-size: 14px;
+		margin-right: 10px;
+	}
+	.contextmenu li:hover {
+		background-color: #ecf5ff;
+		color: #66b1ff;
+	}
+	.contextmenu li.disabled {
+		cursor: not-allowed;
+		color: #bbb;
+		background: transparent;
+	}
+
+	.dark .contextmenu li {color: var(--el-text-color-primary);}
+</style>

+ 66 - 0
src/layout/components/topbar.vue

@@ -0,0 +1,66 @@
+<template>
+	<div class="aminui-topbar">
+		<div class="left-panel">
+            <div class="panel-item hidden-sm-and-down" @click="$store.commit('TOGGLE_menuIsCollapse')">
+                <sc-iconify :icon="menuIsCollapse ? 'ep:expand': 'ep:fold'" size="20"></sc-iconify>
+            </div>
+
+			<el-breadcrumb separator-icon="el-icon-arrow-right" class="hidden-sm-and-down">
+				<transition-group name="breadcrumb">
+					<template v-for="item in breadList" :key="item.name">
+						<el-breadcrumb-item v-if="item.path != '/'" :key="item.meta.title"><sc-iconify :icon="item.meta.icon || undefined" size="14"></sc-iconify>{{ item.meta.title }}</el-breadcrumb-item>
+					</template>
+				</transition-group>
+			</el-breadcrumb>
+		</div>
+		<div class="center-panel"></div>
+		<div class="right-panel">
+			<slot></slot>
+        </div>
+	</div>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+
+export default {
+    data() {
+        return {
+            breadList: []
+        }
+    },
+    
+    watch: {
+        $route() {
+            this.getBreadcrumb();
+        }
+    },
+
+    computed: {
+        menuIsCollapse() {
+            return this.$store.state.global.menuIsCollapse
+        }
+    },
+
+    created() {
+        this.getBreadcrumb();
+    },
+
+    methods: {
+        getBreadcrumb() {
+            this.breadList = XEUtils.get(XEUtils.findTree(this.$TOOL.data.get("MENU"), item => item.path == this.$route.path), "nodes", []);
+        }
+    }
+}
+</script>
+
+<style scoped>
+.aminui-topbar .panel-item:hover {background: unset;}
+
+.el-breadcrumb {margin-left: 15px;}
+.el-breadcrumb :deep(.el-breadcrumb__inner) {display: inline-flex;align-items: center;}
+.el-breadcrumb .el-breadcrumb__inner .sc-iconify-icon {margin-right: 5px;}
+.breadcrumb-enter-active, .breadcrumb-leave-active {transition: all 0.3s;}
+.breadcrumb-enter-from, .breadcrumb-leave-active {opacity: 0;transform: translateX(20px);}
+.breadcrumb-leave-active {position: absolute;}
+</style>

+ 110 - 0
src/layout/components/userbar.vue

@@ -0,0 +1,110 @@
+<template>
+	<div class="user-bar">
+        <!-- <div class="panel-item" @click="showProDrawer">
+            <vxe-text-ellipsis v-if="projectName" :title="projectName" class="project-name" :content="projectName"></vxe-text-ellipsis>
+            <sc-iconify title="切换项目" icon="ep:sort" size="20"></sc-iconify>
+		</div>
+        <div class="panel-item hidden-sm-and-down" @click="toScreen">
+            <sc-iconify title="大屏展示" icon="tabler:heart-rate-monitor" size="20"></sc-iconify>
+		</div> -->
+        <div class="panel-item hidden-sm-and-down" @click="dialog.search = true">
+            <sc-iconify title="搜索" icon="mingcute:search-line" size="20"></sc-iconify>
+		</div>
+		<el-dropdown class="user panel-item" trigger="click" @command="handleUser">
+			<div class="user-avatar">
+				<el-avatar :size="30"><sc-iconify icon="ep:avatar" size="20"></sc-iconify></el-avatar>
+			</div>
+			<template #dropdown>
+				<el-dropdown-menu>
+					<el-dropdown-item command="uc">个人信息</el-dropdown-item>
+					<el-dropdown-item command="layout">布局设置</el-dropdown-item>
+					<el-dropdown-item command="up">修改密码</el-dropdown-item>
+					<el-dropdown-item divided command="ol">退出登录</el-dropdown-item>
+				</el-dropdown-menu>
+			</template>
+		</el-dropdown>
+	</div>
+        
+    <dept-pro-drawer v-if="dialog.project" ref="deptProDrawer" @closed="dialog.project = false"></dept-pro-drawer>
+
+	<el-dialog v-model="dialog.search" title="搜索" :width="700" center destroy-on-close>
+		<search @success="dialog.search = false"></search>
+	</el-dialog>
+
+    <el-drawer v-model="dialog.setting" title="布局设置" :size="400" append-to-body destroy-on-close>
+		<setting></setting>
+	</el-drawer>
+
+    <password-detail v-if="dialog.password" ref="passwordDetail" @closed="dialog.password = false"></password-detail>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+import deptProDrawer from "./probar";
+import search from "./search";
+import setting from "./setting";
+import passwordDetail from "./password";
+
+export default {
+    components: { deptProDrawer, search, setting, passwordDetail },
+    data() {
+        return {
+            dialog: {
+                project: false,
+                search: false,
+                setting: false,
+                password: false
+            }
+        }
+    },
+
+    computed: {
+        projectName() {
+            return this.$store.state.project.projectName;
+        }
+    },
+
+    created() {
+        // this.$store.dispatch("fetchProject");
+    },
+
+    methods: {
+        showProDrawer() {
+            if (this.projectName) {
+                this.dialog.project = true;
+                nextTick(() => this.$refs.deptProDrawer.open());
+            }
+        },
+
+        toScreen() {
+            // if (this.$TOOL.data.get("PROJECT")) window.open(`/easydo/media/#/school?projectId=${this.$TOOL.data.get("PROJECT_ID")}`)
+        },
+        
+        handleUser(command) {
+            if (command == "uc") this.$router.push({ path: "/usercenter" });
+            if (command == "layout") this.dialog.setting = true;
+            if (command == "up") {
+                this.dialog.password = true;
+                nextTick(() => this.$refs.passwordDetail.open());
+            }
+            if (command == "ol") {
+                this.$confirm("确认是否退出当前用户?", "温馨提示", {
+                    type: "warning",
+                    confirmButtonText: "退出"
+                }).then(() => {
+                    this.$TOOL.cookie.remove("MES_TOKEN");
+                    this.$TOOL.data.remove("USER_INFO");
+                    this.$TOOL.data.remove("MENU");
+                    this.$router.replace({ path: "/login" });
+                }).catch(() => {})
+            }
+        }
+    }
+}
+</script>
+
+<style scoped>
+.user-bar {display: flex;align-items: center;height: 100%;}
+.user-bar .panel-item .project-name {max-width: 346px;font-size: 16px;font-weight: bold;}
+.user-bar .user-avatar .sc-iconify-icon{top: -1px;}
+</style>

+ 206 - 0
src/layout/index.vue

@@ -0,0 +1,206 @@
+<template>
+	<!-- 通栏布局 -->
+	<template v-if="layout == 'header'">
+        <header class="aminui-header">
+            <div class="aminui-header-left">
+                <div class="logo-bar">
+                    <img class="logo" src="img/logo.png">
+                    <span>{{ $CONFIG.APP_NAME }}</span>
+                </div>
+                <el-tabs v-if="!ismobile" ref="menuTabs" class="nav-menu" v-model="tabsName" @tab-change="showMenu">
+                    <el-tab-pane v-for="item in menus" :key="item" :name="item.path">
+                        <template #label>
+                            <sc-iconify :icon="item.meta.icon || undefined"></sc-iconify>
+                            <vxe-text-ellipsis :title="item.meta.title" :content="item.meta.title"></vxe-text-ellipsis>
+                        </template>
+                    </el-tab-pane>
+                </el-tabs>
+            </div>
+            <div class="aminui-header-right">
+                <userbar></userbar>
+            </div>
+        </header>
+        <section class="aminui-wrapper">
+            <div v-if="!ismobile && currentMenu?.children?.length" :class="['aminui-side', menuIsCollapse && 'isCollapse']">
+                <div v-if="!menuIsCollapse" class="aminui-side-top">
+                    <h2><vxe-text-ellipsis :title="currentMenu.meta.title" :content="currentMenu.meta.title"></vxe-text-ellipsis></h2>
+                </div>
+                <div class="aminui-side-scroll">
+                    <el-scrollbar>
+                        <el-menu :default-active="$route.fullPath" router :collapse="menuIsCollapse" :unique-opened="$CONFIG.MENU_UNIQUE_OPENED">
+                            <NavMenu :navMenus="menus.find(menu => menu.path == currentMenu.path).children"></NavMenu>
+                        </el-menu>
+                    </el-scrollbar>
+                </div>
+            </div>
+            <Side-m v-if="ismobile"></Side-m>
+            <div class="aminui-body el-container">
+				<Topbar v-if="!ismobile && currentMenu?.children?.length"></Topbar>
+                <Tags v-if="!ismobile && layoutTags"></Tags>
+                <div class="aminui-main" id="aminui-main">
+                    <router-view v-slot="{ Component }">
+                        <keep-alive :include="this.$store.state.keepAlive.keepLiveRoute">
+                            <component v-if="$store.state.keepAlive.routeShow" :is="Component" :key="$route.fullPath" />
+                            <page-loading v-else />
+                        </keep-alive>
+                    </router-view>
+                </div>
+            </div>
+        </section>
+	</template>
+
+	<!-- 经典布局 -->
+	<template v-else-if="layout == 'menu'">
+        <header class="aminui-header">
+			<div class="aminui-header-left">
+				<div class="logo-bar">
+					<img class="logo" src="img/logo.png">
+					<span>{{ $CONFIG.APP_NAME }}</span>
+				</div>
+			</div>
+			<div class="aminui-header-right">
+				<userbar></userbar>
+			</div>
+		</header>
+		<section class="aminui-wrapper">
+			<div v-if="!ismobile" :class="['aminui-side', menuIsCollapse && 'isCollapse']">
+				<div class="aminui-side-scroll">
+					<el-scrollbar>
+						<el-menu :default-active="$route.fullPath" router :collapse="menuIsCollapse" :unique-opened="$CONFIG.MENU_UNIQUE_OPENED">
+							<NavMenu :navMenus="menus"></NavMenu>
+						</el-menu>
+					</el-scrollbar>
+				</div>
+			</div>
+			<Side-m v-if="ismobile"></Side-m>
+			<div class="aminui-body el-container">
+				<Topbar v-if="!ismobile"></Topbar>
+				<Tags v-if="!ismobile && layoutTags"></Tags>
+				<div class="aminui-main" id="aminui-main">
+					<router-view v-slot="{ Component }">
+					    <keep-alive :include="this.$store.state.keepAlive.keepLiveRoute">
+                            <component v-if="$store.state.keepAlive.routeShow" :is="Component" :key="$route.fullPath" />
+                            <page-loading v-else />
+					    </keep-alive>
+					</router-view>
+				</div>
+			</div>
+		</section>
+	</template>
+
+	<!-- 默认布局 -->
+	<template v-else>
+        <section class="aminui-wrapper">
+            <div v-if="!ismobile" :class="['aminui-side hasMenu', menuIsCollapse && 'isCollapse']">
+                <div class="aminui-side-top">
+                    <div class="logo-bar">
+                        <img class="logo" src="img/logo.png">
+                        <span v-if="!menuIsCollapse">{{ $CONFIG.APP_NAME }}</span>
+                    </div>
+                </div>
+                <div class="aminui-side-scroll">
+                    <el-scrollbar>
+                        <el-menu popper-class="side-menu-popper" :default-active="$route.fullPath" router :collapse="menuIsCollapse" :unique-opened="$CONFIG.MENU_UNIQUE_OPENED">
+                            <NavMenu :navMenus="menus"></NavMenu>
+                        </el-menu>
+                    </el-scrollbar>
+                </div>
+            </div>
+            <Side-m v-if="ismobile"></Side-m>
+            <div class="aminui-body el-container">
+				<Topbar>
+				    <userbar></userbar>
+                </Topbar>
+                <Tags v-if="!ismobile && layoutTags"></Tags>
+                <div class="aminui-main" id="aminui-main">
+                    <router-view v-slot="{ Component }">
+                        <keep-alive :include="this.$store.state.keepAlive.keepLiveRoute">
+                            <component v-if="$store.state.keepAlive.routeShow" :is="Component" :key="$route.fullPath" />
+                            <page-loading v-else />
+                        </keep-alive>
+                    </router-view>
+                </div>
+            </div>
+        </section>
+	</template>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+
+import pageLoading from "./components/pageLoading";
+import SideM from "./components/sideM";
+import Topbar from "./components/topbar";
+import Tags from "./components/tags";
+import NavMenu from "./components/NavMenu";
+import userbar from "./components/userbar";
+import { nextTick } from 'vue';
+
+export default {
+    name: "index",
+    components: { pageLoading, SideM, Topbar, Tags, NavMenu, userbar },
+
+    data() {
+        return {
+            tabsName: ""
+        }
+    },
+
+    watch: {
+        layout: {
+            immediate: true,
+            handler(val) {
+                document.body.setAttribute("data-layout", val)
+            }
+        },
+
+        currentMenu: {
+            immediate: true,
+            handler(val) {
+                this.tabsName = XEUtils.get(val, "path", "")
+            }
+        },
+    },
+
+    computed: {
+        ismobile() {
+            return this.$store.state.global.ismobile
+        },
+        layout() {
+            return this.$store.state.global.layout
+        },
+        layoutTags() {
+            return this.$store.state.global.layoutTags
+        },
+        menuIsCollapse() {
+            return this.$store.state.global.menuIsCollapse
+        },
+        menus() {
+            return (this.$TOOL.data.get("MENU") || []).filter(item => item.path !== this.$CONFIG.DASHBOARD_URL && !item.meta?.hidden)
+        }, 
+        currentMenu() {
+            return XEUtils.first(XEUtils.searchTree(this.menus, item => item.path === this.$route.path)) || {}
+        }
+    },
+
+    created() {
+        this.onLayoutResize();
+        window.addEventListener("resize", this.onLayoutResize);
+    },
+
+    methods: {
+        onLayoutResize() {          
+            this.$store.commit("SET_ismobile", document.body.clientWidth < 992);
+        },
+
+        // 点击显示
+        showMenu(path) {
+            const currentMenu = XEUtils.first(XEUtils.searchTree(this.menus, item => item.path === path))
+            currentMenu && this.$router.push({ path: currentMenu.redirect || currentMenu.path });
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 73 - 0
src/layout/other/404.vue

@@ -0,0 +1,73 @@
+<template>
+	<div class="router-err">
+		<div class="router-err__icon">
+			<img src="img/404.png" />
+		</div>
+		<div class="router-err__content">
+			<h2>无权限或找不到页面</h2>
+			<p>当前页面无权限访问或者打开了一个不存在的链接,请检查当前账户权限和链接的可访问性。</p>
+			<el-button type="primary" plain round @click="gohome">返回首页</el-button>
+			<el-button type="primary" round @click="goback">返回上一页</el-button>
+		</div>
+	</div>
+</template>
+
+<script>
+	export default {
+		methods: {
+			gohome() {
+				location.href= "#/";
+			},
+			goback() {
+				this.$router.go(-1);
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+.router-err {
+  display: flex;
+  align-items: center;
+  width: 900px;
+  margin: 50px auto;
+
+  .router-err__icon {
+    width: 400px;
+
+    img {
+      width: 100%;
+    }
+  }
+
+  .router-err__content {
+    flex: 1;
+    padding: 40px;
+
+    h2 {
+      font-size: 26px;
+    }
+
+    p {
+      margin: 15px 0 30px;
+      line-height: 1.5;
+      font-size: 14px;
+      color: #999;
+    }
+  }
+}
+
+@media (max-width: 992px) {
+  .router-err {
+    display: block;
+    width: 100%;
+    margin-top: 10px;
+    text-align: center;
+
+    .router-err__icon {
+      width: 280px;
+      margin: 0 auto;
+    }
+  }
+}
+</style>

+ 3 - 0
src/layout/other/empty.vue

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

+ 28 - 0
src/locales/index.js

@@ -0,0 +1,28 @@
+import sysConfig from "@/config"
+import tool from "@/utils/tool"
+import { createI18n } from "vue-i18n"
+import el_zh_cn from "element-plus/es/locale/lang/zh-cn"
+import el_en from "element-plus/es/locale/lang/en"
+
+import zh_cn from "./lang/zh-cn.js"
+import en from "./lang/en.js"
+
+const messages = {
+	"zh-cn": {
+		el: el_zh_cn,
+		...zh_cn
+	},
+	"en": {
+		el: el_en,
+		...en
+	}
+}
+
+const i18n = createI18n({
+	locale: tool.data.get("APP_LANG") || sysConfig.LANG,
+	fallbackLocale: "zh-cn",
+	globalInjection: true,
+	messages
+})
+
+export default i18n;

+ 18 - 0
src/locales/lang/en.js

@@ -0,0 +1,18 @@
+export default {
+	login: {
+		formTitle: "User login",
+		rememberMe: "Remember me",
+		signIn: "Sign in",
+		userPlaceholder: "Please input a user name",
+		userError: "Please input a user name",
+		PWPlaceholder: "Please input a password",
+		PWError: "Please input a password",
+		codePlaceholder: "Please input the verification code",
+		codeError: "Please input the verification code"
+	},
+
+    user: {
+		nightmode: "night mode",
+		language: "language"
+	}
+}

+ 18 - 0
src/locales/lang/zh-cn.js

@@ -0,0 +1,18 @@
+export default {
+	login: {
+		formTitle: "用户登录",
+		rememberMe: "24小时免登录",
+		signIn: "登录",
+		userPlaceholder: "请输入用户名",
+		userError: "请输入用户名",
+		PWPlaceholder: "请输入密码",
+		PWError: "请输入密码",
+		codePlaceholder: "请输入验证码",
+		codeError: "请输入验证码"
+	},
+
+    user: {
+		nightmode: "黑夜模式",
+		language: "语言"
+	}
+}

+ 23 - 0
src/main.js

@@ -0,0 +1,23 @@
+import ElementPlus from "element-plus";
+import "element-plus/dist/index.css";
+import "element-plus/theme-chalk/display.css";
+import "element-plus/theme-chalk/dark/css-vars.css";
+
+import vxeTable from "./vxeTable";
+import scui from "./scui";
+import i18n from "./locales";
+import router from "./router";
+import store from "./store";
+import App from "./App.vue";
+
+const app = createApp(App);
+
+app.use(store);
+app.use(router);
+app.use(ElementPlus);
+app.use(i18n);
+app.use(scui);
+app.use(vxeTable);
+
+//挂载app
+app.mount("#app");

+ 161 - 0
src/router/index.js

@@ -0,0 +1,161 @@
+import XEUtils from "xe-utils"
+import { createRouter, createWebHashHistory } from "vue-router"
+import NProgress from "nprogress"
+import "nprogress/nprogress.css"
+
+import api from "@/api"
+import tool from "@/utils/tool"
+import config from "@/config"
+import userRoutes from "@/config/route"
+import systemRouter from "./systemRouter"
+import { beforeEach, afterEach } from "./scrollBehavior"
+
+// 系统路由
+const routes = systemRouter
+
+// 系统特殊路由
+const routes_404 = {
+	path: "/:pathMatch(.*)*",
+	hidden: true,
+	component: () => import(/* webpackChunkName: "404" */ "@/layout/other/404"),
+}
+
+const routes_empty = {
+	path: "/:pathMatch(.*)*",
+	hidden: true,
+	component: () => import(/* webpackChunkName: "404" */ "@/layout/other/empty"),
+}
+
+let routes_404_r = () => {}
+
+const router = createRouter({
+	history: createWebHashHistory(),
+	routes: routes
+})
+
+// 判断是否已加载过动态/静态路由
+let isGetRouter = false;
+// FIX 多个API同时401时疯狂弹窗BUG
+let MessageBox_401_show = false
+
+router.beforeEach(async (to, from, next) => {
+	NProgress.start()
+	// 动态标题
+	document.title = to.meta.title ? `${to.meta.title} - ${config.APP_NAME}` : `${config.APP_NAME}`
+
+	let token = tool.cookie.get("MES_TOKEN")
+    
+	if (to.path === "/login") {
+		// 删除路由(替换当前layout路由)
+		router.addRoute(routes[0])
+		// 删除路由(404)
+		routes_404_r()
+		isGetRouter = false
+		if (token) next(from.fullPath)
+        else {
+            to.redirectedFrom = from
+            next()
+        }
+		return false
+	}
+
+	if (!token) {
+		next({ path: "/login" })
+		return false
+	}
+
+	// 整页路由处理
+	if (to.meta.fullpage) to.matched = [to.matched[to.matched.length - 1]]
+    
+    // 加载动态/静态路由
+	if (!isGetRouter) {
+        const apiMenu = await api.system.menu.build()
+        if (!apiMenu.length) {
+            if (!MessageBox_401_show) {
+                MessageBox_401_show = true;
+                ElMessageBox.confirm("当前用户无任何菜单权限,请联系系统管理员", "无权限访问", {
+                    type: "error",
+                    showClose: false,
+                    closeOnPressEscape: false,
+                    closeOnClickModal: false,
+                    center: true,
+                    showCancelButton: false,
+                    beforeClose: (action, instance, done) => {
+                        MessageBox_401_show = false
+                        done()
+                    }
+                }).then(() => {
+                    tool.cookie.remove("MES_TOKEN")
+                    location.reload() // 为了重新实例化vue-router对象 避免bug
+                })
+            }
+
+            routes_404_r = router.addRoute(routes_empty);
+        } else {
+            tool.data.set("MENU", [...userRoutes, ...mapAsyncMenu(apiMenu)])
+
+            const menuRouter = XEUtils.mapTree([...userRoutes, ...mapAsyncMenu(apiMenu)], item => {
+                return {
+                    ...XEUtils.omit(item, "component"),
+                    [item.component ? "component" : "redirect"]: item.component ? loadComponent(item.component) : XEUtils.get(XEUtils.first(item.children), "path", "")
+                }
+            })
+            XEUtils.arrayEach(XEUtils.toTreeArray(menuRouter), item => router.addRoute("layout", item))
+            routes_404_r = router.addRoute(routes_404)
+            !to.matched.length && router.push(to.fullPath);
+        }
+
+		isGetRouter = true;
+	}
+
+	beforeEach(to, from)
+	next();
+});
+
+router.afterEach((to, from) => {
+	afterEach(to, from);
+	NProgress.done();
+});
+
+router.onError(error => {
+    NProgress.done();
+	ElNotification.error({
+		title: "路由错误",
+		message: error.message
+	});
+
+    // const pattern = /Loading chunk (\d)+ failed/g;
+    // const isChunkLoadFailed = error.message.match(pattern);
+    // const targetPath = router.history.pending.fullPath;
+    // if (isChunkLoadFailed) {
+    //     router.replace(targetPath);
+    // }
+});
+
+function mapPath(path) {
+    return XEUtils.startsWith(path, "/") ? path : `/${path}`;
+}
+
+// 转换
+function mapAsyncMenu(menus) {
+    XEUtils.arrayEach(menus, item => item.path = mapPath(item.path));
+    return XEUtils.mapTree(XEUtils.toArrayTree(menus, { parentKey: "pid", sortKey: "menuSort" }), item => {
+        return {
+            name: item.title,
+            path: XEUtils.map(XEUtils.get(XEUtils.findTree(menus, parent => parent.id == item.pid), "nodes", []), node => XEUtils.get(node, "path", "")).join("") + item.path,
+            iframe: item.iframe,
+            meta: { title: item.title, icon: item.icon, hidden: item.hidden },
+            component: item.component
+        }
+    })
+}
+
+function loadComponent(component) {
+	if (component) {
+		return () => import(/* webpackChunkName: "[request]" */ `@/views/${component}`)
+	} else {
+		return () => import(`@/layout/other/empty`)
+	}
+}
+
+export default router

+ 19 - 0
src/router/scrollBehavior.js

@@ -0,0 +1,19 @@
+import store from "@/store"
+
+export function beforeEach(to, from) {
+	const adminMain = document.querySelector("#aminui-main")
+	if (!adminMain) return false
+	store.commit("updateViewTags", {
+		fullPath: from.fullPath,
+		scrollTop: adminMain.scrollTop
+	})
+}
+
+export function afterEach(to) {
+	const adminMain = document.querySelector("#aminui-main")
+	if (!adminMain) return false
+	nextTick(() => {
+		const beforeRoute = store.state.viewTags.viewTags.filter(v => v.fullPath == to.fullPath)[0]
+		if (beforeRoute) adminMain.scrollTop = beforeRoute.scrollTop || 0
+	})
+}

+ 19 - 0
src/router/systemRouter.js

@@ -0,0 +1,19 @@
+import config from "@/config";
+
+//系统路由
+const routes = [
+	{
+		name: "layout",
+		path: "/",
+		component: () => import(/* webpackChunkName: "layout" */ "@/layout"),
+		redirect: config.DASHBOARD_URL || "/home",
+		children: []
+	},
+	{
+		path: "/login",
+		component: () => import(/* webpackChunkName: "login" */ "@/views/login"),
+		meta: { title: "登录" }
+	}
+]
+
+export default routes;

+ 61 - 0
src/scui.js

@@ -0,0 +1,61 @@
+import config from "./config";
+import api from "./api";
+import tool from "./utils/tool";
+import http from "./utils/request";
+import { permission, rolePermission } from "./utils/permission";
+
+import auth from "./directives/auth";
+import auths from "./directives/auths";
+import authsAll from "./directives/authsAll";
+import role from "./directives/role";
+import time from "./directives/time";
+import copy from "./directives/copy";
+import errorHandler from "./utils/errorHandler";
+
+import * as elIcons from "@element-plus/icons-vue";
+import * as scIcons from "./assets/icons";
+
+export default {
+	install(app) {
+		//挂载全局对象
+		app.config.globalProperties.$CONFIG = config;
+		app.config.globalProperties.$TOOL = tool;
+		app.config.globalProperties.$HTTP = http;
+		app.config.globalProperties.$API = api;
+		app.config.globalProperties.$AUTH = permission;
+		app.config.globalProperties.$ROLE = rolePermission;
+
+		//注册全局指令
+		app.directive("auth", auth);
+		app.directive("auths", auths);
+		app.directive("auths-all", authsAll);
+		app.directive("role", role);
+		app.directive("time", time);
+		app.directive("copy", copy);
+
+        // 统一注册el-icon图标
+		for (let icon in elIcons) {
+            app.component(`ElIcon${icon}`, elIcons[icon])
+        }
+		// 统一注册sc-icon图标
+		for (let icon in scIcons.default) {
+            app.component(`ScIcon${icon}`, scIcons.default[icon])
+        }
+
+		//关闭async-validator全局控制台警告
+		window.ASYNC_VALIDATOR_NO_WARNING = 1;
+
+		//全局代码错误捕捉
+		app.config.errorHandler = errorHandler;
+
+        // 可以监听localStorage数据变化
+        const signSetItem = localStorage.setItem
+        localStorage.setItem = function (key, val) {
+            let setEvent = new Event("setItemEvent")
+            setEvent.key = key
+            setEvent.newValue = val
+            window.dispatchEvent(setEvent)
+            signSetItem.apply(this, arguments)
+        }
+	}
+}

+ 15 - 0
src/store/index.js

@@ -0,0 +1,15 @@
+/**
+ * @description 自动import导入所有 vuex 模块
+ */
+
+import { createStore } from 'vuex';
+
+const files = require.context('./modules', false, /\.js$/);
+const modules = {}
+files.keys().forEach((key) => {
+	modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default
+})
+
+export default createStore({
+	modules
+});

+ 0 - 0
src/store/modules/global.js


Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác