zhuangyunsheng 2 týždňov pred
commit
74975d22ff
79 zmenil súbory, kde vykonal 5199 pridanie a 0 odobranie
  1. 12 0
      .editorconfig
  2. 12 0
      .env.development
  3. 8 0
      .env.production
  4. 1 0
      .eslintignore
  5. 57 0
      .gitignore
  6. 21 0
      LICENSE
  7. 0 0
      README.md
  8. 5 0
      babel.config.js
  9. 17 0
      jsconfig.json
  10. 62 0
      package.json
  11. 11 0
      public/config.js
  12. BIN
      public/img/404.png
  13. BIN
      public/img/background.jpg
  14. BIN
      public/img/icon.png
  15. 78 0
      public/index.html
  16. 62 0
      src/App.vue
  17. 11 0
      src/api/index.js
  18. 12 0
      src/api/model/attendance.js
  19. 44 0
      src/api/model/camera.js
  20. 28 0
      src/api/model/nvr.js
  21. 12 0
      src/api/model/system.js
  22. 3 0
      src/assets/icons/Channel.vue
  23. 3 0
      src/assets/icons/Configure.vue
  24. 3 0
      src/assets/icons/Nvr.vue
  25. 3 0
      src/assets/icons/Speaker.vue
  26. 12 0
      src/assets/icons/index.js
  27. 100 0
      src/components/scContextmenu/index.vue
  28. 87 0
      src/components/scContextmenu/item.vue
  29. 159 0
      src/components/scDialog/index.vue
  30. 184 0
      src/components/scIconSelect/index.vue
  31. 28 0
      src/components/scModal/index.vue
  32. 172 0
      src/components/scTable/columnSetting.vue
  33. 387 0
      src/components/scTable/index.vue
  34. 43 0
      src/components/scTitle/index.vue
  35. 40 0
      src/components/scTooltip/index.vue
  36. 30 0
      src/components/scUpload/imageViewer.vue
  37. 23 0
      src/config/iconSelect.js
  38. 43 0
      src/config/index.js
  39. 37 0
      src/config/route.js
  40. 85 0
      src/config/table.js
  41. 45 0
      src/directives/time.js
  42. 43 0
      src/layout/components/NavMenu.vue
  43. 157 0
      src/layout/components/sideM.vue
  44. 273 0
      src/layout/components/tags.vue
  45. 103 0
      src/layout/components/topbar.vue
  46. 112 0
      src/layout/index.vue
  47. 73 0
      src/layout/other/404.vue
  48. 3 0
      src/layout/other/empty.vue
  49. 27 0
      src/locales/index.js
  50. 12 0
      src/locales/lang/en.js
  51. 12 0
      src/locales/lang/zh-cn.js
  52. 20 0
      src/main.js
  53. 103 0
      src/router/index.js
  54. 22 0
      src/router/scrollBehavior.js
  55. 14 0
      src/router/systemRouter.js
  56. 51 0
      src/scui.js
  57. 15 0
      src/store/index.js
  58. 11 0
      src/store/modules/global.js
  59. 34 0
      src/store/modules/keepAlive.js
  60. 46 0
      src/store/modules/viewTags.js
  61. 99 0
      src/style/app.scss
  62. 144 0
      src/style/fix.scss
  63. 37 0
      src/style/gradientBtn.scss
  64. 63 0
      src/style/media.scss
  65. 4 0
      src/style/style.scss
  66. 29 0
      src/utils/color.js
  67. 34 0
      src/utils/debounce.js
  68. 33 0
      src/utils/errorHandler.js
  69. 187 0
      src/utils/request.js
  70. 100 0
      src/utils/tool.js
  71. 156 0
      src/views/attendance/index.vue
  72. 120 0
      src/views/channel/channel.vue
  73. 160 0
      src/views/channel/index.vue
  74. 156 0
      src/views/config/canvas.vue
  75. 415 0
      src/views/config/index.vue
  76. 52 0
      src/views/config/main.js
  77. 134 0
      src/views/nvr/index.vue
  78. 134 0
      src/views/speaker/index.vue
  79. 71 0
      vue.config.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

+ 12 - 0
.env.development

@@ -0,0 +1,12 @@
+# 本地环境
+NODE_ENV = development
+
+# 标题
+VUE_APP_TITLE = EasyDo_AiBox
+
+# 接口地址
+VUE_APP_API_BASEURL = http://192.168.101.242/
+VUE_APP_IMAGE_BASEURL = http://192.168.101.242/
+
+# 本地端口
+VUE_APP_PORT = 6060

+ 8 - 0
.env.production

@@ -0,0 +1,8 @@
+# 生产环境
+NODE_ENV = production
+
+# 标题
+VUE_APP_TITLE = EasyDo_AiBox
+
+# 接口地址
+VUE_APP_API_BASEURL = 

+ 1 - 0
.eslintignore

@@ -0,0 +1 @@
+src/assets/js

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

+ 0 - 0
README.md


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

+ 62 - 0
package.json

@@ -0,0 +1,62 @@
+{
+    "name": "easydo-ugli-ai",
+    "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",
+        "axios": "1.3.4",
+        "element-plus": "2.6.0",
+        "moment": "^2.29.4",
+        "nprogress": "0.2.0",
+        "sortablejs": "1.15.0",
+        "vue": "3.2.47",
+        "vue-i18n": "9.2.2",
+        "vue-router": "4.1.6",
+        "vuex": "4.1.0"
+    },
+    "devDependencies": {
+        "@babel/core": "^7.24.4",
+        "@babel/eslint-parser": "7.19.1",
+        "@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"
+    },
+    "eslintConfig": {
+        "root": true,
+        "env": {
+            "node": true
+        },
+        "globals": {
+            "APP_CONFIG": true
+        },
+        "extends": [
+            "plugin:vue/vue3-essential",
+            "eslint:recommended"
+        ],
+        "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: "",
+
+	//接口地址,如遇跨域需使用nginx代理
+	API_URL: location.origin
+}

BIN
public/img/404.png


BIN
public/img/background.jpg


BIN
public/img/icon.png


+ 78 - 0
public/index.html

@@ -0,0 +1,78 @@
+<!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 %>img/icon.png">
+		<title><%= VUE_APP_TITLE %></title>
+		<script type="text/javascript">
+			document.write("<script src='config.js?"+new Date().getTime()+"'><\/script>");
+		</script>
+	</head>
+	<body data-layout="default">
+		<noscript>
+			<strong>We're sorry but <%= VUE_APP_TITLE %> doesn't work properly without JavaScript
+				enabled. Please enable it to continue.</strong>
+		</noscript>
+		<div id="app" class="aminui">
+		</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>

+ 62 - 0
src/App.vue

@@ -0,0 +1,62 @@
+<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() {
+			// 设置主题颜色
+			document.documentElement.style.setProperty('--el-color-primary', this.$CONFIG.COLOR);
+			for (let i = 1; i <= 9; i++) {
+				document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, colorTool.lighten(this.$CONFIG.COLOR, i / 10));
+			}
+			for (let i = 1; i <= 9; i++) {
+				document.documentElement.style.setProperty(`--el-color-primary-dark-${i}`, colorTool.darken(this.$CONFIG.COLOR, i / 10));
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+@import "@/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

+ 12 - 0
src/api/model/attendance.js

@@ -0,0 +1,12 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+	record: {
+		url: `${config.API_URL}/api/getPersonRecord`,
+		name: "考勤列表",
+		get: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	}
+}

+ 44 - 0
src/api/model/camera.js

@@ -0,0 +1,44 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+	list: {
+		url: `${config.API_URL}/api/getCameraList`,
+		name: "摄像头列表",
+		get: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	},
+
+	state: {
+		url: `${config.API_URL}/api/video_line`,
+		name: "摄像头在线状态",
+		get: async function (data = {}) {
+			return await http.get(this.url, data);
+		}
+	},
+
+	add: {
+		url: `${config.API_URL}/api/saveCamera`,
+		name: "摄像头列表",
+		post: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	},
+
+	edit: {
+		url: `${config.API_URL}/api/updateCamera`,
+		name: "摄像头列表",
+		post: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	},
+
+	del: {
+		url: `${config.API_URL}/api/popCamera`,
+		name: "摄像头列表",
+		post: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	}
+}

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

@@ -0,0 +1,28 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+	list: {
+		url: `${config.API_URL}/api/getNvr`,
+		name: "nvr配置",
+		get: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	},
+
+	edit: {
+		url: `${config.API_URL}/api/updateNvr`,
+		name: "nvr配置",
+		post: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	},
+
+	del: {
+		url: `${config.API_URL}/api/popNvr`,
+		name: "nvr配置",
+		post: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	}
+}

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

@@ -0,0 +1,12 @@
+import config from "@/config"
+import http from "@/utils/request"
+
+export default {
+	config: {
+		url: `${config.API_URL}/api/getConfig`,
+		name: "是否启用NVR",
+		post: async function (data = {}) {
+			return await http.post(this.url, data);
+		}
+	}
+}

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


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


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


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 3 - 0
src/assets/icons/Speaker.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

+ 100 - 0
src/components/scContextmenu/index.vue

@@ -0,0 +1,100 @@
+<!--
+ * @Descripttion: scContextmenu组件
+ * @version: 1.1
+ * @Author: sakuya
+ * @Date: 2021年7月23日09:25:57
+ * @LastEditors: sakuya
+ * @LastEditTime: 2022年5月30日20:17:42
+ * @other: 代码完全开源,欢迎参考,也欢迎PR
+-->
+
+<template>
+	<transition name="el-zoom-in-top">
+		<div v-if="visible" ref="contextmenu" class="sc-contextmenu" :style="{left:left+'px',top:top+'px'}" @contextmenu.prevent="fun">
+			<ul class="sc-contextmenu__menu">
+				<slot></slot>
+			</ul>
+		</div>
+	</transition>
+</template>
+
+<script>
+	export default {
+		provide() {
+			return {
+				menuClick: this.menuClick
+			}
+		},
+		data() {
+			return {
+				visible: false,
+				top: 0,
+				left: 0
+			}
+		},
+		watch: {
+			visible(value) {
+				if (value) {
+					document.body.addEventListener('click', this.cm, true)
+				}else{
+					document.body.removeEventListener('click', this.cm, true)
+				}
+			}
+		},
+		mounted() {
+
+		},
+		methods: {
+			cm(e){
+				let sp = this.$refs.contextmenu
+				if(sp&&!sp.contains(e.target)){
+					this.closeMenu()
+				}
+			},
+			menuClick(command){
+				this.closeMenu()
+				this.$emit('command', command)
+			},
+			openMenu(e) {
+				e.preventDefault()
+				this.visible = true
+				this.left = e.clientX + 1
+				this.top = e.clientY + 1
+
+				this.$nextTick(() => {
+					var ex = e.clientX + 1
+					var ey = e.clientY + 1
+					var innerWidth = window.innerWidth
+					var innerHeight = window.innerHeight
+					var menuHeight = this.$refs.contextmenu.offsetHeight
+					var menuWidth = this.$refs.contextmenu.offsetWidth
+					//位置修正公示
+					//left = (当前点击X + 菜单宽度 > 可视区域宽度 ? 可视区域宽度 - 菜单宽度 : 当前点击X)
+					//top = (当前点击Y + 菜单高度 > 可视区域高度 ? 当前点击Y - 菜单高度 : 当前点击Y)
+					this.left = ex + menuWidth > innerWidth ? innerWidth - menuWidth : ex
+					this.top = ey + menuHeight > innerHeight ? ey - menuHeight : ey
+				})
+				this.$emit('visibleChange', true)
+			},
+			closeMenu() {
+				this.visible = false;
+				this.$emit('visibleChange', false)
+			},
+			fun(){
+				return false;
+			}
+		}
+	}
+</script>
+
+<style>
+	.sc-contextmenu {position: fixed;z-index: 3000;font-size: 12px;}
+	.sc-contextmenu__menu {display: inline-block;min-width: 120px;border: 1px solid #e4e7ed;background: #fff;box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);z-index: 3000;list-style-type: none;padding: 10px 0;}
+	.sc-contextmenu__menu > hr {margin:5px 0;border: none;height: 1px;font-size: 0px;background-color: #ebeef5;}
+	.sc-contextmenu__menu > li {margin:0;cursor: pointer;line-height: 30px;padding: 0 17px 0 10px;color: #606266;display: flex;justify-content: space-between;white-space: nowrap;text-decoration: none;position: relative;}
+	.sc-contextmenu__menu > li:hover {background-color: #ecf5ff;color: #66b1ff;}
+	.sc-contextmenu__menu > li.disabled {cursor: not-allowed;color: #bbb;background: transparent;}
+	.sc-contextmenu__icon {display: inline-block;width: 14px;font-size: 14px;margin-right: 10px;}
+	.sc-contextmenu__suffix {margin-left: 40px;color: #999;}
+	.sc-contextmenu__menu li ul {position: absolute;top:0px;left:100%;display: none;margin: -11px 0;}
+</style>

+ 87 - 0
src/components/scContextmenu/item.vue

@@ -0,0 +1,87 @@
+<!--
+ * @Descripttion: scContextmenuItem组件
+ * @version: 1.3
+ * @Author: sakuya
+ * @Date: 2021年7月23日16:29:36
+ * @LastEditors: sakuya
+ * @LastEditTime: 2022年11月23日10:09:54
+-->
+
+<template>
+	<hr v-if="divided">
+	<li :class="disabled?'disabled':''" @click.stop="liClick" @mouseenter="openSubmenu($event)" @mouseleave="closeSubmenu($event)">
+		<span class="title">
+			<el-icon class="sc-contextmenu__icon"><component v-if="icon" :is="icon" /></el-icon>
+			{{title}}
+		</span>
+		<span class="sc-contextmenu__suffix">
+			<el-icon v-if="$slots.default"><el-icon-arrow-right /></el-icon>
+			<template v-else>{{suffix}}</template>
+		</span>
+		<ul v-if="$slots.default" class="sc-contextmenu__menu">
+			<slot></slot>
+		</ul>
+	</li>
+</template>
+
+<script>
+	export default {
+		props: {
+			command: { type: String, default: "" },
+			title: { type: String, default: "" },
+			suffix: { type: String, default: "" },
+			icon: { type: String, default: "" },
+			divided: { type: Boolean, default: false },
+			disabled: { type: Boolean, default: false },
+		},
+		inject: ['menuClick'],
+		methods: {
+			liClick(){
+				if(this.$slots.default){
+					return false
+				}
+				if(this.disabled){
+					return false
+				}
+				this.menuClick(this.command)
+			},
+			openSubmenu(e){
+				var menu = e.target.querySelector('ul')
+				if(!menu){
+					return false
+				}
+				if(this.disabled){
+					return false
+				}
+				menu.style.display = 'inline-block'
+				var rect = menu.getBoundingClientRect()
+				var menuX = rect.left
+				var menuY = rect.top
+				var innerWidth = window.innerWidth
+				var innerHeight = window.innerHeight
+				var menuHeight = menu.offsetHeight
+				var menuWidth = menu.offsetWidth
+				if(menuX + menuWidth > innerWidth){
+					menu.style.left = 'auto'
+					menu.style.right = '100%'
+				}
+				if(menuY + menuHeight > innerHeight){
+					menu.style.top = 'auto'
+					menu.style.bottom = '0'
+				}
+			},
+			closeSubmenu(e){
+				var menu = e.target.querySelector('ul')
+				if(!menu){
+					return false
+				}
+				menu.removeAttribute("style")
+				menu.style.display = 'none'
+			}
+		}
+	}
+</script>
+
+<style>
+
+</style>

+ 159 - 0
src/components/scDialog/index.vue

@@ -0,0 +1,159 @@
+<!--
+ * @Descripttion: 弹窗扩展组件
+ * @version: 2.0
+ * @Author: sakuya
+ * @Date: 2021年8月27日08:51:52
+ * @LastEditors: sakuya
+ * @LastEditTime: 2022年5月14日15:13:41
+-->
+
+<template>
+	<div class="sc-dialog" ref="scDialog">
+		<el-dialog ref="dialog" v-model="dialogVisible" :fullscreen="isFullscreen" v-bind="$attrs" :show-close="false" :before-close="closeDialog" :close-on-click-modal="false">
+			<template #header>
+				<slot name="header">
+					<span class="el-dialog__title">{{ title }}</span>
+				</slot>
+				<div class="sc-dialog__headerbtn">
+					<button v-if="showFullscreen" aria-label="fullscreen" type="button" @click="setFullscreen">
+						<el-icon v-if="isFullscreen" class="el-dialog__close"><el-icon-bottom-left /></el-icon>
+						<el-icon v-else class="el-dialog__close"><el-icon-full-screen /></el-icon>
+					</button>
+					<button v-if="showClose" aria-label="close" type="button" @click="closeDialog">
+						<el-icon class="el-dialog__close"><el-icon-close /></el-icon>
+					</button>
+				</div>
+			</template>
+			<div v-loading="loading">
+				<slot></slot>
+			</div>
+			<template #footer>
+				<slot name="footer"></slot>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+	export default {
+		props: {
+			modelValue: { type: Boolean, default: false },
+			title: { type: String, default: "" },
+			showClose: { type: Boolean, default: true },
+			showFullscreen: { type: Boolean, default: true },
+      fullscreen: { type: Boolean, default: false },
+			loading: { type: Boolean, default: false }
+		},
+		data() {
+			return {
+				dialogVisible: false,
+				isFullscreen: false
+			}
+		},
+		watch: {
+			modelValue(val) {
+				this.dialogVisible = val
+				if (val) this.isFullscreen = false
+        if (this.showFullscreen && this.fullscreen) this.isFullscreen = this.fullscreen
+			}
+		},
+
+		mounted() {
+			this.dialogVisible = this.modelValue
+		},
+
+		methods: {
+			// 关闭
+			closeDialog() {
+        this.dialogVisible = false
+			},
+
+			//最大化
+			setFullscreen() {
+				this.isFullscreen = !this.isFullscreen
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+.sc-dialog:deep(.el-dialog) {
+  .el-dialog__header {
+    position: relative;
+    margin-right: 0;
+
+    &::after {
+      content: "";
+      position: absolute;
+      left: 20px;
+      bottom: -10px;
+      width: calc(100% - 40px);
+      height: 1px;
+      background: var(--el-border-color-light);
+    }
+
+    .el-dialog__title {
+      position: relative;
+      padding-left: 10px;
+      font-weight: normal;
+
+      &::before {
+        content: "";
+        position: absolute;
+        left: 0;
+        top: 2px;
+        width: 3px;
+        height: calc(100% - 4px);
+        background: blue;
+      }
+    }
+
+    .sc-dialog__headerbtn {
+      position: absolute;
+      top: var(--el-dialog-padding-primary);
+      right: var(--el-dialog-padding-primary);
+
+      button {
+        padding: 0;
+        background: transparent;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        font-size: var(--el-message-close-size, 16px);
+        margin-left: 15px;
+        color: var(--el-color-info);
+
+        &:hover .el-dialog__close {
+          color: var(--el-color-primary);
+        }
+      }
+    }
+  }
+
+  .el-dialog__body {
+    padding: calc(var(--el-dialog-padding-primary) + 10px)
+      var(--el-dialog-padding-primary);
+  }
+
+  .el-dialog__footer button {
+    font-weight: normal;
+  }
+}
+
+.sc-dialog:deep(.el-dialog).is-fullscreen {
+  display: flex;
+  flex-direction: column;
+  top: 0px !important;
+  left: 0px !important;
+
+  .el-dialog__body {
+    flex: 1;
+    overflow: auto;
+  }
+
+  .el-dialog__footer {
+    padding-bottom: 10px;
+    border-top: 1px solid var(--el-border-color-base);
+  }
+}
+</style>

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

@@ -0,0 +1,184 @@
+<!--
+ * @Descripttion: 图标选择器组件
+ * @version: 2.0
+ * @Author: sakuya
+ * @Date: 2021年7月27日10:02:46
+ * @LastEditors: sakuya
+ * @LastEditTime: 2022年6月6日13:48:49
+-->
+
+<template>
+	<div class="sc-icon-select">
+		<div class="sc-icon-select__wrapper" :class="{ 'hasValue': value }" @click="open">
+			<el-input :prefix-icon="value||'el-icon-plus'" v-model="value" :disabled="disabled" readonly></el-input>
+		</div>
+		<el-dialog title="图标选择器" v-model="dialogVisible" :width="760" destroy-on-close append-to-body>
+			<div class="sc-icon-select__dialog" style="margin: -20px 0 -10px 0;">
+				<el-form :rules="{}">
+					<el-form-item prop="searchText">
+						<el-input class="sc-icon-select__search-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, index) in data" :key="item.name" lazy :name="item.name">
+						<template #label>
+							{{ item.name }} <el-tag size="small" type="info">{{ total[index] }}</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>
+										<el-icon><component :is="icon" /></el-icon>
+									</li>
+								</ul>
+							</el-scrollbar>
+						</div>
+					</el-tab-pane>
+				</el-tabs>
+			</div>
+			<template #footer>
+				<el-button @click="clear" text>清除</el-button>
+				<el-button @click="dialogVisible=false">取消</el-button>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+	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)),
+				total: [],
+				value: "",
+				dialogVisible: false,
+				searchText: ""
+			}
+		},
+		watch: {
+			modelValue(val) {
+				this.value = val
+			},
+			value(val) {
+				this.$emit('update:modelValue', val) 
+			},
+			searchText(val) {
+				this.search(val)
+			},
+			data(value) {
+				this.total = value.map(val => val.icons.length)
+			}
+		},
+		mounted() {
+			this.value = this.modelValue
+			this.total = this.data.map(d => d.icons.length)
+			this.activeName = this.modelValue && this.modelValue.includes('sc-icon') ? '扩展' : '默认'
+		    this.searchText = this.modelValue ? this.modelValue.split('-')[this.modelValue.split('-').length - 1] : ''
+		},
+		methods: {
+			open() {
+				if (this.disabled) return false
+				this.dialogVisible = true
+			},
+			selectIcon(e) {
+				if (e.target.tagName != 'SPAN') return false
+				this.value = e.target.dataset.icon
+				this.dialogVisible = false
+			},
+			clear() {
+				this.value = ""
+				this.dialogVisible = false
+			},
+			search(text) {
+				let filterData = JSON.parse(JSON.stringify(config.icons))
+				if (text) {
+					filterData.forEach(t => {
+						t.icons = t.icons.filter(n => n.toLowerCase().replaceAll('-', '').includes(text.toLowerCase()))
+					})
+				}
+				
+				this.data = filterData
+			}
+		}
+	}
+</script>
+
+<style scoped>
+.sc-icon-select {
+  display: inline-flex;
+}
+.sc-icon-select__wrapper {
+  cursor: pointer;
+  display: inline-flex;
+}
+.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:deep(.el-input__icon) {
+  margin: 0;
+  font-size: 16px;
+}
+.sc-icon-select__wrapper.hasValue:deep(.el-input__icon) {
+  color: var(--el-text-color-regular);
+}
+
+.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>

+ 28 - 0
src/components/scModal/index.vue

@@ -0,0 +1,28 @@
+<template>
+	<div :style="{ zIndex }" class="sc-modal"></div>
+</template>
+
+<script>
+	export default {
+		props: {
+			zIndex: { type: [String, Number], default: "var(--el-index-normal)" }
+		},
+
+		data() {
+			return {}
+		}
+	}
+</script>
+
+<style scoped>
+.sc-modal {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  height: 100%;
+  background-color: transparent;
+  overflow: auto;
+}
+</style>

+ 172 - 0
src/components/scTable/columnSetting.vue

@@ -0,0 +1,172 @@
+<template>
+	<div v-if="usercolumn.length>0" class="setting-column" v-loading="isSave">
+		<div class="setting-column__title">
+			<span class="move_b"></span>
+			<span class="show_b">显示</span>
+			<span class="name_b">名称</span>
+			<span class="width_b">宽度</span>
+			<span class="sortable_b">排序</span>
+			<span class="fixed_b">固定</span>
+		</div>
+		<div class="setting-column__list" ref="list">
+			<ul>
+				<li v-for="item in usercolumn" :key="item.prop">
+					<span class="move_b">
+						<el-tag class="move" style="cursor: move;"><el-icon-d-caret style="width: 1em; height: 1em;"/></el-tag>
+					</span>
+					<span class="show_b">
+						<el-switch v-model="item.hide" :active-value="false" :inactive-value="true"></el-switch>
+					</span>
+					<span class="name_b overflow-ellipsis" :title="item.prop">{{ item.label }}</span>
+					<span class="width_b">
+						<el-input v-model="item.width" placeholder="auto" size="small"></el-input>
+					</span>
+					<span class="sortable_b">
+						<el-switch v-model="item.sortable"></el-switch>
+					</span>
+					<span class="fixed_b">
+						<el-switch v-model="item.fixed"></el-switch>
+					</span>
+				</li>
+			</ul>
+		</div>
+		<div class="setting-column__bottom">
+			<el-button class="sc-button-default" :disabled="isSave" @click="backDefaul">重置</el-button>
+			<el-button class="sc-button-primary" @click="save">保存</el-button>
+		</div>
+	</div>
+	<el-empty v-else description="暂无可配置的列" :image-size="80"></el-empty>
+</template>
+
+<script>
+	import Sortable from 'sortablejs'
+
+	export default {
+		components: {
+			Sortable
+		},
+		props: {
+			column: { type: Object, default: () => {} }
+		},
+		data() {
+			return {
+				isSave: false,
+				usercolumn: JSON.parse(JSON.stringify(this.column||[]))
+			}
+		},
+		watch:{
+			usercolumn: {
+				handler(){
+					this.$emit('userChange', this.usercolumn)
+				},
+				deep: true
+			}
+		},
+		mounted() {
+			this.usercolumn.length>0 && this.rowDrop()
+		},
+		methods: {
+			rowDrop(){
+				const _this = this
+				const tbody = this.$refs.list.querySelector('ul')
+				Sortable.create(tbody, {
+					handle: ".move",
+					animation: 300,
+					ghostClass: "ghost",
+					onEnd({ newIndex, oldIndex }) {
+						const tableData = _this.usercolumn
+						const currRow = tableData.splice(oldIndex, 1)[0]
+						tableData.splice(newIndex, 0, currRow)
+					}
+				})
+			},
+			backDefaul(){
+				this.$emit('back', this.usercolumn)
+			},
+			save(){
+				this.$emit('save', this.usercolumn)
+			}
+		}
+	}
+</script>
+
+<style scoped>
+.setting-column {
+}
+
+.setting-column__title {
+  border-bottom: 1px solid #ebeef5;
+  padding-bottom: 15px;
+}
+.setting-column__title span {
+  display: inline-block;
+  font-weight: bold;
+  color: #909399;
+  font-size: 12px;
+}
+.setting-column__title span.move_b {
+  width: 30px;
+  margin-right: 15px;
+}
+.setting-column__title span.show_b {
+  width: 60px;
+}
+.setting-column__title span.name_b {
+  width: 140px;
+}
+.setting-column__title span.width_b {
+  width: 60px;
+  margin-right: 15px;
+}
+.setting-column__title span.sortable_b {
+  width: 60px;
+}
+.setting-column__title span.fixed_b {
+  width: 60px;
+}
+
+.setting-column__list {
+  max-height: 314px;
+  overflow: auto;
+}
+.setting-column__list li {
+  list-style: none;
+  margin: 10px 0;
+  display: flex;
+  align-items: center;
+}
+.setting-column__list li > span {
+  display: inline-block;
+  font-size: 12px;
+}
+.setting-column__list li span.move_b {
+  width: 30px;
+  margin-right: 15px;
+}
+.setting-column__list li span.show_b {
+  width: 60px;
+}
+.setting-column__list li span.name_b {
+  width: 140px;
+  cursor: default;
+}
+.setting-column__list li span.width_b {
+  width: 60px;
+  margin-right: 15px;
+}
+.setting-column__list li span.sortable_b {
+  width: 60px;
+}
+.setting-column__list li span.fixed_b {
+  width: 60px;
+}
+.setting-column__list li.ghost {
+  opacity: 0.3;
+}
+
+.setting-column__bottom {
+  border-top: 1px solid #ebeef5;
+  padding-top: 15px;
+  text-align: right;
+}
+</style>

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

@@ -0,0 +1,387 @@
+<!--
+ * @Descripttion: 数据表格组件
+ * @version: 1.10
+ * @Author: sakuya
+ * @Date: 2021年11月29日21:51:15
+ * @LastEditors: sakuya
+ * @LastEditTime: 2022年6月4日17:35:26
+-->
+
+<template>
+	<div class="scTable" :style="{ 'height': _height }" ref="scTableMain" v-loading="loading">
+		<div class="scTable-table" :style="{ 'height': _table_height }">
+			<el-table v-bind="$attrs" :data="tableData" :row-key="rowKey" :key="toggleIndex" ref="scTable" :height="height == 'auto' ? null : '100%'" :size="config.size" :border="config.border" :stripe="config.stripe" @sort-change="sortChange" @filter-change="filterChange">
+				<slot></slot>
+				<template v-for="(item, index) in userColumn" :key="index">
+					<el-table-column v-if="!item.hide" :align="item.align || align" :column-key="item.prop" :label="item.label" :prop="item.prop" :width="item.width" :sortable="item.sortable" :fixed="item.fixed" :filters="item.filters" :filter-method="remoteFilter || !item.filters ? null : filterHandler" :show-overflow-tooltip="item.showOverflowTooltip">
+						<template #default="scope">
+							<slot :name="item.prop" v-bind="scope">{{ scope.row[item.prop] }}</slot>
+						</template>
+					</el-table-column>
+				</template>
+				<el-table-column class-name="placeholder-cell" min-width="1"></el-table-column>
+				<template #empty>
+					<el-empty description="暂无数据" :image-size="100"></el-empty>
+				</template>
+			</el-table>
+		</div>
+		<div class="scTable-page" v-if="!hidePagination || !hideDo">
+			<div class="scTable-pagination">
+				<el-pagination v-if="!hidePagination" background :small="true" :layout="paginationLayout" :total="total" :page-size="scPageSize" :page-sizes="pageSizes" v-model:currentPage="currentPage" @current-change="paginationChange" @update:page-size="pageSizeChange"></el-pagination>
+			</div>
+			<div class="scTable-do" v-if="!hideDo">
+				<el-button v-if="!hideRefresh" @click="refresh" icon="el-icon-refresh" circle style="margin-left:15px"></el-button>
+				<el-popover v-if="column" placement="top" title="列设置" :width="500" trigger="click" :hide-after="0" @show="customColumnShow = true" @after-leave="customColumnShow = false">
+					<template #reference>
+						<el-button icon="el-icon-set-up" circle style="margin-left:15px"></el-button>
+					</template>
+					<columnSetting v-if="customColumnShow" ref="columnSetting" @userChange="columnSettingChange" @save="columnSettingSave" @back="columnSettingBack" :column="userColumn"></columnSetting>
+				</el-popover>
+				<el-popover v-if="!hideSetting" placement="top" title="表格设置" :width="400" trigger="click" :hide-after="0">
+					<template #reference>
+						<el-button icon="el-icon-setting" circle style="margin-left:15px"></el-button>
+					</template>
+					<el-form label-width="80px" label-position="left">
+						<el-form-item label="表格尺寸">
+							<el-radio-group v-model="config.size" size="small" @change="configSizeChange">
+								<el-radio-button label="large">大</el-radio-button>
+								<el-radio-button label="default">正常</el-radio-button>
+								<el-radio-button label="small">小</el-radio-button>
+							</el-radio-group>
+						</el-form-item>
+						<el-form-item label="样式">
+							<el-checkbox v-model="config.border" label="纵向边框"></el-checkbox>
+							<el-checkbox v-model="config.stripe" label="斑马纹"></el-checkbox>
+						</el-form-item>
+					</el-form>
+				</el-popover>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+	import config from "@/config/table";
+	import columnSetting from './columnSetting'
+
+	export default {
+		name: 'scTable',
+		components: {
+			columnSetting
+		},
+		props: {
+			tableName: { type: String, default: "" },
+			apiObj: { type: Object, default: () => {} },
+			apiKey: { type: String, default: "get" },
+			params: { type: Object, default: () => {} },
+			data: { type: Object, default: () => {} },
+			height: { type: [String, Number], default: "100%" },
+			size: { type: String, default: "default" },
+			border: { type: Boolean, default: false },
+			pageParams: { type: String, default: "page" },
+			stripe: { type: Boolean, default: false },
+			pageSize: { type: Number, default: config.pageSize },
+			pageSizes: { type: Array, default: () => config.pageSizes },
+			rowKey: { type: String, default: "" },
+			column: { type: Object, default: () => {} },
+			remoteSort: { type: Boolean, default: false },
+			remoteFilter: { type: Boolean, default: false },
+			hidePagination: { type: Boolean, default: false },
+			hideDo: { type: Boolean, default: false },
+			hideRefresh: { type: Boolean, default: false },
+			hideSetting: { type: Boolean, default: false },
+			paginationLayout: { type: String, default: config.paginationLayout },
+			align: { type: String, default: 'left' }
+		},
+
+		watch: {
+			// 监听从props里拿到值了
+			data() {
+				this.tableData = this.data;
+				this.total = this.tableData.length;
+			},
+
+			apiObj() {
+				this.tableParams = this.params;
+				this.refresh();
+			},
+
+			column() {
+				this.userColumn = this.column;
+			}
+		},
+
+		computed: {
+			_height() {
+				return Number(this.height) ? Number(this.height) + 'px' : this.height
+			},
+
+			_table_height() {
+				return this.hidePagination && this.hideDo ? "100%" : "calc(100% - 50px)"
+			}
+		},
+
+		data() {
+			return {
+				scPageSize: this.pageSize,
+				currentPage: 1,
+				isActivat: true,
+				toggleIndex: 0,
+				tableData: [],
+				total: 0,
+				prop: null,
+				order: null,
+				loading: false,
+				tableHeight:'100%',
+				tableParams: this.params,
+				userColumn: [],
+				customColumnShow: false,
+				config: {
+					size: this.size,
+					border: this.border,
+					stripe: this.stripe
+				}
+			}
+		},
+		mounted() {
+			// 判断是否开启自定义列
+			if (this.column) this.getCustomColumn();
+			else this.userColumn = this.column;
+
+			// 判断是否静态数据
+			if (this.apiObj) this.getData();
+			else if (this.data) {
+				this.tableData = this.data;
+				this.total =  this.tableData.length;
+			}
+		},
+		activated() {
+			if (!this.isActivat) this.$refs.scTable.doLayout()
+		},
+		deactivated() {
+			this.isActivat = false;
+		},
+		methods: {
+			// 获取列
+			async getCustomColumn() {
+				const userColumn = await config.columnSettingGet(this.tableName, this.column)
+				this.userColumn = userColumn
+			},
+			// 获取数据
+			async getData() {
+				try {
+					this.loading = true;
+					let reqData = {
+						[config.request[this.pageParams].key]: config.request[this.pageParams].pageStart(this.currentPage),
+						[config.request.pageSize]: this.scPageSize,
+					}
+					if (this.hidePagination) {
+						delete reqData[config.request[this.pageParams].key]
+						delete reqData[config.request.pageSize]
+					}
+					
+					Object.assign(reqData, this.tableParams);
+					var res = await this.apiObj[this.apiKey](reqData);
+					const response = config.parseData(res);
+					this.tableData = response.data || [];
+					this.total = parseInt(response.total) || 0;
+					this.loading = false;
+					this.$refs.scTable.setScrollTop(0);
+				} catch(error) {
+					this.loading = false;
+				}
+			},
+			//分页点击
+			paginationChange(){
+				this.getData();
+			},
+			//条数变化
+			pageSizeChange(size){
+				this.scPageSize = size
+				this.getData();
+			},
+			//刷新数据
+			refresh(){
+				this.$refs.scTable.clearSelection();
+				this.getData();
+			},
+			//更新数据 合并上一次params
+			upData(params, page = 1) {
+				this.currentPage = page;
+				this.$refs.scTable.clearSelection();
+				Object.assign(this.tableParams, params || {})
+				this.getData()
+			},
+			//重载数据 替换params
+			reload(params, page = 1) {
+				this.currentPage = page;
+				this.tableParams = params || {}
+				this.$refs.scTable.clearSelection();
+				this.$refs.scTable.clearSort()
+				this.$refs.scTable.clearFilter()
+				this.getData()
+			},
+			//自定义变化事件
+			columnSettingChange(userColumn){
+				this.userColumn = userColumn;
+				this.toggleIndex += 1;
+			},
+			//自定义列保存
+			async columnSettingSave(userColumn){
+				this.$refs.columnSetting.isSave = true
+				try {
+					await config.columnSettingSave(this.tableName, userColumn)
+				}catch(error){
+					this.$message.error('保存失败')
+					this.$refs.columnSetting.isSave = false
+				}
+				this.$message.success('保存成功')
+				this.$refs.columnSetting.isSave = false
+			},
+			//自定义列重置
+			async columnSettingBack(){
+				this.$refs.columnSetting.isSave = true
+				try {
+					const column = await config.columnSettingReset(this.tableName, this.column)
+					this.userColumn = column
+					this.$refs.columnSetting.usercolumn = JSON.parse(JSON.stringify(this.userColumn||[]))
+				}catch(error){
+					this.$message.error('重置失败')
+					this.$refs.columnSetting.isSave = false
+				}
+				this.$refs.columnSetting.isSave = false
+			},
+			//排序事件
+			sortChange(obj){
+				if(!this.remoteSort){
+					return false
+				}
+				if(obj.column && obj.prop){
+					this.prop = obj.prop
+					this.order = obj.order
+				}else{
+					this.prop = null
+					this.order = null
+				}
+				this.getData()
+			},
+			//本地过滤
+			filterHandler(value, row, column){
+				const property = column.property;
+				return row[property] === value;
+			},
+			//过滤事件
+			filterChange(filters){
+				if(!this.remoteFilter){
+					return false
+				}
+				Object.keys(filters).forEach(key => {
+					filters[key] = filters[key].join(',')
+				})
+				this.upData(filters)
+			},
+			//远程合计行处理
+			configSizeChange(){
+				this.$refs.scTable.doLayout()
+			},
+			//插入行 unshiftRow
+			unshiftRow(row){
+				this.tableData.unshift(row)
+			},
+			//插入行 pushRow
+			pushRow(row){
+				this.tableData.push(row)
+			},
+			//根据key覆盖数据
+			updateKey(row, rowKey=this.rowKey){
+				this.tableData.filter(item => item[rowKey]===row[rowKey] ).forEach(item => {
+					Object.assign(item, row)
+				})
+			},
+			//根据index覆盖数据
+			updateIndex(row, index){
+				Object.assign(this.tableData[index], row)
+			},
+			//根据index删除
+			removeIndex(index){
+				this.tableData.splice(index, 1)
+			},
+			//根据index批量删除
+			removeIndexes(indexes=[]){
+				indexes.forEach(index => {
+					this.tableData.splice(index, 1)
+				})
+			},
+			//根据key删除
+			removeKey(key, rowKey=this.rowKey){
+				this.tableData.splice(this.tableData.findIndex(item => item[rowKey]===key), 1)
+			},
+			//根据keys批量删除
+			removeKeys(keys=[], rowKey=this.rowKey){
+				keys.forEach(key => {
+					this.tableData.splice(this.tableData.findIndex(item => item[rowKey]===key), 1)
+				})
+			},
+			//原生方法转发
+			clearSelection(){
+				this.$refs.scTable.clearSelection()
+			},
+			toggleRowSelection(row, selected){
+				this.$refs.scTable.toggleRowSelection(row, selected)
+			},
+			toggleAllSelection(){
+				this.$refs.scTable.toggleAllSelection()
+			},
+			toggleRowExpansion(row, expanded){
+				this.$refs.scTable.toggleRowExpansion(row, expanded)
+			},
+			setCurrentRow(row){
+				this.$refs.scTable.setCurrentRow(row)
+			},
+			clearSort(){
+				this.$refs.scTable.clearSort()
+			},
+			clearFilter(columnKey){
+				this.$refs.scTable.clearFilter(columnKey)
+			},
+			doLayout(){
+				this.$refs.scTable.doLayout()
+			},
+			sort(prop, order){
+				this.$refs.scTable.sort(prop, order)
+			}
+		}
+	}
+</script>
+
+<style scoped>
+.scTable-table {
+  height: calc(100% - 50px);
+}
+.scTable-page {
+  height: 50px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 15px;
+}
+.scTable-do {
+  white-space: nowrap;
+}
+.scTable:deep(.el-table__row--striped) .el-table-fixed-column--right,
+.scTable:deep(.el-table__row--striped) .el-table__cell {
+  background-color: rgba(235, 245, 255, 1) !important;
+}
+
+.scTable:deep(.el-table__footer) .cell {
+  font-weight: bold;
+}
+.scTable:deep(.el-table__body-wrapper) .el-scrollbar__bar.is-horizontal {
+  height: 12px;
+  border-radius: 12px;
+}
+.scTable:deep(.el-table__body-wrapper) .el-scrollbar__bar.is-vertical {
+  width: 12px;
+  border-radius: 12px;
+}
+</style>

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

@@ -0,0 +1,43 @@
+<template>
+	<div :class="['sc-title', border && 'bordering']">
+		<slot></slot>
+	</div>
+</template>
+
+<script>
+	export default {
+		props: {
+			border: { type: Boolean, default: false }
+		},
+
+		data() {
+			return {}
+		}
+	}
+</script>
+
+<style scoped>
+.sc-title {
+  position: relative;
+  padding-left: 19px;
+  line-height: 22px;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.sc-title::before {
+  content: "";
+  position: absolute;
+  left: 8px;
+  top: 2px;
+  width: 3px;
+  height: 18px;
+  background: blue;
+}
+
+.bordering {
+  margin-bottom: 15px;
+  padding-bottom: 10px;
+  border-bottom: 1px solid var(--el-border-color-light);
+}
+</style>

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

@@ -0,0 +1,40 @@
+<!--
+ * @Descripttion: tooltip组件
+ * @version: 2.0
+ * @Date: 2023年12月11日16:17:52
+ * @LastEditTime: 2023年12月11日23:17:52
+-->
+
+<template>
+  <el-tooltip :content="content" :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: {
+			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>

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

@@ -0,0 +1,30 @@
+<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>
+	import { ElImageViewer } from "element-plus";
+
+	export default {
+		emits: ['closed'],
+		components: {
+			ElImageViewer
+		},
+
+		props: {
+			showViewer: { type: Boolean, default: false },
+			imageList: { type: Array, default: () => [] }
+		},
+
+		data() {
+			return {}
+		},
+
+		methods: {}
+	}
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 23 - 0
src/config/iconSelect.js

@@ -0,0 +1,23 @@
+import * as elIcons from '@element-plus/icons-vue'
+import * as scIcons from "@/assets/icons"
+
+function formatIcon(icon) {
+	return icon.split(/(?=[A-Z])/).map(n => n.toLowerCase()).join('-')
+}
+
+function selectIcon(menuIcon) {
+	const icon = menuIcon.split('-').map(str => str.slice(0, 1).toUpperCase() + str.slice(1)).join('')
+	const allIcons = icons.map(i => i.icons).reduce((p, v) => p.concat(v)).map(i => formatIcon(i))
+	return allIcons.filter(a => a == 'el-icon-' + formatIcon(icon) || a == 'sc-icon-' + formatIcon(icon))[0] || null
+}
+
+const icons = [{
+	name: '默认',
+	icons: Object.keys(elIcons).map(name => formatIcon(`ElIcon${name}`))
+}, {
+	name: '扩展',
+	icons: Object.keys(scIcons.default).map(name => formatIcon(`ScIcon${name}`))
+}]
+
+// 图标选择器配置
+export default { icons, selectIcon }

+ 43 - 0
src/config/index.js

@@ -0,0 +1,43 @@
+const DEFAULT_CONFIG = {
+	//标题
+	APP_NAME: process.env.VUE_APP_TITLE,
+
+	//首页地址
+	DASHBOARD_URL: "/config",
+
+	//接口地址
+	API_URL: process.env.NODE_ENV === "development" ? "" : process.env.VUE_APP_API_BASEURL,
+
+	//请求超时
+	TIMEOUT: 30000,
+
+	//追加其他头
+	HEADERS: {},
+
+	//请求是否开启缓存
+	REQUEST_CACHE: false,
+
+	//布局 默认:default | 通栏:header | 经典:menu | 功能坞:dock
+	//dock将关闭标签和面包屑栏
+	LAYOUT: "menu",
+
+	//菜单是否启用手风琴效果
+	MENU_UNIQUE_OPENED: true,
+
+	//是否开启多标签
+	LAYOUT_TAGS: true,
+
+	//语言
+	LANG: "zh-cn",
+
+	//主题颜色
+	COLOR: "#409EFF"
+}
+
+// 如果生产模式,就合并动态的APP_CONFIG
+// public/config.js
+if (process.env.NODE_ENV === "production") {
+	Object.assign(DEFAULT_CONFIG, APP_CONFIG)
+}
+
+export default DEFAULT_CONFIG

+ 37 - 0
src/config/route.js

@@ -0,0 +1,37 @@
+// 静态路由配置
+// 书写格式与动态路由格式一致,全部经由框架统一转换
+// 比较动态路由在meta中多加入了role角色权限,为数组类型。一个菜单是否有权限显示,取决于它以及后代菜单是否有权限。
+// routes 显示在左侧菜单中的路由(显示顺序在动态路由之前)
+
+const routes = [
+    {
+        component: "config",
+        meta: { title: "智能配置", icon: "configure", affix: true },
+        name: "智能配置",
+        path: "/config"
+    }, {
+        component: "nvr",
+        meta: { title: "NVR配置", icon: "nvr" },
+        name: "NVR配置",
+        path: "/nvr"
+    }, {
+        component: "speaker",
+        meta: { title: "音柱配置", icon: "speaker" },
+        name: "音柱配置",
+        path: "/speaker"
+    }, {
+        component: "channel",
+        meta: { title: "摄像头列表", icon: "channel" },
+        name: "摄像头列表",
+        path: "/channel"
+    }
+    
+    // , {
+    //     component: "attendance",
+    //     meta: { title: "考勤列表", icon: "date" },
+    //     name: "考勤列表",
+    //     path: "/attendance"
+    // }
+]
+
+export default routes;

+ 85 - 0
src/config/table.js

@@ -0,0 +1,85 @@
+//数据表格配置
+
+import tool from '@/utils/tool'
+
+export default {
+	successCode: 200,
+	currentPage: 1,													//表格开始页码
+	pageSize: 10,													//表格每一页条数
+	pageSizes: [10, 20, 30, 40, 50],								//表格可设置的一页条数
+	paginationLayout: "total, sizes, prev, pager, next, jumper",	//表格分页布局,可设置"total, sizes, prev, pager, next, jumper"
+	parseData: function (res) {
+		return {
+			data: res.content || res.records || res.datas || res.data || res,				    																//分析无分页的数据字段结构
+			total: isNUllOrUndefined(res.totalElements) || isNUllOrUndefined(res.total) || res.expands && isNUllOrUndefined(res.expands.total) || '0',		    	//分析总数字段结构
+			msg: res.message || "success",  																								//分析描述字段结构
+			code: res.code || 200		    																								//分析状态字段结构
+		}
+	},
+
+	request: {							//请求规定字段
+		page: {
+			key: 'page',
+			pageStart: function (page) {
+				return page - 1
+			}
+		},
+		current: {
+			key: 'current',
+			pageStart: function (page) {
+				return page
+			}
+		},
+		//规定一页条数字段
+		pageSize: 'size'
+	},
+	/**
+	 * 自定义列保存处理
+	 * @tableName scTable组件的props->tableName
+	 * @column 用户配置好的列
+	 */
+	columnSettingSave: function (tableName, column) {
+		return new Promise((resolve) => {
+			setTimeout(() => {
+				//这里为了演示使用了session和setTimeout演示,开发时应用数据请求
+				tool.session.set(tableName, column)
+				resolve(true)
+			}, 1000)
+		})
+	},
+	/**
+	 * 获取自定义列
+	 * @tableName scTable组件的props->tableName
+	 * @column 组件接受到的props->column
+	 */
+	columnSettingGet: function (tableName, column) {
+		return new Promise((resolve) => {
+			//这里为了演示使用了session和setTimeout演示,开发时应用数据请求
+			const userColumn = tool.session.get(tableName)
+			if (userColumn) {
+				resolve(userColumn)
+			} else {
+				resolve(column)
+			}
+		})
+	},
+	/**
+	 * 重置自定义列
+	 * @tableName scTable组件的props->tableName
+	 * @column 组件接受到的props->column
+	 */
+	columnSettingReset: function (tableName, column) {
+		return new Promise((resolve) => {
+			//这里为了演示使用了session和setTimeout演示,开发时应用数据请求
+			setTimeout(() => {
+				tool.session.remove(tableName)
+				resolve(column)
+			}, 1000)
+		})
+	}
+}
+
+function isNUllOrUndefined(column) {
+	if (column === null || column === undefined) return undefined
+	return column + ''
+}

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

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

@@ -0,0 +1,43 @@
+<template>
+	<template v-for="navMenu in navMenus" v-bind:key="navMenu">
+		<el-menu-item v-if="!hasChildren(navMenu)" :index="navMenu.path">
+			<a v-if="navMenu.iframe" :href="navMenu.path + $TOOL.data.get('PROJECT_ID')" target="_blank" @click.stop="() => {}"></a>
+			<el-icon v-if="navMenu.meta.icon"><component :is="menuIcon(navMenu.meta.icon)" /></el-icon>
+			<template #title>
+				<span>{{ navMenu.meta.title }}</span>
+			</template>
+		</el-menu-item>
+		<el-sub-menu v-else :index="navMenu.path">
+			<template #title>
+				<a v-if="navMenu.iframe" :href="navMenu.path + $TOOL.data.get('PROJECT_ID')" target="_blank" @click.stop='() => {}'></a>
+				<el-icon v-if="navMenu.meta.icon"><component :is="menuIcon(navMenu.meta.icon)" /></el-icon>
+				<span>{{ navMenu.meta.title }}</span>
+			</template>
+			<NavMenu :navMenus="navMenu.children"></NavMenu>
+		</el-sub-menu>
+	</template>
+</template>
+
+<script>
+	import config from "@/config/iconSelect";
+
+	export default {
+		name: 'NavMenu',
+		props: ['navMenus'],
+		data() {
+			return {}
+		},
+		computed: {
+			menuIcon() {
+				return function(icon) {
+					return config.selectIcon(icon) || 'el-icon-link';
+				}
+			}
+		},
+		methods: {
+			hasChildren(item) {
+				return item.children && !item.children.every(item => item.meta.hidden);
+			}
+		}
+	}
+</script>

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

@@ -0,0 +1,157 @@
+<template>
+	<div ref="" class="mobile-nav-button" @click="showMobileNav($event)" v-drag draggable="false"><el-icon><el-icon-menu /></el-icon></div>
+
+	<el-drawer ref="mobileNavBox" title="移动端菜单" :size="205" 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/icon.png"><span>{{ $CONFIG.APP_NAME }}</span></div>
+			</el-header>
+			<el-main class="nopadding">
+				<el-scrollbar>
+					<el-menu :default-active="$route.meta.active || $route.fullPath" @select="select" router background-color="#212d3d" text-color="#fff" active-text-color="#409EFF">
+						<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,
+				menu: []
+			}
+		},
+		
+		created() {
+			this.menu = this.filterUrl(this.$TOOL.data.get("MENU"));
+		},
+
+		methods: {
+			showMobileNav(e) {
+				var isdrag = e.currentTarget.getAttribute('drag-flag');
+				if (isdrag == 'true') return false;
+				else this.nav = true;
+
+			},
+
+			select() {
+				this.$refs.mobileNavBox.handleClose();
+			},
+
+			// 转换外部链接的路由
+			filterUrl(map) {
+				let newMap = [];
+				map && map.forEach(item => {
+					item.meta = item.meta ? item.meta : {};
+					// 处理隐藏
+					if (item.hidden) return false;
+					// 递归循环
+					if (item.children && item.children.length > 0) item.children = this.filterUrl(item.children);
+					newMap.push(item);
+				});
+				return newMap;
+			}
+		},
+		directives: {
+			drag(el) {
+				let oDiv = el; //当前元素
+				let firstTime='', lastTime='';
+				//禁止选择网页上的文字
+				// document.onselectstart = function() {
+				// 	return false;
+				// };
+				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 lang="scss" scoped>
+.mobile-nav-button {
+  position: fixed;
+  bottom: 10px;
+  left: 10px;
+  z-index: 10;
+  width: 50px;
+  height: 50px;
+  background: #409eff;
+  box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 1);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  i {
+    color: #fff;
+    font-size: 20px;
+  }
+}
+
+.mobile-nav {
+  background: linear-gradient(to top, #001c41 0%, #082d60 100%);
+
+  .el-header {
+    background: transparent;
+    border: 0;
+
+    .logo-bar {
+      display: flex;
+      align-items: center;
+      font-weight: bold;
+      font-size: 17px;
+      color: #fff;
+
+      img {
+        width: 30px;
+        margin-right: 10px;
+      }
+    }
+  }
+
+  .el-main {
+    background: transparent;
+  }
+}
+</style>

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

@@ -0,0 +1,273 @@
+<template>
+	<div class="adminui-tags dashed-border">
+		<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 :to="tag"><scTooltip :content="tag.meta.title"></scTooltip></router-link>
+				<el-icon v-if="!tag.meta.affix" @click.prevent.stop="closeSelectedTag(tag)"><el-icon-close /></el-icon>
+			</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>
+			<hr>
+			<li @click="openWindow()"><el-icon><el-icon-copy-document /></el-icon>在新的窗口中打开</li>
+		</ul>
+	</transition>
+</template>
+
+<script>
+	import Sortable from "sortablejs";
+
+	export default {
+		name: "tags",
+		data() {
+			return {
+				contextMenuVisible: false,
+				contextMenuItem: null,
+				left: 0,
+				top: 0,
+				tagList: this.$store.state.viewTags.viewTags,
+				tipDisplayed: false
+			}
+		},
+		watch: {
+			$route(e) {
+				this.addViewTags(e);
+				// 判断标签容器是否出现滚动条
+				this.$nextTick(() => {
+					const tags = this.$refs.tags;
+					if (tags && tags.scrollWidth > tags.clientWidth) {
+						// 确保当前标签在可视范围内
+						let targetTag = tags.querySelector(".active");
+						targetTag.scrollIntoView();
+						// 显示提示
+						if (!this.tipDisplayed) {
+							this.$msgbox({
+								type: "warning",
+								center: true,
+								title: "提示",
+								message: "当前标签数量过多,可通过鼠标滚轴滚动标签栏。关闭标签数量可减少系统性能消耗。",
+								confirmButtonText: "知道了"
+							});
+							this.tipDisplayed = true;
+						}
+					}
+				});
+			},
+			contextMenuVisible(value) {
+				const cm = e => {
+					const sp = document.getElementById("contextmenu");
+					if (sp && !sp.contains(e.target)) this.closeMenu();
+				}
+				if (value) {
+					document.body.addEventListener("click", e => cm(e));
+				} else {
+					document.body.removeEventListener("click", e => cm(e));
+				}
+			}
+		},
+		created() {
+			const menu = this.$TOOL.data.get("MENU");
+			let dashboardRoute = this.treeFind(menu, node => node.path == this.$CONFIG.DASHBOARD_URL);
+			if (dashboardRoute) {
+				dashboardRoute.fullPath = dashboardRoute.path;
+				this.addViewTags(dashboardRoute);
+				this.addViewTags(this.$route);
+			}
+		},
+		mounted() {
+			this.tagDrop();
+			this.scrollInit();
+		},
+		methods: {
+			// 查找树
+			treeFind(tree, func) {
+				for (const data of tree) {
+					if (func(data)) return data;
+					if (data.children) {
+						const res = this.treeFind(data.children, func);
+						if (res) return res;
+					}
+				}
+				return null;
+			},
+			// 标签拖拽排序
+			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
+					});
+				}
+				let tags = [...this.tagList];
+				tags.forEach(tag => {
+					if(tag.meta && tag.meta.affix || nowTag.fullPath == tag.fullPath) return true;
+					else this.closeSelectedTag(tag, false);
+				})
+				this.contextMenuVisible = false;
+			},
+			// 新窗口打开
+			openWindow() {
+				const nowTag = this.contextMenuItem;
+				if (!nowTag.meta.affix) this.closeSelectedTag(nowTag);
+				window.open(nowTag.href || "/");
+				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 lang="scss">
+.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, 0.1);
+  z-index: 3000;
+  list-style-type: none;
+  padding: 10px 0;
+
+  hr {
+    margin: 5px 0;
+    border: none;
+    height: 1px;
+    font-size: 0px;
+    background-color: var(--el-border-color-light);
+  }
+
+  li {
+    display: flex;
+    align-items: center;
+    margin: 0;
+    cursor: pointer;
+    line-height: 30px;
+    padding: 0 17px;
+    color: #606266;
+
+    &:hover {
+      background-color: #ecf5ff;
+      color: #66b1ff;
+    }
+
+    i {
+      font-size: 14px;
+      margin-right: 10px;
+    }
+  }
+
+  li.disabled {
+    cursor: not-allowed;
+    color: #bbb;
+    background: transparent;
+  }
+}
+</style>

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

@@ -0,0 +1,103 @@
+<template>
+	<div class="adminui-topbar" :class="ismobile ? '' : 'dashed-border'">
+		<div class="left-panel">
+			<el-breadcrumb separator-icon="el-icon-arrow-right" class="hidden-sm-and-down">
+				<transition-group name="breadcrumb">
+					<template v-for="item in breadList" :key="item.title" >
+						<el-breadcrumb-item v-if="item.path != '/'" :key="item.meta.title"><el-icon v-if="item.meta.icon"><component :is="menuIcon(item.meta.icon)" /></el-icon>{{ item.meta.title }}</el-breadcrumb-item>
+					</template>
+				</transition-group>
+			</el-breadcrumb>
+		</div>
+		<div class="right-panel">
+			<el-avatar :size="30">A</el-avatar>
+			<label>admin</label>
+		</div>
+	</div>
+</template>
+
+<script>
+	import config from "@/config/iconSelect";
+	
+	export default {
+		data() {
+			return {
+				breadList: []
+			}
+		},
+
+		created() {
+			this.getBreadcrumb();
+		},
+
+		watch: {
+			$route() {
+				this.getBreadcrumb();
+			}
+		},
+
+		computed: {
+			ismobile() {
+				return this.$store.state.global.ismobile;
+			},
+
+			menuIcon() {
+				return function(icon) {
+					return config.selectIcon(icon) || 'el-icon-menu';
+				}
+			}
+		},
+
+		methods: {
+			getBreadcrumb() {
+				let matched = this.pmenu = this.$TOOL.data.get("MENU").find(item => item.path == this.$route.fullPath || item.children && item.children.findIndex(child => child.path == this.$route.fullPath) != -1) || {};
+				this.breadList = Object.keys(matched).length && matched.children && matched.children.length ? [matched, this.$route] : [this.$route];
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+.el-breadcrumb {
+  margin-left: 15px;
+
+  :deep(.el-breadcrumb__inner) {
+    display: flex;
+    align-items: center;
+
+    .el-icon {
+      margin-right: 5px;
+      font-size: 14px;
+    }
+  }
+}
+
+.breadcrumb-enter-active,
+.breadcrumb-leave-active {
+  transition: all 0.3s;
+}
+
+.breadcrumb-enter-from,
+.breadcrumb-leave-active {
+  opacity: 0;
+  transform: translateX(20px);
+  -webkit-transform: translateX(20px);
+  -moz-transform: translateX(20px);
+  -o-transform: translateX(20px);
+}
+
+.breadcrumb-leave-active {
+  position: absolute;
+}
+
+.el-avatar {
+  --el-avatar-text-color: var(--el-bg-color);
+  --el-avatar-bg-color: var(--el-color-primary);
+}
+
+label {
+  display: inline-block;
+  margin-left: 5px;
+  font-size: 12px;
+}
+</style>

+ 112 - 0
src/layout/index.vue

@@ -0,0 +1,112 @@
+<template>
+	<section class="aminui-wrapper">
+		<div v-if="!ismobile" class="aminui-side-split">
+			<div class="aminui-side-split-top">
+				<router-link :to="$CONFIG.DASHBOARD_URL">
+					<img class="logo" :title="$CONFIG.APP_NAME" src="img/icon.png">
+					<span>{{ $CONFIG.APP_NAME }}</span>
+				</router-link>
+			</div>
+			<div class="adminui-side-scroll">
+				<el-scrollbar>
+					<el-menu :default-active="$route.meta.active || $route.fullPath" router background-color="#212d3d" text-color="#fff" active-text-color="#409EFF">
+						<NavMenu :navMenus="menu"></NavMenu>
+					</el-menu>
+				</el-scrollbar>
+			</div>
+		</div>
+		<Side-m v-if="ismobile"></Side-m>
+		<div class="aminui-body el-container">
+			<Topbar></Topbar>
+			<Tags v-if="!ismobile && $CONFIG.LAYOUT_TAGS"></Tags>
+			<div class="adminui-main" id="adminui-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 SideM from './components/sideM.vue';
+	import Topbar from './components/topbar.vue';
+	import Tags from './components/tags.vue';
+	import NavMenu from './components/NavMenu.vue';
+
+	export default {
+		name: 'index',
+		components: {
+			SideM,
+			Topbar,
+			Tags,
+			NavMenu
+		},
+		data() {
+			return {
+				menu: [],
+				pmenu: {},
+				active: ""
+			}
+		},
+		computed:{
+			ismobile() {
+				return this.$store.state.global.ismobile;
+			},
+			
+			hasChildren() {
+				return function(item) {
+					return !this.ismobile && this.filterUrl(item.children).length || !item.component
+				}
+			}
+		},
+
+		created() {
+			this.onLayoutResize();
+			window.addEventListener('resize', this.onLayoutResize);
+			this.menu = this.filterUrl(this.$TOOL.data.get("MENU"));
+			this.showThis();
+		},
+
+		watch: {
+			$route() {
+				this.showThis();
+			}
+		},
+
+		methods: {
+			onLayoutResize() {
+				this.$store.commit("SET_ismobile", document.body.clientWidth < 992);
+			},
+
+			// 路由监听高亮
+			showThis() {
+				this.pmenu = this.$TOOL.data.get("MENU").find(item => item.path == this.$route.fullPath || item.children && item.children.findIndex(child => child.path == this.$route.fullPath) != -1) || {};
+				this.$nextTick(()=> this.active = this.$route.fullPath);
+			},
+
+			showMenu(route) {
+				if (route.iframe) return window.open(route.path);
+				if ((!route.children || route.children.length == 0) && route.component) {
+					this.pmenu = route;
+					this.$router.push({ path: route.path });
+				}
+			},
+
+			filterUrl(map) {
+				let newMap = [];
+				map && map.forEach(item => {
+					item.meta = item.meta ? item.meta : {};
+					// 处理隐藏
+					if (item.hidden) return false;
+					// 递归循环
+					if (item.children && item.children.length > 0) item.children = this.filterUrl(item.children);
+					newMap.push(item);
+				});
+				return newMap;
+			}
+		}
+	}
+</script>

+ 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;
+  width: 900px;
+  margin: 50px auto;
+  align-items: center;
+
+  .router-err__icon {
+    width: 400px;
+
+    img {
+      width: 100%;
+    }
+  }
+
+  .router-err__content {
+    flex: 1;
+    padding: 40px;
+
+    h2 {
+      font-size: 26px;
+    }
+
+    p {
+      font-size: 14px;
+      color: #999;
+      margin: 15px 0 30px 0;
+      line-height: 1.5;
+    }
+  }
+}
+
+@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>

+ 27 - 0
src/locales/index.js

@@ -0,0 +1,27 @@
+import sysConfig from "@/config"
+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: sysConfig.LANG,
+	fallbackLocale: 'zh-cn',
+	globalInjection: true,
+	messages,
+})
+
+export default i18n;

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

@@ -0,0 +1,12 @@
+export default {
+	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'
+	}
+}

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

@@ -0,0 +1,12 @@
+export default {
+	login: {
+		rememberMe: '24小时免登录',
+		signIn: '登录',
+		userPlaceholder: '请输入用户名',
+		userError: '请输入用户名',
+		PWPlaceholder: '请输入密码',
+		PWError: '请输入密码',
+		codePlaceholder: '请输入验证码',
+		codeError: '请输入验证码'
+	}
+}

+ 20 - 0
src/main.js

@@ -0,0 +1,20 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import 'element-plus/theme-chalk/display.css'
+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
+app.mount('#app');

+ 103 - 0
src/router/index.js

@@ -0,0 +1,103 @@
+import { createRouter, createWebHashHistory } from 'vue-router';
+import { ElNotification } from 'element-plus';
+import config from "@/config"
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+import api from "@/api";
+import tool from "@/utils/tool";
+import systemRouter from './systemRouter';
+import userRoutes from '@/config/route';
+import { beforeEach, afterEach } from './scrollBehavior';
+
+//系统路由
+const routes = systemRouter
+
+//系统特殊路由
+const routes_404 = {
+	path: "/:pathMatch(.*)*",
+	hidden: true,
+	component: () => import(/* webpackChunkName: "404" */ '@/layout/other/404'),
+}
+
+//系统特殊路由
+const routes_empty = {
+	path: "/:pathMatch(.*)*",
+	hidden: true,
+	component: () => import(/* webpackChunkName: "404" */ '@/layout/other/empty'),
+}
+
+const router = createRouter({
+	history: createWebHashHistory(),
+	routes: routes
+})
+
+//设置标题
+document.title = config.APP_NAME;
+
+//判断是否已加载过动态/静态路由
+let isGetRouter = false;
+
+router.beforeEach(async (to, from, next) => {
+
+	NProgress.start();
+
+	// 动态标题
+	document.title = to.meta.title ? `${to.meta.title} - ${config.APP_NAME}` : `${config.APP_NAME}`;
+
+	// 整页路由处理
+	if (to.meta.fullpage) to.matched = [to.matched[to.matched.length - 1]];
+	try {
+		if (!isGetRouter) {
+			const res = await api.system.config.post();
+            const configType = res.configType || 1;
+
+			isGetRouter = true;
+			// 1:直连 2:NVR
+			tool.data.set("CONFIG_TYPE", configType);
+			tool.data.set("MENU", configType == 1 ? [...userRoutes.filter(r => r.path != "/nvr")] : userRoutes);
+			filterAsyncRouter(configType == 1 ? [...userRoutes.filter(r => r.path != "/nvr")] : userRoutes);
+			router.addRoute(routes_404);
+			if (to.matched.length == 0) router.push(to.fullPath);
+		}
+	} catch (error) {
+		tool.data.remove("CONFIG_TYPE");
+		router.addRoute(routes_empty);
+	}
+
+	beforeEach(to, from);
+	next();
+});
+
+router.afterEach((to, from) => {
+	afterEach(to, from);
+	NProgress.done();
+});
+
+router.onError((error) => {
+	NProgress.done();
+	ElNotification.error({
+		title: '路由错误',
+		message: error.message
+	});
+});
+
+// 生成路由对象
+function filterAsyncRouter(routerMap) {
+	routerMap.forEach(item => {
+		const route = {
+			path: item.path,
+			name: item.name,
+			meta: item.meta ? item.meta : {},
+			redirect: item.redirect,
+			children: item.children ? filterAsyncRouter(item.children) : null,
+			component: loadComponent(item)
+		}
+		router.addRoute("layout", route);
+	})
+}
+
+function loadComponent({ component }) {
+	return () => import(/* webpackChunkName: "[request]" */ `@/views/${component}`);
+}
+
+export default router;

+ 22 - 0
src/router/scrollBehavior.js

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

+ 14 - 0
src/router/systemRouter.js

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

+ 51 - 0
src/scui.js

@@ -0,0 +1,51 @@
+import config from "./config"
+import api from "./api"
+import tool from "./utils/tool"
+import http from "./utils/request"
+
+import scTooltip from "./components/scTooltip"
+import scTable from "./components/scTable"
+import scDialog from "./components/scDialog"
+import scTitle from "./components/scTitle"
+import scModal from "./components/scModal"
+
+import time from "./directives/time"
+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.component("scTooltip", scTooltip);
+		app.component("scTable", scTable);
+		app.component("scDialog", scDialog);
+		app.component("scTitle", scTitle);
+		app.component("scModal", scModal);
+
+		//注册全局指令
+		app.directive("time", time);
+
+		//统一注册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
+	}
+}

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

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

@@ -0,0 +1,11 @@
+export default {
+	state: {
+		// 移动端布局
+		ismobile: false
+	},
+	mutations: {
+		SET_ismobile(state, key) {
+			state.ismobile = key;
+		}
+	}
+}

+ 34 - 0
src/store/modules/keepAlive.js

@@ -0,0 +1,34 @@
+export default {
+	state: {
+		keepLiveRoute: [],
+		routeKey: null,
+		routeShow: true
+	},
+	mutations: {
+		pushKeepLive(state, component) {
+			if (!state.keepLiveRoute.includes(component)) {
+				state.keepLiveRoute.push(component)
+			}
+		},
+		removeKeepLive(state, component) {
+			var index = state.keepLiveRoute.indexOf(component);
+			if (index !== -1) {
+				state.keepLiveRoute.splice(index, 1);
+			}
+		},
+		clearKeepLive(state) {
+			state.keepLiveRoute = []
+		},
+		setRouteKey(state, key) {
+			state.routeKey = key
+		},
+		setRouteShow(state, key) {
+			state.routeShow = key
+		}
+	},
+	actions: {
+		setRouteKey({ commit }, key) {
+			commit('setRouteKey', key);
+		}
+	}
+}

+ 46 - 0
src/store/modules/viewTags.js

@@ -0,0 +1,46 @@
+import router from '@/router'
+
+export default {
+	state: {
+		viewTags: []
+	},
+	mutations: {
+		pushViewTags(state, route){
+			let backPathIndex = state.viewTags.findIndex(item => item.fullPath == router.options.history.state.back)
+			let target = state.viewTags.find((item) => item.fullPath === route.fullPath)
+			let isName = route.name
+			if(!target && isName){
+				if(backPathIndex == -1){
+					state.viewTags.push(route)
+				}else{
+					state.viewTags.splice(backPathIndex+1, 0, route)
+				}
+			}
+		},
+		removeViewTags(state, route){
+			state.viewTags.forEach((item, index) => {
+				if (item.fullPath === route.fullPath){
+					state.viewTags.splice(index, 1)
+				}
+			})
+		},
+		updateViewTags(state, route){
+			state.viewTags.forEach((item) => {
+				if (item.fullPath == route.fullPath){
+					item = Object.assign(item, route)
+				}
+			})
+		},
+		updateViewTagsTitle(state, title=''){
+			const nowFullPath = location.hash.substring(1)
+			state.viewTags.forEach((item) => {
+				if (item.fullPath == nowFullPath){
+					item.meta.title = title
+				}
+			})
+		},
+		clearViewTags(state){
+			state.viewTags = []
+		}
+	}
+}

+ 99 - 0
src/style/app.scss

@@ -0,0 +1,99 @@
+/* 全局 */
+#app, body, html {width: 100%;height: 100%;background-color: #f6f8f9;font-size: 12px;}
+a {color: #333;text-decoration: none;}
+a:hover, a:focus {color: #000;text-decoration: none;}
+a:link {text-decoration: none;}
+a:-webkit-any-link {text-decoration: none;}
+a,button,input,textarea{-webkit-tap-highlight-color:rgba(0,0,0,0);box-sizing: border-box;outline:none !important; -webkit-appearance: none;}
+* {margin: 0;padding: 0;box-sizing: border-box;outline: none;}
+
+/* 大布局样式 */
+.aminui {display: flex;flex-flow: column;}
+.aminui-wrapper {display: flex;flex:1;overflow: auto;}
+
+/* 全局滚动条样式 */
+.scrollable {-webkit-overflow-scrolling: touch;}
+::-webkit-scrollbar {width: 5px;height: 5px;}
+::-webkit-scrollbar-thumb {background-color: rgba(50, 50, 50, 0.3);}
+::-webkit-scrollbar-thumb:hover {background-color: rgba(50, 50, 50, 0.6);}
+::-webkit-scrollbar-track {background-color: rgba(50, 50, 50, 0.1);}
+::-webkit-scrollbar-track:hover {background-color: rgba(50, 50, 50, 0.2);}
+
+.rules-popper.el-popper {
+    --el-color-success: #13ce66;
+    .pl-1 {padding-left: 1px;}
+    .pr-1 {padding-right: 1px;}
+    .rules-popper__content {
+        flex-direction: column;
+        .el-steps {margin-bottom: 10px;}
+        p {line-height: 1.4;font-size: 14px;color: #606266;}
+        .rules-popper__btns {justify-content: space-between;margin-top: 10px;}
+    }
+}
+
+/* 左侧菜单 */
+.aminui-side-split {width:205px;flex-shrink:0;background: linear-gradient(to top, #001c41 0%, #082d60 100%);display: flex;flex-flow: column;}
+.aminui-side-split-top {height: 49px;margin-bottom: 1px;border-bottom: 1px solid #424549;border-right: 1px solid #424549;}
+.aminui-side-split-top a {display: inline-block;width: 100%;height: 100%;display: flex;align-items: center;justify-content: center;font: 15px 仿宋;font-weight: 700;color:#fff;}
+.aminui-side-split-top .logo {width:30px;margin-right: 10px;vertical-align: bottom;}
+
+.aminui-wrapper .el-menu, .aminui-wrapper .el-menu .el-sub-menu__title {background: transparent!important;}
+.aminui-wrapper .el-menu .el-sub-menu__title, .aminui-wrapper .el-menu .el-menu-item {font-size: 14px;&:hover {padding-left: calc(var(--el-menu-base-level-padding) + 10px + var(--el-menu-level) * (var(--el-menu-level-padding) + 5px));transition: padding 0.3s;background: var(--el-color-primary)!important;color: #fff;}}
+.aminui-wrapper .el-menu .el-menu {background: #083168!important;}
+.aminui-wrapper .el-menu .el-menu-item {font-size: 13px;}
+
+/* 右侧内容 */
+.aminui-body {flex: 1;display: flex;flex-flow: column;}
+
+.adminui-topbar {height: 50px;display: flex;justify-content:space-between;}
+.adminui-topbar .left-panel {display: flex;align-items: center;}
+.adminui-topbar .right-panel {display: flex;align-items: center;padding: 0 10px;}
+
+.adminui-tags ul {display: flex;overflow: hidden;}
+.adminui-tags li {cursor: pointer;display: inline-block;width:96px;height:34px;line-height: 34px;position: relative;flex-shrink: 0;}
+.adminui-tags li:not(:first-child)::before {content: '';position: absolute;left: -1px; top: 10px;width: 2px;height: 14px;background: #3333338f;}
+.adminui-tags li a {position: absolute;left: 14px;display: block;width:68px;height:100%;text-align:center;font-size: 13px;color: #333;text-decoration:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;overflow-wrap:break-word;}
+.adminui-tags li i {position: absolute;right: 0;top: 0;display: none;align-items: center;justify-content: center;border-radius: 3px;width:18px;height:18px;color: #3333338f;}
+.adminui-tags li:hover i {display: flex;}
+.adminui-tags li:hover a {color: var(--el-color-primary);}
+.adminui-tags li i:hover {background: rgba(64,158,255,.2);}
+.adminui-tags li.active a {font-weight: 600;color: var(--el-color-primary);}
+.adminui-tags li.active::after {content: '';position: absolute;left: 0; bottom: 0;width: 100%;height: 2px;background: linear-gradient(90deg, transparent, var(--el-color-primary), transparent);}
+
+.adminui-main {overflow: auto;background-color: #e6f6fe;flex: 1;}
+
+/*定宽页面*/
+.sc-page {width: 1230px;margin: 0 auto;}
+
+/*溢出(...)*/
+.overflow-ellipsis {overflow: hidden;white-space: nowrap;text-overflow: ellipsis;}
+
+/* header-table */
+.aminui-main-container {
+    width: 100%;padding: 15px;overflow: auto;
+    .relative {position: relative;}
+    .el-header {height: fit-content;margin-bottom: 15px;}
+    .aminui-main-container__query-header {
+        flex-direction: column;justify-content: unset;align-items: unset;padding: 10px 15px 2px;border: none;border-radius: 4px;
+        .el-form {
+            padding-left: 8px;
+            .el-form-item .el-select, .el-input {width: 200px;}
+            .el-form-item.el-form-item__button .el-button {padding: 8px 10px;font-size: 13px;}
+        }
+    }
+    .aminui-main-container__table-main {
+        height: 0;padding: 10px 15px 15px;background: #fff;border-radius: 4px;
+        .el-header {padding: 0;padding-bottom: 10px;padding-right: 38px;}
+        .el-main.nopadding {padding: 0;}
+    }
+}
+
+/* header-table-dialog */
+.aminui-main-container__sc-dialog .el-form {
+    .flex-box {display: flex;padding: 18px 18px 0;}
+    .el-form-item {
+        align-items: center;
+        .el-form-item__content {flex: unset;}
+        .el-select, .el-input, .el-input-number, .el-date-editor {width: 180px;}
+    }
+}

+ 144 - 0
src/style/fix.scss

@@ -0,0 +1,144 @@
+/* 覆盖element-plus样式 */
+
+:root {
+	--el-color-primary: #409EFF;
+	--el-color-primary-light-1: #53a7ff;
+	--el-color-primary-light-2: #66b1ff;
+	--el-color-primary-light-3: #79bbff;
+	--el-color-primary-light-4: #8cc4ff;
+	--el-color-primary-light-5: #9fceff;
+	--el-color-primary-light-6: #b2d8ff;
+	--el-color-primary-light-7: #c5e1ff;
+	--el-color-primary-light-8: #d8ebff;
+	--el-color-primary-light-9: #ebf5ff;
+	--el-color-primary-dark-1: #398ee5;
+	--el-color-primary-dark-2: #337ecc;
+	--el-color-primary-dark-3: #2c6eb2;
+	--el-color-primary-dark-4: #265e99;
+	--el-color-primary-dark-5: #204f7f;
+	--el-color-primary-dark-6: #193f66;
+	--el-color-primary-dark-7: #132f4c;
+	--el-color-primary-dark-8: #0c1f32;
+	--el-color-primary-dark-9: #060f19;
+}
+
+.el-menu {border: none!important;}
+.el-menu .el-menu-item a {color: inherit;text-decoration: none;display: block;width:100%;height:100%;position: absolute;top:0px;left:0px;z-index: 1;}
+.el-form-item-msg {font-size: 12px;color: #999;clear: both;width: 100%;}
+.el-container {height: 100%;}
+.el-aside {border-right: 1px solid var(--el-border-color-light);}
+.el-container + .el-aside {border-right: 0;border-left: 1px solid var(--el-border-color-light);}
+.el-header {background: #fff;border-bottom: 1px solid var(--el-border-color-light);padding:13px 15px;display: flex;justify-content: space-between;align-items: center;}
+.el-header .left-panel {display: flex;align-items: center;}
+.el-header .right-panel {display: flex;align-items: center;}
+.el-header .right-panel > * + * {margin-left:10px;}
+.el-footer {background: #fff;border-top: 1px solid var(--el-border-color-light);padding:13px 15px;}
+.el-main {padding:15px;}
+.el-main.nopadding {padding:0;background: #fff;}
+.el-drawer__body {overflow: auto;padding:0;}
+.el-popconfirm__main {margin: 14px 0;}
+.el-card.is-hover-shadow:focus, .el-card.is-hover-shadow:hover {box-shadow: 0 10px 20px 0 rgba(0,0,0,.1);transition: all .3s ease-in-out;}
+.el-card__header {border-bottom: 0;font-size: 17px;font-weight: bold;padding:15px 20px 0px 20px;}
+.el-dialog__title, .el-drawer__header>:first-child {font-size: 17px;font-weight: bold;}
+.el-tree.el-tree--highlight-current {
+	.el-tree-node {
+		color: var(--el-text-color-primary);
+		.el-tree-node__content {
+			height:36px;
+			&:hover {background-color: var(--el-color-primary-light-9);}
+			.custom-tree-node {
+				display: flex;align-items: center;width: calc(100% - 24px);padding-right: 12px;font-size: 14px;
+			}
+		}
+	}
+	.el-tree-node.is-current > .el-tree-node__content {
+		background-color: transparent;color: var(--el-color-primary);
+		&:hover {background-color: var(--el-color-primary-light-9);}
+		.custom-tree-node span.el-tooltip__trigger {font-weight: bold;}
+	}
+}
+.el-progress__text {font-size: 12px!important;}
+.el-progress__text i {font-size: 14.4px!important;}
+.el-step.is-horizontal .el-step__line {height:1px;}
+.el-step__title {font-size: 14px;}
+.drawerBG {background: #f6f8f9;}
+.el-button+.el-dropdown {margin-left: 10px;}
+.el-button-group+.el-dropdown {margin-left: 10px;}
+.el-button-group+.el-button-group {margin-left: 10px;}
+.el-tabs__nav-wrap::after {height: 1px;}
+.el-table .el-table__header-wrapper tr th.el-table__cell {font-size: 13px;color: #000;padding: 12px 0;background: var(--el-color-primary-light-8);}
+.el-table .el-table__header-wrapper tr th.is-sortable:hover {background: var(--el-color-primary-light-7);}
+.el-table .el-table__body-wrapper {background: #f6f8f9;}
+.el-table--striped .el-table__body-wrapper tr.el-table__row--striped td.el-table__cell {background-color: rgba($color: #ebf5ff, $alpha: .4);}
+
+.el-table .el-table__body-wrapper tr:hover > td.el-table__cell {background: var(--el-color-primary-light-9);}
+.el-table--striped.el-table .el-table__body-wrapper tr:hover > td.el-table__cell {background: var(--el-fill-color-light);}
+
+.el-table .el-table__body-wrapper {background: #f6f8f9;}
+.el-table .el-table__body-wrapper tr > td .cell .el-button-group {
+	position: absolute;top: 0;bottom: 0;left: 0;right: 0;display: flex;justify-content: center;align-items: center;
+	.el-button.sc-button-press {
+		--sc-button-width: 46px;--sc-button-start-color: #75b7fa;--sc-button-end-color: #453fd1;--sc-button-shadow-color: #312ba9;
+		width: var(--sc-button-width);height: 40px;padding: 0;background: transparent;border: none;border-radius: 4px;font-size: inherit;color: var(--sc-button-end-color);
+		&:hover, &:active {color: #fff;}
+		span {
+			position: absolute;top: 8px;bottom: 8px;left: 0;right: 0;justify-content: center;
+			border-radius: 4px;
+			filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='var(--sc-button-start-color)', endColorstr='var(--sc-button-end-color)', GradientType=0);
+		}
+		&:hover span {
+			top: 8px;bottom: 10px;
+			background: linear-gradient(to bottom, var(--sc-button-start-color) 0%, var(--sc-button-end-color) 100%);
+			box-shadow: var(--sc-button-shadow-color) 0 4px 0px, rgba(0, 0, 0, 0.5) 0 5px 2px;
+		}
+		&:active span {
+			top: 10px;bottom: 9px;
+			background: linear-gradient(to bottom, var(--sc-button-start-color) 0%, var(--sc-button-end-color) 100%);
+			box-shadow: var(--sc-button-shadow-color) 0 1px 0, rgba(0, 0, 0, 0.3) 0px 2px 2px;
+		}
+	}
+}
+.el-col .el-card {margin-bottom: 15px;}
+.el-main {flex-basis: 100%;}
+.el-main > .scTable .el-table--border::before {display: none;}
+.el-main > .scTable .el-table--border::after {display: none;}
+.el-main > .scTable .el-table--border .el-table__inner-wrapper::after {display: none;}
+.el-main > .scTable .el-table__border-left-patch {display: none;}
+.el-main > .scTable .el-table--border .el-table__inner-wrapper tr:first-child td:first-child {border-left: 0;}
+.el-main > .scTable .el-table--border .el-table__inner-wrapper tr:first-child th:first-child {border-left: 0;}
+.el-main > .scTable .el-table--border .el-table__cell.placeholder-cell {border-right: none;}
+.el-table.el-table--large { font-size: 14px;
+	.el-table__body-wrapper tr > td .cell .el-button-group .el-button.sc-button-press {
+		span {top: 12px;bottom: 12px;}
+		&:hover span {top: 6px;bottom: 12px;box-shadow: var(--sc-button-shadow-color) 0 6px 0px, rgba(0, 0, 0, 0.5) 0 7px 2px;}
+		&:active span {top: 10px;bottom: 12px;box-shadow: var(--sc-button-shadow-color) 0 1px 0, rgba(0, 0, 0, 0.3) 0px 2px 2px;}
+	}
+}
+.el-table.el-table--small {font-size: 12px;
+	.el-table__body-wrapper tr > td .cell .el-button-group .el-button.sc-button-press {
+		span {top: 4px;bottom: 4px;}
+		&:hover span {top: 4px;bottom: 8px;}
+		&:active span {top: 7px;bottom: 7px;}
+	}
+}
+.el-table {font-size: 12px;}
+.el-radio-button__inner {font-size: 12px;}
+.el-checkbox-button__inner {font-size: 12px;}
+.el-sub-menu .el-icon {font-size: 17px;}
+.el-sub-menu .el-sub-menu__icon-arrow {font-size: 12px;}
+
+/* 输入框placeholder样式 */
+.el-input__inner::placeholder, .el-range-input::placeholder {font-size: 13px;}
+
+.aminui-side-split li.active {background-color: var(--el-color-primary);}
+.contextmenu li:hover {background-color: var(--el-color-primary-light-9)!important;color: var(--el-color-primary-light-2)!important;}
+.data-box .item-background {background-color: var(--el-color-primary)!important;}
+
+/* el-tree-select样式 */
+.el-popper.el-tree-select__popper .el-select-dropdown.el-tree-select__popper .el-tree-node__content {
+	height: 34px;
+	.el-select-dropdown__item {height: 34px;line-height: 34px;}
+}
+
+/* 全部禁用el-tag动画 */
+.el-tag {transition: all 0s !important;}

+ 37 - 0
src/style/gradientBtn.scss

@@ -0,0 +1,37 @@
+.sc-button-default {
+    padding: 8px 16px;background: linear-gradient(to right, #f796c0, #76aef1);border: none;color: #fff;transition: all 0.3s ease;
+    &:focus, &:hover {background: linear-gradient(to right, #76aef1, #f796c0);color: #fff;}
+    &:active {background: linear-gradient(to right, #f796c0, #76aef1);color: #fff;}
+}
+
+.sc-button-primary {
+    padding: 8px 16px;background: linear-gradient(to right, #0072ff, #00c6ff);border: none;color: #fff;transition: all 0.3s ease;
+    &:focus, &:hover {background: linear-gradient(to right, #00c6ff, #0072ff);color: #fff;}
+    &:active {background: linear-gradient(to right, #0072ff, #00c6ff);color: #fff;}
+}
+
+.sc-button-success {
+    padding: 8px 16px;background: linear-gradient(to right, #56ab2f, #a8e063);border: none;color: #fff;transition: all 0.3s ease;
+    &:focus, &:hover {background: linear-gradient(to right, #a8e063, #56ab2f);color: #fff;}
+    &:active {background: linear-gradient(to right, #56ab2f, #a8e063);color: #fff;}
+}
+
+.sc-button-warning {
+    padding: 8px 16px;background: linear-gradient(to right, #f7b733, #fc4a1a);border: none;color: #fff;transition: all 0.3s ease;
+    &:focus, &:hover {background: linear-gradient(to right, #fc4a1a, #f7b733);color: #fff;}
+    &:active {background: linear-gradient(to right, #f7b733, #fc4a1a);color: #fff;}
+}
+
+.sc-button-info,
+.el-popper.del-pop-confirm .el-button.el-button--info {
+    padding: 8px 16px;background: linear-gradient(to right, #979c9c, #696d69);border: none;color: #fff;transition: all 0.3s ease;
+    &:focus, &:hover {background: linear-gradient(to right, #696d69, #979c9c);color: #fff;}
+    &:active {background: linear-gradient(to right, #979c9c, #696d69);color: #fff;}
+}
+
+.sc-button-danger,
+.el-popper.del-pop-confirm .el-button.el-button--danger {
+    padding: 8px 16px;background: linear-gradient(to right, #ff5f6d, #ffc371);border: none;color: #fff;transition: all 0.3s ease;
+    &:focus, &:hover {background: linear-gradient(to right, #ffc371, #ff5f6d);color: #fff;}
+    &:active {background: linear-gradient(to right, #ff5f6d, #ffc371);color: #fff;}
+}

+ 63 - 0
src/style/media.scss

@@ -0,0 +1,63 @@
+@media (max-width: 992px) {
+	// 移动端样式覆盖
+	.el-form-item {display: block;}
+	.el-form-item__label {display: block;text-align: left;padding: 0 0 10px;}
+	.el-dialog {width: 90%!important;}
+	.el-dialog.is-fullscreen {width: 100%!important;}
+	.el-drawer.rtl {width: 90%!important;}
+	.el-form-item__content {margin-left: 0px!important;}
+
+	.adminui-main {
+		>.el-container {display: block;height:auto;}
+		>.el-container > .el-aside {width: 100%!important;border: 0!important;}
+	}
+	.scTable {
+		.el-table,
+		.el-table__body-wrapper {display: block!important;height:auto!important;}
+		.el-scrollbar__wrap {height:auto!important;}
+		.el-pagination__total,
+		.el-pagination__jump,
+		.el-pagination__sizes {display: none!important;}
+		.btn-prev {margin-left: 0!important;}
+	}
+
+	.headerPublic {
+		height: auto!important;display: block;
+		.left-panel {overflow: auto;}
+		.left-panel::-webkit-scrollbar{display: none;}
+		.right-panel {display: block;margin-top: 15px;}
+		.right-panel .right-panel-search {display: block;}
+		.right-panel .right-panel-search >* {width: 100%;margin: 0;margin-top: 15px;}
+	}
+	.adminui-main > .el-container >*:first-child:not(.el-aside):not(.el-header) {border: 0;margin-top: 0;}
+	.adminui-main > .el-container >*:first-child:not(.el-aside):not(.el-header) + .el-aside {margin-top: 0;}
+	.adminui-main > .el-container > .el-container {margin-top: 15px;}
+	.adminui-main > .el-container > .el-container + .el-aside {margin-top: 15px;}
+	.adminui-main > .el-container > .el-header {@extend .headerPublic;}
+	.adminui-main > .el-container > .el-main.nopadding {margin-top: 15px;}
+	.adminui-main > .el-container > .el-main + .el-aside {margin-top: 15px;}
+	.adminui-main > .el-container > .el-footer {margin-top: 15px;}
+	.adminui-main > .el-container > .el-container > .el-header {@extend .headerPublic}
+	.adminui-main > .el-container > .el-container > .el-header .left-panel {display: block;}
+
+	.sc-page {width: 100%;margin: 0;}
+	
+	.aminui-main-container {
+		.aminui-main-container__query-header {
+			.el-form .el-form-item {
+				display: block;margin-right: 0;
+				.el-select, .el-input {width: 100%;}
+			}
+		} 
+		.aminui-main-container__table-main {height: 100%;}
+	}
+	.aminui-main-container__sc-dialog .el-form {
+    	.el-card .el-card__body, .flex-card.el-card .el-card__body {display: block!important;padding-bottom: 0;}
+		.el-row {display: block;}
+		.el-form-item {
+        	width: 100%;
+			.el-form-item__label-wrap {margin-left: 0!important;}
+			.el-select, .el-input, .el-input-number, .el-date-editor {width: 100%;}
+		}
+	}
+}

+ 4 - 0
src/style/style.scss

@@ -0,0 +1,4 @@
+@import 'app.scss';
+@import 'fix.scss';
+@import 'media.scss';
+@import 'gradientBtn.scss';

+ 29 - 0
src/utils/color.js

@@ -0,0 +1,29 @@
+export default {
+	//hex颜色转rgb颜色
+	HexToRgb(str) {
+		str = str.replace("#", "")
+		var hxs = str.match(/../g)
+		for (var i = 0; i < 3; i++) hxs[i] = parseInt(hxs[i], 16)
+		return hxs
+	},
+	//rgb颜色转hex颜色
+	RgbToHex(a, b, c) {
+		var hexs = [a.toString(16), b.toString(16), c.toString(16)]
+		for (var i = 0; i < 3; i++) {
+			if (hexs[i].length == 1) hexs[i] = "0" + hexs[i]
+		}
+		return "#" + hexs.join("");
+	},
+	//加深
+	darken(color, level) {
+		var rgbc = this.HexToRgb(color)
+		for (var i = 0; i < 3; i++) rgbc[i] = Math.floor(rgbc[i] * (1 - level))
+		return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2])
+	},
+	//变淡
+	lighten(color, level) {
+		var rgbc = this.HexToRgb(color)
+		for (var i = 0; i < 3; i++) rgbc[i] = Math.floor((255 - rgbc[i]) * level + rgbc[i])
+		return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2])
+	}
+}

+ 34 - 0
src/utils/debounce.js

@@ -0,0 +1,34 @@
+export function debounce(func, wait, immediate) {
+    let timeout, args, context, timestamp, result
+  
+    const later = function () {
+      // 据上一次触发时间间隔
+      const last = +new Date() - timestamp
+  
+      // 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
+      if (last < wait && last > 0) {
+        timeout = setTimeout(later, wait - last)
+      } else {
+        timeout = null
+        // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
+        if (!immediate) {
+          result = func.apply(context, args)
+          if (!timeout) context = args = null
+        }
+      }
+    }
+  
+    return function (...args) {
+      context = this
+      timestamp = +new Date()
+      const callNow = immediate && !timeout
+      // 如果延时不存在,重新设定延时
+      if (!timeout) timeout = setTimeout(later, wait)
+      if (callNow) {
+        result = func.apply(context, args)
+        context = args = null
+      }
+  
+      return result
+    }
+  }

+ 33 - 0
src/utils/errorHandler.js

@@ -0,0 +1,33 @@
+/**
+ * 全局代码错误捕捉
+ * 比如 null.length 就会被捕捉到
+ */
+
+export default (error, vm)=>{
+	//过滤HTTP请求错误
+	if(error.status || error.status==0){
+		return false
+	}
+
+	var errorMap = {
+		InternalError: "Javascript引擎内部错误",
+		ReferenceError: "未找到对象",
+		TypeError: "使用了错误的类型或对象",
+		RangeError: "使用内置对象时,参数超范围",
+		SyntaxError: "语法错误",
+		EvalError: "错误的使用了Eval",
+		URIError: "URI错误"
+	}
+	var errorName = errorMap[error.name] || "未知错误"
+
+	console.warn(`[SCUI error]: ${error}`);
+	console.error(error);
+	//throw error;
+
+	vm.$nextTick(() => {
+		vm.$notify.error({
+			title: errorName,
+			message: error
+		});
+	})
+}

+ 187 - 0
src/utils/request.js

@@ -0,0 +1,187 @@
+import axios from 'axios';
+import { ElNotification } from 'element-plus';
+import sysConfig from "@/config";
+
+axios.defaults.baseURL = ''
+
+axios.defaults.timeout = sysConfig.TIMEOUT
+
+// HTTP request 拦截器
+axios.interceptors.request.use(
+	(config) => {
+		if (!sysConfig.REQUEST_CACHE && config.method == 'get') {
+			config.params = config.params || {};
+			config.params['_'] = new Date().getTime();
+		}
+		Object.assign(config.headers, sysConfig.HEADERS)
+		return config;
+	},
+	(error) => {
+		return Promise.reject(error);
+	}
+);
+
+// HTTP response 拦截器
+axios.interceptors.response.use(
+	(response) => {
+		return response;
+	},
+	(error) => {
+		if (error.response) {
+			if (error.response.status == 404) {
+				ElNotification.error({
+					title: '请求错误',
+					message: "Status:404,正在请求不存在的服务器记录!"
+				});
+			} else if (error.response.status == 500) {
+				ElNotification.error({
+					title: '请求错误',
+					message: error.response.data.message || "Status:500,服务器发生错误!"
+				});
+			} else {
+				ElNotification.error({
+					title: '请求错误',
+					message: error.response.data.message || `Status:${error.response.status},未知错误!`
+				});
+			}
+
+			return Promise.reject(error.response);
+		} else {
+			ElNotification.error({
+				title: '请求错误',
+				message: "请求服务器无响应!"
+			});
+
+			return Promise.reject(error);
+		}
+	}
+);
+
+var http = {
+
+	/** get 请求
+	 * @param  {string} url 接口地址
+	 * @param  {object} params 请求参数
+	 * @param  {object} config 参数
+	 */
+	get: function (url, params = {}, config = {}) {
+		return new Promise((resolve, reject) => {
+			axios({
+				method: 'get',
+				url: url,
+				params: params,
+				...config
+			}).then((response) => {
+				resolve(response.data);
+			}).catch((error) => {
+				reject(error);
+			})
+		})
+	},
+
+	/** post 请求
+	 * @param  {string} url 接口地址
+	 * @param  {object} data 请求参数
+	 * @param  {object} config 参数
+	 */
+	post: function (url, data = {}, config = {}) {
+		return new Promise((resolve, reject) => {
+			axios({
+				method: 'post',
+				url: url,
+				data: data,
+				...config,
+			}).then((response) => {
+				resolve(response.data)
+			}).catch((error) => {
+				reject(error);
+			})
+		})
+	},
+
+	/** put 请求
+	 * @param  {string} url 接口地址
+	 * @param  {object} data 请求参数
+	 * @param  {object} config 参数
+	 */
+	put: function (url, data = {}, config = {}) {
+		return new Promise((resolve, reject) => {
+			axios({
+				method: 'put',
+				url: url,
+				data: data,
+				...config
+			}).then((response) => {
+				resolve(response.data);
+			}).catch((error) => {
+				reject(error);
+			})
+		})
+	},
+
+	/** patch 请求
+	 * @param  {string} url 接口地址
+	 * @param  {object} data 请求参数
+	 * @param  {object} config 参数
+	 */
+	patch: function (url, data = {}, config = {}) {
+		return new Promise((resolve, reject) => {
+			axios({
+				method: 'patch',
+				url: url,
+				data: data,
+				...config
+			}).then((response) => {
+				resolve(response.data);
+			}).catch((error) => {
+				reject(error);
+			})
+		})
+	},
+
+	/** delete 请求
+	 * @param  {string} url 接口地址
+	 * @param  {object} data 请求参数
+	 * @param  {object} config 参数
+	 */
+	delete: function (url, data = {}, config = {}) {
+		return new Promise((resolve, reject) => {
+			axios({
+				method: 'delete',
+				url: url,
+				data: data,
+				...config
+			}).then((response) => {
+				resolve(response.data);
+			}).catch((error) => {
+				reject(error);
+			})
+		})
+	},
+
+	/** jsonp 请求
+	 * @param  {string} url 接口地址
+	 * @param  {string} name JSONP回调函数名称
+	 */
+	jsonp: function (url, name = 'jsonp') {
+		return new Promise((resolve) => {
+			var script = document.createElement('script')
+			var _id = `jsonp${Math.ceil(Math.random() * 1000000)}`
+			script.id = _id
+			script.type = 'text/javascript'
+			script.src = url
+			window[name] = (response) => {
+				resolve(response)
+				document.getElementsByTagName('head')[0].removeChild(script)
+				try {
+					delete window[name];
+				} catch (e) {
+					window[name] = undefined;
+				}
+			}
+			document.getElementsByTagName('head')[0].appendChild(script)
+		})
+	}
+}
+
+export default http;

+ 100 - 0
src/utils/tool.js

@@ -0,0 +1,100 @@
+/*
+ * @Descripttion: 工具集
+ * @version: 1.2
+ * @LastEditors: sakuya
+ * @LastEditTime: 2022年5月24日00:28:56
+ */
+
+import moment from "moment";
+
+const tool = {}
+
+/* localStorage */
+tool.data = {
+	set(key, data, datetime = 0) {
+		let cacheValue = {
+			content: data,
+			datetime: parseInt(datetime) === 0 ? 0 : new Date().getTime() + parseInt(datetime) * 1000
+		}
+		return localStorage.setItem(key, JSON.stringify(cacheValue))
+	},
+	get(key) {
+		try {
+			const value = JSON.parse(localStorage.getItem(key))
+			if (value) {
+				let nowTime = new Date().getTime()
+				if (nowTime > value.datetime && value.datetime != 0) {
+					localStorage.removeItem(key)
+					return null;
+				}
+				return value.content
+			}
+			return null
+		} catch (err) {
+			return null
+		}
+	},
+	remove(key) {
+		return localStorage.removeItem(key)
+	},
+	clear() {
+		return localStorage.clear()
+	}
+}
+
+/*sessionStorage*/
+tool.session = {
+	set(table, settings) {
+		var _set = JSON.stringify(settings)
+		return sessionStorage.setItem(table, _set);
+	},
+	get(table) {
+		var data = sessionStorage.getItem(table);
+		try {
+			data = JSON.parse(data)
+		} catch (err) {
+			return null
+		}
+		return data;
+	},
+	remove(table) {
+		return sessionStorage.removeItem(table);
+	},
+	clear() {
+		return sessionStorage.clear();
+	}
+}
+
+/* Fullscreen */
+tool.screen = function (element) {
+	var isFull = !!(document.webkitIsFullScreen || document.mozFullScreen || document.msFullscreenElement || document.fullscreenElement);
+	if (isFull) {
+		if (document.exitFullscreen) {
+			document.exitFullscreen();
+		} else if (document.msExitFullscreen) {
+			document.msExitFullscreen();
+		} else if (document.mozCancelFullScreen) {
+			document.mozCancelFullScreen();
+		} else if (document.webkitExitFullscreen) {
+			document.webkitExitFullscreen();
+		}
+	} else {
+		if (element.requestFullscreen) {
+			element.requestFullscreen();
+		} else if (element.msRequestFullscreen) {
+			element.msRequestFullscreen();
+		} else if (element.mozRequestFullScreen) {
+			element.mozRequestFullScreen();
+		} else if (element.webkitRequestFullscreen) {
+			element.webkitRequestFullscreen();
+		}
+	}
+}
+
+/* 日期格式化 */
+tool.dateFormat = function (date, fmt = "YYYY-MM-DD HH:mm:ss") {
+	if (date.includes("T") && date.includes("Z")) return moment(date, "YYYY-MM-DDTHH:mm:ss[Z]").format(fmt);
+	return moment(date).format(fmt);
+}
+
+export default tool

+ 156 - 0
src/views/attendance/index.vue

@@ -0,0 +1,156 @@
+<template>
+    <el-container class="aminui-main-container">
+        <el-header class="aminui-main-container__query-header">
+            <scTitle border>查询条件</scTitle>
+            <el-form inline>
+                <el-form-item label="设备编号">
+                    <el-select v-model="deviceNum" clearable placeholder="请选择设备">
+                        <el-option v-for="(item, index) in devices" :key="index" :label="item.device" :value="index"></el-option>
+                    </el-select>
+                </el-form-item>
+                <el-form-item label="考勤类型">
+                    <el-select v-model="attTypes" multiple collapse-tags clearable placeholder="请选择考勤类型">
+                        <el-option v-for="(label, key) in attModel" :key="key" :label="label" :value="key"></el-option>
+                    </el-select>
+                </el-form-item>
+                <el-form-item label="身份证号">
+                    <el-input v-model="idCard" clearable placeholder="输入身份证号"></el-input>
+                </el-form-item>
+                <el-form-item label="考勤时间">
+                    <el-date-picker v-model="datePicker.date" :clearable="false" placeholder="选择考勤日期"></el-date-picker>
+                    <el-time-picker v-model="datePicker.time" is-range range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间"></el-time-picker>
+                </el-form-item>
+                <el-form-item class="el-form-item__button">
+                    <el-button class="sc-button-primary" icon="el-icon-search" @click="reloadTable">查询</el-button>
+                </el-form-item>
+            </el-form>
+        </el-header>
+
+        <el-container class="aminui-main-container__table-main">
+            <el-header>
+                <div class="left-panel"><scTitle>考勤列表</scTitle></div>
+            </el-header>
+            <el-main class="nopadding">
+                <scTable class="scTable" ref="table" row-key="id" :apiObj="apiObj" :params="params" pageParams="current" :column="column" hideSetting stripe align="center">
+				    <template #createTime="scope"><span v-time="scope.row.create_time" format="YYYY-MM-DD HH:mm:ss"></span></template>
+				    <template #type="scope">{{ formatType(scope.row) }}</template>
+                    <el-table-column label="操作" fixed="right" align="center" width="120">
+                        <template #default="scope">
+                            <el-button-group>
+                                <el-button style="--sc-button-start-color: #3994fc; --sc-button-end-color: #0679fc; --sc-button-shadow-color: #0252ad;" class="sc-button-press" @click="openRow(scope.row)">查看</el-button>
+                            </el-button-group>
+                        </template>
+                    </el-table-column>
+                </scTable>
+            </el-main>
+        </el-container>
+    </el-container>
+
+    <sc-image-viewer :showViewer="showViewer" :imageList="imageList" teleported @close="showViewer = false"></sc-image-viewer>
+</template>
+
+<script>
+	import moment from "moment";
+    import { defineAsyncComponent } from "vue";
+    
+    export default {
+        components: {
+            scImageViewer: defineAsyncComponent(() => import("@/components/scUpload/imageViewer"))
+        },
+
+        data() {
+            return {
+                attModel: {
+                    teacher_enter: "教师考勤",
+                    student_enter: "学生出勤-进",
+                    student_exit: "学生出勤-出"
+                },
+                devices: [],
+
+                deviceNum: "",
+                idCard: "",
+                attTypes: [],
+                datePicker: {
+                    date: moment(),
+                    time: [moment().startOf("day"), moment().endOf("day")]
+                },
+
+                apiObj: null,
+                params: {},
+                column: [
+                    { label: "设备编号", prop: "device_num", width: 200 },
+                    { label: "身份证号", prop: "id_card", width: 200 },
+                    { label: "考勤类型", prop: "type", width: 200 },
+                    { label: "考勤时间", prop: "createTime", width: 200 }
+                ],
+
+                showViewer: false,
+                imageList: []
+            }
+        },
+
+        mounted() {
+            this.attTypes = Object.keys(this.attModel);
+            this.getCameras();
+            this.reloadTable();
+        },
+
+		methods: {
+            formatType({ type }) {
+                return this.attModel[type] || "";
+            },
+            
+            async getCameras() {
+                try {
+                    this.devices = await this.$API.camera.list.get();
+                } catch (error) {
+                    this.devices = [];
+                }
+            },
+
+            reloadTable() {
+                const { date, time } = this.datePicker;
+                const params = {
+                    createTimeBegin: moment(date).format("YYYY-MM-DD") + moment(time[0]).format(" HH:mm:ss"),
+                    createTimeEnd: moment(date).format("YYYY-MM-DD") + moment(time[1]).format(" HH:mm:ss")
+                }
+                if (this.attTypes && this.attTypes.length) params["types"] = this.attTypes;
+                if (this.deviceNum) params["deviceNum"] = this.deviceNum;
+                if (this.idCard) params["idCard"] = this.idCard;
+                this.params = Object.assign({}, params);
+                
+                if (this.apiObj) this.$refs.table.reload(this.params, this.$refs.table.currentPage);
+                else this.apiObj = this.$API.attendance.record;
+            },
+
+            openRow({ id_card, image }) {
+                if (!image) return this.$message({ showClose: true, type: "error", message: `image not found ${id_card}` });
+                this.showViewer = true;
+                this.imageList = ["/minio" + image];
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+.aminui-main-container {
+  .aminui-main-container__query-header .el-form {
+    :deep(.el-date-editor.el-date-editor--date) {
+      width: 120px;
+      margin-right: 10px;
+    }
+
+    :deep(.el-date-editor.el-date-editor--timerange) {
+      width: 200px;
+
+      .el-icon {
+        display: none;
+      }
+
+      .el-range-input {
+        width: 100%;
+      }
+    }
+  }
+}
+</style>

+ 120 - 0
src/views/channel/channel.vue

@@ -0,0 +1,120 @@
+<template>
+	<sc-dialog v-model="visible" :title="titleMap[mode]" width="500" :showFullscreen="false" @closed="$emit('closed')">
+		<el-form ref="dialogForm" :model="form" :rules="rules" label-width="80px">
+			<el-form-item label="设备编号" prop="device">
+                <el-input v-model="form.device" placeholder="输入设备编号"></el-input>
+            </el-form-item>
+            <el-form-item label="ip" prop="ip">
+                <el-input v-model="form.ip" placeholder="输入IP"></el-input>
+			</el-form-item>
+            <el-form-item label="用户名" prop="user">
+                <el-input v-model="form.user" placeholder="输入用户名"></el-input>
+            </el-form-item>
+			<el-form-item label="密码" prop="password">
+                <el-input v-model="form.password" type="password" show-password placeholder="输入密码"></el-input>
+            </el-form-item>
+			<el-form-item v-if="$TOOL.data.get('CONFIG_TYPE') == 2" label="通道号" prop="channel">
+				<el-input-number v-model="form.channel" :min="1" :max="99" placeholder="输入通道号" :controls="false" />
+            </el-form-item>
+		</el-form>
+		<template #footer>
+			<el-button class="sc-button-info" @click="visible = false">取 消</el-button>
+			<el-button class="sc-button-primary" :loading="isSaveing" @click="submit">确 定</el-button>
+		</template>
+	</sc-dialog>
+</template>
+
+<script>
+	import { channelData } from "@/views/config/main";
+
+	export default {
+		emits: ["success", "closed"],
+
+		data() {
+			return {
+				mode: "add",
+				titleMap: {
+					add: "新增设备",
+					edit: "编辑设备"
+				},
+				visible: false,
+				isSaveing: false,
+
+				form: Object.assign({}, channelData),
+				rules: {
+					ip: [{ required: true, message: "请输入IP" }],
+					user: [{ required: true, message: "请输入用户名" }],
+					password: [{ required: true, message: "请输入密码" }],
+					device: [{ required: true, message: "请输入设备编号" }],
+					channel: [{ required: true, message: "请输入通道号" }]
+				}
+			}
+		},
+
+		methods: {
+			/* 通道补0 */
+			formatChannel(num) {
+				if (num >= 10) return num;
+				return "0" + num;
+			},
+
+			// 显示
+			open(mode = "add") {
+				this.mode = mode;
+				this.visible = true;
+				return this;
+			},
+
+			// 表单注入数据
+			setData(data) {
+				for (const key in channelData) {
+					if (key == "channel") this.form[key] = JSON.parse(data)[key] && parseInt((JSON.parse(data)[key] + "").slice(0, -2)) || "";
+					else this.form[key] = JSON.parse(data)[key];
+				}
+
+				this.form["id"] = JSON.parse(data)["id"];
+				if (JSON.parse(data)["rules"]) this.form["rules"] = JSON.parse(data)["rules"];
+			},
+			
+			// 表单提交方法
+			submit() {
+				this.$refs.dialogForm.validate(valid => {
+					if (valid) {
+						const data = {};
+						for (const key in this.form) {
+							if (key == "channel") {
+								if (this.form[key]) data[key] = this.formatChannel(this.form[key]) + "01"
+							} else data[key] = this.form[key];
+						}
+
+						this.isSaveing = true;
+						this.$API.camera[this.mode].post(data).then(() => {
+							this.isSaveing = false;
+							this.$message.success("操作成功");
+							this.visible = false;
+							this.$emit("success")
+						}).catch(() => this.isSaveing = false);
+					} else {
+						return false;
+					}
+				});
+			}
+		}
+	}
+</script>
+
+
+<style lang="scss" scoped>
+.el-form {
+  width: 80%;
+  margin: 20px auto 0;
+
+  .el-input-number {
+    width: 100%;
+
+    :deep(.el-input__inner) {
+      text-align: left;
+    }
+  }
+}
+</style>

+ 160 - 0
src/views/channel/index.vue

@@ -0,0 +1,160 @@
+<template>
+    <el-container class="aminui-main-container">
+        <el-header class="aminui-main-container__query-header">
+            <scTitle border>查询条件</scTitle>
+            <el-form :model="params" inline>
+                <el-form-item label="设备编号">
+                    <el-input v-model="params.deviceLike" clearable placeholder="输入设备编号"></el-input>
+                </el-form-item>
+                <el-form-item class="el-form-item__button">
+                    <el-button class="sc-button-primary" icon="el-icon-search" @click="reloadTable">查询</el-button>
+                </el-form-item>
+            </el-form>
+        </el-header>
+
+        <el-container class="aminui-main-container__table-main">
+            <el-header>
+                <div class="left-panel"><scTitle>摄像头列表</scTitle></div>
+                <div class="right-panel">
+                    <el-button class="sc-button-primary" icon="el-icon-plus" size="small" @click="table_add"></el-button>
+                </div>
+            </el-header>
+            <el-main class="nopadding">
+                <scTable class="scTable" ref="table" row-key="id" :apiObj="null" :data="cameraList" hidePagination hideSetting stripe>
+                    <el-table-column label="设备编号" prop="device" align="center"></el-table-column>
+                    <el-table-column v-if="$TOOL.data.get('CONFIG_TYPE') == 2" label="通道号" prop="channel" align="center"></el-table-column>
+                    <el-table-column label="IP" prop="ip" align="center"></el-table-column>
+                    <el-table-column label="用户名" prop="user" align="center"></el-table-column>
+                    <el-table-column label="密码" prop="password" align="center"></el-table-column>
+                    <el-table-column label="状态" prop="status" align="center">
+                        <template #default="scope">
+                            <el-tag v-if="formatStatus(scope.$index)" type="success">在线</el-tag>
+                            <el-tag v-else type="danger">离线</el-tag>
+                        </template>
+                    </el-table-column>
+                    <el-table-column label="操作" fixed="right" align="center" width="120">
+                        <template #default="scope">
+                            <el-button-group>
+                                <el-button style="--sc-button-start-color: #65eba1;--sc-button-end-color: #13ce66;--sc-button-shadow-color: #1da158;" class="sc-button-press" @click.stop="table_edit(scope)">编辑</el-button>
+                                <el-button style="--sc-button-start-color: #f59790;--sc-button-end-color: #eb4c5b;--sc-button-shadow-color: #c32d3b;" class="sc-button-press" @click.stop="table_del(scope.$index)">删除</el-button>
+                            </el-button-group>
+                        </template>
+                    </el-table-column>
+                </scTable>
+            </el-main>
+        </el-container>
+    </el-container>
+
+	<channel-dialog v-if="dialog" ref="channelDialog" @success="reloadTable" @closed="dialog = false"></channel-dialog>
+</template>
+
+<script>
+	import channelDialog from "./channel";
+
+    export default {
+		components: {
+			channelDialog
+		},
+
+        data() {
+			return {
+                params: {
+                    deviceLike: ""
+                },
+                cameraList: [],
+                state: {
+                    0: { value: "offline", interval: null }
+                },
+
+				dialog: false
+            }
+        },
+
+        mounted() {
+            this.reloadTable();
+        },
+
+        beforeUnmount() {
+            this.clearState();
+        },
+
+		methods: {
+            formatStatus(id) {
+                return this.state[id] && this.state[id].value == "online";
+            },
+
+            clearState() {
+                for (const id in this.state) {
+                    this.state[id]["value"] = "offline";
+
+                    if (this.state[id]["interval"]) {
+                        clearInterval(this.state[id]["interval"]);
+                        this.state[id]["interval"] = null;
+                    }
+                }
+            },
+
+            async reloadTable() {
+                try {
+                    this.clearState();
+                    this.$refs.table.loading = true;
+                    const res = await this.$API.camera.list.get(this.params);
+                    this.$refs.table.loading = false;
+                    this.cameraList = res;
+                    res.forEach((item, index) => {
+                        if (!this.state[index]) this.state[index] = {}
+                        this.deviceCheck(index);
+                        this.state[index]["interval"] = setInterval(() => this.deviceCheck(index), 120 * 1000);
+                    });
+                } catch (error) {
+                    this.clearState();
+                    this.cameraList = [];
+                    this.$refs.table.loading = false;
+                }
+            },
+
+            async deviceCheck(id) {
+                try {
+                    const res = await this.$API.camera.state.get({ id });
+                    this.state[id]["value"] = res && res.result || "offline";
+                } catch (error) {
+                    this.state[id]["value"] = "offline";
+                }
+            },
+
+            closed() {
+                this.dialog = false;
+				this.reloadTable();
+            },
+
+            // 添加设备
+			table_add() {
+				this.dialog = true;
+				this.$nextTick(() => this.$refs.channelDialog.open());
+			},
+
+			// 编辑设备
+			table_edit({ row, $index }) {
+				this.dialog = true;
+				this.$nextTick(() => this.$refs.channelDialog.open("edit").setData(JSON.stringify({ ...row, id: $index })));
+			},
+
+            // 删除设备
+			table_del(id) {
+				this.$confirm("确认删除当前的设备吗?", "提示", {
+					type: "warning",
+					confirmButtonText: "删除",
+					confirmButtonClass: "sc-button-danger",
+					cancelButtonClass: "sc-button-info"
+				}).then(() => {
+					this.$API.camera.del.post({ id }).then(res => {
+                        if (res == "success") {
+                            this.$message.success("操作成功");
+                            this.reloadTable();
+                        } else this.$notify.error({ title: "删除失败", message: res.message || res || "未知错误" });
+					}).catch(() => {});
+				}).catch(() => {});
+			}
+        }
+    }
+</script>

+ 156 - 0
src/views/config/canvas.vue

@@ -0,0 +1,156 @@
+<!--
+ * @Descripttion: Canvas
+ * @version: 1.1
+ * @Date: 2021年11月29日12:10:06
+ * @LastEditTime: 2023年12月22日12:02:50
+-->
+
+<template>
+	<div :class="['sc-canvas', !isSuccess ? 'z-index-0' : isCurrent ? 'z-index-2' : '']" @contextmenu.prevent>
+		<canvas ref="canvas" width="640" height="360" @mousedown="mousedown" @mouseup="mouseup" @mousemove="mousemove" @mouseleave="mouseleave">当前浏览器版本不支持,请升级</canvas>
+	</div>
+</template>
+
+<script>
+	import { pointsDiff } from "./main";
+	let ctx = null
+
+	export default {
+		emits: ["change", "success"],
+		props: {
+			scale: { type: Number, default: () => 4 },
+			isSuccess: { type: Boolean, default: true },
+			ruleItem: { type: Object, default: () => {} },
+			isCurrent: { type: Boolean, default: false }
+		},
+
+		data() {
+			return {
+				canvasPoints: [], // 转换为实际坐标
+				moveIndex: -1 // 可移动的点
+			}
+		},
+
+		watch: {
+			ruleItem() {
+				this.init("watch:ruleItem");
+			},
+
+			isCurrent() {
+				this.graphing();
+			},
+
+			canvasPoints: {
+				deep: true,
+				handler(value) {
+					this.$emit("change", value);
+				}
+			}
+		},
+
+		mounted() {
+			this.init();
+		},
+
+		methods: {
+			init() {
+				this.canvasPoints = this.ruleItem.points && this.ruleItem.points.length && this.ruleItem.points.map(p => ({ x: p.x / this.scale, y: 360 - (p.y / this.scale) })) || [];
+				this.graphing();
+			},
+
+			// 绘制区域
+			graphing() {
+				ctx = this.$refs.canvas.getContext("2d");
+				ctx.clearRect(0, 0, 640, 360); // 清除画布上的内容
+				if (this.canvasPoints.length) {
+					ctx.beginPath();
+					const points = this.canvasPoints.slice();
+					points.push(points[0]);
+					points.forEach(({ x, y }, index) => {
+						if (index == 0) ctx.moveTo(x, y); // 从第一个顶点开始绘制路径
+						else ctx.lineTo(x, y);
+					});
+					ctx.strokeStyle = this.isCurrent && "#1890ff" || "#2be9eb"; // 设颜色
+					ctx.lineWidth = 3.5; // 线条粗细
+					ctx.stroke(); // 描边,使多边形可见
+					if (this.isCurrent) {
+						ctx.fillStyle = "rgba(24, 144, 255, .2)";
+						ctx.fill();
+					}
+
+					this.drawVertices();
+				}
+			},
+
+			// 绘制顶点
+			drawVertices() {
+				this.canvasPoints.forEach(({ x, y }) => {
+					ctx.beginPath();
+					// 填充样式
+					ctx.lineWidth = 2.5;
+					ctx.strokeStyle = "#fff";
+					ctx.fillStyle = this.isCurrent && "#1890ff" || "#2be9eb";
+					// x,y,半径,起始点,终点,(方向)
+					ctx.arc(x, y, 5, 0, 2 * Math.PI);
+					ctx.stroke();
+					ctx.fill();
+				});
+			},
+
+			mousedown({ button, offsetX, offsetY }) {
+				if (this.isCurrent && !this.ruleItem.event.find(e => e == "face" || e == "face_att_teacher")) {
+					const movePoint = { x: offsetX, y: offsetY };
+					let moveIndex = -1;
+					if (button == 0) moveIndex = this.canvasPoints.findIndex(item => pointsDiff(item, movePoint));
+					this.moveIndex = moveIndex;
+
+					if (this.ruleItem.id == -1 && this.canvasPoints.length < 6) { // 新增
+						// 当前点位数量大于等于4 点开始点位/单击右键 -> 闭合路径
+						if (this.canvasPoints.length >= 4 && (moveIndex == 0 || button == 2)) this.$emit("success");
+						else {
+							if (button == 0 && moveIndex == -1) {
+								this.canvasPoints.push(movePoint);
+								if (this.canvasPoints.length == 6) this.$emit("success");
+								this.graphing();
+							}
+						}
+					}
+				}
+			},
+
+			mouseup() {
+				this.moveIndex = -1;
+			},
+
+			mousemove(e) {
+				if (this.isCurrent && !this.ruleItem.event.find(e => e == "face" || e == "face_att_teacher") && this.moveIndex != -1) {
+					const movePoint = { x: e.offsetX, y: e.offsetY };
+					this.canvasPoints[this.moveIndex] = Object.assign({}, movePoint);
+					this.graphing();
+				}
+			},
+
+			mouseleave() {
+				if (this.isCurrent && !this.ruleItem.event.find(e => e == "face" || e == "face_att_teacher") && this.moveIndex != -1) this.moveIndex = -1;
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+.sc-canvas {
+  position: absolute;
+  top: 0;
+  z-index: 1;
+  width: 640px;
+  height: 360px;
+}
+
+.z-index-0 {
+  z-index: -1;
+}
+
+.z-index-2 {
+  z-index: 2;
+}
+</style>

+ 415 - 0
src/views/config/index.vue

@@ -0,0 +1,415 @@
+<template>
+    <el-container class="aminui-main-container">
+        <el-header class="aminui-main-container__query-header">
+            <scTitle border>查询条件</scTitle>
+            <el-form :class="visible && 'relative'" :model="params" inline>
+                <scModal v-if="visible"></scModal>
+                <el-form-item label="设备编号">
+                    <el-select v-model="params.channelid" placeholder="请选择摄像头">
+                        <el-option v-for="(item, index) in channels" :key="index" :label="item.device" :value="index"></el-option>
+                    </el-select>
+                </el-form-item>
+                <el-form-item class="el-form-item__button">
+                    <el-button class="sc-button-primary" icon="el-icon-search" @click="reloadData">查询</el-button>
+                </el-form-item>
+            </el-form>
+        </el-header>
+
+        <el-container class="aminui-main-container__table-main">
+            <el-header>
+                <div class="left-panel"><scTitle>智能配置</scTitle></div>
+                <div :class="['right-panel', (!isSuccess || visible) && 'relative']">
+                    <scModal v-if="!isSuccess || visible"></scModal>
+                    <el-popover popper-class="rules-popper" :visible="visible" title="绘制规则" :width="300" placement="top" trigger="click">
+                        <el-container class="rules-popper__content">
+                            <el-main class="nopadding">
+                                <el-steps :active="stepNums" finish-status="success" simple>
+                                    <el-step v-for="num in 4" :key="num" :title="num + ''" />
+                                </el-steps>
+                                <p>(<span class="pl-1">1</span><span class="pl-1 pr-1">)</span> 单击左键绘制 4 ~ 6 点 ({{ stepNums }} / 6)</p>
+                                <p>(2) 闭合完成(点击起始点 | 单击右键)</p>
+                                <el-container class="rules-popper__btns">  
+                                    <el-button class="sc-button-primary" @click="draw_fullScreen">绘制全屏</el-button>
+                                    <el-button class="sc-button-danger" @click="draw_cancel">取消绘制</el-button>
+                                </el-container>
+                            </el-main>
+                        </el-container>
+                        <template #reference>
+                            <el-button class="sc-button-primary" icon="el-icon-edit" size="small" @click="draw_add"></el-button>
+                        </template>
+                    </el-popover>
+                    <el-tooltip content="规则删除" placement="top">
+                        <el-button class="sc-button-danger" icon="el-icon-delete" size="small" @click="draw_del"></el-button>
+                    </el-tooltip>
+                </div>
+            </el-header>
+            
+            <el-row :gutter="15">
+                <el-col :lg="16" :md="24">
+                    <el-main class="nopadding">
+                        <div v-loading="loading" class="left-video">
+                            <el-empty v-if="!hasChannel" description="暂无设备"></el-empty>
+                            <template v-else>
+                                <el-image :src="videoUrl" @load="videoSuccess" @error="videoError">
+                                    <template #placeholder>
+                                        <div class="video__img-slot">Loading...</div>
+                                    </template>
+                                    <template #error>
+                                        <el-empty description="设备不在线"></el-empty>
+                                    </template>
+                                </el-image>
+
+                                <video-canvas :ref="`videoCanvas${index}`" v-for="(item, index) in rules" :key="item.id" :scale="scale" :isSuccess="isSuccess" :ruleItem="item" :isCurrent="rulesIndex == index" @change="draw_change" @success="draw_success"></video-canvas>
+                            </template>
+                        </div>
+                    </el-main>
+                </el-col>
+                <el-col :lg="8" :md="24">
+                    <el-container class="right-rules-container">
+                        <el-header :class="(!isSuccess || visible) && 'relative'">
+                            <scModal v-if="!isSuccess || visible"></scModal>
+                            <div class="left-panel">
+                                <el-button :class="isSuccess && !visible && rulesIndex == index && 'is-selected'" v-for="(item, index) in rules" :key="index" type="primary" plain @click="rules_change(index)">{{ index + 1 }}区域</el-button>
+                            </div>
+                            <div class="right-panel">
+                                <el-button class="sc-button-primary" :loading="isSaveing" icon="el-icon-finished" size="small" @click="submit"></el-button>
+                            </div>
+                        </el-header>
+                        <el-main class="nopadding">
+                            <el-scrollbar>
+                                <el-card :class="(!isSuccess || visible) && 'relative'" shadow="never">
+                                    <scModal v-if="!isSuccess || visible" :zIndex="2"></scModal>
+                                    <template #header><scTitle>人脸配置</scTitle></template>
+                                    <el-checkbox-group v-model="rules[rulesIndex].event" size="large">
+                                        <el-checkbox v-for="(item, key) in algorithmDic['faceModel']" :key="key" :label="item.label" :value="key" border :disabled="item.disabled" @change="algoChange($event, key)"></el-checkbox>
+                                        <scTitle style="width: 100%; margin-top: 15px;">算法配置</scTitle>
+                                        <el-checkbox v-for="(item, key) in algorithmDic['otherModel']" :key="key" :label="item.label" :value="key" border :disabled="item.disabled" @change="algoChange($event, key)"></el-checkbox>
+                                    </el-checkbox-group>
+                                </el-card>
+                            </el-scrollbar>
+                        </el-main>
+                    </el-container>
+                </el-col>
+            </el-row>
+        </el-container>
+    </el-container>
+</template>
+
+<script>
+  	import { defineAsyncComponent } from "vue";
+    import { algorithmDic, fullScreenPoints } from "./main";
+
+    export default {
+		components: {
+			videoCanvas: defineAsyncComponent(() => import("./canvas"))
+        },
+
+        data() {
+			return {
+                loading: false,
+                isSuccess: false,
+				isSaveing: false,
+
+                algorithmDic,
+                channels: [],
+
+                params: {
+                    channelid: ""
+                },
+                
+                videoUrl: "",
+                scale: 4,
+                rulesIndex: 0,
+                rules: [{
+                    id: 0, // 区域id(0-3)
+                    event: [], // 行为类/人脸
+                    points: []
+                }],
+
+                visible: false
+            }
+        },
+
+        computed: {
+            hasChannel() {
+                return typeof this.params.channelid === "number";
+            },
+
+            stepNums() {
+                return this.rules[this.rulesIndex].points.length;
+            }
+        },
+
+        mounted() {
+            this.getChannel();
+        },
+
+		methods: {
+            async getChannel() {
+                this.channels = await this.$API.camera.list.get();
+                if (this.channels[0]) this.params.channelid = 0;
+                this.reloadData();
+            },
+
+            // 本地更新数据
+			async reloadData() {
+                this.videoUrl = "";
+                this.scale = 4;
+                try {
+                    if (this.hasChannel) {
+                        this.loading = true;
+                        const { result, width } = await this.$API.camera.state.get({ id: this.params.channelid });
+                        this.loading = false;
+                        if (result && result == "online") {
+                            this.videoUrl = `/api/video_stream?id=${this.params.channelid}`;
+                            this.scale = width / 640;
+                        }
+                    }
+                } catch (error) {
+                    this.loading = false;
+                }
+            },
+
+            videoSuccess() {
+                this.rulesIndex = 0;
+                this.rules = this.channels[this.params.channelid].rules || [{ id: 0, event: [], points: [] }];
+                this.isSuccess = true;
+            },
+
+            videoError() {
+                this.isSuccess = false;
+                if (this.videoUrl) {
+                    this.rulesIndex = 0;
+                    this.rules = [{ id: 0, event: [], points: [] }];
+                }
+            },
+
+            rules_change(index) {
+                this.rulesIndex = index;
+            },
+
+            algoChange(e, key) {
+                if ((key == "face" || key == "face_att_teacher") && e) {
+                    this.rules[this.rulesIndex].points = fullScreenPoints.slice();
+                    this.$refs[`videoCanvas${this.rulesIndex}`][0].init();
+                }
+
+                // 考勤单独区域
+                if (key.includes("face_att")) {
+                    if (e) {
+                        const RIndex = this.rules.findIndex((r, i) => i != this.rulesIndex && r.event[0] == key)
+                        if (RIndex != -1) {
+                            this.rules[this.rulesIndex].event = this.rules[this.rulesIndex].event.filter(item => item != key);
+                            return this.$notify.error({ title: "提示", message: `区域${RIndex + 1}已存在${algorithmDic['faceModel'][key].label}算法`, duration: 1500 });
+                        } else this.rules[this.rulesIndex].event = [key];
+                    }
+                } else this.rules[this.rulesIndex].event = this.rules[this.rulesIndex].event.filter(item => !item.includes("face_att"));
+            },
+
+            // 绘制区域
+			draw_add() {
+				if (this.rules.length == 4) return this.$notify.warning({ title: "提示", message: "最多绘制4个区域", duration: 1500 });
+                this.visible = true;
+                if (this.rules[this.rulesIndex].points.length) {
+                    this.rules.push({ id: -1, event: [], points: [] });
+                    this.rulesIndex = this.rules.length - 1;
+                } else this.rules[this.rulesIndex].id = -1;
+			},
+
+            draw_change(points) {
+                this.rules[this.rulesIndex].points = points.map(item => ({ x: item.x * this.scale, y: (360 - item.y) * this.scale }));
+            },
+
+            draw_fullScreen() {
+                this.rules[this.rulesIndex].points = fullScreenPoints.slice();
+                this.draw_success();
+            },
+
+            draw_success() {
+                this.rules[this.rulesIndex].id = [0, 1, 2, 3].find(id => !this.rules.find(r => r.id == id));
+                this.visible = false;
+            },
+
+            // 取消绘制
+            draw_cancel() {
+                if (this.rules.length == 1) this.rules = [{ id: 0, event: [], points: [] }];
+                else {
+                    this.rules.pop();
+                    this.rulesIndex = 0;
+                }
+                this.visible = false;
+            },
+
+            draw_del() {
+                if (this.rules.length == 1) this.rules = [{ id: 0, event: [], points: [] }];
+                else {
+                    this.rules.splice(this.rulesIndex, 1);
+                    this.rulesIndex = 0;
+                }
+            },
+
+            async submit() {
+                try {
+                    this.rules.forEach((item, index) => {
+                        if (!item.points.length) throw `区域${index + 1}: 请绘制区域`;
+                        if (!item.event.length) throw `区域${index + 1}: 请选择算法`;
+                        if (item.event.length == 1 && item.event[0] == "face") throw `区域${index + 1}: 不能只选择人脸识别`;
+                    });
+                    
+                    const data = {
+                        ...this.channels[this.params.channelid],
+                        id: this.params.channelid,
+                        rules: this.rules
+                    };
+                    this.isSaveing = true;
+                    this.$API.camera.edit.post(data).then(() => {
+                        this.isSaveing = false;
+                        this.$message.success("下发成功");
+                    }).catch(() => this.isSaveing = false);
+                } catch (error) {
+                    this.$notify.error({ title: "提示", message: error, duration: 1500 });
+                }
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+.aminui-main-container .aminui-main-container__table-main {
+  .el-row {
+    flex: 1;
+    overflow: auto;
+    box-sizing: border-box;
+
+    .el-col {
+      height: 100%;
+
+      .el-main {
+        height: 100%;
+
+        .left-video {
+          position: relative;
+          display: flex;
+          justify-content: center;
+          width: 100%;
+          height: 100%;
+          border: 1px solid var(--el-border-color-light);
+          border-radius: 4px;
+
+          .el-empty {
+            width: 640px;
+            height: 360px;
+          }
+
+          .el-image {
+            width: 640px;
+            height: 360px;
+
+            .video__img-slot {
+              display: flex;
+              justify-content: center;
+              align-items: center;
+              width: 100%;
+              height: 100%;
+              font-size: 12px;
+              color: var(--el-text-color-placeholder);
+              background-color: var(--el-fill-color-lighter);
+            }
+
+            .el-empty {
+              width: 100%;
+              height: 100%;
+            }
+          }
+        }
+      }
+
+      .right-rules-container {
+        .el-header {
+          margin-bottom: 20px;
+          padding: 0;
+          border: none;
+
+          .left-panel {
+            margin-right: 10px;
+
+            .el-button {
+              --el-button-bg-color: transparent;
+              width: 52px;
+              height: 28px;
+              padding: 4px 0;
+              font-size: var(--el-font-size-small);
+            }
+
+            .el-button + .el-button {
+              margin-left: 10px;
+            }
+
+            .el-button.is-selected {
+              position: relative;
+
+              &::after {
+                content: "";
+                position: absolute;
+                bottom: -12px;
+                left: 18px;
+                border: 8px solid #1890ff;
+                border-top: none;
+                border-left-color: rgba(0, 0, 0, 0);
+                border-right-color: rgba(0, 0, 0, 0);
+              }
+            }
+          }
+        }
+
+        .el-main {
+          padding: 15px 0 10px;
+          border-radius: 4px;
+          border: 1px solid var(--el-border-color-light);
+
+          .el-card {
+            height: 100%;
+            padding: 0 10px;
+            margin-bottom: 0;
+            border: none;
+
+            :deep(.el-card__header),
+            :deep(.el-card__body) {
+              padding: 0;
+            }
+
+            :deep(.el-card__body) {
+              .el-checkbox-group {
+                display: flex;
+                flex-wrap: wrap;
+                justify-content: space-between;
+
+                .el-checkbox {
+                  display: flex;
+                  flex-direction: row-reverse;
+                  justify-content: space-between;
+                  align-items: center;
+                  width: calc(50% - 6px);
+                  margin: 12px 0 0;
+                  padding: 0 11px;
+
+                  .el-checkbox__label {
+                    padding-left: 0;
+                    padding-right: 8px;
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+@media (max-width: 1200px) {
+  .aminui-main-container .aminui-main-container__table-main .el-row .el-col {
+    margin-bottom: 15px;
+  }
+}
+</style>

+ 52 - 0
src/views/config/main.js

@@ -0,0 +1,52 @@
+/**
+ * 计算两个点之间的距离是否在5以内 用于判定两个点是否重合
+ * @param {Object} point1
+ * @param {Object} point2
+ */
+export function pointsDiff(point1, point2) {
+    let diffX = Math.abs(point1.x - point2.x);
+    let diffY = Math.abs(point1.y - point2.y);
+    return Math.sqrt(diffX * diffX + diffY * diffY) <= 6;
+}
+
+// 
+/**
+ * 行为类算法
+ * @disabled 暂不支持
+*/
+export const algorithmDic = {
+    faceModel: {
+        face: { label: "人脸识别" },
+        // face_att_teacher: { label: "教师考勤" },
+        // face_att_student_enter: { label: "学生出勤-进" },
+        // face_att_student_exit: { label: "学生出勤-出" }
+    },
+
+    otherModel: {
+        helmet: { label: "未戴安全帽" },
+        smokingdetection: { label: "吸烟告警", disabled: true },
+        fumesdetection: { label: "烟雾监测", disabled: true },
+        fire: { label: "明火告警" },
+        invade: { label: "区域入侵" },
+        fall: { label: "跌倒检测" },
+        fight: { label: "打架斗殴" },
+        vest: { label: "反光衣/带检测" }
+    }
+}
+
+/**
+ * 摄像头默认data
+ * @rules 算法下发参数
+ *  @id 区域id(0-3)
+ *  @event 行为类/人脸
+ *  @points 点位坐标
+*/
+export const channelData = {
+    ip: "",
+    user: "",
+    password: "",
+    device: "",
+    channel: null
+}
+
+export const fullScreenPoints = [{ x: 0, y: 0 }, { x: 0, y: 1440 }, { x: 2560, y: 1440 }, { x: 2560, y: 0 }]

+ 134 - 0
src/views/nvr/index.vue

@@ -0,0 +1,134 @@
+<template>
+    <el-container class="aminui-main-container__table-main">
+        <el-header>
+            <div class="left-panel"><scTitle>NVR配置</scTitle></div>
+        </el-header>
+        <el-main class="nopadding">
+            <el-form ref="nvrForm" :model="nvrData" :rules="rules" label-width="80px">
+                <el-form-item label="ip" prop="ip">
+                    <el-input v-model="nvrData.ip" placeholder="输入IP"></el-input>
+                </el-form-item>
+                <el-form-item label="用户名" prop="user">
+                    <el-input v-model="nvrData.user" placeholder="输入用户名"></el-input>
+                </el-form-item>
+                <el-form-item label="密码" prop="password">
+                    <el-input v-model="nvrData.password" type="password" show-password placeholder="输入密码"></el-input>
+                </el-form-item>
+                <el-form-item label="端口" required>
+                    <el-input v-model="nvrData.port" disabled></el-input>
+                </el-form-item>
+
+                <el-form-item class="btn-group-item">
+                    <el-button class="sc-button-primary" icon="el-icon-edit" @click="table_edit">保存</el-button>
+                    <el-button v-if="!hide" class="sc-button-danger" icon="el-icon-delete" @click="table_del">删除</el-button>
+                </el-form-item>
+            </el-form>
+        </el-main>
+    </el-container>
+</template>
+
+<script>
+    export default {
+        data() {
+			return {
+				isSaveing: false,
+                hide: true,
+                nvrData: {
+                    ip: "",
+					user: "",
+					password: "",
+					port: 554
+                },
+
+                rules: {
+					ip: [{ required: true, message: "请输入IP" }],
+					user: [{ required: true, message: "请输入用户名" }],
+					password: [{ required: true, message: "请输入密码" }]
+				}
+            }
+        },
+
+        mounted() {
+            this.reloadTable();
+        },
+
+		methods: {
+            async reloadTable() {
+                try {
+                    const res = await this.$API.nvr.list.get();
+                    this.nvrData = res;
+                    if (!res["port"]) {
+                        this.hide = true;
+                        this.nvrData["port"] = 554;
+                        this.$refs.nvrForm.resetFields();
+                    } else this.hide = false;
+                } catch (error) {
+                    this.hide = true;
+                    this.nvrData = {
+                        ip: "",
+                        user: "",
+                        password: "",
+                        port: 554
+                    };
+                }
+            },
+
+			// 编辑配置
+			table_edit() {
+				this.$refs.nvrForm.validate(valid => {
+					if (valid) {
+						this.isSaveing = true;
+						this.$API.nvr.edit.post(this.nvrData).then(() => {
+							this.isSaveing = false;
+							this.$message.success("操作成功");
+                            this.reloadTable();
+						}).catch(() => this.isSaveing = false);
+					} else {
+						return false;
+					}
+				});
+			},
+
+            // 删除配置
+			table_del() {
+				this.$confirm("确认删除当前的配置吗?", "提示", {
+					type: "warning",
+					confirmButtonText: "删除",
+					confirmButtonClass: "sc-button-danger",
+					cancelButtonClass: "sc-button-info"
+				}).then(() => {
+					this.$API.nvr.del.post().then(res => {
+                        if (res == "success") {
+                            this.$message.success("操作成功");
+                            this.reloadTable();
+                        } else this.$notify.error({ title: "删除失败", message: res.message || res || "未知错误" });
+					}).catch(() => {});
+				}).catch(() => {});
+			}
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+.aminui-main-container__table-main {
+  width: 100%;
+  padding: 15px;
+  overflow: auto;
+
+  .el-form {
+    width: 650px;
+    margin: 40px auto 0;
+
+    --el-disabled-bg-color: #fff;
+    --el-disabled-text-color: #000;
+
+    .el-form-item {
+      margin-bottom: 24px;
+    }
+
+    .el-form-item.btn-group-item :deep(.el-form-item__content) {
+      justify-content: flex-end;
+    }
+  }
+}
+</style>

+ 134 - 0
src/views/speaker/index.vue

@@ -0,0 +1,134 @@
+<template>
+    <el-container class="aminui-main-container__table-main">
+        <el-header>
+            <div class="left-panel"><scTitle>音柱配置</scTitle></div>
+        </el-header>
+        <el-main class="nopadding">
+            <el-form ref="nvrForm" :model="nvrData" :rules="rules" label-width="80px">
+                <el-form-item label="ip" prop="ip">
+                    <el-input v-model="nvrData.ip" placeholder="输入IP"></el-input>
+                </el-form-item>
+                <el-form-item label="用户名" prop="user">
+                    <el-input v-model="nvrData.user" placeholder="输入用户名"></el-input>
+                </el-form-item>
+                <el-form-item label="密码" prop="password">
+                    <el-input v-model="nvrData.password" type="password" show-password placeholder="输入密码"></el-input>
+                </el-form-item>
+                <el-form-item label="端口" required>
+                    <el-input v-model="nvrData.port" disabled></el-input>
+                </el-form-item>
+
+                <el-form-item class="btn-group-item">
+                    <el-button class="sc-button-primary" icon="el-icon-edit" @click="table_edit">保存</el-button>
+                    <el-button v-if="!hide" class="sc-button-danger" icon="el-icon-delete" @click="table_del">删除</el-button>
+                </el-form-item>
+            </el-form>
+        </el-main>
+    </el-container>
+</template>
+
+<script>
+    export default {
+        data() {
+			return {
+				isSaveing: false,
+                hide: true,
+                nvrData: {
+                    ip: "",
+					user: "",
+					password: "",
+					port: 554
+                },
+
+                rules: {
+					ip: [{ required: true, message: "请输入IP" }],
+					user: [{ required: true, message: "请输入用户名" }],
+					password: [{ required: true, message: "请输入密码" }]
+				}
+            }
+        },
+
+        mounted() {
+            this.reloadTable();
+        },
+
+		methods: {
+            async reloadTable() {
+                try {
+                    const res = await this.$API.nvr.list.get();
+                    this.nvrData = res;
+                    if (!res["port"]) {
+                        this.hide = true;
+                        this.nvrData["port"] = 554;
+                        this.$refs.nvrForm.resetFields();
+                    } else this.hide = false;
+                } catch (error) {
+                    this.hide = true;
+                    this.nvrData = {
+                        ip: "",
+                        user: "",
+                        password: "",
+                        port: 554
+                    };
+                }
+            },
+
+			// 编辑配置
+			table_edit() {
+				this.$refs.nvrForm.validate(valid => {
+					if (valid) {
+						this.isSaveing = true;
+						this.$API.nvr.edit.post(this.nvrData).then(() => {
+							this.isSaveing = false;
+							this.$message.success("操作成功");
+                            this.reloadTable();
+						}).catch(() => this.isSaveing = false);
+					} else {
+						return false;
+					}
+				});
+			},
+
+            // 删除配置
+			table_del() {
+				this.$confirm("确认删除当前的配置吗?", "提示", {
+					type: "warning",
+					confirmButtonText: "删除",
+					confirmButtonClass: "sc-button-danger",
+					cancelButtonClass: "sc-button-info"
+				}).then(() => {
+					this.$API.nvr.del.post().then(res => {
+                        if (res == "success") {
+                            this.$message.success("操作成功");
+                            this.reloadTable();
+                        } else this.$notify.error({ title: "删除失败", message: res.message || res || "未知错误" });
+					}).catch(() => {});
+				}).catch(() => {});
+			}
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+.aminui-main-container__table-main {
+  width: 100%;
+  padding: 15px;
+  overflow: auto;
+
+  .el-form {
+    width: 650px;
+    margin: 40px auto 0;
+
+    --el-disabled-bg-color: #fff;
+    --el-disabled-text-color: #000;
+
+    .el-form-item {
+      margin-bottom: 24px;
+    }
+
+    .el-form-item.btn-group-item :deep(.el-form-item__content) {
+      justify-content: flex-end;
+    }
+  }
+}
+</style>

+ 71 - 0
vue.config.js

@@ -0,0 +1,71 @@
+const { defineConfig } = require("@vue/cli-service")
+
+module.exports = defineConfig({
+	//设置为空打包后不分更目录还是多级目录
+	publicPath: "",
+	outputDir: "ugliAI",
+	//build编译后存放静态文件的目录
+	//assetsDir: "static",
+
+	// build编译后不生成资源MAP文件
+	productionSourceMap: false,
+
+	//开发服务,build后的生产模式还需nginx代理
+	devServer: {
+		allowedHosts: "all",
+		open: false, //运行后自动打开浏览器
+		port: process.env.VUE_APP_PORT, //挂载端口
+		client: { overlay: false },
+		proxy: {
+			"/api": {
+				target: process.env.VUE_APP_API_BASEURL,
+				changeOrigin: true,
+				ws: false,
+				pathRewrite: {
+					"^/api": "/api"
+				}
+			},
+			"/minio": {
+				target: process.env.VUE_APP_IMAGE_BASEURL,
+				changeOrigin: true,
+				ws: false,
+				pathRewrite: {
+					"^/minio": ""
+				}
+			}
+		}
+	},
+
+	chainWebpack: config => {
+		// 移除 prefetch 插件
+		config.plugins.delete("preload");
+		config.plugins.delete("prefetch");
+		config.resolve.alias.set("vue-i18n", "vue-i18n/dist/vue-i18n.cjs.js");
+	},
+
+	configureWebpack: {
+		//性能提示
+		performance: {
+			hints: false
+		},
+		optimization: {
+			splitChunks: {
+				chunks: "all",
+				automaticNameDelimiter: "~",
+				name: "scuiChunks",
+				cacheGroups: {
+					//第三方库抽离
+					vendor: {
+						name: "modules",
+						test: /[\\/]node_modules[\\/]/,
+						priority: -10
+					},
+					elicons: {
+						name: "elicons",
+						test: /[\\/]node_modules[\\/]@element-plus[\\/]icons-vue[\\/]/
+					}
+				}
+			}
+		}
+	}
+})