加载中...

Vue3.3 + Vite4.3 + Element-Plus + TypeScript 从0到1搭建企业级后台管理系统(前后端开源)

Vue3.3 + Vite4.3 + Element-Plus + TypeScript 从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)、按需自动导入、暗黑模式的支持。

项目源码

前后端源码GiteeGithub
前端vue3-element-adminvue3-element-admin
后端youlai-bootyoulai-boot

项目预览

在线预览

vue3.youlai.tech/

首页控制台

New Image

接口文档

New Image

权限管理系统

New ImageNew Image
New ImageNew Image

扩展生态

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

youlai-mall 商品管理mall-app 移动端
New ImageNew Image

项目指南

  • 功能清单

New Image

  • 技术栈
技术栈描述官网
Vue3渐进式 JavaScript 框架cn.vuejs.org/
Element Plus基于 Vue 3,面向设计师和开发者的组件库element-plus.gitee.io/zh-CN/
Vite前端开发与构建工具cn.vitejs.dev/
TypeScript微软新推出的一种语言,是 JavaScript 的超集www.tslang.cn/
Pinia新一代状态管理工具pinia.vuejs.org/
Vue RouterVue.js 的官方路由router.vuejs.org/zh/
wangEditorTypescript 开发的 Web 富文本编辑器www.wangeditor.com/
Echarts一个基于 JavaScript 的开源可视化图表库echarts.apache.org/zh/
vue-i18nVue 国际化多语言插件vue-i18n.intlify.dev/
VueUse基于Vue组合式API的实用工具集(类比HuTool工具)www.vueusejs.com/

环境准备

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

vite 项目初始化

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

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

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

New Image

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

bash
复制代码
npm install npm run dev

New Image

浏览器访问 localhost:5173 预览

New Image

src 路径别名配置

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

Vite 配置

配置 vite.config.ts

New Image 安装@types/node

import path from ''path''编译器报错:TS2307: Cannot find module ''path'' or its corresponding type declarations.

本地安装 Node 的 TypeScript 类型描述文件即可解决编译器报错

bash
复制代码
npm install @types/node --save-dev

TypeScript 编译配置

同样还是import path from ''path'' 编译报错: TS1259: Module ''"path"'' can only be default-imported using the ''allowSyntheticDefaultImports'' flag

因为 typescript 特殊的 import 方式 , 需要配置允许默认导入的方式,还有路径别名的配置

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

路径别名使用

typescript
复制代码
// src/App.vue import HelloWorld from ''/src/components/HelloWorld.vue'' import HelloWorld from ''@/components/HelloWorld.vue''

New Image

unplugin 自动导入

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

概念

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

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

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

插件名未使用自动导入使用自动导入
unplugin-auto-importNew ImageNew Image
unplugin-vue-componentsNew ImageNew Image

安装插件依赖

bash
复制代码
npm install -D unplugin-auto-import unplugin-vue-components

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

先创建好 /src/types 目录用于存放自动导入函数和组件的TS类型声明文件,再进行自动导入配置

typescript
复制代码
// vite.config.ts import AutoImport from "unplugin-auto-import/vite"; import Components from "unplugin-vue-components/vite"; import path from "path"; const pathSrc = path.resolve(__dirname, "src"); 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 规则引入

javascript
复制代码
"extends": [ "./.eslintrc-auto-import.json" ],

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

json
复制代码
{ "include": ["src/**/*.d.ts"] }

自动导入效果

运行项目 npm run dev 自动

New Image

整合 Element Plus

参考: element plus 按需自动导入

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

安装 Element Plus

bash
复制代码
npm install element-plus

安装自动导入 Icon 依赖

bash
复制代码
npm i -D unplugin-icons

vite.config.ts 配置

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

typescript
复制代码
// vite.config.ts import { ElementPlusResolver } from "unplugin-vue-components/resolvers"; import Icons from "unplugin-icons/vite"; import IconsResolver from "unplugin-icons/resolver"; 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, }), ], }; };

示例代码

html
复制代码
<!-- 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>

效果预览

New Image

整合 SVG 图标

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

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

安装依赖

bash
复制代码
npm install -D fast-glob@3.2.11 npm install -D vite-plugin-svg-icons@2.0.1

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

New Image

main.ts 引入注册脚本

typescript
复制代码
// src/main.ts import ''virtual:svg-icons-register'';

vite.config.ts 配置插件

typescript
复制代码
// 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 组件封装

html
复制代码
<!-- 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>

组件使用

html
复制代码
<!-- src/components/HelloWorld.vue --> <template> <el-button type="info"><svg-icon icon-class="block"/>SVG 本地图标</el-button> </template>

New Image

整合 SCSS

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

安装依赖

bash
复制代码
npm i -D sass

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

scss
复制代码
// src/styles/variables.scss $bg-color:#242424;

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

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

style 标签使用SCSS全局变量

html
复制代码
<!-- 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 结尾的文件

scss
复制代码
// src/styles/variables.module.scss // 导出 variables.scss 文件的变量 :export{ bgColor:$bg-color }

TypeScript 使用 SCSS 全局变量

html
复制代码
<!-- 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 官方文档

安装依赖

bash
复制代码
npm install -D unocss

vite.config.ts 配置

typescript
复制代码
// vite.config.ts import UnoCSS from ''unocss/vite'' export default { plugins: [ UnoCSS({ /* options */ }), ], }

main.ts 引入 uno.css

typescript
复制代码
// src/main.ts import ''uno.css''

VSCode 安装 UnoCSS 插件

New Image

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

代码效果
New ImageNew Image

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

整合 Pinia

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

参考:Pinia 官方文档

安装依赖

bash
复制代码
npm install pinia

main.ts 引入 pinia

typescript
复制代码
// 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
New ImageNew Image

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

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

typescript
复制代码
// 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 }; });

父组件

html
复制代码
<!-- 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>

子组件

html
复制代码
<!-- 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>

效果预览

New Image

环境变量

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

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

env配置文件

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

  • 开发环境变量配置:.env.development

    properties
    复制代码
    # 变量必须以 VITE_ 为前缀才能暴露给外部读取 VITE_APP_TITLE = ''vue3-element-admin'' VITE_APP_PORT = 3000 VITE_APP_BASE_API = ''/dev-api''
  • 生产环境变量配置:.env.production

    properties
    复制代码
    VITE_APP_TITLE = ''vue3-element-admin'' VITE_APP_PORT = 3000 VITE_APP_BASE_API = ''/prod-api''

环境变量智能提示

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

typescript
复制代码
// 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的配置。

New Image

反向代理解决跨域

跨域原理

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

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

vite.config.ts 配置代理

New Image

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

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

New Image

整合 Axios

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

参考: Axios 官方文档

安装依赖

bash
复制代码
npm install axios

Axios 工具类封装

typescript
复制代码
// 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 在线接口文档, 查看登录接口请求参数和响应数据类型

New Image

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

New Image

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

typescript
复制代码
/** * 登录请求参数 */ export interface LoginData { /** * 用户名 */ username: string; /** * 密码 */ password: string; } /** * 登录响应 */ export interface LoginResult { /** * 访问token */ accessToken?: string; /** * 过期时间(单位:毫秒) */ expires?: number; /** * 刷新token */ refreshToken?: string; /** * token 类型 */ tokenType?: string; }

登录 API 定义

typescript
复制代码
// 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 调用

typescript
复制代码
// 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 动态路由

安装 vue-router

bash
复制代码
npm install vue-router@next

路由实例

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

typescript
复制代码
// 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;

全局注册路由实例

typescript
复制代码
// main.ts import router from "@/router"; app.use(router).mount(''#app'')

动态权限路由

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

New Image

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

typescript
复制代码
// 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 }; });

接口获取得到的路由数据

New Image

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

src/layout/componets/Sidebar/index.vuesrc/layout/componets/Sidebar/SidebarItem.vue
New ImageNew Image

按钮权限

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

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

自定义指令

typescript
复制代码
// 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'']\"" ); } } };

全局注册自定义指令

typescript
复制代码
// 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); }
typescript
复制代码
// src/main.ts import { setupDirective } from ''@/directive''; const app = createApp(App); // 全局注册 自定义指令(directive) setupDirective(app);

组件使用自定义指令

html
复制代码
// 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 用于全局配置国际化的设置。

html
复制代码
<!-- 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

typescript
复制代码
// 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 }; });

切换语言组件调用

html
复制代码
<!-- 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 分页组件看下国际化的效果

New Image

New Image

vue-i18n 自定义国际化

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

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

安装 vue-i18n

bash
复制代码
npm install vue-i18n@9

自定义语言包

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

中文语言包 zh-cn.ts英文语言包 en.ts
New ImageNew Image

创建 i18n 实例

typescript
复制代码
// 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 全局注册

typescript
复制代码
// main.ts // 国际化 import i18n from ''@/lang/index''; app.use(i18n).mount(''#app'');

登录页面国际化使用

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

html
复制代码
<span>{{ $t("login.title") }}</span>

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

效果预览

New Image

暗黑模式

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

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

导入 Element Plus 暗黑模式变量

typescript
复制代码
// src/main.ts import ''element-plus/theme-chalk/dark/css-vars.css''

切换暗黑模式设置

html
复制代码
<!-- 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 组件样式之外,应用中还有很多自定义的组件和样式,像这样的:

New Image

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

新建 src/styles/dark.scss

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

在 Element Plus 的样式之后导入它

typescript
复制代码
// main.ts import ''element-plus/theme-chalk/dark/css-vars.css'' import ''@/styles/dark.scss'';

效果预览

New Image

组件封装

wangEditor 富文本

参考: wangEditor 官方文档

安装 wangEditor

bash
复制代码
npm install @wangeditor/editor @wangeditor/editor-for-vue@next

wangEditor 组件封装

html
复制代码
<!-- 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>

使用案例

html
复制代码
<!-- 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>

效果预览

New Image

Echarts 图表

参考:📊 Echarts 官方示例

安装 Echarts

bash
复制代码
npm install echarts

组件封装

html
复制代码
<!-- 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>

组件使用

html
复制代码
<script setup lang="ts"> import BarChart from ''./components/BarChart.vue''; </script> <template> <BarChart id="barChart" height="400px"width="300px" /> </template>

效果预览

New Image

图标选择器

组件封装

html
复制代码
<!-- 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>

组件使用

html
复制代码
<!-- 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>

效果预览

New Image

规范配置

代码统一规范

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

启动部署

项目启动

bash
复制代码
# 安装 pnpm npm install pnpm -g # 安装依赖 pnpm install # 项目运行 pnpm run dev

项目部署

bash
复制代码
# 项目打包 pnpm run build:prod

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

FAQ

1: defineProps is not defined

  • 问题描述

    ''defineProps'' is not defined.eslint no-undef

    New Image

  • 解决方案

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

    New Image

    安装 vue-eslint-parser 解析器

    bash
    复制代码
    npm install -D vue-eslint-parser

    New Image

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

    bash
    复制代码
    parser: ''vue-eslint-parser'', extends: [ ''eslint:recommended'', // ... ],

    重启 VSCode 已无报错提示

    New Image

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

  • 问题描述

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

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

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

    解决方案升级 vite 4.3 版本 github.com/vitejs/vite… New Image

结语

本篇从项目介绍、环境准备、VSCode 的代码规范配置 、整合各种框架 、再到最后的启动部署,完整讲述如何基于 Vue3 + Vite4 + TypeScript + Element Plus 等主流技术栈从 0 到 1构建一个企业应用级管理前端框架。

项目有问题建议 issue 或者可以通过项目 关于我们 加入交流群反馈。