• 基于 vueelementadmin 升级的 Vue3 +TS +ElementPlus 版本的从0到1构建说明,有来开源组织又一精心开源力作


    项目简介

    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 等技术栈少花些时间,少走些弯路,这样团队在毫无保留开源才有些许意义。

    功能清单

    img

    技术栈清单

    技术栈 描述 官网
    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/

    项目预览

    在线预览地址:vue3.youlai.tech

    以下截图是来自有来商城管理前端 mall-admin-web ,是基于 vue3-element-admin 为基础开发的具有一套完整的系统权限管理的商城管理系统,数据均为线上真实的而非Mock。

    首页控制台

    结构样式基本遵循 vue-element-admin , 首页模块均已做组件封装,可简单的实现替换。

    首页控制台

    国际化

    已实现 Element Plus 组件和菜单路由的国际化,不过只做了少量国际化工作,国际化大部分是体力活,如果你有国际化的需求,会在下文从0到1实现Element Plus组件和菜单路由的国际化。

    国际化

    主题设置

    主题设置

    大小切换

    大小切换

    角色管理

    角色管理

    菜单管理

    菜单管理

    商品上架

    商品上架

    库存设置

    库存设置

    微信小程序/ APP/ H5 显示上架商品效果

    img

    启动部署

    • 项目启动
    npm install 
    npm run dev
    

    浏览器访问 http://localhost:3000

    • 项目部署
    npm run build:prod 
    

    生成的静态文件在工程根目录 dist 文件夹

    项目从0到1构建

    安装第三方插件请注意项目源码的package.json版本号,有些升级不考虑兼容性的插件在 install 的时候我会带上具体版本号,例如 npm install vue-i18n@9.1.9npm i vite-plugin-svg-icons@2.0.1 -D

    环境准备

    1. 运行环境Node

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

    根据本机环境选择对应版本下载,安装过程可视化操作非常简便,静默安装即可。

    安装完成后命令行终端 node -v 查看版本号以验证是否安装成功:

    img

    2. 开发工具VSCode

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

    3. 必装插件Volar

    VSCode 插件市场搜索 Volar (就排在第一位的骷髅头),且要禁用默认的 Vetur.

    img

    项目初始化

    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模板

    img

    3. 启动项目

    cd vue3-element-admin
    npm install
    npm run dev
    

    浏览器访问: http://localhost:3000

    img

    整合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全局组件类型声明

    // tsconfig.json
    {
      "compilerOptions": {
        // ...
        "types": ["element-plus/global"]
      }
    }
    

    4. 页面使用 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>
    

    5. 效果预览

    img

    路径别名配置

    使用 @ 代替 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
    }
    

    后面在使用自定义环境变量就会有智能提示,环境变量使用请参考下一节。

    img

    浏览器跨域处理

    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: 'https://api.youlai.tech', // 有来商城线上接口地址
                            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 fast-glob@3.2.11 -D
    npm i vite-plugin-svg-icons@2.0.1 -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 { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
    
    export default ({command, mode}: ConfigEnv): UserConfig => {
        // 获取 .env 环境配置文件
        const env = loadEnv(mode, process.cwd())
    
        return (
            {
                plugins: [
                    vue(),
                    createSvgIconsPlugin({
                        // 指定需要缓存的图标文件夹
                        iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
                        // 指定symbolId格式
                        symbolId: 'icon-[dir]-[name]',
                    })
                ]
            }
        )
    }
    

    5. TypeScript支持

    // tsconfig.json
    {
      "compilerOptions": {
        "types": ["vite-plugin-svg-icons/client"]
      }
    }
    

    6. 组件封装

    <!-- 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 {
       1em;
      height: 1em;
      vertical-align: -0.15em;
      overflow: hidden;
      fill: currentColor;
    }
    </style>
    

    7. 使用案例

    <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 。

    img

    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, from, 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@9.1.9
    

    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富文本编辑器

    推荐教程:Vue3 官方示例

    1. 安装wangEditor和Vue3组件

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

    2. wangEditor组件封装

    <!-- src/components/WangEditor/index.vue -->
    <template>
      <div style="border: 1px solid #ccc">
        <!-- 工具栏 -->
        <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" style="border-bottom: 1px solid #ccc" :mode="mode" />
        <!-- 编辑器 -->
        <Editor :defaultConfig="editorConfig" v-model="defaultHtml" @onChange="handleChange"
          style="height: 500px; overflow-y: hidden;" :mode="mode" @onCreated="handleCreated" />
      </div>
    </template>
    
    <script setup lang="ts">
    import { onBeforeUnmount, shallowRef, reactive, toRefs } from 'vue'
    import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
    
    // API 引用
    import { uploadFile } from "@/api/system/file";
    
    const props = defineProps({
      modelValue: {
        type: [String],
        default: ''
      },
    })
    
    const emit = defineEmits(['update:modelValue']);
    
    // 编辑器实例,必须用 shallowRef
    const editorRef = shallowRef()
    
    const state = reactive({
      toolbarConfig: {},
      editorConfig: {
        placeholder: '请输入内容...',
        MENU_CONF: {
          uploadImage: {
            // 自定义图片上传
            async customUpload(file: any, insertFn: any) {
              console.log("上传图片")
              uploadFile(file).then(response => {
                const url = response.data
                insertFn(url)
              })
            }
          }
        }
      },
      defaultHtml: props.modelValue,
      mode: 'default'
    })
    
    const { toolbarConfig, editorConfig, defaultHtml, mode } = toRefs(state)
    
    
    const handleCreated = (editor: any) => {
      editorRef.value = editor // 记录 editor 实例,重要!
    }
    
    
    function handleChange(editor: any) {
      emit('update:modelValue', editor.getHtml())
    }
    
    // 组件销毁时,也及时销毁编辑器
    onBeforeUnmount(() => {
      const editor = editorRef.value
      if (editor == null) return
      editor.destroy()
    })
    
    </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>
    

    img

    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: ''
      },
       {
        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>
    

    img

    项目源码

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

    加入我们

    如果有问题或有好的建议可以添加开发者微信,备注「有来」进入学习交流群,备注「无回」参与开发。

    开发人员 开发人员
    rui chuan
  • 相关阅读:
    JS组件系列——使用HTML标签的data属性初始化JS组件
    JS组件系列——分享自己封装的Bootstrap树形组件:jqTree
    JS组件系列——封装自己的JS组件,你也可以
    JS组件系列——Bootstrap 树控件使用经验分享
    JS组件系列——Bootstrap Select2组件使用小结
    面试干货——年底干货大放送,你准备好了吗?
    JS组件系列——基于Bootstrap Ace模板的菜单和Tab页效果分享(你值得拥有)
    JS组件系列——Form表单验证神器: BootstrapValidator
    HTTPS的加密过程
    Http知识总结
  • 原文地址:https://www.cnblogs.com/haoxianrui/p/16090029.html
Copyright © 2020-2023  润新知