├── src ├── views │ ├── product │ │ ├── new.vue │ │ └── index.vue │ ├── form │ │ ├── adminList │ │ │ └── index.vue │ │ ├── addPerson.vue │ │ └── index.vue │ ├── nested │ │ ├── menu1 │ │ │ ├── index.vue │ │ │ ├── menu1-3 │ │ │ │ └── index.vue │ │ │ ├── menu1-2 │ │ │ │ └── index.vue │ │ │ └── menu1-1 │ │ │ │ └── index.vue │ │ └── menu2 │ │ │ └── index.vue │ ├── permission │ │ ├── page.vue │ │ ├── components │ │ │ └── SwitchRoles.vue │ │ └── directive.vue │ ├── dashboard │ │ └── index.vue │ ├── tree │ │ └── index.vue │ ├── table │ │ └── index.vue │ ├── 404.vue │ └── login │ │ └── index.vue ├── assets │ ├── earth.jpg │ └── 404_images │ │ ├── 404.png │ │ └── 404_cloud.png ├── layout │ ├── components │ │ ├── index.js │ │ ├── Sidebar │ │ │ ├── Item.vue │ │ │ ├── Link.vue │ │ │ ├── FixiOSBug.js │ │ │ ├── index.vue │ │ │ ├── Logo.vue │ │ │ └── SidebarItem.vue │ │ ├── AppMain.vue │ │ └── Navbar.vue │ ├── mixin │ │ └── ResizeHandler.js │ └── index.vue ├── App.vue ├── api │ ├── table.js │ └── user.js ├── icons │ ├── svg │ │ ├── link.svg │ │ ├── user.svg │ │ ├── example.svg │ │ ├── table.svg │ │ ├── password.svg │ │ ├── nested.svg │ │ ├── eye.svg │ │ ├── eye-open.svg │ │ ├── tree.svg │ │ ├── dashboard.svg │ │ └── form.svg │ ├── index.js │ └── svgo.yml ├── utils │ ├── get-page-title.js │ ├── auth.js │ ├── validate.js │ ├── permission.js │ ├── request.js │ └── index.js ├── directive │ ├── waves │ │ ├── index.js │ │ ├── waves.css │ │ └── waves.js │ ├── el-drag-dialog │ │ ├── index.js │ │ └── drag.js │ ├── clipboard │ │ ├── index.js │ │ └── clipboard.js │ ├── permission │ │ ├── index.js │ │ └── permission.js │ ├── el-table │ │ ├── index.js │ │ └── adaptive.js │ └── sticky.js ├── settings.js ├── styles │ ├── mixin.scss │ ├── variables.scss │ ├── element-ui.scss │ ├── transition.scss │ ├── index.scss │ └── sidebar.scss ├── store │ ├── getters.js │ ├── modules │ │ ├── settings.js │ │ ├── app.js │ │ ├── permission.js │ │ ├── user.js │ │ └── tagsView.js │ └── index.js ├── main.js ├── components │ ├── Hamburger │ │ └── index.vue │ ├── SvgIcon │ │ └── index.vue │ └── Breadcrumb │ │ └── index.vue ├── permission.js └── router │ └── index.js ├── debug.log ├── babel.config.js ├── tests └── unit │ ├── .eslintrc.js │ ├── components │ ├── Hamburger.spec.js │ ├── SvgIcon.spec.js │ └── Breadcrumb.spec.js │ └── utils │ ├── validate.spec.js │ ├── parseTime.spec.js │ └── formatTime.spec.js ├── public ├── favicon.ico └── index.html ├── jsconfig.json ├── postcss.config.js ├── mock ├── product.js ├── table.js ├── index.js ├── mock-server.js └── user.js ├── jest.config.js ├── LICENSE ├── package.json ├── README.md ├── README-zh.md └── vue.config.js /src/views/product/new.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /debug.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rainnnnyall/huishouplatform/HEAD/debug.log -------------------------------------------------------------------------------- /src/views/product/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rainnnnyall/huishouplatform/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/earth.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rainnnnyall/huishouplatform/HEAD/src/assets/earth.jpg -------------------------------------------------------------------------------- /src/views/form/adminList/index.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/404_images/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rainnnnyall/huishouplatform/HEAD/src/assets/404_images/404.png -------------------------------------------------------------------------------- /src/assets/404_images/404_cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rainnnnyall/huishouplatform/HEAD/src/assets/404_images/404_cloud.png -------------------------------------------------------------------------------- /src/views/nested/menu1/index.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/layout/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Navbar } from './Navbar' 2 | export { default as Sidebar } from './Sidebar' 3 | export { default as AppMain } from './AppMain' 4 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | } 7 | }, 8 | "exclude": ["node_modules", "dist"] 9 | } 10 | -------------------------------------------------------------------------------- /src/views/nested/menu1/menu1-3/index.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/views/form/addPerson.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/nested/menu1/menu1-2/index.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/api/table.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request' 2 | 3 | export function getList(params) { 4 | return request({ 5 | url: '/vue-admin-template/table/list', 6 | method: 'get', 7 | params 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | 'plugins': { 5 | // to edit target browsers: use "browserslist" field in package.json 6 | 'autoprefixer': {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/icons/svg/link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/get-page-title.js: -------------------------------------------------------------------------------- 1 | import defaultSettings from '@/settings' 2 | 3 | const title = defaultSettings.title || 'Vue Admin Template' 4 | 5 | export default function getPageTitle(pageTitle) { 6 | if (pageTitle) { 7 | return `${pageTitle} - ${title}` 8 | } 9 | return `${title}` 10 | } 11 | -------------------------------------------------------------------------------- /src/directive/waves/index.js: -------------------------------------------------------------------------------- 1 | import waves from './waves' 2 | 3 | const install = function(Vue) { 4 | Vue.directive('waves', waves) 5 | } 6 | 7 | if (window.Vue) { 8 | window.waves = waves 9 | Vue.use(install); // eslint-disable-line 10 | } 11 | 12 | waves.install = install 13 | export default waves 14 | -------------------------------------------------------------------------------- /src/directive/el-drag-dialog/index.js: -------------------------------------------------------------------------------- 1 | import drag from './drag' 2 | 3 | const install = function(Vue) { 4 | Vue.directive('el-drag-dialog', drag) 5 | } 6 | 7 | if (window.Vue) { 8 | window['el-drag-dialog'] = drag 9 | Vue.use(install); // eslint-disable-line 10 | } 11 | 12 | drag.install = install 13 | export default drag 14 | -------------------------------------------------------------------------------- /src/icons/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import SvgIcon from '@/components/SvgIcon'// svg component 3 | 4 | // register globally 5 | Vue.component('svg-icon', SvgIcon) 6 | 7 | const req = require.context('./svg', false, /\.svg$/) 8 | const requireAll = requireContext => requireContext.keys().map(requireContext) 9 | requireAll(req) 10 | -------------------------------------------------------------------------------- /src/directive/clipboard/index.js: -------------------------------------------------------------------------------- 1 | import Clipboard from './clipboard' 2 | 3 | const install = function(Vue) { 4 | Vue.directive('Clipboard', Clipboard) 5 | } 6 | 7 | if (window.Vue) { 8 | window.clipboard = Clipboard 9 | Vue.use(install); // eslint-disable-line 10 | } 11 | 12 | Clipboard.install = install 13 | export default Clipboard 14 | -------------------------------------------------------------------------------- /src/directive/permission/index.js: -------------------------------------------------------------------------------- 1 | import permission from './permission' 2 | 3 | const install = function(Vue) { 4 | Vue.directive('permission', permission) 5 | } 6 | 7 | if (window.Vue) { 8 | window['permission'] = permission 9 | Vue.use(install); // eslint-disable-line 10 | } 11 | 12 | permission.install = install 13 | export default permission 14 | -------------------------------------------------------------------------------- /src/icons/svgo.yml: -------------------------------------------------------------------------------- 1 | # replace default config 2 | 3 | # multipass: true 4 | # full: true 5 | 6 | plugins: 7 | 8 | # - name 9 | # 10 | # or: 11 | # - name: false 12 | # - name: true 13 | # 14 | # or: 15 | # - name: 16 | # param1: 1 17 | # param2: 2 18 | 19 | - removeAttrs: 20 | attrs: 21 | - 'fill' 22 | - 'fill-rule' 23 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | title: 'Vue Admin Template', 4 | 5 | /** 6 | * @type {boolean} true | false 7 | * @description Whether fix the header 8 | */ 9 | fixedHeader: true, 10 | 11 | /** 12 | * @type {boolean} true | false 13 | * @description Whether show the logo in sidebar 14 | */ 15 | sidebarLogo: true 16 | } 17 | -------------------------------------------------------------------------------- /src/directive/el-table/index.js: -------------------------------------------------------------------------------- 1 | import adaptive from './adaptive' 2 | 3 | const install = function(Vue) { 4 | Vue.directive('el-height-adaptive-table', adaptive) 5 | } 6 | 7 | if (window.Vue) { 8 | window['el-height-adaptive-table'] = adaptive 9 | Vue.use(install); // eslint-disable-line 10 | } 11 | 12 | adaptive.install = install 13 | export default adaptive 14 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | //设置获取tocken 2 | import Cookies from 'js-cookie' 3 | 4 | const TokenKey = 'vue_admin_template_token' 5 | 6 | export function getToken() { 7 | return Cookies.get(TokenKey) 8 | } 9 | 10 | export function setToken(token) { 11 | return Cookies.set(TokenKey, token) 12 | } 13 | 14 | export function removeToken() { 15 | return Cookies.remove(TokenKey) 16 | } 17 | -------------------------------------------------------------------------------- /src/icons/svg/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/permission/page.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /src/icons/svg/example.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/validate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by PanJiaChen on 16/11/18. 3 | */ 4 | 5 | /** 6 | * @param {string} path 7 | * @returns {Boolean} 8 | */ 9 | export function isExternal(path) { 10 | return /^(https?:|mailto:|tel:)/.test(path) 11 | } 12 | 13 | /** 14 | * @param {string} str 15 | * @returns {Boolean} 16 | */ 17 | export function validUsername(str) { 18 | if(str.trim()){ 19 | return true 20 | } 21 | // const valid_map = ['admin', 'recyclrer','user'] 22 | // return str.trim()>= 0 23 | } 24 | -------------------------------------------------------------------------------- /src/styles/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin clearfix { 2 | &:after { 3 | content: ""; 4 | display: table; 5 | clear: both; 6 | } 7 | } 8 | 9 | @mixin scrollBar { 10 | &::-webkit-scrollbar-track-piece { 11 | background: #d3dce6; 12 | } 13 | 14 | &::-webkit-scrollbar { 15 | width: 6px; 16 | } 17 | 18 | &::-webkit-scrollbar-thumb { 19 | background: #99a9bf; 20 | border-radius: 20px; 21 | } 22 | } 23 | 24 | @mixin relative { 25 | position: relative; 26 | width: 100%; 27 | height: 100%; 28 | } 29 | -------------------------------------------------------------------------------- /src/api/user.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request' 2 | 3 | export function login(data) { 4 | return request({ 5 | url: '/vue-admin-template/user/login', 6 | method: 'post', 7 | data 8 | }) 9 | } 10 | 11 | export function getInfo(token) { 12 | return request({ 13 | url: '/vue-admin-template/user/info', 14 | method: 'get', 15 | params: { token } 16 | }) 17 | } 18 | 19 | export function logout() { 20 | return request({ 21 | url: '/vue-admin-template/user/logout', 22 | method: 'post' 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | const getters = { 2 | sidebar: state => state.app.sidebar, 3 | device: state => state.app.device, 4 | token: state => state.user.token, 5 | avatar: state => state.user.avatar, 6 | name: state => state.user.name, 7 | introduction: state => state.user.introduction, 8 | roles: state => state.user.roles, 9 | permission_routes: state => state.permission.routes, 10 | visitedViews: state => state.tagsView.visitedViews, 11 | cachedViews: state => state.tagsView.cachedViews 12 | } 13 | export default getters 14 | -------------------------------------------------------------------------------- /src/icons/svg/table.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/svg/password.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/Item.vue: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /src/directive/permission/permission.js: -------------------------------------------------------------------------------- 1 | import store from '@/store' 2 | 3 | export default { 4 | inserted(el, binding, vnode) { 5 | const { value } = binding 6 | const roles = store.getters && store.getters.roles 7 | 8 | if (value && value instanceof Array && value.length > 0) { 9 | const permissionRoles = value 10 | 11 | const hasPermission = roles.some(role => { 12 | return permissionRoles.includes(role) 13 | }) 14 | 15 | if (!hasPermission) { 16 | el.parentNode && el.parentNode.removeChild(el) 17 | } 18 | } else { 19 | throw new Error(`need roles! Like v-permission="['admin','editor']"`) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= webpackConfig.name %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/store/modules/settings.js: -------------------------------------------------------------------------------- 1 | //菜单栏logo的配置 2 | import defaultSettings from '@/settings' 3 | 4 | const { showSettings, fixedHeader, sidebarLogo } = defaultSettings 5 | 6 | const state = { 7 | showSettings: showSettings, 8 | fixedHeader: fixedHeader, 9 | sidebarLogo: sidebarLogo 10 | } 11 | 12 | const mutations = { 13 | CHANGE_SETTING: (state, { key, value }) => { 14 | if (state.hasOwnProperty(key)) { 15 | state[key] = value 16 | } 17 | } 18 | } 19 | 20 | const actions = { 21 | changeSetting({ commit }, data) { 22 | commit('CHANGE_SETTING', data) 23 | } 24 | } 25 | 26 | export default { 27 | namespaced: true, 28 | state, 29 | mutations, 30 | actions 31 | } 32 | 33 | -------------------------------------------------------------------------------- /mock/product.js: -------------------------------------------------------------------------------- 1 | import Mock from 'mockjs' 2 | 3 | const data = Mock.mock({ 4 | //造了30条数据 5 | 'products|10': [{ 6 | 'id|+1': 1, 7 | name: '@sentence(10, 20)', 8 | 'price|1000-2000': 1234, 9 | 'comment|1-30': [ 10 | { 11 | 'id':'@id', 12 | 'content': '@paragraph' 13 | } 14 | ] 15 | }] 16 | }) 17 | 18 | 19 | export default [ 20 | { 21 | url: '/vue-admin-template/product/list', 22 | type: 'get', 23 | response: config => { 24 | const products = data.products 25 | return { 26 | code: 20000, 27 | data: { 28 | total: products.length, 29 | items: products 30 | } 31 | } 32 | } 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | // sidebar 2 | $menuText:#bfcbd9; 3 | $menuActiveText:#409EFF; 4 | $subMenuActiveText:#f4f4f5; //https://github.com/ElemeFE/element/issues/12951 5 | 6 | $menuBg:#304156; 7 | $menuHover:#263445; 8 | 9 | $subMenuBg:#1f2d3d; 10 | $subMenuHover:#001528; 11 | 12 | $sideBarWidth: 210px; 13 | 14 | // the :export directive is the magic sauce for webpack 15 | // https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass 16 | :export { 17 | menuText: $menuText; 18 | menuActiveText: $menuActiveText; 19 | subMenuActiveText: $subMenuActiveText; 20 | menuBg: $menuBg; 21 | menuHover: $menuHover; 22 | subMenuBg: $subMenuBg; 23 | subMenuHover: $subMenuHover; 24 | sideBarWidth: $sideBarWidth; 25 | } 26 | -------------------------------------------------------------------------------- /tests/unit/components/Hamburger.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | import Hamburger from '@/components/Hamburger/index.vue' 3 | describe('Hamburger.vue', () => { 4 | it('toggle click', () => { 5 | const wrapper = shallowMount(Hamburger) 6 | const mockFn = jest.fn() 7 | wrapper.vm.$on('toggleClick', mockFn) 8 | wrapper.find('.hamburger').trigger('click') 9 | expect(mockFn).toBeCalled() 10 | }) 11 | it('prop isActive', () => { 12 | const wrapper = shallowMount(Hamburger) 13 | wrapper.setProps({ isActive: true }) 14 | expect(wrapper.contains('.is-active')).toBe(true) 15 | wrapper.setProps({ isActive: false }) 16 | expect(wrapper.contains('.is-active')).toBe(false) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /tests/unit/components/SvgIcon.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | import SvgIcon from '@/components/SvgIcon/index.vue' 3 | describe('SvgIcon.vue', () => { 4 | it('iconClass', () => { 5 | const wrapper = shallowMount(SvgIcon, { 6 | propsData: { 7 | iconClass: 'test' 8 | } 9 | }) 10 | expect(wrapper.find('use').attributes().href).toBe('#icon-test') 11 | }) 12 | it('className', () => { 13 | const wrapper = shallowMount(SvgIcon, { 14 | propsData: { 15 | iconClass: 'test' 16 | } 17 | }) 18 | expect(wrapper.classes().length).toBe(1) 19 | wrapper.setProps({ className: 'test' }) 20 | expect(wrapper.classes().includes('test')).toBe(true) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/Link.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 37 | -------------------------------------------------------------------------------- /src/utils/permission.js: -------------------------------------------------------------------------------- 1 | import store from '@/store' 2 | 3 | /** 4 | * 检测当前登录用户是否含有指定的角色,在页面使用,用于根据当前登录用户的角色显示不同的页面 5 | * @param {Array} value 6 | * @returns {Boolean} 7 | * @example see @/views/permission/directive.vue 8 | */ 9 | export default function checkPermission(value) { 10 | if (value && value instanceof Array && value.length > 0) { 11 | const roles = store.getters && store.getters.roles 12 | const permissionRoles = value 13 | const hasPermission = roles.some(role => { 14 | return permissionRoles.includes(role) 15 | }) 16 | 17 | if (!hasPermission) { 18 | return false 19 | } 20 | return true 21 | } else { 22 | console.error(`need roles! Like v-permission="['admin','editor']"`) 23 | return false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/FixiOSBug.js: -------------------------------------------------------------------------------- 1 | export default { 2 | computed: { 3 | device() { 4 | return this.$store.state.app.device 5 | } 6 | }, 7 | mounted() { 8 | // In order to fix the click on menu on the ios device will trigger the mouseleave bug 9 | // https://github.com/PanJiaChen/vue-element-admin/issues/1135 10 | this.fixBugIniOS() 11 | }, 12 | methods: { 13 | fixBugIniOS() { 14 | const $subMenu = this.$refs.subMenu 15 | if ($subMenu) { 16 | const handleMouseleave = $subMenu.handleMouseleave 17 | $subMenu.handleMouseleave = (e) => { 18 | if (this.device === 'mobile') { 19 | return 20 | } 21 | handleMouseleave(e) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/views/permission/components/SwitchRoles.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 33 | -------------------------------------------------------------------------------- /tests/unit/utils/validate.spec.js: -------------------------------------------------------------------------------- 1 | import { validUsername, isExternal } from '@/utils/validate.js' 2 | 3 | describe('Utils:validate', () => { 4 | it('validUsername', () => { 5 | expect(validUsername('admin')).toBe(true) 6 | expect(validUsername('editor')).toBe(true) 7 | expect(validUsername('xxxx')).toBe(false) 8 | }) 9 | it('isExternal', () => { 10 | expect(isExternal('https://github.com/PanJiaChen/vue-element-admin')).toBe(true) 11 | expect(isExternal('http://github.com/PanJiaChen/vue-element-admin')).toBe(true) 12 | expect(isExternal('github.com/PanJiaChen/vue-element-admin')).toBe(false) 13 | expect(isExternal('/dashboard')).toBe(false) 14 | expect(isExternal('./dashboard')).toBe(false) 15 | expect(isExternal('dashboard')).toBe(false) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/layout/components/AppMain.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | 32 | 33 | 40 | -------------------------------------------------------------------------------- /src/icons/svg/nested.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/dashboard/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 38 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'jsx', 'json', 'vue'], 3 | transform: { 4 | '^.+\\.vue$': 'vue-jest', 5 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 6 | 'jest-transform-stub', 7 | '^.+\\.jsx?$': 'babel-jest' 8 | }, 9 | moduleNameMapper: { 10 | '^@/(.*)$': '/src/$1' 11 | }, 12 | snapshotSerializers: ['jest-serializer-vue'], 13 | testMatch: [ 14 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 15 | ], 16 | collectCoverageFrom: ['src/utils/**/*.{js,vue}', '!src/utils/auth.js', '!src/utils/request.js', 'src/components/**/*.{js,vue}'], 17 | coverageDirectory: '/tests/unit/coverage', 18 | // 'collectCoverage': true, 19 | 'coverageReporters': [ 20 | 'lcov', 21 | 'text-summary' 22 | ], 23 | testURL: 'http://localhost/' 24 | } 25 | -------------------------------------------------------------------------------- /src/styles/element-ui.scss: -------------------------------------------------------------------------------- 1 | // cover some element-ui styles 2 | 3 | .el-breadcrumb__inner, 4 | .el-breadcrumb__inner a { 5 | font-weight: 400 !important; 6 | } 7 | 8 | .el-upload { 9 | input[type="file"] { 10 | display: none !important; 11 | } 12 | } 13 | 14 | .el-upload__input { 15 | display: none; 16 | } 17 | 18 | 19 | // to fixed https://github.com/ElemeFE/element/issues/2461 20 | .el-dialog { 21 | transform: none; 22 | left: 0; 23 | position: relative; 24 | margin: 0 auto; 25 | } 26 | 27 | // refine element ui upload 28 | .upload-container { 29 | .el-upload { 30 | width: 100%; 31 | 32 | .el-upload-dragger { 33 | width: 100%; 34 | height: 200px; 35 | } 36 | } 37 | } 38 | 39 | // dropdown 40 | .el-dropdown-menu { 41 | a { 42 | display: block 43 | } 44 | } 45 | 46 | // to fix el-date-picker css style 47 | .el-range-separator { 48 | box-sizing: content-box; 49 | } 50 | -------------------------------------------------------------------------------- /mock/table.js: -------------------------------------------------------------------------------- 1 | import Mock from 'mockjs' 2 | 3 | const data = Mock.mock({ 4 | //造了30条数据 5 | 'items|10': [{ 6 | id: '@integer(1,100)', 7 | title: '@cword(2,5)', 8 | num: '@integer(1,100)', 9 | price: '@integer(300, 5000)', 10 | display_time: '@datetime' 11 | }] 12 | }) 13 | 14 | export default [ 15 | { 16 | url: '/vue-admin-template/table/list', 17 | type: 'get', 18 | response: config => { 19 | const items = data.items 20 | return { 21 | code: 20000, 22 | data: { 23 | total: items.length, 24 | items: items 25 | } 26 | } 27 | } 28 | },{ 29 | url: '/vue-admin-template/table/tese2', 30 | type: 'get', 31 | response: config => { 32 | const items = data.items 33 | return { 34 | code: 20000, 35 | data: { 36 | items: [{name:'小明'},{age:12}] 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | -------------------------------------------------------------------------------- /src/icons/svg/eye.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/directive/waves/waves.css: -------------------------------------------------------------------------------- 1 | .waves-ripple { 2 | position: absolute; 3 | border-radius: 100%; 4 | background-color: rgba(0, 0, 0, 0.15); 5 | background-clip: padding-box; 6 | pointer-events: none; 7 | -webkit-user-select: none; 8 | -moz-user-select: none; 9 | -ms-user-select: none; 10 | user-select: none; 11 | -webkit-transform: scale(0); 12 | -ms-transform: scale(0); 13 | transform: scale(0); 14 | opacity: 1; 15 | } 16 | 17 | .waves-ripple.z-active { 18 | opacity: 0; 19 | -webkit-transform: scale(2); 20 | -ms-transform: scale(2); 21 | transform: scale(2); 22 | -webkit-transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out; 23 | transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out; 24 | transition: opacity 1.2s ease-out, transform 0.6s ease-out; 25 | transition: opacity 1.2s ease-out, transform 0.6s ease-out, -webkit-transform 0.6s ease-out; 26 | } -------------------------------------------------------------------------------- /src/styles/transition.scss: -------------------------------------------------------------------------------- 1 | // global transition css 2 | 3 | /* fade */ 4 | .fade-enter-active, 5 | .fade-leave-active { 6 | transition: opacity 0.28s; 7 | } 8 | 9 | .fade-enter, 10 | .fade-leave-active { 11 | opacity: 0; 12 | } 13 | 14 | /* fade-transform */ 15 | .fade-transform-leave-active, 16 | .fade-transform-enter-active { 17 | transition: all .5s; 18 | } 19 | 20 | .fade-transform-enter { 21 | opacity: 0; 22 | transform: translateX(-30px); 23 | } 24 | 25 | .fade-transform-leave-to { 26 | opacity: 0; 27 | transform: translateX(30px); 28 | } 29 | 30 | /* breadcrumb transition */ 31 | .breadcrumb-enter-active, 32 | .breadcrumb-leave-active { 33 | transition: all .5s; 34 | } 35 | 36 | .breadcrumb-enter, 37 | .breadcrumb-leave-active { 38 | opacity: 0; 39 | transform: translateX(20px); 40 | } 41 | 42 | .breadcrumb-move { 43 | transition: all .5s; 44 | } 45 | 46 | .breadcrumb-leave-active { 47 | position: absolute; 48 | } 49 | -------------------------------------------------------------------------------- /tests/unit/utils/parseTime.spec.js: -------------------------------------------------------------------------------- 1 | import { parseTime } from '@/utils/index.js' 2 | 3 | describe('Utils:parseTime', () => { 4 | const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01" 5 | it('timestamp', () => { 6 | expect(parseTime(d)).toBe('2018-07-13 17:54:01') 7 | }) 8 | it('ten digits timestamp', () => { 9 | expect(parseTime((d / 1000).toFixed(0))).toBe('2018-07-13 17:54:01') 10 | }) 11 | it('new Date', () => { 12 | expect(parseTime(new Date(d))).toBe('2018-07-13 17:54:01') 13 | }) 14 | it('format', () => { 15 | expect(parseTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54') 16 | expect(parseTime(d, '{y}-{m}-{d}')).toBe('2018-07-13') 17 | expect(parseTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54') 18 | }) 19 | it('get the day of the week', () => { 20 | expect(parseTime(d, '{a}')).toBe('五') // 星期五 21 | }) 22 | it('get the day of the week', () => { 23 | expect(parseTime(+d + 1000 * 60 * 60 * 24 * 2, '{a}')).toBe('日') // 星期日 24 | }) 25 | it('empty argument', () => { 26 | expect(parseTime()).toBeNull() 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present PanJiaChen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/unit/utils/formatTime.spec.js: -------------------------------------------------------------------------------- 1 | import { formatTime } from '@/utils/index.js' 2 | 3 | describe('Utils:formatTime', () => { 4 | const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01" 5 | const retrofit = 5 * 1000 6 | 7 | it('ten digits timestamp', () => { 8 | expect(formatTime((d / 1000).toFixed(0))).toBe('7月13日17时54分') 9 | }) 10 | it('test now', () => { 11 | expect(formatTime(+new Date() - 1)).toBe('刚刚') 12 | }) 13 | it('less two minute', () => { 14 | expect(formatTime(+new Date() - 60 * 2 * 1000 + retrofit)).toBe('2分钟前') 15 | }) 16 | it('less two hour', () => { 17 | expect(formatTime(+new Date() - 60 * 60 * 2 * 1000 + retrofit)).toBe('2小时前') 18 | }) 19 | it('less one day', () => { 20 | expect(formatTime(+new Date() - 60 * 60 * 24 * 1 * 1000)).toBe('1天前') 21 | }) 22 | it('more than one day', () => { 23 | expect(formatTime(d)).toBe('7月13日17时54分') 24 | }) 25 | it('format', () => { 26 | expect(formatTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54') 27 | expect(formatTime(d, '{y}-{m}-{d}')).toBe('2018-07-13') 28 | expect(formatTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './variables.scss'; 2 | @import './mixin.scss'; 3 | @import './transition.scss'; 4 | @import './element-ui.scss'; 5 | @import './sidebar.scss'; 6 | 7 | body { 8 | height: 100%; 9 | -moz-osx-font-smoothing: grayscale; 10 | -webkit-font-smoothing: antialiased; 11 | text-rendering: optimizeLegibility; 12 | font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; 13 | } 14 | 15 | label { 16 | font-weight: 700; 17 | } 18 | 19 | html { 20 | height: 100%; 21 | box-sizing: border-box; 22 | } 23 | 24 | #app { 25 | height: 100%; 26 | } 27 | 28 | *, 29 | *:before, 30 | *:after { 31 | box-sizing: inherit; 32 | } 33 | 34 | a:focus, 35 | a:active { 36 | outline: none; 37 | } 38 | 39 | a, 40 | a:focus, 41 | a:hover { 42 | cursor: pointer; 43 | color: inherit; 44 | text-decoration: none; 45 | } 46 | 47 | div:focus { 48 | outline: none; 49 | } 50 | 51 | .clearfix { 52 | &:after { 53 | visibility: hidden; 54 | display: block; 55 | font-size: 0; 56 | content: " "; 57 | clear: both; 58 | height: 0; 59 | } 60 | } 61 | 62 | // main-container global css 63 | .app-container { 64 | padding: 20px; 65 | } 66 | -------------------------------------------------------------------------------- /src/icons/svg/eye-open.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import 'normalize.css/normalize.css' // A modern alternative to CSS resets 4 | 5 | import ElementUI from 'element-ui' 6 | import 'element-ui/lib/theme-chalk/index.css' 7 | import locale from 'element-ui/lib/locale/lang/en' // lang i18n 8 | 9 | import '@/styles/index.scss' // global css 10 | 11 | import App from './App' 12 | import store from './store' 13 | import router from './router' 14 | 15 | import '@/icons' // icon 16 | import '@/permission' // permission control 17 | 18 | // import Vue from 'vue' 19 | // import Vuex from 'vuex' 20 | 21 | 22 | /** 23 | * If you don't want to use mock-server 24 | * you want to use MockJs for mock api 25 | * you can execute: mockXHR() 26 | * 27 | * Currently MockJs will be used in the production environment, 28 | * please remove it before going online ! ! ! 29 | */ 30 | if (process.env.NODE_ENV === 'production') { 31 | const { mockXHR } = require('../mock') 32 | mockXHR() 33 | } 34 | 35 | // set ElementUI lang to EN 36 | Vue.use(ElementUI, { locale }) 37 | // 如果想要中文版 element-ui,按如下方式声明 38 | // Vue.use(ElementUI) 39 | 40 | Vue.config.productionTip = false 41 | 42 | new Vue({ 43 | el: '#app', 44 | router, 45 | store, 46 | render: h => h(App) 47 | }) 48 | 49 | // Vue.use(Vuex) 50 | -------------------------------------------------------------------------------- /src/store/modules/app.js: -------------------------------------------------------------------------------- 1 | //记录slideBar的开关 2 | import Cookies from 'js-cookie' 3 | 4 | const state = { 5 | sidebar: { 6 | opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true, 7 | withoutAnimation: false 8 | }, 9 | device: 'desktop' 10 | } 11 | 12 | const mutations = { 13 | TOGGLE_SIDEBAR: state => { 14 | state.sidebar.opened = !state.sidebar.opened 15 | state.sidebar.withoutAnimation = false 16 | if (state.sidebar.opened) { 17 | Cookies.set('sidebarStatus', 1) 18 | } else { 19 | Cookies.set('sidebarStatus', 0) 20 | } 21 | }, 22 | CLOSE_SIDEBAR: (state, withoutAnimation) => { 23 | Cookies.set('sidebarStatus', 0) 24 | state.sidebar.opened = false 25 | state.sidebar.withoutAnimation = withoutAnimation 26 | }, 27 | TOGGLE_DEVICE: (state, device) => { 28 | state.device = device 29 | } 30 | } 31 | 32 | const actions = { 33 | toggleSideBar({ commit }) { 34 | commit('TOGGLE_SIDEBAR') 35 | }, 36 | closeSideBar({ commit }, { withoutAnimation }) { 37 | commit('CLOSE_SIDEBAR', withoutAnimation) 38 | }, 39 | toggleDevice({ commit }, device) { 40 | commit('TOGGLE_DEVICE', device) 41 | } 42 | } 43 | 44 | export default { 45 | namespaced: true, 46 | state, 47 | mutations, 48 | actions 49 | } 50 | -------------------------------------------------------------------------------- /src/directive/el-table/adaptive.js: -------------------------------------------------------------------------------- 1 | import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event' 2 | 3 | /** 4 | * How to use 5 | * ... 6 | * el-table height is must be set 7 | * bottomOffset: 30(default) // The height of the table from the bottom of the page. 8 | */ 9 | 10 | const doResize = (el, binding, vnode) => { 11 | const { componentInstance: $table } = vnode 12 | 13 | const { value } = binding 14 | 15 | if (!$table.height) { 16 | throw new Error(`el-$table must set the height. Such as height='100px'`) 17 | } 18 | const bottomOffset = (value && value.bottomOffset) || 30 19 | 20 | if (!$table) return 21 | 22 | const height = window.innerHeight - el.getBoundingClientRect().top - bottomOffset 23 | $table.layout.setHeight(height) 24 | $table.doLayout() 25 | } 26 | 27 | export default { 28 | bind(el, binding, vnode) { 29 | el.resizeListener = () => { 30 | doResize(el, binding, vnode) 31 | } 32 | // parameter 1 is must be "Element" type 33 | addResizeListener(window.document.body, el.resizeListener) 34 | }, 35 | inserted(el, binding, vnode) { 36 | doResize(el, binding, vnode) 37 | }, 38 | unbind(el) { 39 | removeResizeListener(window.document.body, el.resizeListener) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Hamburger/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | 33 | 45 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import getters from './getters' 4 | import app from './modules/app' 5 | import settings from './modules/settings' 6 | import user from './modules/user' 7 | import permission from './modules/permission' 8 | import tagsView from './modules/tagsView' 9 | 10 | Vue.use(Vuex) 11 | 12 | const store = new Vuex.Store({ 13 | state: { 14 | login: [{ 15 | username: 'admin', 16 | password: '123456', 17 | id: 0 18 | }, 19 | { 20 | username: 'recyclrer', 21 | password: '123456', 22 | id: 1 23 | }, 24 | { 25 | username: 'user', 26 | password: '123456', 27 | id: 2 28 | }] 29 | }, 30 | modules: { 31 | app, 32 | settings, 33 | user, 34 | permission, 35 | tagsView 36 | }, 37 | mutations: { 38 | newPassword(state, obj) { 39 | console.log(state, obj, '传递数据') 40 | var arr = store.state.login 41 | console.log(store.state.login, 'vuex数据') 42 | for (var i in arr) { 43 | if (arr[i].username == obj.username) { 44 | arr[i].password = obj.password 45 | } 46 | } 47 | }, 48 | addAdmin(state, obj) { 49 | store.state.login.push(obj) 50 | console.log(store.state.login) 51 | }, 52 | }, 53 | 54 | getters 55 | }) 56 | 57 | export default store 58 | -------------------------------------------------------------------------------- /src/layout/mixin/ResizeHandler.js: -------------------------------------------------------------------------------- 1 | import store from '@/store' 2 | 3 | const { body } = document 4 | const WIDTH = 992 // refer to Bootstrap's responsive design 5 | 6 | export default { 7 | watch: { 8 | $route(route) { 9 | if (this.device === 'mobile' && this.sidebar.opened) { 10 | store.dispatch('app/closeSideBar', { withoutAnimation: false }) 11 | } 12 | } 13 | }, 14 | beforeMount() { 15 | window.addEventListener('resize', this.$_resizeHandler) 16 | }, 17 | beforeDestroy() { 18 | window.removeEventListener('resize', this.$_resizeHandler) 19 | }, 20 | mounted() { 21 | const isMobile = this.$_isMobile() 22 | if (isMobile) { 23 | store.dispatch('app/toggleDevice', 'mobile') 24 | store.dispatch('app/closeSideBar', { withoutAnimation: true }) 25 | } 26 | }, 27 | methods: { 28 | // use $_ for mixins properties 29 | // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential 30 | $_isMobile() { 31 | const rect = body.getBoundingClientRect() 32 | return rect.width - 1 < WIDTH 33 | }, 34 | $_resizeHandler() { 35 | if (!document.hidden) { 36 | const isMobile = this.$_isMobile() 37 | store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop') 38 | 39 | if (isMobile) { 40 | store.dispatch('app/closeSideBar', { withoutAnimation: true }) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/SvgIcon/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 47 | 48 | 63 | -------------------------------------------------------------------------------- /src/store/modules/permission.js: -------------------------------------------------------------------------------- 1 | //用户路由菜单 2 | import { asyncRoutes, constantRoutes } from '@/router' 3 | 4 | /** 5 | * Use meta.role to determine if the current user has permission 6 | * 判断该用户对应的角色有没有指定的路由 7 | * @param roles 8 | * @param route 9 | */ 10 | function hasPermission(roles, route) { 11 | if (route.meta && route.meta.roles) { 12 | return roles.some(role => route.meta.roles.includes(role)) 13 | } else { 14 | return true 15 | } 16 | } 17 | 18 | /** 19 | * Filter asynchronous routing tables by recursion 20 | * 根据用户对应的角色过滤对应的路由 21 | * @param routes asyncRoutes 22 | * @param roles 23 | */ 24 | export function filterAsyncRoutes(routes, roles) { 25 | const res = [] 26 | 27 | routes.forEach(route => { 28 | const tmp = { ...route } 29 | if (hasPermission(roles, tmp)) { 30 | if (tmp.children) { 31 | tmp.children = filterAsyncRoutes(tmp.children, roles) 32 | } 33 | res.push(tmp) 34 | } 35 | }) 36 | 37 | return res 38 | } 39 | 40 | const state = { 41 | routes: [], 42 | addRoutes: [] 43 | } 44 | 45 | const mutations = { 46 | // 将用户对应的路由信息保存到vuex以便页面显示 47 | SET_ROUTES: (state, routes) => { 48 | state.addRoutes = routes 49 | state.routes = constantRoutes.concat(routes) 50 | } 51 | } 52 | 53 | const actions = { 54 | //根据用户角色生成路由的方法 55 | generateRoutes({ commit }, roles) { 56 | return new Promise(resolve => { 57 | let accessedRoutes 58 | accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) 59 | commit('SET_ROUTES', accessedRoutes) 60 | resolve(accessedRoutes) 61 | }) 62 | } 63 | } 64 | 65 | 66 | export default { 67 | namespaced: true, 68 | state, 69 | mutations, 70 | actions 71 | } 72 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 59 | -------------------------------------------------------------------------------- /src/directive/clipboard/clipboard.js: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/Inndy/vue-clipboard2 2 | const Clipboard = require('clipboard') 3 | if (!Clipboard) { 4 | throw new Error('you should npm install `clipboard` --save at first ') 5 | } 6 | 7 | export default { 8 | bind(el, binding) { 9 | if (binding.arg === 'success') { 10 | el._v_clipboard_success = binding.value 11 | } else if (binding.arg === 'error') { 12 | el._v_clipboard_error = binding.value 13 | } else { 14 | const clipboard = new Clipboard(el, { 15 | text() { return binding.value }, 16 | action() { return binding.arg === 'cut' ? 'cut' : 'copy' } 17 | }) 18 | clipboard.on('success', e => { 19 | const callback = el._v_clipboard_success 20 | callback && callback(e) // eslint-disable-line 21 | }) 22 | clipboard.on('error', e => { 23 | const callback = el._v_clipboard_error 24 | callback && callback(e) // eslint-disable-line 25 | }) 26 | el._v_clipboard = clipboard 27 | } 28 | }, 29 | update(el, binding) { 30 | if (binding.arg === 'success') { 31 | el._v_clipboard_success = binding.value 32 | } else if (binding.arg === 'error') { 33 | el._v_clipboard_error = binding.value 34 | } else { 35 | el._v_clipboard.text = function() { return binding.value } 36 | el._v_clipboard.action = function() { return binding.arg === 'cut' ? 'cut' : 'copy' } 37 | } 38 | }, 39 | unbind(el, binding) { 40 | if (binding.arg === 'success') { 41 | delete el._v_clipboard_success 42 | } else if (binding.arg === 'error') { 43 | delete el._v_clipboard_error 44 | } else { 45 | el._v_clipboard.destroy() 46 | delete el._v_clipboard 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/icons/svg/tree.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mock/index.js: -------------------------------------------------------------------------------- 1 | import Mock from 'mockjs' 2 | import { param2Obj } from '../src/utils' 3 | 4 | import user from './user' 5 | import table from './table' 6 | import product from './product' 7 | 8 | const mocks = [ 9 | ...user, 10 | ...table, 11 | ...product 12 | ] 13 | 14 | // for front mock 15 | // please use it cautiously, it will redefine XMLHttpRequest, 16 | // which will cause many of your third-party libraries to be invalidated(like progress event). 17 | export function mockXHR() { 18 | // mock patch 19 | // https://github.com/nuysoft/Mock/issues/300 20 | Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send 21 | Mock.XHR.prototype.send = function() { 22 | if (this.custom.xhr) { 23 | this.custom.xhr.withCredentials = this.withCredentials || false 24 | 25 | if (this.responseType) { 26 | this.custom.xhr.responseType = this.responseType 27 | } 28 | } 29 | this.proxy_send(...arguments) 30 | } 31 | 32 | function XHR2ExpressReqWrap(respond) { 33 | return function(options) { 34 | let result = null 35 | if (respond instanceof Function) { 36 | const { body, type, url } = options 37 | // https://expressjs.com/en/4x/api.html#req 38 | result = respond({ 39 | method: type, 40 | body: JSON.parse(body), 41 | query: param2Obj(url) 42 | }) 43 | } else { 44 | result = respond 45 | } 46 | return Mock.mock(result) 47 | } 48 | } 49 | 50 | for (const i of mocks) { 51 | Mock.mock(new RegExp(i.url), i.type || 'get', XHR2ExpressReqWrap(i.response)) 52 | } 53 | } 54 | 55 | // for mock server 56 | const responseFake = (url, type, respond) => { 57 | return { 58 | url: new RegExp(`${process.env.VUE_APP_BASE_API}${url}`), 59 | type: type || 'get', 60 | response(req, res) { 61 | console.log('request invoke:' + req.path) 62 | res.json(Mock.mock(respond instanceof Function ? respond(req, res) : respond)) 63 | } 64 | } 65 | } 66 | 67 | export default mocks.map(route => { 68 | return responseFake(route.url, route.type, route.response) 69 | }) 70 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/Logo.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 33 | 34 | 83 | -------------------------------------------------------------------------------- /mock/mock-server.js: -------------------------------------------------------------------------------- 1 | const chokidar = require('chokidar') 2 | const bodyParser = require('body-parser') 3 | const chalk = require('chalk') 4 | const path = require('path') 5 | 6 | const mockDir = path.join(process.cwd(), 'mock') 7 | 8 | function registerRoutes(app) { 9 | let mockLastIndex 10 | const { default: mocks } = require('./index.js') 11 | for (const mock of mocks) { 12 | app[mock.type](mock.url, mock.response) 13 | mockLastIndex = app._router.stack.length 14 | } 15 | const mockRoutesLength = Object.keys(mocks).length 16 | return { 17 | mockRoutesLength: mockRoutesLength, 18 | mockStartIndex: mockLastIndex - mockRoutesLength 19 | } 20 | } 21 | 22 | function unregisterRoutes() { 23 | Object.keys(require.cache).forEach(i => { 24 | if (i.includes(mockDir)) { 25 | delete require.cache[require.resolve(i)] 26 | } 27 | }) 28 | } 29 | 30 | module.exports = app => { 31 | // es6 polyfill 32 | require('@babel/register') 33 | 34 | // parse app.body 35 | // https://expressjs.com/en/4x/api.html#req.body 36 | app.use(bodyParser.json()) 37 | app.use(bodyParser.urlencoded({ 38 | extended: true 39 | })) 40 | 41 | const mockRoutes = registerRoutes(app) 42 | var mockRoutesLength = mockRoutes.mockRoutesLength 43 | var mockStartIndex = mockRoutes.mockStartIndex 44 | 45 | // watch files, hot reload mock server 46 | chokidar.watch(mockDir, { 47 | ignored: /mock-server/, 48 | ignoreInitial: true 49 | }).on('all', (event, path) => { 50 | if (event === 'change' || event === 'add') { 51 | try { 52 | // remove mock routes stack 53 | app._router.stack.splice(mockStartIndex, mockRoutesLength) 54 | 55 | // clear routes cache 56 | unregisterRoutes() 57 | 58 | const mockRoutes = registerRoutes(app) 59 | mockRoutesLength = mockRoutes.mockRoutesLength 60 | mockStartIndex = mockRoutes.mockStartIndex 61 | 62 | console.log(chalk.magentaBright(`\n > Mock Server hot reload success! changed ${path}`)) 63 | } catch (error) { 64 | console.log(chalk.redBright(error)) 65 | } 66 | } 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-admin-template", 3 | "version": "4.2.1", 4 | "description": "A vue admin template with Element UI & axios & iconfont & permission control & lint", 5 | "author": "Pan ", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "vue-cli-service serve", 9 | "build:prod": "vue-cli-service build", 10 | "build:stage": "vue-cli-service build --mode staging", 11 | "preview": "node build/index.js --preview", 12 | "lint": "eslint --ext .js,.vue src", 13 | "test:unit": "jest --clearCache && vue-cli-service test:unit", 14 | "test:ci": "npm run lint && npm run test:unit", 15 | "svgo": "svgo -f src/icons/svg --config=src/icons/svgo.yml" 16 | }, 17 | "dependencies": { 18 | "axios": "^0.21.1", 19 | "element-ui": "2.13.0", 20 | "js-cookie": "2.2.0", 21 | "node-sass": "^4.14.1", 22 | "normalize.css": "7.0.0", 23 | "nprogress": "0.2.0", 24 | "path-to-regexp": "2.4.0", 25 | "vue": "2.6.10", 26 | "vue-router": "3.0.6", 27 | "vuex": "3.1.0" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "7.0.0", 31 | "@babel/register": "7.0.0", 32 | "@vue/cli-plugin-babel": "3.6.0", 33 | "@vue/cli-plugin-eslint": "^3.9.1", 34 | "@vue/cli-plugin-unit-jest": "3.6.3", 35 | "@vue/cli-service": "3.6.0", 36 | "@vue/test-utils": "1.0.0-beta.29", 37 | "autoprefixer": "^9.5.1", 38 | "babel-core": "7.0.0-bridge.0", 39 | "babel-eslint": "10.0.1", 40 | "babel-jest": "23.6.0", 41 | "chalk": "2.4.2", 42 | "connect": "3.6.6", 43 | "eslint": "5.15.3", 44 | "eslint-plugin-vue": "5.2.2", 45 | "html-webpack-plugin": "3.2.0", 46 | "mockjs": "1.0.1-beta3", 47 | "runjs": "^4.3.2", 48 | "sass-loader": "^7.1.0", 49 | "script-ext-html-webpack-plugin": "2.1.3", 50 | "script-loader": "0.7.2", 51 | "serve-static": "^1.13.2", 52 | "svg-sprite-loader": "4.1.3", 53 | "svgo": "1.2.2", 54 | "vue-template-compiler": "2.6.10" 55 | }, 56 | "engines": { 57 | "node": ">=8.9", 58 | "npm": ">= 3.0.0" 59 | }, 60 | "browserslist": [ 61 | "> 1%", 62 | "last 2 versions" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/permission.js: -------------------------------------------------------------------------------- 1 | import router from './router' 2 | import store from './store' 3 | import { Message } from 'element-ui' 4 | import NProgress from 'nprogress' // progress bar 5 | import 'nprogress/nprogress.css' // progress bar style 6 | import { getToken } from '@/utils/auth' // get token from cookie 7 | import getPageTitle from '@/utils/get-page-title' 8 | 9 | NProgress.configure({ showSpinner: false }) // NProgress Configuration 10 | 11 | const whiteList = ['/login'] // no redirect whitelist 12 | 13 | // 路由跳转之前,获取当前登录用户信息,动态加载用户角色对应的路由 14 | router.beforeEach(async(to, from, next) => { 15 | // 全局的NProgress开始工作 16 | NProgress.start() 17 | // 设置页面标题 18 | document.title = getPageTitle(to.meta.title) 19 | // 获取用户tocken 20 | const hasToken = getToken() 21 | // 如果用户已经登录了 22 | if (hasToken) { 23 | // 不能再访问登录页面,跳转首页 24 | if (to.path === '/login') { 25 | next({ path: '/' }) 26 | NProgress.done() 27 | } else { 28 | const hasGetUserInfo = store.getters.name 29 | // 如果已经获取过用户信息了,直接放行 30 | if (hasGetUserInfo) { 31 | next() 32 | } else { 33 | // 如果没有获取过用户信息,调用vuex的user/getInfo获取用户信息 34 | try { 35 | const { roles } = await store.dispatch('user/getInfo') 36 | // 获取用户对应的权限菜单(这里通过vuex保存用户菜单信息,router.addRoutes没有意义) 37 | const accessRoutes = await store.dispatch('permission/generateRoutes', roles) 38 | router.addRoutes(accessRoutes) 39 | // replace: true 确保导航不会有历史记录 40 | next({ ...to, replace: true }) 41 | } catch (error) { 42 | // 如果有错,重置tocken并且来到登录页面 43 | await store.dispatch('user/resetToken') 44 | Message.error(error || 'Has Error') 45 | next(`/login?redirect=${to.path}`) 46 | NProgress.done() 47 | } 48 | } 49 | } 50 | } else { 51 | // 没有tocken 52 | if (whiteList.indexOf(to.path) !== -1) { 53 | // 在白名单中直接放行 54 | next() 55 | } else { 56 | // 不在白名单中来到登录页面 57 | next(`/login?redirect=${to.path}`) 58 | NProgress.done() 59 | } 60 | } 61 | }) 62 | 63 | router.afterEach(() => { 64 | // finish progress bar 65 | NProgress.done() 66 | }) 67 | -------------------------------------------------------------------------------- /mock/user.js: -------------------------------------------------------------------------------- 1 | 2 | //后台给前台返回的tocken信息 3 | const tokens = { 4 | admin: { 5 | token: 'admin-token' 6 | }, 7 | recyclrer: { 8 | token: 'recyclrer-token' 9 | }, 10 | user: { 11 | token: 'user-token' 12 | } 13 | } 14 | 15 | //用户登录后给前台返回的用户信息 16 | const users = { 17 | 'admin-token': { 18 | roles: ['admin'], 19 | introduction: 'I am a super administrator', 20 | avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', 21 | name: 'admin' 22 | }, 23 | 'recyclrer-token': { 24 | roles: ['recyclrer'], 25 | introduction: 'I am an recyclrer', 26 | avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', 27 | name: 'recyclrer' 28 | }, 29 | 'user-token': { 30 | roles: ['user'], 31 | introduction: 'I am an user', 32 | avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', 33 | name: 'user' 34 | } 35 | } 36 | 37 | export default [ 38 | // 用户登录接口 39 | { 40 | url: '/vue-admin-template/user/login', 41 | type: 'post', 42 | response: config => { 43 | const { username } = config.body 44 | const token = tokens[username] 45 | 46 | // mock error 47 | if (!token) { 48 | return { 49 | code: 60204, 50 | message: '用户名密码输入不正确!' 51 | } 52 | } 53 | return { 54 | code: 20000, 55 | data: token 56 | } 57 | } 58 | }, 59 | 60 | // 获取用户信息接口 61 | { 62 | url: '/vue-admin-template/user/info\.*', 63 | type: 'get', 64 | response: config => { 65 | const { token } = config.query 66 | const info = users[token] 67 | 68 | // mock error 69 | if (!info) { 70 | return { 71 | code: 50008, 72 | message: 'Login failed, unable to get user details.' 73 | } 74 | } 75 | 76 | return { 77 | code: 20000, 78 | data: info 79 | } 80 | } 81 | }, 82 | 83 | // 用户退出登录接口 84 | { 85 | url: '/vue-admin-template/user/logout', 86 | type: 'post', 87 | response: _ => { 88 | return { 89 | code: 20000, 90 | data: 'success' 91 | } 92 | } 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /src/icons/svg/dashboard.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 52 | 53 | 94 | -------------------------------------------------------------------------------- /src/components/Breadcrumb/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 65 | 66 | 79 | -------------------------------------------------------------------------------- /src/icons/svg/form.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/directive/waves/waves.js: -------------------------------------------------------------------------------- 1 | import './waves.css' 2 | 3 | const context = '@@wavesContext' 4 | 5 | function handleClick(el, binding) { 6 | function handle(e) { 7 | const customOpts = Object.assign({}, binding.value) 8 | const opts = Object.assign({ 9 | ele: el, // 波纹作用元素 10 | type: 'hit', // hit 点击位置扩散 center中心点扩展 11 | color: 'rgba(0, 0, 0, 0.15)' // 波纹颜色 12 | }, 13 | customOpts 14 | ) 15 | const target = opts.ele 16 | if (target) { 17 | target.style.position = 'relative' 18 | target.style.overflow = 'hidden' 19 | const rect = target.getBoundingClientRect() 20 | let ripple = target.querySelector('.waves-ripple') 21 | if (!ripple) { 22 | ripple = document.createElement('span') 23 | ripple.className = 'waves-ripple' 24 | ripple.style.height = ripple.style.width = Math.max(rect.width, rect.height) + 'px' 25 | target.appendChild(ripple) 26 | } else { 27 | ripple.className = 'waves-ripple' 28 | } 29 | switch (opts.type) { 30 | case 'center': 31 | ripple.style.top = rect.height / 2 - ripple.offsetHeight / 2 + 'px' 32 | ripple.style.left = rect.width / 2 - ripple.offsetWidth / 2 + 'px' 33 | break 34 | default: 35 | ripple.style.top = 36 | (e.pageY - rect.top - ripple.offsetHeight / 2 - document.documentElement.scrollTop || 37 | document.body.scrollTop) + 'px' 38 | ripple.style.left = 39 | (e.pageX - rect.left - ripple.offsetWidth / 2 - document.documentElement.scrollLeft || 40 | document.body.scrollLeft) + 'px' 41 | } 42 | ripple.style.backgroundColor = opts.color 43 | ripple.className = 'waves-ripple z-active' 44 | return false 45 | } 46 | } 47 | 48 | if (!el[context]) { 49 | el[context] = { 50 | removeHandle: handle 51 | } 52 | } else { 53 | el[context].removeHandle = handle 54 | } 55 | 56 | return handle 57 | } 58 | 59 | export default { 60 | bind(el, binding) { 61 | el.addEventListener('click', handleClick(el, binding), false) 62 | }, 63 | update(el, binding) { 64 | el.removeEventListener('click', el[context].removeHandle, false) 65 | el.addEventListener('click', handleClick(el, binding), false) 66 | }, 67 | unbind(el) { 68 | el.removeEventListener('click', el[context].removeHandle, false) 69 | el[context] = null 70 | delete el[context] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { MessageBox, Message } from 'element-ui' 3 | import store from '@/store' 4 | import { getToken } from '@/utils/auth' 5 | 6 | // create an axios instance 7 | const service = axios.create({ 8 | baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url 9 | // withCredentials: true, // send cookies when cross-domain requests 10 | timeout: 5000 // request timeout 11 | }) 12 | 13 | // 请求的时候携带tocken 14 | service.interceptors.request.use( 15 | config => { 16 | // do something before request is sent 17 | 18 | if (store.getters.token) { 19 | // let each request carry token 20 | // ['X-Token'] is a custom headers key 21 | // please modify it according to the actual situation 22 | config.headers['X-Token'] = getToken() 23 | } 24 | return config 25 | }, 26 | error => { 27 | // do something with request error 28 | console.log(error) // for debug 29 | return Promise.reject(error) 30 | } 31 | ) 32 | 33 | // response interceptor 34 | service.interceptors.response.use( 35 | /** 36 | * If you want to get http information such as headers or status 37 | * Please return response => response 38 | */ 39 | 40 | /** 41 | * Determine the request status by custom code 42 | * Here is just an example 43 | * You can also judge the status by HTTP Status Code 44 | */ 45 | response => { 46 | const res = response.data 47 | 48 | // if the custom code is not 20000, it is judged as an error. 49 | if (res.code !== 20000) { 50 | Message({ 51 | message: res.message || 'Error', 52 | type: 'error', 53 | duration: 5 * 1000 54 | }) 55 | 56 | // 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired; 57 | if (res.code === 50008 || res.code === 50012 || res.code === 50014) { 58 | // to re-login 59 | MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', { 60 | confirmButtonText: 'Re-Login', 61 | cancelButtonText: 'Cancel', 62 | type: 'warning' 63 | }).then(() => { 64 | store.dispatch('user/resetToken').then(() => { 65 | location.reload() 66 | }) 67 | }) 68 | } 69 | return Promise.reject(new Error(res.message || 'Error')) 70 | } else { 71 | return res 72 | } 73 | }, 74 | error => { 75 | console.log('err' + error) // for debug 76 | Message({ 77 | message: error.message, 78 | type: 'error', 79 | duration: 5 * 1000 80 | }) 81 | return Promise.reject(error) 82 | } 83 | ) 84 | 85 | export default service 86 | -------------------------------------------------------------------------------- /src/directive/el-drag-dialog/drag.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bind(el, binding, vnode) { 3 | const dialogHeaderEl = el.querySelector('.el-dialog__header') 4 | const dragDom = el.querySelector('.el-dialog') 5 | dialogHeaderEl.style.cssText += ';cursor:move;' 6 | dragDom.style.cssText += ';top:0px;' 7 | 8 | // 获取原有属性 ie dom元素.currentStyle 火狐谷歌 window.getComputedStyle(dom元素, null); 9 | const getStyle = (function() { 10 | if (window.document.currentStyle) { 11 | return (dom, attr) => dom.currentStyle[attr] 12 | } else { 13 | return (dom, attr) => getComputedStyle(dom, false)[attr] 14 | } 15 | })() 16 | 17 | dialogHeaderEl.onmousedown = (e) => { 18 | // 鼠标按下,计算当前元素距离可视区的距离 19 | const disX = e.clientX - dialogHeaderEl.offsetLeft 20 | const disY = e.clientY - dialogHeaderEl.offsetTop 21 | 22 | const dragDomWidth = dragDom.offsetWidth 23 | const dragDomHeight = dragDom.offsetHeight 24 | 25 | const screenWidth = document.body.clientWidth 26 | const screenHeight = document.body.clientHeight 27 | 28 | const minDragDomLeft = dragDom.offsetLeft 29 | const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth 30 | 31 | const minDragDomTop = dragDom.offsetTop 32 | const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomHeight 33 | 34 | // 获取到的值带px 正则匹配替换 35 | let styL = getStyle(dragDom, 'left') 36 | let styT = getStyle(dragDom, 'top') 37 | 38 | if (styL.includes('%')) { 39 | styL = +document.body.clientWidth * (+styL.replace(/\%/g, '') / 100) 40 | styT = +document.body.clientHeight * (+styT.replace(/\%/g, '') / 100) 41 | } else { 42 | styL = +styL.replace(/\px/g, '') 43 | styT = +styT.replace(/\px/g, '') 44 | } 45 | 46 | document.onmousemove = function(e) { 47 | // 通过事件委托,计算移动的距离 48 | let left = e.clientX - disX 49 | let top = e.clientY - disY 50 | 51 | // 边界处理 52 | if (-(left) > minDragDomLeft) { 53 | left = -minDragDomLeft 54 | } else if (left > maxDragDomLeft) { 55 | left = maxDragDomLeft 56 | } 57 | 58 | if (-(top) > minDragDomTop) { 59 | top = -minDragDomTop 60 | } else if (top > maxDragDomTop) { 61 | top = maxDragDomTop 62 | } 63 | 64 | // 移动当前元素 65 | dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;` 66 | 67 | // emit onDrag event 68 | vnode.child.$emit('dragDialog') 69 | } 70 | 71 | document.onmouseup = function(e) { 72 | document.onmousemove = null 73 | document.onmouseup = null 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by PanJiaChen on 16/11/18. 3 | */ 4 | 5 | /** 6 | * Parse the time to string 7 | * @param {(Object|string|number)} time 8 | * @param {string} cFormat 9 | * @returns {string | null} 10 | */ 11 | export function parseTime(time, cFormat) { 12 | if (arguments.length === 0) { 13 | return null 14 | } 15 | const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}' 16 | let date 17 | if (typeof time === 'object') { 18 | date = time 19 | } else { 20 | if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) { 21 | time = parseInt(time) 22 | } 23 | if ((typeof time === 'number') && (time.toString().length === 10)) { 24 | time = time * 1000 25 | } 26 | date = new Date(time) 27 | } 28 | const formatObj = { 29 | y: date.getFullYear(), 30 | m: date.getMonth() + 1, 31 | d: date.getDate(), 32 | h: date.getHours(), 33 | i: date.getMinutes(), 34 | s: date.getSeconds(), 35 | a: date.getDay() 36 | } 37 | const time_str = format.replace(/{([ymdhisa])+}/g, (result, key) => { 38 | const value = formatObj[key] 39 | // Note: getDay() returns 0 on Sunday 40 | if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value ] } 41 | return value.toString().padStart(2, '0') 42 | }) 43 | return time_str 44 | } 45 | 46 | /** 47 | * @param {number} time 48 | * @param {string} option 49 | * @returns {string} 50 | */ 51 | export function formatTime(time, option) { 52 | if (('' + time).length === 10) { 53 | time = parseInt(time) * 1000 54 | } else { 55 | time = +time 56 | } 57 | const d = new Date(time) 58 | const now = Date.now() 59 | 60 | const diff = (now - d) / 1000 61 | 62 | if (diff < 30) { 63 | return '刚刚' 64 | } else if (diff < 3600) { 65 | // less 1 hour 66 | return Math.ceil(diff / 60) + '分钟前' 67 | } else if (diff < 3600 * 24) { 68 | return Math.ceil(diff / 3600) + '小时前' 69 | } else if (diff < 3600 * 24 * 2) { 70 | return '1天前' 71 | } 72 | if (option) { 73 | return parseTime(time, option) 74 | } else { 75 | return ( 76 | d.getMonth() + 77 | 1 + 78 | '月' + 79 | d.getDate() + 80 | '日' + 81 | d.getHours() + 82 | '时' + 83 | d.getMinutes() + 84 | '分' 85 | ) 86 | } 87 | } 88 | 89 | /** 90 | * @param {string} url 91 | * @returns {Object} 92 | */ 93 | export function param2Obj(url) { 94 | const search = url.split('?')[1] 95 | if (!search) { 96 | return {} 97 | } 98 | return JSON.parse( 99 | '{"' + 100 | decodeURIComponent(search) 101 | .replace(/"/g, '\\"') 102 | .replace(/&/g, '","') 103 | .replace(/=/g, '":"') 104 | .replace(/\+/g, ' ') + 105 | '"}' 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /src/directive/sticky.js: -------------------------------------------------------------------------------- 1 | const vueSticky = {} 2 | let listenAction 3 | vueSticky.install = Vue => { 4 | Vue.directive('sticky', { 5 | inserted(el, binding) { 6 | const params = binding.value || {} 7 | const stickyTop = params.stickyTop || 0 8 | const zIndex = params.zIndex || 1000 9 | const elStyle = el.style 10 | 11 | elStyle.position = '-webkit-sticky' 12 | elStyle.position = 'sticky' 13 | // if the browser support css sticky(Currently Safari, Firefox and Chrome Canary) 14 | // if (~elStyle.position.indexOf('sticky')) { 15 | // elStyle.top = `${stickyTop}px`; 16 | // elStyle.zIndex = zIndex; 17 | // return 18 | // } 19 | const elHeight = el.getBoundingClientRect().height 20 | const elWidth = el.getBoundingClientRect().width 21 | elStyle.cssText = `top: ${stickyTop}px; z-index: ${zIndex}` 22 | 23 | const parentElm = el.parentNode || document.documentElement 24 | const placeholder = document.createElement('div') 25 | placeholder.style.display = 'none' 26 | placeholder.style.width = `${elWidth}px` 27 | placeholder.style.height = `${elHeight}px` 28 | parentElm.insertBefore(placeholder, el) 29 | 30 | let active = false 31 | 32 | const getScroll = (target, top) => { 33 | const prop = top ? 'pageYOffset' : 'pageXOffset' 34 | const method = top ? 'scrollTop' : 'scrollLeft' 35 | let ret = target[prop] 36 | if (typeof ret !== 'number') { 37 | ret = window.document.documentElement[method] 38 | } 39 | return ret 40 | } 41 | 42 | const sticky = () => { 43 | if (active) { 44 | return 45 | } 46 | if (!elStyle.height) { 47 | elStyle.height = `${el.offsetHeight}px` 48 | } 49 | 50 | elStyle.position = 'fixed' 51 | elStyle.width = `${elWidth}px` 52 | placeholder.style.display = 'inline-block' 53 | active = true 54 | } 55 | 56 | const reset = () => { 57 | if (!active) { 58 | return 59 | } 60 | 61 | elStyle.position = '' 62 | placeholder.style.display = 'none' 63 | active = false 64 | } 65 | 66 | const check = () => { 67 | const scrollTop = getScroll(window, true) 68 | const offsetTop = el.getBoundingClientRect().top 69 | if (offsetTop < stickyTop) { 70 | sticky() 71 | } else { 72 | if (scrollTop < elHeight + stickyTop) { 73 | reset() 74 | } 75 | } 76 | } 77 | listenAction = () => { 78 | check() 79 | } 80 | 81 | window.addEventListener('scroll', listenAction) 82 | }, 83 | 84 | unbind() { 85 | window.removeEventListener('scroll', listenAction) 86 | } 87 | }) 88 | } 89 | 90 | export default vueSticky 91 | 92 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/SidebarItem.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 96 | -------------------------------------------------------------------------------- /tests/unit/components/Breadcrumb.spec.js: -------------------------------------------------------------------------------- 1 | import { mount, createLocalVue } from '@vue/test-utils' 2 | import VueRouter from 'vue-router' 3 | import ElementUI from 'element-ui' 4 | import Breadcrumb from '@/components/Breadcrumb/index.vue' 5 | 6 | const localVue = createLocalVue() 7 | localVue.use(VueRouter) 8 | localVue.use(ElementUI) 9 | 10 | const routes = [ 11 | { 12 | path: '/', 13 | name: 'home', 14 | children: [{ 15 | path: 'dashboard', 16 | name: 'dashboard' 17 | }] 18 | }, 19 | { 20 | path: '/menu', 21 | name: 'menu', 22 | children: [{ 23 | path: 'menu1', 24 | name: 'menu1', 25 | meta: { title: 'menu1' }, 26 | children: [{ 27 | path: 'menu1-1', 28 | name: 'menu1-1', 29 | meta: { title: 'menu1-1' } 30 | }, 31 | { 32 | path: 'menu1-2', 33 | name: 'menu1-2', 34 | redirect: 'noredirect', 35 | meta: { title: 'menu1-2' }, 36 | children: [{ 37 | path: 'menu1-2-1', 38 | name: 'menu1-2-1', 39 | meta: { title: 'menu1-2-1' } 40 | }, 41 | { 42 | path: 'menu1-2-2', 43 | name: 'menu1-2-2' 44 | }] 45 | }] 46 | }] 47 | }] 48 | 49 | const router = new VueRouter({ 50 | routes 51 | }) 52 | 53 | describe('Breadcrumb.vue', () => { 54 | const wrapper = mount(Breadcrumb, { 55 | localVue, 56 | router 57 | }) 58 | it('dashboard', () => { 59 | router.push('/dashboard') 60 | const len = wrapper.findAll('.el-breadcrumb__inner').length 61 | expect(len).toBe(1) 62 | }) 63 | it('normal route', () => { 64 | router.push('/menu/menu1') 65 | const len = wrapper.findAll('.el-breadcrumb__inner').length 66 | expect(len).toBe(2) 67 | }) 68 | it('nested route', () => { 69 | router.push('/menu/menu1/menu1-2/menu1-2-1') 70 | const len = wrapper.findAll('.el-breadcrumb__inner').length 71 | expect(len).toBe(4) 72 | }) 73 | it('no meta.title', () => { 74 | router.push('/menu/menu1/menu1-2/menu1-2-2') 75 | const len = wrapper.findAll('.el-breadcrumb__inner').length 76 | expect(len).toBe(3) 77 | }) 78 | // it('click link', () => { 79 | // router.push('/menu/menu1/menu1-2/menu1-2-2') 80 | // const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner') 81 | // const second = breadcrumbArray.at(1) 82 | // console.log(breadcrumbArray) 83 | // const href = second.find('a').attributes().href 84 | // expect(href).toBe('#/menu/menu1') 85 | // }) 86 | // it('noRedirect', () => { 87 | // router.push('/menu/menu1/menu1-2/menu1-2-1') 88 | // const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner') 89 | // const redirectBreadcrumb = breadcrumbArray.at(2) 90 | // expect(redirectBreadcrumb.contains('a')).toBe(false) 91 | // }) 92 | it('last breadcrumb', () => { 93 | router.push('/menu/menu1/menu1-2/menu1-2-1') 94 | const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner') 95 | const redirectBreadcrumb = breadcrumbArray.at(3) 96 | expect(redirectBreadcrumb.contains('a')).toBe(false) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/views/form/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 105 | -------------------------------------------------------------------------------- /src/views/nested/menu2/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-admin-template 2 | 3 | English | [简体中文](./README-zh.md) 4 | 5 | > A minimal vue admin template with Element UI & axios & iconfont & permission control & lint 6 | 7 | **Live demo:** http://panjiachen.github.io/vue-admin-template 8 | 9 | 10 | **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`** 11 | 12 | ## Build Setup 13 | 14 | 15 | ```bash 16 | # clone the project 17 | git clone https://github.com/PanJiaChen/vue-admin-template.git 18 | 19 | # enter the project directory 20 | cd vue-admin-template 21 | 22 | # install dependency 23 | npm install 24 | 25 | # develop 26 | npm run dev 27 | ``` 28 | 29 | This will automatically open http://localhost:9528 30 | 31 | ## Build 32 | 33 | ```bash 34 | # build for test environment 35 | npm run build:stage 36 | 37 | # build for production environment 38 | npm run build:prod 39 | ``` 40 | 41 | ## Advanced 42 | 43 | ```bash 44 | # preview the release environment effect 45 | npm run preview 46 | 47 | # preview the release environment effect + static resource analysis 48 | npm run preview -- --report 49 | 50 | # code format check 51 | npm run lint 52 | 53 | # code format check and auto fix 54 | npm run lint -- --fix 55 | ``` 56 | 57 | Refer to [Documentation](https://panjiachen.github.io/vue-element-admin-site/guide/essentials/deploy.html) for more information 58 | 59 | ## Demo 60 | 61 | ![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif) 62 | 63 | ## Extra 64 | 65 | 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) 66 | 67 | For `typescript` version, you can use [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) (Credits: [@Armour](https://github.com/Armour)) 68 | 69 | ## Related Project 70 | 71 | - [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin) 72 | 73 | - [electron-vue-admin](https://github.com/PanJiaChen/electron-vue-admin) 74 | 75 | - [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) 76 | 77 | - [awesome-project](https://github.com/PanJiaChen/vue-element-admin/issues/2312) 78 | 79 | ## Browsers support 80 | 81 | Modern browsers and Internet Explorer 10+. 82 | 83 | | [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | 84 | | --------- | --------- | --------- | --------- | 85 | | IE10, IE11, Edge| last 2 versions| last 2 versions| last 2 versions 86 | 87 | ## License 88 | 89 | [MIT](https://github.com/PanJiaChen/vue-admin-template/blob/master/LICENSE) license. 90 | 91 | Copyright (c) 2017-present PanJiaChen 92 | -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | # vue-admin-template 2 | 3 | > 这是一个极简的 vue admin 管理后台。它只包含了 Element UI & axios & iconfont & permission control & lint,这些搭建后台必要的东西。 4 | 5 | [线上地址](http://panjiachen.github.io/vue-admin-template) 6 | 7 | [国内访问](https://panjiachen.gitee.io/vue-admin-template) 8 | 9 | 目前版本为 `v4.0+` 基于 `vue-cli` 进行构建,若你想使用旧版本,可以切换分支到[tag/3.11.0](https://github.com/PanJiaChen/vue-admin-template/tree/tag/3.11.0),它不依赖 `vue-cli`。 10 | 11 | ## Extra 12 | 13 | 如果你想要根据用户角色来动态生成侧边栏和 router,你可以使用该分支[permission-control](https://github.com/PanJiaChen/vue-admin-template/tree/permission-control) 14 | 15 | ## 相关项目 16 | 17 | - [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin) 18 | 19 | - [electron-vue-admin](https://github.com/PanJiaChen/electron-vue-admin) 20 | 21 | - [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) 22 | 23 | - [awesome-project](https://github.com/PanJiaChen/vue-element-admin/issues/2312) 24 | 25 | 写了一个系列的教程配套文章,如何从零构建后一个完整的后台项目: 26 | 27 | - [手摸手,带你用 vue 撸后台 系列一(基础篇)](https://juejin.im/post/59097cd7a22b9d0065fb61d2) 28 | - [手摸手,带你用 vue 撸后台 系列二(登录权限篇)](https://juejin.im/post/591aa14f570c35006961acac) 29 | - [手摸手,带你用 vue 撸后台 系列三 (实战篇)](https://juejin.im/post/593121aa0ce4630057f70d35) 30 | - [手摸手,带你用 vue 撸后台 系列四(vueAdmin 一个极简的后台基础模板,专门针对本项目的文章,算作是一篇文档)](https://juejin.im/post/595b4d776fb9a06bbe7dba56) 31 | - [手摸手,带你封装一个 vue component](https://segmentfault.com/a/1190000009090836) 32 | 33 | ## Build Setup 34 | 35 | ```bash 36 | # 克隆项目 37 | git clone https://github.com/PanJiaChen/vue-admin-template.git 38 | 39 | # 进入项目目录 40 | cd vue-admin-template 41 | 42 | # 安装依赖 43 | npm install 44 | 45 | # 建议不要直接使用 cnpm 安装以来,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题 46 | npm install --registry=https://registry.npm.taobao.org 47 | 48 | # 启动服务 49 | npm run dev 50 | ``` 51 | 52 | 浏览器访问 [http://localhost:9528](http://localhost:9528) 53 | 54 | ## 发布 55 | 56 | ```bash 57 | # 构建测试环境 58 | npm run build:stage 59 | 60 | # 构建生产环境 61 | npm run build:prod 62 | ``` 63 | 64 | ## 其它 65 | 66 | ```bash 67 | # 预览发布环境效果 68 | npm run preview 69 | 70 | # 预览发布环境效果 + 静态资源分析 71 | npm run preview -- --report 72 | 73 | # 代码格式检查 74 | npm run lint 75 | 76 | # 代码格式检查并自动修复 77 | npm run lint -- --fix 78 | ``` 79 | 80 | 更多信息请参考 [使用文档](https://panjiachen.github.io/vue-element-admin-site/zh/) 81 | 82 | ## Demo 83 | 84 | ![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif) 85 | 86 | ## Browsers support 87 | 88 | Modern browsers and Internet Explorer 10+. 89 | 90 | | [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | 91 | | --------- | --------- | --------- | --------- | 92 | | IE10, IE11, Edge| last 2 versions| last 2 versions| last 2 versions 93 | 94 | ## License 95 | 96 | [MIT](https://github.com/PanJiaChen/vue-admin-template/blob/master/LICENSE) license. 97 | 98 | Copyright (c) 2017-present PanJiaChen 99 | -------------------------------------------------------------------------------- /src/store/modules/user.js: -------------------------------------------------------------------------------- 1 | import { login, logout, getInfo } from '@/api/user' 2 | import { getToken, setToken, removeToken } from '@/utils/auth' 3 | import { resetRouter } from '@/router' 4 | 5 | const getDefaultState = () => { 6 | return { 7 | token: getToken(), 8 | name: '', 9 | avatar: '', 10 | roles: [], 11 | introduction: '' 12 | } 13 | } 14 | 15 | const state = getDefaultState() 16 | 17 | const mutations = { 18 | RESET_STATE: (state) => { 19 | Object.assign(state, getDefaultState()) 20 | }, 21 | SET_TOKEN: (state, token) => { 22 | state.token = token 23 | }, 24 | SET_NAME: (state, name) => { 25 | state.name = name 26 | }, 27 | SET_AVATAR: (state, avatar) => { 28 | state.avatar = avatar 29 | }, 30 | SET_ROLES: (state, roles) => { 31 | state.roles = roles 32 | }, 33 | SET_INTRODUCTION: (state, introduction) => { 34 | state.introduction = introduction 35 | } 36 | } 37 | 38 | const actions = { 39 | // 2.用户登录的方法 40 | login({ commit }, userInfo) { 41 | const { username, password } = userInfo 42 | return new Promise((resolve, reject) => { 43 | // 3.调用api/user中的login方法登录 44 | login({ username: username.trim(), password: password }).then(response => { 45 | const { data } = response 46 | // 4.保存后台返回的token信息 47 | commit('SET_TOKEN', data.token) 48 | setToken(data.token) 49 | resolve() 50 | }).catch(error => { 51 | reject(error) 52 | }) 53 | }) 54 | }, 55 | 56 | // 获取用户信息的方法 57 | getInfo({ commit, state }) { 58 | return new Promise((resolve, reject) => { 59 | getInfo(state.token).then(response => { 60 | const { data } = response 61 | 62 | if (!data) { 63 | reject('Verification failed, please Login again.') 64 | } 65 | 66 | const { roles, name, avatar, introduction } = data 67 | 68 | if (!roles || roles.length <= 0) { 69 | reject('getInfo: roles must be a non-null array!') 70 | } 71 | 72 | commit('SET_ROLES', roles) 73 | commit('SET_NAME', name) 74 | commit('SET_AVATAR', avatar) 75 | commit('SET_INTRODUCTION', introduction) 76 | 77 | resolve(data) 78 | }).catch(error => { 79 | reject(error) 80 | }) 81 | }) 82 | }, 83 | 84 | // user logout 85 | logout({ commit, state }) { 86 | return new Promise((resolve, reject) => { 87 | logout(state.token).then(() => { 88 | removeToken() // must remove token first 89 | resetRouter() 90 | commit('RESET_STATE') 91 | resolve() 92 | }).catch(error => { 93 | reject(error) 94 | }) 95 | }) 96 | }, 97 | 98 | changeRoles({ commit, dispatch }, role) { 99 | return new Promise(async resolve => { 100 | const token = role + '-token' 101 | 102 | commit('SET_TOKEN', token) 103 | setToken(token) 104 | 105 | const { roles } = await dispatch('getInfo') 106 | resetRouter() 107 | // generate accessible routes map based on roles 108 | const accessRoutes = await dispatch('permission/generateRoutes', roles, { root: true }) 109 | // dynamically add accessible routes 110 | // router.addRoutes(accessRoutes) 111 | // reset visited views and cached views 112 | dispatch('tagsView/delAllViews', null, { root: true }) 113 | 114 | resolve() 115 | }) 116 | }, 117 | 118 | // remove token 119 | resetToken({ commit }) { 120 | return new Promise(resolve => { 121 | removeToken() // must remove token first 122 | commit('RESET_STATE') 123 | resolve() 124 | }) 125 | } 126 | } 127 | 128 | export default { 129 | namespaced: true, 130 | state, 131 | mutations, 132 | actions 133 | } 134 | 135 | -------------------------------------------------------------------------------- /src/layout/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 61 | 62 | 140 | -------------------------------------------------------------------------------- /src/views/permission/directive.vue: -------------------------------------------------------------------------------- 1 | 2 | 74 | 75 | 97 | 98 | 117 | 118 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const defaultSettings = require('./src/settings.js') 4 | 5 | function resolve(dir) { 6 | return path.join(__dirname, dir) 7 | } 8 | 9 | const name = defaultSettings.title || 'vue Admin Template' // page title 10 | 11 | // If your port is set to 80, 12 | // use administrator privileges to execute the command line. 13 | // For example, Mac: sudo npm run 14 | // You can change the port by the following methods: 15 | // port = 9528 npm run dev OR npm run dev --port = 9528 16 | const port = process.env.port || process.env.npm_config_port || 9528 // dev port 17 | 18 | // All configuration item explanations can be find in https://cli.vuejs.org/config/ 19 | module.exports = { 20 | /** 21 | * You will need to set publicPath if you plan to deploy your site under a sub path, 22 | * for example GitHub Pages. If you plan to deploy your site to https://foo.github.io/bar/, 23 | * then publicPath should be set to "/bar/". 24 | * In most cases please use '/' !!! 25 | * Detail: https://cli.vuejs.org/config/#publicpath 26 | */ 27 | publicPath: '/', 28 | outputDir: 'dist', 29 | assetsDir: 'static', 30 | lintOnSave: process.env.NODE_ENV === 'development', 31 | productionSourceMap: false, 32 | devServer: { 33 | port: port, 34 | open: true, 35 | overlay: { 36 | warnings: false, 37 | errors: true 38 | }, 39 | before: require('./mock/mock-server.js') 40 | }, 41 | configureWebpack: { 42 | // provide the app's title in webpack's name field, so that 43 | // it can be accessed in index.html to inject the correct title. 44 | name: name, 45 | resolve: { 46 | alias: { 47 | '@': resolve('src') 48 | } 49 | } 50 | }, 51 | chainWebpack(config) { 52 | config.plugins.delete('preload') // TODO: need test 53 | config.plugins.delete('prefetch') // TODO: need test 54 | 55 | // set svg-sprite-loader 56 | config.module 57 | .rule('svg') 58 | .exclude.add(resolve('src/icons')) 59 | .end() 60 | config.module 61 | .rule('icons') 62 | .test(/\.svg$/) 63 | .include.add(resolve('src/icons')) 64 | .end() 65 | .use('svg-sprite-loader') 66 | .loader('svg-sprite-loader') 67 | .options({ 68 | symbolId: 'icon-[name]' 69 | }) 70 | .end() 71 | 72 | // set preserveWhitespace 73 | config.module 74 | .rule('vue') 75 | .use('vue-loader') 76 | .loader('vue-loader') 77 | .tap(options => { 78 | options.compilerOptions.preserveWhitespace = true 79 | return options 80 | }) 81 | .end() 82 | 83 | config 84 | // https://webpack.js.org/configuration/devtool/#development 85 | .when(process.env.NODE_ENV === 'development', 86 | config => config.devtool('cheap-source-map') 87 | ) 88 | 89 | config 90 | .when(process.env.NODE_ENV !== 'development', 91 | config => { 92 | config 93 | .plugin('ScriptExtHtmlWebpackPlugin') 94 | .after('html') 95 | .use('script-ext-html-webpack-plugin', [{ 96 | // `runtime` must same as runtimeChunk name. default is `runtime` 97 | inline: /runtime\..*\.js$/ 98 | }]) 99 | .end() 100 | config 101 | .optimization.splitChunks({ 102 | chunks: 'all', 103 | cacheGroups: { 104 | libs: { 105 | name: 'chunk-libs', 106 | test: /[\\/]node_modules[\\/]/, 107 | priority: 10, 108 | chunks: 'initial' // only package third parties that are initially dependent 109 | }, 110 | elementUI: { 111 | name: 'chunk-elementUI', // split elementUI into a single package 112 | priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app 113 | test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm 114 | }, 115 | commons: { 116 | name: 'chunk-commons', 117 | test: resolve('src/components'), // can customize your rules 118 | minChunks: 3, // minimum common number 119 | priority: 5, 120 | reuseExistingChunk: true 121 | } 122 | } 123 | }) 124 | config.optimization.runtimeChunk('single') 125 | } 126 | ) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/views/tree/index.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 158 | -------------------------------------------------------------------------------- /src/styles/sidebar.scss: -------------------------------------------------------------------------------- 1 | #app { 2 | 3 | .main-container { 4 | min-height: 100%; 5 | transition: margin-left .28s; 6 | margin-left: $sideBarWidth; 7 | position: relative; 8 | } 9 | 10 | .sidebar-container { 11 | transition: width 0.28s; 12 | width: $sideBarWidth !important; 13 | background-color: $menuBg; 14 | height: 100%; 15 | position: fixed; 16 | font-size: 0px; 17 | top: 0; 18 | bottom: 0; 19 | left: 0; 20 | z-index: 1001; 21 | overflow: hidden; 22 | 23 | // reset element-ui css 24 | .horizontal-collapse-transition { 25 | transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out; 26 | } 27 | 28 | .scrollbar-wrapper { 29 | overflow-x: hidden !important; 30 | } 31 | 32 | .el-scrollbar__bar.is-vertical { 33 | right: 0px; 34 | } 35 | 36 | .el-scrollbar { 37 | height: 100%; 38 | } 39 | 40 | &.has-logo { 41 | .el-scrollbar { 42 | height: calc(100% - 50px); 43 | } 44 | } 45 | 46 | .is-horizontal { 47 | display: none; 48 | } 49 | 50 | a { 51 | display: inline-block; 52 | width: 100%; 53 | overflow: hidden; 54 | } 55 | 56 | .svg-icon { 57 | margin-right: 16px; 58 | } 59 | 60 | .el-menu { 61 | border: none; 62 | height: 100%; 63 | width: 100% !important; 64 | } 65 | 66 | // menu hover 67 | .submenu-title-noDropdown, 68 | .el-submenu__title { 69 | &:hover { 70 | background-color: $menuHover !important; 71 | } 72 | } 73 | 74 | .is-active>.el-submenu__title { 75 | color: $subMenuActiveText !important; 76 | } 77 | 78 | & .nest-menu .el-submenu>.el-submenu__title, 79 | & .el-submenu .el-menu-item { 80 | min-width: $sideBarWidth !important; 81 | background-color: $subMenuBg !important; 82 | 83 | &:hover { 84 | background-color: $subMenuHover !important; 85 | } 86 | } 87 | } 88 | 89 | .hideSidebar { 90 | .sidebar-container { 91 | width: 54px !important; 92 | } 93 | 94 | .main-container { 95 | margin-left: 54px; 96 | } 97 | 98 | .submenu-title-noDropdown { 99 | padding: 0 !important; 100 | position: relative; 101 | 102 | .el-tooltip { 103 | padding: 0 !important; 104 | 105 | .svg-icon { 106 | margin-left: 20px; 107 | } 108 | } 109 | } 110 | 111 | .el-submenu { 112 | overflow: hidden; 113 | 114 | &>.el-submenu__title { 115 | padding: 0 !important; 116 | 117 | .svg-icon { 118 | margin-left: 20px; 119 | } 120 | 121 | .el-submenu__icon-arrow { 122 | display: none; 123 | } 124 | } 125 | } 126 | 127 | .el-menu--collapse { 128 | .el-submenu { 129 | &>.el-submenu__title { 130 | &>span { 131 | height: 0; 132 | width: 0; 133 | overflow: hidden; 134 | visibility: hidden; 135 | display: inline-block; 136 | } 137 | } 138 | } 139 | } 140 | } 141 | 142 | .el-menu--collapse .el-menu .el-submenu { 143 | min-width: $sideBarWidth !important; 144 | } 145 | 146 | // mobile responsive 147 | .mobile { 148 | .main-container { 149 | margin-left: 0px; 150 | } 151 | 152 | .sidebar-container { 153 | transition: transform .28s; 154 | width: $sideBarWidth !important; 155 | } 156 | 157 | &.hideSidebar { 158 | .sidebar-container { 159 | pointer-events: none; 160 | transition-duration: 0.3s; 161 | transform: translate3d(-$sideBarWidth, 0, 0); 162 | } 163 | } 164 | } 165 | 166 | .withoutAnimation { 167 | 168 | .main-container, 169 | .sidebar-container { 170 | transition: none; 171 | } 172 | } 173 | } 174 | 175 | // when menu collapsed 176 | .el-menu--vertical { 177 | &>.el-menu { 178 | .svg-icon { 179 | margin-right: 16px; 180 | } 181 | } 182 | 183 | .nest-menu .el-submenu>.el-submenu__title, 184 | .el-menu-item { 185 | &:hover { 186 | // you can use $subMenuHover 187 | background-color: $menuHover !important; 188 | } 189 | } 190 | 191 | // the scroll bar appears when the subMenu is too long 192 | >.el-menu--popup { 193 | max-height: 100vh; 194 | overflow-y: auto; 195 | 196 | &::-webkit-scrollbar-track-piece { 197 | background: #d3dce6; 198 | } 199 | 200 | &::-webkit-scrollbar { 201 | width: 6px; 202 | } 203 | 204 | &::-webkit-scrollbar-thumb { 205 | background: #99a9bf; 206 | border-radius: 20px; 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/views/table/index.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 160 | -------------------------------------------------------------------------------- /src/store/modules/tagsView.js: -------------------------------------------------------------------------------- 1 | //记录访问和缓存的视图 2 | const state = { 3 | visitedViews: [], 4 | cachedViews: [] 5 | } 6 | 7 | const mutations = { 8 | ADD_VISITED_VIEW: (state, view) => { 9 | if (state.visitedViews.some(v => v.path === view.path)) return 10 | state.visitedViews.push( 11 | Object.assign({}, view, { 12 | title: view.meta.title || 'no-name' 13 | }) 14 | ) 15 | }, 16 | ADD_CACHED_VIEW: (state, view) => { 17 | if (state.cachedViews.includes(view.name)) return 18 | if (!view.meta.noCache) { 19 | state.cachedViews.push(view.name) 20 | } 21 | }, 22 | 23 | DEL_VISITED_VIEW: (state, view) => { 24 | for (const [i, v] of state.visitedViews.entries()) { 25 | if (v.path === view.path) { 26 | state.visitedViews.splice(i, 1) 27 | break 28 | } 29 | } 30 | }, 31 | DEL_CACHED_VIEW: (state, view) => { 32 | const index = state.cachedViews.indexOf(view.name) 33 | index > -1 && state.cachedViews.splice(index, 1) 34 | }, 35 | 36 | DEL_OTHERS_VISITED_VIEWS: (state, view) => { 37 | state.visitedViews = state.visitedViews.filter(v => { 38 | return v.meta.affix || v.path === view.path 39 | }) 40 | }, 41 | DEL_OTHERS_CACHED_VIEWS: (state, view) => { 42 | const index = state.cachedViews.indexOf(view.name) 43 | if (index > -1) { 44 | state.cachedViews = state.cachedViews.slice(index, index + 1) 45 | } else { 46 | // if index = -1, there is no cached tags 47 | state.cachedViews = [] 48 | } 49 | }, 50 | 51 | DEL_ALL_VISITED_VIEWS: state => { 52 | // keep affix tags 53 | const affixTags = state.visitedViews.filter(tag => tag.meta.affix) 54 | state.visitedViews = affixTags 55 | }, 56 | DEL_ALL_CACHED_VIEWS: state => { 57 | state.cachedViews = [] 58 | }, 59 | 60 | UPDATE_VISITED_VIEW: (state, view) => { 61 | for (let v of state.visitedViews) { 62 | if (v.path === view.path) { 63 | v = Object.assign(v, view) 64 | break 65 | } 66 | } 67 | } 68 | } 69 | 70 | const actions = { 71 | addView({ dispatch }, view) { 72 | dispatch('addVisitedView', view) 73 | dispatch('addCachedView', view) 74 | }, 75 | addVisitedView({ commit }, view) { 76 | commit('ADD_VISITED_VIEW', view) 77 | }, 78 | addCachedView({ commit }, view) { 79 | commit('ADD_CACHED_VIEW', view) 80 | }, 81 | 82 | delView({ dispatch, state }, view) { 83 | return new Promise(resolve => { 84 | dispatch('delVisitedView', view) 85 | dispatch('delCachedView', view) 86 | resolve({ 87 | visitedViews: [...state.visitedViews], 88 | cachedViews: [...state.cachedViews] 89 | }) 90 | }) 91 | }, 92 | delVisitedView({ commit, state }, view) { 93 | return new Promise(resolve => { 94 | commit('DEL_VISITED_VIEW', view) 95 | resolve([...state.visitedViews]) 96 | }) 97 | }, 98 | delCachedView({ commit, state }, view) { 99 | return new Promise(resolve => { 100 | commit('DEL_CACHED_VIEW', view) 101 | resolve([...state.cachedViews]) 102 | }) 103 | }, 104 | 105 | delOthersViews({ dispatch, state }, view) { 106 | return new Promise(resolve => { 107 | dispatch('delOthersVisitedViews', view) 108 | dispatch('delOthersCachedViews', view) 109 | resolve({ 110 | visitedViews: [...state.visitedViews], 111 | cachedViews: [...state.cachedViews] 112 | }) 113 | }) 114 | }, 115 | delOthersVisitedViews({ commit, state }, view) { 116 | return new Promise(resolve => { 117 | commit('DEL_OTHERS_VISITED_VIEWS', view) 118 | resolve([...state.visitedViews]) 119 | }) 120 | }, 121 | delOthersCachedViews({ commit, state }, view) { 122 | return new Promise(resolve => { 123 | commit('DEL_OTHERS_CACHED_VIEWS', view) 124 | resolve([...state.cachedViews]) 125 | }) 126 | }, 127 | 128 | delAllViews({ dispatch, state }, view) { 129 | return new Promise(resolve => { 130 | dispatch('delAllVisitedViews', view) 131 | dispatch('delAllCachedViews', view) 132 | resolve({ 133 | visitedViews: [...state.visitedViews], 134 | cachedViews: [...state.cachedViews] 135 | }) 136 | }) 137 | }, 138 | delAllVisitedViews({ commit, state }) { 139 | return new Promise(resolve => { 140 | commit('DEL_ALL_VISITED_VIEWS') 141 | resolve([...state.visitedViews]) 142 | }) 143 | }, 144 | delAllCachedViews({ commit, state }) { 145 | return new Promise(resolve => { 146 | commit('DEL_ALL_CACHED_VIEWS') 147 | resolve([...state.cachedViews]) 148 | }) 149 | }, 150 | 151 | updateVisitedView({ commit }, view) { 152 | commit('UPDATE_VISITED_VIEW', view) 153 | } 154 | } 155 | 156 | export default { 157 | namespaced: true, 158 | state, 159 | mutations, 160 | actions 161 | } 162 | -------------------------------------------------------------------------------- /src/views/nested/menu1/menu1-1/index.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 163 | 178 | -------------------------------------------------------------------------------- /src/views/404.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 34 | 35 | 229 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | 4 | Vue.use(Router) 5 | 6 | /* Layout */ 7 | import Layout from '@/layout' 8 | 9 | /** 10 | * Note: sub-menu only appear when route children.length >= 1 11 | * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html 12 | * 13 | * hidden: true if set true, item will not show in the sidebar(default is false) 14 | * alwaysShow: true if set true, will always show the root menu 15 | * if not set alwaysShow, when item has more than one children route, 16 | * it will becomes nested mode, otherwise not show the root menu 17 | * redirect: noRedirect if set noRedirect will no redirect in the breadcrumb 18 | * name:'router-name' the name is used by (must set!!!) 19 | * meta : { 20 | roles: ['admin','editor'] control the page roles (you can set multiple roles) 21 | title: 'title' the name show in sidebar and breadcrumb (recommend set) 22 | icon: 'svg-name' the icon show in the sidebar 23 | breadcrumb: false if set false, the item will hidden in breadcrumb(default is true) 24 | activeMenu: '/example/list' if set path, the sidebar will highlight the path you set 25 | } 26 | */ 27 | 28 | /** 29 | * constantRoutes 30 | * a base page that does not have permission requirements 31 | * all roles can be accessed 32 | */ 33 | 34 | // constantRouterMap:主要是通用部分,每个用户都有的页面 35 | export const constantRoutes = [ 36 | { 37 | path: '/login', 38 | component: () => import('@/views/login/index'), 39 | hidden: true 40 | }, 41 | { 42 | path: '/404', 43 | component: () => import('@/views/404'), 44 | hidden: true 45 | }, 46 | { 47 | path: '/', 48 | component: Layout, 49 | redirect: '/dashboard', 50 | children: [{ 51 | path: 'dashboard', 52 | name: 'Dashboard', 53 | component: () => import('@/views/dashboard/index'), 54 | meta: { title: '首页', icon: 'dashboard' } 55 | }] 56 | }, 57 | { 58 | path: '/example', 59 | component: Layout, 60 | redirect: '/example/table', 61 | name: 'Example', 62 | meta: { title: 'Example', icon: 'example' }, 63 | children: [ 64 | { 65 | path: 'table', 66 | name: 'Table', 67 | component: () => import('@/views/table/index'), 68 | meta: { title: '订单列表', icon: 'table' } 69 | }, 70 | { 71 | path: 'tree', 72 | name: 'Tree', 73 | component: () => import('@/views/tree/index'), 74 | meta: { title: '回收员订单列表', icon: 'tree' } 75 | } 76 | ] 77 | }, 78 | // { 79 | // path: '/form', 80 | // component: Layout, 81 | // name:"Center", 82 | // meta: { 83 | // title: '安全中心', 84 | // icon: 'example' 85 | // }, 86 | // children: [ 87 | // { 88 | // path: 'index', 89 | // name: 'Center', 90 | // component: () => import('@/views/form/index'), 91 | // meta: { title: '修改密码', icon: 'eye' } 92 | // },{ 93 | // path: 'addPerson', 94 | // name: 'addPerson', 95 | // component: () => import('@/views/form/addPerson'), 96 | // meta: { title: '用户管理', icon: 'tree' }, 97 | 98 | // } 99 | // ] 100 | // }, 101 | { 102 | path: '/nested', 103 | component: Layout, 104 | redirect: '/nested/menu1', 105 | name: 'Nested', 106 | meta: { 107 | title: '安全中心', 108 | icon: 'example' 109 | }, 110 | children: [ 111 | { 112 | path: 'menu1', 113 | component: () => import('@/views/nested/menu1/index'), // Parent router-view 114 | name: 'Menu1', 115 | meta: { title: '用户管理',icon: 'tree' }, 116 | children: [ 117 | { 118 | path: 'menu1-1', 119 | component: () => import('@/views/nested/menu1/menu1-1'), 120 | name: 'Menu1-1', 121 | meta: { title: '管理员权限',icon: 'user' } 122 | }, 123 | { 124 | path: 'menu1-2', 125 | component: () => import('@/views/nested/menu1/menu1-2'), 126 | name: 'Menu1-2', 127 | meta: { title: '回收员权限',icon: 'user' }, 128 | }, 129 | { 130 | path: 'menu1-3', 131 | component: () => import('@/views/nested/menu1/menu1-3'), 132 | name: 'Menu1-3', 133 | meta: { title: '普通用户权限',icon: 'user' } 134 | } 135 | ] 136 | }, 137 | { 138 | path: 'menu2', 139 | component: () => import('@/views/nested/menu2/index'), 140 | meta: { title: '修改密码', icon: 'eye' } 141 | } 142 | ] 143 | }, 144 | { 145 | path: 'external-link', 146 | component: Layout, 147 | children: [ 148 | { 149 | path: 'https://panjiachen.github.io/vue-element-admin-site/#/', 150 | meta: { title: 'External Link', icon: 'link' } 151 | } 152 | ] 153 | }, 154 | { 155 | path: '/product', 156 | component: Layout, 157 | name: 'product', 158 | meta: { 159 | title: '商品模块', 160 | icon: 'nested' 161 | }, 162 | children: [ 163 | { 164 | path: 'index', 165 | name: 'Product', 166 | component: () => import('@/views/product/index'), 167 | meta: { title: '商品', icon: 'eye' } 168 | }, 169 | { 170 | path: 'new', 171 | component: () => import('@/views/product/new'), 172 | meta: { title: '新增商品', icon: 'form' } 173 | } 174 | ] 175 | }, 176 | // 404 page must be placed at the end !!! 177 | { path: '*', redirect: '/404', hidden: true } 178 | ] 179 | 180 | // asyncRouterMap:需要进行权限过滤的页面 181 | export const asyncRoutes = [ 182 | { 183 | path: '/permission', 184 | component: Layout, 185 | redirect: '/permission/page', 186 | alwaysShow: true, // will always show the root menu 187 | name: 'Permission', 188 | meta: { 189 | title: 'Permission', 190 | icon: 'tree', 191 | roles: ['admin','editor'] // you can set roles in root nav 192 | }, 193 | children: [ 194 | { 195 | path: 'page', 196 | component: () => import('@/views/permission/page'), 197 | name: 'PagePermission', 198 | meta: { 199 | title: 'Page Permission', 200 | roles: ['admin'] // or you can only set roles in sub nav 201 | } 202 | }, 203 | { 204 | path: 'directive', 205 | component: () => import('@/views/permission/directive'), 206 | name: 'DirectivePermission', 207 | meta: { 208 | title: 'Directive Permission' 209 | // if do not set roles, means: this page does not require permission 210 | } 211 | } 212 | ] 213 | } 214 | ] 215 | 216 | const createRouter = () => new Router({ 217 | // mode: 'history', // require service support 218 | scrollBehavior: () => ({ y: 0 }), 219 | routes: constantRoutes 220 | }) 221 | 222 | const router = createRouter() 223 | 224 | // Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465 225 | export function resetRouter() { 226 | const newRouter = createRouter() 227 | router.matcher = newRouter.matcher // reset router 228 | } 229 | 230 | export default router 231 | -------------------------------------------------------------------------------- /src/views/login/index.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 216 | 217 | 269 | 270 | 334 | --------------------------------------------------------------------------------