vue-element-admin

vscode 插件和配置推荐

这是一篇对 vue-element-admin 的学习总结文章。

官方文档

目录结构

├── build                      # 构建相关
├── mock                       # 项目mock 模拟数据
├── plop-templates             # 基本模板
├── public                     # 静态资源
│   │── favicon.ico            # favicon图标
│   └── index.html             # html模板
├── src                        # 源代码
│   ├── api                    # 所有请求
│   ├── assets                 # 主题 字体等静态资源
│   ├── components             # 全局公用组件
│   ├── directive              # 全局指令
│   ├── filters                # 全局 filter
│   ├── icons                  # 项目所有 svg icons
│   ├── lang                   # 国际化 language
│   ├── layout                 # 全局 layout
│   ├── router                 # 路由
│   ├── store                  # 全局 store管理
│   ├── styles                 # 全局样式
│   ├── utils                  # 全局公用方法
│   ├── vendor                 # 公用vendor
│   ├── views                  # views 所有页面
│   ├── App.vue                # 入口页面
│   ├── main.js                # 入口文件 加载组件 初始化等
│   └── permission.js          # 权限管理
├── tests                      # 测试
├── .env.xxx                   # 环境变量配置
├── .eslintrc.js               # eslint 配置项
├── .babelrc                   # babel-loader 配置
├── .travis.yml                # 自动化CI配置
├── vue.config.js              # vue-cli 配置
├── postcss.config.js          # postcss 配置
└── package.json               # package.json

开始开发

# 克隆项目
git clone https://github.com/PanJiaChen/vue-element-admin.git

# 进入项目目录
cd vue-element-admin

# 安装依赖
npm install

# 建议不要直接使用 cnpm 安装依赖,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题
npm install --registry=https://registry.npm.taobao.org

# 启动服务
npm run dev

#发布

#正式环境
npm run build:prod

#集成环境
npm run sit

如果node-sass安装报错的话,可以重试npm install node-sass,如果还是不行的话,可以npm install --registry=https://registry.npm.taobao.org/,再安装;实在不行的话用cnpm install node-sass安装这个包感觉也没关系,虽然文档不建议,但是这个包用cnpm肯定是没问题的

node-sass报错可参考链接:https://github.com/PanJiaChen/vue-element-admin/issues/24

一、src 目录

views 和 api 两个模块一一对应,从而方便维护

api:请求接口文件夹

views:页面组件文件夹

api

一个.js对应一个views文件夹里面的一个模块

例如:

api-views.png

api 里面的login.js,对应的是 views 里面的login文件夹,如果有公共模块就单独放置就好

用法:

1. 先在/src/api 新建xxx.js,例如bind.js

2. 引入

import axios from "@/utils/request";
import * as qs from "qs";

// 解除管控-列表页
export const deleteClassPlateCtrlByBatch = (params) => {
  return axios.post(
    `ctrlSystem/deleteClassPlateCtrlByBatch`,
    qs.stringify(params)
  );
};

3. 使用

3.1 引入
import { deleteStudentPlateCtrlByBatch } from "@/api/bindManage";
3.2 函数中使用
deleCtro(){
     let defaultBaseInfo = this.$store.state.user.defaultBaseInfo;
     let obj = {
        interUser: "runLfb",
        interPwd: hex_md5(1234578),
        operateAccountNo: defaultBaseInfo.operateAccountNo,
        belongSchoolId: defaultBaseInfo.belongSchoolId,
        schoolId: queryObj.schoolId,
        classId: queryObj.classId,
        surfacePlateBindRequestVoList: this.classIdList,
      };
      let params = {
        requestJson: JSON.stringify(obj)
      }
      console.log(obj);
      deleteStudentPlateCtrlByBatch(params).then((r) => {
        console.log("deleteClassPlateCtrlByBatch", r);
        this.success(r);
      });
}

封装 axios

1. /src/utils/request.js

import Vue from "vue";
import axios from "axios";
import { MessageBox, Message } from "element-ui";
import store from "@/store";
import { getToken } from "@/utils/auth";
import router from "../router";
import { Loading } from "element-ui";
import Cookies from "js-cookie";
// create an axios instance
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
  // withCredentials: true, // send cookies when cross-domain requests
  timeout: 10000, // request timeout
});

// //========================================================token ======================================
var loading = ""; //定义loading变量

function startLoading() {
  //使用Element loading-start 方法
  loading = Loading.service({
    lock: true,
    // text: '加载中……',
    background: "rgba(0, 0, 0, 0)",
  });
}
function endLoading() {
  //使用Element loading-close 方法
  loading.close();
}
// 刷新token的过期时间判断
function isRefreshTokenExpired() {
  const oData = store.getters.getTokenTime; // 这是在登陆时候保存的时间戳
  const nDta = new Date().getTime();
  const stamp = nDta - oData; // 相差的微秒数
  // const seconds = parseInt((stamp % (1000 * 60 * 60)) / 1000)  错误的计算,秒数差永远<3600
  const seconds = parseInt(stamp / 1000);
  return (
    seconds >= (store.getters.getTokenUsable * 3) / 4 &&
    seconds < store.getters.getTokenUsable
  );
  // return false
}
// 刷新token
function getRefreshToken() {
  // 刷新token 注意这里用到的service
  return service.post("/public/regenerationToken").then((res) => {
    return Promise.resolve(res.data);
  });
}

// 是否正在刷新的标志
window.isRefreshing = false;
// 存储请求的数组
let refreshSubscribers = [];

/* 将所有的请求都push到数组中*/
function subscribeTokenRefresh(cb) {
  refreshSubscribers.push(cb);
}

// 数组中的请求得到新的token之后自执行,用新的token去请求数据
function onRrefreshed(token) {
  refreshSubscribers.map((cb) => cb(token));
}
// 删除cookie
function removeCookie() {
  Cookies.remove("username", { path: "/" });
  Cookies.remove("password", { path: "/" });
}
// request interceptor
service.interceptors.request.use(
  (config) => {
    startLoading();
    let url = config.url;
    // 解决问题:axios不会对url中的功能性字符进行编码,手动编码
    // get参数编码
    if (config.method === "get" && config.params) {
      url += "?";
      const keys = Object.keys(config.params);
      for (const key of keys) {
        url += `${key}=${encodeURIComponent(config.params[key])}&`;
      }
      url = url.substring(0, url.length - 1);
      config.params = {};
    }
    config.url = url;

    const accessToken = store.getters.getAccessToken; // 本地保存的token
    const refreshToken = store.getters.getRefreshToken; // 本地保存的token
    /* 判断token是否存在*/
    if (accessToken && accessToken != "undefined") {
      /* 在请求头中添加token类型、token*/
      config.headers.access_token = accessToken;
      config.headers.client_type = store.state.user.client_type;
      // config.url = config.url + '?t=' + (new Date()).getTime().toString(); // 清楚缓存
      /* 判断token是否将要过期 */
      if (
        isRefreshTokenExpired() &&
        config.url.indexOf("public/regenerationToken") === -1
      ) {
        if (!window.isRefreshing) {
          // /*判断是否正在刷新*/
          window.isRefreshing = true;
          /* 发起刷新token的请求*/
          // config.headers.Authorization = ''
          getRefreshToken();

          /* 把请求(token)=>{....}都push到一个数组中*/
          const retry = new Promise((resolve, reject) => {
            /* (token) => {...}这个函数就是回调函数*/
            subscribeTokenRefresh((token) => {
              // config.headers.common['Authorization'] = 'bearer ' + token;
              config.headers.access_token = token;
              /* 将请求挂起*/
              resolve(config);
            });
          });
          return retry;
        }
        return config;
      } else if (config.url.search(/\/public\/regenerationToken$/) >= 0) {
        config.headers.refresh_token = refreshToken;
        return config;
      } else {
        return config;
      }
    } else {
      return config;
    }
  },
  (error) => {
    return Promise.reject(error);
  }
);

// response interceptor
service.interceptors.response.use(
  /**
   * 根据后端的code码,做一些操作
   */
  (response) => {
    endLoading();
    const res = response.data;
    // 没有身份令牌或过期
    if (res.code == 11002 || res.code == 11001) {
      store.commit("user/setAccessToken", null);
      store.commit("user/setRefreshToken", null);
      store.commit("user/setTokenTime", null);
      store.commit("user/setTokenUsable", null);
      localStorage.clear();
      removeCookie();
      Message({
        message: "登录信息失效,请重新登录",
        type: "error",
        duration: 3 * 1000,
      });
      router.push("/login");
    }
    // alert(response.config.url)
    // console.log(response.config.url, 'response.config.url')
    if (
      (response.config.url.search(/\/user\/phoneLogin$/) >= 0 && res.flag) ||
      (response.config.url.search(/\/user\/registerByVerificationCode$/) >= 0 &&
        res.flag)
    ) {
      store.commit("user/setAccessToken", response.headers.access_token);
      store.commit("user/setRefreshToken", response.headers.refresh_token);
      store.commit("user/setTokenTime", new Date().getTime());
      store.commit("user/setTokenUsable", response.headers.token_usable);
    } else if (
      response.config.url.search(/\/public\/regenerationToken$/) >= 0
    ) {
      if (res.code == "0") {
        store.commit("user/setAccessToken", response.headers.access_token);
        store.commit("user/setRefreshToken", response.headers.refresh_token);
        store.commit("user/setTokenTime", new Date().getTime());
        store.commit("user/setTokenUsable", response.headers.token_usable);
        onRrefreshed(response.headers.access_token);
        window.isRefreshing = false;
        refreshSubscribers = [];
      } else {
        /* 清除本地保存的*/
        store.commit("user/setAccessToken", null);
        store.commit("user/setRefreshToken", null);
        store.commit("user/setTokenTime", null);
        store.commit("user/setTokenUsable", null);
        localStorage.clear();
        removeCookie();
        Message({
          message: "登录信息失效,请重新登录",
          type: "error",
          duration: 3 * 1000,
        });
        router.push("/login");
      }
    }
    return Promise.resolve(res);
  },
  (error) => {
    // Vue.prototype.$log4b.error("响应错误"+error.config.url+"错误信息"+JSON.stringify(error) )
    console.log("err", error); // for debug
    if (error && error.response) {
      switch (error.response.status) {
        case 400:
          error.message = "请求错误(400)";
          break;
        case 401:
          return history.push("/login");
          break;
        case 403:
          error.message = "拒绝访问(403)";
          break;
        case 404:
          error.message = "请求出错(404)";
          break;
        case 408:
          error.message = "请求超时(408)";
          break;
        case 500:
          error.message = "服务器错误(500)";
          break;
        case 501:
          error.message = "服务未实现(501)";
          break;
        case 502:
          error.message = "网络错误(502)";
          break;
        case 503:
          error.message = "服务不可用(503)";
          break;
        case 504:
          error.message = "网络超时(504)";
          break;
        case 505:
          error.message = "HTTP版本不受支持(505)";
          break;
        default:
          error.message = `连接出错(${error.response.status})!`;
      }
      Message({
        message: error.message,
        type: "error",
        duration: 3 * 1000,
      });
    }
    return Promise.reject(error);
  }
);

export default service;

components

components 放置的都是全局公用的一些组件,简单来说就是多个页面能用到的,不只是你当前页面,如上传组件;一些页面级的组件建议还是放在各自 views 文件下,方便管理。

store

用来写 vuex

用法:

1. ./store/index.js

vuex-along:解决 vuex 刷新消失问题,周下载量 215

vuex-along 使用文档地址

vuex-persistedstate:解决 vuex 刷新消失问题,周下载量 10w+

vuex-persistedstate 使用文档地址

后续建议使用vuex-persistedstate

  • vuex-along 周下载量
vuex-along 周下载量
  • vuex-persistedstate 周下载量


    vuex-persistedstate
import Vue from "vue";
import Vuex from "vuex";
import getters from "./getters";
import createVuexAlong from "vuex-along";
Vue.use(Vuex);

// https://webpack.js.org/guides/dependency-management/#requirecontext
const modulesFiles = require.context("./modules", true, /\.js$/);

// you do not need `import app from './modules/app'`
// it will auto require all vuex module from modules file
const modules = modulesFiles.keys().reduce((modules, modulePath) => {
  // set './app.js' => 'app'
  const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, "$1");
  const value = modulesFiles(modulePath);
  modules[moduleName] = value.default;
  return modules;
}, {});

const store = new Vuex.Store({
  modules,
  getters,
  plugins: [createVuexAlong()],
});

export default store;
2.0. 在/store/modules/新建xxx.js,例如,bind.js
bind.js
2.1. bind.js 代码:
const state = {
  bindFilter: "", //筛选条件缓存
  detailRouter: false, //是否是详情页回来的
};

const mutations = {
  bindFilter_Fun: (state, data) => {
    state.bindFilter = data;
    localStorage.setItem("bindFilter", JSON.stringify(data)); //缓存在localStorage里面,解决vuex刷新消失问题
  },
  detailRouter_Fun: (state, data) => {
    state.detailRouter = data;
    localStorage.setItem("detailRouter", JSON.stringify(data));
  },
};

const actions = {
  bindFilter({ commit, state }, data) {
    commit("bindFilter_Fun", data);
  },
  detailRouter({ commit, state }, data) {
    commit("detailRouter_Fun", data);
  },
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
};
3. /store/getters.js
const getters = {
  // 绑定管理筛选数据
  getBindFilter: (state) =>
    state.bind.bindFilter || JSON.parse(localStorage.getItem("bindFilter")),
  getDetailRouter: (state) =>
    state.bind.detailRouter || JSON.parse(localStorage.getItem("detailRouter")),
};
export default getters;
4.组件里面的用法

更多方法请查看:https://www.jianshu.com/p/0f13a6bec687

存入数据:this.$store.dispatch("bind/detailRouter", false)
获取数据:this.$store.getters.getDetailRouter
5.namespaced

vuex 中的 store 分模块管理,需要在 store 的 index.js 中引入各个模块,为了解决不同模块命名冲突的问题,将不同模块的 namespaced:true,之后在不同页面中引入 getter、actions、mutations 时,需要加上所属的模块名,相反,如果 namespaced:false 就是正常使用不用加模块名字;

文档链接

icon 图标的使用方式

把下载好的图标放入/src/icons/svg/文件夹

1. 使用方式

<svg-icon icon-class="password" /> // icon-class 为 icon 的名字

2. 改变颜色

svg-icon 默认会读取其父级的 color fill: currentColor;

你可以改变父级的color或者直接改变fill的颜色即可。

相关文档

二、layout布局

这里简单看一下layout的布局,方便以后好修改;

简单来说就是app.vue里面包含着layout,layout又包含着TagsView,sideBar,AppMain;然后我们写的东西都是在AppMain里面的

  • app.vue
    • layout
      • TagsView
      • sideBar
      • AppMain (内容容器)
layout

三、环境变量配置

1. 本地开发环境

.env.development---这个对应本地地址打包环境

# just a flag
ENV = 'development'

# base api
#VUE_APP_BASE_API = '/dev-api'

#代理服务器api
#VUE_APP_BASE_API = '/api' 接口地址

#不用代理服务器api 接口地址
VUE_APP_BASE_API = 'http://119.23.xxx.xxx:9001/service-soa'

# vue-cli uses the VUE_CLI_BABEL_TRANSPILE_MODULES environment variable,
# to control whether the babel-plugin-dynamic-import-node plugin is enabled.
# It only does one thing by converting all import() to require().
# This configuration can significantly increase the speed of hot updates,
# when you have a large number of pages.
# Detail:  https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/babel-preset-app/index.js
#开发环境不使用路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

2. 上线正式环境

.env.production------这个对应正式环境

# just a flag
ENV = 'production'

# base api 接口地址
VUE_APP_BASE_API = 'http://119.23.xxx.xx:8080/service-soa'

3. 集成测试环境

.env.sit------这个对应正式环境

#
NODE_ENV = production

# just a flag
ENV = 'sit'

# base api 接口地址
VUE_APP_BASE_API = 'http://119.23.xxx.71:9001/service-soa'

四、 vue.config.js

相关文档-My

相关文档-Other

"use strict";
const path = require("path");
const defaultSettings = require("./src/settings.js");
function resolve(dir) {
  return path.join(__dirname, dir);
}
const name = defaultSettings.title || "vue Element Admin"; // page title
const port = process.env.port || process.env.npm_config_port || 9530; // 端口号 port
module.exports = {
  publicPath: "/",
  outputDir: "dist",
  assetsDir: "static",
  // lintOnSave: process.env.NODE_ENV === 'development',
  lintOnSave: false,
  productionSourceMap: false,
  devServer: {
    hot: true, // 热加载
    port: port,
    https: false, // false关闭https,true为开启
    // open: true,
    overlay: {
      warnings: false,
      errors: true,
    },
    // before: require('./mock/mock-server.js'),
    proxy: {
      "/api": {
        target: "http://119.23.xxx.xxx:9001/service-soa",
        // 在本地会创建一个虚拟服务端,然后发送请求的数据,并同时接收请求的数据,这样服务端和服务端进行数据的交互就不会有跨域问题
        changeOrigin: true,
        ws: true,
        pathRewrite: {
          // 替换target中的请求地址,也就是说以后你在请求http://api.jisuapi.com/XXXXX这个地址的时候直接写成/api/xxx即可
          "^/api": "/",
        },
      },

      "/qq": {
        target: "https://xxx.qq.com/oauth2.0",
        // 在本地会创建一个虚拟服务端,然后发送请求的数据,并同时接收请求的数据,这样服务端和服务端进行数据的交互就不会有跨域问题
        changeOrigin: true,
        ws: true,
        pathRewrite: {
          // 替换target中的请求地址,也就是说以后你在请求http://api.jisuapi.com/XXXXX这个地址的时候直接写成/api/xxx即可
          "^/qq": "/",
        },
      },
      "/oss": {
        target: "http://xxx.xxx.aliyuncs.com",
        // 在本地会创建一个虚拟服务端,然后发送请求的数据,并同时接收请求的数据,这样服务端和服务端进行数据的交互就不会有跨域问题
        changeOrigin: true,
        ws: true,
        pathRewrite: {
          // 替换target中的请求地址,也就是说以后你在请求http://api.jisuapi.com/XXXXX这个地址的时候直接写成/api/xxx即可
          "^/oss": "/",
        },
      },
    },
  },
  configureWebpack: {
    // provide the app's title in webpack's name field, so that
    // it can be accessed in index.html to inject the correct title.
    name: name,
    resolve: {
      alias: {
        "@": resolve("src"),
      },
    },
  },
  chainWebpack(config) {
    config.plugins.delete("preload"); // TODO: need test
    config.plugins.delete("prefetch"); // TODO: need test

    // set svg-sprite-loader
    config.module.rule("svg").exclude.add(resolve("src/icons")).end();
    config.module
      .rule("icons")
      .test(/\.svg$/)
      .include.add(resolve("src/icons"))
      .end()
      .use("svg-sprite-loader")
      .loader("svg-sprite-loader")
      .options({
        symbolId: "icon-[name]",
      })
      .end();

    // set preserveWhitespace
    config.module
      .rule("vue")
      .use("vue-loader")
      .loader("vue-loader")
      .tap((options) => {
        options.compilerOptions.preserveWhitespace = true;
        return options;
      })
      .end();

    config
      // https://webpack.js.org/configuration/devtool/#development
      .when(process.env.NODE_ENV === "development", (config) =>
        config.devtool("cheap-source-map")
      );

    config.when(process.env.NODE_ENV !== "development", (config) => {
      config
        .plugin("ScriptExtHtmlWebpackPlugin")
        .after("html")
        .use("script-ext-html-webpack-plugin", [
          {
            // `runtime` must same as runtimeChunk name. default is `runtime`
            inline: /runtime\..*\.js$/,
          },
        ])
        .end();
      config.optimization.splitChunks({
        chunks: "all",
        cacheGroups: {
          libs: {
            name: "chunk-libs",
            test: /[\\/]node_modules[\\/]/,
            priority: 10,
            chunks: "initial", // only package third parties that are initially dependent
          },
          elementUI: {
            name: "chunk-elementUI", // split elementUI into a single package
            priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
            test: /[\\/]node_modules[\\/]_?element-ui(.*)/, // in order to adapt to cnpm
          },
          commons: {
            name: "chunk-commons",
            test: resolve("src/components"), // can customize your rules
            minChunks: 3, //  minimum common number
            priority: 5,
            reuseExistingChunk: true,
          },
        },
      });
      config.optimization.runtimeChunk("single");
    });
  },
};

五、 package.json

{
  "name": "vue-element-admin",
  "version": "4.2.1",
  "description": "A magical vue admin. An out-of-box UI solution for enterprise applications. Newest development stack of vue. Lots of awesome features",
  "author": "Pan <panfree23@gmail.com>",
  "license": "MIT",
  "scripts": {
    "dev": "vue-cli-service serve --open",
    "sit": "vue-cli-service build --mode sit",
    "prod": "vue-cli-service build --mode production",
    "build:prod": "vue-cli-service build",
    "build:stage": "vue-cli-service build --mode staging",
    "preview": "node build/index.js --preview",
    "lint": "eslint --ext .js,.vue src",
    "test:unit": "jest --clearCache && vue-cli-service test:unit",
    "test:ci": "npm run lint && npm run test:unit",
    "svgo": "svgo -f src/icons/svg --config=src/icons/svgo.yml",
    "new": "plop"
  },
  "lint-staged": {
    "src/**/*.{js,vue}": ["eslint --fix", "git add"]
  },
  "keywords": [
    "vue",
    "admin",
    "dashboard",
    "element-ui",
    "boilerplate",
    "admin-template",
    "management-system"
  ],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/PanJiaChen/vue-element-admin.git"
  },
  "bugs": {
    "url": "https://github.com/PanJiaChen/vue-element-admin/issues"
  },
  "dependencies": {
    "@fortawesome/fontawesome-svg-core": "^1.2.29",
    "@fortawesome/free-brands-svg-icons": "^5.13.1",
    "@fortawesome/free-regular-svg-icons": "^5.13.1",
    "@fortawesome/free-solid-svg-icons": "^5.13.1",
    "@fortawesome/vue-fontawesome": "^0.1.10",
    "arr2tree": "0.0.5",
    "axios": "0.18.1",
    "clipboard": "2.0.4",
    "codemirror": "5.45.0",
    "crypto-js": "^4.0.0",
    "driver.js": "0.9.5",
    "dropzone": "5.5.1",
    "echarts": "4.2.1",
    "element-ui": "2.13.0",
    "file-saver": "2.0.1",
    "fingerprintjs2": "^2.1.0",
    "fundebug-javascript": "^2.4.2",
    "fundebug-vue": "0.0.1",
    "fuse.js": "3.4.4",
    "js-cookie": "2.2.0",
    "jsonlint": "1.6.3",
    "jszip": "3.2.1",
    "kindeditor": "^4.1.10",
    "moment": "^2.27.0",
    "normalize.css": "7.0.0",
    "nprogress": "0.2.0",
    "path-to-regexp": "2.4.0",
    "qrcode": "^1.4.4",
    "qrcodejs2": "0.0.2",
    "screenfull": "4.2.0",
    "script-loader": "0.7.2",
    "showdown": "1.9.0",
    "sortablejs": "1.8.4",
    "tui-editor": "1.3.3",
    "vue": "2.6.10",
    "vue-count-to": "1.0.13",
    "vue-router": "3.0.2",
    "vue-splitpane": "1.0.4",
    "vuedraggable": "2.20.0",
    "vuex": "3.1.0",
    "vuex-along": "^1.2.11",
    "wangeditor": "^3.1.1",
    "xlsx": "0.14.1"
  },
  "devDependencies": {
    "@babel/core": "7.0.0",
    "@babel/register": "7.0.0",
    "@vue/cli-plugin-babel": "3.5.3",
    "@vue/cli-plugin-eslint": "^3.9.1",
    "@vue/cli-plugin-unit-jest": "3.5.3",
    "@vue/cli-service": "3.5.3",
    "@vue/test-utils": "1.0.0-beta.29",
    "autoprefixer": "^9.5.1",
    "babel-core": "7.0.0-bridge.0",
    "babel-eslint": "10.0.1",
    "babel-jest": "23.6.0",
    "chalk": "2.4.2",
    "chokidar": "2.1.5",
    "connect": "3.6.6",
    "eslint": "5.15.3",
    "eslint-plugin-vue": "5.2.2",
    "html-webpack-plugin": "3.2.0",
    "husky": "1.3.1",
    "lint-staged": "8.1.5",
    "mockjs": "1.0.1-beta3",
    "node-sass": "^4.9.0",
    "plop": "2.3.0",
    "runjs": "^4.3.2",
    "sass-loader": "^7.1.0",
    "script-ext-html-webpack-plugin": "2.1.3",
    "serve-static": "^1.13.2",
    "svg-sprite-loader": "4.1.3",
    "svgo": "1.2.0",
    "vue-template-compiler": "2.6.10"
  },
  "engines": {
    "node": ">=8.9",
    "npm": ">= 3.0.0"
  },
  "browserslist": ["> 1%", "last 2 versions"]
}

六、权限

1. 路由权限

流程:

1.登录页面按钮点击
2.vuex 里面的 login 方法被调用
3.vuex 里面的 login 方法被调用 完毕 4.监听路由改变 然后获取当前登录的用户角色 5.获取当前用户信息 获取角色组 并保存登录状态,返回当前角色信息 6.通过 角色 和 所有路由 匹配出对应角色拥有的路由权限 返回路由组
7 将上面获取到的 路由权限 挂载到真实的路由上面去

路由权限涉及文件:

/src/views/login/index.vue 登录页面的入口文件

/src/store/modules/user.js vuex 的文件 全局方法

/src/permission.js 监听路由改变后的 js

/src/store/mudules/permission.js 通过 角色返回 登录角色的对应路由列表的方法

src/views/permission/components/SwitchRoles.vue 切换角色的文件 这个登录不走 切换角色才会走

/src/router/index.js

首先路由页面 router:
有 2 个参数

export const constantRouterMap = [] 为初始路由参数,如登录 首页 404 等共有页面 不需要权限控制的路由
export const asyncRouterMap = []为动态路由 登录成功后 在 router.beforeEach 中根据后端权限 加载不同路由 已展示不同的左侧菜单

import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
/* Layout */
import Layout from '@/layout'
/**
 * Note: sub-menu only appear when route children.length >= 1
 * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
 *
 * hidden: true                  如果设置为true,则不会在侧边栏中显示该项(默认为false)
 * alwaysShow: true               如果设置为true,将始终显示根菜单
 *                                如果未设置alwaysShow,则当项目有多个子路径时,
                                  *它将变成嵌套模式,否则不显示根菜单
 * redirect: noRedirect           如果set noRedirect将不会在breadcrumb中重定向
 * name:'router-name'             名称由<keep alive>使用(必须设置!!!)
 * meta : {
    roles: ['admin','editor']    控制页面角色(可以设置多个角色)
    title: 'title'               在边栏和面包屑中显示的名称(推荐集)
    icon: 'svg-name'             图标显示在侧栏中
    noCache: true                如果设置为true,则不会缓存该页(默认值为false)
    affix: true                  如果设置为true,则标记将附加在tags视图中
    breadcrumb: false            如果设置为false,则项目将隐藏在breadcrumb中(默认值为true)
    activeMenu: '/example/list'  如果设置路径,侧栏将突出显示您设置的路径
  }
 */

/**
 * constantRoutes
 * 无权限的基础路由,所有角色可访问
 */
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login/login-index.vue'),
    hidden: true
  },
  {
    path: '/',
    component: Layout,
    redirect: '/bind-management'
  },
  {
    path: '/auth-redirect',
    component: () => import('@/views/login/auth-redirect'),
    hidden: true
  },
  {
    path: '/404',
    component: () => import('@/views/error-page/404'),
    hidden: true
  },
  {
    path: '/401',
    component: () => import('@/views/error-page/401'),
    hidden: true
  },
  {
    path: '/notice',
    component: Layout,
    hidden: true,
    children: [{
      path: 'index',
      component: () => import('@/views/noticeManagement/noticeList'),
      name: 'Notice',
      meta: {
        title: '消息通知',
        icon: 'guide',
        noCache: true
      }
    }]
  }

]
/**
 * asyncRoutes
 * 有权限,权限为admin可以访问
 */
export const asyncRoutes = [

  {
    path: '/icon',
    component: Layout,
    children: [{
      path: 'index',
      component: () => import('@/views/icons/index'),
      name: 'Icons',
      meta: {
        title: 'Icons',
        icon: 'icon',
        noCache: true,
        roles: ['noPremission']
      }
    }]
  },
  const strategyManagementRouter = {
  path: '/strategy-management',
  component: Layout,
  redirect: '/strategy-management/index',
  meta: {
    title: '策略管理',
    icon: 'ctrl_icon_strategy',
    roles: ['admin']
  },
  children: [
    {
      path: '/strategy-management/index',
      component: () => import('@/views/strategyManagement/index'),
      name: 'strategyManagement',
      alwaysShow: true,
      meta: {
        title: '策略管理',
        icon: 'ctrl_icon_strategy',
        roles: ['admin']
      }
    }
  ]
},
   {
  path: '/bind-management',
  component: Layout,
  redirect: '/bind-management/index',
  meta: {
    title: '绑定管理',
    icon: 'ctrl_icon_bindings',
    roles: ['admin'],
    noCache: false
  },
  children: [
    {
      path: '/bind-management/index',
      component: () => import('@/views/bindManagement/index'),
      name: 'bindManagement',
      alwaysShow: true,
      meta: {
        title: '绑定管理',
        icon: 'ctrl_icon_bindings',
        roles: ['admin'],
        noCache: false
      }
    },
    {
      path: '/bind-management/detail',
      component: () => import('@/views/bindManagement/bindDetail'),
      name: 'bindDetail',
      hidden: true,
      meta: {
        title: '班级详情',
        icon: 'ctrl_icon_bindings',
        roles: ['admin'],
        activeMenu: '/bind-management/index',
        noCache: false
      }
    }
  ]
},
  // 404 page must be placed at the end !!!
  {
    path: '*',
    redirect: '/404',
    hidden: true
  }
]

const createRouter = () => new Router({
  scrollBehavior: () => ({
    y: 0
  }),
  routes: constantRoutes
})
const router = createRouter()
//重新设置路由
export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher // reset router
}

export default router

/src/permission.js

import router from "./router";
import store from "./store";
import { Message } from "element-ui";
import NProgress from "nprogress"; // progress bar
import "nprogress/nprogress.css"; // progress bar style
import getPageTitle from "@/utils/get-page-title";
NProgress.configure({
  showSpinner: false,
}); // NProgress Configuration

const whiteList = ["/login", "/auth-redirect", "/dashboard"]; // no redirect whitelist
let flag = 0;

router.beforeEach(async (to, from, next) => {
  // 路由加载进度条
  NProgress.start();
  // 设置页面title
  document.title = getPageTitle(to.meta.title);

  // 确定是否登录
  const hasToken = store.getters.getAccessToken;
  if (hasToken) {
    if (to.path === "/login") {
      next({
        path: "/",
      });
      NProgress.done();
    } else {
      try {
        const hasAddRoutes =
          store.getters.addRoutes && store.getters.addRoutes.length > 0;
        if (flag === 0 || !hasAddRoutes) {
          const permissionRoutes = await store.dispatch(
            "user/queryFuncByRoles"
          ); //触发权限函数,查询路由、按钮权限
          const buttonCode = permissionRoutes.buttonCode;
          localStorage.setItem("buttonCode", JSON.stringify(buttonCode)); //保存权限按钮到本地
          const accessRoutes = await store.dispatch(
            "permission/generateRoutes",
            permissionRoutes.sysFuncViewList
          ); //获取动态路由数组
          if (!accessRoutes.length) {
            await store.dispatch("user/resetToken");
            Message.error("该账户无可访问权限");
            NProgress.done();
            next(`/login?redirect=${to.path}`);
            return;
          }
          console.log("accessRoutes", accessRoutes);
          router.addRoutes(accessRoutes);
          flag++;
          next({ ...to, replace: true });
        } else {
          next();
        }
      } catch (error) {
        // 删除token,跳转到登录页
        await store.dispatch("user/resetToken");
        Message.error({
          message: error || "出现错误,请稍后再试",
        });
        next(`/login?redirect=${to.path}`);
        NProgress.done();
      }
    }
  } else {
    // 未登录去whiteList里面的路由可以去,去别的则跳转登录页
    if (whiteList.indexOf(to.path) !== -1) {
      next();
    } else {
      //没权限的重定向到首页
      next(`/login`);
      NProgress.done();
    }
  }
});

router.afterEach(() => {
  NProgress.done();
});

/src/store/mudules/permission.js

/*
 * @Author: your name
 * @Date: 2020-10-27 17:49:08
 * @LastEditTime: 2020-11-18 16:09:40
 * @LastEditors: Please set LastEditors
 * @Description: In User Settings Edit
 * @FilePath: \Git\plate-control-admin\src\store\modules\permission.js
 */
import { asyncRoutes, constantRoutes } from "@/router";

/**
 * 通过meta.roles判断是否与当前用户权限匹配
 * 判断传进来的路由(route)里面的meta.roles是否满足'admin'条件,满足返回true,相反false
 * @param roles 权限数组 ['admin']
 * @param route 路由数组
 */
function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
    return roles.some((role) => route.meta.roles.includes(role));
  } else {
    return true;
  }
}

/**
 * 递归过滤异步路由表,返回符合用户角色权限的路由表
 * @param routes asyncRoutes
 * @param roles
 */
export function filterAsyncRoutes(routes, roles) {
  const res = [];

  routes.forEach((route) => {
    const tmp = { ...route };
    if (hasPermission(roles, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles);
      }
      res.push(tmp);
    }
  });
  console.log("roles", res);

  return res;
}

export function getResultRouters(treeData, arr) {
  treeData.forEach((element) => {
    arr.forEach((ele) => {
      if (element.path == ele.funcUrl) {
        element.meta.roles = ["admin"];
      }
    });
    if (element.children && element.children.length > 0) {
      getResultRouters(element.children, arr);
    }
  });
  return treeData;
}

const state = {
  routes: [],
  addRoutes: [],
};

const mutations = {
  SET_ROUTES: (state, routes) => {
    //保存动态路由时 将静态路由和动态路由合并
    state.addRoutes = routes;
    state.routes = constantRoutes.concat(routes);
  },
};

const actions = {
  generateRoutes({ commit }, roles) {
    return new Promise((resolve) => {
      let resetRouters, accessedRoutes;
      resetRouters = getResultRouters(asyncRoutes, roles);
      if (!resetRouters.length) {
        resolve(resetRouters);
        return;
      }
      accessedRoutes = filterAsyncRoutes(resetRouters, ["admin"]);
      if (!accessedRoutes.length) {
        commit("SET_ROUTES", []);
      } else {
        commit("SET_ROUTES", accessedRoutes);
      }
      resolve(accessedRoutes);
    });
  },
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
};

可参考链接

2. 按钮级别权限控制

2.1 思路:

  • 页面展示需要鉴权的所有按钮,需要先鉴权菜单权限的显示与隐藏。

  • 勾选每个角色或者用户所能看的权限保存在数据库。该权限数据是一个权限字段的数组。

  • 全局自定义指令(directive)控制按钮权限数据的方法,登入时获取后端传来的按钮权限数组。

  • 在每个按钮中调用该指令,并传入该操作的权限字段和后端保存的权限字段进行匹配,能匹配则该操作按钮可显示

我们公司这一块是不用根据菜单权限,来判断按钮权限,只需要根据后端返回的权限字段的数组判断就好了,然后这一块我们公司也是做的指令封装,代码如下

2.2 使用方法

  • /src/directive/新建/btnPermission/btnPermission.js

    /src/directive/btnPermission/btnPermission.js

export const hasPermission = {
  install(Vue) {
    Vue.directive("hasPermission", {
      bind(el, binding, vnode) {
        const permissionsNameList = JSON.parse(
          localStorage.getItem("buttonCode")
        ); //按钮数组列表
        const permissions = Object.keys(permissionsNameList); //返回一个由一个给定对象的自身可枚举属性组成的数组,对象的key
        console.log(permissions, "permissions");
        const value = binding.value;
        let flag = true;
        for (const v of value) {
          //遍历传进来的数组
          if (!permissions.includes(v)) {
            //判断后端给的数组,是否包含传进来的这个字段,包含则显示,不包含则隐藏
            flag = false;
          }
        }
        if (!flag) {
          if (!el.parentNode) {
            el.style.display = "none";
          } else {
            el.parentNode.removeChild(el);
          }
        }
      },
    });
  },
};
  • /src/main.js引入
// 引入权限按钮文件
import { hasPermission } from "../src/directive/btnPermission/btnPermission.js"; // 按钮权限指令

Vue.use(hasPermission); // 按钮权限指令
  • 使用方法
<el-button
  class="inquireButton"
  v-hasPermission="['platectrl_b_policy_search']"
  @click="inquire"
  >查询</el-button
>
<el-button
  class="addNewButton"
  v-hasPermission="['platectrl_b_policy_add']"
  @click="addNew"
  >新增</el-button
>

可参考链接

七、媒体查询移动、PC 兼容

虽然 element 框架有一些自适应的处理,但是还是有一些需要调整,所以我就自己写了一套媒体查询,哪里需要做一些样式处理,只需要在对应的屏幕宽度下面修改就好,这里我的 rem 计算方法是px/10/2或者直接根据媒体查询调整;

1. 用法:

  • 先在/src/styles/里面新建media.scss
  • 引入到/src/index.scss/里面

直接在index.scss引入就好

@import './variables.scss';
@import './mixin.scss';
@import './transition.scss';
@import './element-ui.scss';
@import './sidebar.scss';
@import './btn.scss';

@import "./media.scss"; /*媒体查询的css*/
  • 代码如下
/* -----------mobile----------- */
@media screen and (max-width: 480px) {
  /* 登录自适应 */
  .login-right {
    min-width: 20rem;
    overflow: auto;
  }
  .bg-container {
    width: 100%;
    justify-content: center;
  }
  .login-left {
    display: none;
  }
  .login-left-title_phone {
    margin-bottom: 1rem;
    font-size: 1.4rem;
    display: block;
  }
  /* 登录自适应 end*/

  /* 弹窗自适应 dialog */
  .Dialog-box {
    max-height: 60%;

    .el-dialog {
      width: 80% !important;
    }
    .el-form-item--medium .el-form-item__label {
      width: 106px !important;
    }
    .el-form-item--medium .el-form-item__content {
      margin-left: 72px !important;
    }
  }
  // 按钮位置
  .el-form-item-btns {
    float: left !important;
  }
  .inquireButton-father {
    // float: none !important;
    // width: 22% !important;
  }
}

/* -----------ipad small----------- */
@media screen and (min-device-width: 481px) and (max-device-width: 768px) {
  /* 登录自适应 */
  .login-right {
    min-width: 20rem;
    overflow: auto;
  }
  .bg-container {
    width: 100%;
    justify-content: center;
  }
  .login-left {
    display: none;
  }
  .login-left-title_phone {
    margin-bottom: 1rem;
    font-size: 1.4rem;
    display: block;
  }
  /* 弹窗自适应 dialog */
  .Dialog-box {
    max-height: 60%;

    .el-dialog {
      width: 80% !important;
    }
    .el-form-item--medium .el-form-item__label {
      width: 106px !important;
    }
    .el-form-item--medium .el-form-item__content {
      margin-left: 72px !important;
    }
  }
  // 按钮位置
  .el-form-item-btns {
    float: left !important;
  }
  .inquireButton-father {
    // float: none !important;
    width: 22% !important;
  }
}

/* ----------- iPad  big----------- */

@media screen and (min-device-width: 768px) and (max-device-width: 1024px) {
  /* 登录自适应 */
  .user-input {
    background-color: #fff;
    padding: 0 2%;
    margin: 0;
    height: 9rem;
  }
  /* 弹窗自适应 dialog */
  .Dialog-box {
    max-height: 60%;

    .el-dialog {
      width: 80% !important;
    }
    .el-form-item--medium .el-form-item__label {
      width: 106px !important;
    }
    .el-form-item--medium .el-form-item__content {
      margin-left: 72px !important;
    }
  }
  // 按钮位置
  .el-form-item-btns {
    float: left !important;
  }
  .inquireButton-father {
    // float: none !important;
    width: 22% !important;
  }
}
/* ----------- iPad Pro 屏幕小的笔记本----------- */
/* Portrait and Landscape */
@media only screen and (min-device-width: 1025px) and (max-device-width: 1366px) and (-webkit-min-device-pixel-ratio: 1.5) {
  /* 登录自适应 */
  .login-right {
    min-width: 27rem;
    overflow: auto;
  }
}

/* Portrait */
@media only screen and (min-device-width: 1024px) and (max-device-width: 1366px) and (orientation: portrait) and (-webkit-min-device-pixel-ratio: 1.5) {
  /* 登录自适应 */
  .login-right {
    min-width: 27rem;
    overflow: auto;
  }
}

/* Landscape */
@media only screen and (min-device-width: 1024px) and (max-device-width: 1366px) and (orientation: landscape) and (-webkit-min-device-pixel-ratio: 1.5) {
  /* 登录自适应 */
  .login-right {
    min-width: 27rem;
    overflow: auto;
  }
}

vue 中 rem 用法及 scss

个人建议:

如果拿来开发的话,建议选用vue-admin-template这套框架,这是vue-admin-element的极简版本,它只包含了 Element UI & axios & iconfont & permission control & lint,这些搭建后台必要的东西;如果想用vue-admin-element的东西,也是可以直接拿过来就用的,就不会有那么多的代码沉余;

vue-admin-template官方文档链接

总结:

框架功能比较丰富,社区完整,是个值得入手学习的框架;现在还是在初期使用阶段,一些细节上的技术点,会在使用中持续更新;

可参考链接:https://www.jianshu.com/p/d3e3b21696e4

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,383评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,522评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,852评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,621评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,741评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,929评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,076评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,803评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,265评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,582评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,716评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,395评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,039评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,027评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,488评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,612评论 2 350