Browse Source

UI界面提交

wanger 11 months ago
parent
commit
6a5cb299db
72 changed files with 6361 additions and 191 deletions
  1. 1 1
      index.html
  2. 3 0
      package.json
  3. 1 1
      src/api/menu.js
  4. 54 0
      src/assets/styles/variables.scss
  5. 2 2
      src/common/scss/globe.scss
  6. 1 1
      src/components/BigScreen/bigScreen.scss
  7. 74 0
      src/components/Breadcrumb/index.vue
  8. 2 2
      src/components/Combinations/LayerManage/LayerManage.scss
  9. 8 4
      src/components/Combinations/toolBar/toolBar.scss
  10. 11 9
      src/components/Combinations/toolBar/toolBar.vue
  11. 161 0
      src/components/Crontab/day.vue
  12. 114 0
      src/components/Crontab/hour.vue
  13. 430 0
      src/components/Crontab/index.vue
  14. 116 0
      src/components/Crontab/min.vue
  15. 114 0
      src/components/Crontab/month.vue
  16. 559 0
      src/components/Crontab/result.vue
  17. 117 0
      src/components/Crontab/second.vue
  18. 202 0
      src/components/Crontab/week.vue
  19. 131 0
      src/components/Crontab/year.vue
  20. 49 0
      src/components/DictData/index.js
  21. 52 0
      src/components/DictTag/index.vue
  22. 272 0
      src/components/Editor/index.vue
  23. 214 0
      src/components/FileUpload/index.vue
  24. 44 0
      src/components/Hamburger/index.vue
  25. 190 0
      src/components/HeaderSearch/index.vue
  26. 68 0
      src/components/IconSelect/index.vue
  27. 11 0
      src/components/IconSelect/requireIcons.js
  28. 82 0
      src/components/ImagePreview/index.vue
  29. 221 0
      src/components/ImageUpload/index.vue
  30. 114 0
      src/components/Pagination/index.vue
  31. 142 0
      src/components/PanThumb/index.vue
  32. 112 0
      src/components/RightPanel/index.vue
  33. 104 0
      src/components/RightToolbar/index.vue
  34. 21 0
      src/components/RuoYi/Doc/index.vue
  35. 21 0
      src/components/RuoYi/Git/index.vue
  36. 57 0
      src/components/Screenfull/index.vue
  37. 56 0
      src/components/SizeSelect/index.vue
  38. 61 0
      src/components/SvgIcon/index.vue
  39. 173 0
      src/components/ThemePicker/index.vue
  40. 181 0
      src/components/TopNav/index.vue
  41. 36 0
      src/components/iFrame/index.vue
  42. 61 0
      src/layout/components/AppMain.vue
  43. 24 0
      src/layout/components/IframeToggle/index.vue
  44. 47 0
      src/layout/components/InnerLink/index.vue
  45. 209 0
      src/layout/components/Navbar.vue
  46. 260 0
      src/layout/components/Settings/index.vue
  47. 25 0
      src/layout/components/Sidebar/FixiOSBug.js
  48. 33 0
      src/layout/components/Sidebar/Item.vue
  49. 43 0
      src/layout/components/Sidebar/Link.vue
  50. 128 0
      src/layout/components/Sidebar/Logo.vue
  51. 100 0
      src/layout/components/Sidebar/SidebarItem.vue
  52. 57 0
      src/layout/components/Sidebar/index.vue
  53. 94 0
      src/layout/components/TagsView/ScrollPane.vue
  54. 332 0
      src/layout/components/TagsView/index.vue
  55. 5 0
      src/layout/components/index.js
  56. 111 0
      src/layout/index.vue
  57. 45 0
      src/layout/mixin/ResizeHandler.js
  58. 1 0
      src/main.js
  59. 53 0
      src/permission.js
  60. 19 21
      src/router/index.js
  61. 5 1
      src/store/index.js
  62. 0 115
      src/store/modules/user copy.js
  63. 1 1
      src/utils/request.js
  64. 83 0
      src/utils/validate.js
  65. 18 18
      src/views/login.vue
  66. 183 0
      src/views/map3d.vue
  67. 42 0
      src/views/overview.vue
  68. 0 15
      src/views/test.vue
  69. BIN
      static/images/overview/menu.png
  70. BIN
      static/images/overview/menuactive.png
  71. BIN
      static/images/overview/time.png
  72. BIN
      static/images/overview/title.png

+ 1 - 1
index.html

@@ -16,7 +16,7 @@
     <meta http-equiv="pragma" content="no-cache">
     <meta http-equiv="cache-control" content="no-cache"> -->
     <!-- <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests"> -->
-    <title>三亚市国土空间智慧治理平台</title>
+    <title>海南省国土空间智慧治理试点(三亚)</title>
     <link href="./static/css/geoFont/iconfont.css" rel="stylesheet">
     <link href="./static/css/index.css" rel="stylesheet">
     <link href="./static/Cesium/Widgets/widgets.css" rel="stylesheet">

+ 3 - 0
package.json

@@ -39,11 +39,14 @@
         "css-loader": "^0.28.0",
         "extract-text-webpack-plugin": "^3.0.0",
         "file-loader": "^1.1.4",
+        "fuse.js": "6.4.3",
+        "screenfull": "5.0.2",
         "friendly-errors-webpack-plugin": "^1.6.1",
         "html-webpack-plugin": "^2.30.1",
         "image-webpack-loader": "^6.0.0",
         "iview": "3.5.4",
         "node-notifier": "^5.1.2",
+        "nprogress": "0.2.0",
         "node-sass": "^4.5.3",
         "optimize-css-assets-webpack-plugin": "^3.2.0",
         "ora": "^1.2.0",

+ 1 - 1
src/api/menu.js

@@ -3,7 +3,7 @@ import request from '@/utils/request'
 // 获取路由
 export const getRouters = () => {
     return request({
-        url: '/system/menu/getRouters',
+        url: '/system/menu/getRouters?modulesource=1',
         method: 'get'
     })
 }

+ 54 - 0
src/assets/styles/variables.scss

@@ -0,0 +1,54 @@
+// base color
+$blue:#324157;
+$light-blue:#3A71A8;
+$red:#C03639;
+$pink: #E65D6E;
+$green: #30B08F;
+$tiffany: #4AB7BD;
+$yellow:#FEC171;
+$panGreen: #30B08F;
+
+// 默认菜单主题风格
+$base-menu-color:#bfcbd9;
+$base-menu-color-active:#f4f4f5;
+$base-menu-background:#304156;
+$base-logo-title-color: #ffffff;
+
+$base-menu-light-color:rgba(0,0,0,.70);
+$base-menu-light-background:#ffffff;
+$base-logo-light-title-color: #001529;
+
+$base-sub-menu-background:#1f2d3d;
+$base-sub-menu-hover:#001528;
+
+// 自定义暗色菜单风格
+/**
+$base-menu-color:hsla(0,0%,100%,.65);
+$base-menu-color-active:#fff;
+$base-menu-background:#001529;
+$base-logo-title-color: #ffffff;
+
+$base-menu-light-color:rgba(0,0,0,.70);
+$base-menu-light-background:#ffffff;
+$base-logo-light-title-color: #001529;
+
+$base-sub-menu-background:#000c17;
+$base-sub-menu-hover:#001528;
+*/
+
+$base-sidebar-width: 200px;
+
+// the :export directive is the magic sauce for webpack
+// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
+:export {
+  menuColor: $base-menu-color;
+  menuLightColor: $base-menu-light-color;
+  menuColorActive: $base-menu-color-active;
+  menuBackground: $base-menu-background;
+  menuLightBackground: $base-menu-light-background;
+  subMenuBackground: $base-sub-menu-background;
+  subMenuHover: $base-sub-menu-hover;
+  sideBarWidth: $base-sidebar-width;
+  logoTitleColor: $base-logo-title-color;
+  logoLightTitleColor: $base-logo-light-title-color
+}

+ 2 - 2
src/common/scss/globe.scss

@@ -37,8 +37,8 @@ div {
     min-width: 280px;
     display: inline-block;
     position: absolute;
-    right: 20px;
-    top: 169px;
+    right: 370px;
+    top: 70px;
     z-index: 99;
     cursor: pointer;
     // border: 1px solid #526f82;

+ 1 - 1
src/components/BigScreen/bigScreen.scss

@@ -475,7 +475,7 @@ html,
     width: 100vw;
     height: 100vh;
     position: fixed;
-    top: 0rem;
+    // top: 0rem;
     left: 0px;
     display: flex;
     flex-direction: column;

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

@@ -0,0 +1,74 @@
+<template>
+  <el-breadcrumb class="app-breadcrumb" separator="/">
+    <transition-group name="breadcrumb">
+      <el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path">
+        <span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">{{ item.meta.title }}</span>
+        <a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
+      </el-breadcrumb-item>
+    </transition-group>
+  </el-breadcrumb>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      levelList: null
+    }
+  },
+  watch: {
+    $route(route) {
+      // if you go to the redirect page, do not update the breadcrumbs
+      if (route.path.startsWith('/redirect/')) {
+        return
+      }
+      this.getBreadcrumb()
+    }
+  },
+  created() {
+    this.getBreadcrumb()
+  },
+  methods: {
+    getBreadcrumb() {
+      // only show routes with meta.title
+      let matched = this.$route.matched.filter(item => item.meta && item.meta.title)
+      const first = matched[0]
+
+      if (!this.isDashboard(first)) {
+        matched = [{ path: '/index', meta: { title: '首页' }}].concat(matched)
+      }
+
+      this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
+    },
+    isDashboard(route) {
+      const name = route && route.name
+      if (!name) {
+        return false
+      }
+      return name.trim() === 'Index'
+    },
+    handleLink(item) {
+      const { redirect, path } = item
+      if (redirect) {
+        this.$router.push(redirect)
+        return
+      }
+      this.$router.push(path)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-breadcrumb.el-breadcrumb {
+  display: inline-block;
+  font-size: 14px;
+  line-height: 50px;
+  margin-left: 8px;
+
+  .no-redirect {
+    color: #97a8be;
+    cursor: text;
+  }
+}
+</style>

+ 2 - 2
src/components/Combinations/LayerManage/LayerManage.scss

@@ -28,8 +28,8 @@
     background: url(/static/images/bigscreen/tree-bg.png) no-repeat;
     background-size: 100% 100%;
     background-position: center;
-    top: 178px !important;
-    left: 20px !important;
+    // top: 178px !important;
+    // left: 20px !important;
 }
 
 .ivu-select-dropdown {

+ 8 - 4
src/components/Combinations/toolBar/toolBar.scss

@@ -47,15 +47,19 @@
 
 .toolBar {
     width: auto;
+    display: -webkit-box;
+    display: -ms-flexbox;
     display: flex;
+    -ms-flex-wrap: nowrap;
     flex-wrap: nowrap;
+    -webkit-box-pack: start;
+    -ms-flex-pack: start;
     justify-content: flex-start;
-    // background-color: red;
     position: absolute;
-    top: 118px;
-    right: 20px;
+    top: 8px;
+    right: 200px;
     margin: 5px 0 0 5px;
-    background: url(/static/images/bigscreen/filter-bg.png) no-repeat;
+    // background: url(/static/images/bigscreen/filter-bg.png) no-repeat;
     background-size: cover;
     background-position: center;
 }

+ 11 - 9
src/components/Combinations/toolBar/toolBar.vue

@@ -1,22 +1,23 @@
 <template>
   <div v-if="ToolBarShow">
-    <div class="resourceTree" @click="choose(0)"></div>
+    <!-- <div class="resourceTree" @click="choose(0)"></div> -->
     <div class="toolBar">
       <li
         class="sm-btn sm-tool-btn"
-        :title="Resource.analysis"
-        @click="choose(6)"
+        :title="Resource.Resource"
+        @click="choose(0)"
       >
-        <span class="iconfont iconsanweifenxi"></span>
+        <span class="iconfont icontuceng"></span>
       </li>
+
       <ul v-if="show">
-        <!-- <li
+        <li
           class="sm-btn sm-tool-btn"
-          :title="Resource.addLayer"
-          @click="choose(1)"
+          :title="Resource.analysis"
+          @click="choose(6)"
         >
-          <span class="iconfont iconjiazaituceng"></span>
-        </li> -->
+          <span class="iconfont iconsanweifenxi"></span>
+        </li>
         <li class="sm-btn sm-tool-btn" title="点击查询" @click="choose(9)">
           <Icon
             type="md-information-circle"
@@ -80,6 +81,7 @@
         </li> -->
       </ul>
       <div
+        style="display: none"
         class="sm-tool-btn"
         @click="toggleVisibility"
         :class="{ 'sm-tool-btn-only': !show }"

+ 161 - 0
src/components/Crontab/day.vue

@@ -0,0 +1,161 @@
+<template>
+	<el-form size="small">
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="1">
+				日,允许的通配符[, - * ? / L W]
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="2">
+				不指定
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="3">
+				周期从
+				<el-input-number v-model='cycle01' :min="1" :max="30" /> -
+				<el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : 2" :max="31" /> 日
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="4">
+				从
+				<el-input-number v-model='average01' :min="1" :max="30" /> 号开始,每
+				<el-input-number v-model='average02' :min="1" :max="31 - average01 || 1" /> 日执行一次
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="5">
+				每月
+				<el-input-number v-model='workday' :min="1" :max="31" /> 号最近的那个工作日
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="6">
+				本月最后一天
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="7">
+				指定
+				<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
+					<el-option v-for="item in 31" :key="item" :value="item">{{item}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+	</el-form>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			radioValue: 1,
+			workday: 1,
+			cycle01: 1,
+			cycle02: 2,
+			average01: 1,
+			average02: 1,
+			checkboxList: [],
+			checkNum: this.$options.propsData.check
+		}
+	},
+	name: 'crontab-day',
+	props: ['check', 'cron'],
+	methods: {
+		// 单选按钮值变化时
+		radioChange() {
+			('day rachange');
+			if (this.radioValue !== 2 && this.cron.week !== '?') {
+				this.$emit('update', 'week', '?', 'day')
+			}
+
+			switch (this.radioValue) {
+				case 1:
+					this.$emit('update', 'day', '*');
+					break;
+				case 2:
+					this.$emit('update', 'day', '?');
+					break;
+				case 3:
+					this.$emit('update', 'day', this.cycleTotal);
+					break;
+				case 4:
+					this.$emit('update', 'day', this.averageTotal);
+					break;
+				case 5:
+					this.$emit('update', 'day', this.workday + 'W');
+					break;
+				case 6:
+					this.$emit('update', 'day', 'L');
+					break;
+				case 7:
+					this.$emit('update', 'day', this.checkboxString);
+					break;
+			}
+			('day rachange end');
+		},
+		// 周期两个值变化时
+		cycleChange() {
+			if (this.radioValue == '3') {
+				this.$emit('update', 'day', this.cycleTotal);
+			}
+		},
+		// 平均两个值变化时
+		averageChange() {
+			if (this.radioValue == '4') {
+				this.$emit('update', 'day', this.averageTotal);
+			}
+		},
+		// 最近工作日值变化时
+		workdayChange() {
+			if (this.radioValue == '5') {
+				this.$emit('update', 'day', this.workdayCheck + 'W');
+			}
+		},
+		// checkbox值变化时
+		checkboxChange() {
+			if (this.radioValue == '7') {
+				this.$emit('update', 'day', this.checkboxString);
+			}
+		}
+	},
+	watch: {
+		'radioValue': 'radioChange',
+		'cycleTotal': 'cycleChange',
+		'averageTotal': 'averageChange',
+		'workdayCheck': 'workdayChange',
+		'checkboxString': 'checkboxChange',
+	},
+	computed: {
+		// 计算两个周期值
+		cycleTotal: function () {
+			const cycle01 = this.checkNum(this.cycle01, 1, 30)
+			const cycle02 = this.checkNum(this.cycle02, cycle01 ? cycle01 + 1 : 2, 31, 31)
+			return cycle01 + '-' + cycle02;
+		},
+		// 计算平均用到的值
+		averageTotal: function () {
+			const average01 = this.checkNum(this.average01, 1, 30)
+			const average02 = this.checkNum(this.average02, 1, 31 - average01 || 0)
+			return average01 + '/' + average02;
+		},
+		// 计算工作日格式
+		workdayCheck: function () {
+			const workday = this.checkNum(this.workday, 1, 31)
+			return workday;
+		},
+		// 计算勾选的checkbox值合集
+		checkboxString: function () {
+			let str = this.checkboxList.join();
+			return str == '' ? '*' : str;
+		}
+	}
+}
+</script>

+ 114 - 0
src/components/Crontab/hour.vue

@@ -0,0 +1,114 @@
+<template>
+	<el-form size="small">
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="1">
+				小时,允许的通配符[, - * /]
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="2">
+				周期从
+				<el-input-number v-model='cycle01' :min="0" :max="22" /> -
+				<el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : 1" :max="23" /> 小时
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="3">
+				从
+				<el-input-number v-model='average01' :min="0" :max="22" /> 小时开始,每
+				<el-input-number v-model='average02' :min="1" :max="23 - average01 || 0" /> 小时执行一次
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="4">
+				指定
+				<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
+					<el-option v-for="item in 24" :key="item" :value="item-1">{{item-1}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+	</el-form>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			radioValue: 1,
+			cycle01: 0,
+			cycle02: 1,
+			average01: 0,
+			average02: 1,
+			checkboxList: [],
+			checkNum: this.$options.propsData.check
+		}
+	},
+	name: 'crontab-hour',
+	props: ['check', 'cron'],
+	methods: {
+		// 单选按钮值变化时
+		radioChange() {
+			switch (this.radioValue) {
+				case 1:
+        	this.$emit('update', 'hour', '*')
+        	break;
+				case 2:
+					this.$emit('update', 'hour', this.cycleTotal);
+					break;
+				case 3:
+					this.$emit('update', 'hour', this.averageTotal);
+					break;
+				case 4:
+					this.$emit('update', 'hour', this.checkboxString);
+					break;
+			}
+		},
+		// 周期两个值变化时
+		cycleChange() {
+			if (this.radioValue == '2') {
+				this.$emit('update', 'hour', this.cycleTotal);
+			}
+		},
+		// 平均两个值变化时
+		averageChange() {
+			if (this.radioValue == '3') {
+				this.$emit('update', 'hour', this.averageTotal);
+			}
+		},
+		// checkbox值变化时
+		checkboxChange() {
+			if (this.radioValue == '4') {
+				this.$emit('update', 'hour', this.checkboxString);
+			}
+		}
+	},
+	watch: {
+		'radioValue': 'radioChange',
+		'cycleTotal': 'cycleChange',
+		'averageTotal': 'averageChange',
+		'checkboxString': 'checkboxChange'
+	},
+	computed: {
+		// 计算两个周期值
+		cycleTotal: function () {
+			const cycle01 = this.checkNum(this.cycle01, 0, 22)
+			const cycle02 = this.checkNum(this.cycle02, cycle01 ? cycle01 + 1 : 1, 23)
+			return cycle01 + '-' + cycle02;
+		},
+		// 计算平均用到的值
+		averageTotal: function () {
+			const average01 = this.checkNum(this.average01, 0, 22)
+			const average02 = this.checkNum(this.average02, 1, 23 - average01 || 0)
+			return average01 + '/' + average02;
+		},
+		// 计算勾选的checkbox值合集
+		checkboxString: function () {
+			let str = this.checkboxList.join();
+			return str == '' ? '*' : str;
+		}
+	}
+}
+</script>

+ 430 - 0
src/components/Crontab/index.vue

@@ -0,0 +1,430 @@
+<template>
+  <div>
+    <el-tabs type="border-card">
+      <el-tab-pane label="秒" v-if="shouldHide('second')">
+        <CrontabSecond
+          @update="updateCrontabValue"
+          :check="checkNumber"
+          :cron="crontabValueObj"
+          ref="cronsecond"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="分钟" v-if="shouldHide('min')">
+        <CrontabMin
+          @update="updateCrontabValue"
+          :check="checkNumber"
+          :cron="crontabValueObj"
+          ref="cronmin"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="小时" v-if="shouldHide('hour')">
+        <CrontabHour
+          @update="updateCrontabValue"
+          :check="checkNumber"
+          :cron="crontabValueObj"
+          ref="cronhour"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="日" v-if="shouldHide('day')">
+        <CrontabDay
+          @update="updateCrontabValue"
+          :check="checkNumber"
+          :cron="crontabValueObj"
+          ref="cronday"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="月" v-if="shouldHide('month')">
+        <CrontabMonth
+          @update="updateCrontabValue"
+          :check="checkNumber"
+          :cron="crontabValueObj"
+          ref="cronmonth"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="周" v-if="shouldHide('week')">
+        <CrontabWeek
+          @update="updateCrontabValue"
+          :check="checkNumber"
+          :cron="crontabValueObj"
+          ref="cronweek"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="年" v-if="shouldHide('year')">
+        <CrontabYear
+          @update="updateCrontabValue"
+          :check="checkNumber"
+          :cron="crontabValueObj"
+          ref="cronyear"
+        />
+      </el-tab-pane>
+    </el-tabs>
+
+    <div class="popup-main">
+      <div class="popup-result">
+        <p class="title">时间表达式</p>
+        <table>
+          <thead>
+            <th v-for="item of tabTitles" width="40" :key="item">{{item}}</th>
+            <th>Cron 表达式</th>
+          </thead>
+          <tbody>
+            <td>
+              <span>{{crontabValueObj.second}}</span>
+            </td>
+            <td>
+              <span>{{crontabValueObj.min}}</span>
+            </td>
+            <td>
+              <span>{{crontabValueObj.hour}}</span>
+            </td>
+            <td>
+              <span>{{crontabValueObj.day}}</span>
+            </td>
+            <td>
+              <span>{{crontabValueObj.month}}</span>
+            </td>
+            <td>
+              <span>{{crontabValueObj.week}}</span>
+            </td>
+            <td>
+              <span>{{crontabValueObj.year}}</span>
+            </td>
+            <td>
+              <span>{{crontabValueString}}</span>
+            </td>
+          </tbody>
+        </table>
+      </div>
+      <CrontabResult :ex="crontabValueString"></CrontabResult>
+
+      <div class="pop_btn">
+        <el-button size="small" type="primary" @click="submitFill">确定</el-button>
+        <el-button size="small" type="warning" @click="clearCron">重置</el-button>
+        <el-button size="small" @click="hidePopup">取消</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import CrontabSecond from "./second.vue";
+import CrontabMin from "./min.vue";
+import CrontabHour from "./hour.vue";
+import CrontabDay from "./day.vue";
+import CrontabMonth from "./month.vue";
+import CrontabWeek from "./week.vue";
+import CrontabYear from "./year.vue";
+import CrontabResult from "./result.vue";
+
+export default {
+  data() {
+    return {
+      tabTitles: ["秒", "分钟", "小时", "日", "月", "周", "年"],
+      tabActive: 0,
+      myindex: 0,
+      crontabValueObj: {
+        second: "*",
+        min: "*",
+        hour: "*",
+        day: "*",
+        month: "*",
+        week: "?",
+        year: "",
+      },
+    };
+  },
+  name: "vcrontab",
+  props: ["expression", "hideComponent"],
+  methods: {
+    shouldHide(key) {
+      if (this.hideComponent && this.hideComponent.includes(key)) return false;
+      return true;
+    },
+    resolveExp() {
+      // 反解析 表达式
+      if (this.expression) {
+        let arr = this.expression.split(" ");
+        if (arr.length >= 6) {
+          //6 位以上是合法表达式
+          let obj = {
+            second: arr[0],
+            min: arr[1],
+            hour: arr[2],
+            day: arr[3],
+            month: arr[4],
+            week: arr[5],
+            year: arr[6] ? arr[6] : "",
+          };
+          this.crontabValueObj = {
+            ...obj,
+          };
+          for (let i in obj) {
+            if (obj[i]) this.changeRadio(i, obj[i]);
+          }
+        }
+      } else {
+        // 没有传入的表达式 则还原
+        this.clearCron();
+      }
+    },
+    // tab切换值
+    tabCheck(index) {
+      this.tabActive = index;
+    },
+    // 由子组件触发,更改表达式组成的字段值
+    updateCrontabValue(name, value, from) {
+      "updateCrontabValue", name, value, from;
+      this.crontabValueObj[name] = value;
+      if (from && from !== name) {
+        console.log(`来自组件 ${from} 改变了 ${name} ${value}`);
+        this.changeRadio(name, value);
+      }
+    },
+    // 赋值到组件
+    changeRadio(name, value) {
+      let arr = ["second", "min", "hour", "month"],
+        refName = "cron" + name,
+        insValue;
+
+      if (!this.$refs[refName]) return;
+
+      if (arr.includes(name)) {
+        if (value === "*") {
+          insValue = 1;
+        } else if (value.indexOf("-") > -1) {
+          let indexArr = value.split("-");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].cycle01 = 0)
+            : (this.$refs[refName].cycle01 = indexArr[0]);
+          this.$refs[refName].cycle02 = indexArr[1];
+          insValue = 2;
+        } else if (value.indexOf("/") > -1) {
+          let indexArr = value.split("/");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].average01 = 0)
+            : (this.$refs[refName].average01 = indexArr[0]);
+          this.$refs[refName].average02 = indexArr[1];
+          insValue = 3;
+        } else {
+          insValue = 4;
+          this.$refs[refName].checkboxList = value.split(",");
+        }
+      } else if (name == "day") {
+        if (value === "*") {
+          insValue = 1;
+        } else if (value == "?") {
+          insValue = 2;
+        } else if (value.indexOf("-") > -1) {
+          let indexArr = value.split("-");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].cycle01 = 0)
+            : (this.$refs[refName].cycle01 = indexArr[0]);
+          this.$refs[refName].cycle02 = indexArr[1];
+          insValue = 3;
+        } else if (value.indexOf("/") > -1) {
+          let indexArr = value.split("/");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].average01 = 0)
+            : (this.$refs[refName].average01 = indexArr[0]);
+          this.$refs[refName].average02 = indexArr[1];
+          insValue = 4;
+        } else if (value.indexOf("W") > -1) {
+          let indexArr = value.split("W");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].workday = 0)
+            : (this.$refs[refName].workday = indexArr[0]);
+          insValue = 5;
+        } else if (value === "L") {
+          insValue = 6;
+        } else {
+          this.$refs[refName].checkboxList = value.split(",");
+          insValue = 7;
+        }
+      } else if (name == "week") {
+        if (value === "*") {
+          insValue = 1;
+        } else if (value == "?") {
+          insValue = 2;
+        } else if (value.indexOf("-") > -1) {
+          let indexArr = value.split("-");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].cycle01 = 0)
+            : (this.$refs[refName].cycle01 = indexArr[0]);
+          this.$refs[refName].cycle02 = indexArr[1];
+          insValue = 3;
+        } else if (value.indexOf("#") > -1) {
+          let indexArr = value.split("#");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].average01 = 1)
+            : (this.$refs[refName].average01 = indexArr[0]);
+          this.$refs[refName].average02 = indexArr[1];
+          insValue = 4;
+        } else if (value.indexOf("L") > -1) {
+          let indexArr = value.split("L");
+          isNaN(indexArr[0])
+            ? (this.$refs[refName].weekday = 1)
+            : (this.$refs[refName].weekday = indexArr[0]);
+          insValue = 5;
+        } else {
+          this.$refs[refName].checkboxList = value.split(",");
+          insValue = 6;
+        }
+      } else if (name == "year") {
+        if (value == "") {
+          insValue = 1;
+        } else if (value == "*") {
+          insValue = 2;
+        } else if (value.indexOf("-") > -1) {
+          insValue = 3;
+        } else if (value.indexOf("/") > -1) {
+          insValue = 4;
+        } else {
+          this.$refs[refName].checkboxList = value.split(",");
+          insValue = 5;
+        }
+      }
+      this.$refs[refName].radioValue = insValue;
+    },
+    // 表单选项的子组件校验数字格式(通过-props传递)
+    checkNumber(value, minLimit, maxLimit) {
+      // 检查必须为整数
+      value = Math.floor(value);
+      if (value < minLimit) {
+        value = minLimit;
+      } else if (value > maxLimit) {
+        value = maxLimit;
+      }
+      return value;
+    },
+    // 隐藏弹窗
+    hidePopup() {
+      this.$emit("hide");
+    },
+    // 填充表达式
+    submitFill() {
+      this.$emit("fill", this.crontabValueString);
+      this.hidePopup();
+    },
+    clearCron() {
+      // 还原选择项
+      ("准备还原");
+      this.crontabValueObj = {
+        second: "*",
+        min: "*",
+        hour: "*",
+        day: "*",
+        month: "*",
+        week: "?",
+        year: "",
+      };
+      for (let j in this.crontabValueObj) {
+        this.changeRadio(j, this.crontabValueObj[j]);
+      }
+    },
+  },
+  computed: {
+    crontabValueString: function() {
+      let obj = this.crontabValueObj;
+      let str =
+        obj.second +
+        " " +
+        obj.min +
+        " " +
+        obj.hour +
+        " " +
+        obj.day +
+        " " +
+        obj.month +
+        " " +
+        obj.week +
+        (obj.year == "" ? "" : " " + obj.year);
+      return str;
+    },
+  },
+  components: {
+    CrontabSecond,
+    CrontabMin,
+    CrontabHour,
+    CrontabDay,
+    CrontabMonth,
+    CrontabWeek,
+    CrontabYear,
+    CrontabResult,
+  },
+  watch: {
+    expression: "resolveExp",
+    hideComponent(value) {
+      // 隐藏部分组件
+    },
+  },
+  mounted: function() {
+    this.resolveExp();
+  },
+};
+</script>
+<style scoped>
+.pop_btn {
+  text-align: center;
+  margin-top: 20px;
+}
+.popup-main {
+  position: relative;
+  margin: 10px auto;
+  background: #fff;
+  border-radius: 5px;
+  font-size: 12px;
+  overflow: hidden;
+}
+.popup-title {
+  overflow: hidden;
+  line-height: 34px;
+  padding-top: 6px;
+  background: #f2f2f2;
+}
+.popup-result {
+  box-sizing: border-box;
+  line-height: 24px;
+  margin: 25px auto;
+  padding: 15px 10px 10px;
+  border: 1px solid #ccc;
+  position: relative;
+}
+.popup-result .title {
+  position: absolute;
+  top: -28px;
+  left: 50%;
+  width: 140px;
+  font-size: 14px;
+  margin-left: -70px;
+  text-align: center;
+  line-height: 30px;
+  background: #fff;
+}
+.popup-result table {
+  text-align: center;
+  width: 100%;
+  margin: 0 auto;
+}
+.popup-result table span {
+  display: block;
+  width: 100%;
+  font-family: arial;
+  line-height: 30px;
+  height: 30px;
+  white-space: nowrap;
+  overflow: hidden;
+  border: 1px solid #e8e8e8;
+}
+.popup-result-scroll {
+  font-size: 12px;
+  line-height: 24px;
+  height: 10em;
+  overflow-y: auto;
+}
+</style>

+ 116 - 0
src/components/Crontab/min.vue

@@ -0,0 +1,116 @@
+<template>
+	<el-form size="small">
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="1">
+				分钟,允许的通配符[, - * /]
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="2">
+				周期从
+				<el-input-number v-model='cycle01' :min="0" :max="58" /> -
+				<el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : 1" :max="59" /> 分钟
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="3">
+				从
+				<el-input-number v-model='average01' :min="0" :max="58" /> 分钟开始,每
+				<el-input-number v-model='average02' :min="1" :max="59 - average01 || 0" /> 分钟执行一次
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="4">
+				指定
+				<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
+					<el-option v-for="item in 60" :key="item" :value="item-1">{{item-1}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+	</el-form>
+
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			radioValue: 1,
+			cycle01: 1,
+			cycle02: 2,
+			average01: 0,
+			average02: 1,
+			checkboxList: [],
+			checkNum: this.$options.propsData.check
+		}
+	},
+	name: 'crontab-min',
+	props: ['check', 'cron'],
+	methods: {
+		// 单选按钮值变化时
+		radioChange() {
+			switch (this.radioValue) {
+				case 1:
+					this.$emit('update', 'min', '*', 'min');
+					break;
+				case 2:
+					this.$emit('update', 'min', this.cycleTotal, 'min');
+					break;
+				case 3:
+					this.$emit('update', 'min', this.averageTotal, 'min');
+					break;
+				case 4:
+					this.$emit('update', 'min', this.checkboxString, 'min');
+					break;
+			}
+		},
+		// 周期两个值变化时
+		cycleChange() {
+			if (this.radioValue == '2') {
+				this.$emit('update', 'min', this.cycleTotal, 'min');
+			}
+		},
+		// 平均两个值变化时
+		averageChange() {
+			if (this.radioValue == '3') {
+				this.$emit('update', 'min', this.averageTotal, 'min');
+			}
+		},
+		// checkbox值变化时
+		checkboxChange() {
+			if (this.radioValue == '4') {
+				this.$emit('update', 'min', this.checkboxString, 'min');
+			}
+		},
+
+	},
+	watch: {
+		'radioValue': 'radioChange',
+		'cycleTotal': 'cycleChange',
+		'averageTotal': 'averageChange',
+		'checkboxString': 'checkboxChange',
+	},
+	computed: {
+		// 计算两个周期值
+		cycleTotal: function () {
+			const cycle01 = this.checkNum(this.cycle01, 0, 58)
+			const cycle02 = this.checkNum(this.cycle02, cycle01 ? cycle01 + 1 : 1, 59)
+			return cycle01 + '-' + cycle02;
+		},
+		// 计算平均用到的值
+		averageTotal: function () {
+			const average01 = this.checkNum(this.average01, 0, 58)
+			const average02 = this.checkNum(this.average02, 1, 59 - average01 || 0)
+			return average01 + '/' + average02;
+		},
+		// 计算勾选的checkbox值合集
+		checkboxString: function () {
+			let str = this.checkboxList.join();
+			return str == '' ? '*' : str;
+		}
+	}
+}
+</script>

+ 114 - 0
src/components/Crontab/month.vue

@@ -0,0 +1,114 @@
+<template>
+	<el-form size='small'>
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="1">
+				月,允许的通配符[, - * /]
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="2">
+				周期从
+				<el-input-number v-model='cycle01' :min="1" :max="11" /> -
+				<el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : 2" :max="12" /> 月
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="3">
+				从
+				<el-input-number v-model='average01' :min="1" :max="11" /> 月开始,每
+				<el-input-number v-model='average02' :min="1" :max="12 - average01 || 0" /> 月月执行一次
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="4">
+				指定
+				<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
+					<el-option v-for="item in 12" :key="item" :value="item">{{item}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+	</el-form>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			radioValue: 1,
+			cycle01: 1,
+			cycle02: 2,
+			average01: 1,
+			average02: 1,
+			checkboxList: [],
+			checkNum: this.check
+		}
+	},
+	name: 'crontab-month',
+	props: ['check', 'cron'],
+	methods: {
+		// 单选按钮值变化时
+		radioChange() {
+			switch (this.radioValue) {
+				case 1:
+					this.$emit('update', 'month', '*');
+					break;
+				case 2:
+					this.$emit('update', 'month', this.cycleTotal);
+					break;
+				case 3:
+					this.$emit('update', 'month', this.averageTotal);
+					break;
+				case 4:
+					this.$emit('update', 'month', this.checkboxString);
+					break;
+			}
+		},
+		// 周期两个值变化时
+		cycleChange() {
+			if (this.radioValue == '2') {
+				this.$emit('update', 'month', this.cycleTotal);
+			}
+		},
+		// 平均两个值变化时
+		averageChange() {
+			if (this.radioValue == '3') {
+				this.$emit('update', 'month', this.averageTotal);
+			}
+		},
+		// checkbox值变化时
+		checkboxChange() {
+			if (this.radioValue == '4') {
+				this.$emit('update', 'month', this.checkboxString);
+			}
+		}
+	},
+	watch: {
+		'radioValue': 'radioChange',
+		'cycleTotal': 'cycleChange',
+		'averageTotal': 'averageChange',
+		'checkboxString': 'checkboxChange'
+	},
+	computed: {
+		// 计算两个周期值
+		cycleTotal: function () {
+			const cycle01 = this.checkNum(this.cycle01, 1, 11)
+			const cycle02 = this.checkNum(this.cycle02, cycle01 ? cycle01 + 1 : 2, 12)
+			return cycle01 + '-' + cycle02;
+		},
+		// 计算平均用到的值
+		averageTotal: function () {
+			const average01 = this.checkNum(this.average01, 1, 11)
+			const average02 = this.checkNum(this.average02, 1, 12 - average01 || 0)
+			return average01 + '/' + average02;
+		},
+		// 计算勾选的checkbox值合集
+		checkboxString: function () {
+			let str = this.checkboxList.join();
+			return str == '' ? '*' : str;
+		}
+	}
+}
+</script>

+ 559 - 0
src/components/Crontab/result.vue

@@ -0,0 +1,559 @@
+<template>
+	<div class="popup-result">
+		<p class="title">最近5次运行时间</p>
+		<ul class="popup-result-scroll">
+			<template v-if='isShow'>
+				<li v-for='item in resultList' :key="item">{{item}}</li>
+			</template>
+			<li v-else>计算结果中...</li>
+		</ul>
+	</div>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			dayRule: '',
+			dayRuleSup: '',
+			dateArr: [],
+			resultList: [],
+			isShow: false
+		}
+	},
+	name: 'crontab-result',
+	methods: {
+		// 表达式值变化时,开始去计算结果
+		expressionChange() {
+
+			// 计算开始-隐藏结果
+			this.isShow = false;
+			// 获取规则数组[0秒、1分、2时、3日、4月、5星期、6年]
+			let ruleArr = this.$options.propsData.ex.split(' ');
+			// 用于记录进入循环的次数
+			let nums = 0;
+			// 用于暂时存符号时间规则结果的数组
+			let resultArr = [];
+			// 获取当前时间精确至[年、月、日、时、分、秒]
+			let nTime = new Date();
+			let nYear = nTime.getFullYear();
+			let nMonth = nTime.getMonth() + 1;
+			let nDay = nTime.getDate();
+			let nHour = nTime.getHours();
+			let nMin = nTime.getMinutes();
+			let nSecond = nTime.getSeconds();
+			// 根据规则获取到近100年可能年数组、月数组等等
+			this.getSecondArr(ruleArr[0]);
+			this.getMinArr(ruleArr[1]);
+			this.getHourArr(ruleArr[2]);
+			this.getDayArr(ruleArr[3]);
+			this.getMonthArr(ruleArr[4]);
+			this.getWeekArr(ruleArr[5]);
+			this.getYearArr(ruleArr[6], nYear);
+			// 将获取到的数组赋值-方便使用
+			let sDate = this.dateArr[0];
+			let mDate = this.dateArr[1];
+			let hDate = this.dateArr[2];
+			let DDate = this.dateArr[3];
+			let MDate = this.dateArr[4];
+			let YDate = this.dateArr[5];
+			// 获取当前时间在数组中的索引
+			let sIdx = this.getIndex(sDate, nSecond);
+			let mIdx = this.getIndex(mDate, nMin);
+			let hIdx = this.getIndex(hDate, nHour);
+			let DIdx = this.getIndex(DDate, nDay);
+			let MIdx = this.getIndex(MDate, nMonth);
+			let YIdx = this.getIndex(YDate, nYear);
+			// 重置月日时分秒的函数(后面用的比较多)
+			const resetSecond = function () {
+				sIdx = 0;
+				nSecond = sDate[sIdx]
+			}
+			const resetMin = function () {
+				mIdx = 0;
+				nMin = mDate[mIdx]
+				resetSecond();
+			}
+			const resetHour = function () {
+				hIdx = 0;
+				nHour = hDate[hIdx]
+				resetMin();
+			}
+			const resetDay = function () {
+				DIdx = 0;
+				nDay = DDate[DIdx]
+				resetHour();
+			}
+			const resetMonth = function () {
+				MIdx = 0;
+				nMonth = MDate[MIdx]
+				resetDay();
+			}
+			// 如果当前年份不为数组中当前值
+			if (nYear !== YDate[YIdx]) {
+				resetMonth();
+			}
+			// 如果当前月份不为数组中当前值
+			if (nMonth !== MDate[MIdx]) {
+				resetDay();
+			}
+			// 如果当前“日”不为数组中当前值
+			if (nDay !== DDate[DIdx]) {
+				resetHour();
+			}
+			// 如果当前“时”不为数组中当前值
+			if (nHour !== hDate[hIdx]) {
+				resetMin();
+			}
+			// 如果当前“分”不为数组中当前值
+			if (nMin !== mDate[mIdx]) {
+				resetSecond();
+			}
+
+			// 循环年份数组
+			goYear: for (let Yi = YIdx; Yi < YDate.length; Yi++) {
+				let YY = YDate[Yi];
+				// 如果到达最大值时
+				if (nMonth > MDate[MDate.length - 1]) {
+					resetMonth();
+					continue;
+				}
+				// 循环月份数组
+				goMonth: for (let Mi = MIdx; Mi < MDate.length; Mi++) {
+					// 赋值、方便后面运算
+					let MM = MDate[Mi];
+					MM = MM < 10 ? '0' + MM : MM;
+					// 如果到达最大值时
+					if (nDay > DDate[DDate.length - 1]) {
+						resetDay();
+						if (Mi == MDate.length - 1) {
+							resetMonth();
+							continue goYear;
+						}
+						continue;
+					}
+					// 循环日期数组
+					goDay: for (let Di = DIdx; Di < DDate.length; Di++) {
+						// 赋值、方便后面运算
+						let DD = DDate[Di];
+						let thisDD = DD < 10 ? '0' + DD : DD;
+
+						// 如果到达最大值时
+						if (nHour > hDate[hDate.length - 1]) {
+							resetHour();
+							if (Di == DDate.length - 1) {
+								resetDay();
+								if (Mi == MDate.length - 1) {
+									resetMonth();
+									continue goYear;
+								}
+								continue goMonth;
+							}
+							continue;
+						}
+
+						// 判断日期的合法性,不合法的话也是跳出当前循环
+						if (this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true && this.dayRule !== 'workDay' && this.dayRule !== 'lastWeek' && this.dayRule !== 'lastDay') {
+							resetDay();
+							continue goMonth;
+						}
+						// 如果日期规则中有值时
+						if (this.dayRule == 'lastDay') {
+							// 如果不是合法日期则需要将前将日期调到合法日期即月末最后一天
+
+							if (this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+								while (DD > 0 && this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+									DD--;
+
+									thisDD = DD < 10 ? '0' + DD : DD;
+								}
+							}
+						} else if (this.dayRule == 'workDay') {
+							// 校验并调整如果是2月30号这种日期传进来时需调整至正常月底
+							if (this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+								while (DD > 0 && this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+									DD--;
+									thisDD = DD < 10 ? '0' + DD : DD;
+								}
+							}
+							// 获取达到条件的日期是星期X
+							let thisWeek = this.formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week');
+							// 当星期日时
+							if (thisWeek == 1) {
+								// 先找下一个日,并判断是否为月底
+								DD++;
+								thisDD = DD < 10 ? '0' + DD : DD;
+								// 判断下一日已经不是合法日期
+								if (this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+									DD -= 3;
+								}
+							} else if (thisWeek == 7) {
+								// 当星期6时只需判断不是1号就可进行操作
+								if (this.dayRuleSup !== 1) {
+									DD--;
+								} else {
+									DD += 2;
+								}
+							}
+						} else if (this.dayRule == 'weekDay') {
+							// 如果指定了是星期几
+							// 获取当前日期是属于星期几
+							let thisWeek = this.formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week');
+							// 校验当前星期是否在星期池(dayRuleSup)中
+							if (this.dayRuleSup.indexOf(thisWeek) < 0) {
+								// 如果到达最大值时
+								if (Di == DDate.length - 1) {
+									resetDay();
+									if (Mi == MDate.length - 1) {
+										resetMonth();
+										continue goYear;
+									}
+									continue goMonth;
+								}
+								continue;
+							}
+						} else if (this.dayRule == 'assWeek') {
+							// 如果指定了是第几周的星期几
+							// 获取每月1号是属于星期几
+							let thisWeek = this.formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week');
+							if (this.dayRuleSup[1] >= thisWeek) {
+								DD = (this.dayRuleSup[0] - 1) * 7 + this.dayRuleSup[1] - thisWeek + 1;
+							} else {
+								DD = this.dayRuleSup[0] * 7 + this.dayRuleSup[1] - thisWeek + 1;
+							}
+						} else if (this.dayRule == 'lastWeek') {
+							// 如果指定了每月最后一个星期几
+							// 校验并调整如果是2月30号这种日期传进来时需调整至正常月底
+							if (this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+								while (DD > 0 && this.checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+									DD--;
+									thisDD = DD < 10 ? '0' + DD : DD;
+								}
+							}
+							// 获取月末最后一天是星期几
+							let thisWeek = this.formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week');
+							// 找到要求中最近的那个星期几
+							if (this.dayRuleSup < thisWeek) {
+								DD -= thisWeek - this.dayRuleSup;
+							} else if (this.dayRuleSup > thisWeek) {
+								DD -= 7 - (this.dayRuleSup - thisWeek)
+							}
+						}
+						// 判断时间值是否小于10置换成“05”这种格式
+						DD = DD < 10 ? '0' + DD : DD;
+
+						// 循环“时”数组
+						goHour: for (let hi = hIdx; hi < hDate.length; hi++) {
+							let hh = hDate[hi] < 10 ? '0' + hDate[hi] : hDate[hi]
+
+							// 如果到达最大值时
+							if (nMin > mDate[mDate.length - 1]) {
+								resetMin();
+								if (hi == hDate.length - 1) {
+									resetHour();
+									if (Di == DDate.length - 1) {
+										resetDay();
+										if (Mi == MDate.length - 1) {
+											resetMonth();
+											continue goYear;
+										}
+										continue goMonth;
+									}
+									continue goDay;
+								}
+								continue;
+							}
+							// 循环"分"数组
+							goMin: for (let mi = mIdx; mi < mDate.length; mi++) {
+								let mm = mDate[mi] < 10 ? '0' + mDate[mi] : mDate[mi];
+
+								// 如果到达最大值时
+								if (nSecond > sDate[sDate.length - 1]) {
+									resetSecond();
+									if (mi == mDate.length - 1) {
+										resetMin();
+										if (hi == hDate.length - 1) {
+											resetHour();
+											if (Di == DDate.length - 1) {
+												resetDay();
+												if (Mi == MDate.length - 1) {
+													resetMonth();
+													continue goYear;
+												}
+												continue goMonth;
+											}
+											continue goDay;
+										}
+										continue goHour;
+									}
+									continue;
+								}
+								// 循环"秒"数组
+								goSecond: for (let si = sIdx; si <= sDate.length - 1; si++) {
+									let ss = sDate[si] < 10 ? '0' + sDate[si] : sDate[si];
+									// 添加当前时间(时间合法性在日期循环时已经判断)
+									if (MM !== '00' && DD !== '00') {
+										resultArr.push(YY + '-' + MM + '-' + DD + ' ' + hh + ':' + mm + ':' + ss)
+										nums++;
+									}
+									// 如果条数满了就退出循环
+									if (nums == 5) break goYear;
+									// 如果到达最大值时
+									if (si == sDate.length - 1) {
+										resetSecond();
+										if (mi == mDate.length - 1) {
+											resetMin();
+											if (hi == hDate.length - 1) {
+												resetHour();
+												if (Di == DDate.length - 1) {
+													resetDay();
+													if (Mi == MDate.length - 1) {
+														resetMonth();
+														continue goYear;
+													}
+													continue goMonth;
+												}
+												continue goDay;
+											}
+											continue goHour;
+										}
+										continue goMin;
+									}
+								} //goSecond
+							} //goMin
+						}//goHour
+					}//goDay
+				}//goMonth
+			}
+			// 判断100年内的结果条数
+			if (resultArr.length == 0) {
+				this.resultList = ['没有达到条件的结果!'];
+			} else {
+				this.resultList = resultArr;
+				if (resultArr.length !== 5) {
+					this.resultList.push('最近100年内只有上面' + resultArr.length + '条结果!')
+				}
+			}
+			// 计算完成-显示结果
+			this.isShow = true;
+
+
+		},
+		// 用于计算某位数字在数组中的索引
+		getIndex(arr, value) {
+			if (value <= arr[0] || value > arr[arr.length - 1]) {
+				return 0;
+			} else {
+				for (let i = 0; i < arr.length - 1; i++) {
+					if (value > arr[i] && value <= arr[i + 1]) {
+						return i + 1;
+					}
+				}
+			}
+		},
+		// 获取"年"数组
+		getYearArr(rule, year) {
+			this.dateArr[5] = this.getOrderArr(year, year + 100);
+			if (rule !== undefined) {
+				if (rule.indexOf('-') >= 0) {
+					this.dateArr[5] = this.getCycleArr(rule, year + 100, false)
+				} else if (rule.indexOf('/') >= 0) {
+					this.dateArr[5] = this.getAverageArr(rule, year + 100)
+				} else if (rule !== '*') {
+					this.dateArr[5] = this.getAssignArr(rule)
+				}
+			}
+		},
+		// 获取"月"数组
+		getMonthArr(rule) {
+			this.dateArr[4] = this.getOrderArr(1, 12);
+			if (rule.indexOf('-') >= 0) {
+				this.dateArr[4] = this.getCycleArr(rule, 12, false)
+			} else if (rule.indexOf('/') >= 0) {
+				this.dateArr[4] = this.getAverageArr(rule, 12)
+			} else if (rule !== '*') {
+				this.dateArr[4] = this.getAssignArr(rule)
+			}
+		},
+		// 获取"日"数组-主要为日期规则
+		getWeekArr(rule) {
+			// 只有当日期规则的两个值均为“”时则表达日期是有选项的
+			if (this.dayRule == '' && this.dayRuleSup == '') {
+				if (rule.indexOf('-') >= 0) {
+					this.dayRule = 'weekDay';
+					this.dayRuleSup = this.getCycleArr(rule, 7, false)
+				} else if (rule.indexOf('#') >= 0) {
+					this.dayRule = 'assWeek';
+					let matchRule = rule.match(/[0-9]{1}/g);
+					this.dayRuleSup = [Number(matchRule[1]), Number(matchRule[0])];
+					this.dateArr[3] = [1];
+					if (this.dayRuleSup[1] == 7) {
+						this.dayRuleSup[1] = 0;
+					}
+				} else if (rule.indexOf('L') >= 0) {
+					this.dayRule = 'lastWeek';
+					this.dayRuleSup = Number(rule.match(/[0-9]{1,2}/g)[0]);
+					this.dateArr[3] = [31];
+					if (this.dayRuleSup == 7) {
+						this.dayRuleSup = 0;
+					}
+				} else if (rule !== '*' && rule !== '?') {
+					this.dayRule = 'weekDay';
+					this.dayRuleSup = this.getAssignArr(rule)
+				}
+			}
+		},
+		// 获取"日"数组-少量为日期规则
+		getDayArr(rule) {
+			this.dateArr[3] = this.getOrderArr(1, 31);
+			this.dayRule = '';
+			this.dayRuleSup = '';
+			if (rule.indexOf('-') >= 0) {
+				this.dateArr[3] = this.getCycleArr(rule, 31, false)
+				this.dayRuleSup = 'null';
+			} else if (rule.indexOf('/') >= 0) {
+				this.dateArr[3] = this.getAverageArr(rule, 31)
+				this.dayRuleSup = 'null';
+			} else if (rule.indexOf('W') >= 0) {
+				this.dayRule = 'workDay';
+				this.dayRuleSup = Number(rule.match(/[0-9]{1,2}/g)[0]);
+				this.dateArr[3] = [this.dayRuleSup];
+			} else if (rule.indexOf('L') >= 0) {
+				this.dayRule = 'lastDay';
+				this.dayRuleSup = 'null';
+				this.dateArr[3] = [31];
+			} else if (rule !== '*' && rule !== '?') {
+				this.dateArr[3] = this.getAssignArr(rule)
+				this.dayRuleSup = 'null';
+			} else if (rule == '*') {
+				this.dayRuleSup = 'null';
+			}
+		},
+		// 获取"时"数组
+		getHourArr(rule) {
+			this.dateArr[2] = this.getOrderArr(0, 23);
+			if (rule.indexOf('-') >= 0) {
+				this.dateArr[2] = this.getCycleArr(rule, 24, true)
+			} else if (rule.indexOf('/') >= 0) {
+				this.dateArr[2] = this.getAverageArr(rule, 23)
+			} else if (rule !== '*') {
+				this.dateArr[2] = this.getAssignArr(rule)
+			}
+		},
+		// 获取"分"数组
+		getMinArr(rule) {
+			this.dateArr[1] = this.getOrderArr(0, 59);
+			if (rule.indexOf('-') >= 0) {
+				this.dateArr[1] = this.getCycleArr(rule, 60, true)
+			} else if (rule.indexOf('/') >= 0) {
+				this.dateArr[1] = this.getAverageArr(rule, 59)
+			} else if (rule !== '*') {
+				this.dateArr[1] = this.getAssignArr(rule)
+			}
+		},
+		// 获取"秒"数组
+		getSecondArr(rule) {
+			this.dateArr[0] = this.getOrderArr(0, 59);
+			if (rule.indexOf('-') >= 0) {
+				this.dateArr[0] = this.getCycleArr(rule, 60, true)
+			} else if (rule.indexOf('/') >= 0) {
+				this.dateArr[0] = this.getAverageArr(rule, 59)
+			} else if (rule !== '*') {
+				this.dateArr[0] = this.getAssignArr(rule)
+			}
+		},
+		// 根据传进来的min-max返回一个顺序的数组
+		getOrderArr(min, max) {
+			let arr = [];
+			for (let i = min; i <= max; i++) {
+				arr.push(i);
+			}
+			return arr;
+		},
+		// 根据规则中指定的零散值返回一个数组
+		getAssignArr(rule) {
+			let arr = [];
+			let assiginArr = rule.split(',');
+			for (let i = 0; i < assiginArr.length; i++) {
+				arr[i] = Number(assiginArr[i])
+			}
+			arr.sort(this.compare)
+			return arr;
+		},
+		// 根据一定算术规则计算返回一个数组
+		getAverageArr(rule, limit) {
+			let arr = [];
+			let agArr = rule.split('/');
+			let min = Number(agArr[0]);
+			let step = Number(agArr[1]);
+			while (min <= limit) {
+				arr.push(min);
+				min += step;
+			}
+			return arr;
+		},
+		// 根据规则返回一个具有周期性的数组
+		getCycleArr(rule, limit, status) {
+			// status--表示是否从0开始(则从1开始)
+			let arr = [];
+			let cycleArr = rule.split('-');
+			let min = Number(cycleArr[0]);
+			let max = Number(cycleArr[1]);
+			if (min > max) {
+				max += limit;
+			}
+			for (let i = min; i <= max; i++) {
+				let add = 0;
+				if (status == false && i % limit == 0) {
+					add = limit;
+				}
+				arr.push(Math.round(i % limit + add))
+			}
+			arr.sort(this.compare)
+			return arr;
+		},
+		// 比较数字大小(用于Array.sort)
+		compare(value1, value2) {
+			if (value2 - value1 > 0) {
+				return -1;
+			} else {
+				return 1;
+			}
+		},
+		// 格式化日期格式如:2017-9-19 18:04:33
+		formatDate(value, type) {
+			// 计算日期相关值
+			let time = typeof value == 'number' ? new Date(value) : value;
+			let Y = time.getFullYear();
+			let M = time.getMonth() + 1;
+			let D = time.getDate();
+			let h = time.getHours();
+			let m = time.getMinutes();
+			let s = time.getSeconds();
+			let week = time.getDay();
+			// 如果传递了type的话
+			if (type == undefined) {
+				return Y + '-' + (M < 10 ? '0' + M : M) + '-' + (D < 10 ? '0' + D : D) + ' ' + (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
+			} else if (type == 'week') {
+				// 在quartz中 1为星期日
+				return week + 1;
+			}
+		},
+		// 检查日期是否存在
+		checkDate(value) {
+			let time = new Date(value);
+			let format = this.formatDate(time)
+			return value === format;
+		}
+	},
+	watch: {
+		'ex': 'expressionChange'
+	},
+	props: ['ex'],
+	mounted: function () {
+		// 初始化 获取一次结果
+		this.expressionChange();
+	}
+}
+
+</script>

+ 117 - 0
src/components/Crontab/second.vue

@@ -0,0 +1,117 @@
+<template>
+	<el-form size="small">
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="1">
+				秒,允许的通配符[, - * /]
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="2">
+				周期从
+				<el-input-number v-model='cycle01' :min="0" :max="58" /> -
+				<el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : 1" :max="59" /> 秒
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="3">
+				从
+				<el-input-number v-model='average01' :min="0" :max="58" /> 秒开始,每
+				<el-input-number v-model='average02' :min="1" :max="59 - average01 || 0" /> 秒执行一次
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="4">
+				指定
+				<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
+					<el-option v-for="item in 60" :key="item" :value="item-1">{{item-1}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+	</el-form>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			radioValue: 1,
+			cycle01: 1,
+			cycle02: 2,
+			average01: 0,
+			average02: 1,
+			checkboxList: [],
+			checkNum: this.$options.propsData.check
+		}
+	},
+	name: 'crontab-second',
+	props: ['check', 'radioParent'],
+	methods: {
+		// 单选按钮值变化时
+		radioChange() {
+			switch (this.radioValue) {
+				case 1:
+					this.$emit('update', 'second', '*', 'second');
+					break;
+				case 2:
+					this.$emit('update', 'second', this.cycleTotal);
+					break;
+				case 3:
+					this.$emit('update', 'second', this.averageTotal);
+					break;
+				case 4:
+					this.$emit('update', 'second', this.checkboxString);
+					break;
+			}
+		},
+		// 周期两个值变化时
+		cycleChange() {
+			if (this.radioValue == '2') {
+				this.$emit('update', 'second', this.cycleTotal);
+			}
+		},
+		// 平均两个值变化时
+		averageChange() {
+			if (this.radioValue == '3') {
+				this.$emit('update', 'second', this.averageTotal);
+			}
+		},
+		// checkbox值变化时
+		checkboxChange() {
+			if (this.radioValue == '4') {
+				this.$emit('update', 'second', this.checkboxString);
+			}
+		}
+	},
+	watch: {
+		'radioValue': 'radioChange',
+		'cycleTotal': 'cycleChange',
+		'averageTotal': 'averageChange',
+		'checkboxString': 'checkboxChange',
+		radioParent() {
+			this.radioValue = this.radioParent
+		}
+	},
+	computed: {
+		// 计算两个周期值
+		cycleTotal: function () {
+			const cycle01 = this.checkNum(this.cycle01, 0, 58)
+			const cycle02 = this.checkNum(this.cycle02, cycle01 ? cycle01 + 1 : 1, 59)
+			return cycle01 + '-' + cycle02;
+		},
+		// 计算平均用到的值
+		averageTotal: function () {
+			const average01 = this.checkNum(this.average01, 0, 58)
+			const average02 = this.checkNum(this.average02, 1, 59 - average01 || 0)
+			return average01 + '/' + average02;
+		},
+		// 计算勾选的checkbox值合集
+		checkboxString: function () {
+			let str = this.checkboxList.join();
+			return str == '' ? '*' : str;
+		}
+	}
+}
+</script>

+ 202 - 0
src/components/Crontab/week.vue

@@ -0,0 +1,202 @@
+<template>
+	<el-form size='small'>
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="1">
+				周,允许的通配符[, - * ? / L #]
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="2">
+				不指定
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="3">
+				周期从星期
+				<el-select clearable v-model="cycle01">
+					<el-option
+						v-for="(item,index) of weekList"
+						:key="index"
+						:label="item.value"
+						:value="item.key"
+						:disabled="item.key === 1"
+					>{{item.value}}</el-option>
+				</el-select>
+				-
+				<el-select clearable v-model="cycle02">
+					<el-option
+						v-for="(item,index) of weekList"
+						:key="index"
+						:label="item.value"
+						:value="item.key"
+						:disabled="item.key < cycle01 && item.key !== 1"
+					>{{item.value}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="4">
+				第
+				<el-input-number v-model='average01' :min="1" :max="4" /> 周的星期
+				<el-select clearable v-model="average02">
+					<el-option v-for="(item,index) of weekList" :key="index" :label="item.value" :value="item.key">{{item.value}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="5">
+				本月最后一个星期
+				<el-select clearable v-model="weekday">
+					<el-option v-for="(item,index) of weekList" :key="index" :label="item.value" :value="item.key">{{item.value}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio v-model='radioValue' :label="6">
+				指定
+				<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
+					<el-option v-for="(item,index) of weekList" :key="index" :label="item.value" :value="String(item.key)">{{item.value}}</el-option>
+				</el-select>
+			</el-radio>
+		</el-form-item>
+
+	</el-form>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			radioValue: 2,
+			weekday: 2,
+			cycle01: 2,
+			cycle02: 3,
+			average01: 1,
+			average02: 2,
+			checkboxList: [],
+			weekList: [
+				{
+					key: 2,
+					value: '星期一'
+				},
+				{
+					key: 3,
+					value: '星期二'
+				},
+				{
+					key: 4,
+					value: '星期三'
+				},
+				{
+					key: 5,
+					value: '星期四'
+				},
+				{
+					key: 6,
+					value: '星期五'
+				},
+				{
+					key: 7,
+					value: '星期六'
+				},
+				{
+					key: 1,
+					value: '星期日'
+				}
+			],
+			checkNum: this.$options.propsData.check
+		}
+	},
+	name: 'crontab-week',
+	props: ['check', 'cron'],
+	methods: {
+		// 单选按钮值变化时
+		radioChange() {
+			if (this.radioValue !== 2 && this.cron.day !== '?') {
+				this.$emit('update', 'day', '?', 'week');
+			}
+			switch (this.radioValue) {
+				case 1:
+					this.$emit('update', 'week', '*');
+					break;
+				case 2:
+					this.$emit('update', 'week', '?');
+					break;
+				case 3:
+					this.$emit('update', 'week', this.cycleTotal);
+					break;
+				case 4:
+					this.$emit('update', 'week', this.averageTotal);
+					break;
+				case 5:
+					this.$emit('update', 'week', this.weekdayCheck + 'L');
+					break;
+				case 6:
+					this.$emit('update', 'week', this.checkboxString);
+					break;
+			}
+		},
+
+		// 周期两个值变化时
+		cycleChange() {
+			if (this.radioValue == '3') {
+				this.$emit('update', 'week', this.cycleTotal);
+			}
+		},
+		// 平均两个值变化时
+		averageChange() {
+			if (this.radioValue == '4') {
+				this.$emit('update', 'week', this.averageTotal);
+			}
+		},
+		// 最近工作日值变化时
+		weekdayChange() {
+			if (this.radioValue == '5') {
+				this.$emit('update', 'week', this.weekday + 'L');
+			}
+		},
+		// checkbox值变化时
+		checkboxChange() {
+			if (this.radioValue == '6') {
+				this.$emit('update', 'week', this.checkboxString);
+			}
+		},
+	},
+	watch: {
+		'radioValue': 'radioChange',
+		'cycleTotal': 'cycleChange',
+		'averageTotal': 'averageChange',
+		'weekdayCheck': 'weekdayChange',
+		'checkboxString': 'checkboxChange',
+	},
+	computed: {
+		// 计算两个周期值
+		cycleTotal: function () {
+			this.cycle01 = this.checkNum(this.cycle01, 1, 7)
+			this.cycle02 = this.checkNum(this.cycle02, 1, 7)
+			return this.cycle01 + '-' + this.cycle02;
+		},
+		// 计算平均用到的值
+		averageTotal: function () {
+			this.average01 = this.checkNum(this.average01, 1, 4)
+			this.average02 = this.checkNum(this.average02, 1, 7)
+			return this.average02 + '#' + this.average01;
+		},
+		// 最近的工作日(格式)
+		weekdayCheck: function () {
+			this.weekday = this.checkNum(this.weekday, 1, 7)
+			return this.weekday;
+		},
+		// 计算勾选的checkbox值合集
+		checkboxString: function () {
+			let str = this.checkboxList.join();
+			return str == '' ? '*' : str;
+		}
+	}
+}
+</script>

+ 131 - 0
src/components/Crontab/year.vue

@@ -0,0 +1,131 @@
+<template>
+	<el-form size="small">
+		<el-form-item>
+			<el-radio :label="1" v-model='radioValue'>
+				不填,允许的通配符[, - * /]
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio :label="2" v-model='radioValue'>
+				每年
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio :label="3" v-model='radioValue'>
+				周期从
+				<el-input-number v-model='cycle01' :min='fullYear' :max="2098" /> -
+				<el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : fullYear + 1" :max="2099" />
+			</el-radio>
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio :label="4" v-model='radioValue'>
+				从
+				<el-input-number v-model='average01' :min='fullYear' :max="2098"/> 年开始,每
+				<el-input-number v-model='average02' :min="1" :max="2099 - average01 || fullYear" /> 年执行一次
+			</el-radio>
+
+		</el-form-item>
+
+		<el-form-item>
+			<el-radio :label="5" v-model='radioValue'>
+				指定
+				<el-select clearable v-model="checkboxList" placeholder="可多选" multiple>
+					<el-option v-for="item in 9" :key="item" :value="item - 1 + fullYear" :label="item -1 + fullYear" />
+				</el-select>
+			</el-radio>
+		</el-form-item>
+	</el-form>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			fullYear: 0,
+			radioValue: 1,
+			cycle01: 0,
+			cycle02: 0,
+			average01: 0,
+			average02: 1,
+			checkboxList: [],
+			checkNum: this.$options.propsData.check
+		}
+	},
+	name: 'crontab-year',
+	props: ['check', 'month', 'cron'],
+	methods: {
+		// 单选按钮值变化时
+		radioChange() {
+			switch (this.radioValue) {
+				case 1:
+					this.$emit('update', 'year', '');
+					break;
+				case 2:
+					this.$emit('update', 'year', '*');
+					break;
+				case 3:
+					this.$emit('update', 'year', this.cycleTotal);
+					break;
+				case 4:
+					this.$emit('update', 'year', this.averageTotal);
+					break;
+				case 5:
+					this.$emit('update', 'year', this.checkboxString);
+					break;
+			}
+		},
+		// 周期两个值变化时
+		cycleChange() {
+			if (this.radioValue == '3') {
+				this.$emit('update', 'year', this.cycleTotal);
+			}
+		},
+		// 平均两个值变化时
+		averageChange() {
+			if (this.radioValue == '4') {
+				this.$emit('update', 'year', this.averageTotal);
+			}
+		},
+		// checkbox值变化时
+		checkboxChange() {
+			if (this.radioValue == '5') {
+				this.$emit('update', 'year', this.checkboxString);
+			}
+		}
+	},
+	watch: {
+		'radioValue': 'radioChange',
+		'cycleTotal': 'cycleChange',
+		'averageTotal': 'averageChange',
+		'checkboxString': 'checkboxChange'
+	},
+	computed: {
+		// 计算两个周期值
+		cycleTotal: function () {
+			const cycle01 = this.checkNum(this.cycle01, this.fullYear, 2098)
+			const cycle02 = this.checkNum(this.cycle02, cycle01 ? cycle01 + 1 : this.fullYear + 1, 2099)
+			return cycle01 + '-' + cycle02;
+		},
+		// 计算平均用到的值
+		averageTotal: function () {
+			const average01 = this.checkNum(this.average01, this.fullYear, 2098)
+			const average02 = this.checkNum(this.average02, 1, 2099 - average01 || this.fullYear)
+			return average01 + '/' + average02;
+		},
+		// 计算勾选的checkbox值合集
+		checkboxString: function () {
+			let str = this.checkboxList.join();
+			return str;
+		}
+	},
+	mounted: function () {
+		// 仅获取当前年份
+		this.fullYear = Number(new Date().getFullYear());
+		this.cycle01 = this.fullYear
+		this.average01 = this.fullYear
+	}
+}
+</script>

+ 49 - 0
src/components/DictData/index.js

@@ -0,0 +1,49 @@
+import Vue from 'vue'
+import store from '@/store'
+import DataDict from '@/utils/dict'
+import { getDicts as getDicts } from '@/api/system/dict/data'
+
+function searchDictByKey(dict, key) {
+  if (key == null && key == "") {
+    return null
+  }
+  try {
+    for (let i = 0; i < dict.length; i++) {
+      if (dict[i].key == key) {
+        return dict[i].value
+      }
+    }
+  } catch (e) {
+    return null
+  }
+}
+
+function install() {
+  Vue.use(DataDict, {
+    metas: {
+      '*': {
+        labelField: 'dictLabel',
+        valueField: 'dictValue',
+        request(dictMeta) {
+          const storeDict = searchDictByKey(store.getters.dict, dictMeta.type)
+          if (storeDict) {
+            return new Promise(resolve => { resolve(storeDict) })
+          } else {
+            return new Promise((resolve, reject) => {
+              getDicts(dictMeta.type).then(res => {
+                store.dispatch('dict/setDict', { key: dictMeta.type, value: res.data })
+                resolve(res.data)
+              }).catch(error => {
+                reject(error)
+              })
+            })
+          }
+        },
+      },
+    },
+  })
+}
+
+export default {
+  install,
+}

+ 52 - 0
src/components/DictTag/index.vue

@@ -0,0 +1,52 @@
+<template>
+  <div>
+    <template v-for="(item, index) in options">
+      <template v-if="values.includes(item.value)">
+        <span
+          v-if="item.raw.listClass == 'default' || item.raw.listClass == ''"
+          :key="item.value"
+          :index="index"
+          :class="item.raw.cssClass"
+          >{{ item.label }}</span
+        >
+        <el-tag
+          v-else
+          :disable-transitions="true"
+          :key="item.value"
+          :index="index"
+          :type="item.raw.listClass == 'primary' ? '' : item.raw.listClass"
+          :class="item.raw.cssClass"
+        >
+          {{ item.label }}
+        </el-tag>
+      </template>
+    </template>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "DictTag",
+  props: {
+    options: {
+      type: Array,
+      default: null,
+    },
+    value: [Number, String, Array],
+  },
+  computed: {
+    values() {
+      if (this.value !== null && typeof this.value !== 'undefined') {
+        return Array.isArray(this.value) ? this.value : [String(this.value)];
+      } else {
+        return [];
+      }
+    },
+  },
+};
+</script>
+<style scoped>
+.el-tag + .el-tag {
+  margin-left: 10px;
+}
+</style>

+ 272 - 0
src/components/Editor/index.vue

@@ -0,0 +1,272 @@
+<template>
+  <div>
+    <el-upload
+      :action="uploadUrl"
+      :before-upload="handleBeforeUpload"
+      :on-success="handleUploadSuccess"
+      :on-error="handleUploadError"
+      name="file"
+      :show-file-list="false"
+      :headers="headers"
+      style="display: none"
+      ref="upload"
+      v-if="this.type == 'url'"
+    >
+    </el-upload>
+    <div class="editor" ref="editor" :style="styles"></div>
+  </div>
+</template>
+
+<script>
+import Quill from "quill";
+import "quill/dist/quill.core.css";
+import "quill/dist/quill.snow.css";
+import "quill/dist/quill.bubble.css";
+import { getToken } from "@/utils/auth";
+
+export default {
+  name: "Editor",
+  props: {
+    /* 编辑器的内容 */
+    value: {
+      type: String,
+      default: "",
+    },
+    /* 高度 */
+    height: {
+      type: Number,
+      default: null,
+    },
+    /* 最小高度 */
+    minHeight: {
+      type: Number,
+      default: null,
+    },
+    /* 只读 */
+    readOnly: {
+      type: Boolean,
+      default: false,
+    },
+    // 上传文件大小限制(MB)
+    fileSize: {
+      type: Number,
+      default: 5,
+    },
+    /* 类型(base64格式、url格式) */
+    type: {
+      type: String,
+      default: "url",
+    }
+  },
+  data() {
+    return {
+      uploadUrl: process.env.VUE_APP_BASE_API + "/file/upload", // 上传的图片服务器地址
+      headers: {
+        Authorization: "Bearer " + getToken()
+      },
+      Quill: null,
+      currentValue: "",
+      options: {
+        theme: "snow",
+        bounds: document.body,
+        debug: "warn",
+        modules: {
+          // 工具栏配置
+          toolbar: [
+            ["bold", "italic", "underline", "strike"],       // 加粗 斜体 下划线 删除线
+            ["blockquote", "code-block"],                    // 引用  代码块
+            [{ list: "ordered" }, { list: "bullet" }],       // 有序、无序列表
+            [{ indent: "-1" }, { indent: "+1" }],            // 缩进
+            [{ size: ["small", false, "large", "huge"] }],   // 字体大小
+            [{ header: [1, 2, 3, 4, 5, 6, false] }],         // 标题
+            [{ color: [] }, { background: [] }],             // 字体颜色、字体背景颜色
+            [{ align: [] }],                                 // 对齐方式
+            ["clean"],                                       // 清除文本格式
+            ["link", "image", "video"]                       // 链接、图片、视频
+          ],
+        },
+        placeholder: "请输入内容",
+        readOnly: this.readOnly,
+      },
+    };
+  },
+  computed: {
+    styles() {
+      let style = {};
+      if (this.minHeight) {
+        style.minHeight = `${this.minHeight}px`;
+      }
+      if (this.height) {
+        style.height = `${this.height}px`;
+      }
+      return style;
+    },
+  },
+  watch: {
+    value: {
+      handler(val) {
+        if (val !== this.currentValue) {
+          this.currentValue = val === null ? "" : val;
+          if (this.Quill) {
+            this.Quill.pasteHTML(this.currentValue);
+          }
+        }
+      },
+      immediate: true,
+    },
+  },
+  mounted() {
+    this.init();
+  },
+  beforeDestroy() {
+    this.Quill = null;
+  },
+  methods: {
+    init() {
+      const editor = this.$refs.editor;
+      this.Quill = new Quill(editor, this.options);
+      // 如果设置了上传地址则自定义图片上传事件
+      if (this.type == 'url') {
+        let toolbar = this.Quill.getModule("toolbar");
+        toolbar.addHandler("image", (value) => {
+          this.uploadType = "image";
+          if (value) {
+            this.$refs.upload.$children[0].$refs.input.click();
+          } else {
+            this.quill.format("image", false);
+          }
+        });
+      }
+      this.Quill.pasteHTML(this.currentValue);
+      this.Quill.on("text-change", (delta, oldDelta, source) => {
+        const html = this.$refs.editor.children[0].innerHTML;
+        const text = this.Quill.getText();
+        const quill = this.Quill;
+        this.currentValue = html;
+        this.$emit("input", html);
+        this.$emit("on-change", { html, text, quill });
+      });
+      this.Quill.on("text-change", (delta, oldDelta, source) => {
+        this.$emit("on-text-change", delta, oldDelta, source);
+      });
+      this.Quill.on("selection-change", (range, oldRange, source) => {
+        this.$emit("on-selection-change", range, oldRange, source);
+      });
+      this.Quill.on("editor-change", (eventName, ...args) => {
+        this.$emit("on-editor-change", eventName, ...args);
+      });
+    },
+    // 上传前校检格式和大小
+    handleBeforeUpload(file) {
+      // 校检文件大小
+      if (this.fileSize) {
+        const isLt = file.size / 1024 / 1024 < this.fileSize;
+        if (!isLt) {
+          this.$message.error(`上传文件大小不能超过 ${this.fileSize} MB!`);
+          return false;
+        }
+      }
+      return true;
+    },
+    handleUploadSuccess(res, file) {
+      // 获取富文本组件实例
+      let quill = this.Quill;
+      // 如果上传成功
+      if (res.code == 200) {
+        // 获取光标所在位置
+        let length = quill.getSelection().index;
+        // 插入图片  res.url为服务器返回的图片地址
+        quill.insertEmbed(length, "image", res.data.url);
+        // 调整光标到最后
+        quill.setSelection(length + 1);
+      } else {
+        this.$message.error("图片插入失败");
+      }
+    },
+    handleUploadError() {
+      this.$message.error("图片插入失败");
+    },
+  },
+};
+</script>
+
+<style>
+.editor, .ql-toolbar {
+  white-space: pre-wrap !important;
+  line-height: normal !important;
+}
+.quill-img {
+  display: none;
+}
+.ql-snow .ql-tooltip[data-mode="link"]::before {
+  content: "请输入链接地址:";
+}
+.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
+  border-right: 0px;
+  content: "保存";
+  padding-right: 0px;
+}
+
+.ql-snow .ql-tooltip[data-mode="video"]::before {
+  content: "请输入视频地址:";
+}
+
+.ql-snow .ql-picker.ql-size .ql-picker-label::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item::before {
+  content: "14px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="small"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="small"]::before {
+  content: "10px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="large"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="large"]::before {
+  content: "18px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="huge"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="huge"]::before {
+  content: "32px";
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item::before {
+  content: "文本";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
+  content: "标题1";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
+  content: "标题2";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
+  content: "标题3";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
+  content: "标题4";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
+  content: "标题5";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
+  content: "标题6";
+}
+
+.ql-snow .ql-picker.ql-font .ql-picker-label::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item::before {
+  content: "标准字体";
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="serif"]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="serif"]::before {
+  content: "衬线字体";
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="monospace"]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="monospace"]::before {
+  content: "等宽字体";
+}
+</style>

+ 214 - 0
src/components/FileUpload/index.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="upload-file">
+    <el-upload
+      multiple
+      :action="uploadFileUrl"
+      :before-upload="handleBeforeUpload"
+      :file-list="fileList"
+      :limit="limit"
+      :on-error="handleUploadError"
+      :on-exceed="handleExceed"
+      :on-success="handleUploadSuccess"
+      :show-file-list="false"
+      :headers="headers"
+      class="upload-file-uploader"
+      ref="fileUpload"
+    >
+      <!-- 上传按钮 -->
+      <el-button size="mini" type="primary">选取文件</el-button>
+      <!-- 上传提示 -->
+      <div class="el-upload__tip" slot="tip" v-if="showTip">
+        请上传
+        <template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
+        <template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
+        的文件
+      </div>
+    </el-upload>
+
+    <!-- 文件列表 -->
+    <transition-group class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
+      <li :key="file.url" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
+        <el-link :href="file.url" :underline="false" target="_blank">
+          <span class="el-icon-document"> {{ getFileName(file.name) }} </span>
+        </el-link>
+        <div class="ele-upload-list__item-content-action">
+          <el-link :underline="false" @click="handleDelete(index)" type="danger">删除</el-link>
+        </div>
+      </li>
+    </transition-group>
+  </div>
+</template>
+
+<script>
+import { getToken } from "@/utils/auth";
+
+export default {
+  name: "FileUpload",
+  props: {
+    // 值
+    value: [String, Object, Array],
+    // 数量限制
+    limit: {
+      type: Number,
+      default: 5,
+    },
+    // 大小限制(MB)
+    fileSize: {
+      type: Number,
+      default: 5,
+    },
+    // 文件类型, 例如['png', 'jpg', 'jpeg']
+    fileType: {
+      type: Array,
+      default: () => ["doc", "xls", "ppt", "txt", "pdf"],
+    },
+    // 是否显示提示
+    isShowTip: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data() {
+    return {
+      number: 0,
+      uploadList: [],
+      uploadFileUrl: process.env.VUE_APP_BASE_API + "/file/upload", // 上传文件服务器地址
+      headers: {
+        Authorization: "Bearer " + getToken(),
+      },
+      fileList: [],
+    };
+  },
+  watch: {
+    value: {
+      handler(val) {
+        if (val) {
+          let temp = 1;
+          // 首先将值转为数组
+          const list = Array.isArray(val) ? val : this.value.split(',');
+          // 然后将数组转为对象数组
+          this.fileList = list.map(item => {
+            if (typeof item === "string") {
+              item = { name: item, url: item };
+            }
+            item.uid = item.uid || new Date().getTime() + temp++;
+            return item;
+          });
+        } else {
+          this.fileList = [];
+          return [];
+        }
+      },
+      deep: true,
+      immediate: true
+    }
+  },
+  computed: {
+    // 是否显示提示
+    showTip() {
+      return this.isShowTip && (this.fileType || this.fileSize);
+    },
+  },
+  methods: {
+    // 上传前校检格式和大小
+    handleBeforeUpload(file) {
+      // 校检文件类型
+      if (this.fileType) {
+        const fileName = file.name.split('.');
+        const fileExt = fileName[fileName.length - 1];
+        const isTypeOk = this.fileType.indexOf(fileExt) >= 0;
+        if (!isTypeOk) {
+          this.$modal.msgError(`文件格式不正确, 请上传${this.fileType.join("/")}格式文件!`);
+          return false;
+        }
+      }
+      // 校检文件大小
+      if (this.fileSize) {
+        const isLt = file.size / 1024 / 1024 < this.fileSize;
+        if (!isLt) {
+          this.$modal.msgError(`上传文件大小不能超过 ${this.fileSize} MB!`);
+          return false;
+        }
+      }
+      this.$modal.loading("正在上传文件,请稍候...");
+      this.number++;
+      return true;
+    },
+    // 文件个数超出
+    handleExceed() {
+      this.$modal.msgError(`上传文件数量不能超过 ${this.limit} 个!`);
+    },
+    // 上传失败
+    handleUploadError(err) {
+      this.$modal.msgError("上传文件失败,请重试");
+      this.$modal.closeLoading()
+    },
+    // 上传成功回调
+    handleUploadSuccess(res, file) {
+      if (res.code === 200) {
+        this.uploadList.push({ name: res.data.url, url: res.data.url });
+        this.uploadedSuccessfully();
+      } else {
+        this.number--;
+        this.$modal.closeLoading();
+        this.$modal.msgError(res.msg);
+        this.$refs.fileUpload.handleRemove(file);
+        this.uploadedSuccessfully();
+      }
+    },
+    // 删除文件
+    handleDelete(index) {
+      this.fileList.splice(index, 1);
+      this.$emit("input", this.listToString(this.fileList));
+    },
+    // 上传结束处理
+    uploadedSuccessfully() {
+      if (this.number > 0 && this.uploadList.length === this.number) {
+        this.fileList = this.fileList.concat(this.uploadList);
+        this.uploadList = [];
+        this.number = 0;
+        this.$emit("input", this.listToString(this.fileList));
+        this.$modal.closeLoading();
+      }
+    },
+    // 获取文件名称
+    getFileName(name) {
+      if (name.lastIndexOf("/") > -1) {
+        return name.slice(name.lastIndexOf("/") + 1);
+      } else {
+        return "";
+      }
+    },
+    // 对象转成指定字符串分隔
+    listToString(list, separator) {
+      let strs = "";
+      separator = separator || ",";
+      for (let i in list) {
+        strs += list[i].url + separator;
+      }
+      return strs != '' ? strs.substr(0, strs.length - 1) : '';
+    }
+  }
+};
+</script>
+
+<style scoped lang="scss">
+.upload-file-uploader {
+  margin-bottom: 5px;
+}
+.upload-file-list .el-upload-list__item {
+  border: 1px solid #e4e7ed;
+  line-height: 2;
+  margin-bottom: 10px;
+  position: relative;
+}
+.upload-file-list .ele-upload-list__item-content {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  color: inherit;
+}
+.ele-upload-list__item-content-action .el-link {
+  margin-right: 10px;
+}
+</style>

+ 44 - 0
src/components/Hamburger/index.vue

@@ -0,0 +1,44 @@
+<template>
+  <div style="padding: 0 15px;" @click="toggleClick">
+    <svg
+      :class="{'is-active':isActive}"
+      class="hamburger"
+      viewBox="0 0 1024 1024"
+      xmlns="http://www.w3.org/2000/svg"
+      width="64"
+      height="64"
+    >
+      <path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
+    </svg>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Hamburger',
+  props: {
+    isActive: {
+      type: Boolean,
+      default: false
+    }
+  },
+  methods: {
+    toggleClick() {
+      this.$emit('toggleClick')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.hamburger {
+  display: inline-block;
+  vertical-align: middle;
+  width: 20px;
+  height: 20px;
+}
+
+.hamburger.is-active {
+  transform: rotate(180deg);
+}
+</style>

+ 190 - 0
src/components/HeaderSearch/index.vue

@@ -0,0 +1,190 @@
+<template>
+  <div :class="{'show':show}" class="header-search">
+    <svg-icon class-name="search-icon" icon-class="search" @click.stop="click" />
+    <el-select
+      ref="headerSearchSelect"
+      v-model="search"
+      :remote-method="querySearch"
+      filterable
+      default-first-option
+      remote
+      placeholder="Search"
+      class="header-search-select"
+      @change="change"
+    >
+      <el-option v-for="option in options" :key="option.item.path" :value="option.item" :label="option.item.title.join(' > ')" />
+    </el-select>
+  </div>
+</template>
+
+<script>
+// fuse is a lightweight fuzzy-search module
+// make search results more in line with expectations
+import Fuse from 'fuse.js/dist/fuse.min.js'
+import path from 'path'
+
+export default {
+  name: 'HeaderSearch',
+  data() {
+    return {
+      search: '',
+      options: [],
+      searchPool: [],
+      show: false,
+      fuse: undefined
+    }
+  },
+  computed: {
+    routes() {
+      return this.$store.getters.permission_routes
+    }
+  },
+  watch: {
+    routes() {
+      this.searchPool = this.generateRoutes(this.routes)
+    },
+    searchPool(list) {
+      this.initFuse(list)
+    },
+    show(value) {
+      if (value) {
+        document.body.addEventListener('click', this.close)
+      } else {
+        document.body.removeEventListener('click', this.close)
+      }
+    }
+  },
+  mounted() {
+    this.searchPool = this.generateRoutes(this.routes)
+  },
+  methods: {
+    click() {
+      this.show = !this.show
+      if (this.show) {
+        this.$refs.headerSearchSelect && this.$refs.headerSearchSelect.focus()
+      }
+    },
+    close() {
+      this.$refs.headerSearchSelect && this.$refs.headerSearchSelect.blur()
+      this.options = []
+      this.show = false
+    },
+    change(val) {
+      const path = val.path;
+      if(this.ishttp(val.path)) {
+        // http(s):// 路径新窗口打开
+        const pindex = path.indexOf("http");
+        window.open(path.substr(pindex, path.length), "_blank");
+      } else {
+        this.$router.push(val.path)
+      }
+      this.search = ''
+      this.options = []
+      this.$nextTick(() => {
+        this.show = false
+      })
+    },
+    initFuse(list) {
+      this.fuse = new Fuse(list, {
+        shouldSort: true,
+        threshold: 0.4,
+        location: 0,
+        distance: 100,
+        maxPatternLength: 32,
+        minMatchCharLength: 1,
+        keys: [{
+          name: 'title',
+          weight: 0.7
+        }, {
+          name: 'path',
+          weight: 0.3
+        }]
+      })
+    },
+    // Filter out the routes that can be displayed in the sidebar
+    // And generate the internationalized title
+    generateRoutes(routes, basePath = '/', prefixTitle = []) {
+      let res = []
+
+      for (const router of routes) {
+        // skip hidden router
+        if (router.hidden) { continue }
+
+        const data = {
+          path: !this.ishttp(router.path) ? path.resolve(basePath, router.path) : router.path,
+          title: [...prefixTitle]
+        }
+
+        if (router.meta && router.meta.title) {
+          data.title = [...data.title, router.meta.title]
+
+          if (router.redirect !== 'noRedirect') {
+            // only push the routes with title
+            // special case: need to exclude parent router without redirect
+            res.push(data)
+          }
+        }
+
+        // recursive child routes
+        if (router.children) {
+          const tempRoutes = this.generateRoutes(router.children, data.path, data.title)
+          if (tempRoutes.length >= 1) {
+            res = [...res, ...tempRoutes]
+          }
+        }
+      }
+      return res
+    },
+    querySearch(query) {
+      if (query !== '') {
+        this.options = this.fuse.search(query)
+      } else {
+        this.options = []
+      }
+    },
+    ishttp(url) {
+      return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.header-search {
+  font-size: 0 !important;
+
+  .search-icon {
+    cursor: pointer;
+    font-size: 18px;
+    vertical-align: middle;
+  }
+
+  .header-search-select {
+    font-size: 18px;
+    transition: width 0.2s;
+    width: 0;
+    overflow: hidden;
+    background: transparent;
+    border-radius: 0;
+    display: inline-block;
+    vertical-align: middle;
+
+    ::v-deep .el-input__inner {
+      border-radius: 0;
+      border: 0;
+      padding-left: 0;
+      padding-right: 0;
+      box-shadow: none !important;
+      border-bottom: 1px solid #d9d9d9;
+      vertical-align: middle;
+    }
+  }
+
+  &.show {
+    .header-search-select {
+      width: 210px;
+      margin-left: 10px;
+    }
+  }
+}
+</style>

+ 68 - 0
src/components/IconSelect/index.vue

@@ -0,0 +1,68 @@
+<!-- @author zhengjie -->
+<template>
+  <div class="icon-body">
+    <el-input v-model="name" style="position: relative;" clearable placeholder="请输入图标名称" @clear="filterIcons" @input.native="filterIcons">
+      <i slot="suffix" class="el-icon-search el-input__icon" />
+    </el-input>
+    <div class="icon-list">
+      <div v-for="(item, index) in iconList" :key="index" @click="selectedIcon(item)">
+        <svg-icon :icon-class="item" style="height: 30px;width: 16px;" />
+        <span>{{ item }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import icons from './requireIcons'
+export default {
+  name: 'IconSelect',
+  data() {
+    return {
+      name: '',
+      iconList: icons
+    }
+  },
+  methods: {
+    filterIcons() {
+      this.iconList = icons
+      if (this.name) {
+        this.iconList = this.iconList.filter(item => item.includes(this.name))
+      }
+    },
+    selectedIcon(name) {
+      this.$emit('selected', name)
+      document.body.click()
+    },
+    reset() {
+      this.name = ''
+      this.iconList = icons
+    }
+  }
+}
+</script>
+
+<style rel="stylesheet/scss" lang="scss" scoped>
+  .icon-body {
+    width: 100%;
+    padding: 10px;
+    .icon-list {
+      height: 200px;
+      overflow-y: scroll;
+      div {
+        height: 30px;
+        line-height: 30px;
+        margin-bottom: -5px;
+        cursor: pointer;
+        width: 33%;
+        float: left;
+      }
+      span {
+        display: inline-block;
+        vertical-align: -0.15em;
+        fill: currentColor;
+        overflow: hidden;
+      }
+    }
+  }
+</style>

+ 11 - 0
src/components/IconSelect/requireIcons.js

@@ -0,0 +1,11 @@
+
+const req = require.context('../../assets/icons/svg', false, /\.svg$/)
+const requireAll = requireContext => requireContext.keys()
+
+const re = /\.\/(.*)\.svg/
+
+const icons = requireAll(req).map(i => {
+  return i.match(re)[1]
+})
+
+export default icons

+ 82 - 0
src/components/ImagePreview/index.vue

@@ -0,0 +1,82 @@
+<template>
+  <el-image
+    :src="`${realSrc}`"
+    fit="cover"
+    :style="`width:${realWidth};height:${realHeight};`"
+    :preview-src-list="realSrcList"
+  >
+    <div slot="error" class="image-slot">
+      <i class="el-icon-picture-outline"></i>
+    </div>
+  </el-image>
+</template>
+
+<script>
+export default {
+  name: "ImagePreview",
+  props: {
+    src: {
+      type: String,
+      default: ""
+    },
+    width: {
+      type: [Number, String],
+      default: ""
+    },
+    height: {
+      type: [Number, String],
+      default: ""
+    }
+  },
+  computed: {
+    realSrc() {
+      if (!this.src) {
+        return;
+      }
+      let real_src = this.src.split(",")[0];
+      return real_src;
+    },
+    realSrcList() {
+      if (!this.src) {
+        return;
+      }
+      let real_src_list = this.src.split(",");
+      let srcList = [];
+      real_src_list.forEach(item => {
+        return srcList.push(item);
+      });
+      return srcList;
+    },
+    realWidth() {
+      return typeof this.width == "string" ? this.width : `${this.width}px`;
+    },
+    realHeight() {
+      return typeof this.height == "string" ? this.height : `${this.height}px`;
+    }
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.el-image {
+  border-radius: 5px;
+  background-color: #ebeef5;
+  box-shadow: 0 0 5px 1px #ccc;
+  ::v-deep .el-image__inner {
+    transition: all 0.3s;
+    cursor: pointer;
+    &:hover {
+      transform: scale(1.2);
+    }
+  }
+  ::v-deep .image-slot {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 100%;
+    height: 100%;
+    color: #909399;
+    font-size: 30px;
+  }
+}
+</style>

+ 221 - 0
src/components/ImageUpload/index.vue

@@ -0,0 +1,221 @@
+<template>
+  <div class="component-upload-image">
+    <el-upload
+      multiple
+      :action="uploadImgUrl"
+      list-type="picture-card"
+      :on-success="handleUploadSuccess"
+      :before-upload="handleBeforeUpload"
+      :limit="limit"
+      :on-error="handleUploadError"
+      :on-exceed="handleExceed"
+      ref="imageUpload"
+      :on-remove="handleDelete"
+      :show-file-list="true"
+      :headers="headers"
+      :file-list="fileList"
+      :on-preview="handlePictureCardPreview"
+      :class="{hide: this.fileList.length >= this.limit}"
+    >
+      <i class="el-icon-plus"></i>
+    </el-upload>
+    
+    <!-- 上传提示 -->
+    <div class="el-upload__tip" slot="tip" v-if="showTip">
+      请上传
+      <template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
+      <template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
+      的文件
+    </div>
+
+    <el-dialog
+      :visible.sync="dialogVisible"
+      title="预览"
+      width="800"
+      append-to-body
+    >
+      <img
+        :src="dialogImageUrl"
+        style="display: block; max-width: 100%; margin: 0 auto"
+      />
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getToken } from "@/utils/auth";
+
+export default {
+  props: {
+    value: [String, Object, Array],
+    // 图片数量限制
+    limit: {
+      type: Number,
+      default: 5,
+    },
+    // 大小限制(MB)
+    fileSize: {
+       type: Number,
+      default: 5,
+    },
+    // 文件类型, 例如['png', 'jpg', 'jpeg']
+    fileType: {
+      type: Array,
+      default: () => ["png", "jpg", "jpeg"],
+    },
+    // 是否显示提示
+    isShowTip: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data() {
+    return {
+      number: 0,
+      uploadList: [],
+      dialogImageUrl: "",
+      dialogVisible: false,
+      hideUpload: false,
+      uploadImgUrl: process.env.VUE_APP_BASE_API + "/file/upload", // 上传的图片服务器地址
+      headers: {
+        Authorization: "Bearer " + getToken(),
+      },
+      fileList: []
+    };
+  },
+  watch: {
+    value: {
+      handler(val) {
+        if (val) {
+          // 首先将值转为数组
+          const list = Array.isArray(val) ? val : this.value.split(',');
+          // 然后将数组转为对象数组
+          this.fileList = list.map(item => {
+            if (typeof item === "string") {
+              item = { name: item, url: item };
+            }
+            return item;
+          });
+        } else {
+          this.fileList = [];
+          return [];
+        }
+      },
+      deep: true,
+      immediate: true
+    }
+  },
+  computed: {
+    // 是否显示提示
+    showTip() {
+      return this.isShowTip && (this.fileType || this.fileSize);
+    },
+  },
+  methods: {
+    // 上传前loading加载
+    handleBeforeUpload(file) {
+      let isImg = false;
+      if (this.fileType.length) {
+        let fileExtension = "";
+        if (file.name.lastIndexOf(".") > -1) {
+          fileExtension = file.name.slice(file.name.lastIndexOf(".") + 1);
+        }
+        isImg = this.fileType.some(type => {
+          if (file.type.indexOf(type) > -1) return true;
+          if (fileExtension && fileExtension.indexOf(type) > -1) return true;
+          return false;
+        });
+      } else {
+        isImg = file.type.indexOf("image") > -1;
+      }
+
+      if (!isImg) {
+        this.$modal.msgError(`文件格式不正确, 请上传${this.fileType.join("/")}图片格式文件!`);
+        return false;
+      }
+      if (this.fileSize) {
+        const isLt = file.size / 1024 / 1024 < this.fileSize;
+        if (!isLt) {
+          this.$modal.msgError(`上传头像图片大小不能超过 ${this.fileSize} MB!`);
+          return false;
+        }
+      }
+      this.$modal.loading("正在上传图片,请稍候...");
+      this.number++;
+    },
+    // 文件个数超出
+    handleExceed() {
+      this.$modal.msgError(`上传文件数量不能超过 ${this.limit} 个!`);
+    },
+    // 上传成功回调
+    handleUploadSuccess(res, file) {
+      if (res.code === 200) {
+        this.uploadList.push({ name: res.data.url, url: res.data.url });
+        this.uploadedSuccessfully();
+      } else {
+        this.number--;
+        this.$modal.closeLoading();
+        this.$modal.msgError(res.msg);
+        this.$refs.imageUpload.handleRemove(file);
+        this.uploadedSuccessfully();
+      }
+    },
+    // 删除图片
+    handleDelete(file) {
+      const findex = this.fileList.map(f => f.name).indexOf(file.name);
+      if (findex > -1) {
+        this.fileList.splice(findex, 1);
+        this.$emit("input", this.listToString(this.fileList));
+      }
+    },
+    // 上传失败
+    handleUploadError() {
+      this.$modal.msgError("上传图片失败,请重试");
+      this.$modal.closeLoading();
+    },
+    // 上传结束处理
+    uploadedSuccessfully() {
+      if (this.number > 0 && this.uploadList.length === this.number) {
+        this.fileList = this.fileList.concat(this.uploadList);
+        this.uploadList = [];
+        this.number = 0;
+        this.$emit("input", this.listToString(this.fileList));
+        this.$modal.closeLoading();
+      }
+    },
+    // 预览
+    handlePictureCardPreview(file) {
+      this.dialogImageUrl = file.url;
+      this.dialogVisible = true;
+    },
+    // 对象转成指定字符串分隔
+    listToString(list, separator) {
+      let strs = "";
+      separator = separator || ",";
+      for (let i in list) {
+        if (list[i].url) {
+          strs += list[i].url.replace(this.baseUrl, "") + separator;
+        }
+      }
+      return strs != '' ? strs.substr(0, strs.length - 1) : '';
+    }
+  }
+};
+</script>
+<style scoped lang="scss">
+// .el-upload--picture-card 控制加号部分
+::v-deep.hide .el-upload--picture-card {
+    display: none;
+}
+// 去掉动画效果
+::v-deep .el-list-enter-active,
+::v-deep .el-list-leave-active {
+    transition: all 0s;
+}
+
+::v-deep .el-list-enter, .el-list-leave-active {
+    opacity: 0;
+    transform: translateY(0);
+}
+</style>
+

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

@@ -0,0 +1,114 @@
+<template>
+  <div :class="{'hidden':hidden}" class="pagination-container">
+    <el-pagination
+      :background="background"
+      :current-page.sync="currentPage"
+      :page-size.sync="pageSize"
+      :layout="layout"
+      :page-sizes="pageSizes"
+      :pager-count="pagerCount"
+      :total="total"
+      v-bind="$attrs"
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+    />
+  </div>
+</template>
+
+<script>
+import { scrollTo } from '@/utils/scroll-to'
+
+export default {
+  name: 'Pagination',
+  props: {
+    total: {
+      required: true,
+      type: Number
+    },
+    page: {
+      type: Number,
+      default: 1
+    },
+    limit: {
+      type: Number,
+      default: 20
+    },
+    pageSizes: {
+      type: Array,
+      default() {
+        return [10, 20, 30, 50]
+      }
+    },
+    // 移动端页码按钮的数量端默认值5
+    pagerCount: {
+      type: Number,
+      default: document.body.clientWidth < 992 ? 5 : 7
+    },
+    layout: {
+      type: String,
+      default: 'total, sizes, prev, pager, next, jumper'
+    },
+    background: {
+      type: Boolean,
+      default: true
+    },
+    autoScroll: {
+      type: Boolean,
+      default: true
+    },
+    hidden: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+    };
+  },
+  computed: {
+    currentPage: {
+      get() {
+        return this.page
+      },
+      set(val) {
+        this.$emit('update:page', val)
+      }
+    },
+    pageSize: {
+      get() {
+        return this.limit
+      },
+      set(val) {
+        this.$emit('update:limit', val)
+      }
+    }
+  },
+  methods: {
+    handleSizeChange(val) {
+      if (this.currentPage * val > this.total) {
+        this.currentPage = 1
+      }
+      this.$emit('pagination', { page: this.currentPage, limit: val })
+      if (this.autoScroll) {
+        scrollTo(0, 800)
+      }
+    },
+    handleCurrentChange(val) {
+      this.$emit('pagination', { page: val, limit: this.pageSize })
+      if (this.autoScroll) {
+        scrollTo(0, 800)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pagination-container {
+  background: #fff;
+  padding: 32px 16px;
+}
+.pagination-container.hidden {
+  display: none;
+}
+</style>

+ 142 - 0
src/components/PanThumb/index.vue

@@ -0,0 +1,142 @@
+<template>
+  <div :style="{zIndex:zIndex,height:height,width:width}" class="pan-item">
+    <div class="pan-info">
+      <div class="pan-info-roles-container">
+        <slot />
+      </div>
+    </div>
+    <!-- eslint-disable-next-line -->
+    <div :style="{backgroundImage: `url(${image})`}" class="pan-thumb"></div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'PanThumb',
+  props: {
+    image: {
+      type: String,
+      required: true
+    },
+    zIndex: {
+      type: Number,
+      default: 1
+    },
+    width: {
+      type: String,
+      default: '150px'
+    },
+    height: {
+      type: String,
+      default: '150px'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pan-item {
+  width: 200px;
+  height: 200px;
+  border-radius: 50%;
+  display: inline-block;
+  position: relative;
+  cursor: default;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+}
+
+.pan-info-roles-container {
+  padding: 20px;
+  text-align: center;
+}
+
+.pan-thumb {
+  width: 100%;
+  height: 100%;
+  background-position: center center;
+  background-size: cover;
+  border-radius: 50%;
+  overflow: hidden;
+  position: absolute;
+  transform-origin: 95% 40%;
+  transition: all 0.3s ease-in-out;
+}
+
+/* .pan-thumb:after {
+  content: '';
+  width: 8px;
+  height: 8px;
+  position: absolute;
+  border-radius: 50%;
+  top: 40%;
+  left: 95%;
+  margin: -4px 0 0 -4px;
+  background: radial-gradient(ellipse at center, rgba(14, 14, 14, 1) 0%, rgba(125, 126, 125, 1) 100%);
+  box-shadow: 0 0 1px rgba(255, 255, 255, 0.9);
+} */
+
+.pan-info {
+  position: absolute;
+  width: inherit;
+  height: inherit;
+  border-radius: 50%;
+  overflow: hidden;
+  box-shadow: inset 0 0 0 5px rgba(0, 0, 0, 0.05);
+}
+
+.pan-info h3 {
+  color: #fff;
+  text-transform: uppercase;
+  position: relative;
+  letter-spacing: 2px;
+  font-size: 18px;
+  margin: 0 60px;
+  padding: 22px 0 0 0;
+  height: 85px;
+  font-family: 'Open Sans', Arial, sans-serif;
+  text-shadow: 0 0 1px #fff, 0 1px 2px rgba(0, 0, 0, 0.3);
+}
+
+.pan-info p {
+  color: #fff;
+  padding: 10px 5px;
+  font-style: italic;
+  margin: 0 30px;
+  font-size: 12px;
+  border-top: 1px solid rgba(255, 255, 255, 0.5);
+}
+
+.pan-info p a {
+  display: block;
+  color: #333;
+  width: 80px;
+  height: 80px;
+  background: rgba(255, 255, 255, 0.3);
+  border-radius: 50%;
+  color: #fff;
+  font-style: normal;
+  font-weight: 700;
+  text-transform: uppercase;
+  font-size: 9px;
+  letter-spacing: 1px;
+  padding-top: 24px;
+  margin: 7px auto 0;
+  font-family: 'Open Sans', Arial, sans-serif;
+  opacity: 0;
+  transition: transform 0.3s ease-in-out 0.2s, opacity 0.3s ease-in-out 0.2s, background 0.2s linear 0s;
+  transform: translateX(60px) rotate(90deg);
+}
+
+.pan-info p a:hover {
+  background: rgba(255, 255, 255, 0.5);
+}
+
+.pan-item:hover .pan-thumb {
+  transform: rotate(-110deg);
+}
+
+.pan-item:hover .pan-info p a {
+  opacity: 1;
+  transform: translateX(0px) rotate(0deg);
+}
+</style>

+ 112 - 0
src/components/RightPanel/index.vue

@@ -0,0 +1,112 @@
+<template>
+  <div ref="rightPanel" class="rightPanel-container">
+    <div class="rightPanel-background" />
+    <div class="rightPanel">
+      <div class="rightPanel-items">
+        <slot />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RightPanel',
+  props: {
+    clickNotClose: {
+      default: false,
+      type: Boolean
+    }
+  },
+  computed: {
+    show: {
+      get() {
+        return this.$store.state.settings.showSettings
+      },
+      set(val) {
+        this.$store.dispatch('settings/changeSetting', {
+          key: 'showSettings',
+          value: val
+        })
+      }
+    }
+  },
+  watch: {
+    show(value) {
+      if (value && !this.clickNotClose) {
+        this.addEventClick()
+      }
+    }
+  },
+  mounted() {
+    this.insertToBody()
+    this.addEventClick()
+  },
+  beforeDestroy() {
+    const elx = this.$refs.rightPanel
+    elx.remove()
+  },
+  methods: {
+    addEventClick() {
+      window.addEventListener('click', this.closeSidebar)
+    },
+    closeSidebar(evt) {
+      const parent = evt.target.closest('.el-drawer__body')
+      if (!parent) {
+        this.show = false
+        window.removeEventListener('click', this.closeSidebar)
+      }
+    },
+    insertToBody() {
+      const elx = this.$refs.rightPanel
+      const body = document.querySelector('body')
+      body.insertBefore(elx, body.firstChild)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.rightPanel-background {
+  position: fixed;
+  top: 0;
+  left: 0;
+  opacity: 0;
+  transition: opacity .3s cubic-bezier(.7, .3, .1, 1);
+  background: rgba(0, 0, 0, .2);
+  z-index: -1;
+}
+
+.rightPanel {
+  width: 100%;
+  max-width: 260px;
+  height: 100vh;
+  position: fixed;
+  top: 0;
+  right: 0;
+  box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, .05);
+  transition: all .25s cubic-bezier(.7, .3, .1, 1);
+  transform: translate(100%);
+  background: #fff;
+  z-index: 40000;
+}
+
+.handle-button {
+  width: 48px;
+  height: 48px;
+  position: absolute;
+  left: -48px;
+  text-align: center;
+  font-size: 24px;
+  border-radius: 6px 0 0 6px !important;
+  z-index: 0;
+  pointer-events: auto;
+  cursor: pointer;
+  color: #fff;
+  line-height: 48px;
+  i {
+    font-size: 24px;
+    line-height: 48px;
+  }
+}
+</style>

+ 104 - 0
src/components/RightToolbar/index.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="top-right-btn" :style="style">
+    <el-row>
+      <el-tooltip class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top" v-if="search">
+        <el-button size="mini" circle icon="el-icon-search" @click="toggleSearch()" />
+      </el-tooltip>
+      <el-tooltip class="item" effect="dark" content="刷新" placement="top">
+        <el-button size="mini" circle icon="el-icon-refresh" @click="refresh()" />
+      </el-tooltip>
+      <el-tooltip class="item" effect="dark" content="显隐列" placement="top" v-if="columns">
+        <el-button size="mini" circle icon="el-icon-menu" @click="showColumn()" />
+      </el-tooltip>
+    </el-row>
+    <el-dialog :title="title" :visible.sync="open" append-to-body>
+      <el-transfer
+        :titles="['显示', '隐藏']"
+        v-model="value"
+        :data="columns"
+        @change="dataChange"
+      ></el-transfer>
+    </el-dialog>
+  </div>
+</template>
+<script>
+export default {
+  name: "RightToolbar",
+  data() {
+    return {
+      // 显隐数据
+      value: [],
+      // 弹出层标题
+      title: "显示/隐藏",
+      // 是否显示弹出层
+      open: false,
+    };
+  },
+  props: {
+    showSearch: {
+      type: Boolean,
+      default: true,
+    },
+    columns: {
+      type: Array,
+    },
+    search: {
+      type: Boolean,
+      default: true,
+    },
+    gutter: {
+      type: Number,
+      default: 10,
+    },
+  },
+  computed: {
+    style() {
+      const ret = {};
+      if (this.gutter) {
+        ret.marginRight = `${this.gutter / 2}px`;
+      }
+      return ret;
+    }
+  },
+  created() {
+    // 显隐列初始默认隐藏列
+    for (let item in this.columns) {
+      if (this.columns[item].visible === false) {
+        this.value.push(parseInt(item));
+      }
+    }
+  },
+  methods: {
+    // 搜索
+    toggleSearch() {
+      this.$emit("update:showSearch", !this.showSearch);
+    },
+    // 刷新
+    refresh() {
+      this.$emit("queryTable");
+    },
+    // 右侧列表元素变化
+    dataChange(data) {
+      for (let item in this.columns) {
+        const key = this.columns[item].key;
+        this.columns[item].visible = !data.includes(key);
+      }
+    },
+    // 打开显隐列dialog
+    showColumn() {
+      this.open = true;
+    },
+  },
+};
+</script>
+<style lang="scss" scoped>
+::v-deep .el-transfer__button {
+  border-radius: 50%;
+  padding: 12px;
+  display: block;
+  margin-left: 0px;
+}
+::v-deep .el-transfer__button:first-child {
+  margin-bottom: 10px;
+}
+</style>

+ 21 - 0
src/components/RuoYi/Doc/index.vue

@@ -0,0 +1,21 @@
+<template>
+  <div>
+    <svg-icon icon-class="question" @click="goto" />
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuoYiDoc',
+  data() {
+    return {
+      url: 'http://doc.ruoyi.vip/ruoyi-cloud'
+    }
+  },
+  methods: {
+    goto() {
+      window.open(this.url)
+    }
+  }
+}
+</script>

+ 21 - 0
src/components/RuoYi/Git/index.vue

@@ -0,0 +1,21 @@
+<template>
+  <div>
+    <svg-icon icon-class="github" @click="goto" />
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuoYiGit',
+  data() {
+    return {
+      url: 'https://gitee.com/y_project/RuoYi-Cloud'
+    }
+  },
+  methods: {
+    goto() {
+      window.open(this.url)
+    }
+  }
+}
+</script>

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

@@ -0,0 +1,57 @@
+<template>
+  <div>
+    <svg-icon :icon-class="isFullscreen?'exit-fullscreen':'fullscreen'" @click="click" />
+  </div>
+</template>
+
+<script>
+import screenfull from 'screenfull'
+
+export default {
+  name: 'Screenfull',
+  data() {
+    return {
+      isFullscreen: false
+    }
+  },
+  mounted() {
+    this.init()
+  },
+  beforeDestroy() {
+    this.destroy()
+  },
+  methods: {
+    click() {
+      if (!screenfull.isEnabled) {
+        this.$message({ message: '你的浏览器不支持全屏', type: 'warning' })
+        return false
+      }
+      screenfull.toggle()
+    },
+    change() {
+      this.isFullscreen = screenfull.isFullscreen
+    },
+    init() {
+      if (screenfull.isEnabled) {
+        screenfull.on('change', this.change)
+      }
+    },
+    destroy() {
+      if (screenfull.isEnabled) {
+        screenfull.off('change', this.change)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.screenfull-svg {
+  display: inline-block;
+  cursor: pointer;
+  fill: #5a5e66;;
+  width: 20px;
+  height: 20px;
+  vertical-align: 10px;
+}
+</style>

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

@@ -0,0 +1,56 @@
+<template>
+  <el-dropdown trigger="click" @command="handleSetSize">
+    <div>
+      <svg-icon class-name="size-icon" icon-class="size" />
+    </div>
+    <el-dropdown-menu slot="dropdown">
+      <el-dropdown-item v-for="item of sizeOptions" :key="item.value" :disabled="size===item.value" :command="item.value">
+        {{ item.label }}
+      </el-dropdown-item>
+    </el-dropdown-menu>
+  </el-dropdown>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      sizeOptions: [
+        { label: 'Default', value: 'default' },
+        { label: 'Medium', value: 'medium' },
+        { label: 'Small', value: 'small' },
+        { label: 'Mini', value: 'mini' }
+      ]
+    }
+  },
+  computed: {
+    size() {
+      return this.$store.getters.size
+    }
+  },
+  methods: {
+    handleSetSize(size) {
+      this.$ELEMENT.size = size
+      this.$store.dispatch('app/setSize', size)
+      this.refreshView()
+      this.$message({
+        message: 'Switch Size Success',
+        type: 'success'
+      })
+    },
+    refreshView() {
+      // In order to make the cached page re-rendered
+      this.$store.dispatch('tagsView/delAllCachedViews', this.$route)
+
+      const { fullPath } = this.$route
+
+      this.$nextTick(() => {
+        this.$router.replace({
+          path: '/redirect' + fullPath
+        })
+      })
+    }
+  }
+
+}
+</script>

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

@@ -0,0 +1,61 @@
+<template>
+  <div v-if="isExternal" :style="styleExternalIcon" class="svg-external-icon svg-icon" v-on="$listeners" />
+  <svg v-else :class="svgClass" aria-hidden="true" v-on="$listeners">
+    <use :xlink:href="iconName" />
+  </svg>
+</template>
+
+<script>
+import { isExternal } from '@/utils/validate'
+
+export default {
+  name: 'SvgIcon',
+  props: {
+    iconClass: {
+      type: String,
+      required: true
+    },
+    className: {
+      type: String,
+      default: ''
+    }
+  },
+  computed: {
+    isExternal() {
+      return isExternal(this.iconClass)
+    },
+    iconName() {
+      return `#icon-${this.iconClass}`
+    },
+    svgClass() {
+      if (this.className) {
+        return 'svg-icon ' + this.className
+      } else {
+        return 'svg-icon'
+      }
+    },
+    styleExternalIcon() {
+      return {
+        mask: `url(${this.iconClass}) no-repeat 50% 50%`,
+        '-webkit-mask': `url(${this.iconClass}) no-repeat 50% 50%`
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.svg-icon {
+  width: 1em;
+  height: 1em;
+  vertical-align: -0.15em;
+  fill: currentColor;
+  overflow: hidden;
+}
+
+.svg-external-icon {
+  background-color: currentColor;
+  mask-size: cover!important;
+  display: inline-block;
+}
+</style>

+ 173 - 0
src/components/ThemePicker/index.vue

@@ -0,0 +1,173 @@
+<template>
+  <el-color-picker
+    v-model="theme"
+    :predefine="['#409EFF', '#1890ff', '#304156','#212121','#11a983', '#13c2c2', '#6959CD', '#f5222d', ]"
+    class="theme-picker"
+    popper-class="theme-picker-dropdown"
+  />
+</template>
+
+<script>
+const version = require('element-ui/package.json').version // element-ui version from node_modules
+const ORIGINAL_THEME = '#409EFF' // default color
+
+export default {
+  data() {
+    return {
+      chalk: '', // content of theme-chalk css
+      theme: ''
+    }
+  },
+  computed: {
+    defaultTheme() {
+      return this.$store.state.settings.theme
+    }
+  },
+  watch: {
+    defaultTheme: {
+      handler: function(val, oldVal) {
+        this.theme = val
+      },
+      immediate: true
+    },
+    async theme(val) {
+      await this.setTheme(val)
+    }
+  },
+  created() {
+    if(this.defaultTheme !== ORIGINAL_THEME) {
+      this.setTheme(this.defaultTheme)
+    }
+  },
+
+  methods: {
+    async setTheme(val) {
+      const oldVal = this.chalk ? this.theme : ORIGINAL_THEME
+      if (typeof val !== 'string') return
+      const themeCluster = this.getThemeCluster(val.replace('#', ''))
+      const originalCluster = this.getThemeCluster(oldVal.replace('#', ''))
+
+      const getHandler = (variable, id) => {
+        return () => {
+          const originalCluster = this.getThemeCluster(ORIGINAL_THEME.replace('#', ''))
+          const newStyle = this.updateStyle(this[variable], originalCluster, themeCluster)
+
+          let styleTag = document.getElementById(id)
+          if (!styleTag) {
+            styleTag = document.createElement('style')
+            styleTag.setAttribute('id', id)
+            document.head.appendChild(styleTag)
+          }
+          styleTag.innerText = newStyle
+        }
+      }
+
+      if (!this.chalk) {
+        const url = `https://unpkg.com/element-ui@${version}/lib/theme-chalk/index.css`
+        await this.getCSSString(url, 'chalk')
+      }
+
+      const chalkHandler = getHandler('chalk', 'chalk-style')
+
+      chalkHandler()
+
+      const styles = [].slice.call(document.querySelectorAll('style'))
+        .filter(style => {
+          const text = style.innerText
+          return new RegExp(oldVal, 'i').test(text) && !/Chalk Variables/.test(text)
+        })
+      styles.forEach(style => {
+        const { innerText } = style
+        if (typeof innerText !== 'string') return
+        style.innerText = this.updateStyle(innerText, originalCluster, themeCluster)
+      })
+
+      this.$emit('change', val)
+    },
+
+    updateStyle(style, oldCluster, newCluster) {
+      let newStyle = style
+      oldCluster.forEach((color, index) => {
+        newStyle = newStyle.replace(new RegExp(color, 'ig'), newCluster[index])
+      })
+      return newStyle
+    },
+
+    getCSSString(url, variable) {
+      return new Promise(resolve => {
+        const xhr = new XMLHttpRequest()
+        xhr.onreadystatechange = () => {
+          if (xhr.readyState === 4 && xhr.status === 200) {
+            this[variable] = xhr.responseText.replace(/@font-face{[^}]+}/, '')
+            resolve()
+          }
+        }
+        xhr.open('GET', url)
+        xhr.send()
+      })
+    },
+
+    getThemeCluster(theme) {
+      const tintColor = (color, tint) => {
+        let red = parseInt(color.slice(0, 2), 16)
+        let green = parseInt(color.slice(2, 4), 16)
+        let blue = parseInt(color.slice(4, 6), 16)
+
+        if (tint === 0) { // when primary color is in its rgb space
+          return [red, green, blue].join(',')
+        } else {
+          red += Math.round(tint * (255 - red))
+          green += Math.round(tint * (255 - green))
+          blue += Math.round(tint * (255 - blue))
+
+          red = red.toString(16)
+          green = green.toString(16)
+          blue = blue.toString(16)
+
+          return `#${red}${green}${blue}`
+        }
+      }
+
+      const shadeColor = (color, shade) => {
+        let red = parseInt(color.slice(0, 2), 16)
+        let green = parseInt(color.slice(2, 4), 16)
+        let blue = parseInt(color.slice(4, 6), 16)
+
+        red = Math.round((1 - shade) * red)
+        green = Math.round((1 - shade) * green)
+        blue = Math.round((1 - shade) * blue)
+
+        red = red.toString(16)
+        green = green.toString(16)
+        blue = blue.toString(16)
+
+        return `#${red}${green}${blue}`
+      }
+
+      const clusters = [theme]
+      for (let i = 0; i <= 9; i++) {
+        clusters.push(tintColor(theme, Number((i / 10).toFixed(2))))
+      }
+      clusters.push(shadeColor(theme, 0.1))
+      return clusters
+    }
+  }
+}
+</script>
+
+<style>
+.theme-message,
+.theme-picker-dropdown {
+  z-index: 99999 !important;
+}
+
+.theme-picker .el-color-picker__trigger {
+  height: 26px !important;
+  width: 26px !important;
+  padding: 2px;
+}
+
+.theme-picker-dropdown .el-color-dropdown__link-btn {
+  display: none;
+}
+</style>

+ 181 - 0
src/components/TopNav/index.vue

@@ -0,0 +1,181 @@
+<template>
+  <el-menu
+    :default-active="activeMenu"
+    mode="horizontal"
+    @select="handleSelect"
+  >
+    <template v-for="(item, index) in topMenus">
+      <el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber"
+        ><svg-icon :icon-class="item.meta.icon" />
+        {{ item.meta.title }}</el-menu-item
+      >
+    </template>
+
+    <!-- 顶部菜单超出数量折叠 -->
+    <el-submenu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
+      <template slot="title">更多菜单</template>
+      <template v-for="(item, index) in topMenus">
+        <el-menu-item
+          :index="item.path"
+          :key="index"
+          v-if="index >= visibleNumber"
+          ><svg-icon :icon-class="item.meta.icon" />
+          {{ item.meta.title }}</el-menu-item
+        >
+      </template>
+    </el-submenu>
+  </el-menu>
+</template>
+
+<script>
+import { constantRoutes } from "@/router";
+
+// 隐藏侧边栏路由
+const hideList = ['/index', '/user/profile'];
+
+export default {
+  data() {
+    return {
+      // 顶部栏初始数
+      visibleNumber: 5,
+      // 当前激活菜单的 index
+      currentIndex: undefined
+    };
+  },
+  computed: {
+    theme() {
+      return this.$store.state.settings.theme;
+    },
+    // 顶部显示菜单
+    topMenus() {
+      let topMenus = [];
+      this.routers.map((menu) => {
+        if (menu.hidden !== true) {
+          // 兼容顶部栏一级菜单内部跳转
+          if (menu.path === "/") {
+              topMenus.push(menu.children[0]);
+          } else {
+              topMenus.push(menu);
+          }
+        }
+      });
+      return topMenus;
+    },
+    // 所有的路由信息
+    routers() {
+      return this.$store.state.permission.topbarRouters;
+    },
+    // 设置子路由
+    childrenMenus() {
+      var childrenMenus = [];
+      this.routers.map((router) => {
+        for (var item in router.children) {
+          if (router.children[item].parentPath === undefined) {
+            if(router.path === "/") {
+              router.children[item].path = "/" + router.children[item].path;
+            } else {
+              if(!this.ishttp(router.children[item].path)) {
+                router.children[item].path = router.path + "/" + router.children[item].path;
+              }
+            }
+            router.children[item].parentPath = router.path;
+          }
+          childrenMenus.push(router.children[item]);
+        }
+      });
+      return constantRoutes.concat(childrenMenus);
+    },
+    // 默认激活的菜单
+    activeMenu() {
+      const path = this.$route.path;
+      let activePath = path;
+      if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
+        const tmpPath = path.substring(1, path.length);
+        activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"));
+        this.$store.dispatch('app/toggleSideBarHide', false);
+      } else if(!this.$route.children) {
+        activePath = path;
+        this.$store.dispatch('app/toggleSideBarHide', true);
+      }
+      this.activeRoutes(activePath);
+      return activePath;
+    },
+  },
+  beforeMount() {
+    window.addEventListener('resize', this.setVisibleNumber)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.setVisibleNumber)
+  },
+  mounted() {
+    this.setVisibleNumber();
+  },
+  methods: {
+    // 根据宽度计算设置显示栏数
+    setVisibleNumber() {
+      const width = document.body.getBoundingClientRect().width / 3;
+      this.visibleNumber = parseInt(width / 85);
+    },
+    // 菜单选择事件
+    handleSelect(key, keyPath) {
+      this.currentIndex = key;
+      const route = this.routers.find(item => item.path === key);
+      if (this.ishttp(key)) {
+        // http(s):// 路径新窗口打开
+        window.open(key, "_blank");
+      } else if (!route || !route.children) {
+        // 没有子路由路径内部打开
+        this.$router.push({ path: key });
+        this.$store.dispatch('app/toggleSideBarHide', true);
+      } else {
+        // 显示左侧联动菜单
+        this.activeRoutes(key);
+        this.$store.dispatch('app/toggleSideBarHide', false);
+      }
+    },
+    // 当前激活的路由
+    activeRoutes(key) {
+      var routes = [];
+      if (this.childrenMenus && this.childrenMenus.length > 0) {
+        this.childrenMenus.map((item) => {
+          if (key == item.parentPath || (key == "index" && "" == item.path)) {
+            routes.push(item);
+          }
+        });
+      }
+      if(routes.length > 0) {
+        this.$store.commit("SET_SIDEBAR_ROUTERS", routes);
+      }
+    },
+    ishttp(url) {
+      return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1
+    }
+  },
+};
+</script>
+
+<style lang="scss">
+.topmenu-container.el-menu--horizontal > .el-menu-item {
+  float: left;
+  height: 50px !important;
+  line-height: 50px !important;
+  color: #999093 !important;
+  padding: 0 5px !important;
+  margin: 0 10px !important;
+}
+
+.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-submenu.is-active .el-submenu__title {
+  border-bottom: 2px solid #{'var(--theme)'} !important;
+  color: #303133;
+}
+
+/* submenu item */
+.topmenu-container.el-menu--horizontal > .el-submenu .el-submenu__title {
+  float: left;
+  height: 50px !important;
+  line-height: 50px !important;
+  color: #999093 !important;
+  padding: 0 5px !important;
+  margin: 0 10px !important;
+}
+</style>

+ 36 - 0
src/components/iFrame/index.vue

@@ -0,0 +1,36 @@
+<template>
+  <div v-loading="loading" :style="'height:' + height">
+    <iframe
+      :src="src"
+      frameborder="no"
+      style="width: 100%; height: 100%"
+      scrolling="auto"
+    />
+  </div>
+</template>
+<script>
+export default {
+  props: {
+    src: {
+      type: String,
+      required: true
+    },
+  },
+  data() {
+    return {
+      height: document.documentElement.clientHeight - 94.5 + "px;",
+      loading: true,
+      url: this.src
+    };
+  },
+  mounted: function () {
+    setTimeout(() => {
+      this.loading = false;
+    }, 300);
+    const that = this;
+    window.onresize = function temp() {
+      that.height = document.documentElement.clientHeight - 94.5 + "px;";
+    };
+  }
+};
+</script>

+ 61 - 0
src/layout/components/AppMain.vue

@@ -0,0 +1,61 @@
+<template>
+  <section class="app-main">
+    <transition name="fade-transform" mode="out-in">
+      <keep-alive :include="cachedViews">
+        <router-view v-if="!$route.meta.link" :key="key" />
+      </keep-alive>
+    </transition>
+    <iframe-toggle />
+  </section>
+</template>
+
+<script>
+import iframeToggle from "./IframeToggle/index"
+
+export default {
+  name: 'AppMain',
+  components: { iframeToggle },
+  computed: {
+    cachedViews() {
+      return this.$store.state.tagsView.cachedViews
+    },
+    key() {
+      return this.$route.path
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-main {
+  /* 50= navbar  50  */
+  min-height: calc(100vh - 50px);
+  width: 100%;
+  position: relative;
+  overflow: hidden;
+}
+
+.fixed-header + .app-main {
+  padding-top: 50px;
+}
+
+.hasTagsView {
+  .app-main {
+    /* 84 = navbar + tags-view = 50 + 34 */
+    min-height: calc(100vh - 84px);
+  }
+
+  .fixed-header + .app-main {
+    padding-top: 84px;
+  }
+}
+</style>
+
+<style lang="scss">
+// fix css style bug in open el-dialog
+.el-popup-parent--hidden {
+  .fixed-header {
+    padding-right: 17px;
+  }
+}
+</style>

+ 24 - 0
src/layout/components/IframeToggle/index.vue

@@ -0,0 +1,24 @@
+<template>
+  <transition-group name="fade-transform" mode="out-in">
+    <inner-link
+      v-for="(item, index) in iframeViews"
+      :key="item.path"
+      :iframeId="'iframe' + index"
+      v-show="$route.path === item.path"
+      :src="item.meta.link"
+    ></inner-link>
+  </transition-group>
+</template>
+
+<script>
+import InnerLink from "../InnerLink/index"
+
+export default {
+  components: { InnerLink },
+  computed: {
+    iframeViews() {
+      return this.$store.state.tagsView.iframeViews
+    }
+  }
+}
+</script>

+ 47 - 0
src/layout/components/InnerLink/index.vue

@@ -0,0 +1,47 @@
+<template>
+  <div :style="'height:' + height" v-loading="loading" element-loading-text="正在加载页面,请稍候!">
+    <iframe
+      :id="iframeId"
+      style="width: 100%; height: 100%"
+      :src="src"
+      frameborder="no"
+    ></iframe>
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    src: {
+      type: String,
+      default: "/"
+    },
+    iframeId: {
+      type: String
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      height: document.documentElement.clientHeight - 94.5 + "px;"
+    };
+  },
+  mounted() {
+    var _this = this;
+    const iframeId = ("#" + this.iframeId).replace(/\//g, "\\/");
+    const iframe = document.querySelector(iframeId);
+    // iframe页面loading控制
+    if (iframe.attachEvent) {
+      this.loading = true;
+      iframe.attachEvent("onload", function () {
+        _this.loading = false;
+      });
+    } else {
+      this.loading = true;
+      iframe.onload = function () {
+        _this.loading = false;
+      };
+    }
+  }
+};
+</script>

+ 209 - 0
src/layout/components/Navbar.vue

@@ -0,0 +1,209 @@
+<template>
+  <div class="navbar">
+    <hamburger
+      id="hamburger-container"
+      :is-active="sidebar.opened"
+      class="hamburger-container"
+      @toggleClick="toggleSideBar"
+    />
+
+    <breadcrumb
+      id="breadcrumb-container"
+      class="breadcrumb-container"
+      v-if="!topNav"
+    />
+    <top-nav id="topmenu-container" class="topmenu-container" v-if="topNav" />
+
+    <div class="right-menu">
+      <template v-if="device !== 'mobile'">
+        <search id="header-search" class="right-menu-item" />
+
+        <!-- <el-tooltip content="源码地址" effect="dark" placement="bottom">
+          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
+        </el-tooltip>
+
+        <el-tooltip content="文档地址" effect="dark" placement="bottom">
+          <ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
+        </el-tooltip> -->
+
+        <screenfull id="screenfull" class="right-menu-item hover-effect" />
+
+        <el-tooltip content="布局大小" effect="dark" placement="bottom">
+          <size-select id="size-select" class="right-menu-item hover-effect" />
+        </el-tooltip>
+      </template>
+
+      <el-dropdown
+        class="avatar-container right-menu-item hover-effect"
+        trigger="click"
+      >
+        <div class="avatar-wrapper">
+          <img :src="avatar" class="user-avatar" />
+          <i class="el-icon-caret-bottom" />
+        </div>
+        <el-dropdown-menu slot="dropdown">
+          <router-link to="/user/profile">
+            <el-dropdown-item>个人中心</el-dropdown-item>
+          </router-link>
+          <el-dropdown-item @click.native="setting = true">
+            <span>布局设置</span>
+          </el-dropdown-item>
+          <el-dropdown-item divided @click.native="logout">
+            <span>退出登录</span>
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+import Breadcrumb from "@/components/Breadcrumb";
+import TopNav from "@/components/TopNav";
+import Hamburger from "@/components/Hamburger";
+import Screenfull from "@/components/Screenfull";
+import SizeSelect from "@/components/SizeSelect";
+import Search from "@/components/HeaderSearch";
+import RuoYiGit from "@/components/RuoYi/Git";
+import RuoYiDoc from "@/components/RuoYi/Doc";
+
+export default {
+  components: {
+    Breadcrumb,
+    TopNav,
+    Hamburger,
+    Screenfull,
+    SizeSelect,
+    Search,
+    RuoYiGit,
+    RuoYiDoc,
+  },
+  computed: {
+    ...mapGetters(["sidebar", "avatar", "device"]),
+    setting: {
+      get() {
+        return this.$store.state.settings.showSettings;
+      },
+      set(val) {
+        this.$store.dispatch("settings/changeSetting", {
+          key: "showSettings",
+          value: val,
+        });
+      },
+    },
+    topNav: {
+      get() {
+        return this.$store.state.settings.topNav;
+      },
+    },
+  },
+  methods: {
+    toggleSideBar() {
+      this.$store.dispatch("app/toggleSideBar");
+    },
+    async logout() {
+      this.$confirm("确定注销并退出系统吗?", "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning",
+      })
+        .then(() => {
+          this.$store.dispatch("LogOut").then(() => {
+            location.href = "/index";
+          });
+        })
+        .catch(() => {});
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.navbar {
+  height: 50px;
+  overflow: hidden;
+  position: relative;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
+
+  .hamburger-container {
+    line-height: 46px;
+    height: 100%;
+    float: left;
+    cursor: pointer;
+    transition: background 0.3s;
+    -webkit-tap-highlight-color: transparent;
+
+    &:hover {
+      background: rgba(0, 0, 0, 0.025);
+    }
+  }
+
+  .breadcrumb-container {
+    float: left;
+  }
+
+  .topmenu-container {
+    position: absolute;
+    left: 50px;
+  }
+
+  .errLog-container {
+    display: inline-block;
+    vertical-align: top;
+  }
+
+  .right-menu {
+    float: right;
+    height: 100%;
+    line-height: 50px;
+
+    &:focus {
+      outline: none;
+    }
+
+    .right-menu-item {
+      display: inline-block;
+      padding: 0 8px;
+      height: 100%;
+      font-size: 18px;
+      color: #5a5e66;
+      vertical-align: text-bottom;
+
+      &.hover-effect {
+        cursor: pointer;
+        transition: background 0.3s;
+
+        &:hover {
+          background: rgba(0, 0, 0, 0.025);
+        }
+      }
+    }
+
+    .avatar-container {
+      margin-right: 30px;
+
+      .avatar-wrapper {
+        margin-top: 5px;
+        position: relative;
+
+        .user-avatar {
+          cursor: pointer;
+          width: 40px;
+          height: 40px;
+          border-radius: 10px;
+        }
+
+        .el-icon-caret-bottom {
+          cursor: pointer;
+          position: absolute;
+          right: -20px;
+          top: 25px;
+          font-size: 12px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 260 - 0
src/layout/components/Settings/index.vue

@@ -0,0 +1,260 @@
+<template>
+  <el-drawer size="280px" :visible="visible" :with-header="false" :append-to-body="true" :show-close="false">
+    <div class="drawer-container">
+      <div>
+        <div class="setting-drawer-content">
+          <div class="setting-drawer-title">
+            <h3 class="drawer-title">主题风格设置</h3>
+          </div>
+          <div class="setting-drawer-block-checbox">
+            <div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
+              <img src="@/assets/images/dark.svg" alt="dark">
+              <div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
+                <i aria-label="图标: check" class="anticon anticon-check">
+                  <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
+                    <path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
+                  </svg>
+                </i>
+              </div>
+            </div>
+            <div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
+              <img src="@/assets/images/light.svg" alt="light">
+              <div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
+                <i aria-label="图标: check" class="anticon anticon-check">
+                  <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
+                    <path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
+                  </svg>
+                </i>
+              </div>
+            </div>
+          </div>
+
+          <div class="drawer-item">
+            <span>主题颜色</span>
+            <theme-picker style="float: right;height: 26px;margin: -3px 8px 0 0;" @change="themeChange" />
+          </div>
+        </div>
+
+        <el-divider/>
+
+        <h3 class="drawer-title">系统布局配置</h3>
+      
+        <div class="drawer-item">
+          <span>开启 TopNav</span>
+          <el-switch v-model="topNav" class="drawer-switch" />
+        </div>
+
+        <div class="drawer-item">
+          <span>开启 Tags-Views</span>
+          <el-switch v-model="tagsView" class="drawer-switch" />
+        </div>
+
+        <div class="drawer-item">
+          <span>固定 Header</span>
+          <el-switch v-model="fixedHeader" class="drawer-switch" />
+        </div>
+
+        <div class="drawer-item">
+          <span>显示 Logo</span>
+          <el-switch v-model="sidebarLogo" class="drawer-switch" />
+        </div>
+
+        <div class="drawer-item">
+          <span>动态标题</span>
+          <el-switch v-model="dynamicTitle" class="drawer-switch" />
+        </div>
+
+        <el-divider/>
+
+        <el-button size="small" type="primary" plain icon="el-icon-document-add" @click="saveSetting">保存配置</el-button>
+        <el-button size="small" plain icon="el-icon-refresh" @click="resetSetting">重置配置</el-button>
+      </div>
+    </div>
+  </el-drawer>
+</template>
+
+<script>
+import ThemePicker from '@/components/ThemePicker'
+
+export default {
+  components: { ThemePicker },
+  data() {
+    return {
+      theme: this.$store.state.settings.theme,
+      sideTheme: this.$store.state.settings.sideTheme
+    };
+  },
+  computed: {
+    visible: {
+      get() {
+        return this.$store.state.settings.showSettings
+      }
+    },
+    fixedHeader: {
+      get() {
+        return this.$store.state.settings.fixedHeader
+      },
+      set(val) {
+        this.$store.dispatch('settings/changeSetting', {
+          key: 'fixedHeader',
+          value: val
+        })
+      }
+    },
+    topNav: {
+      get() {
+        return this.$store.state.settings.topNav
+      },
+      set(val) {
+        this.$store.dispatch('settings/changeSetting', {
+          key: 'topNav',
+          value: val
+        })
+        if (!val) {
+          this.$store.dispatch('app/toggleSideBarHide', false);
+          this.$store.commit("SET_SIDEBAR_ROUTERS", this.$store.state.permission.defaultRoutes);
+        }
+      }
+    },
+    tagsView: {
+      get() {
+        return this.$store.state.settings.tagsView
+      },
+      set(val) {
+        this.$store.dispatch('settings/changeSetting', {
+          key: 'tagsView',
+          value: val
+        })
+      }
+    },
+    sidebarLogo: {
+      get() {
+        return this.$store.state.settings.sidebarLogo
+      },
+      set(val) {
+        this.$store.dispatch('settings/changeSetting', {
+          key: 'sidebarLogo',
+          value: val
+        })
+      }
+    },
+    dynamicTitle: {
+      get() {
+        return this.$store.state.settings.dynamicTitle
+      },
+      set(val) {
+        this.$store.dispatch('settings/changeSetting', {
+          key: 'dynamicTitle',
+          value: val
+        })
+      }
+    },
+  },
+  methods: {
+    themeChange(val) {
+      this.$store.dispatch('settings/changeSetting', {
+        key: 'theme',
+        value: val
+      })
+      this.theme = val;
+    },
+    handleTheme(val) {
+      this.$store.dispatch('settings/changeSetting', {
+        key: 'sideTheme',
+        value: val
+      })
+      this.sideTheme = val;
+    },
+    saveSetting() {
+      this.$modal.loading("正在保存到本地,请稍候...");
+      this.$cache.local.set(
+        "layout-setting",
+        `{
+            "topNav":${this.topNav},
+            "tagsView":${this.tagsView},
+            "fixedHeader":${this.fixedHeader},
+            "sidebarLogo":${this.sidebarLogo},
+            "dynamicTitle":${this.dynamicTitle},
+            "sideTheme":"${this.sideTheme}",
+            "theme":"${this.theme}"
+          }`
+      );
+      setTimeout(this.$modal.closeLoading(), 1000)
+    },
+    resetSetting() {
+      this.$modal.loading("正在清除设置缓存并刷新,请稍候...");
+      this.$cache.local.remove("layout-setting")
+      setTimeout("window.location.reload()", 1000)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+  .setting-drawer-content {
+    .setting-drawer-title {
+      margin-bottom: 12px;
+      color: rgba(0, 0, 0, .85);
+      font-size: 14px;
+      line-height: 22px;
+      font-weight: bold;
+    }
+
+    .setting-drawer-block-checbox {
+      display: flex;
+      justify-content: flex-start;
+      align-items: center;
+      margin-top: 10px;
+      margin-bottom: 20px;
+
+      .setting-drawer-block-checbox-item {
+        position: relative;
+        margin-right: 16px;
+        border-radius: 2px;
+        cursor: pointer;
+
+        img {
+          width: 48px;
+          height: 48px;
+        }
+
+        .setting-drawer-block-checbox-selectIcon {
+          position: absolute;
+          top: 0;
+          right: 0;
+          width: 100%;
+          height: 100%;
+          padding-top: 15px;
+          padding-left: 24px;
+          color: #1890ff;
+          font-weight: 700;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+
+  .drawer-container {
+    padding: 20px;
+    font-size: 14px;
+    line-height: 1.5;
+    word-wrap: break-word;
+
+    .drawer-title {
+      margin-bottom: 12px;
+      color: rgba(0, 0, 0, .85);
+      font-size: 14px;
+      line-height: 22px;
+    }
+
+    .drawer-item {
+      color: rgba(0, 0, 0, .65);
+      font-size: 14px;
+      padding: 12px 0;
+    }
+
+    .drawer-switch {
+      float: right
+    }
+  }
+</style>

+ 25 - 0
src/layout/components/Sidebar/FixiOSBug.js

@@ -0,0 +1,25 @@
+export default {
+  computed: {
+    device() {
+      return this.$store.state.app.device
+    }
+  },
+  mounted() {
+    // In order to fix the click on menu on the ios device will trigger the mouseleave bug
+    this.fixBugIniOS()
+  },
+  methods: {
+    fixBugIniOS() {
+      const $subMenu = this.$refs.subMenu
+      if ($subMenu) {
+        const handleMouseleave = $subMenu.handleMouseleave
+        $subMenu.handleMouseleave = (e) => {
+          if (this.device === 'mobile') {
+            return
+          }
+          handleMouseleave(e)
+        }
+      }
+    }
+  }
+}

+ 33 - 0
src/layout/components/Sidebar/Item.vue

@@ -0,0 +1,33 @@
+<script>
+export default {
+  name: 'MenuItem',
+  functional: true,
+  props: {
+    icon: {
+      type: String,
+      default: ''
+    },
+    title: {
+      type: String,
+      default: ''
+    }
+  },
+  render(h, context) {
+    const { icon, title } = context.props
+    const vnodes = []
+
+    if (icon) {
+      vnodes.push(<svg-icon icon-class={icon}/>)
+    }
+
+    if (title) {
+      if (title.length > 5) {
+        vnodes.push(<span slot='title' title={(title)}>{(title)}</span>)
+      } else {
+        vnodes.push(<span slot='title'>{(title)}</span>)
+      }
+    }
+    return vnodes
+  }
+}
+</script>

+ 43 - 0
src/layout/components/Sidebar/Link.vue

@@ -0,0 +1,43 @@
+<template>
+  <component :is="type" v-bind="linkProps(to)">
+    <slot />
+  </component>
+</template>
+
+<script>
+import { isExternal } from '@/utils/validate'
+
+export default {
+  props: {
+    to: {
+      type: [String, Object],
+      required: true
+    }
+  },
+  computed: {
+    isExternal() {
+      return isExternal(this.to)
+    },
+    type() {
+      if (this.isExternal) {
+        return 'a'
+      }
+      return 'router-link'
+    }
+  },
+  methods: {
+    linkProps(to) {
+      if (this.isExternal) {
+        return {
+          href: to,
+          target: '_blank',
+          rel: 'noopener'
+        }
+      }
+      return {
+        to: to
+      }
+    }
+  }
+}
+</script>

+ 128 - 0
src/layout/components/Sidebar/Logo.vue

@@ -0,0 +1,128 @@
+<template>
+  <div
+    class="sidebar-logo-container"
+    :class="{ collapse: collapse }"
+    :style="{
+      backgroundColor:
+        sideTheme === 'theme-dark'
+          ? variables.menuBackground
+          : variables.menuLightBackground,
+    }"
+  >
+    <transition name="sidebarLogoFade">
+      <router-link
+        v-if="collapse"
+        key="collapse"
+        class="sidebar-logo-link"
+        to="/"
+      >
+        <img v-if="logo" :src="logo" class="sidebar-logo" />
+        <h1
+          v-else
+          class="sidebar-title"
+          :style="{
+            color:
+              sideTheme === 'theme-dark'
+                ? variables.logoTitleColor
+                : variables.logoLightTitleColor,
+          }"
+        >
+          {{ title }}
+        </h1>
+      </router-link>
+      <router-link v-else key="expand" class="sidebar-logo-link" to="/">
+        <img v-if="logo" :src="logo" class="sidebar-logo" />
+        <h1
+          class="sidebar-title"
+          :style="{
+            color:
+              sideTheme === 'theme-dark'
+                ? variables.logoTitleColor
+                : variables.logoLightTitleColor,
+          }"
+        >
+          {{ title }}
+        </h1>
+      </router-link>
+    </transition>
+  </div>
+</template>
+
+<script>
+import logoImg from "@/assets/logo/logo.png";
+import variables from "@/assets/styles/variables.scss";
+
+export default {
+  name: "SidebarLogo",
+  props: {
+    collapse: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  computed: {
+    variables() {
+      return variables;
+    },
+    sideTheme() {
+      return this.$store.state.settings.sideTheme;
+    },
+  },
+  data() {
+    return {
+      title: "后台管理系统",
+      logo: logoImg,
+    };
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.sidebarLogoFade-enter-active {
+  transition: opacity 1.5s;
+}
+
+.sidebarLogoFade-enter,
+.sidebarLogoFade-leave-to {
+  opacity: 0;
+}
+
+.sidebar-logo-container {
+  position: relative;
+  width: 100%;
+  height: 50px;
+  line-height: 50px;
+  background: #2b2f3a;
+  text-align: center;
+  overflow: hidden;
+
+  & .sidebar-logo-link {
+    height: 100%;
+    width: 100%;
+
+    & .sidebar-logo {
+      width: 32px;
+      height: 32px;
+      vertical-align: middle;
+      margin-right: 12px;
+    }
+
+    & .sidebar-title {
+      display: inline-block;
+      margin: 0;
+      color: #fff;
+      font-weight: 600;
+      line-height: 50px;
+      font-size: 14px;
+      font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
+      vertical-align: middle;
+    }
+  }
+
+  &.collapse {
+    .sidebar-logo {
+      margin-right: 0px;
+    }
+  }
+}
+</style>

+ 100 - 0
src/layout/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,100 @@
+<template>
+  <div v-if="!item.hidden">
+    <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
+      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
+        <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
+          <item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
+        </el-menu-item>
+      </app-link>
+    </template>
+
+    <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
+      <template slot="title">
+        <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
+      </template>
+      <sidebar-item
+        v-for="child in item.children"
+        :key="child.path"
+        :is-nest="true"
+        :item="child"
+        :base-path="resolvePath(child.path)"
+        class="nest-menu"
+      />
+    </el-submenu>
+  </div>
+</template>
+
+<script>
+import path from 'path'
+import { isExternal } from '@/utils/validate'
+import Item from './Item'
+import AppLink from './Link'
+import FixiOSBug from './FixiOSBug'
+
+export default {
+  name: 'SidebarItem',
+  components: { Item, AppLink },
+  mixins: [FixiOSBug],
+  props: {
+    // route object
+    item: {
+      type: Object,
+      required: true
+    },
+    isNest: {
+      type: Boolean,
+      default: false
+    },
+    basePath: {
+      type: String,
+      default: ''
+    }
+  },
+  data() {
+    this.onlyOneChild = null
+    return {}
+  },
+  methods: {
+    hasOneShowingChild(children = [], parent) {
+      if (!children) {
+        children = [];
+      }
+      const showingChildren = children.filter(item => {
+        if (item.hidden) {
+          return false
+        } else {
+          // Temp set(will be used if only has one showing child)
+          this.onlyOneChild = item
+          return true
+        }
+      })
+
+      // When there is only one child router, the child router is displayed by default
+      if (showingChildren.length === 1) {
+        return true
+      }
+
+      // Show parent if there are no child router to display
+      if (showingChildren.length === 0) {
+        this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
+        return true
+      }
+
+      return false
+    },
+    resolvePath(routePath, routeQuery) {
+      if (isExternal(routePath)) {
+        return routePath
+      }
+      if (isExternal(this.basePath)) {
+        return this.basePath
+      }
+      if (routeQuery) {
+        let query = JSON.parse(routeQuery);
+        return { path: path.resolve(this.basePath, routePath), query: query }
+      }
+      return path.resolve(this.basePath, routePath)
+    }
+  }
+}
+</script>

+ 57 - 0
src/layout/components/Sidebar/index.vue

@@ -0,0 +1,57 @@
+<template>
+    <div :class="{'has-logo':showLogo}" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
+        <logo v-if="showLogo" :collapse="isCollapse" />
+        <el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper">
+            <el-menu
+                :default-active="activeMenu"
+                :collapse="isCollapse"
+                :background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground"
+                :text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
+                :unique-opened="true"
+                :active-text-color="settings.theme"
+                :collapse-transition="false"
+                mode="vertical"
+            >
+                <sidebar-item
+                    v-for="(route, index) in sidebarRouters"
+                    :key="route.path  + index"
+                    :item="route"
+                    :base-path="route.path"
+                />
+            </el-menu>
+        </el-scrollbar>
+    </div>
+</template>
+
+<script>
+import { mapGetters, mapState } from "vuex";
+import Logo from "./Logo";
+import SidebarItem from "./SidebarItem";
+import variables from "@/assets/styles/variables.scss";
+
+export default {
+    components: { SidebarItem, Logo },
+    computed: {
+        ...mapState(["settings"]),
+        ...mapGetters(["sidebarRouters", "sidebar"]),
+        activeMenu() {
+            const route = this.$route;
+            const { meta, path } = route;
+            // if set path, the sidebar will highlight the path you set
+            if (meta.activeMenu) {
+                return meta.activeMenu;
+            }
+            return path;
+        },
+        showLogo() {
+            return this.$store.state.settings.sidebarLogo;
+        },
+        variables() {
+            return variables;
+        },
+        isCollapse() {
+            return !this.sidebar.opened;
+        }
+    }
+};
+</script>

+ 94 - 0
src/layout/components/TagsView/ScrollPane.vue

@@ -0,0 +1,94 @@
+<template>
+  <el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
+    <slot />
+  </el-scrollbar>
+</template>
+
+<script>
+const tagAndTagSpacing = 4 // tagAndTagSpacing
+
+export default {
+  name: 'ScrollPane',
+  data() {
+    return {
+      left: 0
+    }
+  },
+  computed: {
+    scrollWrapper() {
+      return this.$refs.scrollContainer.$refs.wrap
+    }
+  },
+  mounted() {
+    this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
+  },
+  beforeDestroy() {
+    this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
+  },
+  methods: {
+    handleScroll(e) {
+      const eventDelta = e.wheelDelta || -e.deltaY * 40
+      const $scrollWrapper = this.scrollWrapper
+      $scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
+    },
+    emitScroll() {
+      this.$emit('scroll')
+    },
+    moveToTarget(currentTag) {
+      const $container = this.$refs.scrollContainer.$el
+      const $containerWidth = $container.offsetWidth
+      const $scrollWrapper = this.scrollWrapper
+      const tagList = this.$parent.$refs.tag
+
+      let firstTag = null
+      let lastTag = null
+
+      // find first tag and last tag
+      if (tagList.length > 0) {
+        firstTag = tagList[0]
+        lastTag = tagList[tagList.length - 1]
+      }
+
+      if (firstTag === currentTag) {
+        $scrollWrapper.scrollLeft = 0
+      } else if (lastTag === currentTag) {
+        $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
+      } else {
+        // find preTag and nextTag
+        const currentIndex = tagList.findIndex(item => item === currentTag)
+        const prevTag = tagList[currentIndex - 1]
+        const nextTag = tagList[currentIndex + 1]
+
+        // the tag's offsetLeft after of nextTag
+        const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
+
+        // the tag's offsetLeft before of prevTag
+        const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
+
+        if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
+          $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
+        } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
+          $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
+        }
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.scroll-container {
+  white-space: nowrap;
+  position: relative;
+  overflow: hidden;
+  width: 100%;
+  ::v-deep {
+    .el-scrollbar__bar {
+      bottom: 0px;
+    }
+    .el-scrollbar__wrap {
+      height: 49px;
+    }
+  }
+}
+</style>

+ 332 - 0
src/layout/components/TagsView/index.vue

@@ -0,0 +1,332 @@
+<template>
+  <div id="tags-view-container" class="tags-view-container">
+    <scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
+      <router-link
+        v-for="tag in visitedViews"
+        ref="tag"
+        :key="tag.path"
+        :class="isActive(tag)?'active':''"
+        :to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
+        tag="span"
+        class="tags-view-item"
+        :style="activeStyle(tag)"
+        @click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
+        @contextmenu.prevent.native="openMenu(tag,$event)"
+      >
+        {{ tag.title }}
+        <span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
+      </router-link>
+    </scroll-pane>
+    <ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
+      <li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前</li>
+      <li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他</li>
+      <li v-if="!isFirstView()" @click="closeLeftTags"><i class="el-icon-back"></i> 关闭左侧</li>
+      <li v-if="!isLastView()" @click="closeRightTags"><i class="el-icon-right"></i> 关闭右侧</li>
+      <li @click="closeAllTags(selectedTag)"><i class="el-icon-circle-close"></i> 全部关闭</li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import ScrollPane from './ScrollPane'
+import path from 'path'
+
+export default {
+  components: { ScrollPane },
+  data() {
+    return {
+      visible: false,
+      top: 0,
+      left: 0,
+      selectedTag: {},
+      affixTags: []
+    }
+  },
+  computed: {
+    visitedViews() {
+      return this.$store.state.tagsView.visitedViews
+    },
+    routes() {
+      return this.$store.state.permission.routes
+    },
+    theme() {
+      return this.$store.state.settings.theme;
+    }
+  },
+  watch: {
+    $route() {
+      this.addTags()
+      this.moveToCurrentTag()
+    },
+    visible(value) {
+      if (value) {
+        document.body.addEventListener('click', this.closeMenu)
+      } else {
+        document.body.removeEventListener('click', this.closeMenu)
+      }
+    }
+  },
+  mounted() {
+    this.initTags()
+    this.addTags()
+  },
+  methods: {
+    isActive(route) {
+      return route.path === this.$route.path
+    },
+    activeStyle(tag) {
+      if (!this.isActive(tag)) return {};
+      return {
+        "background-color": this.theme,
+        "border-color": this.theme
+      };
+    },
+    isAffix(tag) {
+      return tag.meta && tag.meta.affix
+    },
+    isFirstView() {
+      try {
+        return this.selectedTag.fullPath === this.visitedViews[1].fullPath || this.selectedTag.fullPath === '/index'
+      } catch (err) {
+        return false
+      }
+    },
+    isLastView() {
+      try {
+        return this.selectedTag.fullPath === this.visitedViews[this.visitedViews.length - 1].fullPath
+      } catch (err) {
+        return false
+      }
+    },
+    filterAffixTags(routes, basePath = '/') {
+      let tags = []
+      routes.forEach(route => {
+        if (route.meta && route.meta.affix) {
+          const tagPath = path.resolve(basePath, route.path)
+          tags.push({
+            fullPath: tagPath,
+            path: tagPath,
+            name: route.name,
+            meta: { ...route.meta }
+          })
+        }
+        if (route.children) {
+          const tempTags = this.filterAffixTags(route.children, route.path)
+          if (tempTags.length >= 1) {
+            tags = [...tags, ...tempTags]
+          }
+        }
+      })
+      return tags
+    },
+    initTags() {
+      const affixTags = this.affixTags = this.filterAffixTags(this.routes)
+      for (const tag of affixTags) {
+        // Must have tag name
+        if (tag.name) {
+          this.$store.dispatch('tagsView/addVisitedView', tag)
+        }
+      }
+    },
+    addTags() {
+      const { name } = this.$route
+      if (name) {
+        this.$store.dispatch('tagsView/addView', this.$route)
+        if (this.$route.meta.link) {
+          this.$store.dispatch('tagsView/addIframeView', this.$route)
+        }
+      }
+      return false
+    },
+    moveToCurrentTag() {
+      const tags = this.$refs.tag
+      this.$nextTick(() => {
+        for (const tag of tags) {
+          if (tag.to.path === this.$route.path) {
+            this.$refs.scrollPane.moveToTarget(tag)
+            // when query is different then update
+            if (tag.to.fullPath !== this.$route.fullPath) {
+              this.$store.dispatch('tagsView/updateVisitedView', this.$route)
+            }
+            break
+          }
+        }
+      })
+    },
+    refreshSelectedTag(view) {
+      this.$tab.refreshPage(view);
+      if (this.$route.meta.link) {
+        this.$store.dispatch('tagsView/delIframeView', this.$route)
+      }
+    },
+    closeSelectedTag(view) {
+      this.$tab.closePage(view).then(({ visitedViews }) => {
+        if (this.isActive(view)) {
+          this.toLastView(visitedViews, view)
+        }
+      })
+    },
+    closeRightTags() {
+      this.$tab.closeRightPage(this.selectedTag).then(visitedViews => {
+        if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
+          this.toLastView(visitedViews)
+        }
+      })
+    },
+    closeLeftTags() {
+      this.$tab.closeLeftPage(this.selectedTag).then(visitedViews => {
+        if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
+          this.toLastView(visitedViews)
+        }
+      })
+    },
+    closeOthersTags() {
+      this.$router.push(this.selectedTag).catch(()=>{});
+      this.$tab.closeOtherPage(this.selectedTag).then(() => {
+        this.moveToCurrentTag()
+      })
+    },
+    closeAllTags(view) {
+      this.$tab.closeAllPage().then(({ visitedViews }) => {
+        if (this.affixTags.some(tag => tag.path === this.$route.path)) {
+          return
+        }
+        this.toLastView(visitedViews, view)
+      })
+    },
+    toLastView(visitedViews, view) {
+      const latestView = visitedViews.slice(-1)[0]
+      if (latestView) {
+        this.$router.push(latestView.fullPath)
+      } else {
+        // now the default is to redirect to the home page if there is no tags-view,
+        // you can adjust it according to your needs.
+        if (view.name === 'Dashboard') {
+          // to reload home page
+          this.$router.replace({ path: '/redirect' + view.fullPath })
+        } else {
+          this.$router.push('/')
+        }
+      }
+    },
+    openMenu(tag, e) {
+      const menuMinWidth = 105
+      const offsetLeft = this.$el.getBoundingClientRect().left // container margin left
+      const offsetWidth = this.$el.offsetWidth // container width
+      const maxLeft = offsetWidth - menuMinWidth // left boundary
+      const left = e.clientX - offsetLeft + 15 // 15: margin right
+
+      if (left > maxLeft) {
+        this.left = maxLeft
+      } else {
+        this.left = left
+      }
+
+      this.top = e.clientY
+      this.visible = true
+      this.selectedTag = tag
+    },
+    closeMenu() {
+      this.visible = false
+    },
+    handleScroll() {
+      this.closeMenu()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.tags-view-container {
+  height: 34px;
+  width: 100%;
+  background: #fff;
+  border-bottom: 1px solid #d8dce5;
+  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
+  .tags-view-wrapper {
+    .tags-view-item {
+      display: inline-block;
+      position: relative;
+      cursor: pointer;
+      height: 26px;
+      line-height: 26px;
+      border: 1px solid #d8dce5;
+      color: #495060;
+      background: #fff;
+      padding: 0 8px;
+      font-size: 12px;
+      margin-left: 5px;
+      margin-top: 4px;
+      &:first-of-type {
+        margin-left: 15px;
+      }
+      &:last-of-type {
+        margin-right: 15px;
+      }
+      &.active {
+        background-color: #42b983;
+        color: #fff;
+        border-color: #42b983;
+        &::before {
+          content: '';
+          background: #fff;
+          display: inline-block;
+          width: 8px;
+          height: 8px;
+          border-radius: 50%;
+          position: relative;
+          margin-right: 2px;
+        }
+      }
+    }
+  }
+  .contextmenu {
+    margin: 0;
+    background: #fff;
+    z-index: 3000;
+    position: absolute;
+    list-style-type: none;
+    padding: 5px 0;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 400;
+    color: #333;
+    box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
+    li {
+      margin: 0;
+      padding: 7px 16px;
+      cursor: pointer;
+      &:hover {
+        background: #eee;
+      }
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+//reset element css of el-icon-close
+.tags-view-wrapper {
+  .tags-view-item {
+    .el-icon-close {
+      width: 16px;
+      height: 16px;
+      vertical-align: 2px;
+      border-radius: 50%;
+      text-align: center;
+      transition: all .3s cubic-bezier(.645, .045, .355, 1);
+      transform-origin: 100% 50%;
+      &:before {
+        transform: scale(.6);
+        display: inline-block;
+        vertical-align: -3px;
+      }
+      &:hover {
+        background-color: #b4bccc;
+        color: #fff;
+      }
+    }
+  }
+}
+</style>

+ 5 - 0
src/layout/components/index.js

@@ -0,0 +1,5 @@
+export { default as AppMain } from './AppMain'
+export { default as Navbar } from './Navbar'
+export { default as Settings } from './Settings'
+export { default as Sidebar } from './Sidebar/index.vue'
+export { default as TagsView } from './TagsView/index.vue'

+ 111 - 0
src/layout/index.vue

@@ -0,0 +1,111 @@
+<template>
+  <div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
+    <div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
+    <sidebar v-if="!sidebar.hide" class="sidebar-container" />
+    <div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">
+      <div :class="{'fixed-header':fixedHeader}">
+        <navbar />
+        <tags-view v-if="needTagsView" />
+      </div>
+      <app-main />
+      <right-panel>
+        <settings />
+      </right-panel>
+    </div>
+  </div>
+</template>
+
+<script>
+import RightPanel from '@/components/RightPanel'
+import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
+import ResizeMixin from './mixin/ResizeHandler'
+import { mapState } from 'vuex'
+import variables from '@/assets/styles/variables.scss'
+
+export default {
+  name: 'Layout',
+  components: {
+    AppMain,
+    Navbar,
+    RightPanel,
+    Settings,
+    Sidebar,
+    TagsView
+  },
+  mixins: [ResizeMixin],
+  computed: {
+    ...mapState({
+      theme: state => state.settings.theme,
+      sideTheme: state => state.settings.sideTheme,
+      sidebar: state => state.app.sidebar,
+      device: state => state.app.device,
+      needTagsView: state => state.settings.tagsView,
+      fixedHeader: state => state.settings.fixedHeader
+    }),
+    classObj() {
+      return {
+        hideSidebar: !this.sidebar.opened,
+        openSidebar: this.sidebar.opened,
+        withoutAnimation: this.sidebar.withoutAnimation,
+        mobile: this.device === 'mobile'
+      }
+    },
+    variables() {
+      return variables;
+    }
+  },
+  methods: {
+    handleClickOutside() {
+      this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+  @import "~@/assets/styles/mixin.scss";
+  @import "~@/assets/styles/variables.scss";
+
+  .app-wrapper {
+    @include clearfix;
+    position: relative;
+    height: 100%;
+    width: 100%;
+
+    &.mobile.openSidebar {
+      position: fixed;
+      top: 0;
+    }
+  }
+
+  .drawer-bg {
+    background: #000;
+    opacity: 0.3;
+    width: 100%;
+    top: 0;
+    height: 100%;
+    position: absolute;
+    z-index: 999;
+  }
+
+  .fixed-header {
+    position: fixed;
+    top: 0;
+    right: 0;
+    z-index: 9;
+    width: calc(100% - #{$base-sidebar-width});
+    transition: width 0.28s;
+  }
+
+  .hideSidebar .fixed-header {
+    width: calc(100% - 54px);
+  }
+
+  .sidebarHide .fixed-header {
+    width: 100%;
+  }
+
+  .mobile .fixed-header {
+    width: 100%;
+  }
+</style>

+ 45 - 0
src/layout/mixin/ResizeHandler.js

@@ -0,0 +1,45 @@
+import store from '@/store'
+
+const { body } = document
+const WIDTH = 992 // refer to Bootstrap's responsive design
+
+export default {
+  watch: {
+    $route(route) {
+      if (this.device === 'mobile' && this.sidebar.opened) {
+        store.dispatch('app/closeSideBar', { withoutAnimation: false })
+      }
+    }
+  },
+  beforeMount() {
+    window.addEventListener('resize', this.$_resizeHandler)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.$_resizeHandler)
+  },
+  mounted() {
+    const isMobile = this.$_isMobile()
+    if (isMobile) {
+      store.dispatch('app/toggleDevice', 'mobile')
+      store.dispatch('app/closeSideBar', { withoutAnimation: true })
+    }
+  },
+  methods: {
+    // use $_ for mixins properties
+    // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
+    $_isMobile() {
+      const rect = body.getBoundingClientRect()
+      return rect.width - 1 < WIDTH
+    },
+    $_resizeHandler() {
+      if (!document.hidden) {
+        const isMobile = this.$_isMobile()
+        store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
+
+        if (isMobile) {
+          store.dispatch('app/closeSideBar', { withoutAnimation: true })
+        }
+      }
+    }
+  }
+}

+ 1 - 0
src/main.js

@@ -22,6 +22,7 @@ Vue.use(VueLazyLoad, {
 Vue.use(ElementUI);
 
 
+// import './permission' // permission control
 import router from './router'
 import "./common/scss/globe.scss"; //全局样式
 import store2 from "@/store/store.js" // 局部变量状态管理

+ 53 - 0
src/permission.js

@@ -0,0 +1,53 @@
+import router from './router'
+import store from './store'
+import { Message } from 'element-ui'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+import { getToken } from '@/utils/auth'
+
+NProgress.configure({ showSpinner: false })
+
+const whiteList = ['/login', '/auth-redirect', '/bind', '/register']
+
+router.beforeEach((to, from, next) => {
+    NProgress.start()
+    if (getToken()) {
+        to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
+            /* has token*/
+        if (to.path === '/login') {
+            next({ path: '/' })
+            NProgress.done()
+        } else {
+            if (store.getters.roles.length === 0) {
+                // 判断当前用户是否已拉取完user_info信息
+                store.dispatch('GetInfo').then(() => {
+                    store.dispatch('GenerateRoutes').then(accessRoutes => {
+                        // 根据roles权限生成可访问的路由表
+                        router.addRoutes(accessRoutes) // 动态添加可访问路由表
+                        next({...to, replace: true }) // hack方法 确保addRoutes已完成
+                    })
+                }).catch(err => {
+                    store.dispatch('LogOut').then(() => {
+                        Message.error(err)
+                        next({ path: '/' })
+                    })
+                })
+            } else {
+                next()
+            }
+        }
+    } else {
+        // 没有token
+        if (whiteList.indexOf(to.path) !== -1) {
+            // 在免登录白名单,直接进入
+            next()
+        } else {
+            next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
+            NProgress.done()
+        }
+    }
+})
+
+router.afterEach(() => {
+    NProgress.done()
+})

+ 19 - 21
src/router/index.js

@@ -28,38 +28,36 @@ Vue.use(Router)
 
 // 公共路由
 export const constantRoutes = [{
-        path: '/',
-        component: () =>
-            import ('@/views/map3d'),
-        hidden: true
-    }, {
-        path: '/login',
+    path: '/',
+    component: () =>
+        import ('@/views/map3d'),
+    hidden: true,
+    children: [{
+        path: '/view',
         component: () =>
-            import ('@/views/login'),
+            import ('@/views/view'),
         hidden: true
     }, {
-        path: '/map3d',
+        path: '/overview',
         component: () =>
-            import ('@/views/map3d'),
+            import ('@/views/overview'),
         hidden: true
     }, {
         path: '/cockpit',
         component: () =>
             import ('@/views/cockpit'),
         hidden: true
-    }, {
-        path: '/test',
-        component: () =>
-            import ('@/views/test'),
-        hidden: true
-    }, {
-        path: '/view',
-        component: () =>
-            import ('@/views/view'),
-        hidden: true
-    }
+    }]
+}, {
+    path: '/login',
+    component: () =>
+        import ('@/views/login'),
+    hidden: true
+}]
+
 
-]
+// 动态路由,基于用户权限动态去加载
+export const dynamicRoutes = []
 
 // 防止连续点击多次路由报错
 let routerPush = Router.prototype.push;

+ 5 - 1
src/store/index.js

@@ -3,12 +3,16 @@ import Vuex from 'vuex'
 
 import user from './modules/user'
 import getters from './getters'
+import permission from './modules/permission'
+import settings from './modules/settings'
 
 Vue.use(Vuex)
 
 const store = new Vuex.Store({
     modules: {
-        user
+        user,
+        settings,
+        permission
     },
     getters
 })

+ 0 - 115
src/store/modules/user copy.js

@@ -1,115 +0,0 @@
-import { login, logout, getInfo, refreshToken } from '@/api/login'
-import { getToken, setToken, setExpiresIn, removeToken } from '@/utils/auth'
-
-const user = {
-  state: {
-    token: getToken(),
-    name: '',
-    avatar: '',
-    roles: [],
-    permissions: []
-  },
-
-  mutations: {
-    SET_TOKEN: (state, token) => {
-      state.token = token
-    },
-    SET_EXPIRES_IN: (state, time) => {
-      state.expires_in = time
-    },
-    SET_NAME: (state, name) => {
-      state.name = name
-    },
-    SET_AVATAR: (state, avatar) => {
-      state.avatar = avatar
-    },
-    SET_ROLES: (state, roles) => {
-      state.roles = roles
-    },
-    SET_PERMISSIONS: (state, permissions) => {
-      state.permissions = permissions
-    }
-  },
-
-  actions: {
-    // 登录
-    Login({ commit }, userInfo) {
-      const username = userInfo.username.trim()
-      const password = userInfo.password
-      const code = userInfo.code
-      const uuid = userInfo.uuid
-      return new Promise((resolve, reject) => {
-        login(username, password, code, uuid).then(res => {
-          let data = res.data
-          setToken(data.access_token)
-          commit('SET_TOKEN', data.access_token)
-          setExpiresIn(data.expires_in)
-          commit('SET_EXPIRES_IN', data.expires_in)
-          resolve()
-        }).catch(error => {
-          reject(error)
-        })
-      })
-    },
-
-    // 获取用户信息
-    GetInfo({ commit, state }) {
-      return new Promise((resolve, reject) => {
-        getInfo().then(res => {
-          const user = res.user
-          const avatar = (user.avatar == "" || user.avatar == null) ? require("@/assets/images/profile.jpg") : user.avatar;
-          if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
-            commit('SET_ROLES', res.roles)
-            commit('SET_PERMISSIONS', res.permissions)
-          } else {
-            commit('SET_ROLES', ['ROLE_DEFAULT'])
-          }
-          commit('SET_NAME', user.userName)
-          commit('SET_AVATAR', avatar)
-          resolve(res)
-        }).catch(error => {
-          reject(error)
-        })
-      })
-    },
-
-    // 刷新token
-    RefreshToken({commit, state}) {
-      return new Promise((resolve, reject) => {
-        refreshToken(state.token).then(res => {
-          setExpiresIn(res.data)
-          commit('SET_EXPIRES_IN', res.data)
-          resolve()
-        }).catch(error => {
-          reject(error)
-        })
-      })
-    },
-    
-    // 退出系统
-    LogOut({ commit, state }) {
-      return new Promise((resolve, reject) => {
-        logout(state.token).then(() => {
-          commit('SET_TOKEN', '')
-          commit('SET_ROLES', [])
-          commit('SET_PERMISSIONS', [])
-          removeToken()
-          resolve()
-        }).catch(error => {
-          reject(error)
-        })
-      })
-    },
-
-    // 前端 登出
-    FedLogOut({ commit }) {
-      return new Promise(resolve => {
-        commit('SET_TOKEN', '')
-        removeToken()
-        resolve()
-      })
-    }
-  }
-}
-
-export default user

+ 1 - 1
src/utils/request.js

@@ -8,7 +8,7 @@ import { Notification, MessageBox, Message, Loading } from 'element-ui'
 
 let downloadLoadingInstance;
 // 是否显示重新登录
-let isReloginShow = false;
+export let isReloginShow = false;
 
 axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";
 // 创建axios实例

+ 83 - 0
src/utils/validate.js

@@ -0,0 +1,83 @@
+/**
+ * @param {string} path
+ * @returns {Boolean}
+ */
+export function isExternal(path) {
+  return /^(https?:|mailto:|tel:)/.test(path)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validUsername(str) {
+  const valid_map = ['admin', 'editor']
+  return valid_map.indexOf(str.trim()) >= 0
+}
+
+/**
+ * @param {string} url
+ * @returns {Boolean}
+ */
+export function validURL(url) {
+  const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
+  return reg.test(url)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validLowerCase(str) {
+  const reg = /^[a-z]+$/
+  return reg.test(str)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validUpperCase(str) {
+  const reg = /^[A-Z]+$/
+  return reg.test(str)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validAlphabets(str) {
+  const reg = /^[A-Za-z]+$/
+  return reg.test(str)
+}
+
+/**
+ * @param {string} email
+ * @returns {Boolean}
+ */
+export function validEmail(email) {
+  const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
+  return reg.test(email)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function isString(str) {
+  if (typeof str === 'string' || str instanceof String) {
+    return true
+  }
+  return false
+}
+
+/**
+ * @param {Array} arg
+ * @returns {Boolean}
+ */
+export function isArray(arg) {
+  if (typeof Array.isArray === 'undefined') {
+    return Object.prototype.toString.call(arg) === '[object Array]'
+  }
+  return Array.isArray(arg)
+}

+ 18 - 18
src/views/login.vue

@@ -5,7 +5,7 @@
       <div class="el-login-header">
         <div style="display: flex; align-items: center">
           <img src="@/assets/backimg/logo.png" class="logo" alt="" />
-          <span>海南省国土空间智慧治理</span>
+          <span>海南省国土空间智慧治理试点(三亚)</span>
         </div>
       </div>
       <div class="login_center">
@@ -26,7 +26,9 @@
                 placeholder="账号"
               >
                 <template #prefix
-                  ><svg-icon icon-class="user" class="el-input__icon input-icon"
+                  ><svg-icon
+                    icon-class="user"
+                    class="el-input__icon input-icon"
                 /></template>
               </el-input>
             </el-form-item>
@@ -90,7 +92,7 @@
 
       <!--  底部  -->
       <div class="el-login-footer">
-        <span>版权所有:海南省国土空间智慧治理</span>
+        <span>版权所有:海南省国土空间智慧治理试点(三亚)</span>
       </div>
     </div>
     <div class="login_mask"></div>
@@ -114,23 +116,23 @@ export default {
         password: "123456",
         rememberMe: false,
         code: "",
-        uuid: ""
+        uuid: "",
       },
       loginRules: {
         username: [
-          { required: true, trigger: "blur", message: "请输入您的账号" }
+          { required: true, trigger: "blur", message: "请输入您的账号" },
         ],
         password: [
-          { required: true, trigger: "blur", message: "请输入您的密码" }
+          { required: true, trigger: "blur", message: "请输入您的密码" },
         ],
-        code: [{ required: true, trigger: "change", message: "请输入验证码" }]
+        code: [{ required: true, trigger: "change", message: "请输入验证码" }],
       },
       loading: false,
       // 验证码开关
       captchaEnabled: true,
       // 注册开关
       register: false,
-      redirect: undefined
+      redirect: undefined,
     };
   },
   created() {
@@ -139,7 +141,7 @@ export default {
   },
   methods: {
     getCode() {
-      getCodeImg().then(res => {
+      getCodeImg().then((res) => {
         this.captchaEnabled =
           res.captchaEnabled === undefined ? true : res.captchaEnabled;
         if (this.captchaEnabled) {
@@ -156,20 +158,20 @@ export default {
         username: username === undefined ? this.loginForm.username : username,
         password:
           password === undefined ? this.loginForm.password : decrypt(password),
-        rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
+        rememberMe: rememberMe === undefined ? false : Boolean(rememberMe),
       };
     },
     handleLogin() {
-      this.$refs.loginForm.validate(valid => {
+      this.$refs.loginForm.validate((valid) => {
         if (valid) {
           this.loading = true;
           if (this.loginForm.rememberMe) {
             Cookies.set("username", this.loginForm.username, { expires: 30 });
             Cookies.set("password", encrypt(this.loginForm.password), {
-              expires: 30
+              expires: 30,
             });
             Cookies.set("rememberMe", this.loginForm.rememberMe, {
-              expires: 30
+              expires: 30,
             });
           } else {
             Cookies.remove("username");
@@ -180,9 +182,7 @@ export default {
           this.$store
             .dispatch("Login", this.loginForm)
             .then(() => {
-              this.$router
-                .push({ path: this.redirect || "/view" })
-                .catch(() => {});
+              this.$router.push({ path: this.redirect || "/" }).catch(() => {});
             })
             .catch(() => {
               this.loading = false;
@@ -192,8 +192,8 @@ export default {
             });
         }
       });
-    }
-  }
+    },
+  },
 };
 </script>
 

+ 183 - 0
src/views/map3d.vue

@@ -1,11 +1,194 @@
 <template>
   <div id="app">
+    <div class="header">
+      <div class="menuClass">
+        <div
+          v-for="(item, index) in menu"
+          :key="index"
+          :class="activeMenuId == index ? 'activemenu' : 'menu'"
+          @click="chooseMenu(item, index)"
+        >
+          {{ item.title }}
+        </div>
+      </div>
+
+      <div class="systemTitle">海南省国土空间智慧治理试点</div>
+      <tool-bar></tool-bar>
+      <div class="timeline">
+        <div class="timeline-item" v-html="formattedText"></div>
+        <div
+          class="timeline-item timeline-item-time"
+          v-html="formattedTime"
+        ></div>
+      </div>
+    </div>
+    <div class="routerContainer"><router-view></router-view></div>
     <sm-viewer> </sm-viewer>
   </div>
 </template>
 
 <script>
+import { getRouters } from "@/api/menu";
 export default {
   name: "app",
+  data() {
+    return {
+      menu: [],
+      formattedText: "",
+      formattedTime: "",
+      activeMenuId: 0,
+    };
+  },
+  created() {
+    this.GetRouters();
+  },
+  computed: {},
+  mounted() {
+    //动态时间回显
+    setInterval(() => {
+      this.updateTime();
+    }, 1000);
+    this.updateTime();
+  },
+  methods: {
+    updateTime() {
+      let s = new Date().toLocaleString().split(" ");
+      this.formattedText = s[0];
+      this.formattedTime = s[1];
+    },
+    chooseMenu(item, index) {
+      if (this.activeMenuId == index) {
+        return;
+      }
+      console.log(item);
+      this.activeMenuId = index;
+      this.$router.push({ path: item.path });
+    },
+    GetRouters() {
+      getRouters().then((res) => {
+        this.menu = res.data[0].children;
+        console.log(this.menu);
+        let curRouter = this.$router.currentRoute.path;
+        if (curRouter == "/") {
+          this.$router.push({ path: this.menu[0].path });
+        } else {
+          for (let i = 0; i < this.menu.length; i++) {
+            if (this.menu[i].path == curRouter) {
+              this.activeMenuId = i;
+              this.$router.push({ path: this.menu[i].path });
+              break;
+            }
+          }
+        }
+      });
+    },
+    getDayOfWeek() {
+      const days = [
+        "星期日",
+        "星期一",
+        "星期二",
+        "星期三",
+        "星期四",
+        "星期五",
+        "星期六",
+      ];
+      const date = new Date();
+      return days[date.getDay()];
+    },
+  },
 };
 </script>
+
+<style scoped>
+#app {
+}
+.routerContainer {
+  position: absolute;
+  width: 100%;
+  height: calc(100% - 60px);
+  top: 60px;
+}
+.header {
+  height: 60px;
+  position: fixed;
+  top: 0px;
+  width: 100%;
+  z-index: 999999;
+  background: rgb(4, 16, 36);
+  text-align: center;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: url("/static/images/overview/title.png");
+  background-size: 100% 100%;
+}
+.systemTitle {
+  height: 100%;
+  width: 338px;
+  position: absolute;
+  top: 0px;
+  line-height: 60px;
+  font-size: 25px;
+  color: white;
+}
+.menuClass {
+  position: absolute;
+  left: 84px;
+  font-size: 14px;
+  height: 32px;
+  top: 20px;
+}
+.menu {
+  display: inline-block;
+  width: 108px;
+  height: 100%;
+  line-height: 35px;
+  margin-left: 9px;
+  cursor: pointer;
+  background-image: url("/static/images/overview/menu.png");
+  background-size: 100% 100%;
+}
+.activemenu {
+  display: inline-block;
+  width: 108px;
+  height: 100%;
+  line-height: 35px;
+  margin-left: 9px;
+  cursor: pointer;
+  background-image: url("/static/images/overview/menuactive.png");
+  background-size: 100% 100%;
+}
+.menu:hover,
+.activemenu:hover {
+  /* background: blue; */
+  font-weight: bold;
+}
+.timeline {
+  position: absolute;
+  right: 25px;
+  top: 17px;
+  height: 38px;
+  line-height: 60px;
+  font-size: 14px;
+  width: 148px;
+  color: white;
+  background-image: url(/static/images/overview/time.png);
+  background-size: 100% 100%;
+}
+.timeline-item {
+  height: 25px;
+  line-height: 25px;
+  text-align: center;
+  width: 100%;
+  display: inline-block;
+  font-size: 11px;
+  position: relative;
+  top: -21px;
+  letter-spacing: 1px;
+}
+
+.timeline-item-time {
+  top: -62px;
+  font-size: 19px;
+}
+</style>

+ 42 - 0
src/views/overview.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="overview">
+    <div class="innerContainer leftPane"></div>
+    <div class="innerContainer rightPane"></div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "overview",
+  data() {
+    return {};
+  },
+  created() {},
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped>
+.overview {
+  width: 100%;
+  height: 100%;
+  position: absolute;
+}
+
+.innerContainer {
+  width: 350px;
+  height: calc(100% - 20px);
+  position: absolute;
+  background: #041024;
+  z-index: 99;
+  top: 10px;
+}
+
+.leftPane {
+  left: 10px;
+}
+
+.rightPane {
+  right: 10px;
+}
+</style>

+ 0 - 15
src/views/test.vue

@@ -1,15 +0,0 @@
-<template>
-  <div id="app">
-    <click-query
-      style="width: 400px; height: 400px; position: absolute"
-    ></click-query>
-  </div>
-</template>
-
-<script>
-import clickQuery from "../components/Query/clickQuery/clickQuery.vue";
-export default {
-  components: { clickQuery },
-  name: "app",
-};
-</script>

BIN
static/images/overview/menu.png


BIN
static/images/overview/menuactive.png


BIN
static/images/overview/time.png


BIN
static/images/overview/title.png