一篇文帶你使用vue完成一個完整後臺

介紹

vue-element-admin 是一個後臺前端解決方案,它基於 vue 和 element-ui實現。它使用瞭最新的前端技術棧,內置瞭 i18 國際化解決方案,動態路由,權限驗證,提煉瞭典型的業務模型,提供瞭豐富的功能組件,它可以幫助你快速搭建企業級中後臺產品原型。相信不管你的需求是什麼,本項目都能幫助到你

  • vue-element-admin定位是後臺集成方案,不適合當基礎模板進行二次開發,項目集成瞭許多用不到的功能,會造成代碼沉餘
  • vue-admin-template是一個後臺基礎模板,建議使用此模板進行二次開發
  • electron-vue-admin是一個桌面終端,如果進行桌面終端開發可以使用此模板

功能

- 登錄 / 註銷- 權限驗證  - 頁面權限  - 指令權限  - 權限配置  - 二步登錄- 多環境發佈  - dev sit stage prod- 全局功能  - 國際化多語言  - 多種動態換膚  - 動態側邊欄(支持多級路由嵌套)  - 動態面包屑  - 快捷導航(標簽頁)  - Svg Sprite 圖標  - 本地/後端 mock 數據  - Screenfull全屏  - 自適應收縮側邊欄- 編輯器  - 富文本  - Markdown  - JSON 等多格式- Excel  - 導出excel  - 導入excel  - 前端可視化excel  - 導出zip- 表格  - 動態表格  - 拖拽表格  - 內聯編輯- 錯誤頁面  - 401  - 404- 組件  - 頭像上傳  - 返回頂部  - 拖拽Dialog  - 拖拽Select  - 拖拽看板  - 列表拖拽  - SplitPane  - Dropzone  - Sticky  - CountTo- 綜合實例- 錯誤日志- Dashboard- 引導頁- ECharts 圖表- Clipboard(剪貼復制)- Markdown2html

目錄結構

├── 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# 速度過慢可以使用下面方法進行指定下載鏡像原# 也可以使用nrm選擇下載鏡像原# 建議不要用 cnpm 安裝 會有各種詭異的bug 可以通過如下操作解決 npm 下載速度慢的問題npm install --registry=https://registry.npm.taobao.org# 註意:此框架啟動和平常我們自己設置不同,要使用如下方法進行啟動# 本地開發 啟動項目npm run dev

啟動完成後會自動打開瀏覽器訪問 http://localhost:9527,可以看到頁面就證明你操作成功瞭

layout佈局

在大部分頁面中都是基於layout的,除:404、login等沒有使用到該佈局

layout整合瞭頁面所有佈局進行分塊展示

整個板塊被分成瞭三部分

layout主要編排

Src目錄下 入口文件 main.js


裡面有用到自定義的mock文件訪問,我們要將其註釋掉
src下的mock文件夾建議刪掉,我們後期不會用到

App.vue

src下,除瞭main.js還有兩個文件,permission.jssettings.js

permission.js

permission.js 是控制頁面登錄權限的文件,我們可以先將其全部註釋掉,後期用到在慢慢添加

settings.js

settings.js則是對於一些項目信息的配置,裡面有三個屬性 **title(項目名稱),fixedHeader(固定頭部),sidebarLogo(顯示左側菜單logo)
其中的配置我們在其他地方會用到,不要去動

API模塊和請求封裝模塊介紹

API模塊的單獨請求和 request模塊的封裝

Axios的攔截器

axios的攔截器原理:

通過create創建一個新的axios實例

// 創建瞭一個新的axios實例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: 5000 // request timeout})

請求攔截器
主要處理 token的_統一註入問題_

service.interceptors.request.use(  config => {    if (store.getters.token) {      config.headers['X-Token'] = getToken()    }    return config  },  error => {    return Promise.reject(error)  })

響應攔截器
處理 返回的數據異常 和_數據結構_問題

// 響應攔截器service.interceptors.response.use(  response => {    const res = response.data    // if the custom code is not 20000, it is judged as an error.    // 自定義代碼返回值是自己商量的,按照自己的需求來編寫    if (res.code !== 20000) {      Message({        message: res.message || 'Error',        type: 'error',        duration: 5 * 1000      })          // 自定義代碼返回值是自己商量的,按照自己的需求來編寫      if (res.code === 50008 || res.code === 50012 || res.code === 50014) {        // to re-login        MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', {          confirmButtonText: 'Re-Login',          cancelButtonText: 'Cancel',          type: 'warning'        }).then(() => {          store.dispatch('user/resetToken').then(() => {            location.reload()          })        })      }      return Promise.reject(new Error(res.message || 'Error'))    } else {      return res    }  },  error => {    console.log('err' + error) // for debug    Message({      message: error.message,      type: 'error',      duration: 5 * 1000    })    return Promise.reject(error)  })

上面是在 src/utils/request.js下的源代碼
我們隻需要保留:

// 導出一個axios的實例  而且這個實例要有請求攔截器 響應攔截器import axios from 'axios'const service = axios.create() // 創建一個axios的實例service.interceptors.request.use() // 請求攔截器service.interceptors.response.use() // 響應攔截器export default service // 導出axios實例

單獨封裝api

我們習慣將所有的api請求放到api目錄下統一管理,按照模塊進行劃分使用

api/user.js

import request from '@/utils/request'export function login(data) {  return request({    url: '/vue-admin-template/user/login',    method: 'post',    data  })}export function getInfo(token) {  return request({    url: '/vue-admin-template/user/info',    method: 'get',    params: { token }  })}export function logout() {  return request({    url: '/vue-admin-template/user/logout',    method: 'post'  })}

我們隻需保留如下代碼,後期在進行添加

import request from '@/utils/request'export function login(data) {}export function getInfo(token) {}export function logout() {}

登錄模塊 設置固定的本地訪問端口和網站名稱

設置統一的本地訪問端口和網站title

本地服務端口: 在vue.config.js中進行設置

vue.config.js 就是vue項目相關的編譯,配置,打包,啟動服務相關的配置文件,它的核心在於webpack,但是又不同於webpack,相當於改良版的webpack

我們看到上面是一個環境變量而不是實際地址,那麼我們在哪設置瞭呢

在項目下我們會發現兩個文件

development => 開發環境

production => 生產環境

當我們運行npm run dev進行開發調試的時候,此時會加載執行**.env.development**文件內容

當我們運行npm run build:prod進行生產環境打包的時候,會加載執行**.env.production**文件內容

如果想要設置開發環境的接口,直接在**.env.development**文件中寫入對於變量直接賦值即可

# just a flagENV = 'development'# base apiVUE_APP_BASE_API = 'api/private/v1/'

如果想要設置生產環境的接口**.env.production**文件中寫入對於變量直接賦值即可

# just a flagENV = 'production'# base apiVUE_APP_BASE_API = 'api/private/v1/'

網站名稱

src/settings.js
title 就是網站名稱

配置完我們要進行重啟,否則有些配置不會生效

登錄頁面


設置頭部名稱:

        海豚電商後臺管理平臺 

設置背景圖片:
可根據需求更改

/* reset element-ui css */.login-container {  background-image: url('~@/assets/common/bgc.jpg'); // 設置背景圖片   background-position: center; // 將圖片位置設置為充滿整個屏幕}

對應代碼:

登錄表單的校驗

el-form表單校驗的條件

用戶名和密碼的校驗:

                                                                                                                const validateUsername = (rule, value, callback) => {      if (value.length  12) {        callback(new Error('用戶名最長12位'))      } else {        callback()      }    }    const validatePassword = (rule, value, callback) => {      if (value.length  16) {        callback(new Error('用戶名最長16位'))      } else {        callback()      }    }loginRules: {        username: [{ required: true, trigger: 'blur', validator: validateUsername }],        password: [          { required: true, trigger: 'blur', validator: validatePassword },          { min: 5, max: 12, trigger: 'blur', message: '密碼長度應該在5-12位之間' }        ]      }

Vue-Cli配置跨域代理

出現跨域的原因是什麼呢?
因為當下流行的是前後端分離單獨開發,前端項目和後端接口不在同域名之下,那前端訪問後端接口就出現跨域瞭
那麼問題就來瞭 如何解決呢?
我們所遇到的這種跨域是位於開發環境的,真正部署上線時的跨域是生產環境的,解決方式又不同
我們先解決開發環境,生產環境在打包上線事可以解決,後面再講

解決開發環境的跨域問題

開發環境的跨域,也就是在vue-cli腳手架環境下開發啟動服務時,我們訪問接口所遇到的跨域問題,vue-cli為我們在本地開啟瞭一個服務,可以通過這個服務幫我們代理請求,解決跨域問題
也就是vue-cli配置webpack的反向代理

vue.config.js中進行反向代理配置

module.exports = {  devServer: {   proxy: {      'api/private/v1/': {        target: 'http://127.0.0.1:8888', // 我們要代理的地址,當匹配到上面的'api/private/v1/'時,會將http://localhost:9528 替換成 http://127.0.0.1:8888        changeOrigin: true, // 是否跨越 需要設置此值為 true 才可以讓本地服務代理我們發送請求        pathRewrite: {        // 重新路由  localhost:8888/api/login  => http://127.0.0.1:8888/api/login          '^/api': '/api',          '/hr': ''        }      }    }  }}

同時,還需要註意的是,我們同時需要註釋掉 mock的加載,因為mock-server會導致代理服務的異常

// before: require('./mock/mock-server.js'),  // 註釋mock-server加載

封裝單獨的登錄接口

export function login(data) {  // 返回一個axios對象 => promise  // 返回瞭一個promise對象  return request({    url: 'login', // 因為所有的接口都要跨域 表示所有的接口要帶 /api    method: 'post',    data  })}

封裝Vuex的登錄Action並處理token 在Vuex中對token進行管理


上圖中,組件直接和接口打交道,這並沒有什麼問題,但是ta用的鑰匙來進行相互傳遞,我們需要讓vuex來介入,將用戶的token狀態共享,更方便的讀取

store/modules/user.js配置

// 狀態const state = {}// 修改狀態const mutations = {}// 執行異步const actions = {}export default {  namespaced: true,  state,  mutations,  actions}

設置token共享狀態

const state = {  token: null}

操作 token

utils/auth.js 中,基礎模板已經為我們提供瞭獲取 token ,設置 token ,刪除 token 的方法,可以直接使用

const TokenKey = 'haitun_token'export function getToken() {  // return Cookies.get(TokenKey)  return localStorage.getItem(TokenKey)}export function setToken(token) {  // return Cookies.set(TokenKey, token)  return localStorage.setItem(TokenKey, token)}export function removeToken() {  // return Cookies.remove(TokenKey)  return localStorage.removeItem(TokenKey)}

初始化token狀態

store/modules/user.js

import { getToken, setToken, removeToken } from '@/utils/auth'const state = {  token: getToken() // 設置token初始狀態   token持久化 => 放到緩存中}

提供修改token的mutations

// 修改狀態const mutations = {  // 設置token  setToken(state, token) {    state.token = token // 設置token  隻是修改state的數據  123 =》 1234    setToken(token) // vuex和 緩存數據的同步  },  // 刪除緩存  removeToken(state) {    state.token = null // 刪除vuex的token    removeToken() // 先清除 vuex  再清除緩存 vuex和 緩存數據的同步  }}

封裝登錄的Action

登錄action要做的事情,調用登錄接口,成功後設置token到vuex,失敗則返回失敗

// 執行異步const actions = {  // 定義login action  也需要參數 調用action時 傳遞過來的參數  async login(context, data) {    const result = await login(data)  // 實際上是一個promise  result是執行的結果    // axios默認給數據加瞭一層data    if (result.data.success) {      // 表示登錄接口調用成功 也就是意味著你的用戶名和密碼是正確的      // 現在有用戶token      // actions 修改state 必須通過mutations      context.commit('setToken', result.data.data)    }  }}

為瞭更好的讓其他模塊和組件更好的獲取token數據,我們要在store/getters.js中將token值作為公共的訪問屬性放出

const getters = {  sidebar: state => state.app.sidebar,  device: state => state.app.device,  token: state => state.user.token // 在根級的getters上 開發子模塊的屬性給別人看 給別人用}export default getters

通過此內容,我們可以有個腦圖畫面瞭

區分axios在不同環境中的請求基礎地址


前端兩個主要區分環境,開發環境,生產環境

環境變量 $ process.env.NODE_ENV # 當為production時為生產環境 為development時為開發環境
我們可以在**.env.development和.env.production**定義變量,變量自動就為當前環境的值
基礎模板在以上文件定義瞭變量VUE_APP_BASE_API,該變量可以作為axios請求的baseURL

# 開發環境的基礎地址和代理對應VUE_APP_BASE_API = '/api'---------# 這裡配置瞭/api,意味著需要在Nginx服務器上為該服務配置 nginx的反向代理對應/prod-api的地址 VUE_APP_BASE_API = '/prod-api'  

也可以都寫成一樣的 方便管理

在request中設置baseUrl–基準

// 創建一個axios的實例const service = axios.create({  baseURL: process.env.VUE_APP_BASE_API, // 設置axios請求的基礎的基礎地址  timeout: 5000 // 定義5秒超時}) 

處理axios的響應攔截器

// 響應攔截器service.interceptors.response.use(response => {  // axios默認加瞭一層data  const { success, message, data } = response.data  //   要根據success的成功與否決定下面的操作  if (success) {    return data  } else {    // 業務已經錯誤瞭 還能進then ? 不能 ! 應該進catch    Message.error(message) // 提示錯誤消息    return Promise.reject(new Error(message))  }}, error => {  Message.error(error.message) // 提示錯誤信息  return Promise.reject(error) // 返回執行錯誤 讓當前的執行鏈跳出成功 直接進入 catch})

登錄頁面調用登錄action,處理異常


引入輔助函數

import { mapActions } from 'vuex'  // 引入vuex的輔助函數---------------------methods: {    ...mapActions(['user/login'])}

調用登錄

  this.$refs.loginForm.validate(async isOK => {        if (isOK) {          try {            this.loading = true            // 隻有校驗通過瞭 我們才去調用action            await this['user/login'](this.loginForm)            // 應該登錄成功之後             // 登陸成功後跳轉到主頁            this.$router.push('/')          } catch (error) {            console.log(error)          } finally {            //  不論執行try 還是catch  都去關閉轉圈            this.loading = false          }        }      })

解析

首先使用到瞭elementUI的from表單進行編寫

中間在前臺使用表單驗證進行對用戶輸入的賬戶密碼進行對比,是否符合標準,如果不符合我們定義的標準進行一個提示

我們對表單裡面的輸入框進行雙向數據綁定使用v-model

用戶輸入完畢之後點擊登錄按鈕時也要進行後臺驗證,當我們點擊登錄發送請求到後臺入庫查詢賬戶密碼是否正確,如不正確會彈出提示

在表單裡面使用瞭標簽引入 icon 圖標

我們首先在srccomponents下創建瞭SvgIcon組件

我們向外暴露瞭兩個屬性

通過 computed 監控 icon 的名字和其自定義的樣式,當沒有指定自定義樣式時候,會采用默認樣式,否則會再加上自定義 class

 iconName() {      return `#icon-${this.iconClass}`    },    svgClass() {      if (this.className) {        return 'svg-icon ' + this.className      } else {        return 'svg-icon'      }    }

然後進行默認樣式的編寫

srcicons 中的 index.js 中引入 svg 組件 import IconSvg from '@/components/IconSvg'

使用全局註冊 icon-svg Vue.component('icon-svg', IconSvg)

這樣就可以在項目中任意地方使用

為瞭便於集中管理圖標,所有圖標均放在 @/icons/svg

@代表找到src目錄

require.context 有三個參數:

  • 參數一:說明需要檢索的目錄
  • 參數二:是否檢索子目錄
  • 參數三: 匹配文件的正則表達式

@/main.js中引入import '@/icons'這樣在任意頁面就可以成功使用組件瞭

在頁面中使用就可以進行使用瞭

完整代碼

                    海豚電商後臺管理平臺                                                                                                                                  立即登錄      <!--         username: admin         password: any       -->      import { validUsername } from '@/utils/validate'export default {  name: 'Login',  data () {    const validateUsername = (rule, value, callback) => {      if (value.length  12) {        callback(new Error('用戶名最長12位'))      } else {        callback()      }    }    const validatePassword = (rule, value, callback) => {      if (value.length  16) {        callback(new Error('用戶名最長16位'))      } else {        callback()      }    }    return {      loginForm: {        username: 'admin',        password: '123456'      },      loginRules: {        username: [{ required: true, trigger: 'blur', validator: validateUsername }],        password: [          { required: true, trigger: 'blur', validator: validatePassword },          { min: 5, max: 12, trigger: 'blur', message: '密碼長度應該在5-12位之間' }        ]      },      loading: false,      passwordType: 'password',      redirect: undefined    }  },  watch: {    $route: {      handler: function (route) {        this.redirect = route.query && route.query.redirect      },      immediate: true    }  },  methods: {    showPwd () {      if (this.passwordType === 'password') {        this.passwordType = ''      } else {        this.passwordType = 'password'      }      this.$nextTick(() => {        this.$refs.password.focus()      })    },    async handleLogin () {      try {        await this.$refs.loginForm.validate()        this.loading = true        await this.$store.dispatch('user/login', this.loginForm)        // console.log('ssss')        // 登陸成功後跳轉到主頁        this.$router.push({ path: '/' })        this.loading = false      } catch (err) {        this.loading = false        console.log(err)        return false      }    }  }}/* 修復input 背景不協調 和光標變色 *//* Detail see https://github.com/PanJiaChen/vue-element-admin/pull/927 */$bg: #283443;$light_gray: #fff;$cursor: #fff;@supports (-webkit-mask: none) and (not (cater-color: $cursor)) {  .login-container .el-input input {    color: $cursor;  }}/* reset element-ui css */.login-container {  .el-input {    display: inline-block;    height: 47px;    width: 85%;    input {      background: transparent;      border: 0px;      -webkit-appearance: none;      border-radius: 0px;      padding: 12px 5px 12px 15px;      color: $light_gray;      height: 47px;      caret-color: $cursor;      &:-webkit-autofill {        box-shadow: 0 0 0px 1000px $bg inset !important;        -webkit-text-fill-color: $cursor !important;      }    }  }  .el-form-item {    border: 1px solid rgba(255, 255, 255, 0.1);    background: rgba(0, 0, 0, 0.1);    border-radius: 5px;    color: #454545;  }}$bg: #2d3a4b;$dark_gray: #889aa4;$light_gray: #eee;.login-container {  min-height: 100%;  width: 100%;  background-color: $bg;  overflow: hidden;  .login-form {    position: relative;    width: 520px;    max-width: 100%;    padding: 160px 35px 0;    margin: 0 auto;    overflow: hidden;  }  .tips {    font-size: 14px;    color: #fff;    margin-bottom: 10px;    span {      &:first-of-type {        margin-right: 16px;      }    }  }  .svg-container {    padding: 6px 5px 6px 15px;    color: $dark_gray;    vertical-align: middle;    width: 30px;    display: inline-block;  }  .title-container {    position: relative;    .title {      font-size: 26px;      color: $light_gray;      margin: 0px auto 40px auto;      text-align: center;      font-weight: bold;    }  }  .show-pwd {    position: absolute;    right: 10px;    top: 7px;    font-size: 16px;    color: $dark_gray;    cursor: pointer;    user-select: none;  }}

主頁模塊

主頁token攔截並進行處理 權限攔截的流程圖

我們已經完成瞭登錄的過程,並且存儲瞭token,但是此時主頁並沒有因為token的有無而被控制訪問權限

攔截處理代碼

src/permission.js

import Vue from 'vue'import 'normalize.css/normalize.css' // A modern alternative to CSS resetsimport ElementUI from 'element-ui'import 'element-ui/lib/theme-chalk/index.css'import locale from 'element-ui/lib/locale/lang/zh-CN' // lang i18nimport '@/styles/index.scss' // global cssimport App from './App'import store from './store'import router from './router'import i18n from '@/lang/index'import '@/icons' // iconimport '@/permission' // permission controlimport directives from './directives'import Commponent from '@/components'import filters from './filter'import Print from 'vue-print-nb' // 引入打印// set ElementUI lang to ENVue.use(ElementUI, { locale })// 如果想要中文版 element-ui,按如下方式聲明// Vue.use(ElementUI)Vue.use(Print)Vue.config.productionTip = false// 遍歷註冊自定義指令for (const key in directives) {  Vue.directive(key, directives[key])}Vue.use(Commponent) // 註冊自己的插件// 註冊全局的過濾器// 遍歷註冊過濾器for (const key in filters) {  Vue.filter(key, filters[key])}// 設置element為當前的語言Vue.use(ElementUI, {  i18n: (key, value) => i18n.t(key)})new Vue({  el: '#app',  router,  store,  i18n,  render: h => h(App)})

左側導航

樣式文件styles/siderbar.scss
設置背景圖片

.scrollbar-wrapper {     background: url('~@/assets/common/leftnavBg.png') no-repeat 0 100%;}

左側logo圖片src/setttings.js

module.exports = {  title: '海豚電商後臺管理平臺',  /**   * @type {boolean} true | false   * @description Whether fix the header   */  fixedHeader: false,  /**   * @type {boolean} true | false   * @description Whether show the logo in sidebar   */  sidebarLogo: true   // 顯示logo}

設置頭部圖片結構 src/layout/components/Sidebar/Logo.vue

                          {{ title }}                            {{ title }}            

完整代碼

                            {{ title }}                            {{ title }}            export default {  name: 'SidebarLogo',  props: {    collapse: {      type: Boolean,      required: true    }  },  data () {    return {      title: '海豚電商後臺管理平臺',      logo: 'https://blog.csdn.net/qq_46416934/article/details/@/assets/common/hai.png'    }  }}.sidebarLogoFade-enter-active {  transition: opacity 1.5s;}.sidebarLogoFade-enter,.sidebarLogoFade-leave-to {  opacity: 0;}.sidebar-logo-container {  position: relative;  width: 100%;  height: 50px;  line-height: 50px;  background: #2b2f3a;  text-align: center;  overflow: hidden;  & .sidebar-logo-link {    height: 100%;    width: 100%;    & .sidebar-logo {      width: 32px;      height: 32px;      vertical-align: middle;      margin-right: 12px;    }    & .sidebar-title {      display: inline-block;      margin: 0;      color: #fff;      font-weight: 600;      line-height: 50px;      font-size: 14px;      font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;      vertical-align: middle;    }  }  &.collapse {    .sidebar-logo {      margin-right: 0px;    }  }}

頭部內容的佈局和樣式

頭部組件位置layout/components/Navbar.vue

添加公司名稱時要面包屑

<!--  -->             北京夢囈網絡有限公司      v1.0.0    

右側頭像和下拉菜單等設置

                                                            {{ username }}                                                 主頁                                 郵箱                                設置                                退出                            

完整代碼:樣式+事件

          <!--  -->          北京夢囈網絡有限公司      v1.0.0                                                                    {{ username }}                                                 主頁                                 郵箱                                設置                                退出                              import { mapGetters } from 'vuex'import Breadcrumb from '@/components/Breadcrumb'import Hamburger from '@/components/Hamburger'export default {  components: {    Breadcrumb,    Hamburger  },  data () {    return {      username: '超級管理員',      defaultImg: require('https://blog.csdn.net/qq_46416934/article/details/@/assets/common/bigUserHeader.png')    }  },  created () {    this.usereee()  },  computed: {    ...mapGetters([      'sidebar',      'avatar'    ])  },  methods: {    usereee () {      const res = localStorage.getItem('haitunuser')      // const res = sessionStorage.getItem('user_info')      const username = JSON.parse(res).username      this.username = username    },    toggleSideBar () {      this.$store.dispatch('app/toggleSideBar')    },    async logout () {      await this.$store.dispatch('user/logout')      this.$router.push(`/login`)    }  }}.navbar {  height: 50px;  overflow: hidden;  position: relative;  background-image: linear-gradient(left, #3d6df8, #5b8cff);  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);  .app-breadcrumb {    display: inline-block;    font-size: 18px;    line-height: 50px;    margin-left: 15px;    color: #fff;    cursor: text;    .breadBtn {      background: #84a9fe;      font-size: 14px;      padding: 0 10px;      display: inline-block;      height: 30px;      line-height: 30px;      border-radius: 10px;      margin-left: 15px;    }  }  .hamburger-container {    line-height: 46px;    height: 100%;    float: left;    cursor: pointer;    transition: background 0.3s;    -webkit-tap-highlight-color: transparent;    &:hover {      background: rgba(0, 0, 0, 0.025);    }  }  .breadcrumb-container {    float: left;  }  .right-menu {    float: right;    height: 100%;    line-height: 50px;    &:focus {      outline: none;    }    .right-menu-item {      display: inline-block;      vertical-align: middle;      padding: 0 8px;      height: 100%;      font-size: 18px;      color: #5a5e66;      vertical-align: text-bottom;      &.hover-effect {        cursor: pointer;        transition: background 0.3s;        &:hover {          background: rgba(0, 0, 0, 0.025);        }      }    }    .avatar-container {      margin-right: 30px;      .avatar-wrapper {        display: flex;        margin-top: 5px;        position: relative;        .user-avatar {          cursor: pointer;          width: 40px;          height: 40px;          border-radius: 10px;          vertical-align: middle;          margin-bottom: 10px;        }        .name {          color: #fff;          vertical-align: middle;          margin-left: 5px;        }        .user-dropdown {          color: #fff;        }        .el-icon-caret-bottom {          cursor: pointer;          position: absolute;          right: -20px;          top: 25px;          font-size: 12px;        }      }    }  }}.lang_item {  // background-color: aqua;}

儲存用戶信息

新增變量:src/store/modules/user.js

const getDefaultState = () => {  return {    token: getToken(),    userInfo: {}, // 儲存用戶信息  }}

設置和刪除用戶資料 mutations

// 設置用戶信息 set_userInfo (state, user) {    state.userInfo = user    setUSERINFO(user)  }    // 刪除用戶信息 removeUserInfo (state) {    this.userInfo = {}  }

建立用戶名的映射 src/store/getters.js

const getters = {    token: state => state.user.token,  username: state => state.user.userInfo.username}export default getters

最後我們換成真實名稱即可

        {{ username }}    

這裡可能會出現問題,在頁面刷新拿不到數據,我們可以將其保存到本地中,然後取出

實現退出功能


退出:src/store/modules/user.js

 // user logout  logout (context) {    // 刪除token    context.commit('removeToken') // 不僅僅刪除瞭vuex中的 還刪除瞭緩存中的    // 刪除用戶資料    context.commit('removeUserInfo') // 刪除用戶信息  },

mutation

 removeToken (state) {    state.token = null    removeToken()    removeUSERINFO()    removeLocalMenus()  },  removeUserInfo (state) {    this.userInfo = {}  },

頭部菜單調用 src/layout/components/Navbar.vue

  async logout () {      await this.$store.dispatch('user/logout')      this.$router.push(`/login`)    }

完整代碼:

          <!--  -->          北京夢囈網絡有限公司      v1.0.0                                                                    {{ username }}                                                 主頁                                 郵箱                                設置                                退出                              import { mapGetters } from 'vuex'import Breadcrumb from '@/components/Breadcrumb'import Hamburger from '@/components/Hamburger'export default {  components: {    Breadcrumb,    Hamburger  },  data () {    return {      username: '超級管理員',      defaultImg: require('https://blog.csdn.net/qq_46416934/article/details/@/assets/common/bigUserHeader.png')    }  },  created () {    this.usereee()  },  computed: {    ...mapGetters([      'sidebar',      'avatar'    ])  },  methods: {    usereee () {      const res = localStorage.getItem('haitunuser')      // const res = sessionStorage.getItem('user_info')      const username = JSON.parse(res).username      this.username = username    },    toggleSideBar () {      this.$store.dispatch('app/toggleSideBar')    },    async logout () {      await this.$store.dispatch('user/logout')      this.$router.push(`/login`)    }  }}.navbar {  height: 50px;  overflow: hidden;  position: relative;  background-image: linear-gradient(left, #3d6df8, #5b8cff);  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);  .app-breadcrumb {    display: inline-block;    font-size: 18px;    line-height: 50px;    margin-left: 15px;    color: #fff;    cursor: text;    .breadBtn {      background: #84a9fe;      font-size: 14px;      padding: 0 10px;      display: inline-block;      height: 30px;      line-height: 30px;      border-radius: 10px;      margin-left: 15px;    }  }  .hamburger-container {    line-height: 46px;    height: 100%;    float: left;    cursor: pointer;    transition: background 0.3s;    -webkit-tap-highlight-color: transparent;    &:hover {      background: rgba(0, 0, 0, 0.025);    }  }  .breadcrumb-container {    float: left;  }  .right-menu {    float: right;    height: 100%;    line-height: 50px;    &:focus {      outline: none;    }    .right-menu-item {      display: inline-block;      vertical-align: middle;      padding: 0 8px;      height: 100%;      font-size: 18px;      color: #5a5e66;      vertical-align: text-bottom;      &.hover-effect {        cursor: pointer;        transition: background 0.3s;        &:hover {          background: rgba(0, 0, 0, 0.025);        }      }    }    .avatar-container {      margin-right: 30px;      .avatar-wrapper {        display: flex;        margin-top: 5px;        position: relative;        .user-avatar {          cursor: pointer;          width: 40px;          height: 40px;          border-radius: 10px;          vertical-align: middle;          margin-bottom: 10px;        }        .name {          color: #fff;          vertical-align: middle;          margin-left: 5px;        }        .user-dropdown {          color: #fff;        }        .el-icon-caret-bottom {          cursor: pointer;          position: absolute;          right: -20px;          top: 25px;          font-size: 12px;        }      }    }  }}.lang_item {  // background-color: aqua;}

token失效介入


src/utils/auth.js

const timeKey = 'haitun-setTimeStamp' // 設置一個獨一無二的key// 存儲 token 的時間戳(存的是 setToken 方法執行的時間)// 獲取時間戳export function setTimeStamp () {  return localStorage.setItem(timeKey, Date.now())}// 獲取 token 的過期時間export function getTimeStamp () {  return localStorage.getItem(timeKey)}

src/utils/request.js

import axios from 'axios'import { Message } from 'element-ui'import store from '@/store'import router from '../router'import { getToken, getTimeStamp, removeToken } from '@/utils/auth'// 定義 token 超時時間const timeOut = 3600 * 24 * 3// create an axios instanceconst service = axios.create({  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url  timeout: 5000 // request timeout})// request interceptorservice.interceptors.request.use(  // 註入token  config => {    // do something before request is sent    if (store.getters.token) {      // 判斷當前 token 的時間戳是否過期      // 獲取 token 設置的時間      const tokenTime = getTimeStamp()      // 獲取當前時間      const currenTime = Date.now()      if ((currenTime - tokenTime) / 1000 > timeOut) {        // 如果它為true表示 過期瞭        // token沒用瞭 因為超時瞭        store.dispatch('user/logout') // 登出操作        // 跳轉到登錄頁        router.push('/login')        return Promise.reject(new Error('登錄過期瞭,請重新登錄'))      }      config.headers['Authorization'] = getToken()    }    return config  },  error => {    // do something with request error    console.log(error) // for debug    return Promise.reject(error)  })// response interceptorservice.interceptors.response.use(  response => {    const { meta: { status, msg }, data } = response.data    // if the custom code is not 20000, it is judged as an error.    if (status !== 200 && status !== 201) {      // 處理 token 過期問題      if (status === 400 && msg === '無效的token') {        removeToken()        store.dispatch('user/logout')        router.push('login')      }      Message({        message: msg || 'Error',        type: 'error',        duration: 5 * 1000      })      return Promise.reject(new Error(msg || 'Error'))    } else {      return data    }  },  error => {    console.log('err' + error) // for debug    Message({      message: error.message,      type: 'error',      duration: 5 * 1000    })    return Promise.reject(error)  })export default service

在登錄的時候,如果登錄成功,我們就應該設置時間戳
src/store/modules

async login (context, userInfo) {    const { username, password } = userInfo    const res = await login({ username: username.trim(), password: password })    // 設置用戶信息    const token = res.token    context.commit('set_token', token)    context.commit('set_userInfo', res)    // 設置用戶權限信息    const permission = await getMenus()    const menus = filterPermission(permission)    context.commit('set_menus', menus)  },

token失效處理

src/utils/request.js

response => {    const { meta: { status, msg }, data } = response.data    // if the custom code is not 20000, it is judged as an error.    if (status !== 200 && status !== 201) {      // 處理 token 過期問題      if (status === 400 && msg === '無效的token') {        removeToken()        store.dispatch('user/logout')        router.push('login')      }      Message({        message: msg || 'Error',        type: 'error',        duration: 5 * 1000      })      return Promise.reject(new Error(msg || 'Error'))    } else {      return data    }

路由、頁面、用戶管理、權限管理等等需要什麼頁面自己開發即可,步驟相似的

多語言切換、tab頁全屏 全屏插件的引用

安裝全局插件screenfull

npm i screenfull

封裝全屏插件src/components/ScreenFull/index.vue

                <!--  -->  import ScreenFull from 'screenfull'export default {  methods: {    //   改變全屏    changeScreen () {      if (!ScreenFull.isEnabled) {        // 此時全屏不可用        this.$message.warning('此時全屏組件不可用')        return      }      // document.documentElement.requestFullscreen()  原生js調用      //   如果可用 就可以全屏      ScreenFull.toggle()    }  }}

全局註冊該組件 src/components/index.js

import ScreenFull from './ScreenFull'Vue.component('ScreenFull', ScreenFull) // 註冊全屏組件

放置layout/navbar.vue

-------------------------------.right-menu-item {   vertical-align: middle;}

設置動態主題

封裝全屏插件 src/components/ThemePicker/index.vue

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

全局註冊該組件 src/components/index.js

import ThemePicker from './ThemePicker'Vue.component('ThemePicker', ThemePicker)

放置layout/navbar.vue

  

多語言實現

安裝國際化的語言包 i18n

npm i vue-i18n

需要多語言的實例化文件 src/lang/index.js

import Vue from 'vue' // 引入Vueimport VueI18n from 'vue-i18n' // 引入國際化的包import Cookie from 'js-cookie' // 引入cookie包import elementEN from 'element-ui/lib/locale/lang/en' // 引入餓瞭麼的英文包import elementZH from 'element-ui/lib/locale/lang/zh-CN' // 引入餓瞭麼的中文包import customZH from './zh' // 引入自定義中文包import customEN from './en' // 引入自定義英文包Vue.use(VueI18n) // 全局註冊國際化包export default new VueI18n({  locale: Cookie.get('language') || 'zh', // 從cookie中獲取語言類型 獲取不到就是中文  messages: {    en: {      ...elementEN, // 將餓瞭麼的英文語言包引入      ...customEN    },    zh: {      ...elementZH, // 將餓瞭麼的中文語言包引入      ...customZH    }  }})

main.js中對掛載 i18n的插件,並設置element為當前的語言

// 設置element為當前的語言Vue.use(ElementUI, {  i18n: (key, value) => i18n.t(key, value)})new Vue({  el: '#app',  router,  store,  i18n,  render: h => h(App)})

引入自定義語言包
src/lang/zh.js , src/lang/en.js
zh

export default {  route: {    Dashboard: '首頁',    manage: '後臺管理',    users: '用戶管理',    menus: '菜單管理',    logs: '日志管理',    example: '示例',    table: '數據列表',    // permissions: '權限管理',    // employees: '員工',    // employeesList: '員工管理',    // employeesInfo: '個人信息',    goods: '商品管理',    postInfo: '崗位信息',    manageSelf: '經理自助',    setting: '設置',    reports: '報表分析',    employeesAdd: '添加員工',    EditiNfo: '編輯信息',    rights: '權限管理',    print: '打印頁面',    form: '表單頁',    basicForm: '基礎表單',    stepForm: '分步表單',    advancedList: '高級表單',    step: '步驟',    details: '詳情頁',    BasicsDetails: '基礎詳情頁',    seniorDetails: '高級詳情頁',    import: '導入',    // 註冊    register: '人資-註冊',    login: '人資-登錄',    // 審批    approvals: '審批', // 審批    salaryApproval: '工資審核',    enterApproval: '入職審核',    leaveApproval: '申請請假',    quitApproval: '申請離職',    overtimeApproval: '加班申請',    securitySetting: '審批設置',    // 員工    employees: '員工',    employeesList: '員工列表',    employeesInfo: '個人信息',    employeesAdjust: '調崗',    employeesLeave: '離職',    employeesPrint: '打印',    // 工資    salarys: '工資',    salarysList: '工資列表',    salarysSetting: '工資設置',    salarysDetails: '工資詳情',    salarysHistorical: '歷史歸檔',    salarysMonthStatement: '月報表',    // 社保    'social_securitys': '社保',    socialSecuritys: '社保管理',    socialDetail: '詳情',    socialHistorical: '歷史歸檔',    socialMonthStatement: '當月報表',    // 組織架構    departments: '組織架構',    'departments-import': '引入',    // 公司    settings: '公司設置',    // 考勤    attendances: '考勤',    usersApprovals: '用戶審批',    // saas企業    'saas-clients': '企業',    'saas-clients-details': '企業詳情',    // 權限    'permissions': '權限管理' // 權限管理  },  navbar: {    search: '站內搜索',    logOut: '退出登錄',    dashboard: '首頁',    github: '項目地址',    screenfull: '全屏',    theme: '換膚',    lang: '多語言',    error: '錯誤日志'  },  login: {    title: '人力資源管理系統',    login: '登錄',    username: '賬號',    password: '密碼',    any: '隨便填',    thirdparty: '第三方登錄',    thirdpartyTips: '本地不能模擬,請結合自己業務進行模擬!!!'  },  tagsView: {    close: '關閉',    closeOthers: '關閉其它',    closeAll: '關閉所有',    refresh: '刷新'  },  table: {    title: '請輸入用戶',    search: '搜索',    add: '添加',    addUser: '新增用戶',    id: '序號',    email: '郵箱',    phone: '手機',    name: '姓名',    entryTime: '入職時間',    hireForm: '聘用形式',    jobNumber: '工號',    department: '部門',    managementForm: '管理形式',    city: '工作城市',    turnPositiveTime: '轉正時間',    permissionNew: '新增權限組',    permissionUser: '權限組名稱',    imdsAi: '高級接口授權',    avatar: '頭像',    introduction: '介紹',    paddword: '密碼',    powerCode: '權限代碼',    powerDistriB: '權限分配',    powerTitle: '權限標題',    powerNav: '主導航',    actions: '操作',    edit: '編輯',    delete: '刪除',    cancel: '取 消',    confirm: '確 定',    return: '返回',    operationType: '操作類型',    operationDate: '操作時間',    date: '日期',    submit: '提交',    operator: '操作人',    results: '執行結果',    describe: '描述',    save: '保存',    signOut: '退出',    reset: '重置',    know: '我知道瞭',    view: '查看'  }}

en

export default {  route: {    dashboard: 'Dashboard',    manage: 'manage',    users: 'users',    menus: 'menus',    // permissions: 'permissions',    logs: 'logs',    example: 'example',    table: 'table',    postInfo: 'Job information',    manageSelf: 'Manager self-help',    setting: 'setting',    reports: 'report',    employeesAdd: 'add employees',    EditiNfo: 'Edit information',    print: 'print',    form: 'form',    basicForm: 'basic form',    stepForm: 'step form',    advancedList: 'advanced form',    step: 'step',    details: 'details',    BasicsDetails: 'Basic details page',    seniorDetails: 'Advanced details page',    import: 'Import',    register: 'HRM-Register',    // 登錄    login: 'HRM-Login',    // 審批    approvals: 'Approvals', // 審批    salaryApproval: 'Salary-Approval',    enterApproval: 'Enter-Approval',    leaveApproval: 'Leave-Approval',    quitApproval: 'Quit-Approval',    overtimeApproval: 'Overtime-Approval',    securitySetting: 'Security-Setting',    // 員工    employees: 'Employees',    employeesList: 'Employees-List',    employeesInfo: 'Employees-Info',    employeesAdjust: 'Employees-Adjust',    employeesLeave: 'Employees-Leave',    employeesPrint: 'Employees-Print',    // 工資    salarys: 'salarys',    salarysList: 'Salarys-List',    salarysSetting: 'Salarys-Setting',    salarysDetails: 'Salarys-Details',    salarysHistorical: 'Salarys-Historical',    salarysMonthStatement: 'Salarys-Month',    // 社保    'social_securitys': 'Social',    socialSecuritys: 'Social-Securitys',    socialDetail: 'Social-Detail',    socialHistorical: 'Social-Historical',    socialMonthStatement: 'Social-Month',    // 組織架構    departments: 'departments',    'departments-import': 'import',    // 公司    settings: 'Company-Settings',    // 考勤    attendances: 'Attendances',    // 用戶審批    usersApprovals: 'Users-Approvals',    // 企業    'saas-clients': 'Saas-Clients',    'saas-clients-details': 'Saas-Details',    'permissions': 'permissions' // 權限管理  },  navbar: {    search: 'search',    logOut: 'Log Out',    dashboard: 'Dashboard',    github: 'Github',    screenfull: 'screenfull',    theme: 'theme',    lang: 'i18n',    error: 'error log'  },  login: {    title: 'itheima login',    login: 'Log in',    name: 'name',    entryTime: 'entry time',    hireForm: 'hire form',    jobNumber: 'job number',    department: 'department',    managementForm: 'management form',    city: 'city',    turnPositiveTime: 'turn positive time',    password: 'Password',    any: 'any',    thirdparty: 'Third',    thirdpartyTips: 'Can not be simulated on local, so please combine you own business simulation! ! !'  },  tagsView: {    close: 'Close',    closeOthers: 'Close Others',    closeAll: 'Close All',    refresh: 'refresh'  },  table: {    title: 'Title',    search: 'Search',    add: 'add',    addUser: 'addUser',    id: 'ID',    email: 'Email',    phone: 'Phone',    username: 'User',    permissionNew: 'permissionNew',    permissionUser: 'Permission',    imdsAi: 'Advanced interface authorization',    avatar: 'Avatar',    introduction: 'Introduction',    paddword: 'paddWord',    powerCode: 'Permission code',    powerTitle: 'Permission title',    actions: 'Actions',    edit: 'Edit',    delete: 'Delete',    cancel: 'Cancel',    confirm: 'Confirm',    operationType: 'operationType',    operationDate: 'operationDate',    date: 'Date',    operator: 'operator',    results: 'results of enforcement',    describe: 'Pedagogical operation',    save: 'save',    signOut: 'sign out',    submit: 'submit',    reset: 'reset',    know: 'I Know',    return: 'return',    view: 'view'  }}

index.js中同樣引入該語言包

import customZH from './zh' // 引入自定義中文包import customEN from './en' // 引入自定義英文包Vue.use(VueI18n) // 全局註冊國際化包export default new VueI18n({  locale: Cookie.get('language') || 'zh', // 從cookie中獲取語言類型 獲取不到就是中文  messages: {    en: {      ...elementEN, // 將餓瞭麼的英文語言包引入      ...customEN    },    zh: {      ...elementZH, // 將餓瞭麼的中文語言包引入      ...customZH    }  }})

將左側菜單變成多語言展示文本
layout/components/SidebarItem.vue

封裝多語言組件 src/components/lang/index.vue

                              中文      en      import Cookie from 'js-cookie'export default {  methods: {    changeLanguage (lang) {      Cookie.set('language', lang) // 切換多語言      this.$i18n.locale = lang // 設置給本地的i18n插件      this.$message.success('切換多語言成功')    }  }}

全局註冊該組件 src/components/index.js

import lang from './lang'Vue.component('lang', lang) // 註冊全屏組件

放置layout/navbar.vue

本文來自網絡,不代表程式碼花園立場,如有侵權,請聯系管理員。https://www.codegarden.cn/article/8367/