瀏覽代碼

wanda-adm:feat > 搭建基础框架

shaun-sheep 4 年之前
當前提交
6a553b80eb
共有 90 個文件被更改,包括 2890 次插入0 次删除
  1. 3 0
      .browserslistrc
  2. 35 0
      .editorconfig
  3. 1 0
      .env
  4. 3 0
      .eslintignore
  5. 54 0
      .eslintrc.js
  6. 28 0
      .gitignore
  7. 1 0
      .prod
  8. 1 0
      .stage
  9. 41 0
      README.md
  10. 5 0
      babel.config.js
  11. 74 0
      package.json
  12. 5 0
      postcss.config.js
  13. 二進制
      public/favicon.ico
  14. 17 0
      public/index.html
  15. 20 0
      public/manifest.json
  16. 2 0
      public/robots.txt
  17. 14 0
      src/App.vue
  18. 8 0
      src/api/articles.ts
  19. 17 0
      src/api/types.d.ts
  20. 21 0
      src/api/users.ts
  21. 二進制
      src/assets/404-images/404-cloud.png
  22. 二進制
      src/assets/404-images/404.png
  23. 99 0
      src/components/Breadcrumb/index.vue
  24. 37 0
      src/components/Hamburger/index.vue
  25. 13 0
      src/icons/README.md
  26. 12 0
      src/icons/components/dashboard.ts
  27. 12 0
      src/icons/components/example.ts
  28. 12 0
      src/icons/components/eye-off.ts
  29. 12 0
      src/icons/components/eye-on.ts
  30. 12 0
      src/icons/components/form.ts
  31. 12 0
      src/icons/components/hamburger.ts
  32. 13 0
      src/icons/components/index.ts
  33. 12 0
      src/icons/components/link.ts
  34. 12 0
      src/icons/components/nested.ts
  35. 12 0
      src/icons/components/password.ts
  36. 12 0
      src/icons/components/table.ts
  37. 12 0
      src/icons/components/tree.ts
  38. 12 0
      src/icons/components/user.ts
  39. 3 0
      src/icons/svg/dashboard.svg
  40. 3 0
      src/icons/svg/example.svg
  41. 3 0
      src/icons/svg/eye-off.svg
  42. 6 0
      src/icons/svg/eye-on.svg
  43. 3 0
      src/icons/svg/form.svg
  44. 3 0
      src/icons/svg/hamburger.svg
  45. 5 0
      src/icons/svg/link.svg
  46. 3 0
      src/icons/svg/nested.svg
  47. 3 0
      src/icons/svg/password.svg
  48. 4 0
      src/icons/svg/table.svg
  49. 3 0
      src/icons/svg/tree.svg
  50. 3 0
      src/icons/svg/user.svg
  51. 25 0
      src/layout/components/AppMain.vue
  52. 175 0
      src/layout/components/Navbar/index.vue
  53. 176 0
      src/layout/components/Sidebar/SidebarItem.vue
  54. 30 0
      src/layout/components/Sidebar/SidebarItemLink.vue
  55. 101 0
      src/layout/components/Sidebar/index.vue
  56. 3 0
      src/layout/components/index.ts
  57. 129 0
      src/layout/index.vue
  58. 55 0
      src/layout/mixin/resize.ts
  59. 29 0
      src/main.ts
  60. 60 0
      src/permission.ts
  61. 32 0
      src/registerServiceWorker.ts
  62. 157 0
      src/router.ts
  63. 4 0
      src/shims-vue.d.ts
  64. 14 0
      src/store/index.ts
  65. 66 0
      src/store/modules/app.ts
  66. 95 0
      src/store/modules/user.ts
  67. 8 0
      src/styles/_mixins.scss
  68. 31 0
      src/styles/_svgicon.scss
  69. 49 0
      src/styles/_transition.scss
  70. 34 0
      src/styles/_variables.scss
  71. 9 0
      src/styles/_variables.scss.d.ts
  72. 19 0
      src/styles/element-variables.scss
  73. 47 0
      src/styles/index.scss
  74. 12 0
      src/utils/cookies.ts
  75. 71 0
      src/utils/request.ts
  76. 3 0
      src/utils/validate.ts
  77. 283 0
      src/views/404.vue
  78. 275 0
      src/views/login/index.vue
  79. 13 0
      src/views/maintain/device/index.vue
  80. 13 0
      src/views/maintain/relationship/index.vue
  81. 13 0
      src/views/maintain/space/index.vue
  82. 13 0
      src/views/maintain/system/index.vue
  83. 13 0
      src/views/manage/build/index.vue
  84. 13 0
      src/views/manage/model/index.vue
  85. 13 0
      src/views/project/index.vue
  86. 13 0
      src/views/scene/plane/index.vue
  87. 13 0
      src/views/scene/system/index.vue
  88. 13 0
      src/views/scene/tiepoint/index.vue
  89. 41 0
      tsconfig.json
  90. 29 0
      vue.config.js

+ 3 - 0
.browserslistrc

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

+ 35 - 0
.editorconfig

@@ -0,0 +1,35 @@
+# http://editorconfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+# Indentation override for js(x), ts(x) and vue files
+[*.{js,jsx,ts,tsx,vue}]
+indent_size = 2
+indent_style = space
+
+# Indentation override for css related files
+[*.{css,styl,scss,less,sass}]
+indent_size = 2
+indent_style = space
+
+# Indentation override for html files
+[*.html]
+indent_size = 2
+indent_style = space
+
+# Trailing space override for markdown file
+[*.md]
+trim_trailing_whitespace = false
+
+# Indentation override for config files
+[*.{json,yml}]
+indent_size = 2
+indent_style = space

+ 1 - 0
.env

@@ -0,0 +1 @@
+VUE_APP_BASE_API = 'https://vue-typescript-admin-mock-server.armour.now.sh/mock-api/v1/'

+ 3 - 0
.eslintignore

@@ -0,0 +1,3 @@
+dist/*.js
+src/assets
+tests/unit/coverage

+ 54 - 0
.eslintrc.js

@@ -0,0 +1,54 @@
+module.exports = {
+  root: true,
+  env: {
+    node: true
+  },
+  extends: [
+    'plugin:vue/essential',
+    '@vue/standard',
+    '@vue/typescript/recommended'
+  ],
+  parserOptions: {
+    ecmaVersion: 2020
+  },
+  rules: {
+    '@typescript-eslint/ban-types': 'off',
+    '@typescript-eslint/explicit-module-boundary-types': 'off',
+    '@typescript-eslint/member-delimiter-style': ['error',
+      {
+        multiline: {
+          delimiter: 'none'
+        },
+        singleline: {
+          delimiter: 'comma'
+        }
+      }],
+    '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/no-var-requires': 'off',
+    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
+    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
+    'space-before-function-paren': ['error', 'never'],
+    'vue/array-bracket-spacing': 'error',
+    'vue/arrow-spacing': 'error',
+    'vue/block-spacing': 'error',
+    'vue/brace-style': 'error',
+    'vue/camelcase': 'error',
+    'vue/comma-dangle': 'error',
+    'vue/component-name-in-template-casing': ['error', 'kebab-case'],
+    'vue/eqeqeq': 'error',
+    'vue/key-spacing': 'error',
+    'vue/match-component-file-name': 'error',
+    'vue/object-curly-spacing': 'error'
+  },
+  overrides: [
+    {
+      files: [
+        '**/__tests__/*.{j,t}s?(x)',
+        '**/tests/unit/**/*.spec.{j,t}s?(x)'
+      ],
+      env: {
+        jest: true
+      }
+    }
+  ]
+}

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+.DS_Store
+node_modules
+/dist
+
+/tests/e2e/videos/
+/tests/e2e/screenshots/
+/tests/**/coverage/
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+.history
+.ionide
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 1 - 0
.prod

@@ -0,0 +1 @@
+VUE_APP_BASE_API = 'https://vue-typescript-admin-mock-server.armour.now.sh/mock-api/v1/'

+ 1 - 0
.stage

@@ -0,0 +1 @@
+VUE_APP_BASE_API = 'https://vue-typescript-admin-mock-server.armour.now.sh/mock-api/v1/'

+ 41 - 0
README.md

@@ -0,0 +1,41 @@
+
+
+## 目录结构
+
+```bash
+
+├── public                     # 静态资源 (会被直接复制)
+│   │── favicon.ico            # favicon图标
+│   │── manifest.json          # PWA 配置文件
+│   └── index.html             # html模板
+├── src                        # 源代码
+│   ├── api                    # 所有请求
+│   ├── assets                 # 主题 字体等静态资源 (由 webpack 处理加载)
+│   ├── components             # 全局组件
+│   ├── directive              # 全局指令
+│   ├── filters                # 全局过滤函数
+│   ├── icons                  # svg 图标
+│   ├── layout                 # 全局布局
+│   ├── router                 # 路由
+│   ├── store                  # 全局 vuex store
+│   ├── styles                 # 全局样式
+│   ├── utils                  # 全局方法
+│   ├── views                  # 所有页面
+│   ├── App.vue                # 入口页面
+│   ├── main.js                # 入口文件 加载组件 初始化等
+│   ├── permission.ts          # 权限管理
+│   └── shims.d.ts             # 模块注入
+├── .circleci/                 # 自动化 CI 配置
+├── .browserslistrc            # browserslistrc 配置文件 (用于支持 Autoprefixer)
+├── .editorconfig              # 编辑相关配置
+├── .env                       # 开发环境变量配置
+├── .stage                     # 测试环境变量配置
+├── .prod                      # 线上环境变量配置
+├── .eslintrc.js               # eslint 配置
+├── babel.config.js            # babel-loader 配置
+├── package.json               # package.json 依赖
+├── postcss.config.js          # postcss 配置
+├── tsconfig.json              # typescript 配置
+└── vue.config.js              # vue-cli 配置
+```
+

+ 5 - 0
babel.config.js

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

+ 74 - 0
package.json

@@ -0,0 +1,74 @@
+{
+  "name": "adm",
+  "version": "1.0.0",
+  "private": true,
+  "author": "hao jie <haojie@persagy.com>",
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint",
+    "svg": "vsvg -s ./src/icons/svg -t ./src/icons/components --ext ts --es6",
+    "test:unit": "jest --clearCache && vue-cli-service test:unit"
+  },
+  "dependencies": {
+    "@babel/runtime": "^7.12.5",
+    "axios": "^0.21.0",
+    "core-js": "^3.6.5",
+    "element-ui": "^2.14.0",
+    "js-cookie": "^2.2.1",
+    "normalize.css": "^8.0.1",
+    "nprogress": "^0.2.0",
+    "path-to-regexp": "^6.2.0",
+    "register-service-worker": "^1.7.1",
+    "vue": "^2.6.12",
+    "vue-class-component": "^7.2.6",
+    "vue-property-decorator": "^9.0.2",
+    "vue-router": "^3.4.8",
+    "vue-svgicon": "^3.2.9",
+    "vuex": "^3.5.1",
+    "vuex-module-decorators": "^1.0.1"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.12.10",
+    "@babel/plugin-transform-runtime": "^7.12.10",
+    "@babel/preset-env": "^7.12.11",
+    "@types/jest": "^26.0.15",
+    "@types/js-cookie": "^2.2.6",
+    "@types/node": "^14.14.6",
+    "@types/nprogress": "^0.2.0",
+    "@types/webpack-env": "^1.15.3",
+    "@typescript-eslint/eslint-plugin": "^4.6.0",
+    "@typescript-eslint/parser": "^4.6.0",
+    "@vue/cli-plugin-babel": "^4.5.8",
+    "@vue/cli-plugin-eslint": "^4.5.8",
+    "@vue/cli-plugin-pwa": "^4.5.8",
+    "@vue/cli-plugin-router": "^4.5.8",
+    "@vue/cli-plugin-typescript": "^4.5.8",
+    "@vue/cli-plugin-unit-jest": "^4.5.8",
+    "@vue/cli-plugin-vuex": "^4.5.8",
+    "@vue/cli-service": "^4.5.8",
+    "@vue/eslint-config-standard": "^5.1.2",
+    "@vue/eslint-config-typescript": "^7.0.0",
+    "@vue/test-utils": "^1.1.1",
+    "babel-core": "^7.0.0-bridge.0",
+    "babel-eslint": "^10.1.0",
+    "babel-loader": "^8.1.0",
+    "eslint": "^7.12.1",
+    "eslint-plugin-import": "^2.22.1",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-promise": "^4.2.1",
+    "eslint-plugin-standard": "^4.0.2",
+    "eslint-plugin-vue": "^7.1.0",
+    "fibers": "^5.0.0",
+    "jest": "^26.6.1",
+    "sass": "^1.28.0",
+    "sass-loader": "^10.0.4",
+    "style-resources-loader": "^1.3.3",
+    "ts-jest": "^26.4.3",
+    "typescript": "^4.0.5",
+    "vue-cli-plugin-element": "^1.0.1",
+    "vue-cli-plugin-style-resources-loader": "^0.1.4",
+    "vue-template-compiler": "^2.6.12",
+    "webpack": "^4.46.0"
+  }
+}

+ 5 - 0
postcss.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  plugins: {
+    autoprefixer: {}
+  }
+}

二進制
public/favicon.ico


+ 17 - 0
public/index.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width,initial-scale=1.0">
+  <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+  <title><%= htmlWebpackPlugin.options.title %></title>
+</head>
+<body>
+  <noscript>
+    <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+  </noscript>
+  <div id="app"></div>
+  <!-- built files will be auto injected -->
+</body>
+</html>

+ 20 - 0
public/manifest.json

@@ -0,0 +1,20 @@
+{
+  "name": "wanda-adm",
+  "short_name": "adm",
+  "icons": [
+    {
+      "src": "./img/icons/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png"
+    },
+    {
+      "src": "./img/icons/android-chrome-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ],
+  "start_url": "./index.html",
+  "display": "standalone",
+  "background_color": "#fff",
+  "theme_color": "#4DBA87"
+}

+ 2 - 0
public/robots.txt

@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:

+ 14 - 0
src/App.vue

@@ -0,0 +1,14 @@
+<template>
+  <div id="app">
+    <router-view />
+  </div>
+</template>
+
+<script lang="ts">
+import { Component, Vue } from 'vue-property-decorator'
+
+@Component({
+  name: 'App'
+})
+export default class extends Vue {}
+</script>

+ 8 - 0
src/api/articles.ts

@@ -0,0 +1,8 @@
+import request from '@/utils/request'
+
+export const getArticles = (params: any) =>
+  request({
+    url: '/articles',
+    method: 'get',
+    params
+  })

+ 17 - 0
src/api/types.d.ts

@@ -0,0 +1,17 @@
+export interface IArticleData {
+  id: number
+  status: string
+  title: string
+  abstractContent: string
+  fullContent: string
+  sourceURL: string
+  imageURL: string
+  timestamp: string | number
+  platforms: string[]
+  disableComment: boolean
+  importance: number
+  author: string
+  reviewer: string
+  type: string
+  pageviews: number
+}

+ 21 - 0
src/api/users.ts

@@ -0,0 +1,21 @@
+import request from '@/utils/request'
+
+export const getUserInfo = (data: any) =>
+  request({
+    url: '/users/info',
+    method: 'post',
+    data
+  })
+
+export const login = (data: any) =>
+  request({
+    url: '/users/login',
+    method: 'post',
+    data
+  })
+
+export const logout = () =>
+  request({
+    url: '/users/logout',
+    method: 'post'
+  })

二進制
src/assets/404-images/404-cloud.png


二進制
src/assets/404-images/404.png


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

@@ -0,0 +1,99 @@
+<template>
+  <el-breadcrumb
+    class="app-breadcrumb"
+    separator="/"
+  >
+    <transition-group name="breadcrumb">
+      <el-breadcrumb-item
+        v-for="(item, index) in breadcrumbs"
+        :key="item.path"
+      >
+        <span
+          v-if="item.redirect === 'noredirect' || index === breadcrumbs.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 lang="ts">
+import { compile } from 'path-to-regexp'
+import { Component, Vue, Watch } from 'vue-property-decorator'
+import { RouteRecord, Route } from 'vue-router'
+
+@Component({
+  name: 'Breadcrumb'
+})
+export default class extends Vue {
+  private breadcrumbs: RouteRecord[] = [];
+
+  @Watch('$route')
+  private onRouteChange(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()
+  }
+
+  private getBreadcrumb() {
+    let matched = this.$route.matched.filter(
+      item => item.meta && item.meta.title
+    )
+    const first = matched[0]
+
+    this.breadcrumbs = matched.filter(item => {
+      return item.meta && item.meta.title && item.meta.breadcrumb !== false
+    })
+  }
+
+  private isDashboard(route: RouteRecord) {
+    const name = route && route.meta && route.meta.title
+    return name === 'Dashboard'
+  }
+
+  private pathCompile(path: string) {
+    // To solve this problem https://github.com/PanJiaChen/vue-element-admin/issues/561
+    const { params } = this.$route
+    const toPath = compile(path)
+    return toPath(params)
+  }
+
+  private handleLink(item: any) {
+    const { redirect, path } = item
+    if (redirect) {
+      this.$router.push(redirect)
+      return
+    }
+    this.$router.push(this.pathCompile(path))
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+  font-weight: 400 !important;
+}
+
+.app-breadcrumb.el-breadcrumb {
+  display: inline-block;
+  font-size: 14px;
+  line-height: 50px;
+  margin-left: 8px;
+
+  .no-redirect {
+    color: #97a8be;
+    cursor: text;
+  }
+}
+</style>

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

@@ -0,0 +1,37 @@
+<template>
+  <div
+    :class="[{'is-active': isActive}]"
+    @click="toggleClick"
+  >
+    <svg-icon
+      name="hamburger"
+      width="20"
+      height="20"
+    />
+  </div>
+</template>
+
+<script lang="ts">
+import { Component, Prop, Vue } from 'vue-property-decorator'
+
+@Component({
+  name: 'Hamburger'
+})
+export default class extends Vue {
+  @Prop({ default: false }) private isActive!: boolean
+
+  private toggleClick() {
+    this.$emit('toggle-click')
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.svg-icon {
+  vertical-align: middle;
+}
+
+.is-active {
+  transform: rotate(180deg);
+}
+</style>

+ 13 - 0
src/icons/README.md

@@ -0,0 +1,13 @@
+# vue-svgicon
+
+## English
+
+* All svg components were generated by `vue-svgicon` using svg files
+* After you adding new svg files into `icons/svg` folder, run `yarn svg` to regerenrate all svg components (before this, you should have `vue-svgicon` installed globally or use `npx`)
+* See details at: [https://github.com/MMF-FE/vue-svgicon](https://github.com/MMF-FE/vue-svgicon)
+
+## 中文
+
+* 所有的 svg 组件都是由 `vue-svgicon` 生成的
+* 每当在 `icons/svg` 文件夹内添加 icon 之后,可以通过执行 `yarn svg` 来重新生成所有组件 (在此之前需要全局安装 `vue-svgicon` 或使用 `npx`)
+* 详细文档请见:[https://github.com/MMF-FE/vue-svgicon](https://github.com/MMF-FE/vue-svgicon)

文件差異過大導致無法顯示
+ 12 - 0
src/icons/components/dashboard.ts


+ 12 - 0
src/icons/components/example.ts

@@ -0,0 +1,12 @@
+/* eslint-disable */
+/* tslint:disable */
+// @ts-ignore
+import icon from 'vue-svgicon'
+icon.register({
+  'example': {
+    width: 128,
+    height: 128,
+    viewBox: '0 0 128 128',
+    data: '<path pid="0" d="M96.3 57.5h31.4A64.2 64.2 0 0 0 70.3 0v31.4a32.9 32.9 0 0 1 26 26zm-38.8-26V0A64.2 64.2 0 0 0 0 57.5h31.4a32.9 32.9 0 0 1 26-26zm12.8 64.8v31.4A64.5 64.5 0 0 0 128 70H96.6a33.6 33.6 0 0 1-26.3 26.3zm-38.8-26H0A64.5 64.5 0 0 0 57.8 128V96.6a33.6 33.6 0 0 1-26.3-26.3z"/>'
+  }
+})

文件差異過大導致無法顯示
+ 12 - 0
src/icons/components/eye-off.ts


文件差異過大導致無法顯示
+ 12 - 0
src/icons/components/eye-on.ts


文件差異過大導致無法顯示
+ 12 - 0
src/icons/components/form.ts


+ 12 - 0
src/icons/components/hamburger.ts

@@ -0,0 +1,12 @@
+/* eslint-disable */
+/* tslint:disable */
+// @ts-ignore
+import icon from 'vue-svgicon'
+icon.register({
+  'hamburger': {
+    width: 64,
+    height: 64,
+    viewBox: '0 0 1024 1024',
+    data: '<path pid="0" d="M408 442h480a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8H408a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8zm-8 204a8 8 0 0 0 8 8h480a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8H408a8 8 0 0 0-8 8v56zm504-486H120a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h784a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8zm0 632H120a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h784a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8zM142.4 642.1L298.7 519a8.8 8.8 0 0 0 0-13.9L142.4 381.9a8.9 8.9 0 0 0-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z"/>'
+  }
+})

+ 13 - 0
src/icons/components/index.ts

@@ -0,0 +1,13 @@
+/* tslint:disable */
+import './dashboard'
+import './example'
+import './eye-off'
+import './eye-on'
+import './form'
+import './hamburger'
+import './link'
+import './nested'
+import './password'
+import './table'
+import './tree'
+import './user'

+ 12 - 0
src/icons/components/link.ts

@@ -0,0 +1,12 @@
+/* eslint-disable */
+/* tslint:disable */
+// @ts-ignore
+import icon from 'vue-svgicon'
+icon.register({
+  'link': {
+    width: 128,
+    height: 128,
+    viewBox: '0 0 128 128',
+    data: '<path pid="0" d="M115.6 128H.1V12.3h57.7v12.3H12.4v90.9h90.9V70.2h12.3z"/><path pid="1" d="M116.4 2.8l8.8 8.8-56.8 56.7-8.7-8.7z"/><path pid="2" d="M127.9 38h-12.4V12.4H88.7V0H128z"/>'
+  }
+})

+ 12 - 0
src/icons/components/nested.ts

@@ -0,0 +1,12 @@
+/* eslint-disable */
+/* tslint:disable */
+// @ts-ignore
+import icon from 'vue-svgicon'
+icon.register({
+  'nested': {
+    width: 128,
+    height: 128,
+    viewBox: '0 0 128 128',
+    data: '<path pid="0" d="M0 9.2c0 5 3.6 9.1 8 9.1s8-4 8-9.1c0-5-3.6-9.1-8-9.1S0 4 0 9.2zM32 .1h96v18.2H32V.1zm0 45.6c0 5 3.6 9.2 8 9.2s8-4.1 8-9.2c0-3.2-1.5-6.2-4-7.9a7.2 7.2 0 0 0-8 0 9.4 9.4 0 0 0-4 8zm32-9.1h64v18.3H64V36.6zm-32 82.2c0 5 3.6 9.1 8 9.1s8-4 8-9.1c0-5-3.6-9.1-8-9.1s-8 4-8 9.1zm32-9.1h64v18.2H64v-18.2zm0-27.4c0 5 3.6 9.1 8 9.1s8-4 8-9.1c0-3.3-1.5-6.3-4-8a7.1 7.1 0 0 0-8 0 9.4 9.4 0 0 0-4 8zM96 73h32v18.3H96V73.1z"/>'
+  }
+})

+ 12 - 0
src/icons/components/password.ts

@@ -0,0 +1,12 @@
+/* eslint-disable */
+/* tslint:disable */
+// @ts-ignore
+import icon from 'vue-svgicon'
+icon.register({
+  'password': {
+    width: 128,
+    height: 128,
+    viewBox: '0 0 128 128',
+    data: '<path pid="0" d="M108.8 44.3H89.6V39c0-9-3.3-24.2-25.6-24.2-23.1 0-25.6 16.9-25.6 24.2v5.3H19.2V39C19.2 15.3 36.8 0 64 0c27.2 0 44.8 15.3 44.8 39v5.3zm-32 39.4c0-5.5-5.8-9.9-12.8-9.9-7 0-12.8 4.4-12.8 9.9 0 3.7 2.6 6.8 6.4 8.5v11.2c0 2.7 2.9 5 6.4 5 3.5 0 6.4-2.3 6.4-5V92.2c3.8-1.7 6.4-4.8 6.4-8.5zM128 64v49.2c0 8.2-8.6 14.8-19.2 14.8H19.2C8.7 128 0 121.4 0 113.2V64c0-8.2 8.6-14.8 19.2-14.8h89.6c10.6 0 19.2 6.6 19.2 14.8z"/>'
+  }
+})

+ 12 - 0
src/icons/components/table.ts

@@ -0,0 +1,12 @@
+/* eslint-disable */
+/* tslint:disable */
+// @ts-ignore
+import icon from 'vue-svgicon'
+icon.register({
+  'table': {
+    width: 128,
+    height: 128,
+    viewBox: '0 0 128 128',
+    data: '<path pid="0" d="M0 0h128v31.2H0V0zm0 38h38.4v41.6H0V38zm0 48.5h38.4v41.4H0V86.5zM44.8 38h38.4v41.5H44.8V38zm0 48.4h38.4v41.4H44.8V86.5zM89.6 38H128v41.5H89.6zm0 48.4H128v41.4H89.6z"/><path pid="1" d="M0 0h128v31.2H0V0zm0 38h38.4v41.6H0V38zm0 48.5h38.4v41.4H0V86.5zM44.8 38h38.4v41.5H44.8V38zm0 48.4h38.4v41.4H44.8V86.5zM89.6 38H128v41.5H89.6zm0 48.4H128v41.4H89.6z"/>'
+  }
+})

文件差異過大導致無法顯示
+ 12 - 0
src/icons/components/tree.ts


+ 12 - 0
src/icons/components/user.ts

@@ -0,0 +1,12 @@
+/* eslint-disable */
+/* tslint:disable */
+// @ts-ignore
+import icon from 'vue-svgicon'
+icon.register({
+  'user': {
+    width: 130,
+    height: 130,
+    viewBox: '0 0 130 130',
+    data: '<path pid="0" d="M63.4 65c20.7 0 37.4-14.3 37.4-32 0-17.6-16.7-32-37.4-32-20.6 0-37.3 14.4-37.3 32 0 17.7 16.7 32 37.3 32zm17.2 10.7H49.4C22.8 75.7 1.2 94 1.2 116.9v2.7c0 9.3 21.6 9.3 48.2 9.3h31.2c26.6 0 48.2-.3 48.2-9.3v-2.7c0-22.8-21.6-41.2-48.2-41.2z" _stroke="#979797"/>'
+  }
+})

文件差異過大導致無法顯示
+ 3 - 0
src/icons/svg/dashboard.svg


+ 3 - 0
src/icons/svg/example.svg

@@ -0,0 +1,3 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
+  <path d="M96.3 57.5h31.4A64.2 64.2 0 0 0 70.3 0v31.4a32.9 32.9 0 0 1 26 26zm-38.8-26V0A64.2 64.2 0 0 0 0 57.5h31.4a32.9 32.9 0 0 1 26-26zm12.8 64.8v31.4A64.5 64.5 0 0 0 128 70H96.6a33.6 33.6 0 0 1-26.3 26.3zm-38.8-26H0A64.5 64.5 0 0 0 57.8 128V96.6a33.6 33.6 0 0 1-26.3-26.3z"/>
+</svg>

文件差異過大導致無法顯示
+ 3 - 0
src/icons/svg/eye-off.svg


文件差異過大導致無法顯示
+ 6 - 0
src/icons/svg/eye-on.svg


文件差異過大導致無法顯示
+ 3 - 0
src/icons/svg/form.svg


+ 3 - 0
src/icons/svg/hamburger.svg

@@ -0,0 +1,3 @@
+<svg width="64" height="64" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+  <path d="M408 442h480a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8H408a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8zm-8 204a8 8 0 0 0 8 8h480a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8H408a8 8 0 0 0-8 8v56zm504-486H120a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h784a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8zm0 632H120a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h784a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8zM142.4 642.1L298.7 519a8.8 8.8 0 0 0 0-13.9L142.4 381.9a8.9 8.9 0 0 0-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z"/>
+</svg>

+ 5 - 0
src/icons/svg/link.svg

@@ -0,0 +1,5 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
+  <path d="M115.6 128H.1V12.3h57.7v12.3H12.4v90.9h90.9V70.2h12.3z"/>
+  <path d="M116.4 2.8l8.8 8.8-56.8 56.7-8.7-8.7z"/>
+  <path d="M127.9 38h-12.4V12.4H88.7V0H128z"/>
+</svg>

+ 3 - 0
src/icons/svg/nested.svg

@@ -0,0 +1,3 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
+  <path d="M0 9.2c0 5 3.6 9.1 8 9.1s8-4 8-9.1c0-5-3.6-9.1-8-9.1S0 4 0 9.2zM32 .1h96v18.2H32V.1zm0 45.6c0 5 3.6 9.2 8 9.2s8-4.1 8-9.2c0-3.2-1.5-6.2-4-7.9a7.2 7.2 0 0 0-8 0 9.4 9.4 0 0 0-4 8zm32-9.1h64v18.3H64V36.6zm-32 82.2c0 5 3.6 9.1 8 9.1s8-4 8-9.1c0-5-3.6-9.1-8-9.1s-8 4-8 9.1zm32-9.1h64v18.2H64v-18.2zm0-27.4c0 5 3.6 9.1 8 9.1s8-4 8-9.1c0-3.3-1.5-6.3-4-8a7.1 7.1 0 0 0-8 0 9.4 9.4 0 0 0-4 8zM96 73h32v18.3H96V73.1z"/>
+</svg>

+ 3 - 0
src/icons/svg/password.svg

@@ -0,0 +1,3 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
+  <path d="M108.8 44.3H89.6V39c0-9-3.3-24.2-25.6-24.2-23.1 0-25.6 16.9-25.6 24.2v5.3H19.2V39C19.2 15.3 36.8 0 64 0c27.2 0 44.8 15.3 44.8 39v5.3zm-32 39.4c0-5.5-5.8-9.9-12.8-9.9-7 0-12.8 4.4-12.8 9.9 0 3.7 2.6 6.8 6.4 8.5v11.2c0 2.7 2.9 5 6.4 5 3.5 0 6.4-2.3 6.4-5V92.2c3.8-1.7 6.4-4.8 6.4-8.5zM128 64v49.2c0 8.2-8.6 14.8-19.2 14.8H19.2C8.7 128 0 121.4 0 113.2V64c0-8.2 8.6-14.8 19.2-14.8h89.6c10.6 0 19.2 6.6 19.2 14.8z"/>
+</svg>

+ 4 - 0
src/icons/svg/table.svg

@@ -0,0 +1,4 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
+  <path d="M0 0h128v31.2H0V0zm0 38h38.4v41.6H0V38zm0 48.5h38.4v41.4H0V86.5zM44.8 38h38.4v41.5H44.8V38zm0 48.4h38.4v41.4H44.8V86.5zM89.6 38H128v41.5H89.6zm0 48.4H128v41.4H89.6z"/>
+  <path d="M0 0h128v31.2H0V0zm0 38h38.4v41.6H0V38zm0 48.5h38.4v41.4H0V86.5zM44.8 38h38.4v41.5H44.8V38zm0 48.4h38.4v41.4H44.8V86.5zM89.6 38H128v41.5H89.6zm0 48.4H128v41.4H89.6z"/>
+</svg>

文件差異過大導致無法顯示
+ 3 - 0
src/icons/svg/tree.svg


+ 3 - 0
src/icons/svg/user.svg

@@ -0,0 +1,3 @@
+<svg width="130" height="130" xmlns="http://www.w3.org/2000/svg">
+  <path d="M63.4 65c20.7 0 37.4-14.3 37.4-32 0-17.6-16.7-32-37.4-32-20.6 0-37.3 14.4-37.3 32 0 17.7 16.7 32 37.3 32zm17.2 10.7H49.4C22.8 75.7 1.2 94 1.2 116.9v2.7c0 9.3 21.6 9.3 48.2 9.3h31.2c26.6 0 48.2-.3 48.2-9.3v-2.7c0-22.8-21.6-41.2-48.2-41.2z" stroke="#979797"/>
+</svg>

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

@@ -0,0 +1,25 @@
+<template>
+  <section class="app-main">
+    <transition
+      name="fade-transform"
+      mode="out-in"
+    >
+      <router-view />
+    </transition>
+  </section>
+</template>
+
+<script lang="ts">
+import { Component, Vue } from 'vue-property-decorator'
+
+@Component({
+  name: 'AppMain'
+})
+export default class extends Vue {}
+</script>
+
+<style lang="scss" scoped>
+.app-main {
+  overflow: hidden;
+}
+</style>

+ 175 - 0
src/layout/components/Navbar/index.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="navbar">
+    <hamburger
+      id="hamburger-container"
+      :is-active="sidebar.opened"
+      class="hamburger-container"
+      @toggle-click="toggleSideBar"
+    />
+    <breadcrumb
+      id="breadcrumb-container"
+      class="breadcrumb-container"
+    />
+    <div class="right-menu">
+      <el-dropdown
+        class="avatar-container right-menu-item hover-effect"
+        trigger="click"
+      >
+        <div class="avatar-wrapper">
+          <img
+            :src="avatar+'?imageView2/1/w/80/h/80'"
+            class="user-avatar"
+          >
+          <i class="el-icon-caret-bottom" />
+        </div>
+        <el-dropdown-menu slot="dropdown">
+          <router-link to="/">
+            <el-dropdown-item>
+              Home
+            </el-dropdown-item>
+          </router-link>
+          <a
+            target="_blank"
+            href="https://github.com/armour/vue-typescript-admin-template/"
+          >
+            <el-dropdown-item>
+              Github
+            </el-dropdown-item>
+          </a>
+          <a
+            target="_blank"
+            href="https://armour.github.io/vue-typescript-admin-docs/"
+          >
+            <el-dropdown-item>
+              Docs
+            </el-dropdown-item>
+          </a>
+          <el-dropdown-item divided>
+            <span
+              style="display:block;"
+              @click="logout"
+            >LogOut</span>
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { Component, Vue } from 'vue-property-decorator'
+import { AppModule } from '@/store/modules/app'
+import { UserModule } from '@/store/modules/user'
+import Breadcrumb from '@/components/Breadcrumb/index.vue'
+import Hamburger from '@/components/Hamburger/index.vue'
+
+@Component({
+  name: 'Navbar',
+  components: {
+    Breadcrumb,
+    Hamburger
+  }
+})
+export default class extends Vue {
+  get sidebar() {
+    return AppModule.sidebar
+  }
+
+  get device() {
+    return AppModule.device.toString()
+  }
+
+  get avatar() {
+    return UserModule.avatar
+  }
+
+  private toggleSideBar() {
+    AppModule.ToggleSideBar(false)
+  }
+
+  private async logout() {
+    await UserModule.LogOut()
+    this.$router.push(`/login?redirect=${this.$route.fullPath}`)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.navbar {
+  height: 50px;
+  overflow: hidden;
+  position: relative;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0,21,41,.08);
+
+  .hamburger-container {
+    line-height: 46px;
+    height: 100%;
+    float: left;
+    padding: 0 15px;
+    cursor: pointer;
+    transition: background .3s;
+    -webkit-tap-highlight-color:transparent;
+
+    &:hover {
+      background: rgba(0, 0, 0, .025)
+    }
+  }
+
+  .breadcrumb-container {
+    float: left;
+  }
+
+  .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 .3s;
+
+        &:hover {
+          background: rgba(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>

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

@@ -0,0 +1,176 @@
+<template>
+  <div
+    v-if="!item.meta || !item.meta.hidden"
+    :class="['menu-wrapper', isCollapse ? 'simple-mode' : 'full-mode', {'first-level': isFirstLevel}]"
+  >
+    <template v-if="theOnlyOneChild && !theOnlyOneChild.children">
+      <sidebar-item-link
+        v-if="theOnlyOneChild.meta"
+        :to="resolvePath(theOnlyOneChild.path)"
+      >
+        <el-menu-item
+          :index="resolvePath(theOnlyOneChild.path)"
+          :class="{'submenu-title-noDropdown': isFirstLevel}"
+        >
+          <svg-icon
+            v-if="theOnlyOneChild.meta.icon"
+            :name="theOnlyOneChild.meta.icon"
+          />
+          <span
+            v-if="theOnlyOneChild.meta.title"
+            slot="title"
+          >{{ theOnlyOneChild.meta.title }}</span>
+        </el-menu-item>
+      </sidebar-item-link>
+    </template>
+    <el-submenu
+      v-else
+      :index="resolvePath(item.path)"
+      popper-append-to-body
+    >
+      <template slot="title">
+        <svg-icon
+          v-if="item.meta && item.meta.icon"
+          :name="item.meta.icon"
+        />
+        <span
+          v-if="item.meta && item.meta.title"
+          slot="title"
+        >{{ item.meta.title }}</span>
+      </template>
+      <template v-if="item.children">
+        <sidebar-item
+          v-for="child in item.children"
+          :key="child.path"
+          :item="child"
+          :is-collapse="isCollapse"
+          :is-first-level="false"
+          :base-path="resolvePath(child.path)"
+          class="nest-menu"
+        />
+      </template>
+    </el-submenu>
+  </div>
+</template>
+
+<script lang="ts">
+import path from 'path'
+import { Component, Prop, Vue } from 'vue-property-decorator'
+import { RouteConfig } from 'vue-router'
+import { isExternal } from '@/utils/validate'
+import SidebarItemLink from './SidebarItemLink.vue'
+
+@Component({
+  // Set 'name' here to prevent uglifyjs from causing recursive component not work
+  // See https://medium.com/haiiro-io/element-component-name-with-vue-class-component-f3b435656561 for detail
+  name: 'SidebarItem',
+  components: {
+    SidebarItemLink
+  }
+})
+export default class extends Vue {
+  @Prop({ required: true }) private item!: RouteConfig
+  @Prop({ default: false }) private isCollapse!: boolean
+  @Prop({ default: true }) private isFirstLevel!: boolean
+  @Prop({ default: '' }) private basePath!: string
+
+  get showingChildNumber() {
+    if (this.item.children) {
+      const showingChildren = this.item.children.filter((item) => {
+        if (item.meta && item.meta.hidden) {
+          return false
+        } else {
+          return true
+        }
+      })
+      return showingChildren.length
+    }
+    return 0
+  }
+
+  get theOnlyOneChild() {
+    if (this.showingChildNumber > 1) {
+      return null
+    }
+    if (this.item.children) {
+      for (const child of this.item.children) {
+        if (!child.meta || !child.meta.hidden) {
+          return child
+        }
+      }
+    }
+    // If there is no children, return itself with path removed,
+    // because this.basePath already conatins item's path information
+    return { ...this.item, path: '' }
+  }
+
+  private resolvePath(routePath: string) {
+    if (isExternal(routePath)) {
+      return routePath
+    }
+    if (isExternal(this.basePath)) {
+      return this.basePath
+    }
+    return path.resolve(this.basePath, routePath)
+  }
+}
+</script>
+
+<style lang="scss">
+.el-submenu.is-active > .el-submenu__title {
+  color: $subMenuActiveText !important;
+}
+
+.full-mode {
+  .nest-menu .el-submenu>.el-submenu__title,
+  .el-submenu .el-menu-item {
+    min-width: $sideBarWidth !important;
+    background-color: $subMenuBg !important;
+
+    &:hover {
+      background-color: $subMenuHover !important;
+    }
+  }
+}
+
+.simple-mode {
+  &.first-level {
+    .submenu-title-noDropdown {
+      padding: 0 !important;
+      position: relative;
+
+      .el-tooltip {
+        padding: 0 !important;
+      }
+    }
+
+    .el-submenu {
+      overflow: hidden;
+
+      &>.el-submenu__title {
+        padding: 0px !important;
+
+        .el-submenu__icon-arrow {
+          display: none;
+        }
+
+        &>span {
+          visibility: hidden;
+        }
+      }
+    }
+  }
+}
+</style>
+
+<style lang="scss" scoped>
+.svg-icon {
+  margin-right: 16px;
+}
+
+.simple-mode {
+  .svg-icon {
+    margin-left: 20px;
+  }
+}
+</style>

+ 30 - 0
src/layout/components/Sidebar/SidebarItemLink.vue

@@ -0,0 +1,30 @@
+<template>
+  <a
+    v-if="isExternal(to)"
+    :href="to"
+    target="_blank"
+    rel="noopener"
+  >
+    <slot />
+  </a>
+  <router-link
+    v-else
+    :to="to"
+  >
+    <slot />
+  </router-link>
+</template>
+
+<script lang="ts">
+import { Component, Prop, Vue } from 'vue-property-decorator'
+import { isExternal } from '@/utils/validate'
+
+@Component({
+  name: 'SidebarItemLink'
+})
+export default class extends Vue {
+  @Prop({ required: true }) private to!: string
+
+  private isExternal = isExternal
+}
+</script>

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

@@ -0,0 +1,101 @@
+<template>
+  <el-scrollbar wrap-class="scrollbar-wrapper">
+    <el-menu
+      :default-active="activeMenu"
+      :collapse="isCollapse"
+      :background-color="variables.menuBg"
+      :text-color="variables.menuText"
+      :active-text-color="variables.menuActiveText"
+      :unique-opened="false"
+      :collapse-transition="false"
+      mode="vertical"
+    >
+      <sidebar-item
+        v-for="route in routes"
+        :key="route.path"
+        :item="route"
+        :base-path="route.path"
+        :is-collapse="isCollapse"
+      />
+    </el-menu>
+  </el-scrollbar>
+</template>
+
+<script lang="ts">
+import { Component, Vue } from 'vue-property-decorator'
+import { AppModule } from '@/store/modules/app'
+import SidebarItem from './SidebarItem.vue'
+import variables from '@/styles/_variables.scss'
+
+@Component({
+  name: 'SideBar',
+  components: {
+    SidebarItem
+  }
+})
+export default class extends Vue {
+  get sidebar() {
+    return AppModule.sidebar
+  }
+
+  get routes() {
+    return (this.$router as any).options.routes
+  }
+
+  get variables() {
+    return variables
+  }
+
+  get activeMenu() {
+    const route = this.$route
+    const { meta, path } = route
+    if (meta.activeMenu) {
+      return meta.activeMenu
+    }
+    return path
+  }
+
+  get isCollapse() {
+    return !this.sidebar.opened
+  }
+}
+</script>
+
+<style lang="scss">
+.sidebar-container {
+  // reset element-ui css
+  .horizontal-collapse-transition {
+    transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
+  }
+
+  .scrollbar-wrapper {
+    overflow-x: hidden !important;
+  }
+
+  .el-scrollbar__view {
+    height: 100%
+  }
+
+  .el-scrollbar__bar {
+    &.is-vertical {
+      right: 0px;
+    }
+
+    &.is-horizontal {
+      display: none;
+    }
+  }
+}
+</style>
+
+<style lang="scss" scoped>
+.el-scrollbar {
+  height: 100%
+}
+
+.el-menu {
+  border: none;
+  height: 100%;
+  width: 100% !important;
+}
+</style>

+ 3 - 0
src/layout/components/index.ts

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

+ 129 - 0
src/layout/index.vue

@@ -0,0 +1,129 @@
+<template>
+  <div
+    :class="classObj"
+    class="app-wrapper"
+  >
+    <div
+      v-if="classObj.mobile && sidebar.opened"
+      class="drawer-bg"
+      @click="handleClickOutside"
+    />
+    <sidebar class="sidebar-container" />
+    <div class="main-container">
+      <navbar />
+      <app-main />
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { Component } from 'vue-property-decorator'
+import { mixins } from 'vue-class-component'
+import { DeviceType, AppModule } from '@/store/modules/app'
+import { AppMain, Navbar, Sidebar } from './components'
+import ResizeMixin from './mixin/resize'
+
+@Component({
+  name: 'Layout',
+  components: {
+    AppMain,
+    Navbar,
+    Sidebar
+  }
+})
+export default class extends mixins(ResizeMixin) {
+  get classObj() {
+    return {
+      hideSidebar: !this.sidebar.opened,
+      openSidebar: this.sidebar.opened,
+      withoutAnimation: this.sidebar.withoutAnimation,
+      mobile: this.device === DeviceType.Mobile
+    }
+  }
+
+  private handleClickOutside() {
+    AppModule.CloseSideBar(false)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-wrapper {
+  @include clearfix;
+  position: relative;
+  height: 100%;
+  width: 100%;
+}
+
+.drawer-bg {
+  background: #000;
+  opacity: 0.3;
+  width: 100%;
+  top: 0;
+  height: 100%;
+  position: absolute;
+  z-index: 999;
+}
+
+.main-container {
+  min-height: 100%;
+  transition: margin-left .28s;
+  margin-left: $sideBarWidth;
+  position: relative;
+}
+
+.sidebar-container {
+  transition: width 0.28s;
+  width: $sideBarWidth !important;
+  height: 100%;
+  position: fixed;
+  font-size: 0px;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 1001;
+  overflow: hidden;
+}
+
+.hideSidebar {
+  .main-container {
+    margin-left: 54px;
+  }
+
+  .sidebar-container {
+    width: 54px !important;
+  }
+}
+
+/* for mobile response 适配移动端 */
+.mobile {
+  .main-container {
+    margin-left: 0px;
+  }
+
+  .sidebar-container {
+    transition: transform .28s;
+    width: $sideBarWidth !important;
+  }
+
+  &.openSidebar {
+    position: fixed;
+    top: 0;
+  }
+
+  &.hideSidebar {
+    .sidebar-container {
+      pointer-events: none;
+      transition-duration: 0.3s;
+      transform: translate3d(-$sideBarWidth, 0, 0);
+    }
+  }
+}
+
+.withoutAnimation {
+  .main-container,
+  .sidebar-container {
+    transition: none;
+  }
+}
+</style>

+ 55 - 0
src/layout/mixin/resize.ts

@@ -0,0 +1,55 @@
+import { Component, Vue, Watch } from 'vue-property-decorator'
+import { AppModule, DeviceType } from '@/store/modules/app'
+
+const WIDTH = 992 // refer to Bootstrap's responsive design
+
+@Component({
+  name: 'ResizeMixin'
+})
+export default class extends Vue {
+  get device() {
+    return AppModule.device
+  }
+
+  get sidebar() {
+    return AppModule.sidebar
+  }
+
+  @Watch('$route')
+  private onRouteChange() {
+    if (this.device === DeviceType.Mobile && this.sidebar.opened) {
+      AppModule.CloseSideBar(false)
+    }
+  }
+
+  beforeMount() {
+    window.addEventListener('resize', this.resizeHandler)
+  }
+
+  mounted() {
+    const isMobile = this.isMobile()
+    if (isMobile) {
+      AppModule.ToggleDevice(DeviceType.Mobile)
+      AppModule.CloseSideBar(true)
+    }
+  }
+
+  beforeDestroy() {
+    window.removeEventListener('resize', this.resizeHandler)
+  }
+
+  private isMobile() {
+    const rect = document.body.getBoundingClientRect()
+    return rect.width - 1 < WIDTH
+  }
+
+  private resizeHandler() {
+    if (!document.hidden) {
+      const isMobile = this.isMobile()
+      AppModule.ToggleDevice(isMobile ? DeviceType.Mobile : DeviceType.Desktop)
+      if (isMobile) {
+        AppModule.CloseSideBar(true)
+      }
+    }
+  }
+}

+ 29 - 0
src/main.ts

@@ -0,0 +1,29 @@
+import Vue from 'vue'
+
+import 'normalize.css'
+import ElementUI from 'element-ui'
+import SvgIcon from 'vue-svgicon'
+
+import '@/styles/element-variables.scss'
+import '@/styles/index.scss'
+
+import App from '@/App.vue'
+import store from '@/store'
+import router from '@/router'
+import '@/icons/components'
+import '@/permission'
+
+Vue.use(ElementUI)
+Vue.use(SvgIcon, {
+  tagName: 'svg-icon',
+  defaultWidth: '1em',
+  defaultHeight: '1em'
+})
+
+Vue.config.productionTip = false
+
+new Vue({
+  router,
+  store,
+  render: (h) => h(App)
+}).$mount('#app')

+ 60 - 0
src/permission.ts

@@ -0,0 +1,60 @@
+import router from './router'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+import { Message } from 'element-ui'
+import { Route } from 'vue-router'
+import { UserModule } from '@/store/modules/user'
+
+NProgress.configure({ showSpinner: false })
+
+const whiteList = ['/login']
+
+router.beforeEach(async(to: Route, _: Route, next: any) => {
+  // Start progress bar
+  NProgress.start()
+
+  // Determine whether the user has logged in
+  if (UserModule.token) {
+    if (to.path === '/login') {
+      // If is logged in, redirect to the home page
+      next({ path: '/' })
+      NProgress.done()
+    } else {
+      // Check whether the user has obtained his permission roles
+      if (UserModule.roles.length === 0) {
+        try {
+          // Get user info, including roles
+          await UserModule.GetUserInfo()
+          // Set the replace: true, so the navigation will not leave a history record
+          next({ ...to, replace: true })
+        } catch (err) {
+          // Remove token and redirect to login page
+          UserModule.ResetToken()
+          Message.error(err || 'Has Error')
+          next(`/login?redirect=${to.path}`)
+          NProgress.done()
+        }
+      } else {
+        next()
+      }
+    }
+  } else {
+    // Has no token
+    if (whiteList.indexOf(to.path) !== -1) {
+      // In the free login whitelist, go directly
+      next()
+    } else {
+      // Other pages that do not have permission to access are redirected to the login page.
+      next(`/login?redirect=${to.path}`)
+      NProgress.done()
+    }
+  }
+})
+
+router.afterEach((to: Route) => {
+  // Finish progress bar
+  NProgress.done()
+
+  // set page title
+  document.title = to.meta.title
+})

+ 32 - 0
src/registerServiceWorker.ts

@@ -0,0 +1,32 @@
+/* eslint-disable no-console */
+
+import { register } from 'register-service-worker'
+
+if (process.env.NODE_ENV === 'production') {
+  register(`${process.env.BASE_URL}service-worker.js`, {
+    ready() {
+      console.log(
+        'App is being served from cache by a service worker.\n' +
+        'For more details, visit https://goo.gl/AFskqB'
+      )
+    },
+    registered() {
+      console.log('Service worker has been registered.')
+    },
+    cached() {
+      console.log('Content has been cached for offline use.')
+    },
+    updatefound() {
+      console.log('New content is downloading.')
+    },
+    updated() {
+      console.log('New content is available; please refresh.')
+    },
+    offline() {
+      console.log('No internet connection found. App is running in offline mode.')
+    },
+    error(error) {
+      console.error('Error during service worker registration:', error)
+    }
+  })
+}

+ 157 - 0
src/router.ts

@@ -0,0 +1,157 @@
+import Vue from 'vue'
+import Router from 'vue-router'
+import Layout from '@/layout/index.vue'
+
+Vue.use(Router)
+
+/*
+  redirect:                     如果设置为“noredirect”,则单击面包屑时不会触发重定向操作
+  meta: {
+    title: 'title'               子菜单和面包屑中显示的名称(推荐)
+    icon: 'svg-name'             侧边栏中显示的图标
+    breadcrumb: false            如果为false,则项目将隐藏在breadcrumb中(默认值为true)
+    hidden: true                 如果为true,此路由将不会显示在侧边栏中(默认为false)
+  }
+*/
+
+export default new Router({
+  mode: 'history',
+  scrollBehavior: (to, from, savedPosition) => {
+    if (savedPosition) {
+      return savedPosition
+    } else {
+      return {x: 0, y: 0}
+    }
+  },
+  base: process.env.BASE_URL,
+  routes: [
+    {
+      path: '/login',
+      component: () => import(/* webpackChunkName: "login" */ '@/views/login/index.vue'),
+      meta: {hidden: true}
+    },
+    {
+      path: '/404',
+      component: () => import(/* webpackChunkName: "404" */ '@/views/404.vue'),
+      meta: {hidden: true}
+    },
+    {
+      path: '/',
+      redirect: '/project/index',
+      component: Layout,
+      children: [
+        {
+          path: 'project/index',
+          component: () => import(/* webpackChunkName: "project" */ '@/views/project/index.vue'),
+          meta: {
+            title: '项目管理',
+            icon: 'form'
+          }
+        }
+      ]
+    },
+    {
+      path: '/manage',
+      component: Layout,
+      meta: {
+        title: '文件管理',
+        icon: 'example'
+      },
+      children: [
+        {
+          path: 'build',
+          component: () => import(/* webpackChunkName: "build" */ '@/views/manage/build/index.vue'),
+          meta: {
+            title: '建筑楼层管理',
+            icon: 'tree'
+          }
+        },
+        {
+          path: 'model',
+          component: () => import(/* webpackChunkName: "model" */ '@/views/manage/model/index.vue'),
+          meta: {
+            title: '模型修改任务',
+            icon: 'table'
+          }
+        }
+      ]
+    }, {
+      path: '/maintain',
+      component: Layout,
+      redirect: '/maintain/device',
+      meta: {
+        title: '数据维护',
+        icon: 'nested'
+      },
+      children: [
+        {
+          path: 'device',
+          component: () => import(/* webpackChunkName: "device" */ '@/views/maintain/device/index.vue'),
+          meta: {title: '设备'},
+        },
+        {
+          path: 'space',
+          component: () => import(/* webpackChunkName: "space" */ '@/views/maintain/space/index.vue'),
+          meta: {title: '空间'}
+        },
+        {
+          path: 'system',
+          component: () => import(/* webpackChunkName: "system" */ '@/views/maintain/system/index.vue'),
+          meta: {title: '系统'}
+        },
+        {
+          path: 'relationship',
+          component: () => import(/* webpackChunkName: "relationship" */ '@/views/maintain/relationship/index.vue'),
+          meta: {title: '关系'}
+        }
+      ]
+    },
+
+    {
+      path: '/scene',
+      component: Layout,
+      meta: {
+        title: '现场实施',
+        icon: 'nested'
+      },
+      children: [
+        {
+          path: 'tiepoint',
+          component: () => import(/* webpackChunkName: "tiepoint" */ '@/views/scene/tiepoint/index.vue'),
+          meta: {title: '绑点'},
+        },
+        {
+          path: 'system',
+          component: () => import(/* webpackChunkName: "system" */ '@/views/scene/system/index.vue'),
+          meta: {title: '系统图'}
+        },
+        {
+          path: 'plane',
+          component: () => import(/* webpackChunkName: "plane" */ '@/views/scene/plane/index.vue'),
+          meta: {title: '平面图'}
+        }
+      ]
+    },
+    {
+      path: '/tools',
+      component: Layout,
+      meta: {
+        title: '一致性检查工具',
+        icon: 'nested'
+      },
+    },
+    {
+      path: '/model-tool',
+      component: Layout,
+      meta: {
+        title: '模型检查工具',
+        icon: 'nested'
+      },
+    },
+    {
+      path: '*',
+      redirect: '/404',
+      meta: {hidden: true}
+    }
+  ]
+})

+ 4 - 0
src/shims-vue.d.ts

@@ -0,0 +1,4 @@
+declare module '*.vue' {
+  import Vue from 'vue'
+  export default Vue
+}

+ 14 - 0
src/store/index.ts

@@ -0,0 +1,14 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import { IAppState } from './modules/app'
+import { IUserState } from './modules/user'
+
+Vue.use(Vuex)
+
+export interface IRootState {
+  app: IAppState
+  user: IUserState
+}
+
+// Declare empty store first, dynamically register all modules later.
+export default new Vuex.Store<IRootState>({})

+ 66 - 0
src/store/modules/app.ts

@@ -0,0 +1,66 @@
+import { VuexModule, Module, Mutation, Action, getModule } from 'vuex-module-decorators'
+import { getSidebarStatus, setSidebarStatus } from '@/utils/cookies'
+import store from '@/store'
+
+export enum DeviceType {
+  Mobile,
+  Desktop,
+}
+
+export interface IAppState {
+  device: DeviceType
+  sidebar: {
+    opened: boolean
+    withoutAnimation: boolean
+  }
+}
+
+@Module({ dynamic: true, store, name: 'app' })
+class App extends VuexModule implements IAppState {
+  public sidebar = {
+    opened: getSidebarStatus() !== 'closed',
+    withoutAnimation: false
+  }
+
+  public device = DeviceType.Desktop
+
+  @Mutation
+  private TOGGLE_SIDEBAR(withoutAnimation: boolean) {
+    this.sidebar.opened = !this.sidebar.opened
+    this.sidebar.withoutAnimation = withoutAnimation
+    if (this.sidebar.opened) {
+      setSidebarStatus('opened')
+    } else {
+      setSidebarStatus('closed')
+    }
+  }
+
+  @Mutation
+  private CLOSE_SIDEBAR(withoutAnimation: boolean) {
+    this.sidebar.opened = false
+    this.sidebar.withoutAnimation = withoutAnimation
+    setSidebarStatus('closed')
+  }
+
+  @Mutation
+  private TOGGLE_DEVICE(device: DeviceType) {
+    this.device = device
+  }
+
+  @Action
+  public ToggleSideBar(withoutAnimation: boolean) {
+    this.TOGGLE_SIDEBAR(withoutAnimation)
+  }
+
+  @Action
+  public CloseSideBar(withoutAnimation: boolean) {
+    this.CLOSE_SIDEBAR(withoutAnimation)
+  }
+
+  @Action
+  public ToggleDevice(device: DeviceType) {
+    this.TOGGLE_DEVICE(device)
+  }
+}
+
+export const AppModule = getModule(App)

+ 95 - 0
src/store/modules/user.ts

@@ -0,0 +1,95 @@
+import { VuexModule, Module, Action, Mutation, getModule } from 'vuex-module-decorators'
+import { login, logout, getUserInfo } from '@/api/users'
+import { getToken, setToken, removeToken } from '@/utils/cookies'
+import store from '@/store'
+
+export interface IUserState {
+  token: string
+  name: string
+  avatar: string
+  introduction: string
+  roles: string[]
+}
+
+@Module({ dynamic: true, store, name: 'user' })
+class User extends VuexModule implements IUserState {
+  public token = getToken() || ''
+  public name = ''
+  public avatar = ''
+  public introduction = ''
+  public roles: string[] = []
+
+  @Mutation
+  private SET_TOKEN(token: string) {
+    this.token = token
+  }
+
+  @Mutation
+  private SET_NAME(name: string) {
+    this.name = name
+  }
+
+  @Mutation
+  private SET_AVATAR(avatar: string) {
+    this.avatar = avatar
+  }
+
+  @Mutation
+  private SET_INTRODUCTION(introduction: string) {
+    this.introduction = introduction
+  }
+
+  @Mutation
+  private SET_ROLES(roles: string[]) {
+    this.roles = roles
+  }
+
+  @Action
+  public async Login(userInfo: { username: string, password: string }) {
+    let { username, password } = userInfo
+    username = username.trim()
+    const { data } = await login({ username, password })
+    setToken(data.accessToken)
+    this.SET_TOKEN(data.accessToken)
+  }
+
+  @Action
+  public ResetToken() {
+    removeToken()
+    this.SET_TOKEN('')
+    this.SET_ROLES([])
+  }
+
+  @Action
+  public async GetUserInfo() {
+    if (this.token === '') {
+      throw Error('GetUserInfo: token is undefined!')
+    }
+    const { data } = await getUserInfo({ /* Your params here */ })
+    if (!data) {
+      throw Error('Verification failed, please Login again.')
+    }
+    const { roles, name, avatar, introduction } = data.user
+    // roles must be a non-empty array
+    if (!roles || roles.length <= 0) {
+      throw Error('GetUserInfo: roles must be a non-null array!')
+    }
+    this.SET_ROLES(roles)
+    this.SET_NAME(name)
+    this.SET_AVATAR(avatar)
+    this.SET_INTRODUCTION(introduction)
+  }
+
+  @Action
+  public async LogOut() {
+    if (this.token === '') {
+      throw Error('LogOut: token is undefined!')
+    }
+    await logout()
+    removeToken()
+    this.SET_TOKEN('')
+    this.SET_ROLES([])
+  }
+}
+
+export const UserModule = getModule(User)

+ 8 - 0
src/styles/_mixins.scss

@@ -0,0 +1,8 @@
+/* Mixins */
+@mixin clearfix {
+  &:after {
+    content: "";
+    display: table;
+    clear: both;
+  }
+}

+ 31 - 0
src/styles/_svgicon.scss

@@ -0,0 +1,31 @@
+/* Recommended css code for vue-svgicon */
+.svg-icon {
+    display: inline-block;
+    width: 16px;
+    height: 16px;
+    color: inherit;
+    fill: none;
+    stroke: currentColor;
+    vertical-align: -0.15em;
+}
+
+.svg-fill {
+    fill: currentColor;
+    stroke: none;
+}
+
+.svg-up {
+    transform: rotate(0deg);
+}
+
+.svg-right {
+    transform: rotate(90deg);
+}
+
+.svg-down {
+    transform: rotate(180deg);
+}
+
+.svg-left {
+    transform: rotate(-90deg);
+}

+ 49 - 0
src/styles/_transition.scss

@@ -0,0 +1,49 @@
+/* Global transition */
+// See https://vuejs.org/v2/guide/transitions.html for detail
+
+// fade
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.28s;
+}
+
+.fade-enter,
+.fade-leave-active {
+  opacity: 0;
+}
+
+// fade-transform
+.fade-transform-leave-active,
+.fade-transform-enter-active {
+  transition: all .5s;
+}
+
+.fade-transform-enter {
+  opacity: 0;
+  transform: translateX(-30px);
+}
+
+.fade-transform-leave-to {
+  opacity: 0;
+  transform: translateX(30px);
+}
+
+// breadcrumb
+.breadcrumb-enter-active,
+.breadcrumb-leave-active {
+  transition: all .5s;
+}
+
+.breadcrumb-enter,
+.breadcrumb-leave-active {
+  opacity: 0;
+  transform: translateX(20px);
+}
+
+.breadcrumb-move {
+  transition: all .5s;
+}
+
+.breadcrumb-leave-active {
+  position: absolute;
+}

+ 34 - 0
src/styles/_variables.scss

@@ -0,0 +1,34 @@
+/* Variables */
+
+// Base color
+$blue:#324157;
+$light-blue:#3A71A8;
+$red:#C03639;
+$pink: #E65D6E;
+$green: #30B08F;
+$tiffany: #4AB7BD;
+$yellow:#FEC171;
+$panGreen: #30B08F;
+
+// Sidebar
+$sideBarWidth: 210px;
+$subMenuBg:#1f2d3d;
+$subMenuHover:#001528;
+$subMenuActiveText:#f4f4f5;
+$menuBg:#304156;
+$menuText:#bfcbd9;
+$menuActiveText:#409EFF; // Also see settings.sidebarTextTheme
+
+// Login page
+$lightGray: #eee;
+$darkGray:#889aa4;
+$loginBg: #2d3a4b;
+$loginCursorColor: #fff;
+
+// The :export directive is the magic sauce for webpack
+// https://mattferderer.com/use-sass-variables-in-typescript-and-javascript
+:export {
+  menuBg: $menuBg;
+  menuText: $menuText;
+  menuActiveText: $menuActiveText;
+}

+ 9 - 0
src/styles/_variables.scss.d.ts

@@ -0,0 +1,9 @@
+export interface IScssVariables {
+  menuBg: string
+  menuText: string
+  menuActiveText: string
+}
+
+export const variables: IScssVariables
+
+export default variables

+ 19 - 0
src/styles/element-variables.scss

@@ -0,0 +1,19 @@
+/* Element Variables */
+
+// Override Element UI variables
+$--color-primary: #1890ff;
+$--color-success: #13ce66;
+$--color-warning: #FFBA00;
+$--color-danger: #ff4949;
+$--color-info: #5d5d5d;
+$--button-font-weight: 400;
+$--color-text-regular: #1f2d3d;
+$--border-color-light: #dfe4ed;
+$--border-color-lighter: #e6ebf5;
+$--table-border:1px solid#dfe6ec;
+
+// Icon font path, required
+$--font-path: '~element-ui/lib/theme-chalk/fonts';
+
+// Apply overrided variables in Element UI
+@import '~element-ui/packages/theme-chalk/src/index';

+ 47 - 0
src/styles/index.scss

@@ -0,0 +1,47 @@
+// @import './variables.scss'; // Already imported in style-resources-loader
+// @import './mixins.scss'; // Already imported in style-resources-loader
+@import './transition.scss';
+@import './svgicon.scss';
+
+/* Global scss */
+
+body {
+  height: 100%;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-font-smoothing: antialiased;
+  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
+}
+
+html {
+  height: 100%;
+}
+
+#app {
+  height: 100%;
+}
+
+*,
+*:before,
+*:after {
+  box-sizing: border-box;
+}
+
+a,
+a:focus,
+a:hover {
+  color: inherit;
+  outline: none;
+  text-decoration: none;
+}
+
+div:focus {
+  outline: none;
+}
+
+.clearfix {
+  @include clearfix;
+}
+
+.app-container {
+  padding: 20px;
+}

+ 12 - 0
src/utils/cookies.ts

@@ -0,0 +1,12 @@
+import Cookies from 'js-cookie'
+
+// App
+const sidebarStatusKey = 'sidebar_status'
+export const getSidebarStatus = () => Cookies.get(sidebarStatusKey)
+export const setSidebarStatus = (sidebarStatus: string) => Cookies.set(sidebarStatusKey, sidebarStatus)
+
+// User
+const tokenKey = 'vue_typescript_admin_access_token'
+export const getToken = () => Cookies.get(tokenKey)
+export const setToken = (token: string) => Cookies.set(tokenKey, token)
+export const removeToken = () => Cookies.remove(tokenKey)

+ 71 - 0
src/utils/request.ts

@@ -0,0 +1,71 @@
+import axios from 'axios'
+import { Message, MessageBox } from 'element-ui'
+import { UserModule } from '@/store/modules/user'
+
+const service = axios.create({
+  baseURL: process.env.VUE_APP_BASE_API,
+  timeout: 5000
+})
+
+// Request interceptors
+service.interceptors.request.use(
+  (config) => {
+    // Add X-Access-Token header to every request, you can add other custom headers here
+    if (UserModule.token) {
+      config.headers['X-Access-Token'] = UserModule.token
+    }
+    return config
+  },
+  (error) => {
+    Promise.reject(error)
+  }
+)
+
+// Response interceptors
+service.interceptors.response.use(
+  (response) => {
+    // Some example codes here:
+    // code == 20000: success
+    // code == 50001: invalid access token
+    // code == 50002: already login in other place
+    // code == 50003: access token expired
+    // code == 50004: invalid user (user not exist)
+    // code == 50005: username or password is incorrect
+    // You can change this part for your own usage.
+    const res = response.data
+    if (res.code !== 20000) {
+      Message({
+        message: res.message || 'Error',
+        type: 'error',
+        duration: 5 * 1000
+      })
+      if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
+        MessageBox.confirm(
+          'You have been logged out, try to login again.',
+          'Log out',
+          {
+            confirmButtonText: 'Relogin',
+            cancelButtonText: 'Cancel',
+            type: 'warning'
+          }
+        ).then(() => {
+          UserModule.ResetToken()
+          location.reload() // To prevent bugs from vue-router
+        })
+      }
+      return Promise.reject(new Error(res.message || 'Error'))
+    } else {
+      return response.data
+    }
+  },
+  (error) => {
+    Message({
+      message: error.message,
+      type: 'error',
+      duration: 5 * 1000
+    })
+    return Promise.reject(error)
+  }
+)
+
+export default service

+ 3 - 0
src/utils/validate.ts

@@ -0,0 +1,3 @@
+export const isValidUsername = (str: string) => ['admin', 'editor'].indexOf(str.trim()) >= 0
+
+export const isExternal = (path: string) => /^(https?:|mailto:|tel:)/.test(path)

+ 283 - 0
src/views/404.vue

@@ -0,0 +1,283 @@
+<template>
+  <div class="wscn-http404-container">
+    <div class="wscn-http404">
+      <div class="pic-404">
+        <img
+          class="pic-404__parent"
+          src="@/assets/404-images/404.png"
+          alt="404"
+        >
+        <img
+          class="pic-404__child left"
+          src="@/assets/404-images/404-cloud.png"
+          alt="404"
+        >
+        <img
+          class="pic-404__child mid"
+          src="@/assets/404-images/404-cloud.png"
+          alt="404"
+        >
+        <img
+          class="pic-404__child right"
+          src="@/assets/404-images/404-cloud.png"
+          alt="404"
+        >
+      </div>
+      <div class="text-404">
+        <div class="text-404__oops">
+          OOPS!
+        </div>
+        <div class="text-404__info">
+          All rights reserved
+          <a
+            style="color:#20a0ff"
+            href="https://wallstreetcn.com"
+            target="_blank"
+          >wallstreetcn</a>
+        </div>
+        <div class="text-404__headline">
+          {{ message }}
+        </div>
+        <div class="text-404__info">
+          Please check that the URL you entered is correct, or click the button below to return to the homepage.
+        </div>
+        <a
+          href=""
+          class="text-404__return-home"
+        >Back to home</a>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { Component, Vue } from 'vue-property-decorator'
+
+@Component({
+  name: 'Page404'
+})
+export default class extends Vue {
+  private message = '404 Page Not Found'
+}
+</script>
+
+<style lang="scss" scoped>
+.wscn-http404-container {
+  transform: translate(-50%,-50%);
+  position: absolute;
+  top: 40%;
+  left: 50%;
+}
+
+.wscn-http404 {
+  position: relative;
+  width: 1200px;
+  padding: 0 50px;
+  overflow: hidden;
+
+  .pic-404 {
+    position: relative;
+    float: left;
+    width: 600px;
+    overflow: hidden;
+
+    &__parent {
+      width: 100%;
+    }
+
+    &__child {
+      position: absolute;
+
+      &.left {
+        width: 80px;
+        top: 17px;
+        left: 220px;
+        opacity: 0;
+        animation-name: cloudLeft;
+        animation-duration: 2s;
+        animation-timing-function: linear;
+        animation-fill-mode: forwards;
+        animation-delay: 1s;
+      }
+
+      &.mid {
+        width: 46px;
+        top: 10px;
+        left: 420px;
+        opacity: 0;
+        animation-name: cloudMid;
+        animation-duration: 2s;
+        animation-timing-function: linear;
+        animation-fill-mode: forwards;
+        animation-delay: 1.2s;
+      }
+
+      &.right {
+        width: 62px;
+        top: 100px;
+        left: 500px;
+        opacity: 0;
+        animation-name: cloudRight;
+        animation-duration: 2s;
+        animation-timing-function: linear;
+        animation-fill-mode: forwards;
+        animation-delay: 1s;
+      }
+
+      @keyframes cloudLeft {
+        0% {
+          top: 17px;
+          left: 220px;
+          opacity: 0;
+        }
+
+        20% {
+          top: 33px;
+          left: 188px;
+          opacity: 1;
+        }
+
+        80% {
+          top: 81px;
+          left: 92px;
+          opacity: 1;
+        }
+
+        100% {
+          top: 97px;
+          left: 60px;
+          opacity: 0;
+        }
+      }
+
+      @keyframes cloudMid {
+        0% {
+          top: 10px;
+          left: 420px;
+          opacity: 0;
+        }
+
+        20% {
+          top: 40px;
+          left: 360px;
+          opacity: 1;
+        }
+
+        70% {
+          top: 130px;
+          left: 180px;
+          opacity: 1;
+        }
+
+        100% {
+          top: 160px;
+          left: 120px;
+          opacity: 0;
+        }
+      }
+
+      @keyframes cloudRight {
+        0% {
+          top: 100px;
+          left: 500px;
+          opacity: 0;
+        }
+
+        20% {
+          top: 120px;
+          left: 460px;
+          opacity: 1;
+        }
+
+        80% {
+          top: 180px;
+          left: 340px;
+          opacity: 1;
+        }
+
+        100% {
+          top: 200px;
+          left: 300px;
+          opacity: 0;
+        }
+      }
+    }
+  }
+
+  .text-404 {
+    position: relative;
+    float: left;
+    width: 300px;
+    padding: 30px 0;
+    overflow: hidden;
+
+    &__oops {
+      font-size: 32px;
+      font-weight: bold;
+      line-height: 40px;
+      color: #1482f0;
+      opacity: 0;
+      margin-bottom: 20px;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-fill-mode: forwards;
+    }
+
+    &__headline {
+      font-size: 20px;
+      line-height: 24px;
+      color: #222;
+      font-weight: bold;
+      opacity: 0;
+      margin-bottom: 10px;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-delay: 0.1s;
+      animation-fill-mode: forwards;
+    }
+
+    &__info {
+      font-size: 13px;
+      line-height: 21px;
+      color: grey;
+      opacity: 0;
+      margin-bottom: 30px;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-delay: 0.2s;
+      animation-fill-mode: forwards;
+    }
+
+    &__return-home {
+      display: block;
+      float: left;
+      width: 110px;
+      height: 36px;
+      background: #1482f0;
+      border-radius: 100px;
+      text-align: center;
+      color: #ffffff;
+      opacity: 0;
+      font-size: 14px;
+      line-height: 36px;
+      cursor: pointer;
+      animation-name: slideUp;
+      animation-duration: 0.5s;
+      animation-delay: 0.3s;
+      animation-fill-mode: forwards;
+    }
+
+    @keyframes slideUp {
+      0% {
+        transform: translateY(60px);
+        opacity: 0;
+      }
+
+      100% {
+        transform: translateY(0);
+        opacity: 1;
+      }
+    }
+  }
+}
+</style>

+ 275 - 0
src/views/login/index.vue

@@ -0,0 +1,275 @@
+<template>
+  <div class="login-container">
+    <el-form
+      ref="loginForm"
+      :model="loginForm"
+      :rules="loginRules"
+      class="login-form"
+      autocomplete="on"
+      label-position="left"
+    >
+      <div class="title-container">
+        <h3 class="title">
+          Login Form
+        </h3>
+      </div>
+
+      <el-form-item prop="username">
+        <span class="svg-container">
+          <svg-icon name="user" />
+        </span>
+        <el-input
+          ref="username"
+          v-model="loginForm.username"
+          name="username"
+          type="text"
+          autocomplete="on"
+          placeholder="username"
+        />
+      </el-form-item>
+
+      <el-form-item prop="password">
+        <span class="svg-container">
+          <svg-icon name="password" />
+        </span>
+        <el-input
+          :key="passwordType"
+          ref="password"
+          v-model="loginForm.password"
+          :type="passwordType"
+          placeholder="password"
+          name="password"
+          autocomplete="on"
+          @keyup.enter.native="handleLogin"
+        />
+        <span
+          class="show-pwd"
+          @click="showPwd"
+        >
+          <svg-icon :name="passwordType === 'password' ? 'eye-off' : 'eye-on'" />
+        </span>
+      </el-form-item>
+
+      <el-button
+        :loading="loading"
+        type="primary"
+        style="width:100%; margin-bottom:30px;"
+        @click.native.prevent="handleLogin"
+      >
+        Sign in
+      </el-button>
+
+      <div style="position:relative">
+        <div class="tips">
+          <span> username: admin </span>
+          <span> password: any </span>
+        </div>
+      </div>
+    </el-form>
+  </div>
+</template>
+
+<script lang="ts">
+import { Component, Vue, Watch } from 'vue-property-decorator'
+import { Route } from 'vue-router'
+import { Dictionary } from 'vue-router/types/router'
+import { Form as ElForm, Input } from 'element-ui'
+import { UserModule } from '@/store/modules/user'
+import { isValidUsername } from '@/utils/validate'
+
+@Component({
+  name: 'Login'
+})
+export default class extends Vue {
+  private validateUsername = (rule: any, value: string, callback: Function) => {
+    if (!isValidUsername(value)) {
+      callback(new Error('Please enter the correct user name'))
+    } else {
+      callback()
+    }
+  }
+
+  private validatePassword = (rule: any, value: string, callback: Function) => {
+    if (value.length < 6) {
+      callback(new Error('The password can not be less than 6 digits'))
+    } else {
+      callback()
+    }
+  }
+
+  private loginForm = {
+    username: 'admin',
+    password: '111111'
+  }
+
+  private loginRules = {
+    username: [{ validator: this.validateUsername, trigger: 'blur' }],
+    password: [{ validator: this.validatePassword, trigger: 'blur' }]
+  }
+
+  private passwordType = 'password'
+  private loading = false
+  private showDialog = false
+  private redirect?: string
+  private otherQuery: Dictionary<string> = {}
+
+  @Watch('$route', { immediate: true })
+  private onRouteChange(route: Route) {
+    // TODO: remove the "as Dictionary<string>" hack after v4 release for vue-router
+    // See https://github.com/vuejs/vue-router/pull/2050 for details
+    const query = route.query as Dictionary<string>
+    if (query) {
+      this.redirect = query.redirect
+      this.otherQuery = this.getOtherQuery(query)
+    }
+  }
+
+  mounted() {
+    if (this.loginForm.username === '') {
+      (this.$refs.username as Input).focus()
+    } else if (this.loginForm.password === '') {
+      (this.$refs.password as Input).focus()
+    }
+  }
+
+  private showPwd() {
+    if (this.passwordType === 'password') {
+      this.passwordType = ''
+    } else {
+      this.passwordType = 'password'
+    }
+    this.$nextTick(() => {
+      (this.$refs.password as Input).focus()
+    })
+  }
+
+  private handleLogin() {
+    (this.$refs.loginForm as ElForm).validate(async(valid: boolean) => {
+      if (valid) {
+        this.loading = true
+        await UserModule.Login(this.loginForm)
+        this.$router.push({
+          path: this.redirect || '/',
+          query: this.otherQuery
+        })
+        // Just to simulate the time of the request
+        setTimeout(() => {
+          this.loading = false
+        }, 0.5 * 1000)
+      } else {
+        return false
+      }
+    })
+  }
+
+  private getOtherQuery(query: Dictionary<string>) {
+    return Object.keys(query).reduce((acc, cur) => {
+      if (cur !== 'redirect') {
+        acc[cur] = query[cur]
+      }
+      return acc
+    }, {} as Dictionary<string>)
+  }
+}
+</script>
+
+<style lang="scss">
+// References: https://www.zhangxinxu.com/wordpress/2018/01/css-caret-color-first-line/
+@supports (-webkit-mask: none) and (not (cater-color: $loginCursorColor)) {
+  .login-container .el-input {
+    input { color: $loginCursorColor; }
+    input::first-line { color: $lightGray; }
+  }
+}
+
+.login-container {
+  .el-input {
+    display: inline-block;
+    height: 47px;
+    width: 85%;
+
+    input {
+      height: 47px;
+      background: transparent;
+      border: 0px;
+      border-radius: 0px;
+      padding: 12px 5px 12px 15px;
+      color: $lightGray;
+      caret-color: $loginCursorColor;
+      -webkit-appearance: none;
+
+      &:-webkit-autofill {
+        box-shadow: 0 0 0px 1000px $loginBg inset !important;
+        -webkit-text-fill-color: #fff !important;
+      }
+    }
+  }
+
+  .el-form-item {
+    border: 1px solid rgba(255, 255, 255, 0.1);
+    background: rgba(0, 0, 0, 0.1);
+    border-radius: 5px;
+    color: #454545;
+  }
+}
+</style>
+
+<style lang="scss" scoped>
+.login-container {
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+  background-color: $loginBg;
+
+  .login-form {
+    position: relative;
+    width: 520px;
+    max-width: 100%;
+    padding: 160px 35px 0;
+    margin: 0 auto;
+    overflow: hidden;
+  }
+
+  .tips {
+    font-size: 14px;
+    color: #fff;
+    margin-bottom: 10px;
+
+    span {
+      &:first-of-type {
+        margin-right: 16px;
+      }
+    }
+  }
+
+  .svg-container {
+    padding: 6px 5px 6px 15px;
+    color: $darkGray;
+    vertical-align: middle;
+    width: 30px;
+    display: inline-block;
+  }
+
+  .title-container {
+    position: relative;
+
+    .title {
+      font-size: 26px;
+      color: $lightGray;
+      margin: 0px auto 40px auto;
+      text-align: center;
+      font-weight: bold;
+    }
+  }
+
+  .show-pwd {
+    position: absolute;
+    right: 10px;
+    top: 7px;
+    font-size: 16px;
+    color: $darkGray;
+    cursor: pointer;
+    user-select: none;
+  }
+}
+</style>

+ 13 - 0
src/views/maintain/device/index.vue

@@ -0,0 +1,13 @@
+<template>
+<div>设备</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/maintain/relationship/index.vue

@@ -0,0 +1,13 @@
+<template>
+<div>关系</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/maintain/space/index.vue

@@ -0,0 +1,13 @@
+<template>
+<div>空间</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/maintain/system/index.vue

@@ -0,0 +1,13 @@
+<template>
+<div>系统</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/manage/build/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div>建筑</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/manage/model/index.vue

@@ -0,0 +1,13 @@
+<template>
+<div>模型</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/project/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div>項目管理</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/scene/plane/index.vue

@@ -0,0 +1,13 @@
+<template>
+<div>平面图</div>
+</template>
+
+<script>
+export default {
+  name: "平面图"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/scene/system/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div>系统</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/views/scene/tiepoint/index.vue

@@ -0,0 +1,13 @@
+<template>
+<div>绑点</div>
+</template>
+
+<script>
+export default {
+  name: "index"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 41 - 0
tsconfig.json

@@ -0,0 +1,41 @@
+{
+  "compilerOptions": {
+    "target": "esnext",
+    "module": "esnext",
+    "strict": true,
+    "jsx": "preserve",
+    "importHelpers": true,
+    "moduleResolution": "node",
+    "experimentalDecorators": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "sourceMap": true,
+    "baseUrl": ".",
+    "types": [
+      "node",
+      "jest",
+      "webpack-env"
+    ],
+    "paths": {
+      "@/*": [
+        "src/*"
+      ]
+    },
+    "lib": [
+      "esnext",
+      "dom",
+      "dom.iterable",
+      "scripthost"
+    ]
+  },
+  "include": [
+    "src/**/*.ts",
+    "src/**/*.tsx",
+    "src/**/*.vue",
+    "tests/**/*.ts",
+    "tests/**/*.tsx"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}

+ 29 - 0
vue.config.js

@@ -0,0 +1,29 @@
+const path = require('path')
+const name = 'Vue Typescript Admin'
+
+module.exports = {
+  // TODO: Remember to change publicPath to fit your need
+  publicPath: process.env.NODE_ENV === 'production' ? '/vue-typescript-admin-template/' : '/',
+  lintOnSave: process.env.NODE_ENV === 'development',
+  pwa: {
+    name: name
+  },
+  pluginOptions: {
+    'style-resources-loader': {
+      preProcessor: 'scss',
+      patterns: [
+        path.resolve(__dirname, 'src/styles/_variables.scss'),
+        path.resolve(__dirname, 'src/styles/_mixins.scss')
+      ]
+    }
+  },
+  chainWebpack(config) {
+    // provide the app's title in html-webpack-plugin's options list so that
+    // it can be accessed in index.html to inject the correct title.
+    // https://cli.vuejs.org/guide/webpack.html#modifying-options-of-a-plugin
+    config.plugin('html').tap(args => {
+      args[0].title = name
+      return args
+    })
+  }
+}