基 vue-element-admin升級的Vue3 +TS +Element-Plus 版本的後端管理前端解決方案 vue3-element-admin 正式對外發布,有來開源組織又一精心力作,毫無保留開放從0到1構建過程

有來技術團隊發表於2022-04-02

專案簡介

vue3-element-admin 是基於 vue-element-admin 升級的 Vue3 + Element Plus 版本的後臺管理前端解決方案,是 有來技術團隊youlai-mall 全棧開源商城專案的又一開源力作。

專案使用 Vue3 + Vite2 + TypeScript + Element-Plus + Vue Router + Pinia + Volar 等前端主流技術棧,基於此專案模板完成有來商城管理前端的 Vue3 版本。

本篇先對本專案功能、技術棧進行整體概述,而下一篇則會細節的講述從0到1搭建 vue3-element-admin,有始有終,在希望大家對本專案有個完完整整整了解的同時也能夠在學 Vue3 + TypeScript 等技術棧少花些時間,少走些彎路,這樣團隊在毫無保留開源做的或許才有些許意義。

功能清單

技術棧清單

技術棧 描述 官網
Vue3 漸進式 JavaScript 框架 https://v3.cn.vuejs.org/
TypeScript 微軟新推出的一種語言,是 JavaScript 的超集 https://www.tslang.cn/
Vite2 前端開發與構建工具 https://cn.vitejs.dev/
Element Plus 基於 Vue 3,面向設計師和開發者的元件庫 https://element-plus.gitee.io/zh-CN/
Pinia 新一代狀態管理工具 https://pinia.vuejs.org/
Vue Router Vue.js 的官方路由 https://router.vuejs.org/zh/
wangEditor Typescript 開發的 Web 富文字編輯器 https://www.wangeditor.com/
Echarts 一個基於 JavaScript 的開源視覺化圖表庫 https://echarts.apache.org/zh/

專案起源

首先說下為什麼會有此開源專案:

  • vue-element-admin 過於優秀,但可惜不更新了,停滯在 Vue2 版本 ;

  • 場面上基於 Vue3 + Element Plus 組合的框架封裝複雜,後端接入困難,和 vue-element-admin 相差甚遠;

  • 新技術棧、新特性支援不夠,舉例 TypeScript 、Vue3.2 的 setup 語法糖;

  • 開源專案不穩定,很好的專案悄無聲息的不更新了,所以團隊想把管理前端掌握在自己手裡。

開始為了給有來商城的管理前端找到合適的 Vue3 升級的替代方案,花了不少時間去研究市面對應的開源框架,嘗試過接入有來商城線上微服務介面,但結果都不盡人意,很大部分原因是接入的複雜程度遠遠大於當初接入 vue-element-admin ,也不排除是先入為主的原因。但接入 vue-element-admin 確實是很輕鬆的,有興趣的可以瞭解下接入過程:

vue-element-admin實戰 | 第一篇:移除mock接入後臺微服務介面

vue-element-admin實戰 | 第二篇:最小改動接入後臺實現動態路由選單載入

因為相信大多數同學在 Vue2 學習階段同時學習了 vue-element-admin 這款優秀的開源框架,但隨著時間的腳步也都慢慢被捲入 Vue3 + TypeScript 的學習浪潮,前端技術棧更新迭代太快讓人直呼學不動了,為了減少大家學習成本,便基於 vue-element-admin 升級改造適配當前 Vue3 生態技術棧的 vue3-element-admin,站在巨人的肩膀不僅是為了看的更遠,更多的是一種致敬、延續和希望走的更遠。

專案預覽

線上預覽地址:www.youlai.tech

以下截圖是來自有來商城管理前端 mall-admin-web ,是基於 vue3-element-admin 為基礎開發的具有一套完整的系統許可權管理的商城管理系統,資料均為線上真實的而非Mock。

首頁控制檯

結構樣式基本遵循 vue-element-admin , 首頁模組均已做元件封裝,可簡單的實現替換。

首頁控制檯

國際化

已實現 Element Plus 元件和選單路由的國際化,不過只做了少量國際化工作,國際化大部分是體力活,如果你有國際化的需求,會在下文從0到1實現Element Plus元件和選單路由的國際化。

國際化

主題設定

主題設定

大小切換

大小切換

角色管理

角色管理

選單管理

選單管理

商品上架

商品上架

庫存設定

庫存設定

微信小程式/ APP/ H5 顯示上架商品效果

啟動部署

  • 專案啟動
npm install 
npm run dev

瀏覽器訪問 http://localhost:3000

  • 專案部署
npm run build:prod 

生成的靜態檔案在工程根目錄 dist 資料夾

專案從0到1構建

大家在整合第三方外掛的時候一定要注意版本,不同版本的外掛整合會有區別

環境準備

1. 執行環境Node

Node下載地址: http://nodejs.cn/download/ 

根據本機環境選擇對應版本下載,安裝過程視覺化操作非常簡便,靜默安裝即可。

安裝完成後命令列終端 `node -v` 檢視版本號以驗證是否安裝成功:

2. 開發工具VSCode

下載地址:https://code.visualstudio.com/Download

3. 必裝外掛Volar

VSCode 外掛市場搜尋 Volar (就排在第一位的骷髏頭),且要禁用預設的 Vetur.

專案初始化

1. Vite 是什麼?

Vite是一種新型前端構建工具,能夠顯著提升前端開發體驗。

Vite 官方中文文件:https://cn.vitejs.dev/guide/

2. 初始化專案

npm init vite@latest vue3-element-admin --template vue-ts
  • vue3-element-admin:專案名稱
  • vue-ts : Vue + TypeScript 的模板,除此還有vue,react,react-ts模板

3. 啟動專案

cd vue3-element-admin
npm install
npm run dev

瀏覽器訪問: http://localhost:3000

整合Element-Plus

1.本地安裝Element Plus和圖示元件

npm install element-plus
npm install @element-plus/icons-vue

2.全域性註冊元件

// main.ts
import ElementPlus from 'element-plus'
import 'element-plus/theme-chalk/index.css'

createApp(App)
    .use(ElementPlus)
    .mount('#app')

3. 頁面使用 Element Plus 元件和圖示

<!-- src/App.vue -->
<template>
  <img alt="Vue logo" src="./assets/logo.png"/>
  <HelloWorld msg="Hello Vue 3 + TypeScript + Vite"/>
  <div style="text-align: center;margin-top: 10px">
    <el-button :icon="Search" circle></el-button>
    <el-button type="primary" :icon="Edit" circle></el-button>
    <el-button type="success" :icon="Check" circle></el-button>
    <el-button type="info" :icon="Message" circle></el-button>
    <el-button type="warning" :icon="Star" circle></el-button>
    <el-button type="danger" :icon="Delete" circle></el-button>
  </div>
</template>

<script lang="ts" setup>
     import HelloWorld from '/src/components/HelloWorld.vue'
     import {Search, Edit,Check,Message,Star, Delete} from '@element-plus/icons-vue'
</script>

4. 效果預覽

路徑別名配置

使用 @ 代替 src

1. Vite配置

// vite.config.ts
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'

import path from 'path'

export default defineConfig({
    plugins: [vue()],
    resolve: {
        alias: {
            "@": path.resolve("./src") // 相對路徑別名配置,使用 @ 代替 src
        }
    }
})

2. 安裝@types/node

import path from 'path'編譯器報錯:TS2307: Cannot find module 'path' or its corresponding type declarations.

本地安裝 Node 的 TypeScript 型別描述檔案即可解決編譯器報錯

npm install @types/node --save-dev

3. TypeScript 編譯配置

	同樣還是`import path from 'path'` 編譯報錯: TS1259: Module '"path"' can only be default-imported using the 'allowSyntheticDefaultImports' flag

	因為 typescript 特殊的 import 方式 , 需要配置允許預設匯入的方式,還有路徑別名的配置
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./", // 解析非相對模組的基地址,預設是當前目錄
    "paths": { //路徑對映,相對於baseUrl
      "@/*": ["src/*"] 
    },
    "allowSyntheticDefaultImports": true // 允許預設匯入
  }
}

4.別名使用

// App.vue
import HelloWorld from '/src/components/HelloWorld.vue'
												↓
import HelloWorld from '@/components/HelloWorld.vue'

環境變數

官方教程: https://cn.vitejs.dev/guide/env-and-mode.html

1. env配置檔案

專案根目錄分別新增 開發、生產和模擬環境配置

  • 開發環境配置:.env.development

    # 變數必須以 VITE_ 為字首才能暴露給外部讀取
    VITE_APP_TITLE = 'vue3-element-admin'
    VITE_APP_PORT = 3000
    VITE_APP_BASE_API = '/dev-api'
    
  • 生產環境配置:.env.production

    VITE_APP_TITLE = 'vue3-element-admin'
    VITE_APP_PORT = 3000
    VITE_APP_BASE_API = '/prod-api'
    
  • 模擬生產環境配置:.env.staging

    VITE_APP_TITLE = 'vue3-element-admin'
    VITE_APP_PORT = 3000
    VITE_APP_BASE_API = '/prod--api'
    

2.環境變數智慧提示

新增環境變數型別宣告

// src/ env.d.ts
// 環境變數型別宣告
interface ImportMetaEnv {
  VITE_APP_TITLE: string,
  VITE_APP_PORT: string,
  VITE_APP_BASE_API: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

後面在使用自定義環境變數就會有智慧提示,環境變數使用請參考下一節。

瀏覽器跨域處理

1. 跨域原理

瀏覽器同源策略: 協議、域名和埠都相同是同源,瀏覽器會限制非同源請求讀取響應結果。

解決瀏覽器跨域限制大體分為後端和前端兩個方向:

  • 後端:開啟 CORS 資源共享;
  • 前端:使用反向代理欺騙瀏覽器誤認為是同源請求;

2. 前端反向代理解決跨域

Vite 配置反向代理解決跨域,因為需要讀取環境變數,故寫法和上文的出入較大,這裡貼出完整的 vite.config.ts 配置。

// vite.config.ts
import {UserConfig, ConfigEnv, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default ({command, mode}: ConfigEnv): UserConfig => {
    // 獲取 .env 環境配置檔案
    const env = loadEnv(mode, process.cwd())

    return (
        {
            plugins: [
                vue()
            ],
            // 本地反向代理解決瀏覽器跨域限制
            server: {
                host: 'localhost', 
                port: Number(env.VITE_APP_PORT), 
                open: true, // 啟動是否自動開啟瀏覽器
                proxy: {
                    [env.VITE_APP_BASE_API]: { 
                        target: 'http://www.youlai.tech:9999', // 有來商城線上介面地址
                        changeOrigin: true,
                        rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
                    }
                }
            },
            resolve: {
                alias: {
                    "@": path.resolve("./src") // 相對路徑別名配置,使用 @ 代替 src
                }
            }
        }
    )
}

SVG圖示

官方教程: https://github.com/vbenjs/vite-plugin-svg-icons/blob/main/README.zh_CN.md

Element Plus 圖示庫往往滿足不了實際開發需求,可以引用和使用第三方例如 iconfont 的圖示,本節通過整合 vite-plugin-svg-icons 外掛使用第三方圖示庫。

1. 安裝 vite-plugin-svg-icons

npm i vite-plugin-svg-icons -D

2. 建立圖示資料夾

專案建立 `src/assets/icons` 資料夾,存放 iconfont 下載的 SVG 圖示

3. main.ts 註冊指令碼

// main.ts
import 'virtual:svg-icons-register';

4. vite.config.ts 外掛配置

// vite.config.ts
import {UserConfig, ConfigEnv, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import viteSvgIcons from 'vite-plugin-svg-icons';

export default ({command, mode}: ConfigEnv): UserConfig => {
    // 獲取 .env 環境配置檔案
    const env = loadEnv(mode, process.cwd())

    return (
        {
            plugins: [
                vue(),
                viteSvgIcons({
                    // 指定需要快取的圖示資料夾
                    iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
                    // 指定symbolId格式
                    symbolId: 'icon-[dir]-[name]',
                })
            ]
        }
    )
}

5. 元件封裝

<!-- src/components/SvgIcon/index.vue -->
<template>
  <`svg aria-hidden="true" class="svg-icon">
    <use :xlink:href="symbolId" :fill="color" />
  </svg>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props=defineProps({
  prefix: {
    type: String,
    default: 'icon',
  },
  iconClass: {
    type: String,
    required: true,
  },
  color: {
    type: String,
    default: ''
  }
})

const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
</script>

<style scoped>
.svg-icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  overflow: hidden;
  fill: currentColor;
}
</style>

6. 使用案例

<template>
  <svg-icon icon-class="menu"/>
</template>

<script setup lang="ts">
	import SvgIcon from '@/components/SvgIcon/index.vue';
</script>  

Pinia狀態管理

Pinia 是 Vue.js 的輕量級狀態管理庫,Vuex 的替代方案。

尤雨溪於2021.11.24 在 Twitter 上宣佈:Pinia 正式成為 vuejs 官方的狀態庫,意味著 Pinia 就是 Vuex 5 。

1. 安裝Pinia

npm install pinia

2. Pinia全域性註冊

// src/main.ts
import { createPinia } from "pinia"
app.use(createPinia())
   .mount('#app')

3. Pinia模組封裝

// src/store/modules/user.ts
// 使用者狀態模組
import { defineStore } from "pinia";
import { UserState } from "@/types"; // 使用者state的TypeScript型別宣告,檔案路徑 src/types/store/user.d.ts

const useUserStore = defineStore({
    id: "user",
    state: (): UserState => ({
        token:'',
        nickname: ''
    }),
    actions: {
      getUserInfo() {
      	return new Promise(((resolve, reject) => {
          ...
          resolve(data)
          ...
        }))
      }
    }
})

export default useUserStore;
// src/store/index.ts
import useUserStore from './modules/user'
const useStore = () => ({
    user: useUserStore()
})
export default useStore

4. 使用Pinia

import useStore from "@/store";

const { user } = useStore()
// state
const token = user.token
// action
user.getUserInfo().then(({data})=>{
	console.log(data)
})

Axios網路請求庫封裝

1. axios工具封裝

//  src/utils/request.ts
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import { ElMessage, ElMessageBox } from "element-plus";
import { localStorage } from "@/utils/storage";
import useStore from "@/store"; // pinia

// 建立 axios 例項
const service = axios.create({
    baseURL: import.meta.env.VITE_APP_BASE_API,
    timeout: 50000,
    headers: { 'Content-Type': 'application/json;charset=utf-8' }
})

// 請求攔截器
service.interceptors.request.use(
    (config: AxiosRequestConfig) => {
        if (!config.headers) {
            throw new Error(`Expected 'config' and 'config.headers' not to be undefined`);
        }
        const { user } = useStore()
        if (user.token) {
            config.headers.Authorization = `${localStorage.get('token')}`;
        }
        return config
    }, (error) => {
        return Promise.reject(error);
    }
)

// 響應攔截器
service.interceptors.response.use(
    (response: AxiosResponse) => {
        const { code, msg } = response.data;
        if (code === '00000') {
            return response.data;
        } else {
            ElMessage({
                message: msg || '系統出錯',
                type: 'error'
            })
            return Promise.reject(new Error(msg || 'Error'))
        }
    },
    (error) => {
        const { code, msg } = error.response.data
        if (code === 'A0230') {  // token 過期
            localStorage.clear(); // 清除瀏覽器全部快取
            window.location.href = '/'; // 跳轉登入頁
            ElMessageBox.alert('當前頁面已失效,請重新登入', '提示', {})
                .then(() => {
                })
                .catch(() => {
                });
        } else {
            ElMessage({
                message: msg || '系統出錯',
                type: 'error'
            })
        }
        return Promise.reject(new Error(msg || 'Error'))
    }
);

// 匯出 axios 例項
export default service

2. API封裝

以登入成功後獲取使用者資訊(暱稱、頭像、角色集合和許可權集合)的介面為案例,演示如何通過封裝的 axios 工具類請求後端介面,其中響應資料

// src/api/system/user.ts
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import { UserInfo } from "@/types"; // 使用者資訊返回資料的TypeScript型別宣告,檔案路徑 src/types/api/system/user.d.ts

/**
 * 登入成功後獲取使用者資訊(暱稱、頭像、許可權集合和角色集合)
 */
export function getUserInfo(): AxiosPromise<UserInfo> {
    return request({
        url: '/youlai-admin/api/v1/users/me',
        method: 'get'
    })
}

3. API呼叫

// src/store/modules/user.ts
import { getUserInfo } from "@/api/system/user";

// 獲取登入使用者資訊
getUserInfo().then(({ data }) => {
	const { nickname, avatar, roles, perms } = data
  ...
})

動態許可權路由

官方文件: https://router.vuejs.org/zh/api/

1. 安裝 vue-router

npm install vue-router@next

2. 建立路由例項

建立路由例項並匯出,其中包括靜態路由資料,動態路由後面將通過介面從後端獲取並整合使用者角色的許可權控制。

// src/router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import useStore from "@/store";

export const Layout = () => import('@/layout/index.vue')

// 靜態路由
export const constantRoutes: Array<RouteRecordRaw> = [
    {
        path: '/redirect',
        component: Layout,
        meta: { hidden: true },
        children: [
            {
                path: '/redirect/:path(.*)',
                component: () => import('@/views/redirect/index.vue')
            }
        ]
    },
    {
        path: '/login',
        component: () => import('@/views/login/index.vue'),
        meta: { hidden: true }
    },
    {
        path: '/404',
        component: () => import('@/views/error-page/404.vue'),
        meta: { hidden: true }
    },
    {
        path: '/401',
        component: () => import('@/views/error-page/401.vue'),
        meta: { hidden: true }
    },
    {
        path: '/',
        component: Layout,
        redirect: '/dashboard',
        children: [
            {
                path: 'dashboard',
                component: () => import('@/views/dashboard/index.vue'),
                name: 'Dashboard',
                meta: { title: 'dashboard', icon: 'dashboard', affix: true }
            }
        ]
    }
]

// 建立路由例項
const router = createRouter({
    history: createWebHashHistory(),
    routes: constantRoutes as RouteRecordRaw[],
    // 重新整理時,滾動條位置還原
    scrollBehavior: () => ({ left: 0, top: 0 })
})

// 重置路由
export function resetRouter() {
    const { permission } = useStore()
    permission.routes.forEach((route) => {
        const name = route.name
        if (name) {
            router.hasRoute(name) && router.removeRoute(name)
        }
    })
}

export default router

3. 路由例項全域性註冊

// main.ts
import router from "@/router";

app.use(router)
   .mount('#app')

4. 動態許可權路由

// src/permission.ts
import router from "@/router";
import { ElMessage } from "element-plus";
import useStore from "@/store";
import NProgress from 'nprogress';
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false }) // 進度環顯示/隱藏


// 白名單路由
const whiteList = ['/login', '/auth-redirect']

router.beforeEach(async (to, form, next) => {
    NProgress.start()
    const { user, permission } = useStore()
    const hasToken = user.token
    if (hasToken) {
        // 登入成功,跳轉到首頁
        if (to.path === '/login') {
            next({ path: '/' })
            NProgress.done()
        } else {
            const hasGetUserInfo = user.roles.length > 0
            if (hasGetUserInfo) {
                next()
            } else {
                try {
                    await user.getUserInfo()
                    const roles = user.roles
                    // 使用者擁有許可權的路由集合(accessRoutes) 
                    const accessRoutes: any = await permission.generateRoutes(roles)
                    accessRoutes.forEach((route: any) => {
                        router.addRoute(route)
                    })
                    next({ ...to, replace: true })
                } catch (error) {
                    // 移除 token 並跳轉登入頁
                    await user.resetToken()
                    ElMessage.error(error as any || 'Has Error')
                    next(`/login?redirect=${to.path}`)
                    NProgress.done()
                }
            }
        }
    } else {
        // 未登入可以訪問白名單頁面(登入頁面)
        if (whiteList.indexOf(to.path) !== -1) {
            next()
        } else {
            next(`/login?redirect=${to.path}`)
            NProgress.done()
        }
    }
})

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

其中 const accessRoutes: any = await permission.generateRoutes(roles)是根據使用者角色獲取擁有許可權的路由(靜態路由+動態路由),核心程式碼如下:

// src/store/modules/permission.ts 
import { constantRoutes } from '@/router';
import { listRoutes } from "@/api/system/menu";

const usePermissionStore = defineStore({
    id: "permission",
    state: (): PermissionState => ({
        routes: [],
        addRoutes: []
    }),
    actions: {
        setRoutes(routes: RouteRecordRaw[]) {
            this.addRoutes = routes
          	// 靜態路由 + 動態路由
            this.routes = constantRoutes.concat(routes)
        },
        generateRoutes(roles: string[]) {
            return new Promise((resolve, reject) => {
              	// API 獲取動態路由
                listRoutes().then(response => {
                    const asyncRoutes = response.data
                    let accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
                    this.setRoutes(accessedRoutes)
                    resolve(accessedRoutes)
                }).catch(error => {
                    reject(error)
                })
            })
        }
    }
})

export default usePermissionStore;

按鈕許可權

1. Directive 自定義指令

// src/directive/permission/index.ts

import useStore from "@/store";
import { Directive, DirectiveBinding } from "vue";

/**
 * 按鈕許可權校驗
 */
export const hasPerm: Directive = {
    mounted(el: HTMLElement, binding: DirectiveBinding) {
        // 「超級管理員」擁有所有的按鈕許可權
        const { user } = useStore()
        const roles = user.roles;
        if (roles.includes('ROOT')) {
            return true
        }
        // 「其他角色」按鈕許可權校驗
        const { value } = binding;
        if (value) {
            const requiredPerms = value; // DOM繫結需要的按鈕許可權標識

            const hasPerm = user.perms.some(perm => {
                return requiredPerms.includes(perm)
            })

            if (!hasPerm) {
                el.parentNode && el.parentNode.removeChild(el);
            }
        } else {
            throw new Error("need perms! Like v-has-perm=\"['sys:user:add','sys:user:edit']\"");
        }
    }
};

2. 自定義指令全域性註冊

// src/main.ts

const app = createApp(App)
// 自定義指令
import * as directive from "@/directive";

Object.keys(directive).forEach(key => {
    app.directive(key, (directive as { [key: string]: Directive })[key]);
});

3. 指令使用

// src/views/system/user/index.vue
<el-button v-hasPerm="['sys:user:add']">新增</el-button>
<el-button v-hasPerm="['sys:user:delete']">刪除</el-button>

Element-Plus國際化

官方教程:https://element-plus.gitee.io/zh-CN/guide/i18n.html

Element Plus 官方提供全域性配置 Config Provider實現國際化

//  src/App.vue
<template>
  <el-config-provider :locale="locale">
    <router-view />
  </el-config-provider>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { ElConfigProvider } from "element-plus";

import useStore from "@/store";

// 匯入 Element Plus 語言包
import zhCn from "element-plus/es/locale/lang/zh-cn";
import en from "element-plus/es/locale/lang/en";

// 獲取系統語言
const { app } = useStore();
const language = computed(() => app.language);

const locale = ref();

watch(
  language,
  (value) => {
    if (value == "en") {
      locale.value = en;
    } else { // 預設中文
      locale.value = zhCn;
    }
  },
  {
    // 初始化立即執行
    immediate: true
  }
);
</script>

自定義國際化

i18n 英文全拼 internationalization ,國際化的意思,英文 i 和 n 中間18個英文字母

1. 安裝 vue-i18n

npm install vue-i18n@next

2. 語言包

建立 src/lang 語言包目錄,中文語言包 zh-cn.ts,英文語言包 en.ts

// src/lang/en.ts
export default {
    // 路由國際化
    route: {
        dashboard: 'Dashboard',
        document: 'Document'
    },
    // 登入頁面國際化
    login: {
        title: 'youlai-mall management system',
        username: 'Username',
        password: 'Password',
        login: 'Login',
        code: 'Verification Code',
        copyright: 'Copyright © 2020 - 2022 youlai.tech All Rights Reserved. ',
        icp: ''
    },
    // 導航欄國際化
    navbar:{
        dashboard: 'Dashboard',
        logout:'Logout',
        document:'Document',
        gitee:'Gitee'
    }
}

3. 建立i18n例項

// src/lang/index.ts

// 自定義國際化配置
import {createI18n} from 'vue-i18n'
import {localStorage} from '@/utils/storage'

// 本地語言包
import enLocale from './en'
import zhCnLocale from './zh-cn'

const messages = {
    'zh-cn': {
        ...zhCnLocale
    },
    en: {
        ...enLocale
    }
}

/**
 * 獲取當前系統使用語言字串
 * 
 * @returns zh-cn|en ...
 */
export const getLanguage = () => {
    // 本地快取獲取
    let language = localStorage.get('language')
    if (language) {
        return language
    }
     // 瀏覽器使用語言
    language = navigator.language.toLowerCase()
    const locales = Object.keys(messages)
    for (const locale of locales) {
        if (language.indexOf(locale) > -1) {
            return locale
        }
    }
    return 'zh-cn'
}

const i18n = createI18n({
    locale: getLanguage(),
    messages: messages
})

export default i18n

4. i18n 全域性註冊

// main.ts

// 國際化
import i18n from "@/lang/index";

app.use(i18n)
   .mount('#app');

5. 靜態頁面國際化

$t 是 i18n 提供的根據 key 從語言包翻譯對應的 value 方法

<h3 class="title">{{ $t("login.title") }}</h3>

6. 動態路由國際化

i18n 工具類,主要使用 i18n 的 te (判斷語言包是否存在key) 和 t (翻譯) 兩個方法

//  src/utils/i18n.ts
import i18n from "@/lang/index";

export function generateTitle(title: any) {
    // 判斷是否存在國際化配置,如果沒有原生返回
    const hasKey = i18n.global.te('route.' + title)
    if (hasKey) {
        const translatedTitle = i18n.global.t('route.' + title)
        return translatedTitle
    }
    return title
}

頁面使用

// src/components/Breadcrumb/index.vue
<template>
 	<a v-else @click.prevent="handleLink(item)">
       {{ generateTitle(item.meta.title) }}
    </a>
</template>

<script setup lang="ts">
import {generateTitle} from '@/utils/i18n'
</script>    

wangEditor富文字編輯器

推薦教程:50 行程式碼 Vue3 中使用 wangEditor 富文字編輯器

1. 安裝wangEditor和Vue3元件

npm install @wangeditor/editor
npm install @wangeditor/editor-for-vue@next

2. wangEditor元件封裝

<!-- src/components/WangEditor/index.vue -->
<template>
  <div style="border: 1px solid #ccc">
    <!-- 工具欄 -->
    <Toolbar
        :editorId="editorId"
        :defaultConfig="toolbarConfig"
        style="border-bottom: 1px solid #ccc"
    />
    <!-- 編輯器 -->
    <Editor
        :editorId="editorId"
        :defaultConfig="editorConfig"
        :defaultHtml="defaultHtml"
        @onChange="handleChange"
        style="height: 500px; overflow-y: hidden;"
    />
  </div>
</template>

<script setup lang="ts">
import {computed, onBeforeUnmount, reactive, toRefs} from 'vue'
import {Editor, Toolbar, getEditor, removeEditor} from '@wangeditor/editor-for-vue'

// API 引用
import {uploadFile} from "@/api/system/file";

const props = defineProps({
  modelValue: {
    type: [String],
    default: ''
  },
})

const emit = defineEmits(['update:modelValue']);

const state = reactive({
  editorId: `w-e-${Math.random().toString().slice(-5)}`, //【注意】編輯器 id ,要全域性唯一
  toolbarConfig: {},
  editorConfig: {
    placeholder: '請輸入內容...',
    MENU_CONF: {
      uploadImage: {
        // 自定義圖片上傳
        // @link https://www.wangeditor.com/v5/guide/menu-config.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E5%8A%9F%E8%83%BD
        async customUpload(file:any, insertFn:any) {
          uploadFile(file).then(response => {
            const url = response.data
            insertFn(url)
          })
        }
      }
    }
  },
  defaultHtml: props.modelValue
})

const {editorId, toolbarConfig, editorConfig,defaultHtml} = toRefs(state)

function handleChange(editor:any) {
  emit('update:modelValue', editor.getHtml())
}

// 元件銷燬時,也及時銷燬編輯器
onBeforeUnmount(() => {
  const editor = getEditor(state.editorId)
  if (editor == null) return
  editor.destroy()
  removeEditor(state.editorId)
})

</script>

<style src="@wangeditor/editor/dist/css/style.css"></style>

3. 使用案例

<template>
  <div class="component-container">
  	<editor v-model="modelValue.detail" style="height: 600px" />
  </div>
</template>

<script setup lang="ts">
	import Editor from "@/components/WangEditor/index.vue";
</script>

Echarts圖表

1. 安裝 Echarts

npm install echarts

2. Echarts 自適應大小工具類

側邊欄、瀏覽器視窗大小切換都會觸發圖表的 resize() 方法來進行自適應

// src/utils/resize.ts
import { ref } from 'vue'
export default function() {
    const chart = ref<any>()
    const sidebarElm = ref<Element>()

    const chartResizeHandler = () => {
        if (chart.value) {
            chart.value.resize()
        }
    }

    const sidebarResizeHandler = (e: TransitionEvent) => {
        if (e.propertyName === 'width') {
            chartResizeHandler()
        }
    }

    const initResizeEvent = () => {
        window.addEventListener('resize', chartResizeHandler)
    }

    const destroyResizeEvent = () => {
        window.removeEventListener('resize', chartResizeHandler)
    }

    const initSidebarResizeEvent = () => {
        sidebarElm.value = document.getElementsByClassName('sidebar-container')[0]
        if (sidebarElm.value) {
            sidebarElm.value.addEventListener('transitionend', sidebarResizeHandler as EventListener)
        }
    }

    const destroySidebarResizeEvent = () => {
        if (sidebarElm.value) {
            sidebarElm.value.removeEventListener('transitionend', sidebarResizeHandler as EventListener)
        }
    }

    const mounted = () => {
        initResizeEvent()
        initSidebarResizeEvent()
    }

    const beforeDestroy = () => {
        destroyResizeEvent()
        destroySidebarResizeEvent()
    }

    const activated = () => {
        initResizeEvent()
        initSidebarResizeEvent()
    }

    const deactivated = () => {
        destroyResizeEvent()
        destroySidebarResizeEvent()
    }

    return {
        chart,
        mounted,
        beforeDestroy,
        activated,
        deactivated
    }
}

3. Echarts使用

官方示例: https://echarts.apache.org/examples/zh/index.html

官方的示例文件豐富和詳細,且涵蓋了 JavaScript 和 TypeScript 版本,使用非常簡單。

<!-- src/views/dashboard/components/Chart/BarChart.vue --> 
<!-- 線 + 柱混合圖 -->
<template>
  <div
      :id="id"
      :class="className"
      :style="{height, width}"
  />
</template>

<script setup lang="ts">
import {nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted} from "vue";
import {init, EChartsOption} from 'echarts'
import * as echarts from 'echarts';
import resize from '@/utils/resize'

const props = defineProps({
  id: {
    type: String,
    default: 'barChart'
  },
  className: {
    type: String,
    default: ''
  },
  width: {
    type: String,
    default: '200px',
    required: true
  },
  height: {
    type: String,
    default: '200px',
    required: true
  }
})

const {
  mounted,
  chart,
  beforeDestroy,
  activated,
  deactivated
} = resize()

function initChart() {
  const barChart = init(document.getElementById(props.id) as HTMLDivElement)

  barChart.setOption({
    title: {
      show: true,
      text: '業績總覽(2021年)',
      x: 'center',
      padding: 15,
      textStyle: {
        fontSize: 18,
        fontStyle: 'normal',
        fontWeight: 'bold',
        color: '#337ecc'
      }
    },
    grid: {
      left: '2%',
      right: '2%',
      bottom: '10%',
      containLabel: true
    },
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'cross',
        crossStyle: {
          color: '#999'
        }
      }
    },
    legend: {
      x: 'center',
      y: 'bottom',
      data: ['收入', '毛利潤', '收入增長率', '利潤增長率']
    },
    xAxis: [
      {
        type: 'category',
        data: ['上海', '北京', '浙江', '廣東', '深圳', '四川', '湖北', '安徽'],
        axisPointer: {
          type: 'shadow'
        }
      }
    ],
    yAxis: [
      {
        type: 'value',
        min: 0,
        max: 10000,
        interval: 2000,
        axisLabel: {
          formatter: '{value} '
        }
      },
      {
        type: 'value',
        min: 0,
        max: 100,
        interval: 20,
        axisLabel: {
          formatter: '{value}%'
        }
      }
    ],
    series: [
      {
        name: '收入',
        type: 'bar',
        data: [
          8000, 8200, 7000, 6200, 6500, 5500, 4500, 4200, 3800,
        ],
        barWidth: 20,
        itemStyle: {
          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
            { offset: 0, color: '#83bff6' },
            { offset: 0.5, color: '#188df0' },
            { offset: 1, color: '#188df0' }
          ])
        }
      },
      {
        name: '毛利潤',
        type: 'bar',
        data: [
          6700, 6800, 6300, 5213, 4500, 4200, 4200, 3800
        ],
        barWidth: 20,
        itemStyle: {
          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
            { offset: 0, color: '#25d73c' },
            { offset: 0.5, color: '#1bc23d' },
            { offset: 1, color: '#179e61' }
          ])
        }
      },
      {
        name: '收入增長率',
        type: 'line',
        yAxisIndex: 1,
        data: [65, 67, 65, 53, 47, 45, 43, 42, 41],
        itemStyle: {
          color: '#67C23A'
        }
      },
      {
        name: '利潤增長率',
        type: 'line',
        yAxisIndex: 1,
        data: [80, 81, 78, 67, 65, 60, 56,51, 45 ],
        itemStyle: {
          color: '#409EFF'
        }
      }
    ]
  } as EChartsOption)
  chart.value = barChart
}

onBeforeUnmount(() => {
  beforeDestroy()
})

onActivated(() => {
  activated()
})

onDeactivated(() => {
  deactivated()
})

onMounted(() => {
  mounted()
  nextTick(() => {
    initChart()
  })
})

</script>

專案原始碼

Gitee Github
vue3-element-admin https://gitee.com/youlaiorg/vue3-element-admin https://github.com/youlaitech/vue3-element-admin

加入我們

如果有問題或有好的建議可以新增開發者微信,備註「有來」進入學習交流群,備註「無回」參與開發。

開發人員 開發人員
rui chuan

相關文章