zhuangyunsheng 4 kuukautta sitten
vanhempi
commit
ff7d77730b
100 muutettua tiedostoa jossa 5988 lisäystä ja 0 poistoa
  1. 12 0
      .editorconfig
  2. 17 0
      .env.development
  3. 8 0
      .env.production
  4. 84 0
      .eslintrc-auto-import.json
  5. 57 0
      .gitignore
  6. 21 0
      LICENSE
  7. 5 0
      babel.config.js
  8. 17 0
      jsconfig.json
  9. 80 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. 107 0
      public/index.html
  16. 73 0
      src/App.vue
  17. 11 0
      src/api/index.js
  18. 39 0
      src/api/model/auth.js
  19. 38 0
      src/api/model/carwash.js
  20. 20 0
      src/api/model/common.js
  21. 28 0
      src/api/model/easyRun.js
  22. 20 0
      src/api/model/env.js
  23. 60 0
      src/api/model/facerec.js
  24. 84 0
      src/api/model/passqrcode.js
  25. 20 0
      src/api/model/scc.js
  26. 32 0
      src/api/model/system.js
  27. 28 0
      src/api/model/tower.js
  28. 20 0
      src/api/model/ugliAi.js
  29. BIN
      src/assets/fonts/UnidreamLED.eot
  30. BIN
      src/assets/fonts/UnidreamLED.woff
  31. 3 0
      src/assets/icons/Code.vue
  32. 3 0
      src/assets/icons/Email.vue
  33. 3 0
      src/assets/icons/LoginAccount.vue
  34. 3 0
      src/assets/icons/Nickname.vue
  35. 3 0
      src/assets/icons/Release.vue
  36. 3 0
      src/assets/icons/Stock.vue
  37. 3 0
      src/assets/icons/ValidCode.vue
  38. 12 0
      src/assets/icons/index.js
  39. 83 0
      src/components/scCodeEditor/index.vue
  40. 84 0
      src/components/scCropper/index.vue
  41. 70 0
      src/components/scEcharts/echarts-theme-T.js
  42. 61 0
      src/components/scEcharts/index.vue
  43. 144 0
      src/components/scFormTable/index.vue
  44. 164 0
      src/components/scIconSelect/index.vue
  45. 17 0
      src/components/scIconify/index.vue
  46. 56 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. 84 0
      src/components/scTable/helper.js
  51. 277 0
      src/components/scTable/index.vue
  52. 31 0
      src/components/scTable/renderer/cell-edit.vue
  53. 32 0
      src/components/scTable/renderer/cell-tag.vue
  54. 25 0
      src/components/scTable/renderer/form-radio.vue
  55. 53 0
      src/components/scTable/renderer/form-select.vue
  56. 23 0
      src/components/scTable/renderer/pager-batch-del.vue
  57. 51 0
      src/components/scTable/renderer/table-search.vue
  58. 234 0
      src/components/scTableSelect/index.vue
  59. 41 0
      src/components/scTooltip/index.vue
  60. 201 0
      src/components/scUpload/file.vue
  61. 25 0
      src/components/scUpload/imageViewer.vue
  62. 368 0
      src/components/scUpload/index.vue
  63. 251 0
      src/components/scUpload/multiple.vue
  64. 154 0
      src/components/scUpload/uploadIndex.vue
  65. 53 0
      src/components/scUpload/videoViewer.vue
  66. 114 0
      src/components/scVideo/index.vue
  67. 16 0
      src/config/iconSelect.js
  68. 54 0
      src/config/index.js
  69. 35 0
      src/config/select.js
  70. 113 0
      src/config/table.js
  71. 23 0
      src/config/tableSelect.js
  72. 10 0
      src/config/upload.js
  73. 18 0
      src/directives/auth.js
  74. 24 0
      src/directives/auths.js
  75. 19 0
      src/directives/authsAll.js
  76. 27 0
      src/directives/copy.js
  77. 22 0
      src/directives/role.js
  78. 45 0
      src/directives/time.js
  79. 53 0
      src/layout/components/NavMenu.vue
  80. 91 0
      src/layout/components/password.vue
  81. 78 0
      src/layout/components/search.vue
  82. 80 0
      src/layout/components/setting.vue
  83. 91 0
      src/layout/components/sideM.vue
  84. 250 0
      src/layout/components/tags.vue
  85. 85 0
      src/layout/components/userbar.vue
  86. 118 0
      src/layout/index.vue
  87. 73 0
      src/layout/other/404.vue
  88. 3 0
      src/layout/other/empty.vue
  89. 28 0
      src/locales/index.js
  90. 18 0
      src/locales/lang/en.js
  91. 18 0
      src/locales/lang/zh-cn.js
  92. 23 0
      src/main.js
  93. 374 0
      src/mock/mock.js
  94. 165 0
      src/router/index.js
  95. 19 0
      src/router/scrollBehavior.js
  96. 21 0
      src/router/systemRouter.js
  97. 61 0
      src/scui.js
  98. 15 0
      src/store/index.js
  99. 28 0
      src/store/modules/global.js
  100. 0 0
      src/store/modules/iframe.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

+ 17 - 0
.env.development

@@ -0,0 +1,17 @@
+# 本地环境
+NODE_ENV = development
+
+# 标题
+VUE_APP_TITLE = EasyDo运营中心
+
+# 接口地址
+# VUE_APP_API_BASEURL = http://www.qdeasydo.com/api
+# VUE_APP_ZEROAPI_BASEURL = http://www.qdeasydo.com
+VUE_APP_API_BASEURL  = http://192.168.101.93:8804
+VUE_APP_ZEROAPI_BASEURL  = http://192.168.101.93:8804
+
+# 本地端口
+VUE_APP_PORT = 3200
+
+# 是否开启代理
+VUE_APP_PROXY = true

+ 8 - 0
.env.production

@@ -0,0 +1,8 @@
+# 生产环境
+NODE_ENV = production
+
+# 标题
+VUE_APP_TITLE = EasyDo运营中心
+
+# 接口地址
+VUE_APP_API_BASEURL =

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

@@ -0,0 +1,84 @@
+{
+  "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
+  }
+}

+ 57 - 0
.gitignore

@@ -0,0 +1,57 @@
+
+######################################################################
+# 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/

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

+ 80 - 0
package.json

@@ -0,0 +1,80 @@
+{
+    "name": "easydo-ops",
+    "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",
+        "@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.8.4",
+        "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


+ 107 - 0
public/index.html

@@ -0,0 +1,107 @@
+<!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>

+ 73 - 0
src/App.vue

@@ -0,0 +1,73 @@
+<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) {
+			callback = debounce(callback, 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

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

@@ -0,0 +1,39 @@
+import config from "@/config"
+import tool from '@/utils/tool'
+import http from "@/utils/request"
+
+export default {
+	token: {
+		url: `${config.API_URL}/api/auth/login`,
+		name: "登录获取TOKEN",
+		post: async function (data = {}) {
+			const query = {
+				username: data.user,
+				password: tool.crypto.encrypt(data.password),
+				code: data.code,
+				uuid: data.uuid
+			}
+			return await http.post(this.url, query);
+		}
+	},
+
+	codeImg: {
+		url: `${config.API_URL}/api/auth/code`,
+		name: "获取登录验证码",
+		get: async function () {
+			return await http.get(this.url);
+		}
+	},
+
+    updatePassword: {
+		url: `${config.API_URL}/api/users/updatePass`,
+		name: "修改密码",
+		post: async function (data = {}) {
+			const query = {
+				oldPass: tool.crypto.encrypt(data.userPassword),
+				newPass: tool.crypto.encrypt(data.newPassword)
+			}
+			return await http.post(this.url, query);
+		}
+	}
+}

+ 38 - 0
src/api/model/carwash.js

@@ -0,0 +1,38 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    check: {
+        url: `${config.API_URL}//zeroapi/v2/cwashm/device/check`,
+        name: "查询在线状态",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    gate: {
+        url: `${config.API_URL}/api/carRinseTemp/getMountedPage`,
+        name: "安装点查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    records: {
+        temp: {
+            url: `${config.API_URL}/api/carRinseTemp/getPage`,
+            name: "监测记录-图片",
+            get: async function (data = {}) {
+                return await http.post(this.url, data);
+            }
+        },
+    
+        camera: {
+            url: `${config.API_URL}/api/carRinseCamera/getPage`,
+            name: "监测记录-视频",
+            get: async function (data = {}) {
+                return await http.post(this.url, data);
+            }
+        }
+    }
+}

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

@@ -0,0 +1,20 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+	folder: {
+		url: `${config.API_URL}/zcxt`,
+		name: "文件上传",
+		up: async function (data, config = {}) {
+			return await http.post(`${this.url}/file/upload`, data, config);
+		},
+
+		rm: async function (entityID) {
+			return await http.post(`${this.url}/folder/rm`, { querys: [], expands: { entityID } });
+		},
+
+		get: async function (entityID) {
+			return await http.get(`${this.url}/folder/${entityID}`, {}, { responseType: "blob" });
+		}
+	}
+}

+ 28 - 0
src/api/model/easyRun.js

@@ -0,0 +1,28 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    supplier: {
+        url: `${config.API_URL}/api/easyrunCompany`,
+		name: "供应商管理",
+        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}/add`, data);
+		},
+
+		edit: async function (data = {}) {
+			return await http.post(`${this.url}/updateById`, data);
+		},
+
+        del: async function (id) {
+			return await http.post(`${this.url}/delete?id=${id}`);
+		}
+    }
+}

+ 20 - 0
src/api/model/env.js

@@ -0,0 +1,20 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    gate: {
+        url: `${config.API_URL}/api/envdev/mounted/fetch`,
+        name: "安装点查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    records: {
+        url: `${config.API_URL}/api/envdev/records/fetch`,
+        name: "监测记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    }
+}

+ 60 - 0
src/api/model/facerec.js

@@ -0,0 +1,60 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    check: {
+        url: `${config.API_URL}/zeroapi/v1/frec/device/check`,
+        name: "查询在线状态",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    gate: {
+        url: `${config.API_URL}/api/facerec/gate/fetch`,
+        name: "闸口查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    online: {
+        url: `${config.API_URL}/api/frec/online/fetch`,
+        name: "设备上线/离线记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    monohistory: {
+        url: `${config.API_URL}/zeroapi/v1/frec/worker/monohistory`,
+        name: "fluxs记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    grouphistory: {
+        url: `${config.API_URL}/zeroapi/v1/frec/worker/grouphistory`,
+        name: "groups记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    cmttask: {
+        url: `${config.API_URL}/api/frec/cmttask/fetch`,
+        name: "三方平台下发任务",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    notify: {
+        url: `${config.API_URL}/api/frec/notify/fetch`,
+        name: "三方平台推送任务",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    }
+}

+ 84 - 0
src/api/model/passqrcode.js

@@ -0,0 +1,84 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    check: {
+        url: `${config.API_URL}/zeroapi/v1/gatec/device/check`,
+        name: "查询在线状态",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    gate: {
+        url: `${config.API_URL}/api/passqrcode/gate/fetch`,
+        name: "闸口查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    online: {
+        url: `${config.API_URL}/zeroapi/v1/gatecopts/online/fetch`,
+        name: "设备上线/离线记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    devstat: {
+        name: "设备状态",
+        url: `${config.API_URL}/zeroapi/v1/gatec/devstat`,
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    cardinfo: {
+        name: "卡号信息",
+        url: `${config.API_URL}/zeroapi/v1/gatec/cardinfos`,
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    monohistory: {
+        url: `${config.API_URL}/zeroapi/v1/gatec/worker/monohistory`,
+        name: "fluxs记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    grouphistory: {
+        url: `${config.API_URL}/zeroapi/v1/gatec/worker/grouphistory`,
+        name: "groups记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    cmttask: {
+        url: `${config.API_URL}/zeroapi/v1/gatecopts/cmttask/fetch`,
+        name: "三方平台下发任务",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    notify: {
+        url: `${config.API_URL}/zeroapi/v1/gatecopts/notify/fetch`,
+        name: "三方平台推送任务",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    cardSync: {
+        name: "青岛地铁卡号同步",
+        url: `${config.API_URL}/zeroapi/v1/gatecopts/worker/fetch`,
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    }
+}

+ 20 - 0
src/api/model/scc.js

@@ -0,0 +1,20 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    gate: {
+        url: `${config.API_URL}/api/scc/mounted/fetch`,
+        name: "安装点查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    records: {
+        url: `${config.API_URL}/api/scc/records/fetch`,
+        name: "监测记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    }
+}

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

@@ -0,0 +1,32 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+	project: {
+		url: `${config.API_URL}/api/factory/getFactoryProject`,
+		name: "获取项目",
+		get: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	},
+
+    device: {
+		url: `${config.API_URL}/api/deviceStock/getDevicePage`,
+		name: "设备查询",
+        get: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	},
+
+    user: {
+        url: `${config.API_URL}/api/users`,
+		name: "用户信息",
+        get: async function (params = {}) {
+			return await http.get(this.url, params);
+		},
+
+		edit: async function (data = {}) {
+			return await http.put(this.url, data);
+		}
+    }
+}

+ 28 - 0
src/api/model/tower.js

@@ -0,0 +1,28 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    gate: {
+        url: `${config.API_URL}/api/tcm/mounted/fetch`,
+        name: "安装点查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    records: {
+        url: `${config.API_URL}/api/iotTcm/getPage`,
+        name: "监测记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    warnings: {
+        url: `${config.API_URL}/api/tcm/warnings/fetch`,
+        name: "告警记录",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    }
+}

+ 20 - 0
src/api/model/ugliAi.js

@@ -0,0 +1,20 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+    gate: {
+        url: `${config.API_URL}/api/aihazard/mounted/fetch`,
+        name: "安装点查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+
+    records: {
+        url: `${config.API_URL}/api/aihazard/records/fetch`,
+        name: "安装点查询",
+        get: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    }
+}

BIN
src/assets/fonts/UnidreamLED.eot


BIN
src/assets/fonts/UnidreamLED.woff


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

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 0
src/assets/icons/Email.vue


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

@@ -0,0 +1,3 @@
+<template>
+    <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024"><path d="M409.003 469.333L300.8 361.131l60.33-60.331L572.33 512l-211.2 211.2-60.33-60.33 108.203-108.203H128v-85.334h281.003zM469.333 128h341.334C857.6 128 896 166.4 896 213.333v597.334C896 857.6 857.6 896 810.667 896H469.333v-85.333h341.334V213.333H469.333V128z"></path></svg>
+</template>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 0
src/assets/icons/Nickname.vue


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 0
src/assets/icons/Release.vue


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 0
src/assets/icons/Stock.vue


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 0
src/assets/icons/ValidCode.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
+ * @Author: sakuya
+ * @Date: 2021年7月24日17:05:43
+ * @LastEditors:
+ * @LastEditTime:
+ * @other: 代码完全开源,欢迎参考,也欢迎PR
+-->
+
+<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,
+					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) => {
+					let file = new File([blob], fileName, {type: 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>

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

@@ -0,0 +1,144 @@
+<!--
+ * @Descripttion: 表单表格
+ * @version: 1.3
+ * @Author: sakuya
+ * @Date: 2023年2月9日12:32:26
+ * @LastEditors: sakuya
+ * @LastEditTime: 2023年2月17日10:41:47
+-->
+
+<template>
+	<div class="sc-form-table" ref="scFormTable">
+		<el-table v-bind="$attrs" :data="data" ref="table" border stripe>
+			<el-table-column type="index" width="50" fixed="left" v-if="!hideSeq">
+				<template #header>
+					<el-button v-if="!hideAdd" type="primary" icon="el-icon-plus" size="small" circle @click="rowAdd"></el-button>
+				</template>
+				<template #default="scope">
+					<div :class="['sc-form-table-handle', {'sc-form-table-handle-delete':!hideDelete}]">
+						<span>{{scope.$index + 1}}</span>
+						<el-button v-if="!hideDelete" type="danger" icon="el-icon-delete" size="small" plain circle @click="rowDel(scope.row, scope.$index)"></el-button>
+					</div>
+				</template>
+			</el-table-column>
+			<el-table-column label="" width="50" v-if="dragSort">
+				<template #default>
+					<div class="move" style="cursor: move;"><el-icon-d-caret style="width: 1em; height: 1em;"/></div>
+				</template>
+			</el-table-column>
+			<slot></slot>
+			<template #empty>
+				{{placeholder}}
+			</template>
+		</el-table>
+	</div>
+</template>
+
+<script>
+	import Sortable from "sortablejs"
+
+	export default {
+		props: {
+			modelValue: { type: Array, default: () => [] },
+			addTemplate: { type: Object, default: () => {} },
+			placeholder: { type: String, default: "暂无数据" },
+			dragSort: { type: Boolean, default: false },
+			hideSeq: { type: Boolean, default: false },
+			hideAdd: { type: Boolean, default: false },
+			hideDelete: { type: Boolean, default: false }
+		},
+		data(){
+			return {
+				data: []
+			}
+		},
+		mounted(){
+			this.data = this.modelValue
+			if(this.dragSort){
+				this.rowDrop()
+			}
+		},
+		watch:{
+			modelValue(){
+				this.data = this.modelValue
+			},
+			data: {
+				handler(){
+					this.$emit("update:modelValue", this.data);
+				},
+				deep: true
+			}
+		},
+		methods: {
+			rowDrop(){
+				const _this = this
+				const tbody = this.$refs.table.$el.querySelector(".el-table__body-wrapper tbody")
+				Sortable.create(tbody, {
+					handle: ".move",
+					animation: 300,
+					ghostClass: "ghost",
+					onEnd({ newIndex, oldIndex }) {
+						_this.data.splice(newIndex, 0, _this.data.splice(oldIndex, 1)[0])
+						const newArray = _this.data.slice(0)
+						const tmpHeight = _this.$refs.scFormTable.offsetHeight
+						_this.$refs.scFormTable.style.setProperty("height", tmpHeight + "px")
+						_this.data = []
+						_this.$nextTick(() => {
+							_this.data = newArray
+							_this.$nextTick(() => {
+								_this.$refs.scFormTable.style.removeProperty("height")
+							})
+
+						})
+					}
+				})
+			},
+			rowAdd(){
+				if (this.addTemplate) {
+					const temp = JSON.parse(JSON.stringify(this.addTemplate))
+					this.data.push(temp)
+					this.$emit("rowChange")
+				} else this.$emit("rowAdd")
+			},
+			rowDel(row, index){
+				this.data.splice(index, 1)
+				this.$emit("rowChange")
+			},
+			//插入行
+			pushRow(row){
+				const temp = row || JSON.parse(JSON.stringify(this.addTemplate))
+				this.data.push(temp)
+			},
+			//根据index删除
+			deleteRow(index){
+				this.data.splice(index, 1)
+			}
+		}
+	}
+</script>
+
+<style scoped>
+.sc-form-table {
+  width: 100%;
+}
+.sc-form-table .sc-form-table-handle {
+  text-align: center;
+}
+.sc-form-table .sc-form-table-handle span {
+  display: inline-block;
+}
+.sc-form-table .sc-form-table-handle button {
+  display: none;
+}
+.sc-form-table .hover-row .sc-form-table-handle-delete span {
+  display: none;
+}
+.sc-form-table .hover-row .sc-form-table-handle-delete button {
+  display: inline-block;
+}
+.sc-form-table .move {
+  text-align: center;
+  font-size: 14px;
+  margin-top: 3px;
+}
+</style>

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

@@ -0,0 +1,164 @@
+<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 title="图标选择器" v-model="dialogVisible" :width="760" destroy-on-close append-to-body>
+            <el-form>
+                <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 data" :key="item.name" lazy :name="item.name">
+                    <template #label>
+                        {{ item.name }}<el-tag size="small" type="info">{{ item.icons.length }}</el-tag>
+                    </template>
+
+                    <div class="sc-icon-select__list">
+                        <el-scrollbar>
+                            <ul @click="selectIcon">
+                                <el-empty v-if="!item.icons.length" :image-size="100" description="未查询到相关图标" />
+                                <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: "默认",
+            data: JSON.parse(JSON.stringify(config.icons)),
+
+            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;
+        this.activeName = config.selectIcon(this.modelValue)?.name || "默认";
+    },
+    methods: {
+        open() {
+            if (this.disabled) return false;
+            this.dialogVisible = true;
+        },
+        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 = JSON.parse(JSON.stringify(config.icons));
+            if (text) {
+                XEUtils.arrayEach(filterData, item => {
+                    item.icons = XEUtils.filter(item.icons, icon => icon.toLowerCase().includes(text.toLowerCase()))
+                })
+            }
+            
+            this.data = filterData;
+        }
+    }
+}
+</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" />
+    </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>

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

@@ -0,0 +1,56 @@
+<!--
+ * @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">
+                        <sc-tooltip :content="$attrs.titleText || pageTitle"></sc-tooltip>
+                    </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.onAdd" type="primary" @click="$emit('add')">
+                        <template #icon><sc-iconify icon="ant-design:plus-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: 100%;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;}
+    }
+</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>

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

@@ -0,0 +1,84 @@
+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),
+            framework: XEUtils.get(config, "api.framework", "common"),
+            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}`,
+            [XEUtils.get(config, "props.type")?.includes("range") ? "defaultTime" : ""]: [new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)],
+            ...XEUtils.get(config, "props")
+        },
+        ...XEUtils.omit(config, "props")
+    },
+    ...config
+})

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

@@ -0,0 +1,277 @@
+<!--
+ * @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 VxeUI from "vxe-pc-ui";
+import XEUtils from "xe-utils";
+import store from "@/store";
+import config from "@/config/table";
+import { mockData } from "@/mock/mock";
+import pagerBatchDel from "./renderer/pager-batch-del";
+
+const props = defineProps({
+    apiObj: { type: Object, default: () => {} },
+    apiKey: { type: String, default: () => "get" },
+    framework: { type: String, default: () => "common" },
+    minHeight: { type: [String, Number], default: () => VxeUI.getConfig().table.minHeight },
+    maxHeight: { type: [String, Number] },
+    layouts: { type: Array, default: () => [["Top", "Form"], ["Toolbar", "Table", "Bottom", "Pager"]] },
+    /* ***************  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",
+    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);
+                    const remainder = 24 - (XEUtils.sum(spanItems, s_item => s_item.span || 6) % 24);
+                    item.visible = showItems.length > 0;
+                    item.collapseNode = showItems.length > 3;
+                    item.span = remainder < 6 && 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
+    },
+    proxyConfig: {
+        enabled: false
+    },
+    printConfig: {},
+    importConfig: {
+        // remote: true,
+        types: ["xlsx", "xls"],
+        mode: "insertBottom",
+        modes: ["insertBottom", "insertTop", "covering"],
+        // importMethod: ({ $grid, options }) => {},
+    },
+    exportConfig: {
+        remote: true,
+        types: ["xlsx"],
+        modes: XEUtils.find(props.columns, item => XEUtils.includes(config.exportExcludeFields, item.type)) && ["current", "selected", "all"] || ["current", "all"],
+        columns: XEUtils.filter(props.columns, item => !XEUtils.includes(config.exportExcludeFields, item.type)),
+        exportMethod: ({ $grid, options }) => exportEvent($grid, options)
+    },
+    rowConfig: {
+        keyField: "id",
+        useKey: true,
+        isHover: true
+    },
+    columnConfig: {
+        useKey: true,
+        resizable: true // 列宽拖动功能
+    },
+    headerCellConfig: {
+        height: 36
+    },
+    cellConfig: {
+        height: 36
+    },
+    checkboxConfig: {
+        highlight: true,
+        range: true // 鼠标在复选框的列内滑动选中或取消指定行
+    },
+    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 })
+        },
+        ...props.pagerConfig
+    },
+    ...props.options
+})
+
+watch(() => xGrid.value?.getCheckboxRecords(), val => selectedRows.value = val);
+
+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 = () => {
+    if (!props.apiObj) return;
+    nextTick(() => {
+        gridOptions.value.loading = true;
+        const reqData = config.framework[props.framework].queryData(gridOptions.value, props.paramsColums);
+        props.apiObj[props.apiKey](reqData).then(res => {
+            const response = config.framework[props.framework].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 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;
+    XEUtils.arrayEach(gridOptions.value.formConfig.items, item => XEUtils.set(gridOptions.value.formConfig.data, item.field, XEUtils.get(item, "resetValue")));
+    XEUtils.merge(gridOptions.value.formConfig.data, XEUtils.omit(gridOptions.value.formConfig.data, item => XEUtils.isEmpty(item) && !XEUtils.isNumber(item)));
+
+    getData();
+}
+
+const formCollapseEvent = ({ collapse }) => gridOptions.value.formConfig.collapseStatus = collapse;
+
+const toggleFormEnabled = () => gridOptions.value.formConfig.enabled = !gridOptions.value.formConfig.enabled;
+
+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 exportEvent = async ($grid, options) => {
+    let data = XEUtils.clone(options.data);
+    if (options.mode == "all") {
+        const reqData = config.framework[props.framework].queryExport(gridOptions.value);
+        const res = await props.apiObj[props.apiKey](reqData);
+        const response = config.framework[props.framework].parseData(res);
+        data = response.data || [];
+    }
+
+    $grid.exportData({
+        ...options,
+        remote: false,
+        columns: XEUtils.map(options.columns, item => ({ ...item, [item.type != "seq" && "exportMethod"]: ({ row, column }) => row[column?.field] || "" })),
+        data
+    });
+}
+
+defineExpose({
+    selectedRows,
+    toggleFormEnabled,
+    toggleToolbarProps,
+    reloadColumn,
+    getTableData,
+    reloadData,
+    searchData,
+    resetData
+})
+</script>
+
+<style scoped>
+    .el-main {padding: 0 12px 12px;background: var(--el-bg-color);}
+</style>

+ 31 - 0
src/components/scTable/renderer/cell-edit.vue

@@ -0,0 +1,31 @@
+<template>
+    <div style="display: flex;">
+        <template v-if="renderOpts.type == 'input'">
+            <el-input v-model="modelValue" v-bind="renderOpts.props" @change="compChange"></el-input>
+        </template>
+        <template v-if="renderOpts.type == 'select'">
+            <el-select v-model="modelValue" :loading="loading" v-bind="renderOpts.props" @change="compChange">
+                <el-option v-for="(item, index) in renderOpts.options" :key="index" :label="XEUtils.get(item, renderOpts.optionProps.label, item)" :value="XEUtils.get(item, renderOpts.optionProps.value, index)"></el-option>
+            </el-select>
+        </template>
+
+        <el-button type="primary" link @click="renderOpts.events.update(modelValue)">确定</el-button>
+    </div>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+
+const props = defineProps({
+    renderOpts: { type: Object, default: () => {} },
+    params: { type: Object, default: () => {} }
+})
+
+const loading = ref(false);
+const modelValue = ref(XEUtils.get(props, "renderOpts.defaultValue", null));
+
+const compChange = () => {
+    XEUtils.set(props.params.column.model, "update", true);
+    XEUtils.set(props.params.column.model, "value", modelValue.value);
+};
+</script>

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

@@ -0,0 +1,32 @@
+<template>
+    <el-tag v-if="modelValue" effect="plain" :type="tagType" v-bind="renderOpts.props">{{ modelValue }}</el-tag>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+
+const colorDic = {
+    在线: "success",
+    离线: "red",
+
+    准备就绪: "default",
+    等待执行: "processing",
+    执行中: "warning",
+    重试中: "cyan",
+    已完成: "success",
+    执行失败: "danger",
+    已撤销: "purple",
+    任务超时: "orange",
+
+    任务成功: "success",
+    任务失败: "danger"
+}
+
+const props = defineProps({
+    renderOpts: { type: Object, default: () => {} },
+    params: { type: Object, default: () => {} }
+})
+
+const modelValue = reactive(XEUtils.get(props, "renderOpts.defaultValue", null));
+const tagType = ref(XEUtils.get(colorDic, modelValue, ""));
+</script>

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

@@ -0,0 +1,25 @@
+<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(XEUtils.get(props.params.data, props.params.field) || null);
+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>

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

@@ -0,0 +1,53 @@
+<template>
+    <el-select v-model="modelValue" :loading="loading" v-bind="renderOpts.props" @change="compChange">
+        <template #label="{ label }">
+            <span>{{ label.split(" ")[0] }}</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 })
+
+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 XEUtils.get(config.queryData, XEUtils.get(props.renderOpts, "api.framework", "common"))(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();
+
+onMounted(() => {
+    window.addEventListener("setItemEvent", ({ key, newValue }) => {
+        if (props.renderOpts.storageKey && key === props.renderOpts.storageKey && newValue) options.value = JSON.parse(newValue)?.content;
+    });
+});
+onUnmounted(() => window.removeEventListener("setItemEvent", () => {}));
+</script>

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

@@ -0,0 +1,23 @@
+<template>
+    <el-button type="danger" plain :disabled="!ids || !ids.length" @click="batchDel">批量删除</el-button>
+</template>
+
+<script setup>
+import XEUtils from "xe-utils";
+
+const props = defineProps({
+    params: { type: Object, default: () => {} }
+})
+
+const ids = ref([]);
+
+watch(() => props.params.$grid?.getCheckboxRecords(), val => ids.value = XEUtils.map(val, item => item.id))
+
+const batchDel = () => {
+    console.log('batchDel: ids',ids.value)
+};
+</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>

+ 41 - 0
src/components/scTooltip/index.vue

@@ -0,0 +1,41 @@
+<!--
+ * @Descripttion: tooltip组件
+ * @version: 2.0
+ * @Date: 2023年12月11日16:17:52
+ * @LastEditTime: 2023年12月11日23:17:52
+-->
+
+<template>
+    <el-tooltip :content="content" :placement="placement" :disabled="!showTooltip">
+        <span class="sc-tooltip-content overflow-ellipsis" @mouseenter.stop.prevent="onMouseenter" @mouseleave.stop.prevent="showTooltip = false">{{ content }}</span>
+    </el-tooltip> 
+</template>
+
+<script>
+	export default {
+		props: {
+			placement: { type: String, default: "bottom" },
+			content: { type: String, default: "" },
+      		spacing: { type: Number, default: 0 }
+		},
+
+		data() {
+			return {
+        		showTooltip: false
+			}
+		},
+
+		methods: {
+			onMouseenter({ currentTarget }) {
+				this.showTooltip = currentTarget.scrollWidth >= currentTarget.parentElement.offsetWidth - this.spacing
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+.sc-tooltip-content {
+  max-width: 100% !important;
+  display: inline-block;
+}
+</style>

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

@@ -0,0 +1,201 @@
+<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"
+			: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>
+				<el-button type="primary" :disabled="disabled">Click to upload</el-button>
+			</slot>
+			<template #tip>
+				<div v-if="tip" class="el-upload__tip">{{ tip }}</div>
+			</template>
+		</el-upload>
+		<span style="display: none!important;"><el-input v-model="value"></el-input></span>
+	</div>
+
+	<sc-image-viewer :showViewer="showPictureViewer" :imageList="previewImageList" teleported @close="showPictureViewer = false"></sc-image-viewer>
+	<sc-video-viewer v-if="showVideoViewer" :videoUrl="previewVideoUrl" hideOnModal @close="showVideoViewer = false"></sc-video-viewer>
+</template>
+
+<script>
+	import config from "@/config/upload";
+
+	export default {
+		props: {
+			modelValue: { type: Array, default: () => [] },
+			tip: { type: String, default: "" },
+			accept: { type: String, default: "" },
+			maxSize: { type: Number, default: 50 },
+			limit: { type: Number, default: 0 },
+			multiple: { type: Boolean, default: true },
+			disabled: { type: Boolean, default: false },
+			hideAdd: { type: Boolean, default: false },
+			onSuccess: { type: Function, default: () => { return true } }
+		},
+
+		data() {
+			return {
+				value: "",
+				defaultFileList: [],
+				
+				showPictureViewer: false,
+				showVideoViewer: false,
+
+				previewImageList: [],
+				previewVideoUrl: ""
+			}
+		},
+
+		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, mineType: item.mineType, 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.name = res.fileName;
+				file.path = res.path;
+				file.mineType = res.mineType;
+			},
+
+			error(message) {
+				this.$notify.error({ title: "上传文件未成功", message });
+			},
+
+			beforeRemove({ id, name, path }) { // id, name, path, size
+				return this.$confirm(`是否移除 ${name}? 此操作不可逆!`, "提示", {
+					type: "warning",
+					confirmButtonText: "移除"
+				}).then(() => {
+					const entityID = id || path;
+					this.$API.common.folder.rm(entityID).then(res => {
+						if (res.code == 200) return true;
+						else return false;
+					}).catch(() => {
+						return false;
+					});
+				}).catch(() => {
+					return false;
+				});
+			},
+
+			handleExceed() {
+				this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`);
+			},
+
+			async handlePreview(uploadFile) {
+				if (config.imageIncludes(uploadFile.mineType)) {
+					this.showPictureViewer = true;
+					this.previewImageList = ["/zcxt/folder/" + uploadFile.path];
+				} else if (config.videoIncludes(uploadFile.mineType)) {
+					this.showVideoViewer = true;
+					this.previewVideoUrl = "/zcxt/folder/" + uploadFile.path;
+				} else {
+					const res = await this.$API.common.folder.get(uploadFile.path);
+					const a = document.createElement("a");
+					a.href = URL.createObjectURL(res);
+					a.download = uploadFile.name;
+					a.click();
+				}
+			},
+
+			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(res.message || "未知错误");
+				}).catch(err => param.onError(err));
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+.el-form-item.is-error .sc-upload-file:deep(.el-upload-dragger) {
+  border-color: var(--el-color-danger);
+}
+
+.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 {
+        &:hover {
+          background-color: transparent;
+        }
+
+        .el-upload-list__item-info {
+          width: 100%;
+        }
+
+        .el-upload-list__item-status-label {
+          display: none;
+        }
+      }
+    }
+  }
+}
+</style>

+ 25 - 0
src/components/scUpload/imageViewer.vue

@@ -0,0 +1,25 @@
+<template>
+	<div class="sc-image-viewer">
+		<el-image-viewer v-if="showViewer" v-bind="$attrs" :url-list="imageList" @close="$emit('closed')"></el-image-viewer>
+	</div>
+</template>
+
+<script>
+	export default {
+		emits: ["closed"],
+
+		props: {
+			showViewer: { type: Boolean, default: false },
+			imageList: { type: Array, default: () => [] }
+		},
+
+		data() {
+			return {}
+		},
+
+		methods: {}
+	}
+</script>
+
+<style lang="scss" scoped>
+</style>

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

@@ -0,0 +1,368 @@
+<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">
+			<el-image v-if="isImage(file.mineType)" class="image" :src="'/zcxt/folder/' + file.path" :preview-src-list="['/zcxt/folder/' + file.path]" fit="cover" preview-teleported :z-index="9999">
+				<template #placeholder>
+					<div class="sc-upload__img-slot">Loading...</div>
+				</template>
+			</el-image>
+			<sc-video v-if="isVideo(file.mineType)" :src="'/zcxt/folder/' + file.path" showMask @play="videoPlay"></sc-video>
+			
+			<div class="sc-upload__img-actions" v-if="!disabled">
+				<span class="del" @click="handleRemove()"><el-icon><el-icon-delete /></el-icon></span>
+			</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"
+			: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" :class="disabled && 'is-disabled'">
+					<div 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 title="剪裁" draggable v-model="cropperDialogVisible" :width="580" @closed="cropperClosed" destroy-on-close>
+			<sc-cropper :src="cropperFile.tempCropperFile" :compress="compress" :aspectRatio="aspectRatio" ref="cropper"></sc-cropper>
+			<template #footer>
+				<el-button @click="cropperDialogVisible=false" >取 消</el-button>
+				<el-button type="primary" @click="cropperSave">确 定</el-button>
+			</template>
+		</el-dialog>
+	</div>
+
+	<sc-video-viewer v-if="showVideoViewer" :videoUrl="previewVideoUrl" hideOnModal @close="showVideoViewer = false"></sc-video-viewer>
+</template>
+
+<script>
+	import config from "@/config/upload";
+
+	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 },
+			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,
+
+				showVideoViewer: false,
+				previewVideoUrl: ""
+			}
+		},
+
+		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 config.imageIncludes(type);
+			},
+
+			isVideo(type) {
+				return config.videoIncludes(type);
+			},
+			
+			newFile(data) {
+				this.file = Object.keys(data).length ? { 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(() => {
+					if (file.id) {
+						this.$API.common.folder.rm(file.id).then(res => {
+							if (res.code == 200) this.clearFiles();
+						}).catch(() => {});
+					} else this.clearFiles();
+				}).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") {
+					if (!this.isImage(file.raw.type)) return false;
+					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) {
+				if (!this.isImage(file.type) && !this.isVideo(file.type)) {
+					this.$message.warning({ title: "上传文件警告", message: "选择的文件非图像类/视频类文件" });
+					this.clearFiles();
+					return false;
+				}
+				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 = genFileId();
+				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.name = res.fileName;
+				file.path = res.path;
+				file.mineType = res.mineType;
+				this.value = JSON.stringify({ path: res.path, name: res.fileName, mineType: res.mineType });
+			},
+
+			error(message) {
+				this.$nextTick(() => this.clearFiles());
+				this.$notify.error({ title: "上传文件未成功", message });
+			},
+
+			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(res.message || "未知错误");
+				}).catch(err => param.onError(err));
+			},
+
+			videoPlay() {
+				this.showVideoViewer = true;
+				this.previewVideoUrl = "/zcxt/folder/" + this.file.path;
+			}
+		}
+	}
+</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) {
+  width: 100%;
+  height: 100%;
+  justify-content: unset;
+}
+
+.sc-upload__img {
+  width: 100%;
+  height: 100%;
+  position: relative;
+}
+.sc-upload__img .image {
+  width: 100%;
+  height: 100%;
+  border: 1px solid var(--el-border-color);
+}
+.sc-upload__img-actions {
+  position: absolute;
+  top: 0;
+  right: 0;
+  display: none;
+}
+.sc-upload__img-actions span {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 25px;
+  height: 25px;
+  cursor: pointer;
+  color: #fff;
+}
+.sc-upload__img-actions span i {
+  font-size: 12px;
+}
+.sc-upload__img-actions .del {
+  background: #f56c6c;
+}
+.sc-upload__img:hover .sc-upload__img-actions {
+  display: block;
+}
+.sc-upload__img-slot {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  font-size: 12px;
+  background-color: var(--el-fill-color-lighter);
+}
+
+.sc-upload__uploading {
+  width: 100%;
+  height: 100%;
+  position: relative;
+}
+.sc-upload__progress {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: var(--el-overlay-color-lighter);
+  z-index: 1;
+  padding: 10px;
+}
+.sc-upload__progress .el-progress {
+  width: 100%;
+}
+.sc-upload__uploading .image {
+  width: 100%;
+  height: 100%;
+}
+
+.sc-upload .file-empty {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-direction: column;
+}
+.sc-upload .file-empty i {
+  font-size: 28px;
+}
+.sc-upload .file-empty h4 {
+  font-size: 12px;
+  font-weight: normal;
+  color: #8c939d;
+  margin-top: 8px;
+}
+
+.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%;
+}
+</style>

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

@@ -0,0 +1,251 @@
+<template>
+	<div class="sc-upload-multiple">
+		<el-upload ref="uploader" list-type="picture-card"
+			v-model:file-list="defaultFileList"
+			action=""
+			accept="image/gif, image/jpeg, image/png, video/mp4 , video/avi"
+			: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="'/zcxt/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="'/zcxt/folder/' + file.path" showMask @play="videoPlay(file)"></sc-video>
+
+					<div v-if="!disabled && file.status == 'success'" class="sc-upload__item-actions">
+						<span class="del" @click="handleRemove(file)"><el-icon><el-icon-delete /></el-icon></span>
+					</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>
+
+	<sc-video-viewer v-if="showVideoViewer" :videoUrl="previewVideoUrl" hideOnModal @close="showVideoViewer = false"></sc-video-viewer>
+</template>
+
+<script>
+	import config from "@/config/upload";
+
+	export default {
+		props: {
+			modelValue: { type: Array, default: () => [] },
+			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: [],
+
+				showVideoViewer: false,
+				previewVideoUrl: ""
+			}
+		},
+
+		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 => "/zcxt/folder/" + v.path);
+			}
+		},
+
+		mounted() {
+			this.defaultFileList = this.modelValue;
+			this.value = this.modelValue;
+		},
+
+		methods: {
+			isImage(type) {
+				return config.imageIncludes(type);
+			},
+
+			isVideo(type) {
+				return config.videoIncludes(type);
+			},
+
+			// 格式化数组值
+			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) {
+				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(res.message || "未知错误");
+				}).catch(err => param.onError(err));
+			},
+
+			videoPlay(file) {
+				this.showVideoViewer = true;
+				this.previewVideoUrl = "/zcxt/folder/" + file.path;
+			}
+		}
+	}
+</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-actions .del {
+  background: #f56c6c;
+}
+.sc-upload__item-progress {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  background-color: var(--el-overlay-color-lighter);
+}
+</style>

+ 154 - 0
src/components/scUpload/uploadIndex.vue

@@ -0,0 +1,154 @@
+<template>
+	<el-main>
+		<el-card shadow="never" header="文件示例">
+			<sc-upload-file v-model="fileurl" :limit="3" :data="{otherData:'demo'}" tip="最多上传3个文件,单个文件不要超过10M,请上传xlsx/docx格式文件">
+				<el-button type="primary" icon="el-icon-upload">上传附件</el-button>
+			</sc-upload-file>
+		</el-card>
+
+		<el-card shadow="never" header="文件示例(值为对象数组,适合保存原始文件名)">
+			<sc-upload-file v-model="fileurlArr" :limit="3" tip="最多上传3个文件,单个文件不要超过10M,请上传xlsx/docx格式文件">
+				<el-button type="primary" icon="el-icon-upload">上传附件</el-button>
+			</sc-upload-file>
+		</el-card>
+
+		<el-card shadow="never" header="图片卡片示例(已开启拖拽排序)">
+			<sc-upload-multiple v-model="fileurl2" draggable :limit="3" tip="最多上传3个文件,单个文件不要超过10M,请上传图像格式文件"></sc-upload-multiple>
+		</el-card>
+
+		<el-card shadow="never" header="单图像示例">
+			<el-space wrap :size="8">
+				<sc-upload v-model="fileurl3"></sc-upload>
+				<sc-upload v-model="fileurl4" title="自定义标题" icon="el-icon-picture"></sc-upload>
+				<sc-upload v-model="fileurl5" :apiObj="uploadApi" accept="image/jpg,image/png" :on-success="success" :width="220">
+					<div class="custom-empty">
+						<el-icon><el-icon-upload /></el-icon>
+						<p>自定义插槽</p>
+					</div>
+				</sc-upload>
+				<sc-upload v-model="fileurl6" round icon="el-icon-avatar" title="开启圆形"></sc-upload>
+				<sc-upload v-model="fileurl7" title="开启剪裁" :cropper="true" :compress="1" :aspectRatio="1/1"></sc-upload>
+			</el-space>
+		</el-card>
+
+
+
+		<el-card shadow="never" header="在验证表单中使用">
+			<el-form ref="ruleForm" :model="form" :rules="rules" label-width="100px">
+				<el-form-item label="身份证" required>
+					<el-space wrap :size="8">
+						<el-form-item prop="file1">
+							<sc-upload v-model="form.file1" title="人像面"></sc-upload>
+						</el-form-item>
+						<el-form-item prop="file2">
+							<sc-upload v-model="form.file2" title="国徽面"></sc-upload>
+						</el-form-item>
+					</el-space>
+				</el-form-item>
+				<el-form-item label="其他凭证" prop="file3">
+					<sc-upload-multiple v-model="form.file3"></sc-upload-multiple>
+				</el-form-item>
+				<el-form-item label="附件" prop="file4">
+					<sc-upload-file v-model="form.file4" :limit="1" drag>
+						<el-icon class="el-icon--upload"><el-icon-upload-filled /></el-icon>
+						    <div class="el-upload__text">
+						      Drop file here or <em>click to upload</em>
+						    </div>
+					</sc-upload-file>
+				</el-form-item>
+				<el-form-item label="日期" prop="date">
+					<el-date-picker type="date" placeholder="选择日期" v-model="form.date"></el-date-picker>
+				</el-form-item>
+				<el-form-item>
+					<el-button type="primary" @click="submitForm">保存</el-button>
+				    <el-button @click="resetForm">重置</el-button>
+				</el-form-item>
+			</el-form>
+		</el-card>
+
+	</el-main>
+</template>
+
+<script>
+	export default {
+		name: 'upload',
+		data() {
+			return {
+				uploadApi: this.$API.common.upload,
+				fileurlArr: [
+					{
+						name: '销售合同模板.xlsx',
+						url: 'http://www.scuiadmin.com/files/220000198611262243.xlsx'
+					},
+					{
+						name: '企业员工联系方式.xlsx',
+						url: 'http://www.scuiadmin.com/files/350000201004261875.xlsx',
+					}
+				],
+				fileurl: "http://www.scuiadmin.com/files/220000198611262243.xlsx,http://www.scuiadmin.com/files/350000201004261875.xlsx",
+				fileurl2: "img/auth_banner.jpg,img/avatar3.gif",
+				fileurl3: "img/auth_banner.jpg",
+				fileurl4: "",
+				fileurl5: "",
+				fileurl6: "",
+				fileurl7: "",
+				form: {
+					file1: "",
+					file2: "",
+					file3: "",
+					file4: "",
+					date: ""
+				},
+				rules: {
+					file1: [
+						{required: true, message: '请上传', trigger: 'change'}
+					],
+					file2: [
+						{required: true, message: '请上传', trigger: 'change'}
+					],
+					file3: [
+						{required: true, message: '请上传', trigger: 'change'}
+					],
+					file4: [
+						{required: true, message: '请上传附件', trigger: 'change'}
+					],
+					date: [
+						{required: true, message: '请选择日期', trigger: 'change'}
+					]
+				}
+			}
+		},
+		methods: {
+			success(response){
+				this.$alert(`success函数钩子,可用于类似OCR返回信息,return false后阻止后续执行,回调参数打开控制台查看`, {
+					title: "提示",
+					type: "success"
+				})
+				console.log(response);
+				return false;
+			},
+			submitForm(){
+				this.$refs.ruleForm.validate((valid) => {
+					if (valid) {
+						alert('请看控制台输出');
+						console.log(this.form);
+					}else{
+						return false;
+					}
+				})
+			},
+			resetForm(){
+				this.$refs.ruleForm.resetFields();
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.el-card+.el-card {margin-top: 15px;}
+	.imglist .el-col+.el-col {margin-left: 8px;}
+
+	.custom-empty {width: 100%;height: 100%;display: flex;flex-direction: column;align-items: center;justify-content: center;background: #8c939d;border-radius:5px;}
+	.custom-empty i {font-size: 30px;color: #fff;}
+	.custom-empty p {font-size: 12px;font-weight: normal;color: #fff;margin-top: 10px;}
+</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="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>

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

@@ -0,0 +1,114 @@
+<!--
+ * @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',
+					...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) > * {
+  color: #fff;
+  font-size: 20px;
+  font-weight: bold;
+  text-shadow: 1px 1px 0 #000, -1px -1px 0 #000, -1px 1px 0 #000,
+    1px -1px 0 #000;
+}
+
+.sc-video__start-mock {
+  cursor: pointer;
+  width: 70px;
+  height: 70px;
+  position: absolute;
+  left: calc(50% - 35px);
+  top: calc(50% - 35px);
+  z-index: 120;
+}
+
+.sc-video:deep(.xgplayer-controls) {
+  background-image: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.3));
+}
+
+.sc-video:deep(.xgplayer-progress-tip) {
+  border: 0;
+  color: #fff;
+  background: rgba(0, 0, 0, 0.5);
+  line-height: 25px;
+  padding: 0 10px;
+  border-radius: 25px;
+}
+
+.sc-video:deep(.xgplayer-enter-spinner) {
+  width: 50px;
+  height: 50px;
+}
+</style>

+ 16 - 0
src/config/iconSelect.js

@@ -0,0 +1,16 @@
+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) }
+]
+
+function selectIcon(menuIcon) {
+    const iconKey = icons[XEUtils.findKey(icons, item => XEUtils.includes(item.icons, menuIcon))];
+    return iconKey || menuIcon;
+}
+
+// 图标选择器配置
+export default { icons, selectIcon }

+ 54 - 0
src/config/index.js

@@ -0,0 +1,54 @@
+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_API_BASEURL,
+
+	//请求超时
+	TIMEOUT: 30000,
+
+	//TokenName
+	TOKEN_NAME: "Authorization",
+
+	//Token前缀,注意最后有个空格,如不需要需设置空字符串
+	TOKEN_PREFIX: "Bearer ",
+
+	//追加其他头
+	HEADERS: {},
+
+	//请求是否开启缓存
+	REQUEST_CACHE: false,
+
+	//菜单是否折叠
+	MENU_IS_COLLAPSE: true,
+
+	//菜单是否启用手风琴效果
+	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

+ 35 - 0
src/config/select.js

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

+ 113 - 0
src/config/table.js

@@ -0,0 +1,113 @@
+// 数据表格配置
+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"],
+    
+    framework: {
+        common: {
+            queryData: function ({ formConfig: { data }, pagerConfig: { currentPage, pageSize } }, paramsColumns) {
+                const query = {
+                    current: currentPage,
+                    size: pageSize
+                }
+                XEUtils.arrayEach(XEUtils.filter(paramsColumns, item => !valueIsNull(data, item.field || item.column)), item => XEUtils.set(query, item.column, XEUtils.get(data, item.field || item.column)))
+
+                return XEUtils.omit(query, val => XEUtils.isEmpty(val) && !XEUtils.isNumber(val))
+            },
+            parseData: function (res) {
+                return {
+                    data: res.records,			    // 分析数据字段结构
+                    total: res.total	            // 分析总数字段结构
+                }
+            }
+        },
+
+        zeroLiteOld: {
+            queryData: function ({ formConfig: { data }, pagerConfig: { currentPage, pageSize } }, paramsColumns) {
+                const expands = {}
+                XEUtils.arrayEach(XEUtils.filter(paramsColumns, item => item.type == "expands" && !valueIsNull(data, item.field || item.column)), item => XEUtils.set(expands, item.field || item.column, data[item.field || item.column]))
+
+                const query = {
+                    limit: { length: pageSize, start: (currentPage - 1) * pageSize },
+                    columns: XEUtils.get(XEUtils.find(paramsColumns, item => item.type == "columns"), "field", [])
+                }
+                XEUtils.arrayEach(XEUtils.filter(paramsColumns, item => item.type == "limit"), item => XEUtils.set(query, `limit.${item.column}`, item.field))
+                if (XEUtils.filter(paramsColumns, item => item.type == "orderby").length) {
+                    XEUtils.set(query, "orderby", {
+                        columns: XEUtils.map(XEUtils.filter(paramsColumns, item => item.type == "orderby"), item => item.column),
+                        seq: XEUtils.get(XEUtils.first(XEUtils.filter(paramsColumns, item => item.type == "orderby")), "seq", "desc").toUpperCase()
+                    })
+                }
+
+                const relation = XEUtils.filter(
+                    XEUtils.map(XEUtils.filter(paramsColumns, item => item.type == "relation"), item => {
+                        if (item.defaultValue || !valueIsNull(data, item.field || item.column)) {
+                            const symbol = item.symbol.toUpperCase()
+                            const value = item.formatValue && item.formatValue(XEUtils.get(data, item.field || item.column)) || XEUtils.get(data, item.field || item.column) || item.defaultValue
+                            if (symbol == "OR") {
+                                if (value.length == 1) return { column: item.column, symbol: "EQ", value: XEUtils.first(value) }
+                                return { symbol, relation: value.map(val => ({ column: item.column, symbol: "EQ", value: val })) }
+                            }
+                            return { column: item.column, symbol, value }
+                        }
+                    }), item => XEUtils.isObject(item))
+                if (relation.length == 1) XEUtils.set(query, "condition", XEUtils.first(relation))
+                if (relation.length > 1) XEUtils.set(query, "condition", { symbol: "AND", relation })
+
+                return { querys: XEUtils.isEmpty(XEUtils.omit(query, item => XEUtils.isEmpty(item))) ? [] : [XEUtils.omit(query, item => XEUtils.isEmpty(item))], expands }
+            },
+            parseData: function (res) {
+                return {
+                    data: res.datas,			    // 分析数据字段结构
+                    total: XEUtils.has(res, "expands.total") ? res.expands.total : res.datas.length > 0 ? Infinity : undefined	    //分析总数字段结构
+                }
+            }
+        },
+
+        zeroLite: {
+            queryData: function ({ formConfig: { data }, pagerConfig: { currentPage, pageSize } }, paramsColumns) {
+                const expands = {}
+                XEUtils.arrayEach(XEUtils.filter(paramsColumns, item => item.type == "expands" && !valueIsNull(data, item.field || item.column)), item => XEUtils.set(expands, item.field || item.column, data[item.field || item.column]))
+
+                const query = {
+                    limit: { length: pageSize, start: (currentPage - 1) * pageSize },
+                    orderby: XEUtils.map(XEUtils.filter(paramsColumns, item => item.type == "orderby"), item => ({ column: item.column, seq: item.symbol.toUpperCase() })),
+                    columns: XEUtils.get(XEUtils.find(paramsColumns, item => item.type == "columns"), "field", [])
+                }
+
+                XEUtils.arrayEach(XEUtils.filter(paramsColumns, item => item.type == "limit"), item => XEUtils.set(query, `limit.${item.column}`, item.field))
+
+                const relation = XEUtils.filter(
+                    XEUtils.map(XEUtils.filter(paramsColumns, item => item.type == "relation"), item => {
+                        if (item.defaultValue || !valueIsNull(data, item.field || item.column)) {
+                            const symbol = item.symbol.toUpperCase()
+                            const value = item.formatValue && item.formatValue(XEUtils.get(data, item.field || item.column)) || XEUtils.get(data, item.field || item.column) || item.defaultValue
+                            if (symbol == "OR") {
+                                if (value.length == 1) return { column: item.column, symbol: "EQ", value: XEUtils.first(value) }
+                                return { symbol, relation: value.map(val => ({ column: item.column, symbol: "EQ", value: val })) }
+                            }
+                            return { column: item.column, symbol, value }
+                        }
+                    }), item => XEUtils.isObject(item))
+                if (relation.length == 1) XEUtils.set(query, "condition", XEUtils.first(relation))
+                if (relation.length > 1) XEUtils.set(query, "condition", { symbol: "AND", relation })
+
+                return { querys: XEUtils.isEmpty(XEUtils.omit(query, item => XEUtils.isEmpty(item))) ? [] : [XEUtils.omit(query, item => XEUtils.isEmpty(item))], expands }
+            },
+            parseData: function (res) {
+                return {
+                    data: res.datas,			    // 分析数据字段结构
+                    total: XEUtils.has(res, "expands.total") ? res.expands.total : res.datas.length > 0 ? Infinity : undefined	    //分析总数字段结构
+                }
+            }
+        }
+    }
+}
+
+function valueIsNull(obj, key) {
+    return XEUtils.isEmpty(XEUtils.get(obj, key)) && !XEUtils.isNumber(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值字段
+	}
+}

+ 10 - 0
src/config/upload.js

@@ -0,0 +1,10 @@
+//上传配置
+export default {
+	imageIncludes: function (type) {
+		return ["image/gif", "image/jpeg", "image/png"].includes(type);
+	},
+
+	videoIncludes: function (type) {
+		return ["video/mp4", "video/avi"].includes(type);
+	}
+}

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

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

@@ -0,0 +1,53 @@
+<template>
+	<template v-for="navMenu in navMenus" v-bind:key="navMenu">
+		<el-menu-item v-if="!menuChildren(navMenu).length" :index="navMenu.path">
+            <sc-iconify v-if="navMenu.meta?.icon" :icon="navMenu.meta.icon"></sc-iconify>
+			<template #title>
+                <div v-if="menuIsCollapse" class="menu-collapse-popper">
+                    <span>{{ navMenu.meta.title.split('-').reverse()[0] }}</span>
+				</div>
+                <scTooltip v-else :content="navMenu.meta.title.split('-').reverse()[0]"></scTooltip>
+			</template>
+		</el-menu-item>
+		<el-sub-menu v-else :index="navMenu.path">
+			<template #title>
+                <sc-iconify v-if="navMenu.meta?.icon" :icon="navMenu.meta.icon"></sc-iconify>
+                <scTooltip :content="navMenu.meta.title.split('-').reverse()[0]"></scTooltip>
+			</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>

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

@@ -0,0 +1,91 @@
+<template>
+    <el-dialog v-model="visible" title="修改密码" width="500px">
+        <el-form ref="dialogForm" :model="form" :rules="rules" label-width="120px">
+            <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 @click="cancel">取 消</el-button>
+            <el-button type="primary" :loading="isSaveing" @click="save">确 定</el-button>
+        </template>
+    </el-dialog>
+</template>
+
+<script setup>
+import API from "@/api";
+import TOOL from "@/utils/tool";
+import scPasswordStrength from "@/components/scPasswordStrength";
+
+const router = useRouter();
+
+const visible = ref(false);
+const isSaveing = 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) {
+                callback(new Error("两次输入密码不一致"));
+            } else {
+                callback();
+            }
+        }}
+    ]
+});
+const dialogForm = ref();
+const open = () => {
+    visible.value = true;
+}
+const save = () => {
+    dialogForm.value.validate(valid => {
+        if (valid) {
+            isSaveing.value = true;
+            API.auth.updatePassword.post(form.value).then(() => {
+                ElNotification.success({
+                    title: "提示",
+                    message: "密码修改成功,请重新登录",
+                    duration: 1500
+                });
+                
+                setTimeout(() => {
+                    isSaveing.value = false;
+                    TOOL.cookie.remove("TOKEN");
+                    TOOL.data.remove("USER_INFO");
+                    router.replace({ path: "/login" });
+                }, 1500);
+            }).catch(() => isSaveing.value = false);
+        } else {
+            return false
+        }
+    })
+}
+
+defineExpose({
+    open
+})
+</script>
+
+<style lang="scss" scoped>
+    .el-form {padding-right: 40px;}
+
+    @media (max-width: 992px) {
+        .el-form {padding-right: 0;}
+    }
+</style>

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

@@ -0,0 +1,78 @@
+<template>
+	<div class="sc-search">
+		<el-input ref="input" v-model="input" placeholder="搜索" size="large" clearable prefix-icon="el-icon-search" :trigger-on-focus="false" @input="inputChange" />
+		<div v-if="history.length" class="sc-search-history">
+			<el-tag closable effect="dark" type="info" v-for="(item, index) in history" :key="item" @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 v-if="item.meta?.icon" :icon="item.meta.icon"></sc-iconify>
+						<span class="title">{{ 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 {text-align: center;margin: 40px 0;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 {height:56px;padding:0 15px;background: var(--el-bg-color-overlay);border: 1px solid var(--el-border-color-light);list-style:none;border-radius: 4px;margin-bottom: 5px;font-size: 14px;display: flex;align-items: center;cursor: pointer;}
+	.sc-search-result li  i {font-size: 20px;margin-right: 15px;}
+	.sc-search-result li:hover {background: var(--el-color-primary);color: #fff;border-color: var(--el-color-primary);}
+</style>

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

@@ -0,0 +1,80 @@
+<template>
+	<el-form ref="form" label-width="120px" 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="$t('user.language')">
+			<el-select v-model="lang">
+				<el-option label="简体中文" value="zh-cn"></el-option>
+				<el-option label="English" value="en"></el-option>
+			</el-select>
+		</el-form-item>
+		<el-divider></el-divider>
+		<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 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 {
+            menuIsCollapse: this.$store.state.global.menuIsCollapse,
+            layoutTags: this.$store.state.global.layoutTags,
+            lang: this.$TOOL.data.get("APP_LANG") || this.$CONFIG.LANG,
+            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: {
+        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");
+            }
+        },
+        lang(val){
+            this.$i18n.locale = val
+            this.$TOOL.data.set("APP_LANG", val);
+        },
+        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>

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

@@ -0,0 +1,91 @@
+<template>
+	<div class="mobile-nav-button" @click="showMobileNav" v-drag draggable="false"><sc-iconify icon="ant-design:appstore-outlined"></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>
+				<div class="logo-bar"><img class="logo" src="img/logo.png"><span>{{ $CONFIG.APP_NAME }}</span></div>
+			</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.vue";
+
+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;width: 50px;height: 50px;background: var(--el-color-primary);box-shadow: 0 2px 12px 0 var(--el-color-primary);border-radius: 50%;display: flex;align-items: center;justify-content: center;}
+	.mobile-nav-button i {color: #fff;font-size: 20px;}
+
+	.mobile-nav .el-header {background: var(--el-color-primary);border: 0;}
+	.mobile-nav .el-main {padding:0;}
+	.mobile-nav .logo-bar {display: flex;align-items: center;font-weight: bold;font-size: 20px;color: #fff;}
+	.mobile-nav .logo-bar img {width: 30px;margin-right: 10px;}
+</style>

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

@@ -0,0 +1,250 @@
+<template>
+	<div class="aminui-tags">
+		<ul ref="tags">
+			<li v-for="tag in tagList" v-bind:key="tag" :class="[isActive(tag) && 'active', tag.meta.affix && 'affix']" @contextmenu.prevent="openContextMenu($event, tag)">
+                <router-link v-if="!tag.meta?.hidden" :to="tag">
+                    <sc-iconify v-if="tag.meta?.icon" style="margin-left: 0;margin-right: 5px;" :icon="tag.meta.icon" size="16"></sc-iconify>
+                    <scTooltip :content="tag.meta.title"></scTooltip>
+				    <el-icon v-if="!tag.meta.affix" @click.prevent.stop='closeSelectedTag(tag)'><el-icon-close /></el-icon>
+				</router-link>
+			</li>
+		</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 Sortable from "sortablejs"
+import XEUtils from "xe-utils";
+
+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>

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

@@ -0,0 +1,85 @@
+<template>
+	<div class="user-bar">
+        <div class="panel-item hidden-sm-and-down" @click="search">
+			<el-icon><el-icon-search /></el-icon>
+		</div>
+        <!-- <div class="panel-item hidden-sm-and-down" @click="task_show">任务中心</div> -->
+		<el-dropdown class="user panel-item" trigger="click" @command="handleUser">
+			<div class="user-avatar">
+				<el-avatar :size="30">{{ $TOOL.data.get("USER_INFO")?.nickName?.substring(0, 1).toLocaleUpperCase() }}</el-avatar>
+				<label>{{ $TOOL.data.get("USER_INFO")?.nickName }}</label>
+				<el-icon class="el-icon--right" color="#fff"><el-icon-arrow-down /></el-icon>
+			</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="updatePwd">修改密码</el-dropdown-item>
+					<el-dropdown-item divided command="outLogin">退出登录</el-dropdown-item>
+				</el-dropdown-menu>
+			</template>
+		</el-dropdown>
+	</div>
+
+	<el-dialog v-model="searchVisible" :width="700" title="搜索" center destroy-on-close>
+		<search @success="searchVisible = false"></search>
+	</el-dialog>
+
+    <password v-if="passwordVisible" ref="passwordDialog"></password>
+
+    <el-drawer title="布局实时演示" v-model="settingDialog" :size="400" append-to-body destroy-on-close>
+		<setting></setting>
+	</el-drawer>
+</template>
+
+<script>
+import search from "./search";
+import setting from "./setting";
+import password from "./password";
+
+export default {
+    components: { search, setting, password },
+    data() {
+        return {
+            searchVisible: false,
+            passwordVisible: false,
+            settingDialog: false,
+        }
+    },
+
+    methods: {
+        //个人信息
+        handleUser(command) {
+            if (command == "uc") this.$router.push({ path: "/usercenter" });
+            if (command == "layout") this.settingDialog = true;
+            if (command == "updatePwd") {
+                this.passwordVisible = true;
+                nextTick(() => this.$refs.passwordDialog.open());
+            }
+            if (command == "outLogin") {
+                this.$confirm("确认是否退出当前用户?", "温馨提示", {
+                    type: "warning",
+                    confirmButtonText: "退出"
+                }).then(() => {
+                    this.$TOOL.cookie.remove("TOKEN");
+                    this.$TOOL.data.remove("USER_INFO");
+                    this.$TOOL.data.remove("MENU");
+                    this.$router.replace({ path: "/login" });
+                }).catch(() => {})
+            }
+        },
+        // 搜索
+        search() {
+            this.searchVisible = true
+        }
+    }
+}
+</script>
+
+<style scoped>
+	.user-bar {display: flex;align-items: center;height: 100%;}
+	.user-bar .panel-item {padding: 0 10px;cursor: pointer;height: 100%;display: flex;align-items: center;}
+	.user-bar .panel-item i {font-size: 16px;}
+	.user-bar .user-avatar {height:49px;display: flex;align-items: center;}
+	.user-bar .user-avatar label {display: inline-block;margin-left:5px;font-size: 12px;cursor:pointer;}
+</style>

+ 118 - 0
src/layout/index.vue

@@ -0,0 +1,118 @@
+<template>
+	<!-- 通栏布局 -->
+    <header class="aminui-header">
+        <div class="aminui-header-left">
+            <div class="logo-bar">
+                <div v-if="!ismobile" class="nav-menu-icon">
+                    <el-popover ref="navMenuPopover" popper-class="nav-menu-popover" :width="664" trigger="click" teleported :disabled="!menus.length">
+                        <template #reference>
+                            <sc-iconify icon="ant-design:appstore-outlined" size="24"></sc-iconify>
+                        </template>
+                        <el-row class="nav">
+                            <el-col :class="currentMenu?.path == item.path && 'active'" v-for="item in menus" :key="item" :span="8" @click="showMenu(item)">
+                                <sc-iconify :icon="item.meta.icon" size="24"></sc-iconify>
+                                <div>{{ item.meta?.title }}</div>
+                            </el-col>
+                        </el-row>
+                    </el-popover>
+                </div>
+                <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 && currentMenu?.children?.length" :class="['aminui-side', menuIsCollapse && 'isCollapse']">
+            <div v-if="!menuIsCollapse" class="aminui-side-top">
+                <h2><scTooltip :content="currentMenu.meta.title"></scTooltip></h2>
+            </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.find(menu => menu.path == currentMenu.path).children"></NavMenu>
+                    </el-menu>
+                </el-scrollbar>
+            </div>
+            <div class="aminui-side-bottom" @click="$store.commit('TOGGLE_menuIsCollapse')">
+                <el-icon><el-icon-expand v-if="menuIsCollapse" /><el-icon-fold v-else /></el-icon>
+            </div>
+        </div>
+        <Side-m v-if="ismobile"></Side-m>
+        <div class="aminui-body el-container">
+            <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 :is="Component" :key="$route.fullPath" v-if="$store.state.keepAlive.routeShow"/>
+                    </keep-alive>
+                </router-view>
+            </div>
+        </div>
+    </section>
+</template>
+
+<script>
+import XEUtils from "xe-utils";
+
+import SideM from "./components/sideM";
+import Tags from "./components/tags";
+import NavMenu from "./components/NavMenu";
+import userbar from "./components/userbar";
+    
+export default {
+    name: "index",
+    components: {
+        SideM,
+        Tags,
+        NavMenu,
+        userbar
+    },
+    data() {
+        return {
+        }
+    },
+    computed:{
+        ismobile() {
+            return this.$store.state.global.ismobile
+        },
+        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.searchTree(this.menus, item => item.path === this.$route.path)[0] || {};
+        }
+    },
+    created() {
+        this.onLayoutResize();
+        window.addEventListener("resize", this.onLayoutResize);
+    },
+
+    methods: {
+        onLayoutResize() {
+            this.$store.commit("SET_ismobile", document.body.clientWidth < 992);
+        },
+
+        // 点击显示
+        showMenu(route) {
+            this.$router.push({ path: route.path });
+            this.$refs.navMenuPopover?.hide();
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+    .aminui-header .aminui-header-left {
+        .no-m-r {margin-right: 0;}
+        .m-l-20 {margin-left: 20px;}
+    }
+</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");

+ 374 - 0
src/mock/mock.js

@@ -0,0 +1,374 @@
+export const mockData = {
+	menus: [
+		{
+			name: "home",
+			path: "/home",
+			meta: { title: "首页", affix: true },
+			component: "home"
+		},
+		{
+			name: "userCenter",
+			path: "/usercenter",
+			meta: { title: "个人信息", hidden: true },
+			component: "userCenter"
+		},
+        {
+			name: "basic",
+			path: "/basic",
+			meta: { title: "基础数据管理", icon: "ri:apps-line" },
+            redirect: "/basic/supplier",
+			children: [
+				{
+                    name: "supplier",
+					path: "/basic/supplier",
+					meta: { title: "供应商管理", icon: "ant-design:phone-outlined" },
+					component: "basic/supplier"
+				},
+                {
+                    name: "customer",
+					path: "/basic/customer",
+					meta: { title: "客户管理", icon: "garden:customer-lists-fill-26" },
+					component: "basic/customer"
+				},
+                {
+                    name: "tag",
+					path: "/basic/tag",
+					meta: { title: "标签管理", icon: "mingcute:tag-line" },
+					component: "basic/tag"
+				}
+            ]
+		},
+        {
+			name: "facerec",
+			path: "/facerec",
+			meta: { title: "人脸识别", icon: "tabler:face-id" },
+            redirect: "/facerec/device",
+			children: [
+				{
+                    name: "facerecDevice",
+					path: "/facerec/device",
+					meta: { title: "人脸识别-全部设备", icon: "ant-design:code-sandbox-outlined" },
+					component: "facerec/device"
+				},
+                {
+                    name: "facerecPlatformTask",
+					path: "/facerec/platformTask",
+					meta: { title: "人脸识别-第三方平台下发任务", icon: "solar:list-arrow-down-bold" },
+					component: "facerec/platform/task"
+				},
+                {
+                    name: "facerecPlatformTaskPush",
+					path: "/facerec/platformTaskPush",
+					meta: { title: "人脸识别-第三方平台推送任务", icon: "cil:list-high-priority" },
+					component: "facerec/platform/push"
+				}
+            ]
+		},
+        {
+			name: "passqrcode",
+			path: "/passqrcode",
+			meta: { title: "临时访客", icon: "ant-design:qrcode-outlined" },
+            redirect: "/passqrcode/device",
+			children: [
+				{
+                    name: "passqrcodeDevice",
+					path: "/passqrcode/device",
+					meta: { title: "临时访客-全部设备", icon: "ant-design:code-sandbox-outlined" },
+					component: "passqrcode/device"
+				},
+                {
+                    name: "passqrcodePlatformTask",
+					path: "/passqrcode/platformTask",
+					meta: { title: "临时访客-青岛地铁卡号同步", icon: "mdi:calendar-sync-outline" },
+					component: "passqrcode/platform/task"
+				},
+                {
+                    name: "passqrcodePlatformTaskPush",
+					path: "/passqrcode/platformTaskPush",
+					meta: { title: "临时访客-第三方平台推送任务", icon: "cil:list-high-priority" },
+					component: "passqrcode/platform/push"
+				}
+            ]
+		},
+        {
+			name: "tower",
+			path: "/tower",
+			meta: { title: "塔基监测", icon: "mingcute:tower-crane-line" },
+            redirect: "/tower/device",
+			children: [
+				{
+                    name: "towerDevice",
+					path: "/tower/device",
+					meta: { title: "塔基监测-全部设备", icon: "ant-design:code-sandbox-outlined" },
+					component: "tower/device"
+				},
+                {
+                    name: "towerRecord",
+					path: "/tower/monitor",
+					meta: { title: "塔基监测-监测记录", icon: "ant-design:reconciliation-outlined" },
+					component: "tower/monitor"
+				},
+                {
+                    name: "towerWarning",
+					path: "/tower/warning",
+					meta: { title: "塔基监测-告警记录", icon: "fluent:text-bullet-list-square-warning-16-regular" },
+					component: "tower/warning"
+				}
+            ]
+		},
+        {
+			name: "env",
+			path: "/env",
+			meta: { title: "环境监测", icon: "fluent:earth-leaf-16-regular" },
+            redirect: "/env/device",
+			children: [
+				{
+                    name: "envDevice",
+					path: "/env/device",
+					meta: { title: "环境监测-全部设备", icon: "ant-design:code-sandbox-outlined" },
+					component: "env/device"
+				},
+                {
+                    name: "envRecord",
+					path: "/env/monitor",
+					meta: { title: "环境监测-监测记录", icon: "ant-design:reconciliation-outlined" },
+					component: "env/monitor"
+				}
+            ]
+		},
+        /* ***************************************************************** */ 
+        {
+			name: "dataMock",
+			path: "/dataMock",
+			meta: { title: "数据管理与模拟", icon: "majesticons:data-plus-line" },
+            redirect: "/dataMock/env",
+			children: [
+                {
+                    name: "envMock",
+                    path: "/dataMock/env",
+                    meta: { title: "数据管理与模拟-环境监测", icon: "fluent:earth-leaf-16-regular" },
+                    component: "dataMock/env"
+                },
+                {
+                    name: "standardMock",
+                    path: "/dataMock/standard",
+                    meta: { title: "数据管理与模拟-标养室监测", icon: "dashicons:dashboard" },
+                    component: "dataMock/standard"
+                },
+                {
+                    name: "carwashMock",
+                    path: "/dataMock/carwash",
+                    meta: { title: "数据管理与模拟-车辆冲洗", icon: "map:car-wash" },
+                    component: "dataMock/carwash"
+                },
+                {
+                    name: "ugliAiMock",
+                    path: "/dataMock/ugliAi",
+                    meta: { title: "数据管理与模拟-AI识别", icon: "hugeicons:ai-brain-02" },
+                    component: "dataMock/ugliAi"
+                }
+            ]
+		},
+        {
+			name: "EasyRun",
+			path: "/easyRun",
+			meta: { title: "EasyRun", icon: "fluent-emoji-high-contrast:hammer-and-wrench" },
+            redirect: "/easyRun/saleOrder",
+			children: [
+                {
+                    name: "saleOrder",
+                    path: "/easyRun/saleOrder",
+                    meta: { title: "销售订单", icon: "material-symbols:inactive-order-outline-sharp" },
+                    component: "easyRun/saleOrder"
+                },
+                {
+                    name: "purchaseOrder",
+                    path: "/easyRun/purchaseOrder",
+                    meta: { title: "采购订单", icon: "hugeicons:file-01" },
+                    component: "easyRun/purchaseOrder"
+                }
+            ]
+		},
+        {
+			name: "warranty",
+			path: "/warranty",
+			meta: { title: "质保管理", icon: "streamline-flex:warranty-badge-highlight-remix" },
+            redirect: "/warranty/warranty",
+			children: []
+		},
+        {
+			name: "afterSales",
+			path: "/afterSales",
+			meta: { title: "售后管理", icon: "icon-park-outline:market" },
+            redirect: "/afterSales/afterSales",
+			children: []
+		}
+	],
+
+    // 预警
+    warning: [
+        // 设备离线,
+        // 数据异常
+        {
+			name: "facerec",
+            meta: { title: "人脸识别", icon: "tabler:face-id" },
+			children: [
+				{
+					path: "/facerec/device",
+					meta: { title: "实名制设备" }
+				},
+                {
+					path: "/facerec/tower",
+					meta: { title: "塔机设备" }
+				},
+                {
+					path: "/facerec/elevator",
+					meta: { title: "升降机设备" }
+				}
+            ]
+        },
+        {
+			name: "towerDevice",
+            path: "/tower/device",
+            meta: { title: "塔机监测", icon: "mingcute:tower-crane-line" }
+        },
+        {
+			name: "elevatorDevice",
+            path: "/elevator/device",
+            meta: { title: "升降机监测", icon: "icon-park-outline:elevator" }
+        },
+        {
+			name: "envDevice",
+            path: "/env/device",
+            meta: { title: "环境监测", icon: "fluent:earth-leaf-16-regular" }
+        },
+        {
+			name: "standardDevice",
+            path: "/standard/device",
+            meta: { title: "标养室监测", icon: "dashicons:dashboard" }
+        },
+        {
+			name: "ugliAiDevice",
+            path: "/ugliAi/device",
+            meta: { title: "AI识别", icon: "hugeicons:ai-brain-02" }
+        },
+        {
+			name: "carwashDevice",
+            path: "/carwash/device",
+            meta: { title: "车辆冲洗", icon: "map:car-wash" }
+        },
+        {
+			name: "smokeDevice",
+            path: "/smoke/device",
+            meta: { title: "智能烟感", icon: "cbi:smoke-detector" }
+        },
+        {
+			name: "broadcastDevice",
+            path: "/broadcast/device",
+            meta: { title: "智能广播", icon: "ri:broadcast-fill" }
+        },
+    ],
+
+    // 待办
+    toDo: [
+        {
+			name: "saleOrder",
+            path: "/easyRun/saleOrder",
+            meta: { title: "待发布", icon: "Release" }
+        },
+        {
+			name: "broadcast",
+            path: "/easyRun/device",
+            meta: { title: "待盘货", icon: "Stock" }
+        },
+        {
+			name: "broadcast",
+            path: "/easyRun/device",
+            meta: { title: "待建计划", icon: "pajamas:todo-add" }
+        },
+        {
+			name: "broadcast",
+            path: "/easyRun/device",
+            meta: { title: "待实施", icon: "fluent:window-wrench-32-regular" }
+        },
+        {
+			name: "broadcast",
+            path: "/easyRun/device",
+            meta: { title: "待验收", icon: "bi:clipboard2-check" }
+        },
+        {
+			name: "broadcast",
+            path: "/easyRun/device",
+            meta: { title: "待付款", icon: "mingcute:refund-cny-line" }
+        },
+        {
+			name: "broadcast",
+            path: "/easyRun/device",
+            meta: { title: "待收款", icon: "fluent:payment-32-regular" },
+        },
+    ],
+
+    // 数据模拟
+    dataMock: [
+        {
+            name: "envMock",
+            path: "/dataMock/env",
+            meta: { title: "环境监测", icon: "fluent:earth-leaf-16-regular" },
+            tags: {
+                formData: {
+                    name: "",
+                    type: ""
+                },
+                list: [
+                    { name: "数量过多", type: "danger" },
+                    { name: "分布不均", type: "danger" }
+                ]
+            }
+        },
+        {
+            name: "standardMock",
+            path: "/dataMock/standard",
+            meta: { title: "标养室监测", icon: "dashicons:dashboard" },
+            tags: {
+                formData: {
+                    name: "",
+                    type: ""
+                },
+                list: [
+                    { name: "数量不足", type: "danger" },
+                    { name: "时间段缺失", type: "danger" }
+                ]
+            }
+        },
+        {
+            name: "carwashMock",
+            path: "/dataMock/carwash",
+            meta: { title: "车辆冲洗", icon: "map:car-wash" },
+            tags: {
+                formData: {
+                    name: "",
+                    type: ""
+                },
+                list: [
+                    { name: "数量过多", type: "danger" },
+                    { name: "分布不均", type: "danger" }
+                ]
+            }
+        },
+        {
+            name: "ugliAiMock",
+            path: "/dataMock/ugliAi",
+            meta: { title: "AI识别", icon: "hugeicons:ai-brain-02" },
+            tags: {
+                formData: {
+                    name: "",
+                    type: ""
+                },
+                list: [
+                    { name: "数量不足", type: "danger" },
+                    { name: "时间段缺失", type: "danger" }
+                ]
+            }
+        }
+    ]
+};

+ 165 - 0
src/router/index.js

@@ -0,0 +1,165 @@
+import XEUtils from "xe-utils";
+import { createRouter, createWebHashHistory } from "vue-router";
+import NProgress from "nprogress"
+import "nprogress/nprogress.css"
+import config from "@/config";
+import tool from "@/utils/tool";
+import api from "@/api";
+import { mockData } from "@/mock/mock";
+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"),
+}
+let routes_404_r = () => {}
+
+const router = createRouter({
+	history: createWebHashHistory(),
+	routes: routes
+})
+
+// 判断是否已加载过动态/静态路由
+let isGetRouter = false;
+// FIX 多个API同时401时疯狂弹窗BUG
+let interval = {
+    tower: null,
+    env: null
+}
+
+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("TOKEN");
+
+	if (to.path === "/login") {
+		// 删除路由(替换当前layout路由)
+		router.addRoute(routes[0])
+		// 删除路由(404)
+		routes_404_r()
+		isGetRouter = false;
+		if (token) next(from.fullPath);
+        else {
+            clearIntervals();
+            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]];
+    
+    // 所有闸口/安装点
+    const index = ["facerec", "passqrcode", "tower", "env", "carwash", "ugliAi"].findIndex(key => to.fullPath.includes(key));
+    if (to.name && index !== -1) router.getGates(["facerec", "passqrcode", "tower", "env", "carwash", "ugliAi"][index])
+    // 所有项目
+    if (!tool.data.get("PROJECT")) {
+        let projectRes = await api.system.project.get({ size: 99999 });
+        tool.data.set("PROJECT", projectRes.records);
+    }
+
+    // 加载动态/静态路由
+	if (!isGetRouter) {
+        tool.data.set("MENU", mockData.menus);
+        let menu = tool.data.get("MENU") || [];
+        XEUtils.arrayEach(XEUtils.toTreeArray(filterAsyncRouter(menu)), 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();
+    clearIntervals();
+	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 filterAsyncRouter(routerMap) {
+	return routerMap.map(item => {
+		// MAP转路由对象
+		return {
+			path: item.path,
+			name: item.name,
+			meta: item.meta || {},
+			redirect: item.redirect,
+			children: item.children ? filterAsyncRouter(item.children) : null,
+			component: loadComponent(item.component)
+		}
+	})
+}
+function loadComponent(component) {
+	if (component) {
+		return () => import(/* webpackChunkName: "[request]" */ `@/views/${component}`)
+	} else {
+		return () => import(`@/layout/other/empty`)
+	}
+}
+
+router.getGates = async (storagePath, start = 0, gates = []) => {
+    const path = storagePath == "carwash" ? "ugliAi" : storagePath;
+
+    if (!tool.data.get(`${path.toUpperCase()}_GATE`) || !tool.data.get(`${path.toUpperCase()}_GATE`).length) {
+        const res = await fetchGates(path, start);
+        gates = gates.concat(XEUtils.get(res, "datas", []));
+        if (XEUtils.get(res, "expands.total", 0) > gates.length) router.getGates(path, start + 100, gates);
+        else {
+            if (["tower", "env"].includes(path) && !interval[path]) {
+                interval[path] = setInterval(() => router.getGates(path), 300 * 1000);
+            }
+            tool.data.set(`${path.toUpperCase()}_GATE`, gates, ["tower", "env"].includes(path) && 300 || 0);
+        }
+    }
+}
+
+function fetchGates(path, start) {
+    return XEUtils.get(api, `${path}.gate`).get({ querys: [{ limit: { start, length: 100 } }], expands: { options: ["ground", "devices"] } });
+}
+ 
+function clearIntervals() {
+    if (interval.tower) {
+        clearInterval(interval.tower)
+        interval.tower = null;
+    }
+    if (interval.env) {
+        clearInterval(interval.env)
+        interval.env = null;
+    }
+}
+
+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
+	})
+}

+ 21 - 0
src/router/systemRouter.js

@@ -0,0 +1,21 @@
+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
+});

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

@@ -0,0 +1,28 @@
+import config from "@/config";
+
+export default {
+	state: {
+		// 移动端布局
+		ismobile: false,
+		//菜单是否折叠 toggle
+		menuIsCollapse: config.MENU_IS_COLLAPSE,
+		// 多标签栏
+		layoutTags: config.LAYOUT_TAGS,
+		// 主题
+		theme: config.THEME
+	},
+	mutations: {
+		SET_ismobile(state, key) {
+			state.ismobile = key
+		},
+		SET_theme(state, key) {
+			state.theme = key
+		},
+		TOGGLE_menuIsCollapse(state) {
+			state.menuIsCollapse = !state.menuIsCollapse
+		},
+		TOGGLE_layoutTags(state) {
+			state.layoutTags = !state.layoutTags
+		}
+	}
+}

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


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä