Browse Source

[release]4.1.0 (#211)

花裤衩 6 years ago
parent
commit
a5d4bbda75
92 changed files with 1895 additions and 1475 deletions
  1. 0 12
      .babelrc
  2. 14 0
      .env.development
  3. 6 0
      .env.production
  4. 8 0
      .env.staging
  5. 2 1
      .eslintignore
  6. 5 3
      .eslintrc.js
  7. 1 0
      .gitignore
  8. 2 4
      .postcssrc.js
  9. 32 32
      README-zh.md
  10. 40 39
      README.md
  11. 5 0
      babel.config.js
  12. 0 45
      build/build.js
  13. 0 64
      build/check-versions.js
  14. 35 0
      build/index.js
  15. BIN
      build/logo.png
  16. 0 108
      build/utils.js
  17. 0 5
      build/vue-loader.conf.js
  18. 0 108
      build/webpack.base.conf.js
  19. 0 95
      build/webpack.dev.conf.js
  20. 0 177
      build/webpack.prod.conf.js
  21. 0 8
      config/dev.env.js
  22. 0 86
      config/index.js
  23. 0 5
      config/prod.env.js
  24. 0 12
      index.html
  25. 24 0
      jest.config.js
  26. 61 21
      mock/index.js
  27. 64 0
      mock/mock-server.js
  28. 25 16
      mock/table.js
  29. 44 24
      mock/user.js
  30. 0 14
      mock/utils.js
  31. 39 62
      package.json
  32. 0 0
      public/favicon.ico
  33. 17 0
      public/index.html
  34. 1 1
      src/App.vue
  35. 2 5
      src/api/login.js
  36. 23 14
      src/components/Breadcrumb/index.vue
  37. 9 7
      src/components/Hamburger/index.vue
  38. 1 1
      src/components/SvgIcon/index.vue
  39. 2 2
      src/icons/index.js
  40. 1 0
      src/icons/svg/dashboard.svg
  41. 1 1
      src/icons/svg/eye-open.svg
  42. 1 1
      src/icons/svg/link.svg
  43. 1 1
      src/icons/svg/table.svg
  44. 8 6
      src/views/layout/components/AppMain.vue
  45. 139 0
      src/layout/components/Navbar.vue
  46. 26 0
      src/layout/components/Sidebar/FixiOSBug.js
  47. 8 9
      src/views/layout/components/Sidebar/Item.vue
  48. 1 1
      src/views/layout/components/Sidebar/Link.vue
  49. 82 0
      src/layout/components/Sidebar/Logo.vue
  50. 11 7
      src/views/layout/components/Sidebar/SidebarItem.vue
  51. 54 0
      src/layout/components/Sidebar/index.vue
  52. 0 0
      src/layout/components/index.js
  53. 30 6
      src/views/layout/Layout.vue
  54. 45 0
      src/layout/mixin/ResizeHandler.js
  55. 7 7
      src/main.js
  56. 50 19
      src/permission.js
  57. 58 24
      src/router/index.js
  58. 16 0
      src/settings.js
  59. 2 1
      src/store/getters.js
  60. 5 1
      src/store/index.js
  61. 39 34
      src/store/modules/app.js
  62. 69 0
      src/store/modules/permission.js
  63. 31 0
      src/store/modules/settings.js
  64. 85 70
      src/store/modules/user.js
  65. 17 3
      src/styles/element-ui.scss
  66. 5 18
      src/styles/index.scss
  67. 15 8
      src/styles/sidebar.scss
  68. 4 4
      src/styles/transition.scss
  69. 1 1
      src/styles/variables.scss
  70. 10 0
      src/utils/get-page-title.js
  71. 90 0
      src/utils/index.js
  72. 40 28
      src/utils/request.js
  73. 14 6
      src/utils/validate.js
  74. 6 6
      src/views/404.vue
  75. 3 3
      src/views/dashboard/index.vue
  76. 13 13
      src/views/form/index.vue
  77. 0 95
      src/views/layout/components/Navbar.vue
  78. 0 39
      src/views/layout/components/Sidebar/index.vue
  79. 0 40
      src/views/layout/mixin/ResizeHandler.js
  80. 88 47
      src/views/login/index.vue
  81. 1 1
      src/views/nested/menu1/index.vue
  82. 1 1
      src/views/nested/menu1/menu1-1/index.vue
  83. 4 3
      src/views/table/index.vue
  84. 0 0
      static/.gitkeep
  85. 5 0
      tests/unit/.eslintrc.js
  86. 98 0
      tests/unit/components/Breadcrumb.spec.js
  87. 18 0
      tests/unit/components/Hamburger.spec.js
  88. 22 0
      tests/unit/components/SvgIcon.spec.js
  89. 30 0
      tests/unit/utils/formatTime.spec.js
  90. 28 0
      tests/unit/utils/parseTime.spec.js
  91. 17 0
      tests/unit/utils/validate.spec.js
  92. 133 0
      vue.config.js

+ 0 - 12
.babelrc

@@ -1,12 +0,0 @@
-{
-  "presets": [
-    ["env", {
-      "modules": false,
-      "targets": {
-        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
-      }
-    }],
-    "stage-2"
-  ],
-  "plugins":["transform-vue-jsx", "transform-runtime"]
-}

+ 14 - 0
.env.development

@@ -0,0 +1,14 @@
+# just a flag
+ENV = 'development'
+
+# base api
+VUE_APP_BASE_API = '/dev-api'
+
+# vue-cli uses the VUE_CLI_BABEL_TRANSPILE_MODULES environment variable,
+# to control whether the babel-plugin-dynamic-import-node plugin is enabled.
+# It only does one thing by converting all import() to require().
+# This configuration can significantly increase the speed of hot updates,
+# when you have a large number of pages.
+# Detail:  https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/babel-preset-app/index.js
+
+VUE_CLI_BABEL_TRANSPILE_MODULES = true

+ 6 - 0
.env.production

@@ -0,0 +1,6 @@
+# just a flag
+ENV = 'production'
+
+# base api
+VUE_APP_BASE_API = '/prod-api'
+

+ 8 - 0
.env.staging

@@ -0,0 +1,8 @@
+NODE_ENV = production
+
+# just a flag
+ENV = 'staging'
+
+# base api
+VUE_APP_BASE_API = '/stage-api'
+

+ 2 - 1
.eslintignore

@@ -1,3 +1,4 @@
 build/*.js
-config/*.js
 src/assets
+public
+dist

+ 5 - 3
.eslintrc.js

@@ -21,7 +21,10 @@ module.exports = {
         "allowFirstLine": false
       }
     }],
+    "vue/singleline-html-element-content-newline": "off",
+    "vue/multiline-html-element-content-newline":"off",
     "vue/name-property-casing": ["error", "PascalCase"],
+    "vue/no-v-html": "off",
     'accessor-pairs': 2,
     'arrow-spacing': [2, {
       'before': true,
@@ -44,7 +47,7 @@ module.exports = {
     'curly': [2, 'multi-line'],
     'dot-location': [2, 'property'],
     'eol-last': 2,
-    'eqeqeq': [2, 'allow-null'],
+    'eqeqeq': ["error", "always", {"null": "ignore"}],
     'generator-star-spacing': [2, {
       'before': true,
       'after': true
@@ -73,7 +76,7 @@ module.exports = {
     'no-class-assign': 2,
     'no-cond-assign': 2,
     'no-const-assign': 2,
-    'no-control-regex': 2,
+    'no-control-regex': 0,
     'no-delete-var': 2,
     'no-dupe-args': 2,
     'no-dupe-class-members': 2,
@@ -193,4 +196,3 @@ module.exports = {
     'array-bracket-spacing': [2, 'never']
   }
 }
-

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@ npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
 package-lock.json
+tests/**/coverage/
 
 # Editor directories and files
 .idea

+ 2 - 4
.postcssrc.js

@@ -1,10 +1,8 @@
 // https://github.com/michael-ciniawsky/postcss-load-config
 
 module.exports = {
-  "plugins": {
-    "postcss-import": {},
-    "postcss-url": {},
+  'plugins': {
     // to edit target browsers: use "browserslist" field in package.json
-    "autoprefixer": {}
+    'autoprefixer': {}
   }
 }

+ 32 - 32
README-zh.md

@@ -1,19 +1,17 @@
 # vue-admin-template
 
-> 这是一个 极简的 vue admin 管理后台 它只包含了 Element UI & axios & iconfont & permission control & lint,这些搭建后台必要的东西。
+> 这是一个极简的 vue admin 管理后台。它只包含了 Element UI & axios & iconfont & permission control & lint,这些搭建后台必要的东西。
 
 [线上地址](http://panjiachen.github.io/vue-admin-template)
 
 [国内访问](https://panjiachen.gitee.io/vue-admin-template)
 
+目前版本为 `v4.0+` 基于 `vue-cli` 进行构建,若发现问题,欢迎提 issue。若你想使用旧版本,可以切换分支到[tag/3.11.0](https://github.com/PanJiaChen/vue-admin-template/tree/tag/3.11.0),它不依赖 `vue-cli`。
+
 ## Extra
 
 如果你想要根据用户角色来动态生成侧边栏和 router,你可以使用该分支[permission-control](https://github.com/PanJiaChen/vue-admin-template/tree/permission-control)
 
-本项目基于`webpack4`开发,若还想使用`webpack3`开发,请使用该分支[webpack3](https://github.com/PanJiaChen/vue-admin-template/tree/webpack3)
-
-如果你想使用基于 vue + typescript 的管理后台, 可以看看这个项目: [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) (鸣谢: [@Armour](https://github.com/Armour))
-
 ## 相关项目
 
 [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
@@ -33,53 +31,55 @@
 ## Build Setup
 
 ```bash
-# Clone project
+# 克隆项目
 git clone https://github.com/PanJiaChen/vue-admin-template.git
 
-# Install dependencies
+# 进入项目目录
+cd vue-admin-template
+
+# 安装依赖
 npm install
 
-# 建议不要用cnpm  安装有各种诡异的bug 可以通过如下操作解决npm速度慢的问题
+# 建议不要直接使用 cnpm 安装以来,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题
 npm install --registry=https://registry.npm.taobao.org
 
-# Serve with hot reload at localhost:9528
+# 启动服务
 npm run dev
+```
 
-# Build for production with minification
-npm run build
+浏览器访问 [http://localhost:9528](http://localhost:9528)
 
-# Build for production and view the bundle analyzer report
-npm run build --report
-```
+## 发布
 
-## Demo
+```bash
+# 构建测试环境
+npm run build:stage
 
-![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif)
+# 构建生产环境
+npm run build:prod
+```
 
-### Element-Ui 使用 cdn 教程
+## 其它
 
-首先找到 `index.html` ([根目录下](https://github.com/PanJiaChen/vue-admin-template/blob/element-ui-cdn/index.html))
+```bash
+# 预览发布环境效果
+npm run preview
 
-引入 Element 的 css 和 js ,并且引入 vue 。因为 Element-Ui 是依赖 vue 的,所以必须在它之前引入 vue 。
+# 预览发布环境效果 + 静态资源分析
+npm run preview -- --report
 
-之后找到 [webpack.base.conf.js](https://github.com/PanJiaChen/vue-admin-template/blob/element-ui-cdn/build/webpack.base.conf.js) 加入 `externals` 让 webpack 不打包 vue 和 element
+# 代码格式检查
+npm run lint
 
-```
-externals: {
-  vue: 'Vue',
-  'element-ui':'ELEMENT'
-}
+# 代码格式检查并自动修复
+npm run lint -- --fix
 ```
 
-之后还有一个小细节是如果你用了全局对象方式引入 vue,就不需要 手动 `Vue.use(Vuex)` ,它会自动挂载,具体见 [issue](https://github.com/vuejs/vuex/issues/731)
+更多信息请参考 [使用文档](https://panjiachen.github.io/vue-element-admin-site/zh/)
 
-最终你可以使用 `npm run build --report` 查看效果
-如图:
-![demo](https://panjiachen.github.io/images/element-cdn.png)
-
-**[具体代码](https://github.com/PanJiaChen/vue-admin-template/commit/746aff560932704ae821f82f10b8b2a9681d5177)**
+## Demo
 
-**[对应分支](https://github.com/PanJiaChen/vue-admin-template/tree/element-ui-cdn)**
+![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif)
 
 ## Browsers support
 

+ 40 - 39
README.md

@@ -1,30 +1,61 @@
 # vue-admin-template
 
+English | [简体中文](./README.zh-CN.md)
+
 > A minimal vue admin template with Element UI & axios & iconfont & permission control & lint
 
 **Live demo:** http://panjiachen.github.io/vue-admin-template
 
-[中文文档](https://github.com/PanJiaChen/vue-admin-template/blob/master/README-zh.md)
+
+**The current version is `v4.0+` build on `vue-cli`. If you want to use the old version , you can switch branch to [tag/3.11.0](https://github.com/PanJiaChen/vue-admin-template/tree/tag/3.11.0), it does not rely on `vue-cli'**
 
 ## Build Setup
 
+
 ```bash
-# Clone project
-git clone https://github.com/PanJiaChen/vue-admin-template.git
+# clone the project
+git clone https://github.com/PanJiaChen/vue-element-admin.git
+
+# enter the project directory
+cd vue-element-admin
 
-# Install dependencies
+# install dependency
 npm install
 
-# Serve with hot reload at localhost:9528
+# develop
 npm run dev
+```
+
+This will automatically open http://localhost:9527
+
+## Build
+
+```bash
+# build for test environment
+npm run build:stage
+
+# build for production environment
+npm run build:prod
+```
+
+## Advanced
+
+```bash
+# preview the release environment effect
+npm run preview
+
+# preview the release environment effect + static resource analysis
+npm run preview -- --report
 
-# Build for production with minification
-npm run build
+# code format check
+npm run lint
 
-# Build for production and view the bundle analyzer report
-npm run build --report
+# code format check and auto fix
+npm run lint -- --fix
 ```
 
+Refer to [Documentation](https://panjiachen.github.io/vue-element-admin-site/guide/essentials/deploy.html) for more information
+
 ## Demo
 
 ![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif)
@@ -33,8 +64,6 @@ npm run build --report
 
 If you want router permission && generate menu by user roles , you can use this branch [permission-control](https://github.com/PanJiaChen/vue-admin-template/tree/permission-control)
 
-This project is based on `webpack4` development. If you want to use `webpack3` development, please use this branch [webpack3](https://github.com/PanJiaChen/vue-admin-template/tree/webpack3)
-
 For `typescript` version, you can use [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) (Credits: [@Armour](https://github.com/Armour))
 
 ## Related Project
@@ -45,34 +74,6 @@ For `typescript` version, you can use [vue-typescript-admin-template](https://gi
 
 [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template)
 
-### Element-Ui using cdn tutorial
-
-First find `index.html`([root directory](https://github.com/PanJiaChen/vue-admin-template/blob/element-ui-cdn/index.html))
-
-Import css and js of `Element`, and then import vue. Because `Element` is vue-dependent, vue must be import before it.
-
-Then find [webpack.base.conf.js](https://github.com/PanJiaChen/vue-admin-template/blob/element-ui-cdn/build/webpack.base.conf.js)
-Add `externals` to make webpack not package vue and element.
-
-```
-externals: {
-  vue: 'Vue',
-  'element-ui':'ELEMENT'
-}
-```
-
-Finally there is a small detail to pay attention to that if you import vue in global, you don't need to manually `Vue.use(Vuex)`, it will be automatically mounted, see
-[issue](https://github.com/vuejs/vuex/issues/731)
-
-And you can use `npm run build --report` to see the effect
-
-Pictured:
-![demo](https://panjiachen.github.io/images/element-cdn.png)
-
-**[Detailed code](https://github.com/PanJiaChen/vue-admin-template/commit/746aff560932704ae821f82f10b8b2a9681d5177)**
-
-**[Branch](https://github.com/PanJiaChen/vue-admin-template/tree/element-ui-cdn)**
-
 ## Browsers support
 
 Modern browsers and Internet Explorer 10+.

+ 5 - 0
babel.config.js

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

+ 0 - 45
build/build.js

@@ -1,45 +0,0 @@
-'use strict'
-require('./check-versions')()
-
-process.env.NODE_ENV = 'production'
-
-const ora = require('ora')
-const rm = require('rimraf')
-const path = require('path')
-const chalk = require('chalk')
-const webpack = require('webpack')
-const config = require('../config')
-const webpackConfig = require('./webpack.prod.conf')
-
-const spinner = ora('building for production...')
-spinner.start()
-
-rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
-  if (err) throw err
-  webpack(webpackConfig, (err, stats) => {
-    spinner.stop()
-    if (err) throw err
-    process.stdout.write(
-      stats.toString({
-        colors: true,
-        modules: false,
-        children: false,
-        chunks: false,
-        chunkModules: false
-      }) + '\n\n'
-    )
-
-    if (stats.hasErrors()) {
-      console.log(chalk.red('  Build failed with errors.\n'))
-      process.exit(1)
-    }
-
-    console.log(chalk.cyan('  Build complete.\n'))
-    console.log(
-      chalk.yellow(
-        '  Tip: built files are meant to be served over an HTTP server.\n' +
-          "  Opening index.html over file:// won't work.\n"
-      )
-    )
-  })
-})

+ 0 - 64
build/check-versions.js

@@ -1,64 +0,0 @@
-'use strict'
-const chalk = require('chalk')
-const semver = require('semver')
-const packageConfig = require('../package.json')
-const shell = require('shelljs')
-
-function exec(cmd) {
-  return require('child_process')
-    .execSync(cmd)
-    .toString()
-    .trim()
-}
-
-const versionRequirements = [
-  {
-    name: 'node',
-    currentVersion: semver.clean(process.version),
-    versionRequirement: packageConfig.engines.node
-  }
-]
-
-if (shell.which('npm')) {
-  versionRequirements.push({
-    name: 'npm',
-    currentVersion: exec('npm --version'),
-    versionRequirement: packageConfig.engines.npm
-  })
-}
-
-module.exports = function() {
-  const warnings = []
-
-  for (let i = 0; i < versionRequirements.length; i++) {
-    const mod = versionRequirements[i]
-
-    if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
-      warnings.push(
-        mod.name +
-          ': ' +
-          chalk.red(mod.currentVersion) +
-          ' should be ' +
-          chalk.green(mod.versionRequirement)
-      )
-    }
-  }
-
-  if (warnings.length) {
-    console.log('')
-    console.log(
-      chalk.yellow(
-        'To use this template, you must update following to modules:'
-      )
-    )
-    console.log()
-
-    for (let i = 0; i < warnings.length; i++) {
-      const warning = warnings[i]
-      console.log('  ' + warning)
-    }
-
-    console.log()
-    process.exit(1)
-  }
-}

+ 35 - 0
build/index.js

@@ -0,0 +1,35 @@
+const { run } = require('runjs')
+const chalk = require('chalk')
+const config = require('../vue.config.js')
+const rawArgv = process.argv.slice(2)
+const args = rawArgv.join(' ')
+
+if (process.env.npm_config_preview || rawArgv.includes('--preview')) {
+  const report = rawArgv.includes('--report')
+
+  run(`vue-cli-service build ${args}`)
+
+  const port = 9526
+  const publicPath = config.publicPath
+
+  var connect = require('connect')
+  var serveStatic = require('serve-static')
+  const app = connect()
+
+  app.use(
+    publicPath,
+    serveStatic('./dist', {
+      index: ['index.html', '/']
+    })
+  )
+
+  app.listen(port, function () {
+    console.log(chalk.green(`> Preview at  http://localhost:${port}${publicPath}`))
+    if (report) {
+      console.log(chalk.green(`> Report at  http://localhost:${port}${publicPath}report.html`))
+    }
+
+  })
+} else {
+  run(`vue-cli-service build ${args}`)
+}

BIN
build/logo.png


+ 0 - 108
build/utils.js

@@ -1,108 +0,0 @@
-'use strict'
-const path = require('path')
-const config = require('../config')
-const MiniCssExtractPlugin = require('mini-css-extract-plugin')
-const packageConfig = require('../package.json')
-
-exports.assetsPath = function(_path) {
-  const assetsSubDirectory =
-    process.env.NODE_ENV === 'production'
-      ? config.build.assetsSubDirectory
-      : config.dev.assetsSubDirectory
-
-  return path.posix.join(assetsSubDirectory, _path)
-}
-
-exports.cssLoaders = function(options) {
-  options = options || {}
-
-  const cssLoader = {
-    loader: 'css-loader',
-    options: {
-      sourceMap: options.sourceMap
-    }
-  }
-
-  const postcssLoader = {
-    loader: 'postcss-loader',
-    options: {
-      sourceMap: options.sourceMap
-    }
-  }
-
-  // generate loader string to be used with extract text plugin
-  function generateLoaders(loader, loaderOptions) {
-    const loaders = []
-
-    // Extract CSS when that option is specified
-    // (which is the case during production build)
-    if (options.extract) {
-      loaders.push(MiniCssExtractPlugin.loader)
-    } else {
-      loaders.push('vue-style-loader')
-    }
-
-    loaders.push(cssLoader)
-
-    if (options.usePostCSS) {
-      loaders.push(postcssLoader)
-    }
-
-    if (loader) {
-      loaders.push({
-        loader: loader + '-loader',
-        options: Object.assign({}, loaderOptions, {
-          sourceMap: options.sourceMap
-        })
-      })
-    }
-
-    return loaders
-  }
-  // https://vue-loader.vuejs.org/en/configurations/extract-css.html
-  return {
-    css: generateLoaders(),
-    postcss: generateLoaders(),
-    less: generateLoaders('less'),
-    sass: generateLoaders('sass', {
-      indentedSyntax: true
-    }),
-    scss: generateLoaders('sass'),
-    stylus: generateLoaders('stylus'),
-    styl: generateLoaders('stylus')
-  }
-}
-
-// Generate loaders for standalone style files (outside of .vue)
-exports.styleLoaders = function(options) {
-  const output = []
-  const loaders = exports.cssLoaders(options)
-
-  for (const extension in loaders) {
-    const loader = loaders[extension]
-    output.push({
-      test: new RegExp('\\.' + extension + '$'),
-      use: loader
-    })
-  }
-
-  return output
-}
-
-exports.createNotifierCallback = () => {
-  const notifier = require('node-notifier')
-
-  return (severity, errors) => {
-    if (severity !== 'error') return
-
-    const error = errors[0]
-    const filename = error.file && error.file.split('!').pop()
-
-    notifier.notify({
-      title: packageConfig.name,
-      message: severity + ': ' + error.name,
-      subtitle: filename || '',
-      icon: path.join(__dirname, 'logo.png')
-    })
-  }
-}

+ 0 - 5
build/vue-loader.conf.js

@@ -1,5 +0,0 @@
-'use strict'
-
-module.exports = {
-  //You can set the vue-loader configuration by yourself.
-}

+ 0 - 108
build/webpack.base.conf.js

@@ -1,108 +0,0 @@
-'use strict'
-const path = require('path')
-const utils = require('./utils')
-const config = require('../config')
-const { VueLoaderPlugin } = require('vue-loader')
-const vueLoaderConfig = require('./vue-loader.conf')
-
-function resolve(dir) {
-  return path.join(__dirname, '..', dir)
-}
-
-const createLintingRule = () => ({
-  test: /\.(js|vue)$/,
-  loader: 'eslint-loader',
-  enforce: 'pre',
-  include: [resolve('src'), resolve('test')],
-  options: {
-    formatter: require('eslint-friendly-formatter'),
-    emitWarning: !config.dev.showEslintErrorsInOverlay
-  }
-})
-
-module.exports = {
-  context: path.resolve(__dirname, '../'),
-  entry: {
-    app: './src/main.js'
-  },
-  output: {
-    path: config.build.assetsRoot,
-    filename: '[name].js',
-    publicPath:
-      process.env.NODE_ENV === 'production'
-        ? config.build.assetsPublicPath
-        : config.dev.assetsPublicPath
-  },
-  resolve: {
-    extensions: ['.js', '.vue', '.json'],
-    alias: {
-      '@': resolve('src')
-    }
-  },
-  module: {
-    rules: [
-      ...(config.dev.useEslint ? [createLintingRule()] : []),
-      {
-        test: /\.vue$/,
-        loader: 'vue-loader',
-        options: vueLoaderConfig
-      },
-      {
-        test: /\.js$/,
-        loader: 'babel-loader',
-        include: [
-          resolve('src'),
-          resolve('test'),
-          resolve('mock'),
-          resolve('node_modules/webpack-dev-server/client')
-        ]
-      },
-      {
-        test: /\.svg$/,
-        loader: 'svg-sprite-loader',
-        include: [resolve('src/icons')],
-        options: {
-          symbolId: 'icon-[name]'
-        }
-      },
-      {
-        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
-        loader: 'url-loader',
-        exclude: [resolve('src/icons')],
-        options: {
-          limit: 10000,
-          name: utils.assetsPath('img/[name].[hash:7].[ext]')
-        }
-      },
-      {
-        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
-        loader: 'url-loader',
-        options: {
-          limit: 10000,
-          name: utils.assetsPath('media/[name].[hash:7].[ext]')
-        }
-      },
-      {
-        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
-        loader: 'url-loader',
-        options: {
-          limit: 10000,
-          name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
-        }
-      }
-    ]
-  },
-  plugins: [new VueLoaderPlugin()],
-  node: {
-    // prevent webpack from injecting useless setImmediate polyfill because Vue
-    // source contains it (although only uses it if it's native).
-    setImmediate: false,
-    // prevent webpack from injecting mocks to Node native modules
-    // that does not make sense for the client
-    dgram: 'empty',
-    fs: 'empty',
-    net: 'empty',
-    tls: 'empty',
-    child_process: 'empty'
-  }
-}

+ 0 - 95
build/webpack.dev.conf.js

@@ -1,95 +0,0 @@
-'use strict'
-const path = require('path')
-const utils = require('./utils')
-const webpack = require('webpack')
-const config = require('../config')
-const merge = require('webpack-merge')
-const baseWebpackConfig = require('./webpack.base.conf')
-const HtmlWebpackPlugin = require('html-webpack-plugin')
-const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
-const portfinder = require('portfinder')
-
-function resolve(dir) {
-  return path.join(__dirname, '..', dir)
-}
-
-const HOST = process.env.HOST
-const PORT = process.env.PORT && Number(process.env.PORT)
-
-const devWebpackConfig = merge(baseWebpackConfig, {
-  mode: 'development',
-  module: {
-    rules: utils.styleLoaders({
-      sourceMap: config.dev.cssSourceMap,
-      usePostCSS: true
-    })
-  },
-  // cheap-module-eval-source-map is faster for development
-  devtool: config.dev.devtool,
-
-  // these devServer options should be customized in /config/index.js
-  devServer: {
-    clientLogLevel: 'warning',
-    historyApiFallback: true,
-    hot: true,
-    compress: true,
-    host: HOST || config.dev.host,
-    port: PORT || config.dev.port,
-    open: config.dev.autoOpenBrowser,
-    overlay: config.dev.errorOverlay
-      ? { warnings: false, errors: true }
-      : false,
-    publicPath: config.dev.assetsPublicPath,
-    proxy: config.dev.proxyTable,
-    quiet: true, // necessary for FriendlyErrorsPlugin
-    watchOptions: {
-      poll: config.dev.poll
-    }
-  },
-  plugins: [
-    new webpack.DefinePlugin({
-      'process.env': require('../config/dev.env')
-    }),
-    new webpack.HotModuleReplacementPlugin(),
-    // https://github.com/ampedandwired/html-webpack-plugin
-    new HtmlWebpackPlugin({
-      filename: 'index.html',
-      template: 'index.html',
-      inject: true,
-      favicon: resolve('favicon.ico'),
-      title: 'vue-admin-template'
-    })
-  ]
-})
-
-module.exports = new Promise((resolve, reject) => {
-  portfinder.basePort = process.env.PORT || config.dev.port
-  portfinder.getPort((err, port) => {
-    if (err) {
-      reject(err)
-    } else {
-      // publish the new Port, necessary for e2e tests
-      process.env.PORT = port
-      // add port to devServer config
-      devWebpackConfig.devServer.port = port
-
-      // Add FriendlyErrorsPlugin
-      devWebpackConfig.plugins.push(
-        new FriendlyErrorsPlugin({
-          compilationSuccessInfo: {
-            messages: [
-              `Your application is running here: http://${
-                devWebpackConfig.devServer.host
-              }:${port}`
-            ]
-          },
-          onErrors: config.dev.notifyOnErrors
-            ? utils.createNotifierCallback()
-            : undefined
-        })
-      )
-
-      resolve(devWebpackConfig)
-    }
-  })
-})

+ 0 - 177
build/webpack.prod.conf.js

@@ -1,177 +0,0 @@
-'use strict'
-const path = require('path')
-const utils = require('./utils')
-const webpack = require('webpack')
-const config = require('../config')
-const merge = require('webpack-merge')
-const baseWebpackConfig = require('./webpack.base.conf')
-const CopyWebpackPlugin = require('copy-webpack-plugin')
-const HtmlWebpackPlugin = require('html-webpack-plugin')
-const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin')
-const MiniCssExtractPlugin = require('mini-css-extract-plugin')
-const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
-const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
-
-function resolve(dir) {
-  return path.join(__dirname, '..', dir)
-}
-
-const env = require('../config/prod.env')
-
-// For NamedChunksPlugin
-const seen = new Set()
-const nameLength = 4
-
-const webpackConfig = merge(baseWebpackConfig, {
-  mode: 'production',
-  module: {
-    rules: utils.styleLoaders({
-      sourceMap: config.build.productionSourceMap,
-      extract: true,
-      usePostCSS: true
-    })
-  },
-  devtool: config.build.productionSourceMap ? config.build.devtool : false,
-  output: {
-    path: config.build.assetsRoot,
-    filename: utils.assetsPath('js/[name].[chunkhash:8].js'),
-    chunkFilename: utils.assetsPath('js/[name].[chunkhash:8].js')
-  },
-  plugins: [
-    // http://vuejs.github.io/vue-loader/en/workflow/production.html
-    new webpack.DefinePlugin({
-      'process.env': env
-    }),
-    // extract css into its own file
-    new MiniCssExtractPlugin({
-      filename: utils.assetsPath('css/[name].[contenthash:8].css'),
-      chunkFilename: utils.assetsPath('css/[name].[contenthash:8].css')
-    }),
-    // generate dist index.html with correct asset hash for caching.
-    // you can customize output by editing /index.html
-    // see https://github.com/ampedandwired/html-webpack-plugin
-    new HtmlWebpackPlugin({
-      filename: config.build.index,
-      template: 'index.html',
-      inject: true,
-      favicon: resolve('favicon.ico'),
-      title: 'vue-admin-template',
-      minify: {
-        removeComments: true,
-        collapseWhitespace: true,
-        removeAttributeQuotes: true
-        // more options:
-        // https://github.com/kangax/html-minifier#options-quick-reference
-      }
-      // default sort mode uses toposort which cannot handle cyclic deps
-      // in certain cases, and in webpack 4, chunk order in HTML doesn't
-      // matter anyway
-    }),
-    new ScriptExtHtmlWebpackPlugin({
-      //`runtime` must same as runtimeChunk name. default is `runtime`
-      inline: /runtime\..*\.js$/
-    }),
-    // keep chunk.id stable when chunk has no name
-    new webpack.NamedChunksPlugin(chunk => {
-      if (chunk.name) {
-        return chunk.name
-      }
-      const modules = Array.from(chunk.modulesIterable)
-      if (modules.length > 1) {
-        const hash = require('hash-sum')
-        const joinedHash = hash(modules.map(m => m.id).join('_'))
-        let len = nameLength
-        while (seen.has(joinedHash.substr(0, len))) len++
-        seen.add(joinedHash.substr(0, len))
-        return `chunk-${joinedHash.substr(0, len)}`
-      } else {
-        return modules[0].id
-      }
-    }),
-    // keep module.id stable when vender modules does not change
-    new webpack.HashedModuleIdsPlugin(),
-    // copy custom static assets
-    new CopyWebpackPlugin([
-      {
-        from: path.resolve(__dirname, '../static'),
-        to: config.build.assetsSubDirectory,
-        ignore: ['.*']
-      }
-    ])
-  ],
-  optimization: {
-    splitChunks: {
-      chunks: 'all',
-      cacheGroups: {
-        libs: {
-          name: 'chunk-libs',
-          test: /[\\/]node_modules[\\/]/,
-          priority: 10,
-          chunks: 'initial' // 只打包初始时依赖的第三方
-        },
-        elementUI: {
-          name: 'chunk-elementUI', // 单独将 elementUI 拆包
-          priority: 20, // 权重要大于 libs 和 app 不然会被打包进 libs 或者 app
-          test: /[\\/]node_modules[\\/]element-ui[\\/]/
-        }
-      }
-    },
-    runtimeChunk: 'single',
-    minimizer: [
-      new UglifyJsPlugin({
-        uglifyOptions: {
-          mangle: {
-            safari10: true
-          }
-        },
-        sourceMap: config.build.productionSourceMap,
-        cache: true,
-        parallel: true
-      }),
-      // Compress extracted CSS. We are using this plugin so that possible
-      // duplicated CSS from different components can be deduped.
-      new OptimizeCSSAssetsPlugin()
-    ]
-  }
-})
-
-if (config.build.productionGzip) {
-  const CompressionWebpackPlugin = require('compression-webpack-plugin')
-
-  webpackConfig.plugins.push(
-    new CompressionWebpackPlugin({
-      algorithm: 'gzip',
-      test: new RegExp(
-        '\\.(' + config.build.productionGzipExtensions.join('|') + ')$'
-      ),
-      threshold: 10240,
-      minRatio: 0.8
-    })
-  )
-}
-
-if (config.build.generateAnalyzerReport || config.build.bundleAnalyzerReport) {
-  const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
-    .BundleAnalyzerPlugin
-
-  if (config.build.bundleAnalyzerReport) {
-    webpackConfig.plugins.push(
-      new BundleAnalyzerPlugin({
-        analyzerPort: 8080,
-        generateStatsFile: false
-      })
-    )
-  }
-
-  if (config.build.generateAnalyzerReport) {
-    webpackConfig.plugins.push(
-      new BundleAnalyzerPlugin({
-        analyzerMode: 'static',
-        reportFilename: 'bundle-report.html',
-        openAnalyzer: false
-      })
-    )
-  }
-}
-
-module.exports = webpackConfig

+ 0 - 8
config/dev.env.js

@@ -1,8 +0,0 @@
-'use strict'
-const merge = require('webpack-merge')
-const prodEnv = require('./prod.env')
-
-module.exports = merge(prodEnv, {
-  NODE_ENV: '"development"',
-  BASE_API: '"https://easy-mock.com/mock/5950a2419adc231f356a6636/vue-admin"',
-})

+ 0 - 86
config/index.js

@@ -1,86 +0,0 @@
-'use strict'
-// Template version: 1.2.6
-// see http://vuejs-templates.github.io/webpack for documentation.
-
-const path = require('path')
-
-module.exports = {
-  dev: {
-    // Paths
-    assetsSubDirectory: 'static',
-    assetsPublicPath: '/',
-    proxyTable: {},
-
-    // Various Dev Server settings
-    host: 'localhost', // can be overwritten by process.env.HOST
-    port: 9528, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
-    autoOpenBrowser: true,
-    errorOverlay: true,
-    notifyOnErrors: false,
-    poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions-
-
-    // Use Eslint Loader?
-    // If true, your code will be linted during bundling and
-    // linting errors and warnings will be shown in the console.
-    useEslint: true,
-    // If true, eslint errors and warnings will also be shown in the error overlay
-    // in the browser.
-    showEslintErrorsInOverlay: false,
-
-    /**
-     * Source Maps
-     */
-
-    // https://webpack.js.org/configuration/devtool/#development
-    devtool: 'cheap-source-map',
-
-    // CSS Sourcemaps off by default because relative paths are "buggy"
-    // with this option, according to the CSS-Loader README
-    // (https://github.com/webpack/css-loader#sourcemaps)
-    // In our experience, they generally work as expected,
-    // just be aware of this issue when enabling this option.
-    cssSourceMap: false
-  },
-
-  build: {
-    // Template for index.html
-    index: path.resolve(__dirname, '../dist/index.html'),
-
-    // Paths
-    assetsRoot: path.resolve(__dirname, '../dist'),
-    assetsSubDirectory: 'static',
-
-    /**
-     * You can set by youself according to actual condition
-     * You will need to set this if you plan to deploy your site under a sub path,
-     * for example GitHub pages. If you plan to deploy your site to https://foo.github.io/bar/,
-     * then assetsPublicPath should be set to "/bar/".
-     * In most cases please use '/' !!!
-     */
-    assetsPublicPath: '/',
-
-    /**
-     * Source Maps
-     */
-
-    productionSourceMap: false,
-    // https://webpack.js.org/configuration/devtool/#production
-    devtool: 'source-map',
-
-    // Gzip off by default as many popular static hosts such as
-    // Surge or Netlify already gzip all static assets for you.
-    // Before setting to `true`, make sure to:
-    // npm install --save-dev compression-webpack-plugin
-    productionGzip: false,
-    productionGzipExtensions: ['js', 'css'],
-
-    // Run the build command with an extra argument to
-    // View the bundle analyzer report after build finishes:
-    // `npm run build --report`
-    // Set to `true` or `false` to always turn it on or off
-    bundleAnalyzerReport: process.env.npm_config_report || false,
-
-    // `npm run build:prod --generate_report`
-    generateAnalyzerReport: process.env.npm_config_generate_report || false
-  }
-}

+ 0 - 5
config/prod.env.js

@@ -1,5 +0,0 @@
-'use strict'
-module.exports = {
-  NODE_ENV: '"production"',
-  BASE_API: '"https://easy-mock.com/mock/5950a2419adc231f356a6636/vue-admin"',
-}

+ 0 - 12
index.html

@@ -1,12 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width,initial-scale=1.0">
-    <title>vue-admin-template</title>
-  </head>
-  <body>
-    <div id="app"></div>
-    <!-- built files will be auto injected -->
-  </body>
-</html>

+ 24 - 0
jest.config.js

@@ -0,0 +1,24 @@
+module.exports = {
+  moduleFileExtensions: ['js', 'jsx', 'json', 'vue'],
+  transform: {
+    '^.+\\.vue$': 'vue-jest',
+    '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$':
+      'jest-transform-stub',
+    '^.+\\.jsx?$': 'babel-jest'
+  },
+  moduleNameMapper: {
+    '^@/(.*)$': '<rootDir>/src/$1'
+  },
+  snapshotSerializers: ['jest-serializer-vue'],
+  testMatch: [
+    '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
+  ],
+  collectCoverageFrom: ['src/utils/**/*.{js,vue}', '!src/utils/auth.js', '!src/utils/request.js', 'src/components/**/*.{js,vue}'],
+  coverageDirectory: '<rootDir>/tests/unit/coverage',
+  // 'collectCoverage': true,
+  'coverageReporters': [
+    'lcov',
+    'text-summary'
+  ],
+  testURL: 'http://localhost/'
+}

+ 61 - 21
mock/index.js

@@ -1,26 +1,66 @@
 import Mock from 'mockjs'
-import userAPI from './user'
-import tableAPI from './table'
-
-// Fix an issue with setting withCredentials = true, cross-domain request lost cookies
-// https://github.com/nuysoft/Mock/issues/300
-Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
-Mock.XHR.prototype.send = function() {
-  if (this.custom.xhr) {
-    this.custom.xhr.withCredentials = this.withCredentials || false
+import { param2Obj } from '../src/utils'
+
+import user from './user'
+import table from './table'
+
+const mocks = [
+  ...user,
+  ...table
+]
+
+// for front mock
+// please use it cautiously, it will redefine XMLHttpRequest,
+// which will cause many of your third-party libraries to be invalidated(like progress event).
+export function mockXHR() {
+  // mock patch
+  // https://github.com/nuysoft/Mock/issues/300
+  Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
+  Mock.XHR.prototype.send = function() {
+    if (this.custom.xhr) {
+      this.custom.xhr.withCredentials = this.withCredentials || false
+
+      if (this.responseType) {
+        this.custom.xhr.responseType = this.responseType
+      }
+    }
+    this.proxy_send(...arguments)
   }
-  this.proxy_send(...arguments)
-}
-// Mock.setup({
-//   timeout: '350-600'
-// })
 
-// User
-Mock.mock(/\/user\/login/, 'post', userAPI.login)
-Mock.mock(/\/user\/info/, 'get', userAPI.getInfo)
-Mock.mock(/\/user\/logout/, 'post', userAPI.logout)
+  function XHR2ExpressReqWrap(respond) {
+    return function(options) {
+      let result = null
+      if (respond instanceof Function) {
+        const { body, type, url } = options
+        // https://expressjs.com/en/4x/api.html#req
+        result = respond({
+          method: type,
+          body: JSON.parse(body),
+          query: param2Obj(url)
+        })
+      } else {
+        result = respond
+      }
+      return Mock.mock(result)
+    }
+  }
 
-// Table
-Mock.mock(/\/table\/list/, 'get', tableAPI.list)
+  for (const i of mocks) {
+    Mock.mock(new RegExp(i.url), i.type || 'get', XHR2ExpressReqWrap(i.response))
+  }
+}
+
+// for mock server
+const responseFake = (url, type, respond) => {
+  return {
+    url: new RegExp(`/mock${url}`),
+    type: type || 'get',
+    response(req, res) {
+      res.json(Mock.mock(respond instanceof Function ? respond(req, res) : respond))
+    }
+  }
+}
 
-export default Mock
+export default mocks.map(route => {
+  return responseFake(route.url, route.type, route.response)
+})

+ 64 - 0
mock/mock-server.js

@@ -0,0 +1,64 @@
+const chokidar = require('chokidar')
+const bodyParser = require('body-parser')
+const chalk = require('chalk')
+const path = require('path')
+
+const mockDir = path.join(process.cwd(), 'mock')
+
+function registerRoutes(app) {
+  let mockLastIndex
+  const { default: mocks } = require('./index.js')
+  for (const mock of mocks) {
+    app[mock.type](mock.url, mock.response)
+    mockLastIndex = app._router.stack.length
+  }
+  const mockRoutesLength = Object.keys(mocks).length
+  return {
+    mockRoutesLength: mockRoutesLength,
+    mockStartIndex: mockLastIndex - mockRoutesLength
+  }
+}
+
+function unregisterRoutes() {
+  Object.keys(require.cache).forEach(i => {
+    if (i.includes(mockDir)) {
+      delete require.cache[require.resolve(i)]
+    }
+  })
+}
+
+module.exports = app => {
+  // es6 polyfill
+  require('@babel/register')
+
+  // parse app.body
+  // https://expressjs.com/en/4x/api.html#req.body
+  app.use(bodyParser.json())
+  app.use(bodyParser.urlencoded({
+    extended: true
+  }))
+
+  const mockRoutes = registerRoutes(app)
+  var mockRoutesLength = mockRoutes.mockRoutesLength
+  var mockStartIndex = mockRoutes.mockStartIndex
+
+  // watch files, hot reload mock server
+  chokidar.watch(mockDir, {
+    ignored: /mock-server/,
+    ignoreInitial: true
+  }).on('all', (event, path) => {
+    if (event === 'change' || event === 'add') {
+      // remove mock routes stack
+      app._router.stack.splice(mockStartIndex, mockRoutesLength)
+
+      // clear routes cache
+      unregisterRoutes()
+
+      const mockRoutes = registerRoutes(app)
+      mockRoutesLength = mockRoutes.mockRoutesLength
+      mockStartIndex = mockRoutes.mockStartIndex
+
+      console.log(chalk.magentaBright(`\n > Mock Server hot reload success! changed  ${path}`))
+    }
+  })
+}

+ 25 - 16
mock/table.js

@@ -1,20 +1,29 @@
 import Mock from 'mockjs'
 
-export default {
-  list: () => {
-    const items = Mock.mock({
-      'items|30': [{
-        id: '@id',
-        title: '@sentence(10, 20)',
-        'status|1': ['published', 'draft', 'deleted'],
-        author: 'name',
-        display_time: '@datetime',
-        pageviews: '@integer(300, 5000)'
-      }]
-    })
-    return {
-      code: 20000,
-      data: items
+const data = Mock.mock({
+  'items|30': [{
+    id: '@id',
+    title: '@sentence(10, 20)',
+    'status|1': ['published', 'draft', 'deleted'],
+    author: 'name',
+    display_time: '@datetime',
+    pageviews: '@integer(300, 5000)'
+  }]
+})
+
+export default [
+  {
+    url: '/table/list',
+    type: 'get',
+    response: config => {
+      const items = data.items
+      return {
+        code: 20000,
+        data: {
+          total: items.length,
+          items: items
+        }
+      }
     }
   }
-}
+]

+ 44 - 24
mock/user.js

@@ -1,4 +1,3 @@
-import { param2Obj } from './utils'
 
 const tokens = {
   admin: {
@@ -24,41 +23,62 @@ const users = {
   }
 }
 
-export default {
-  login: res => {
-    const { username } = JSON.parse(res.body)
-    const data = tokens[username]
+export default [
+  // user login
+  {
+    url: '/user/login',
+    type: 'post',
+    response: config => {
+      const { username } = config.body
+      const token = tokens[username]
+
+      // mock error
+      if (!token) {
+        return {
+          code: 60204,
+          message: 'Account and password are incorrect.'
+        }
+      }
 
-    if (data) {
       return {
         code: 20000,
-        data
+        data: token
       }
     }
-    return {
-      code: 60204,
-      message: 'Account and password are incorrect.'
-    }
   },
-  getInfo: res => {
-    const { token } = param2Obj(res.url)
-    const info = users[token]
 
-    if (info) {
+  // get user info
+  {
+    url: '/user/info\.*',
+    type: 'get',
+    response: config => {
+      const { token } = config.query
+      const info = users[token]
+
+      // mock error
+      if (!info) {
+        return {
+          code: 50008,
+          message: 'Login failed, unable to get user details.'
+        }
+      }
+
       return {
         code: 20000,
         data: info
       }
     }
-    return {
-      code: 50008,
-      message: 'Login failed, unable to get user details.'
-    }
   },
-  logout: () => {
-    return {
-      code: 20000,
-      data: 'success'
+
+  // user logout
+  {
+    url: '/user/logout',
+    type: 'post',
+    response: _ => {
+      return {
+        code: 20000,
+        data: 'success'
+      }
     }
   }
-}
+]

+ 0 - 14
mock/utils.js

@@ -1,14 +0,0 @@
-export function param2Obj(url) {
-  const search = url.split('?')[1]
-  if (!search) {
-    return {}
-  }
-  return JSON.parse(
-    '{"' +
-      decodeURIComponent(search)
-        .replace(/"/g, '\\"')
-        .replace(/&/g, '","')
-        .replace(/=/g, '":"') +
-      '"}'
-  )
-}

+ 39 - 62
package.json

@@ -1,82 +1,59 @@
 {
   "name": "vue-admin-template",
-  "version": "3.9.0",
-  "license": "MIT",
+  "version": "4.1.0",
   "description": "A vue admin template with Element UI & axios & iconfont & permission control & lint",
   "author": "Pan <panfree23@gmail.com>",
+  "license": "MIT",
   "scripts": {
-    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
-    "start": "npm run dev",
-    "build": "node build/build.js",
-    "build:report": "npm_config_report=true npm run build",
+    "dev": "vue-cli-service serve",
+    "build:prod": "vue-cli-service build",
+    "build:stage": "vue-cli-service build --mode staging",
+    "preview": "node build/index.js --preview",
     "lint": "eslint --ext .js,.vue src",
-    "test": "npm run lint",
+    "test:unit": "jest --clearCache && vue-cli-service test:unit",
+    "test:ci": "npm run lint && npm run test:unit",
     "svgo": "svgo -f src/icons/svg --config=src/icons/svgo.yml"
   },
   "dependencies": {
     "axios": "0.18.0",
-    "element-ui": "2.4.6",
+    "element-ui": "2.7.2",
     "js-cookie": "2.2.0",
-    "mockjs": "1.0.1-beta3",
     "normalize.css": "7.0.0",
     "nprogress": "0.2.0",
-    "vue": "2.5.17",
-    "vue-router": "3.0.1",
-    "vuex": "3.0.1"
+    "path-to-regexp": "2.4.0",
+    "vue": "2.6.10",
+    "vue-router": "3.0.6",
+    "vuex": "3.1.0"
   },
   "devDependencies": {
-    "autoprefixer": "8.5.0",
-    "babel-core": "6.26.0",
-    "babel-eslint": "8.2.6",
-    "babel-helper-vue-jsx-merge-props": "2.0.3",
-    "babel-loader": "7.1.5",
-    "babel-plugin-syntax-jsx": "6.18.0",
-    "babel-plugin-transform-runtime": "6.23.0",
-    "babel-plugin-transform-vue-jsx": "3.7.0",
-    "babel-preset-env": "1.7.0",
-    "babel-preset-stage-2": "6.24.1",
-    "chalk": "2.4.1",
-    "compression-webpack-plugin": "2.0.0",
-    "copy-webpack-plugin": "4.5.2",
-    "css-loader": "1.0.0",
-    "eslint": "4.19.1",
-    "eslint-friendly-formatter": "4.0.1",
-    "eslint-loader": "2.0.0",
-    "eslint-plugin-vue": "4.7.1",
-    "eventsource-polyfill": "0.9.6",
-    "file-loader": "1.1.11",
-    "friendly-errors-webpack-plugin": "1.7.0",
-    "html-webpack-plugin": "4.0.0-alpha",
-    "mini-css-extract-plugin": "0.4.1",
-    "node-notifier": "5.2.1",
-    "node-sass": "^4.7.2",
-    "optimize-css-assets-webpack-plugin": "5.0.0",
-    "ora": "3.0.0",
-    "path-to-regexp": "2.4.0",
-    "portfinder": "1.0.16",
-    "postcss-import": "12.0.0",
-    "postcss-loader": "2.1.6",
-    "postcss-url": "7.3.2",
-    "rimraf": "2.6.2",
-    "sass-loader": "7.0.3",
-    "script-ext-html-webpack-plugin": "2.0.1",
-    "semver": "5.5.0",
-    "shelljs": "0.8.2",
-    "svg-sprite-loader": "3.8.0",
-    "svgo": "1.0.5",
-    "uglifyjs-webpack-plugin": "1.2.7",
-    "url-loader": "1.0.1",
-    "vue-loader": "15.3.0",
-    "vue-style-loader": "4.1.2",
-    "vue-template-compiler": "2.5.17",
-    "webpack": "4.16.5",
-    "webpack-bundle-analyzer": "2.13.1",
-    "webpack-cli": "3.1.0",
-    "webpack-dev-server": "3.1.14",
-    "webpack-merge": "4.1.4"
+    "@babel/core": "7.0.0",
+    "@babel/register": "7.0.0",
+    "@vue/cli-plugin-babel": "3.6.0",
+    "@vue/cli-plugin-eslint": "3.6.0",
+    "@vue/cli-plugin-unit-jest": "3.6.3",
+    "@vue/cli-service": "3.6.0",
+    "@vue/test-utils": "1.0.0-beta.29",
+    "babel-core": "7.0.0-bridge.0",
+    "babel-eslint": "10.0.1",
+    "babel-jest": "23.6.0",
+    "chalk": "2.4.2",
+    "connect": "3.6.6",
+    "eslint": "5.15.3",
+    "eslint-plugin-vue": "5.2.2",
+    "html-webpack-plugin": "3.2.0",
+    "mockjs": "1.0.1-beta3",
+    "node-sass": "^4.9.0",
+    "runjs": "^4.3.2",
+    "sass-loader": "^7.1.0",
+    "script-ext-html-webpack-plugin": "2.1.3",
+    "script-loader": "0.7.2",
+    "serve-static": "^1.13.2",
+    "svg-sprite-loader": "4.1.3",
+    "svgo": "1.2.2",
+    "vue-template-compiler": "2.6.10"
   },
   "engines": {
-    "node": ">= 6.0.0",
+    "node": ">=8.9",
     "npm": ">= 3.0.0"
   },
   "browserslist": [

favicon.ico → public/favicon.ico


+ 17 - 0
public/index.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <title><%= webpackConfig.name %></title>
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but <%= webpackConfig.name %> 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>

+ 1 - 1
src/App.vue

@@ -1,6 +1,6 @@
 <template>
   <div id="app">
-    <router-view/>
+    <router-view />
   </div>
 </template>
 

+ 2 - 5
src/api/login.js

@@ -1,13 +1,10 @@
 import request from '@/utils/request'
 
-export function login(username, password) {
+export function login(data) {
   return request({
     url: '/user/login',
     method: 'post',
-    data: {
-      username,
-      password
-    }
+    data
   })
 }
 

+ 23 - 14
src/components/Breadcrumb/index.vue

@@ -2,7 +2,7 @@
   <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>
+        <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>
@@ -28,15 +28,23 @@ export default {
   },
   methods: {
     getBreadcrumb() {
-      let matched = this.$route.matched.filter(item => item.name)
-
+      // only show routes with meta.title
+      let matched = this.$route.matched.filter(item => item.meta && item.meta.title)
       const first = matched[0]
-      if (first && first.name !== 'dashboard') {
+
+      if (!this.isDashboard(first)) {
         matched = [{ path: '/dashboard', meta: { title: 'Dashboard' }}].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().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
+    },
     pathCompile(path) {
       // To solve this problem https://github.com/PanJiaChen/vue-element-admin/issues/561
       const { params } = this.$route
@@ -55,15 +63,16 @@ export default {
 }
 </script>
 
-<style rel="stylesheet/scss" lang="scss" scoped>
-  .app-breadcrumb.el-breadcrumb {
-    display: inline-block;
-    font-size: 14px;
-    line-height: 50px;
-    margin-left: 10px;
-    .no-redirect {
-      color: #97a8be;
-      cursor: text;
-    }
+<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>

+ 9 - 7
src/components/Hamburger/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div style="padding: 0 15px;" @click="toggleClick">
     <svg
       :class="{'is-active':isActive}"
       class="hamburger"
@@ -7,7 +7,7 @@
       xmlns="http://www.w3.org/2000/svg"
       width="64"
       height="64"
-      @click="toggleClick">
+    >
       <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>
@@ -20,10 +20,11 @@ export default {
     isActive: {
       type: Boolean,
       default: false
-    },
-    toggleClick: {
-      type: Function,
-      default: null
+    }
+  },
+  methods: {
+    toggleClick() {
+      this.$emit('toggleClick')
     }
   }
 }
@@ -32,10 +33,11 @@ export default {
 <style scoped>
 .hamburger {
   display: inline-block;
-  cursor: pointer;
+  vertical-align: middle;
   width: 20px;
   height: 20px;
 }
+
 .hamburger.is-active {
   transform: rotate(180deg);
 }

+ 1 - 1
src/components/SvgIcon/index.vue

@@ -1,6 +1,6 @@
 <template>
   <svg :class="svgClass" aria-hidden="true" v-on="$listeners">
-    <use :xlink:href="iconName"/>
+    <use :xlink:href="iconName" />
   </svg>
 </template>
 

+ 2 - 2
src/icons/index.js

@@ -1,9 +1,9 @@
 import Vue from 'vue'
-import SvgIcon from '@/components/SvgIcon' // svg组件
+import SvgIcon from '@/components/SvgIcon'// svg component
 
 // register globally
 Vue.component('svg-icon', SvgIcon)
 
-const requireAll = requireContext => requireContext.keys().map(requireContext)
 const req = require.context('./svg', false, /\.svg$/)
+const requireAll = requireContext => requireContext.keys().map(requireContext)
 requireAll(req)

File diff suppressed because it is too large
+ 1 - 0
src/icons/svg/dashboard.svg


File diff suppressed because it is too large
+ 1 - 1
src/icons/svg/eye-open.svg


+ 1 - 1
src/icons/svg/link.svg

@@ -1 +1 @@
-<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><g><path d="M115.625 127.937H.063V12.375h57.781v12.374H12.438v90.813h90.813V70.156h12.374z"/><path d="M116.426 2.821l8.753 8.753-56.734 56.734-8.753-8.745z"/><path d="M127.893 37.982h-12.375V12.375H88.706V0h39.187z"/></g></svg>
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M115.625 127.937H.063V12.375h57.781v12.374H12.438v90.813h90.813V70.156h12.374z"/><path d="M116.426 2.821l8.753 8.753-56.734 56.734-8.753-8.745z"/><path d="M127.893 37.982h-12.375V12.375H88.706V0h39.187z"/></svg>

File diff suppressed because it is too large
+ 1 - 1
src/icons/svg/table.svg


+ 8 - 6
src/views/layout/components/AppMain.vue

@@ -1,9 +1,7 @@
 <template>
   <section class="app-main">
     <transition name="fade-transform" mode="out-in">
-      <!-- or name="fade" -->
-      <!-- <router-view :key="key"></router-view> -->
-      <router-view/>
+      <router-view :key="key" />
     </transition>
   </section>
 </template>
@@ -12,9 +10,9 @@
 export default {
   name: 'AppMain',
   computed: {
-    // key() {
-    //   return this.$route.name !== undefined ? this.$route.name + +new Date() : this.$route + +new Date()
-    // }
+    key() {
+      return this.$route.fullPath
+    }
   }
 }
 </script>
@@ -23,7 +21,11 @@ export default {
 .app-main {
   /*50 = navbar  */
   min-height: calc(100vh - 50px);
+  width: 100%;
   position: relative;
   overflow: hidden;
 }
+.fixed-header+.app-main {
+  padding-top: 50px;
+}
 </style>

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

@@ -0,0 +1,139 @@
+<template>
+  <div class="navbar">
+    <hamburger :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
+
+    <breadcrumb class="breadcrumb-container" />
+
+    <div class="right-menu">
+      <el-dropdown class="avatar-container" 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" class="user-dropdown">
+          <router-link to="/">
+            <el-dropdown-item>
+              Home
+            </el-dropdown-item>
+          </router-link>
+          <a target="_blank" href="https://github.com/PanJiaChen/vue-admin-template/">
+            <el-dropdown-item>Github</el-dropdown-item>
+          </a>
+          <a target="_blank" href="https://panjiachen.github.io/vue-element-admin-site/#/">
+            <el-dropdown-item>Docs</el-dropdown-item>
+          </a>
+          <el-dropdown-item divided>
+            <span style="display:block;" @click="logout">Log Out</span>
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import Breadcrumb from '@/components/Breadcrumb'
+import Hamburger from '@/components/Hamburger'
+
+export default {
+  components: {
+    Breadcrumb,
+    Hamburger
+  },
+  computed: {
+    ...mapGetters([
+      'sidebar',
+      'avatar'
+    ])
+  },
+  methods: {
+    toggleSideBar() {
+      this.$store.dispatch('app/toggleSideBar')
+    },
+    async logout() {
+      await this.$store.dispatch('user/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;
+    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>

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

@@ -0,0 +1,26 @@
+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
+    // https://github.com/PanJiaChen/vue-element-admin/issues/1135
+    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)
+        }
+      }
+    }
+  }
+}

+ 8 - 9
src/views/layout/components/Sidebar/Item.vue

@@ -3,18 +3,17 @@ export default {
   name: 'MenuItem',
   functional: true,
   props: {
-    meta: {
-      type: Object,
-      default: () => {
-        return {
-          title: '',
-          icon: ''
-        }
-      }
+    icon: {
+      type: String,
+      default: ''
+    },
+    title: {
+      type: String,
+      default: ''
     }
   },
   render(h, context) {
-    const { icon, title } = context.props.meta
+    const { icon, title } = context.props
     const vnodes = []
 
     if (icon) {

+ 1 - 1
src/views/layout/components/Sidebar/Link.vue

@@ -2,7 +2,7 @@
 <template>
   <!-- eslint-disable vue/require-component-is -->
   <component v-bind="linkProps(to)">
-    <slot/>
+    <slot />
   </component>
 </template>
 

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

@@ -0,0 +1,82 @@
+<template>
+  <div class="sidebar-logo-container" :class="{'collapse':collapse}">
+    <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">{{ 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">{{ title }} </h1>
+      </router-link>
+    </transition>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'SidebarLogo',
+  props: {
+    collapse: {
+      type: Boolean,
+      required: true
+    }
+  },
+  data() {
+    return {
+      title: 'Vue Admin Template',
+      logo: 'https://wpimg.wallstcn.com/69a1c46c-eb1c-4b46-8bd4-e9e686ef5251.png'
+    }
+  }
+}
+</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>

+ 11 - 7
src/views/layout/components/Sidebar/SidebarItem.vue

@@ -1,27 +1,26 @@
 <template>
   <div v-if="!item.hidden" class="menu-wrapper">
-
     <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
-      <app-link :to="resolvePath(onlyOneChild.path)">
+      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
         <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
-          <item :meta="Object.assign({},item.meta,onlyOneChild.meta)" />
+          <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 :meta="item.meta" />
+        <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"
-        :key="child.path"
         :base-path="resolvePath(child.path)"
-        class="nest-menu" />
+        class="nest-menu"
+      />
     </el-submenu>
-
   </div>
 </template>
 
@@ -30,10 +29,12 @@ 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: {
@@ -84,6 +85,9 @@ export default {
       if (isExternal(routePath)) {
         return routePath
       }
+      if (isExternal(this.basePath)) {
+        return this.basePath
+      }
       return path.resolve(this.basePath, routePath)
     }
   }

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

@@ -0,0 +1,54 @@
+<template>
+  <div :class="{'has-logo':showLogo}">
+    <logo v-if="showLogo" :collapse="isCollapse" />
+    <el-scrollbar wrap-class="scrollbar-wrapper">
+      <el-menu
+        :default-active="activeMenu"
+        :collapse="isCollapse"
+        :background-color="variables.menuBg"
+        :text-color="variables.menuText"
+        :unique-opened="false"
+        :active-text-color="variables.menuActiveText"
+        :collapse-transition="false"
+        mode="vertical"
+      >
+        <sidebar-item v-for="route in permission_routes" :key="route.path" :item="route" :base-path="route.path" />
+      </el-menu>
+    </el-scrollbar>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import Logo from './Logo'
+import SidebarItem from './SidebarItem'
+import variables from '@/styles/variables.scss'
+
+export default {
+  components: { SidebarItem, Logo },
+  computed: {
+    ...mapGetters([
+      'permission_routes',
+      '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>

src/views/layout/components/index.js → src/layout/components/index.js


+ 30 - 6
src/views/layout/Layout.vue

@@ -1,10 +1,12 @@
 <template>
   <div :class="classObj" class="app-wrapper">
-    <div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
-    <sidebar class="sidebar-container"/>
+    <div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
+    <sidebar class="sidebar-container" />
     <div class="main-container">
-      <navbar/>
-      <app-main/>
+      <div :class="{'fixed-header':fixedHeader}">
+        <navbar />
+      </div>
+      <app-main />
     </div>
   </div>
 </template>
@@ -28,6 +30,9 @@ export default {
     device() {
       return this.$store.state.app.device
     },
+    fixedHeader() {
+      return this.$store.state.settings.fixedHeader
+    },
     classObj() {
       return {
         hideSidebar: !this.sidebar.opened,
@@ -45,8 +50,10 @@ export default {
 }
 </script>
 
-<style rel="stylesheet/scss" lang="scss" scoped>
-  @import "src/styles/mixin.scss";
+<style lang="scss" scoped>
+  @import "~@/styles/mixin.scss";
+  @import "~@/styles/variables.scss";
+
   .app-wrapper {
     @include clearfix;
     position: relative;
@@ -66,4 +73,21 @@ export default {
     position: absolute;
     z-index: 999;
   }
+
+  .fixed-header {
+    position: fixed;
+    top: 0;
+    right: 0;
+    z-index: 9;
+    width: calc(100% - #{$sideBarWidth});
+    transition: width 0.28s;
+  }
+
+  .hideSidebar .fixed-header {
+    width: calc(100% - 54px)
+  }
+
+  .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 })
+        }
+      }
+    }
+  }
+}

+ 7 - 7
src/main.js

@@ -16,15 +16,15 @@ import '@/icons' // icon
 import '@/permission' // permission control
 
 /**
- * This project originally used easy-mock to simulate data,
- * but its official service is very unstable,
- * and you can build your own service if you need it.
- * So here I use Mock.js for local emulation,
- * it will intercept your request, so you won't see the request in the network.
- * If you remove `../mock` it will automatically request easy-mock data.
+ * If you don't want to use mock-server
+ * you want to use mockjs for request interception
+ * you can execute:
+ *
+ * import { mockXHR } from '../mock'
+ * mockXHR()
  */
-import '../mock' // simulation data
 
+// set ElementUI lang to EN
 Vue.use(ElementUI, { locale })
 
 Vue.config.productionTip = false

+ 50 - 19
src/permission.js

@@ -1,43 +1,74 @@
 import router from './router'
 import store from './store'
+import { Message } from 'element-ui'
 import NProgress from 'nprogress' // progress bar
 import 'nprogress/nprogress.css' // progress bar style
-import { Message } from 'element-ui'
-import { getToken } from '@/utils/auth' // getToken from cookie
+import { getToken } from '@/utils/auth' // get token from cookie
+import getPageTitle from '@/utils/get-page-title'
 
-NProgress.configure({ showSpinner: false })// NProgress configuration
+NProgress.configure({ showSpinner: false }) // NProgress Configuration
 
-const whiteList = ['/login'] // 不重定向白名单
-router.beforeEach((to, from, next) => {
+const whiteList = ['/login'] // no redirect whitelist
+
+router.beforeEach(async(to, from, next) => {
+  // start progress bar
   NProgress.start()
-  if (getToken()) {
+
+  // set page title
+  document.title = getPageTitle(to.meta.title)
+
+  // determine whether the user has logged in
+  const hasToken = getToken()
+
+  if (hasToken) {
     if (to.path === '/login') {
+      // if is logged in, redirect to the home page
       next({ path: '/' })
-      NProgress.done() // if current page is dashboard will not trigger	afterEach hook, so manually handle it
+      NProgress.done()
     } else {
-      if (store.getters.roles.length === 0) {
-        store.dispatch('GetInfo').then(res => { // 拉取用户信息
-          next()
-        }).catch((err) => {
-          store.dispatch('FedLogOut').then(() => {
-            Message.error(err || 'Verification failed, please login again')
-            next({ path: '/' })
-          })
-        })
-      } else {
+      // determine whether the user has obtained his permission roles through getInfo
+      const hasRoles = store.getters.roles && store.getters.roles.length > 0
+      if (hasRoles) {
         next()
+      } else {
+        try {
+          // get user info
+          // note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
+          const { roles } = await store.dispatch('user/getInfo')
+
+          // generate accessible routes map based on roles
+          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
+
+          // dynamically add accessible routes
+          router.addRoutes(accessRoutes)
+
+          // hack method to ensure that addRoutes is complete
+          // set the replace: true, so the navigation will not leave a history record
+          next({ ...to, replace: true })
+        } catch (error) {
+          // remove token and go to login page to re-login
+          await store.dispatch('user/resetToken')
+          Message.error(error || 'Has Error')
+          next(`/login?redirect=${to.path}`)
+          NProgress.done()
+        }
       }
     }
   } else {
+    /* has no token*/
+
     if (whiteList.indexOf(to.path) !== -1) {
+      // in the free login whitelist, go directly
       next()
     } else {
-      next(`/login?redirect=${to.path}`) // 否则全部重定向到登录页
+      // other pages that do not have permission to access are redirected to the login page.
+      next(`/login?redirect=${to.path}`)
       NProgress.done()
     }
   }
 })
 
 router.afterEach(() => {
-  NProgress.done() // 结束Progress
+  // finish progress bar
+  NProgress.done()
 })

+ 58 - 24
src/router/index.js

@@ -1,40 +1,57 @@
 import Vue from 'vue'
 import Router from 'vue-router'
 
-// in development-env not use lazy-loading, because lazy-loading too many pages will cause webpack hot update too slow. so only in production use lazy-loading;
-// detail: https://panjiachen.github.io/vue-element-admin-site/#/lazy-loading
-
 Vue.use(Router)
 
 /* Layout */
-import Layout from '../views/layout/Layout'
+import Layout from '@/layout'
 
 /**
-* hidden: true                   if `hidden:true` will not show in the sidebar(default is false)
-* alwaysShow: true               if set true, will always show the root menu, whatever its child routes length
-*                                if not set alwaysShow, only more than one route under the children
-*                                it will becomes nested mode, otherwise not show the root menu
-* redirect: noredirect           if `redirect:noredirect` will no redirect in the breadcrumb
-* name:'router-name'             the name is used by <keep-alive> (must set!!!)
-* meta : {
-    title: 'title'               the name show in subMenu and breadcrumb (recommend set)
+ * Note: sub-menu only appear when route children.length >= 1
+ * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
+ *
+ * hidden: true                   if set true, item will not show in the sidebar(default is false)
+ * alwaysShow: true               if set true, will always show the root menu
+ *                                if not set alwaysShow, when item has more than one children route,
+ *                                it will becomes nested mode, otherwise not show the root menu
+ * redirect: noRedirect           if set noRedirect will no redirect in the breadcrumb
+ * name:'router-name'             the name is used by <keep-alive> (must set!!!)
+ * meta : {
+    roles: ['admin','editor']    control the page roles (you can set multiple roles)
+    title: 'title'               the name show in sidebar and breadcrumb (recommend set)
     icon: 'svg-name'             the icon show in the sidebar
-    breadcrumb: false            if false, the item will hidden in breadcrumb(default is true)
+    breadcrumb: false            if set false, the item will hidden in breadcrumb(default is true)
+    activeMenu: '/example/list'  if set path, the sidebar will highlight the path you set
   }
-**/
-export const constantRouterMap = [
-  { path: '/login', component: () => import('@/views/login/index'), hidden: true },
-  { path: '/404', component: () => import('@/views/404'), hidden: true },
+ */
+
+/**
+ * constantRoutes
+ * a base page that does not have permission requirements
+ * all roles can be accessed
+ */
+export const constantRoutes = [
+  {
+    path: '/login',
+    component: () => import('@/views/login/index'),
+    hidden: true
+  },
+
+  {
+    path: '/404',
+    component: () => import('@/views/404'),
+    hidden: true
+  },
 
   {
     path: '/',
     component: Layout,
     redirect: '/dashboard',
-    name: 'Dashboard',
-    hidden: true,
     children: [{
       path: 'dashboard',
-      component: () => import('@/views/dashboard/index')
+      name: 'Dashboard',
+      component: () => import('@/views/dashboard/index'),
+      meta: { title: 'Dashboard', icon: 'dashboard' }
     }]
   },
 
@@ -71,8 +88,14 @@ export const constantRouterMap = [
         meta: { title: 'Form', icon: 'form' }
       }
     ]
-  },
+  }
+]
 
+/**
+ * asyncRoutes
+ * the routes that need to be dynamically loaded based on user roles
+ */
+export const asyncRoutes = [
   {
     path: '/nested',
     component: Layout,
@@ -142,11 +165,22 @@ export const constantRouterMap = [
     ]
   },
 
+  // 404 page must be placed at the end !!!
   { path: '*', redirect: '/404', hidden: true }
 ]
 
-export default new Router({
-  // mode: 'history', //后端支持可开
+const createRouter = () => new Router({
+  // mode: 'history', // require service support
   scrollBehavior: () => ({ y: 0 }),
-  routes: constantRouterMap
+  routes: constantRoutes
 })
+
+const router = createRouter()
+
+// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
+export function resetRouter() {
+  const newRouter = createRouter()
+  router.matcher = newRouter.matcher // reset router
+}
+
+export default router

+ 16 - 0
src/settings.js

@@ -0,0 +1,16 @@
+module.exports = {
+
+  title: 'Vue Admin Template',
+
+  /**
+   * @type {boolean} true | false
+   * @description Whether fix the header
+   */
+  fixedHeader: false,
+
+  /**
+   * @type {boolean} true | false
+   * @description Whether show the logo in sidebar
+   */
+  sidebarLogo: false
+}

+ 2 - 1
src/store/getters.js

@@ -4,6 +4,7 @@ const getters = {
   token: state => state.user.token,
   avatar: state => state.user.avatar,
   name: state => state.user.name,
-  roles: state => state.user.roles
+  roles: state => state.user.roles,
+  permission_routes: state => state.permission.routes
 }
 export default getters

+ 5 - 1
src/store/index.js

@@ -1,14 +1,18 @@
 import Vue from 'vue'
 import Vuex from 'vuex'
+import getters from './getters'
 import app from './modules/app'
+import permission from './modules/permission'
+import settings from './modules/settings'
 import user from './modules/user'
-import getters from './getters'
 
 Vue.use(Vuex)
 
 const store = new Vuex.Store({
   modules: {
     app,
+    permission,
+    settings,
     user
   },
   getters

+ 39 - 34
src/store/modules/app.js

@@ -1,43 +1,48 @@
 import Cookies from 'js-cookie'
 
-const app = {
-  state: {
-    sidebar: {
-      opened: !+Cookies.get('sidebarStatus'),
-      withoutAnimation: false
-    },
-    device: 'desktop'
+const state = {
+  sidebar: {
+    opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
+    withoutAnimation: false
   },
-  mutations: {
-    TOGGLE_SIDEBAR: state => {
-      if (state.sidebar.opened) {
-        Cookies.set('sidebarStatus', 1)
-      } else {
-        Cookies.set('sidebarStatus', 0)
-      }
-      state.sidebar.opened = !state.sidebar.opened
-      state.sidebar.withoutAnimation = false
-    },
-    CLOSE_SIDEBAR: (state, withoutAnimation) => {
+  device: 'desktop'
+}
+
+const mutations = {
+  TOGGLE_SIDEBAR: state => {
+    state.sidebar.opened = !state.sidebar.opened
+    state.sidebar.withoutAnimation = false
+    if (state.sidebar.opened) {
       Cookies.set('sidebarStatus', 1)
-      state.sidebar.opened = false
-      state.sidebar.withoutAnimation = withoutAnimation
-    },
-    TOGGLE_DEVICE: (state, device) => {
-      state.device = device
+    } else {
+      Cookies.set('sidebarStatus', 0)
     }
   },
-  actions: {
-    ToggleSideBar: ({ commit }) => {
-      commit('TOGGLE_SIDEBAR')
-    },
-    CloseSideBar({ commit }, { withoutAnimation }) {
-      commit('CLOSE_SIDEBAR', withoutAnimation)
-    },
-    ToggleDevice({ commit }, device) {
-      commit('TOGGLE_DEVICE', device)
-    }
+  CLOSE_SIDEBAR: (state, withoutAnimation) => {
+    Cookies.set('sidebarStatus', 0)
+    state.sidebar.opened = false
+    state.sidebar.withoutAnimation = withoutAnimation
+  },
+  TOGGLE_DEVICE: (state, device) => {
+    state.device = device
+  }
+}
+
+const actions = {
+  toggleSideBar({ commit }) {
+    commit('TOGGLE_SIDEBAR')
+  },
+  closeSideBar({ commit }, { withoutAnimation }) {
+    commit('CLOSE_SIDEBAR', withoutAnimation)
+  },
+  toggleDevice({ commit }, device) {
+    commit('TOGGLE_DEVICE', device)
   }
 }
 
-export default app
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 69 - 0
src/store/modules/permission.js

@@ -0,0 +1,69 @@
+import { asyncRoutes, constantRoutes } from '@/router'
+
+/**
+ * Use meta.role to determine if the current user has permission
+ * @param roles
+ * @param route
+ */
+function hasPermission(roles, route) {
+  if (route.meta && route.meta.roles) {
+    return roles.some(role => route.meta.roles.includes(role))
+  } else {
+    return true
+  }
+}
+
+/**
+ * Filter asynchronous routing tables by recursion
+ * @param routes asyncRoutes
+ * @param roles
+ */
+export function filterAsyncRoutes(routes, roles) {
+  const res = []
+
+  routes.forEach(route => {
+    const tmp = { ...route }
+    if (hasPermission(roles, tmp)) {
+      if (tmp.children) {
+        tmp.children = filterAsyncRoutes(tmp.children, roles)
+      }
+      res.push(tmp)
+    }
+  })
+
+  return res
+}
+
+const state = {
+  routes: [],
+  addRoutes: []
+}
+
+const mutations = {
+  SET_ROUTES: (state, routes) => {
+    state.addRoutes = routes
+    state.routes = constantRoutes.concat(routes)
+  }
+}
+
+const actions = {
+  generateRoutes({ commit }, roles) {
+    return new Promise(resolve => {
+      let accessedRoutes
+      if (roles.includes('admin')) {
+        accessedRoutes = asyncRoutes || []
+      } else {
+        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
+      }
+      commit('SET_ROUTES', accessedRoutes)
+      resolve(accessedRoutes)
+    })
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 31 - 0
src/store/modules/settings.js

@@ -0,0 +1,31 @@
+import defaultSettings from '@/settings'
+
+const { showSettings, fixedHeader, sidebarLogo } = defaultSettings
+
+const state = {
+  showSettings: showSettings,
+  fixedHeader: fixedHeader,
+  sidebarLogo: sidebarLogo
+}
+
+const mutations = {
+  CHANGE_SETTING: (state, { key, value }) => {
+    if (state.hasOwnProperty(key)) {
+      state[key] = value
+    }
+  }
+}
+
+const actions = {
+  changeSetting({ commit }, data) {
+    commit('CHANGE_SETTING', data)
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}
+

+ 85 - 70
src/store/modules/user.js

@@ -1,87 +1,102 @@
-import { login, logout, getInfo } from '@/api/login'
+import { login, logout, getInfo } from '@/api/user'
 import { getToken, setToken, removeToken } from '@/utils/auth'
+import { resetRouter } from '@/router'
 
-const user = {
-  state: {
-    token: getToken(),
-    name: '',
-    avatar: '',
-    roles: []
-  },
+const state = {
+  token: getToken(),
+  name: '',
+  avatar: '',
+  roles: []
+}
 
-  mutations: {
-    SET_TOKEN: (state, token) => {
-      state.token = token
-    },
-    SET_NAME: (state, name) => {
-      state.name = name
-    },
-    SET_AVATAR: (state, avatar) => {
-      state.avatar = avatar
-    },
-    SET_ROLES: (state, roles) => {
-      state.roles = roles
-    }
+const mutations = {
+  SET_TOKEN: (state, token) => {
+    state.token = token
+  },
+  SET_NAME: (state, name) => {
+    state.name = name
   },
+  SET_AVATAR: (state, avatar) => {
+    state.avatar = avatar
+  },
+  SET_ROLES: (state, roles) => {
+    state.roles = roles
+  }
+}
 
-  actions: {
-    // 登录
-    Login({ commit }, userInfo) {
-      const username = userInfo.username.trim()
-      return new Promise((resolve, reject) => {
-        login(username, userInfo.password).then(response => {
-          const data = response.data
-          setToken(data.token)
-          commit('SET_TOKEN', data.token)
-          resolve()
-        }).catch(error => {
-          reject(error)
-        })
+const actions = {
+  // user login
+  login({ commit }, userInfo) {
+    const { username, password } = userInfo
+    return new Promise((resolve, reject) => {
+      login({ username: username.trim(), password: password }).then(response => {
+        const { data } = response
+        commit('SET_TOKEN', data.token)
+        setToken(data.token)
+        resolve()
+      }).catch(error => {
+        reject(error)
       })
-    },
+    })
+  },
 
-    // 获取用户信息
-    GetInfo({ commit, state }) {
-      return new Promise((resolve, reject) => {
-        getInfo(state.token).then(response => {
-          const data = response.data
-          if (data.roles && data.roles.length > 0) { // 验证返回的roles是否是一个非空数组
-            commit('SET_ROLES', data.roles)
-          } else {
-            reject('getInfo: roles must be a non-null array !')
-          }
-          commit('SET_NAME', data.name)
-          commit('SET_AVATAR', data.avatar)
-          resolve(response)
-        }).catch(error => {
-          reject(error)
-        })
-      })
-    },
+  // get user info
+  getInfo({ commit, state }) {
+    return new Promise((resolve, reject) => {
+      getInfo(state.token).then(response => {
+        const { data } = response
 
-    // 登出
-    LogOut({ commit, state }) {
-      return new Promise((resolve, reject) => {
-        logout(state.token).then(() => {
-          commit('SET_TOKEN', '')
-          commit('SET_ROLES', [])
-          removeToken()
-          resolve()
-        }).catch(error => {
-          reject(error)
-        })
+        if (!data) {
+          reject('Verification failed, please Login again.')
+        }
+
+        const { roles, name, avatar } = data
+
+        // roles must be a non-empty array
+        if (!roles || roles.length <= 0) {
+          reject('getInfo: roles must be a non-null array!')
+        }
+
+        commit('SET_ROLES', roles)
+        commit('SET_NAME', name)
+        commit('SET_AVATAR', avatar)
+        resolve(data)
+      }).catch(error => {
+        reject(error)
       })
-    },
+    })
+  },
 
-    // 前端 登出
-    FedLogOut({ commit }) {
-      return new Promise(resolve => {
+  // user logout
+  logout({ commit, state }) {
+    return new Promise((resolve, reject) => {
+      logout(state.token).then(() => {
         commit('SET_TOKEN', '')
+        commit('SET_ROLES', [])
         removeToken()
+        resetRouter()
         resolve()
+      }).catch(error => {
+        reject(error)
       })
-    }
+    })
+  },
+
+  // remove token
+  resetToken({ commit }) {
+    return new Promise(resolve => {
+      commit('SET_TOKEN', '')
+      commit('SET_ROLES', [])
+      removeToken()
+      resolve()
+    })
   }
 }
 
-export default user
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}
+

+ 17 - 3
src/styles/element-ui.scss

@@ -1,4 +1,10 @@
-//to reset element-ui default css
+// cover some element-ui styles
+
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+  font-weight: 400 !important;
+}
+
 .el-upload {
   input[type="file"] {
     display: none !important;
@@ -9,7 +15,8 @@
   display: none;
 }
 
-//暂时性解决diolag 问题 https://github.com/ElemeFE/element/issues/2461
+
+// to fixed https://github.com/ElemeFE/element/issues/2461
 .el-dialog {
   transform: none;
   left: 0;
@@ -17,7 +24,7 @@
   margin: 0 auto;
 }
 
-//element ui upload
+// refine element ui upload
 .upload-container {
   .el-upload {
     width: 100%;
@@ -28,3 +35,10 @@
     }
   }
 }
+
+// dropdown
+.el-dropdown-menu {
+  a {
+    display: block
+  }
+}

+ 5 - 18
src/styles/index.scss

@@ -31,19 +31,6 @@ html {
   box-sizing: inherit;
 }
 
-a,
-a:focus,
-a:hover {
-  cursor: pointer;
-  color: inherit;
-  outline: none;
-  text-decoration: none;
-}
-
-div:focus {
-  outline: none;
-}
-
 a:focus,
 a:active {
   outline: none;
@@ -57,6 +44,10 @@ a:hover {
   text-decoration: none;
 }
 
+div:focus {
+  outline: none;
+}
+
 .clearfix {
   &:after {
     visibility: hidden;
@@ -68,11 +59,7 @@ a:hover {
   }
 }
 
-//main-container全局样式
-.app-main {
-  min-height: 100%
-}
-
+// main-container global css
 .app-container {
   padding: 20px;
 }

+ 15 - 8
src/styles/sidebar.scss

@@ -1,6 +1,5 @@
 #app {
 
-  // 主体区域 Main container
   .main-container {
     min-height: 100%;
     transition: margin-left .28s;
@@ -8,10 +7,10 @@
     position: relative;
   }
 
-  // 侧边栏 Sidebar container
   .sidebar-container {
     transition: width 0.28s;
     width: $sideBarWidth !important;
+    background-color: $menuBg;
     height: 100%;
     position: fixed;
     font-size: 0px;
@@ -21,23 +20,29 @@
     z-index: 1001;
     overflow: hidden;
 
-    //reset element-ui css
+    // 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;
     }
 
+    .el-scrollbar {
+      height: 100%;
+    }
+
+    &.has-logo {
+      .el-scrollbar {
+        height: calc(100% - 50px);
+      }
+    }
+
     .is-horizontal {
       display: none;
     }
@@ -100,6 +105,7 @@
 
       .el-tooltip {
         padding: 0 !important;
+
         .svg-icon {
           margin-left: 20px;
         }
@@ -111,6 +117,7 @@
 
       &>.el-submenu__title {
         padding: 0 !important;
+
         .svg-icon {
           margin-left: 20px;
         }
@@ -140,7 +147,7 @@
     min-width: $sideBarWidth !important;
   }
 
-  // 适配移动端, Mobile responsive
+  // mobile responsive
   .mobile {
     .main-container {
       margin-left: 0px;

+ 4 - 4
src/styles/transition.scss

@@ -1,6 +1,6 @@
-//globl transition css
+// global transition css
 
-/*fade*/
+/* fade */
 .fade-enter-active,
 .fade-leave-active {
   transition: opacity 0.28s;
@@ -11,7 +11,7 @@
   opacity: 0;
 }
 
-/*fade-transform*/
+/* fade-transform */
 .fade-transform-leave-active,
 .fade-transform-enter-active {
   transition: all .5s;
@@ -27,7 +27,7 @@
   transform: translateX(30px);
 }
 
-/*fade*/
+/* breadcrumb transition */
 .breadcrumb-enter-active,
 .breadcrumb-leave-active {
   transition: all .5s;

+ 1 - 1
src/styles/variables.scss

@@ -1,4 +1,4 @@
-//sidebar
+// sidebar
 $menuText:#bfcbd9;
 $menuActiveText:#409EFF;
 $subMenuActiveText:#f4f4f5; //https://github.com/ElemeFE/element/issues/12951

+ 10 - 0
src/utils/get-page-title.js

@@ -0,0 +1,10 @@
+import defaultSettings from '@/settings'
+
+const title = defaultSettings.title || 'Vue Admin Template'
+
+export default function getPageTitle(pageTitle) {
+  if (pageTitle) {
+    return `${pageTitle} - ${title}`
+  }
+  return `${title}`
+}

+ 90 - 0
src/utils/index.js

@@ -0,0 +1,90 @@
+/**
+ * Created by PanJiaChen on 16/11/18.
+ */
+
+/**
+ * Parse the time to string
+ * @param {(Object|string|number)} time
+ * @param {string} cFormat
+ * @returns {string}
+ */
+export function parseTime(time, cFormat) {
+  if (arguments.length === 0) {
+    return null
+  }
+  const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}'
+  let date
+  if (typeof time === 'object') {
+    date = time
+  } else {
+    if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) {
+      time = parseInt(time)
+    }
+    if ((typeof time === 'number') && (time.toString().length === 10)) {
+      time = time * 1000
+    }
+    date = new Date(time)
+  }
+  const formatObj = {
+    y: date.getFullYear(),
+    m: date.getMonth() + 1,
+    d: date.getDate(),
+    h: date.getHours(),
+    i: date.getMinutes(),
+    s: date.getSeconds(),
+    a: date.getDay()
+  }
+  const time_str = format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => {
+    let value = formatObj[key]
+    // Note: getDay() returns 0 on Sunday
+    if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value ] }
+    if (result.length > 0 && value < 10) {
+      value = '0' + value
+    }
+    return value || 0
+  })
+  return time_str
+}
+
+/**
+ * @param {number} time
+ * @param {string} option
+ * @returns {string}
+ */
+export function formatTime(time, option) {
+  if (('' + time).length === 10) {
+    time = parseInt(time) * 1000
+  } else {
+    time = +time
+  }
+  const d = new Date(time)
+  const now = Date.now()
+
+  const diff = (now - d) / 1000
+
+  if (diff < 30) {
+    return '刚刚'
+  } else if (diff < 3600) {
+    // less 1 hour
+    return Math.ceil(diff / 60) + '分钟前'
+  } else if (diff < 3600 * 24) {
+    return Math.ceil(diff / 3600) + '小时前'
+  } else if (diff < 3600 * 24 * 2) {
+    return '1天前'
+  }
+  if (option) {
+    return parseTime(time, option)
+  } else {
+    return (
+      d.getMonth() +
+      1 +
+      '月' +
+      d.getDate() +
+      '日' +
+      d.getHours() +
+      '时' +
+      d.getMinutes() +
+      '分'
+    )
+  }
+}

+ 40 - 28
src/utils/request.js

@@ -1,62 +1,74 @@
 import axios from 'axios'
-import { Message, MessageBox } from 'element-ui'
-import store from '../store'
+import { MessageBox, Message } from 'element-ui'
+import store from '@/store'
 import { getToken } from '@/utils/auth'
 
-// 创建axios实例
+// create an axios instance
 const service = axios.create({
-  baseURL: process.env.BASE_API, // api 的 base_url
-  timeout: 5000 // 请求超时时间
+  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
+  withCredentials: true, // send cookies when cross-domain requests
+  timeout: 5000 // request timeout
 })
 
-// request拦截器
+// request interceptor
 service.interceptors.request.use(
   config => {
+    // do something before request is sent
+
     if (store.getters.token) {
-      config.headers['X-Token'] = getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
+      // let each request carry token
+      // ['X-Token'] is a custom headers key
+      // please modify it according to the actual situation
+      config.headers['X-Token'] = getToken()
     }
     return config
   },
   error => {
-    // Do something with request error
+    // do something with request error
     console.log(error) // for debug
-    Promise.reject(error)
+    return Promise.reject(error)
   }
 )
 
-// response 拦截器
+// response interceptor
 service.interceptors.response.use(
+  /**
+   * If you want to get http information such as headers or status
+   * Please return  response => response
+  */
+
+  /**
+   * Determine the request status by custom code
+   * Here is just an example
+   * You can also judge the status by HTTP Status Code
+   */
   response => {
-    /**
-     * code为非20000是抛错 可结合自己业务进行修改
-     */
     const res = response.data
+
+    // if the custom code is not 20000, it is judged as an error.
     if (res.code !== 20000) {
       Message({
-        message: res.message,
+        message: res.message || 'error',
         type: 'error',
         duration: 5 * 1000
       })
 
-      // 50008:非法的token; 50012:其他客户端登录了;  50014:Token 过期了;
+      // 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
       if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
-        MessageBox.confirm(
-          '你已被登出,可以取消继续留在该页面,或者重新登录',
-          '确定登出',
-          {
-            confirmButtonText: '重新登录',
-            cancelButtonText: '取消',
-            type: 'warning'
-          }
-        ).then(() => {
-          store.dispatch('FedLogOut').then(() => {
-            location.reload() // 为了重新实例化vue-router对象 避免bug
+        // to re-login
+        MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', {
+          confirmButtonText: 'Re-Login',
+          cancelButtonText: 'Cancel',
+          type: 'warning'
+        }).then(() => {
+          store.dispatch('user/resetToken').then(() => {
+            location.reload()
           })
         })
       }
-      return Promise.reject('error')
+      return Promise.reject(res.message || 'error')
     } else {
-      return response.data
+      return res
     }
   },
   error => {

+ 14 - 6
src/utils/validate.js

@@ -1,12 +1,20 @@
 /**
- * Created by jiachenpan on 16/11/18.
+ * Created by PanJiaChen on 16/11/18.
  */
 
-export function isvalidUsername(str) {
-  const valid_map = ['admin', 'editor']
-  return valid_map.indexOf(str.trim()) >= 0
-}
-
+/**
+ * @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
+}

+ 6 - 6
src/views/404.vue

@@ -9,12 +9,12 @@
       </div>
       <div class="bullshit">
         <div class="bullshit__oops">OOPS!</div>
-        <div class="bullshit__info">版权所有
-          <a class="link-type" href="https://wallstreetcn.com" target="_blank">华尔街见闻</a>
+        <div class="bullshit__info">All rights reserved
+          <a style="color:#20a0ff" href="https://wallstreetcn.com" target="_blank">wallstreetcn</a>
         </div>
         <div class="bullshit__headline">{{ message }}</div>
-        <div class="bullshit__info">请检查您输入的网址是否正确,请点击以下按钮返回主页或者发送错误报告</div>
-        <a href="" class="bullshit__return-home">返回首页</a>
+        <div class="bullshit__info">Please check that the URL you entered is correct, or click the button below to return to the homepage.</div>
+        <a href="" class="bullshit__return-home">Back to home</a>
       </div>
     </div>
   </div>
@@ -26,13 +26,13 @@ export default {
   name: 'Page404',
   computed: {
     message() {
-      return '网管说这个页面你不能进......'
+      return 'The webmaster said that you can not enter this page...'
     }
   }
 }
 </script>
 
-<style rel="stylesheet/scss" lang="scss" scoped>
+<style lang="scss" scoped>
 .wscn-http404-container{
   transform: translate(-50%,-50%);
   position: absolute;

+ 3 - 3
src/views/dashboard/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="dashboard-container">
-    <div class="dashboard-text">name:{{ name }}</div>
-    <div class="dashboard-text">roles:<span v-for="role in roles" :key="role">{{ role }}</span></div>
+    <div class="dashboard-text">name: {{ name }}</div>
+    <div class="dashboard-text">roles: <span v-for="role in roles" :key="role">{{ role }}</span></div>
   </div>
 </template>
 
@@ -19,7 +19,7 @@ export default {
 }
 </script>
 
-<style rel="stylesheet/scss" lang="scss" scoped>
+<style lang="scss" scoped>
 .dashboard {
   &-container {
     margin: 30px;

+ 13 - 13
src/views/form/index.vue

@@ -2,42 +2,42 @@
   <div class="app-container">
     <el-form ref="form" :model="form" label-width="120px">
       <el-form-item label="Activity name">
-        <el-input v-model="form.name"/>
+        <el-input v-model="form.name" />
       </el-form-item>
       <el-form-item label="Activity zone">
         <el-select v-model="form.region" placeholder="please select your zone">
-          <el-option label="Zone one" value="shanghai"/>
-          <el-option label="Zone two" value="beijing"/>
+          <el-option label="Zone one" value="shanghai" />
+          <el-option label="Zone two" value="beijing" />
         </el-select>
       </el-form-item>
       <el-form-item label="Activity time">
         <el-col :span="11">
-          <el-date-picker v-model="form.date1" type="date" placeholder="Pick a date" style="width: 100%;"/>
+          <el-date-picker v-model="form.date1" type="date" placeholder="Pick a date" style="width: 100%;" />
         </el-col>
         <el-col :span="2" class="line">-</el-col>
         <el-col :span="11">
-          <el-time-picker v-model="form.date2" type="fixed-time" placeholder="Pick a time" style="width: 100%;"/>
+          <el-time-picker v-model="form.date2" type="fixed-time" placeholder="Pick a time" style="width: 100%;" />
         </el-col>
       </el-form-item>
       <el-form-item label="Instant delivery">
-        <el-switch v-model="form.delivery"/>
+        <el-switch v-model="form.delivery" />
       </el-form-item>
       <el-form-item label="Activity type">
         <el-checkbox-group v-model="form.type">
-          <el-checkbox label="Online activities" name="type"/>
-          <el-checkbox label="Promotion activities" name="type"/>
-          <el-checkbox label="Offline activities" name="type"/>
-          <el-checkbox label="Simple brand exposure" name="type"/>
+          <el-checkbox label="Online activities" name="type" />
+          <el-checkbox label="Promotion activities" name="type" />
+          <el-checkbox label="Offline activities" name="type" />
+          <el-checkbox label="Simple brand exposure" name="type" />
         </el-checkbox-group>
       </el-form-item>
       <el-form-item label="Resources">
         <el-radio-group v-model="form.resource">
-          <el-radio label="Sponsor"/>
-          <el-radio label="Venue"/>
+          <el-radio label="Sponsor" />
+          <el-radio label="Venue" />
         </el-radio-group>
       </el-form-item>
       <el-form-item label="Activity form">
-        <el-input v-model="form.desc" type="textarea"/>
+        <el-input v-model="form.desc" type="textarea" />
       </el-form-item>
       <el-form-item>
         <el-button type="primary" @click="onSubmit">Create</el-button>

+ 0 - 95
src/views/layout/components/Navbar.vue

@@ -1,95 +0,0 @@
-<template>
-  <div class="navbar">
-    <hamburger :toggle-click="toggleSideBar" :is-active="sidebar.opened" class="hamburger-container"/>
-    <breadcrumb />
-    <el-dropdown class="avatar-container" 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" class="user-dropdown">
-        <router-link class="inlineBlock" to="/">
-          <el-dropdown-item>
-            Home
-          </el-dropdown-item>
-        </router-link>
-        <el-dropdown-item divided>
-          <span style="display:block;" @click="logout">LogOut</span>
-        </el-dropdown-item>
-      </el-dropdown-menu>
-    </el-dropdown>
-  </div>
-</template>
-
-<script>
-import { mapGetters } from 'vuex'
-import Breadcrumb from '@/components/Breadcrumb'
-import Hamburger from '@/components/Hamburger'
-
-export default {
-  components: {
-    Breadcrumb,
-    Hamburger
-  },
-  computed: {
-    ...mapGetters([
-      'sidebar',
-      'avatar'
-    ])
-  },
-  methods: {
-    toggleSideBar() {
-      this.$store.dispatch('ToggleSideBar')
-    },
-    logout() {
-      this.$store.dispatch('LogOut').then(() => {
-        location.reload() // 为了重新实例化vue-router对象 避免bug
-      })
-    }
-  }
-}
-</script>
-
-<style rel="stylesheet/scss" lang="scss" scoped>
-.navbar {
-  height: 50px;
-  line-height: 50px;
-  box-shadow: 0 1px 3px 0 rgba(0,0,0,.12), 0 0 3px 0 rgba(0,0,0,.04);
-  .hamburger-container {
-    line-height: 58px;
-    height: 50px;
-    float: left;
-    padding: 0 10px;
-  }
-  .screenfull {
-    position: absolute;
-    right: 90px;
-    top: 16px;
-    color: red;
-  }
-  .avatar-container {
-    height: 50px;
-    display: inline-block;
-    position: absolute;
-    right: 35px;
-    .avatar-wrapper {
-      cursor: pointer;
-      margin-top: 5px;
-      position: relative;
-      line-height: initial;
-      .user-avatar {
-        width: 40px;
-        height: 40px;
-        border-radius: 10px;
-      }
-      .el-icon-caret-bottom {
-        position: absolute;
-        right: -20px;
-        top: 25px;
-        font-size: 12px;
-      }
-    }
-  }
-}
-</style>
-

+ 0 - 39
src/views/layout/components/Sidebar/index.vue

@@ -1,39 +0,0 @@
-<template>
-  <el-scrollbar wrap-class="scrollbar-wrapper">
-    <el-menu
-      :default-active="$route.path"
-      :collapse="isCollapse"
-      :background-color="variables.menuBg"
-      :text-color="variables.menuText"
-      :active-text-color="variables.menuActiveText"
-      :collapse-transition="false"
-      mode="vertical"
-    >
-      <sidebar-item v-for="route in routes" :key="route.path" :item="route" :base-path="route.path"/>
-    </el-menu>
-  </el-scrollbar>
-</template>
-
-<script>
-import { mapGetters } from 'vuex'
-import variables from '@/styles/variables.scss'
-import SidebarItem from './SidebarItem'
-
-export default {
-  components: { SidebarItem },
-  computed: {
-    ...mapGetters([
-      'sidebar'
-    ]),
-    routes() {
-      return this.$router.options.routes
-    },
-    variables() {
-      return variables
-    },
-    isCollapse() {
-      return !this.sidebar.opened
-    }
-  }
-}
-</script>

+ 0 - 40
src/views/layout/mixin/ResizeHandler.js

@@ -1,40 +0,0 @@
-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('CloseSideBar', { withoutAnimation: false })
-      }
-    }
-  },
-  beforeMount() {
-    window.addEventListener('resize', this.resizeHandler)
-  },
-  mounted() {
-    const isMobile = this.isMobile()
-    if (isMobile) {
-      store.dispatch('ToggleDevice', 'mobile')
-      store.dispatch('CloseSideBar', { withoutAnimation: true })
-    }
-  },
-  methods: {
-    isMobile() {
-      const rect = body.getBoundingClientRect()
-      return rect.width - 1 < WIDTH
-    },
-    resizeHandler() {
-      if (!document.hidden) {
-        const isMobile = this.isMobile()
-        store.dispatch('ToggleDevice', isMobile ? 'mobile' : 'desktop')
-
-        if (isMobile) {
-          store.dispatch('CloseSideBar', { withoutAnimation: true })
-        }
-      }
-    }
-  }
-}

+ 88 - 47
src/views/login/index.vue

@@ -1,57 +1,73 @@
 <template>
   <div class="login-container">
     <el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form" auto-complete="on" label-position="left">
-      <h3 class="title">vue-admin-template</h3>
+
+      <div class="title-container">
+        <h3 class="title">Login Form</h3>
+      </div>
+
       <el-form-item prop="username">
         <span class="svg-container">
           <svg-icon icon-class="user" />
         </span>
-        <el-input v-model="loginForm.username" name="username" type="text" auto-complete="on" placeholder="username" />
+        <el-input
+          ref="username"
+          v-model="loginForm.username"
+          placeholder="Username"
+          name="username"
+          type="text"
+          tabindex="1"
+          auto-complete="on"
+        />
       </el-form-item>
+
       <el-form-item prop="password">
         <span class="svg-container">
           <svg-icon icon-class="password" />
         </span>
         <el-input
-          :type="pwdType"
+          :key="passwordType"
+          ref="password"
           v-model="loginForm.password"
+          :type="passwordType"
+          placeholder="Password"
           name="password"
+          tabindex="2"
           auto-complete="on"
-          placeholder="password"
-          @keyup.enter.native="handleLogin" />
+          @keyup.enter.native="handleLogin"
+        />
         <span class="show-pwd" @click="showPwd">
-          <svg-icon :icon-class="pwdType === 'password' ? 'eye' : 'eye-open'" />
+          <svg-icon :icon-class="passwordType === 'password' ? 'eye' : 'eye-open'" />
         </span>
       </el-form-item>
-      <el-form-item>
-        <el-button :loading="loading" type="primary" style="width:100%;" @click.native.prevent="handleLogin">
-          Sign in
-        </el-button>
-      </el-form-item>
+
+      <el-button :loading="loading" type="primary" style="width:100%;margin-bottom:30px;" @click.native.prevent="handleLogin">Login</el-button>
+
       <div class="tips">
         <span style="margin-right:20px;">username: admin</span>
-        <span> password: admin</span>
+        <span> password: any</span>
       </div>
+
     </el-form>
   </div>
 </template>
 
 <script>
-import { isvalidUsername } from '@/utils/validate'
+import { validUsername } from '@/utils/validate'
 
 export default {
   name: 'Login',
   data() {
     const validateUsername = (rule, value, callback) => {
-      if (!isvalidUsername(value)) {
-        callback(new Error('请输入正确的用户名'))
+      if (!validUsername(value)) {
+        callback(new Error('Please enter the correct user name'))
       } else {
         callback()
       }
     }
-    const validatePass = (rule, value, callback) => {
-      if (value.length < 5) {
-        callback(new Error('密码不能小于5位'))
+    const validatePassword = (rule, value, callback) => {
+      if (value.length < 6) {
+        callback(new Error('The password can not be less than 6 digits'))
       } else {
         callback()
       }
@@ -59,14 +75,14 @@ export default {
     return {
       loginForm: {
         username: 'admin',
-        password: 'admin'
+        password: '111111'
       },
       loginRules: {
         username: [{ required: true, trigger: 'blur', validator: validateUsername }],
-        password: [{ required: true, trigger: 'blur', validator: validatePass }]
+        password: [{ required: true, trigger: 'blur', validator: validatePassword }]
       },
       loading: false,
-      pwdType: 'password',
+      passwordType: 'password',
       redirect: undefined
     }
   },
@@ -80,19 +96,22 @@ export default {
   },
   methods: {
     showPwd() {
-      if (this.pwdType === 'password') {
-        this.pwdType = ''
+      if (this.passwordType === 'password') {
+        this.passwordType = ''
       } else {
-        this.pwdType = 'password'
+        this.passwordType = 'password'
       }
+      this.$nextTick(() => {
+        this.$refs.password.focus()
+      })
     },
     handleLogin() {
       this.$refs.loginForm.validate(valid => {
         if (valid) {
           this.loading = true
-          this.$store.dispatch('Login', this.loginForm).then(() => {
-            this.loading = false
+          this.$store.dispatch('user/login', this.loginForm).then(() => {
             this.$router.push({ path: this.redirect || '/' })
+            this.loading = false
           }).catch(() => {
             this.loading = false
           })
@@ -106,9 +125,19 @@ export default {
 }
 </script>
 
-<style rel="stylesheet/scss" lang="scss">
-$bg:#2d3a4b;
-$light_gray:#eee;
+<style lang="scss">
+/* 修复input 背景不协调 和光标变色 */
+/* Detail see https://github.com/PanJiaChen/vue-element-admin/pull/927 */
+
+$bg:#283443;
+$light_gray:#fff;
+$cursor: #fff;
+
+@supports (-webkit-mask: none) and (not (cater-color: $cursor)) {
+  .login-container .el-input input {
+    color: $cursor;
+  }
+}
 
 /* reset element-ui css */
 .login-container {
@@ -116,6 +145,7 @@ $light_gray:#eee;
     display: inline-block;
     height: 47px;
     width: 85%;
+
     input {
       background: transparent;
       border: 0px;
@@ -124,12 +154,15 @@ $light_gray:#eee;
       padding: 12px 5px 12px 15px;
       color: $light_gray;
       height: 47px;
+      caret-color: $cursor;
+
       &:-webkit-autofill {
-        -webkit-box-shadow: 0 0 0px 1000px $bg inset !important;
-        -webkit-text-fill-color: #fff !important;
+        box-shadow: 0 0 0px 1000px $bg inset !important;
+        -webkit-text-fill-color: $cursor !important;
       }
     }
   }
+
   .el-form-item {
     border: 1px solid rgba(255, 255, 255, 0.1);
     background: rgba(0, 0, 0, 0.1);
@@ -137,37 +170,40 @@ $light_gray:#eee;
     color: #454545;
   }
 }
-
 </style>
 
-<style rel="stylesheet/scss" lang="scss" scoped>
+<style lang="scss" scoped>
 $bg:#2d3a4b;
 $dark_gray:#889aa4;
 $light_gray:#eee;
+
 .login-container {
-  position: fixed;
-  height: 100%;
+  min-height: 100%;
   width: 100%;
   background-color: $bg;
+  overflow: hidden;
+
   .login-form {
-    position: absolute;
-    left: 0;
-    right: 0;
+    position: relative;
     width: 520px;
     max-width: 100%;
-    padding: 35px 35px 15px 35px;
-    margin: 120px auto;
+    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: $dark_gray;
@@ -175,14 +211,19 @@ $light_gray:#eee;
     width: 30px;
     display: inline-block;
   }
-  .title {
-    font-size: 26px;
-    font-weight: 400;
-    color: $light_gray;
-    margin: 0px auto 40px auto;
-    text-align: center;
-    font-weight: bold;
+
+  .title-container {
+    position: relative;
+
+    .title {
+      font-size: 26px;
+      color: $light_gray;
+      margin: 0px auto 40px auto;
+      text-align: center;
+      font-weight: bold;
+    }
   }
+
   .show-pwd {
     position: absolute;
     right: 10px;

+ 1 - 1
src/views/nested/menu1/index.vue

@@ -1,4 +1,4 @@
-<template >
+<template>
   <div style="padding:30px;">
     <el-alert :closable="false" title="menu 1">
       <router-view />

+ 1 - 1
src/views/nested/menu1/menu1-1/index.vue

@@ -1,4 +1,4 @@
-<template >
+<template>
   <div style="padding:30px;">
     <el-alert :closable="false" title="menu 1-1" type="success">
       <router-view />

+ 4 - 3
src/views/table/index.vue

@@ -6,7 +6,8 @@
       element-loading-text="Loading"
       border
       fit
-      highlight-current-row>
+      highlight-current-row
+    >
       <el-table-column align="center" label="ID" width="95">
         <template slot-scope="scope">
           {{ scope.$index }}
@@ -34,7 +35,7 @@
       </el-table-column>
       <el-table-column align="center" prop="created_at" label="Display_time" width="200">
         <template slot-scope="scope">
-          <i class="el-icon-time"/>
+          <i class="el-icon-time" />
           <span>{{ scope.row.display_time }}</span>
         </template>
       </el-table-column>
@@ -68,7 +69,7 @@ export default {
   methods: {
     fetchData() {
       this.listLoading = true
-      getList(this.listQuery).then(response => {
+      getList().then(response => {
         this.list = response.data.items
         this.listLoading = false
       })

+ 0 - 0
static/.gitkeep


+ 5 - 0
tests/unit/.eslintrc.js

@@ -0,0 +1,5 @@
+module.exports = {
+  env: {
+    jest: true
+  }
+}

+ 98 - 0
tests/unit/components/Breadcrumb.spec.js

@@ -0,0 +1,98 @@
+import { mount, createLocalVue } from '@vue/test-utils'
+import VueRouter from 'vue-router'
+import ElementUI from 'element-ui'
+import Breadcrumb from '@/components/Breadcrumb/index.vue'
+
+const localVue = createLocalVue()
+localVue.use(VueRouter)
+localVue.use(ElementUI)
+
+const routes = [
+  {
+    path: '/',
+    name: 'home',
+    children: [{
+      path: 'dashboard',
+      name: 'dashboard'
+    }]
+  },
+  {
+    path: '/menu',
+    name: 'menu',
+    children: [{
+      path: 'menu1',
+      name: 'menu1',
+      meta: { title: 'menu1' },
+      children: [{
+        path: 'menu1-1',
+        name: 'menu1-1',
+        meta: { title: 'menu1-1' }
+      },
+      {
+        path: 'menu1-2',
+        name: 'menu1-2',
+        redirect: 'noredirect',
+        meta: { title: 'menu1-2' },
+        children: [{
+          path: 'menu1-2-1',
+          name: 'menu1-2-1',
+          meta: { title: 'menu1-2-1' }
+        },
+        {
+          path: 'menu1-2-2',
+          name: 'menu1-2-2'
+        }]
+      }]
+    }]
+  }]
+
+const router = new VueRouter({
+  routes
+})
+
+describe('Breadcrumb.vue', () => {
+  const wrapper = mount(Breadcrumb, {
+    localVue,
+    router
+  })
+  it('dashboard', () => {
+    router.push('/dashboard')
+    const len = wrapper.findAll('.el-breadcrumb__inner').length
+    expect(len).toBe(1)
+  })
+  it('normal route', () => {
+    router.push('/menu/menu1')
+    const len = wrapper.findAll('.el-breadcrumb__inner').length
+    expect(len).toBe(2)
+  })
+  it('nested route', () => {
+    router.push('/menu/menu1/menu1-2/menu1-2-1')
+    const len = wrapper.findAll('.el-breadcrumb__inner').length
+    expect(len).toBe(4)
+  })
+  it('no meta.title', () => {
+    router.push('/menu/menu1/menu1-2/menu1-2-2')
+    const len = wrapper.findAll('.el-breadcrumb__inner').length
+    expect(len).toBe(3)
+  })
+  // it('click link', () => {
+  //   router.push('/menu/menu1/menu1-2/menu1-2-2')
+  //   const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner')
+  //   const second = breadcrumbArray.at(1)
+  //   console.log(breadcrumbArray)
+  //   const href = second.find('a').attributes().href
+  //   expect(href).toBe('#/menu/menu1')
+  // })
+  // it('noRedirect', () => {
+  //   router.push('/menu/menu1/menu1-2/menu1-2-1')
+  //   const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner')
+  //   const redirectBreadcrumb = breadcrumbArray.at(2)
+  //   expect(redirectBreadcrumb.contains('a')).toBe(false)
+  // })
+  it('last breadcrumb', () => {
+    router.push('/menu/menu1/menu1-2/menu1-2-1')
+    const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner')
+    const redirectBreadcrumb = breadcrumbArray.at(3)
+    expect(redirectBreadcrumb.contains('a')).toBe(false)
+  })
+})

+ 18 - 0
tests/unit/components/Hamburger.spec.js

@@ -0,0 +1,18 @@
+import { shallowMount } from '@vue/test-utils'
+import Hamburger from '@/components/Hamburger/index.vue'
+describe('Hamburger.vue', () => {
+  it('toggle click', () => {
+    const wrapper = shallowMount(Hamburger)
+    const mockFn = jest.fn()
+    wrapper.vm.$on('toggleClick', mockFn)
+    wrapper.find('.hamburger').trigger('click')
+    expect(mockFn).toBeCalled()
+  })
+  it('prop isActive', () => {
+    const wrapper = shallowMount(Hamburger)
+    wrapper.setProps({ isActive: true })
+    expect(wrapper.contains('.is-active')).toBe(true)
+    wrapper.setProps({ isActive: false })
+    expect(wrapper.contains('.is-active')).toBe(false)
+  })
+})

+ 22 - 0
tests/unit/components/SvgIcon.spec.js

@@ -0,0 +1,22 @@
+import { shallowMount } from '@vue/test-utils'
+import SvgIcon from '@/components/SvgIcon/index.vue'
+describe('SvgIcon.vue', () => {
+  it('iconClass', () => {
+    const wrapper = shallowMount(SvgIcon, {
+      propsData: {
+        iconClass: 'test'
+      }
+    })
+    expect(wrapper.find('use').attributes().href).toBe('#icon-test')
+  })
+  it('className', () => {
+    const wrapper = shallowMount(SvgIcon, {
+      propsData: {
+        iconClass: 'test'
+      }
+    })
+    expect(wrapper.classes().length).toBe(1)
+    wrapper.setProps({ className: 'test' })
+    expect(wrapper.classes().includes('test')).toBe(true)
+  })
+})

+ 30 - 0
tests/unit/utils/formatTime.spec.js

@@ -0,0 +1,30 @@
+import { formatTime } from '@/utils/index.js'
+
+describe('Utils:formatTime', () => {
+  const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
+  const retrofit = 5 * 1000
+
+  it('ten digits timestamp', () => {
+    expect(formatTime((d / 1000).toFixed(0))).toBe('7月13日17时54分')
+  })
+  it('test now', () => {
+    expect(formatTime(+new Date() - 1)).toBe('刚刚')
+  })
+  it('less two minute', () => {
+    expect(formatTime(+new Date() - 60 * 2 * 1000 + retrofit)).toBe('2分钟前')
+  })
+  it('less two hour', () => {
+    expect(formatTime(+new Date() - 60 * 60 * 2 * 1000 + retrofit)).toBe('2小时前')
+  })
+  it('less one day', () => {
+    expect(formatTime(+new Date() - 60 * 60 * 24 * 1 * 1000)).toBe('1天前')
+  })
+  it('more than one day', () => {
+    expect(formatTime(d)).toBe('7月13日17时54分')
+  })
+  it('format', () => {
+    expect(formatTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
+    expect(formatTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
+    expect(formatTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
+  })
+})

+ 28 - 0
tests/unit/utils/parseTime.spec.js

@@ -0,0 +1,28 @@
+import { parseTime } from '@/utils/index.js'
+
+describe('Utils:parseTime', () => {
+  const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
+  it('timestamp', () => {
+    expect(parseTime(d)).toBe('2018-07-13 17:54:01')
+  })
+  it('ten digits timestamp', () => {
+    expect(parseTime((d / 1000).toFixed(0))).toBe('2018-07-13 17:54:01')
+  })
+  it('new Date', () => {
+    expect(parseTime(new Date(d))).toBe('2018-07-13 17:54:01')
+  })
+  it('format', () => {
+    expect(parseTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
+    expect(parseTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
+    expect(parseTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
+  })
+  it('get the day of the week', () => {
+    expect(parseTime(d, '{a}')).toBe('五') // 星期五
+  })
+  it('get the day of the week', () => {
+    expect(parseTime(+d + 1000 * 60 * 60 * 24 * 2, '{a}')).toBe('日') // 星期日
+  })
+  it('empty argument', () => {
+    expect(parseTime()).toBeNull()
+  })
+})

+ 17 - 0
tests/unit/utils/validate.spec.js

@@ -0,0 +1,17 @@
+import { validUsername, isExternal } from '@/utils/validate.js'
+
+describe('Utils:validate', () => {
+  it('validUsername', () => {
+    expect(validUsername('admin')).toBe(true)
+    expect(validUsername('editor')).toBe(true)
+    expect(validUsername('xxxx')).toBe(false)
+  })
+  it('isExternal', () => {
+    expect(isExternal('https://github.com/PanJiaChen/vue-element-admin')).toBe(true)
+    expect(isExternal('http://github.com/PanJiaChen/vue-element-admin')).toBe(true)
+    expect(isExternal('github.com/PanJiaChen/vue-element-admin')).toBe(false)
+    expect(isExternal('/dashboard')).toBe(false)
+    expect(isExternal('./dashboard')).toBe(false)
+    expect(isExternal('dashboard')).toBe(false)
+  })
+})

+ 133 - 0
vue.config.js

@@ -0,0 +1,133 @@
+'use strict'
+const path = require('path')
+const defaultSettings = require('./src/settings.js')
+
+function resolve(dir) {
+  return path.join(__dirname, dir)
+}
+
+const name = defaultSettings.title || 'vue Admin Template' // page title
+const port = 9528 // dev port
+
+// All configuration item explanations can be find in https://cli.vuejs.org/config/
+module.exports = {
+  /**
+   * You will need to set publicPath if you plan to deploy your site under a sub path,
+   * for example GitHub Pages. If you plan to deploy your site to https://foo.github.io/bar/,
+   * then publicPath should be set to "/bar/".
+   * In most cases please use '/' !!!
+   * Detail: https://cli.vuejs.org/config/#publicpath
+   */
+  publicPath: '/',
+  outputDir: 'dist',
+  assetsDir: 'static',
+  lintOnSave: process.env.NODE_ENV === 'development',
+  productionSourceMap: false,
+  devServer: {
+    port: port,
+    open: true,
+    overlay: {
+      warnings: false,
+      errors: true
+    },
+    proxy: {
+      // change xxx-api/login => mock/login
+      // detail: https://cli.vuejs.org/config/#devserver-proxy
+      [process.env.VUE_APP_BASE_API]: {
+        target: `http://localhost:${port}/mock`,
+        changeOrigin: true,
+        pathRewrite: {
+          ['^' + process.env.VUE_APP_BASE_API]: ''
+        }
+      }
+    },
+    after: require('./mock/mock-server.js')
+  },
+  configureWebpack: {
+    // provide the app's title in webpack's name field, so that
+    // it can be accessed in index.html to inject the correct title.
+    name: name,
+    resolve: {
+      alias: {
+        '@': resolve('src')
+      }
+    }
+  },
+  chainWebpack(config) {
+    config.plugins.delete('preload') // TODO: need test
+    config.plugins.delete('prefetch') // TODO: need test
+
+    // set svg-sprite-loader
+    config.module
+      .rule('svg')
+      .exclude.add(resolve('src/icons'))
+      .end()
+    config.module
+      .rule('icons')
+      .test(/\.svg$/)
+      .include.add(resolve('src/icons'))
+      .end()
+      .use('svg-sprite-loader')
+      .loader('svg-sprite-loader')
+      .options({
+        symbolId: 'icon-[name]'
+      })
+      .end()
+
+    // set preserveWhitespace
+    config.module
+      .rule('vue')
+      .use('vue-loader')
+      .loader('vue-loader')
+      .tap(options => {
+        options.compilerOptions.preserveWhitespace = true
+        return options
+      })
+      .end()
+
+    config
+    // https://webpack.js.org/configuration/devtool/#development
+      .when(process.env.NODE_ENV === 'development',
+        config => config.devtool('cheap-source-map')
+      )
+
+    config
+      .when(process.env.NODE_ENV !== 'development',
+        config => {
+          config
+            .plugin('ScriptExtHtmlWebpackPlugin')
+            .after('html')
+            .use('script-ext-html-webpack-plugin', [{
+            // `runtime` must same as runtimeChunk name. default is `runtime`
+              inline: /runtime\..*\.js$/
+            }])
+            .end()
+          config
+            .optimization.splitChunks({
+              chunks: 'all',
+              cacheGroups: {
+                libs: {
+                  name: 'chunk-libs',
+                  test: /[\\/]node_modules[\\/]/,
+                  priority: 10,
+                  chunks: 'initial' // only package third parties that are initially dependent
+                },
+                elementUI: {
+                  name: 'chunk-elementUI', // split elementUI into a single package
+                  priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
+                  test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
+                },
+                commons: {
+                  name: 'chunk-commons',
+                  test: resolve('src/components'), // can customize your rules
+                  minChunks: 3, //  minimum common number
+                  priority: 5,
+                  reuseExistingChunk: true
+                }
+              }
+            })
+          config.optimization.runtimeChunk('single')
+        }
+      )
+  }
+}