5

【vue3-element-admin】基于 Vue3 + Vite4 + TypeScript + Element-Plus 从0到1搭建后...

 1 year ago
source link: https://www.cnblogs.com/haoxianrui/p/17331952.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

【vue3-element-admin】基于 Vue3 + Vite4 + TypeScript + Element-Plus 从0到1搭建后台管理系统(前后端开源@有来开源组织)

vue3-element-admin 是基于 vue-element-admin 升级的 Vue3 + Element Plus 版本的后台管理前端解决方案,技术栈为 Vue3 + Vite4 + TypeScript + Element Plus + Pinia + Vue Router 等当前主流框架。

相较于其他管理前端框架,vue3-element-admin 的优势在于一有一无 (有配套后端、无复杂封装):

  • 配套完整 Java 后端 权限管理接口,开箱即用,提供 OpenAPI 文档 搭配 Apifox 生成 Node、Python、Go等其他服务端代码;

  • 完全基于 vue-element-admin 升级的 Vue3 版本,没有对框架(Element Plus)的组件再封装,上手成本低和扩展性高。

本篇是 vue3-element-admin v2.x 版本从 0 到 1,相较于 v1.x 版本 主要增加了对原子CSS(UnoCSS)、按需自动导入、暗黑模式的支持。

阅读前的两条声明:

  • 博客有时效性,源代码会一直更新,本篇源码 tag 版本 vue3-element-admin v2.2.0

  • 各章节会有先后顺序依赖关系,例如:安装 Element Plus 需要先安装自动导入等,建议按照顺序完成0到1,当然也可各取所需。

在线预览

http://vue3.youlai.tech/

首页控制台

明亮模式
暗黑模式

接口文档

接口文档

权限管理系统

用户管理 角色管理
菜单管理 字典管理

扩展生态

youlai-mall 有来开源商城:Spring Cloud微服务+ vue3-element-admin+uni-app

youlai-mall 商品管理 mall-app 移动端
z8mCoyu3q4wXcBJ.png 3gGM2F9om4RkVtq.png
djxsAKWqzJyl9BG.png

技术栈&官网

技术栈 描述 官网
Vue3 渐进式 JavaScript 框架 https://cn.vuejs.org/
Element Plus 基于 Vue 3,面向设计师和开发者的组件库 https://element-plus.gitee.io/zh-CN/
Vite 前端开发与构建工具 https://cn.vitejs.dev/
TypeScript 微软新推出的一种语言,是 JavaScript 的超集 https://www.tslang.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-i18n Vue 国际化多语言插件 https://vue-i18n.intlify.dev/
VueUse 基于Vue组合式API的实用工具集(类比HuTool工具) http://www.vueusejs.com/

前/后端源码

名称 备注
开发工具 VSCode 下载 -
运行环境 Node 16+ 下载 image-20230224222640120
VSCode插件(必装) 插件市场搜索 Vue Language Features (Volar) TypeScript Vue Plugin (Volar) 安装,且禁用 Vetur image-20230224222541797

项目初始化

按照 🍃Vite 官方文档 - 搭建第一个 Vite 项目 说明,执行以下命令完成 vuetypescirpt 模板项目的初始化

 npm init vite@latest vue3-element-admin --template vue-ts
  • vue3-element-admin: 自定义的项目名称

  • vue-tsvue + typescript 模板的标识,查看 create-vite 以获取每个模板的更多细节:vue,vue-ts,react,react-ts

Hn6f84PMN9CJrso.png

初始化完成项目位于 D:\project\demo\vue3-element-admin , 使用 VSCode 导入,执行以下命令启动:

npm install
npm run dev
J5oLj4Sf9hHDgPv.png

浏览器访问 localhost:5173 预览

U7xGZeWbFIvEgQY.png

路径别名配置

相对路径别名配置,使用 @ 代替 src

Vite 配置

WrYa6gQG23fN14F.png

TypeScirpt 编译器配置

// tsconfig.json
"compilerOptions": {
    ...
    "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
    "paths": { // 路径映射,相对于baseUrl
    	"@/*": ["src/*"] 
    }
}

路径别名使用

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

zNsmC7erXwHDlMi.png

安装自动导入

Element Plus 官方文档中推荐 按需自动导入 的方式,而此需要使用额外的插件 unplugin-auto-importunplugin-vue-components 来导入要使用的组件。所以在整合 Element Plus 之前先了解下自动导入的概念和作用

概念

为了避免在多个页面重复引入 API组件,由此而产生的自动导入插件来节省重复代码和提高开发效率。

插件 概念 自动导入对象
unplugin-auto-import 按需自动导入API ref,reactive,watch,computed 等API
unplugin-vue-components 按需自动导入组件 Element Plus 等三方库和指定目录下的自定义组件

看下自动导入插件未使用和使用的区别:

插件名 未使用自动导入 使用自动导入
unplugin-auto-import eXMRhF2iSGAj5ZL.png IjcrXhKwHMesu3O.png
unplugin-vue-components ePrtslLAFRK4Zdc.png 3dkYAPJC8V4SQFl.png

安装插件依赖

npm install -D unplugin-auto-import unplugin-vue-components 

vite.config.ts - 自动导入配置

新建 /src/types 目录用于存放自动导入函数和组件的TS类型声明文件

import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";

plugins: [
  AutoImport({
    // 自动导入 Vue 相关函数,如:ref, reactive, toRef 等
    imports: ["vue"],
    eslintrc: {
      enabled: true, // 是否自动生成 eslint 规则,建议生成之后设置 false 
      filepath: "./.eslintrc-auto-import.json", // 指定自动导入函数 eslint 规则的文件
    },
    dts: path.resolve(pathSrc, "types", "auto-imports.d.ts"), // 指定自动导入函数TS类型声明文件路径
  }),
  Components({
    dts: path.resolve(pathSrc, "types", "components.d.ts"), // 指定自动导入组件TS类型声明文件路径
  }),
]

.eslintrc.cjs - 自动导入函数 eslint 规则引入

"extends": [
    "./.eslintrc-auto-import.json"
],

tsconfig.json - 自动导入TS类型声明文件引入

{
  "include": ["src/**/*.d.ts"]
}

自动导入效果

运行项目 npm run dev 自动

qYoTpIHuxrUV2CW.png

整合 Element Plus

参考: element plus 按需自动导入

需要完成上面一节的 自动导入 的安装和配置

安装 Element Plus

npm install element-plus

安装自动导入 Icon 依赖

npm i -D unplugin-icons

vite.config.ts 配置

参考: element-plus-best-practices - vite.config.ts

// vite.config.ts
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import Icons from "unplugin-icons/vite";
import IconsResolver from "unplugin-icons/resolve

export default ({ mode }: ConfigEnv): UserConfig => {

  return {
    plugins: [
      // ...
      AutoImport({
        // ...  
        resolvers: [
          // 自动导入 Element Plus 相关函数,如:ElMessage, ElMessageBox... (带样式)
          ElementPlusResolver(),
          // 自动导入图标组件
          IconsResolver({}),
        ]
        vueTemplate: true, // 是否在 vue 模板中自动导入
        dts: path.resolve(pathSrc, 'types', 'auto-imports.d.ts') // 自动导入组件类型声明文件位置,默认根目录
          
      }),
      Components({ 
        resolvers: [
          // 自动导入 Element Plus 组件
          ElementPlusResolver(),
          // 自动注册图标组件
          IconsResolver({
            enabledCollections: ["ep"] // element-plus图标库,其他图标库 https://icon-sets.iconify.design/
          }),
        ],
        dts: path.resolve(pathSrc, "types", "components.d.ts"), //  自动导入组件类型声明文件位置,默认根目录
      }),
      Icons({
        // 自动安装图标库
        autoInstall: true,
      }),
    ],
  };
};

示例代码

<!-- src/components/HelloWorld.vue -->
<div>
  <el-button type="success"><i-ep-SuccessFilled />Success</el-button>
  <el-button type="info"><i-ep-InfoFilled />Info</el-button>
  <el-button type="warning"><i-ep-WarningFilled />Warning</el-button>
  <el-button type="danger"><i-ep-WarnTriangleFilled />Danger</el-button>
</div>

效果预览

h9mYr1oxa2LgJpU.png

整合 SVG 图标

通过 vite-plugin-svg-icons 插件整合 Iconfont 第三方图标库实现本地图标

参考: vite-plugin-svg-icons 安装文档

npm install -D [email protected] 
npm install -D [email protected] 

创建 src/assets/icons 目录 , 放入从 Iconfont 复制的 svg 图标

mdjJaGFUsqKHknW.png

main.ts 引入注册脚本

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

vite.config.ts 配置插件

// vite.config.ts
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';

export default ({command, mode}: ConfigEnv): UserConfig => {
 return (
     {
         plugins: [
             createSvgIconsPlugin({
                 // 指定需要缓存的图标文件夹
                 iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
                 // 指定symbolId格式
                 symbolId: 'icon-[dir]-[name]',
             })
         ]
     }
 )
}

SVG 组件封装

<!-- src/components/SvgIcon/index.vue -->
<script setup lang="ts">
const props = defineProps({
  prefix: {
    type: String,
    default: "icon",
  },
  iconClass: {
    type: String,
    required: false,
  },
  color: {
    type: String,
  },
  size: {
    type: String,
    default: "1em",
  },
});

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

<template>
  <svg
    aria-hidden="true"
    class="svg-icon"
    :style="'width:' + size + ';height:' + size"
  >
    <use :xlink:href="symbolId" :fill="color" />
  </svg>
</template>

<style scoped>
.svg-icon {
  display: inline-block;
  outline: none;
  width: 1em;
  height: 1em;
  vertical-align: -0.15em; /* 因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果 */
  fill: currentColor; /* 定义元素的颜色,currentColor是一个变量,这个变量的值就表示当前元素的color值,如果当前元素未设置color值,则从父元素继承 */
  overflow: hidden;
}
</style>
<!-- src/components/HelloWorld.vue -->
<template>
 <el-button type="info"><svg-icon icon-class="block"/>SVG 本地图标</el-button>
</template>

DeGXnwBRLH4hp8Z.png

整合 SCSS

一款CSS预处理语言,SCSS 是 Sass 3 引入新的语法,其语法完全兼容 CSS3,并且继承了 Sass 的强大功能。

npm i -D sass 

创建 variables.scss 变量文件,添加变量 $bg-color 定义,注意规范变量以 $ 开头

// src/styles/variables.scss
$bg-color:#242424;

Vite 配置导入 SCSS 全局变量文件

// vite.config.ts
css: {
    // CSS 预处理器
    preprocessorOptions: {
        //define global scss variable
        scss: {
            javascriptEnabled: true,
            additionalData: `@use "@/styles/variables.scss" as *;`
        }
    }
}

style 标签使用SCSS全局变量

<!-- src/components/HelloWorld.vue -->
<template>
  <div class="box" />
</template>

<style lang="scss" scoped>
.box {
  width: 100px;
  height: 100px;
  background-color: $bg-color;
}
</style>

上面导入的 SCSS 全局变量在 TypeScript 不生效的,需要创建一个以 .module.scss 结尾的文件

// src/styles/variables.module.scss

// 导出 variables.scss 文件的变量
:export{
    bgColor:$bg-color
}

TypeScript 使用 SCSS 全局变量

<!-- src/components/HelloWorld.vue -->
<script setup lang="ts">
  import variables from "@/styles/variables.module.scss";
  console.log(variables.bgColor)  
</script>

<template>
  <div style="width:100px;height:100px" :style="{ 'background-color': variables.bgColor }" />
</template>

整合 UnoCSS

UnoCSS 是一个具有高性能且极具灵活性的即时原子化 CSS 引擎 。

参考:Vite 安装 UnoCSS 官方文档

npm install -D unocss

vite.config.ts 配置

// vite.config.ts
import UnoCSS from 'unocss/vite'

export default {
  plugins: [
    UnoCSS({ /* options */ }),
  ],
}

main.ts 引入 uno.css

// src/main.ts
import 'uno.css'

VSCode 安装 UnoCSS 插件

L9WazROfvsIPqJB.png

再看下具体使用方式和实际效果:

代码 效果
IFdozf4xapyVEkj.png image-20230222220856251

如果UnoCSS 插件智能提示不生效,请参考:VSCode插件UnoCSS智能提示不生效解决

整合 Pinia

Pinia 是 Vue 的专属状态管理库,它允许你跨组件或页面共享状态。

参考:Pinia 官方文档

安装依赖

npm install pinia

main.ts 引入 pinia

// src/main.ts
import { createPinia } from "pinia";
import App from "./App.vue";

createApp(App).use(createPinia()).mount("#app");

定义 Store

根据 Pinia 官方文档-核心概念 描述 ,Store 定义分为选项式组合式 , 先比较下两种写法的区别:

选项式 Option Store 组合式 Setup Store
nToq6WgSVvM1QxP.png RuJ9SqtL6xCVg3Z.png

至于如何选择,官方给出的建议 :选择你觉得最舒服的那一个就好

这里选择组合式,新建文件 src/store/counter.ts

// src/store/counter.ts
import { defineStore } from "pinia";

export const useCounterStore = defineStore("counter", () => {
  // ref变量 → state 属性
  const count = ref(0);
  // computed计算属性 → getters
  const double = computed(() => {
    return count.value * 2;
  });
  // function函数 → actions
  function increment() {
    count.value++;
  }

  return { count, double, increment };
});

父组件

<!-- src/App.vue -->
<script setup lang="ts">
import HelloWorld from "@/components/HelloWorld.vue";

import { useCounterStore } from "@/store/counter";
const counterStore = useCounterStore();
</script>

<template>
  <h1 class="text-3xl">vue3-element-admin-父组件</h1>
  <el-button type="primary" @click="counterStore.increment">count++</el-button>
  <HelloWorld />
</template>

子组件

<!-- src/components/HelloWorld.vue -->
<script setup lang="ts">
import { useCounterStore } from "@/store/counter";
const counterStore = useCounterStore();
</script>

<template>
  <el-card  class="text-left text-white border-white border-1 border-solid mt-10 bg-[#242424]" >
    <template #header> 子组件 HelloWorld.vue</template>
    <el-form>
      <el-form-item label="数字:"> {{ counterStore.count }}</el-form-item>
      <el-form-item label="加倍:"> {{ counterStore.double }}</el-form-item>
    </el-form>
  </el-card>
</template>

效果预览

gPv7CyoYIJQSDWO.gif

Vite 环境变量主要是为了区分开发、测试、生产等环境的变量

参考: Vite 环境变量配置官方文档

env配置文件

项目根目录新建 .env.development.env.production

  • 开发环境变量配置:.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'
    

环境变量智能提示

新建 src/types/env.d.ts文件存放环境变量TS类型声明

// src/types/env.d.ts
interface ImportMetaEnv {
  /**
   * 应用标题
   */
  VITE_APP_TITLE: string;
  /**
   * 应用端口
   */
  VITE_APP_PORT: number;
  /**
   * API基础路径(反向代理)
   */
  VITE_APP_BASE_API: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

使用自定义环境变量就会有智能提示,环境变量的读取和使用请看下一节的跨域处理中的 vite.config.ts的配置。

Cu9KVtz7ghOrqZb.png

跨域原理

浏览器同源策略: 协议、域名和端口都相同是同源,浏览器会限制非同源请求读取响应结果。

本地开发环境通过 Vite 配置反向代理解决浏览器跨域问题,生产环境则是通过 nginx 配置反向代理 。

vite.config.ts 配置代理

7HwTFWXPCvVx9pO.png

表面肉眼看到的请求地址: http://localhost:3000/dev-api/api/v1/users/me

真实访问的代理目标地址: http://vapi.youlai.tech/api/v1/users/me

QZ3DiKtFuLJm68E.png

整合 Axios

Axios 基于promise可以用于浏览器和node.js的网络请求库

参考: Axios 官方文档

安装依赖

npm install axios

Axios 工具类封装

//  src/utils/request.ts
import axios, { InternalAxiosRequestConfig, AxiosResponse } from 'axios';
import { useUserStoreHook } from '@/store/modules/user';

// 创建 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: InternalAxiosRequestConfig) => {
    const userStore = useUserStoreHook();
    if (userStore.token) {
      config.headers.Authorization = userStore.token;
    }
    return config;
  },
  (error: any) => {
    return Promise.reject(error);
  }
);

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, msg } = response.data;
    // 登录成功
    if (code === '00000') {
      return response.data;
    }

    ElMessage.error(msg || '系统出错');
    return Promise.reject(new Error(msg || 'Error'));
  },
  (error: any) => {
    if (error.response.data) {
      const { code, msg } = error.response.data;
      // token 过期,跳转登录页
      if (code === 'A0230') {
        ElMessageBox.confirm('当前页面已失效,请重新登录', '提示', {
          confirmButtonText: '确定',
          type: 'warning'
        }).then(() => {
          localStorage.clear(); // @vueuse/core 自动导入
          window.location.href = '/';
        });
      }else{
          ElMessage.error(msg || '系统出错');
      }
    }
    return Promise.reject(error.message);
  }
);

// 导出 axios 实例
export default service;

登录接口实战

访问 vue3-element-admin 在线接口文档, 查看登录接口请求参数和响应数据类型

UQJhmX3aNb4rxiG.png

点击 生成代码 获取登录响应数据 TypeScript 类型定义

tDMe3hE5iwpZYmA.png

将类型定义复制到 src/api/auth/types.ts 文件中

/**
 * 登录请求参数
 */
export interface LoginData {
  /**
   * 用户名
   */
  username: string;
  /**
   * 密码
   */
  password: string;
}

/**
 * 登录响应
 */
export interface LoginResult {
  /**
   * 访问token
   */
  accessToken?: string;
  /**
   * 过期时间(单位:毫秒)
   */
  expires?: number;
  /**
   * 刷新token
   */
  refreshToken?: string;
  /**
   * token 类型
   */
  tokenType?: string;
}

登录 API 定义

// src/api/auth/index.ts
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { LoginData, LoginResult } from './types';

/**
 * 登录API 
 * 
 * @param data {LoginData}
 * @returns
 */
export function loginApi(data: LoginData): AxiosPromise<LoginResult> {
  return request({
    url: '/api/v1/auth/login',
    method: 'post',
    params: data
  });
}

登录 API 调用

// src/store/modules/user.ts
import { loginApi } from '@/api/auth';
import { LoginData } from '@/api/auth/types';

/**
 * 登录调用
 *
 * @param {LoginData}
 * @returns
 */
function login(loginData: LoginData) {
  return new Promise<void>((resolve, reject) => {
    loginApi(loginData)
      .then(response => {
        const { tokenType, accessToken } = response.data;
        token.value = tokenType + ' ' + accessToken; // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx
        resolve();
      })
      .catch(error => {
        reject(error);
      });
  });
}

安装 vue-router

npm install vue-router@next

路由实例

创建路由实例,顺带初始化静态路由,而动态路由需要用户登录,根据用户拥有的角色进行权限校验后进行初始化

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

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

// 静态路由
export const constantRoutes: 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: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        name: 'Dashboard',
        meta: { title: 'dashboard', icon: 'homepage', affix: true }
      }
    ]
  }
];

/**
 * 创建路由
 */
const router = createRouter({
  history: createWebHashHistory(),
  routes: constantRoutes as RouteRecordRaw[],
  // 刷新时,滚动条位置还原
  scrollBehavior: () => ({ left: 0, top: 0 })
});

/**
 * 重置路由
 */
export function resetRouter() {
  router.replace({ path: '/login' });
  location.reload();
}

export default router;

全局注册路由实例

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

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

动态权限路由

路由守卫 src/permission.ts ,获取当前登录用户的角色信息进行动态路由的初始化

CYWVvHZsKXRyEnj.png

最终调用 permissionStore.generateRoutes(roles) 方法生成动态路由

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

export const usePermissionStore = defineStore('permission', () => {
  const routes = ref<RouteRecordRaw[]>([]);

  function setRoutes(newRoutes: RouteRecordRaw[]) {
    routes.value = constantRoutes.concat(newRoutes);
  }
  /**
   * 生成动态路由
   *
   * @param roles 用户角色集合
   * @returns
   */
  function generateRoutes(roles: string[]) {
    return new Promise<RouteRecordRaw[]>((resolve, reject) => {
      // 接口获取所有路由
      listRoutes()
        .then(({ data: asyncRoutes }) => {
          // 根据角色获取有访问权限的路由
          const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
          setRoutes(accessedRoutes);
          resolve(accessedRoutes);
        })
        .catch(error => {
          reject(error);
        });
    });
  }
  // 导出 store 的动态路由数据 routes 
  return { routes, setRoutes, generateRoutes };
});

接口获取得到的路由数据

ZNVAcKPfy8BvDSQ.png

根据路由数据 (routes)生成菜单的关键代码

src/layout/componets/Sidebar/index.vue src/layout/componets/Sidebar/SidebarItem.vue
xQWsuCc87p16U2O.png image-20230326145836872

除了 Vue 内置的一系列指令 (比如 v-modelv-show) 之外,Vue 还允许你注册自定义的指令 (Custom Directives),以下就通过自定义指令的方式实现按钮权限控制。

参考:Vue 官方文档-自定义指令

自定义指令

// src/directive/permission/index.ts

import { useUserStoreHook } from '@/store/modules/user';
import { Directive, DirectiveBinding } from 'vue';

/**
 * 按钮权限
 */
export const hasPerm: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    // 「超级管理员」拥有所有的按钮权限
    const { roles, perms } = useUserStoreHook();
    if (roles.includes('ROOT')) {
      return true;
    }
    // 「其他角色」按钮权限校验
    const { value } = binding;
    if (value) {
      const requiredPerms = value; // DOM绑定需要的按钮权限标识

      const hasPerm = 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']\""
      );
    }
  }
};

全局注册自定义指令

// src/directive/index.ts
import type { App } from 'vue';

import { hasPerm } from './permission';

// 全局注册 directive 方法
export function setupDirective(app: App<Element>) {
  // 使 v-hasPerm 在所有组件中都可用
  app.directive('hasPerm', hasPerm);
}
// src/main.ts
import { setupDirective } from '@/directive';

const app = createApp(App);
// 全局注册 自定义指令(directive)
setupDirective(app);

组件使用自定义指令

// 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 框架国际化(官方提供了国际化方式)和自定义国际化(通过 vue-i18n 国际化插件)

Element Plus 国际化

简单的使用方式请参考 Element Plus 官方文档-国际化示例,以下介绍 vue3-element-admin 整合 pinia 实现国际化语言切换。

Element Plus 提供了一个 Vue 组件 ConfigProvider 用于全局配置国际化的设置。

<!-- src/App.vue -->
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus';
import { useAppStore } from '@/store/modules/app';
const appStore = useAppStore();
</script>

<template>
  <el-config-provider :locale="appStore.locale" >
    <router-view />
  </el-config-provider>
</template>

定义 store

// src/store/modules/app.ts
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core';
import defaultSettings from '@/settings';

// 导入 Element Plus 中英文语言包
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import en from 'element-plus/es/locale/lang/en';

// setup
export const useAppStore = defineStore('app', () => {
    
  const language = useStorage('language', defaultSettings.language);
    
  /**
   * 根据语言标识读取对应的语言包
   */
  const locale = computed(() => {
    if (language?.value == 'en') {
      return en;
    } else {
      return zhCn;
    }
  });

  /**
   * 切换语言
   */
  function changeLanguage(val: string) {
    language.value = val;
  }

  return {
    language,
    locale,
    changeLanguage
  };
});

切换语言组件调用

<!-- src/components/LangSelect/index.vue -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useAppStore } from '@/store/modules/app';

const appStore = useAppStore();
const { locale } = useI18n();

function handleLanguageChange(lang: string) {
  locale.value = lang;
  appStore.changeLanguage(lang);
  if (lang == 'en') {
    ElMessage.success('Switch Language Successful!');
  } else {
    ElMessage.success('切换语言成功!');
  }
}
</script>

<template>
  <el-dropdown trigger="click" @command="handleLanguageChange">
    <div>
      <svg-icon icon-class="language" />
    </div>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item
          :disabled="appStore.language === 'zh-cn'"
          command="zh-cn"
        >
          中文
        </el-dropdown-item>
        <el-dropdown-item :disabled="appStore.language === 'en'" command="en">
          English
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

Element Plus 分页组件看下国际化的效果

ukwC3xniMhd6gTe.png
G4pFTioh5gUYbPt.png

vue-i18n 自定义国际化

i18n 英文全拼 internationalization ,国际化的意思,英文 i 和 n 中间18个英文字母

参考:vue-i18n 官方文档 - installation

安装 vue-i18n

npm install vue-i18n@9

自定义语言包

创建 src/lang/package 语言包目录,存放自定义的语言文件

中文语言包 zh-cn.ts 英文语言包 en.ts
FvOGjXaAsEHY6W1.png 2nHIlvEZcht6jgU.png

创建 i18n 实例

// src/lang/index.ts
import { createI18n } from 'vue-i18n';
import { useAppStore } from '@/store/modules/app';

const appStore = useAppStore();
// 本地语言包
import enLocale from './package/en';
import zhCnLocale from './package/zh-cn';

const messages = {
  'zh-cn': {
    ...zhCnLocale
  },
  en: {
    ...enLocale
  }
};
// 创建 i18n 实例
const i18n = createI18n({
  legacy: false,
  locale: appStore.language,
  messages: messages
});
// 导出 i18n 实例
export default i18n;

i18n 全局注册

// main.ts

// 国际化
import i18n from '@/lang/index';

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

登录页面国际化使用

$t 是 i18n 提供的根据 key 从语言包翻译对应的 value 方法

<span>{{ $t("login.title") }}</span>

在登录页面 src/view/login/index.vue 查看如何使用

效果预览

dLWTNYzGnjwAmcJ.gif

Element Plus 2.2.0 版本开始支持暗黑模式,启用方式参考 Element Plus 官方文档 - 暗黑模式, 官方也提供了示例 element-plus-vite-starter 模版

这里根据官方文档和示例讲述 vue3-element-admin 是如何使用 VueUse 的 useDark 方法实现暗黑模式的动态切换。

导入 Element Plus 暗黑模式变量

// src/main.ts
import 'element-plus/theme-chalk/dark/css-vars.css'

切换暗黑模式设置

<!-- src/layout/components/Settings/index.vue -->
<script setup lang="ts">

import IconEpSunny from '~icons/ep/sunny';
import IconEpMoon from '~icons/ep/moon';

/**
 * 暗黑模式
 */
const settingsStore = useSettingsStore();
const isDark = useDark();
const toggleDark = () => useToggle(isDark);

</script>

<template>
  <div class="settings-container">
    <h3 class="text-base font-bold">项目配置</h3>
    <el-divider>主题</el-divider>

    <div class="flex justify-center" @click.stop>
      <el-switch
        v-model="isDark"
        @change="toggleDark"
        inline-prompt
        :active-icon="IconEpMoon"
        :inactive-icon="IconEpSunny"
        active-color="var(--el-fill-color-dark)"
        inactive-color="var(--el-color-primary)"
      />
    </div>
  </div>
</template>

自定义变量

除了 Element Plus 组件样式之外,应用中还有很多自定义的组件和样式,像这样的:

jFrM9s4egZaqTEv.png

应对自定义组件样式实现暗黑模式步骤如下:

新建 src/styles/dark.scss

html.dark {
  /* 修改自定义元素的样式 */   
  .navbar {
    background-color: #141414;
  }
}

在 Element Plus 的样式之后导入它

// main.ts
import 'element-plus/theme-chalk/dark/css-vars.css'
import '@/styles/dark.scss';

效果预览

lWnIAqSZdpBU5EP.gif

wangEditor 富文本

参考: wangEditor 官方文档

安装 wangEditor

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

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 { Editor, Toolbar } from "@wangeditor/editor-for-vue";

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

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

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

const defaultHtml = useVModel(props, "modelValue", emit);

const editorRef = shallowRef(); // 编辑器实例,必须用 shallowRef
const mode = ref("default"); // 编辑器模式
const toolbarConfig = ref({}); // 工具条配置
// 编辑器配置
const editorConfig = ref({
  placeholder: "请输入内容...",
  MENU_CONF: {
    uploadImage: {
      // 自定义图片上传
      async customUpload(file: any, insertFn: any) {
        uploadFileApi(file).then((response) => {
          const url = response.data.url;
          insertFn(url);
        });
      },
    },
  },
});

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>

使用案例

<!-- wangEditor富文本编辑器示例 -->
<script setup lang="ts">
import Editor from '@/components/WangEditor/index.vue';
const value = ref('初始内容');
</script>

<template>
  <div class="app-container">
    <editor v-model="value" style="height: 600px" />
  </div>
</template>

效果预览

du7sTl69h3I5bjx.png

Echarts 图表

参考:📊 Echarts 官方示例

安装 Echarts

npm install echarts

组件封装

<!-- src/views/dashboard/components/Chart/BarChart.vue --> 
<template>
  <el-card>
    <template #header> 线 + 柱混合图 </template>
    <div :id="id" :class="className" :style="{ height, width }" />
  </el-card>
</template>

<script setup lang="ts">
import * as echarts from 'echarts';

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 options = {
  grid: {
    left: '2%',
    right: '2%',
    bottom: '10%',
    containLabel: true
  },
  tooltip: {
    trigger: 'axis',
    axisPointer: {
      type: 'cross',
      crossStyle: {
        color: '#999'
      }
    }
  },
  legend: {
    x: 'center',
    y: 'bottom',
    data: ['收入', '毛利润', '收入增长率', '利润增长率'],
    textStyle: {
      color: '#999'
    }
  },
  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: [7000, 7100, 7200, 7300, 7400],
      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: [8000, 8200, 8400, 8600, 8800],
      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: [60, 65, 70, 75, 80],
      itemStyle: {
        color: '#67C23A'
      }
    },
    {
      name: '利润增长率',
      type: 'line',
      yAxisIndex: 1,
      data: [70, 75, 80, 85, 90],
      itemStyle: {
        color: '#409EFF'
      }
    }
  ]
};

onMounted(() => {
  // 图表初始化
  const chart = echarts.init(
    document.getElementById(props.id) as HTMLDivElement
  );
  chart.setOption(options);

  // 大小自适应
  window.addEventListener('resize', () => {
    chart.resize();
  });
});
</script>

组件使用

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

<template>
  <BarChart id="barChart" height="400px"width="300px" />
</template>

效果预览

ZADo7kyIKeHVmtR.png

图标选择器

组件封装

<!-- src/components/IconSelect/index.vue -->
<script setup lang="ts">
const props = defineProps({
  modelValue: {
    type: String,
    require: false
  }
});

const emit = defineEmits(['update:modelValue']);
const inputValue = toRef(props, 'modelValue');

const visible = ref(false); // 弹窗显示状态

const iconNames: string[] = []; // 所有的图标名称集合

const filterValue = ref(''); // 筛选的值
const filterIconNames = ref<string[]>([]); // 过滤后的图标名称集合

const iconSelectorRef = ref(null);
/**
 * 加载 ICON
 */
function loadIcons() {
  const icons = import.meta.glob('../../assets/icons/*.svg');
  for (const icon in icons) {
    const iconName = icon.split('assets/icons/')[1].split('.svg')[0];
    iconNames.push(iconName);
  }
  filterIconNames.value = iconNames;
}

/**
 * 筛选图标
 */
function handleFilter() {
  if (filterValue.value) {
    filterIconNames.value = iconNames.filter(iconName =>
      iconName.includes(filterValue.value)
    );
  } else {
    filterIconNames.value = iconNames;
  }
}

/**
 * 选择图标
 */
function handleSelect(iconName: string) {
  emit('update:modelValue', iconName);
  visible.value = false;
}

/**
 * 点击容器外的区域关闭弹窗 VueUse onClickOutside
 */
onClickOutside(iconSelectorRef, () => (visible.value = false));

onMounted(() => {
  loadIcons();
});
</script>

<template>
  <div class="iconselect-container" ref="iconSelectorRef">
    <el-input
      v-model="inputValue"
      readonly
      @click="visible = !visible"
      placeholder="点击选择图标"
    >
      <template #prepend>
        <svg-icon :icon-class="inputValue" />
      </template>
    </el-input>

    <el-popover
      shadow="none"
      :visible="visible"
      placement="bottom-end"
      trigger="click"
      width="400"
    >
      <template #reference>
        <div
          @click="visible = !visible"
          class="cursor-pointer text-[#999] absolute right-[10px] top-0 height-[32px] leading-[32px]"
        >
          <i-ep-caret-top v-show="visible"></i-ep-caret-top>
          <i-ep-caret-bottom v-show="!visible"></i-ep-caret-bottom>
        </div>
      </template>

      <!-- 下拉选择弹窗 -->
      <el-input
        class="p-2"
        v-model="filterValue"
        placeholder="搜索图标"
        clearable
        @input="handleFilter"
      />
      <el-divider border-style="dashed" />

      <el-scrollbar height="300px">
        <ul class="icon-list">
          <li
            class="icon-item"
            v-for="(iconName, index) in filterIconNames"
            :key="index"
            @click="handleSelect(iconName)"
          >
            <el-tooltip :content="iconName" placement="bottom" effect="light">
              <svg-icon
                color="var(--el-text-color-regular)"
                :icon-class="iconName"
              />
            </el-tooltip>
          </li>
        </ul>
      </el-scrollbar>
    </el-popover>
  </div>
</template>

组件使用

<!-- src/views/demo/IconSelect.vue -->
<script setup lang="ts">
const iconName = ref('edit');
</script>

<template>
  <div class="app-container">
    <icon-select v-model="iconName" />
  </div>
</template>

效果预览

v5URbkKNHzB3oaq.gif

代码统一规范

【vue3-element-admin】ESLint+Prettier+Stylelint+EditorConfig 约束和统一前端代码规范

  • Eslint: JavaScript 语法规则和代码风格检查;
  • Stylelint: CSS 统一规范和代码检测;
  • Prettier:全局代码格式化。

Git 提交规范

【vue3-element-admin】Husky + Lint-staged + Commitlint + Commitizen + cz-git 配置 Git 提交规范

  • Husky + Lint-staged 整合实现 Git 提交前代码规范检测/格式化;
  • Husky + Commitlint + Commitizen + cz-git 整合实现生成规范化且高度自定义的 Git commit message。
# 安装 pnpm
npm install pnpm -g

# 安装依赖
pnpm install

# 项目运行
pnpm run dev
# 项目打包
pnpm run build:prod

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

1: defineProps is not defined

  • 问题描述

    'defineProps' is not defined.eslint no-undef

    ur7Mpv3HK6Yjh2x.png

  • 解决方案

    根据 Eslint 官方解决方案描述,解析器使用 vue-eslint-parser v9.0.0 + 版本

    bRkofHDOZW7aChe.png

    安装 vue-eslint-parser 解析器

    npm install -D vue-eslint-parser
    

    UmV1xQXKoCHSwdD.png

    .eslintrc.js 关键配置( v9.0.0 及以上版本无需配置编译宏 vue/setup-compiler-macros)如下 :

      parser: 'vue-eslint-parser',
      extends: [
        'eslint:recommended',
    	// ...		
      ],
    

    重启 VSCode 已无报错提示

    47agxCwUoeE6VMn.png

2: Vite 首屏加载慢(白屏久)

  • 问题描述

    Vite 项目启动很快,但首次打开界面加载慢?

    参考文章:为什么有人说 vite 快,有人却说 vite 慢

    vite 启动时,并不像 webpack 那样做一个全量的打包构建,所以启动速度非常快。启动以后,浏览器发起请求时, Dev Server 要把请求需要的资源发送给浏览器,中间需要经历预构建、对请求文件做路径解析、加载源文件、对源文件做转换,然后才能把内容返回给浏览器,这个时间耗时蛮久的,导致白屏时间较长。

    解决方案升级 vite 4.3 版本
    https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md

    745V1n8CshWJwFo.png

如果交流群二维码过期,请添加我的微信备注 前端全栈 拉你进群

微信交流群 我的微信 微信公众号
FYKL3TRAWipGbwo.jpg yRx8uzj4emA5QVr.jpg OGjum9wr8f6idLX.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK