如何从零开始构建基于Vue3、TypeScript和Element-Plus的vue-element-admin开源项目?

2026-05-19 18:211阅读0评论SEO问题
  • 内容介绍
  • 文章标签
  • 相关推荐

本文共计6210个文字,预计阅读时间需要25分钟。

如何从零开始构建基于Vue3、TypeScript和Element-Plus的vue-element-admin开源项目?

项目代码结构基本完整,保留vue-element-admin,代码风格参考Vue.js社区,CSS遵循BEM规范,站点不仅为了观看的更远,更是对尊重、持续和希望走得更远的致敬。项目简介:vue3-项目名称

项目代码结构基本完全保留 vue-element-admin ,代码风格参考Vue.js社区,CSS遵守BEM规范 ,站在巨人的肩膀不仅是为了看的更远,更多的是一种致敬、延续和希望走的更远。 项目简介

vue3-element-admin 是基于 vue-element-admin 升级的 Vue3 + Element Plus 版本的后台管理前端解决方案,是 有来技术团队 继 youlai-mall 全栈开源商城项目的又一开源力作。

项目使用 Vue3 + Vite2 + TypeScript + Element Plus + Vue Router + Pinia + Volar 等前端主流技术栈,基于此项目模板完成有来商城管理前端的 Vue3 版本。

本篇先对本项目功能、技术栈进行整体概述,再细节的讲述从0到1搭建 vue3-element-admin,在希望大家对本项目有个完完整整整了解的同时也能够在学 Vue3 + TypeScript 等技术栈少花些时间,少走些弯路,这样团队在毫无保留开源才有些许意义。

功能清单

技术栈清单 技术栈 描述 官网 Vue3 渐进式 JavaScript 框架 v3.cn.vuejs.org/ TypeScript 微软新推出的一种语言,是 JavaScript 的超集 www.tslang.cn/ Vite2 前端开发与构建工具 cn.vitejs.dev/ Element Plus 基于 Vue 3,面向设计师和开发者的组件库 element-plus.gitee.io/zh-CN/ Pinia 新一代状态管理工具 pinia.vuejs.org/ Vue Router Vue.js 的官方路由 router.vuejs.org/zh/ wangEditor Typescript 开发的 Web 富文本编辑器 www.wangeditor.com/ Echarts 一个基于 JavaScript 的开源可视化图表库 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 显示上架商品效果

启动部署
  • 项目启动

npm install npm run dev

浏览器访问 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下载地址: nodejs.cn/download/

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

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

2. 开发工具VSCode

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

3. 必装插件Volar

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

项目初始化

1. Vite 是什么?

Vite是一种新型前端构建工具,能够显著提升前端开发体验。

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

2. 初始化项目

npm init vite@latest vue3-element-admin --template vue-ts

  • vue3-element-admin:项目名称
  • vue-ts : Vue + TypeScript 的模板,除此还有vue,react,react-ts模板

3. 启动项目

cd vue3-element-admin npm install npm run dev

浏览器访问: localhost:3000

整合Element-Plus

1.本地安装Element Plus和图标组件

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

2.全局注册组件

// main.ts import ElementPlus from 'element-plus' import 'element-plus/theme-chalk/index.css' createApp(App) .use(ElementPlus) .mount('#app')

3. Element Plus全局组件类型声明

// 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. 效果预览

路径别名配置

使用 @ 代替 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' 环境变量

官方教程: 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.环境变量智能提示

添加环境变量类型声明

如何从零开始构建基于Vue3、TypeScript和Element-Plus的vue-element-admin开源项目?

// src/ env.d.ts // 环境变量类型声明 interface ImportMetaEnv { VITE_APP_TITLE: string, VITE_APP_PORT: string, VITE_APP_BASE_API: string } interface ImportMeta { readonly env: ImportMetaEnv }

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

浏览器跨域处理

1. 跨域原理

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

解决浏览器跨域限制大体分为后端和前端两个方向:

  • 后端:开启 CORS 资源共享;
  • 前端:使用反向代理欺骗浏览器误认为是同源请求;

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

Vite 配置反向代理解决跨域,因为需要读取环境变量,故写法和上文的出入较大,这里贴出完整的 vite.config.ts 配置。

// vite.config.ts import {UserConfig, ConfigEnv, loadEnv} from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default ({command, mode}: ConfigEnv): UserConfig => { // 获取 .env 环境配置文件 const env = loadEnv(mode, process.cwd()) return ( { plugins: [ vue() ], // 本地反向代理解决浏览器跨域限制 server: { host: 'localhost', port: Number(env.VITE_APP_PORT), open: true, // 启动是否自动打开浏览器 proxy: { [env.VITE_APP_BASE_API]: { target: 'api.youlai.tech', // 有来商城线上接口地址 changeOrigin: true, rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '') } } }, resolve: { alias: { "@": path.resolve("./src") // 相对路径别名配置,使用 @ 代替 src } } } ) } SVG图标

官方教程: 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 viteSvgIcons 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 { width: 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 。

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 ... }) 动态权限路由

官方文档: router.vuejs.org/zh/api/

1. 安装 vue-router

npm install vue-router@next

2. 创建路由实例

创建路由实例并导出,其中包括静态路由数据,动态路由后面将通过接口从后端获取并整合用户角色的权限控制。

// src/router/index.ts import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' import useStore from "@/store"; export const Layout = () => import('@/layout/index.vue') // 静态路由 export const constantRoutes: Array<RouteRecordRaw> = [ { path: '/redirect', component: Layout, meta: { hidden: true }, children: [ { path: '/redirect/:path(.*)', component: () => import('@/views/redirect/index.vue') } ] }, { path: '/login', component: () => import('@/views/login/index.vue'), meta: { hidden: true } }, { path: '/404', component: () => import('@/views/error-page/404.vue'), meta: { hidden: true } }, { path: '/401', component: () => import('@/views/error-page/401.vue'), meta: { hidden: true } }, { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', component: () => import('@/views/dashboard/index.vue'), name: 'Dashboard', meta: { title: 'dashboard', icon: 'dashboard', affix: true } } ] } ] // 创建路由实例 const router = createRouter({ history: createWebHashHistory(), routes: constantRoutes as RouteRecordRaw[], // 刷新时,滚动条位置还原 scrollBehavior: () => ({ left: 0, top: 0 }) }) // 重置路由 export function resetRouter() { const { permission } = useStore() permission.routes.forEach((route) => { const name = route.name if (name) { router.hasRoute(name) && router.removeRoute(name) } }) } export default router

3. 路由实例全局注册

// main.ts import router from "@/router"; app.use(router) .mount('#app')

4. 动态权限路由

// src/permission.ts import router from "@/router"; import { ElMessage } from "element-plus"; import useStore from "@/store"; import NProgress from 'nprogress'; import 'nprogress/nprogress.css' NProgress.configure({ showSpinner: false }) // 进度环显示/隐藏 // 白名单路由 const whiteList = ['/login', '/auth-redirect'] router.beforeEach(async (to, form, next) => { NProgress.start() const { user, permission } = useStore() const hasToken = user.token if (hasToken) { // 登录成功,跳转到首页 if (to.path === '/login') { next({ path: '/' }) NProgress.done() } else { const hasGetUserInfo = user.roles.length > 0 if (hasGetUserInfo) { next() } else { try { await user.getUserInfo() const roles = user.roles // 用户拥有权限的路由集合(accessRoutes) const accessRoutes: any = await permission.generateRoutes(roles) accessRoutes.forEach((route: any) => { router.addRoute(route) }) next({ ...to, replace: true }) } catch (error) { // 移除 token 并跳转登录页 await user.resetToken() ElMessage.error(error as any || 'Has Error') next(`/login?redirect=${to.path}`) NProgress.done() } } } } else { // 未登录可以访问白名单页面(登录页面) if (whiteList.indexOf(to.path) !== -1) { next() } else { next(`/login?redirect=${to.path}`) NProgress.done() } } }) router.afterEach(() => { NProgress.done() })

其中 const accessRoutes: any = await permission.generateRoutes(roles)是根据用户角色获取拥有权限的路由(静态路由+动态路由),核心代码如下:

// src/store/modules/permission.ts import { constantRoutes } from '@/router'; import { listRoutes } from "@/api/system/menu"; const usePermissionStore = defineStore({ id: "permission", state: (): PermissionState => ({ routes: [], addRoutes: [] }), actions: { setRoutes(routes: RouteRecordRaw[]) { this.addRoutes = routes // 静态路由 + 动态路由 this.routes = constantRoutes.concat(routes) }, generateRoutes(roles: string[]) { return new Promise((resolve, reject) => { // API 获取动态路由 listRoutes().then(response => { const asyncRoutes = response.data let accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) this.setRoutes(accessedRoutes) resolve(accessedRoutes) }).catch(error => { reject(error) }) }) } } }) export default usePermissionStore; 按钮权限

1. Directive 自定义指令

// src/directive/permission/index.ts import useStore from "@/store"; import { Directive, DirectiveBinding } from "vue"; /** * 按钮权限校验 */ export const hasPerm: Directive = { mounted(el: HTMLElement, binding: DirectiveBinding) { // 「超级管理员」拥有所有的按钮权限 const { user } = useStore() const roles = user.roles; if (roles.includes('ROOT')) { return true } // 「其他角色」按钮权限校验 const { value } = binding; if (value) { const requiredPerms = value; // DOM绑定需要的按钮权限标识 const hasPerm = user.perms.some(perm => { return requiredPerms.includes(perm) }) if (!hasPerm) { el.parentNode && el.parentNode.removeChild(el); } } else { throw new Error("need perms! Like v-has-perm=\"['sys:user:add','sys:user:edit']\""); } } };

2. 自定义指令全局注册

// src/main.ts const app = createApp(App) // 自定义指令 import * as directive from "@/directive"; Object.keys(directive).forEach(key => { app.directive(key, (directive as { [key: string]: Directive })[key]); });

3. 指令使用

// src/views/system/user/index.vue <el-button v-hasPerm="['sys:user:add']">新增</el-button> <el-button v-hasPerm="['sys:user:delete']">删除</el-button> Element-Plus国际化

官方教程: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>

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使用

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

官方的示例文档丰富和详细,且涵盖了 JavaScript 和 TypeScript 版本,使用非常简单。

<!-- src/views/dashboard/components/Chart/BarChart.vue --> <!-- 线 + 柱混合图 --> <template> <div :id="id" :class="className" :style="{height, width}" /> </template> <script setup lang="ts"> import {nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted} from "vue"; import {init, EChartsOption} from 'echarts' import * as echarts from 'echarts'; import resize from '@/utils/resize' const props = defineProps({ id: { type: String, default: 'barChart' }, className: { type: String, default: '' }, width: { type: String, default: '200px', required: true }, height: { type: String, default: '200px', required: true } }) const { mounted, chart, beforeDestroy, activated, deactivated } = resize() function initChart() { const barChart = init(document.getElementById(props.id) as HTMLDivElement) barChart.setOption({ title: { show: true, text: '业绩总览(2021年)', x: 'center', padding: 15, textStyle: { fontSize: 18, fontStyle: 'normal', fontWeight: 'bold', color: '#337ecc' } }, grid: { left: '2%', right: '2%', bottom: '10%', containLabel: true }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross', crossStyle: { color: '#999' } } }, legend: { x: 'center', y: 'bottom', data: ['收入', '毛利润', '收入增长率', '利润增长率'] }, xAxis: [ { type: 'category', data: ['上海', '北京', '浙江', '广东', '深圳', '四川', '湖北', '安徽'], axisPointer: { type: 'shadow' } } ], yAxis: [ { type: 'value', min: 0, max: 10000, interval: 2000, axisLabel: { formatter: '{value} ' } }, { type: 'value', min: 0, max: 100, interval: 20, axisLabel: { formatter: '{value}%' } } ], series: [ { name: '收入', type: 'bar', data: [ 8000, 8200, 7000, 6200, 6500, 5500, 4500, 4200, 3800, ], barWidth: 20, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#83bff6' }, { offset: 0.5, color: '#188df0' }, { offset: 1, color: '#188df0' } ]) } }, { name: '毛利润', type: 'bar', data: [ 6700, 6800, 6300, 5213, 4500, 4200, 4200, 3800 ], barWidth: 20, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#25d73c' }, { offset: 0.5, color: '#1bc23d' }, { offset: 1, color: '#179e61' } ]) } }, { name: '收入增长率', type: 'line', yAxisIndex: 1, data: [65, 67, 65, 53, 47, 45, 43, 42, 41], itemStyle: { color: '#67C23A' } }, { name: '利润增长率', type: 'line', yAxisIndex: 1, data: [80, 81, 78, 67, 65, 60, 56,51, 45 ], itemStyle: { color: '#409EFF' } } ] } as EChartsOption) chart.value = barChart } onBeforeUnmount(() => { beforeDestroy() }) onActivated(() => { activated() }) onDeactivated(() => { deactivated() }) onMounted(() => { mounted() nextTick(() => { initChart() }) }) </script>

项目源码 Gitee Github vue3-element-admin gitee.com/youlaiorg/vue3-element-admin github.com/youlaitech/vue3-element-admin 加入我们

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

开发人员 开发人员
标签:vue3

本文共计6210个文字,预计阅读时间需要25分钟。

如何从零开始构建基于Vue3、TypeScript和Element-Plus的vue-element-admin开源项目?

项目代码结构基本完整,保留vue-element-admin,代码风格参考Vue.js社区,CSS遵循BEM规范,站点不仅为了观看的更远,更是对尊重、持续和希望走得更远的致敬。项目简介:vue3-项目名称

项目代码结构基本完全保留 vue-element-admin ,代码风格参考Vue.js社区,CSS遵守BEM规范 ,站在巨人的肩膀不仅是为了看的更远,更多的是一种致敬、延续和希望走的更远。 项目简介

vue3-element-admin 是基于 vue-element-admin 升级的 Vue3 + Element Plus 版本的后台管理前端解决方案,是 有来技术团队 继 youlai-mall 全栈开源商城项目的又一开源力作。

项目使用 Vue3 + Vite2 + TypeScript + Element Plus + Vue Router + Pinia + Volar 等前端主流技术栈,基于此项目模板完成有来商城管理前端的 Vue3 版本。

本篇先对本项目功能、技术栈进行整体概述,再细节的讲述从0到1搭建 vue3-element-admin,在希望大家对本项目有个完完整整整了解的同时也能够在学 Vue3 + TypeScript 等技术栈少花些时间,少走些弯路,这样团队在毫无保留开源才有些许意义。

功能清单

技术栈清单 技术栈 描述 官网 Vue3 渐进式 JavaScript 框架 v3.cn.vuejs.org/ TypeScript 微软新推出的一种语言,是 JavaScript 的超集 www.tslang.cn/ Vite2 前端开发与构建工具 cn.vitejs.dev/ Element Plus 基于 Vue 3,面向设计师和开发者的组件库 element-plus.gitee.io/zh-CN/ Pinia 新一代状态管理工具 pinia.vuejs.org/ Vue Router Vue.js 的官方路由 router.vuejs.org/zh/ wangEditor Typescript 开发的 Web 富文本编辑器 www.wangeditor.com/ Echarts 一个基于 JavaScript 的开源可视化图表库 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 显示上架商品效果

启动部署
  • 项目启动

npm install npm run dev

浏览器访问 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下载地址: nodejs.cn/download/

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

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

2. 开发工具VSCode

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

3. 必装插件Volar

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

项目初始化

1. Vite 是什么?

Vite是一种新型前端构建工具,能够显著提升前端开发体验。

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

2. 初始化项目

npm init vite@latest vue3-element-admin --template vue-ts

  • vue3-element-admin:项目名称
  • vue-ts : Vue + TypeScript 的模板,除此还有vue,react,react-ts模板

3. 启动项目

cd vue3-element-admin npm install npm run dev

浏览器访问: localhost:3000

整合Element-Plus

1.本地安装Element Plus和图标组件

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

2.全局注册组件

// main.ts import ElementPlus from 'element-plus' import 'element-plus/theme-chalk/index.css' createApp(App) .use(ElementPlus) .mount('#app')

3. Element Plus全局组件类型声明

// 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. 效果预览

路径别名配置

使用 @ 代替 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' 环境变量

官方教程: 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.环境变量智能提示

添加环境变量类型声明

如何从零开始构建基于Vue3、TypeScript和Element-Plus的vue-element-admin开源项目?

// src/ env.d.ts // 环境变量类型声明 interface ImportMetaEnv { VITE_APP_TITLE: string, VITE_APP_PORT: string, VITE_APP_BASE_API: string } interface ImportMeta { readonly env: ImportMetaEnv }

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

浏览器跨域处理

1. 跨域原理

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

解决浏览器跨域限制大体分为后端和前端两个方向:

  • 后端:开启 CORS 资源共享;
  • 前端:使用反向代理欺骗浏览器误认为是同源请求;

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

Vite 配置反向代理解决跨域,因为需要读取环境变量,故写法和上文的出入较大,这里贴出完整的 vite.config.ts 配置。

// vite.config.ts import {UserConfig, ConfigEnv, loadEnv} from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default ({command, mode}: ConfigEnv): UserConfig => { // 获取 .env 环境配置文件 const env = loadEnv(mode, process.cwd()) return ( { plugins: [ vue() ], // 本地反向代理解决浏览器跨域限制 server: { host: 'localhost', port: Number(env.VITE_APP_PORT), open: true, // 启动是否自动打开浏览器 proxy: { [env.VITE_APP_BASE_API]: { target: 'api.youlai.tech', // 有来商城线上接口地址 changeOrigin: true, rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '') } } }, resolve: { alias: { "@": path.resolve("./src") // 相对路径别名配置,使用 @ 代替 src } } } ) } SVG图标

官方教程: 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 viteSvgIcons 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 { width: 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 。

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 ... }) 动态权限路由

官方文档: router.vuejs.org/zh/api/

1. 安装 vue-router

npm install vue-router@next

2. 创建路由实例

创建路由实例并导出,其中包括静态路由数据,动态路由后面将通过接口从后端获取并整合用户角色的权限控制。

// src/router/index.ts import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' import useStore from "@/store"; export const Layout = () => import('@/layout/index.vue') // 静态路由 export const constantRoutes: Array<RouteRecordRaw> = [ { path: '/redirect', component: Layout, meta: { hidden: true }, children: [ { path: '/redirect/:path(.*)', component: () => import('@/views/redirect/index.vue') } ] }, { path: '/login', component: () => import('@/views/login/index.vue'), meta: { hidden: true } }, { path: '/404', component: () => import('@/views/error-page/404.vue'), meta: { hidden: true } }, { path: '/401', component: () => import('@/views/error-page/401.vue'), meta: { hidden: true } }, { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', component: () => import('@/views/dashboard/index.vue'), name: 'Dashboard', meta: { title: 'dashboard', icon: 'dashboard', affix: true } } ] } ] // 创建路由实例 const router = createRouter({ history: createWebHashHistory(), routes: constantRoutes as RouteRecordRaw[], // 刷新时,滚动条位置还原 scrollBehavior: () => ({ left: 0, top: 0 }) }) // 重置路由 export function resetRouter() { const { permission } = useStore() permission.routes.forEach((route) => { const name = route.name if (name) { router.hasRoute(name) && router.removeRoute(name) } }) } export default router

3. 路由实例全局注册

// main.ts import router from "@/router"; app.use(router) .mount('#app')

4. 动态权限路由

// src/permission.ts import router from "@/router"; import { ElMessage } from "element-plus"; import useStore from "@/store"; import NProgress from 'nprogress'; import 'nprogress/nprogress.css' NProgress.configure({ showSpinner: false }) // 进度环显示/隐藏 // 白名单路由 const whiteList = ['/login', '/auth-redirect'] router.beforeEach(async (to, form, next) => { NProgress.start() const { user, permission } = useStore() const hasToken = user.token if (hasToken) { // 登录成功,跳转到首页 if (to.path === '/login') { next({ path: '/' }) NProgress.done() } else { const hasGetUserInfo = user.roles.length > 0 if (hasGetUserInfo) { next() } else { try { await user.getUserInfo() const roles = user.roles // 用户拥有权限的路由集合(accessRoutes) const accessRoutes: any = await permission.generateRoutes(roles) accessRoutes.forEach((route: any) => { router.addRoute(route) }) next({ ...to, replace: true }) } catch (error) { // 移除 token 并跳转登录页 await user.resetToken() ElMessage.error(error as any || 'Has Error') next(`/login?redirect=${to.path}`) NProgress.done() } } } } else { // 未登录可以访问白名单页面(登录页面) if (whiteList.indexOf(to.path) !== -1) { next() } else { next(`/login?redirect=${to.path}`) NProgress.done() } } }) router.afterEach(() => { NProgress.done() })

其中 const accessRoutes: any = await permission.generateRoutes(roles)是根据用户角色获取拥有权限的路由(静态路由+动态路由),核心代码如下:

// src/store/modules/permission.ts import { constantRoutes } from '@/router'; import { listRoutes } from "@/api/system/menu"; const usePermissionStore = defineStore({ id: "permission", state: (): PermissionState => ({ routes: [], addRoutes: [] }), actions: { setRoutes(routes: RouteRecordRaw[]) { this.addRoutes = routes // 静态路由 + 动态路由 this.routes = constantRoutes.concat(routes) }, generateRoutes(roles: string[]) { return new Promise((resolve, reject) => { // API 获取动态路由 listRoutes().then(response => { const asyncRoutes = response.data let accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) this.setRoutes(accessedRoutes) resolve(accessedRoutes) }).catch(error => { reject(error) }) }) } } }) export default usePermissionStore; 按钮权限

1. Directive 自定义指令

// src/directive/permission/index.ts import useStore from "@/store"; import { Directive, DirectiveBinding } from "vue"; /** * 按钮权限校验 */ export const hasPerm: Directive = { mounted(el: HTMLElement, binding: DirectiveBinding) { // 「超级管理员」拥有所有的按钮权限 const { user } = useStore() const roles = user.roles; if (roles.includes('ROOT')) { return true } // 「其他角色」按钮权限校验 const { value } = binding; if (value) { const requiredPerms = value; // DOM绑定需要的按钮权限标识 const hasPerm = user.perms.some(perm => { return requiredPerms.includes(perm) }) if (!hasPerm) { el.parentNode && el.parentNode.removeChild(el); } } else { throw new Error("need perms! Like v-has-perm=\"['sys:user:add','sys:user:edit']\""); } } };

2. 自定义指令全局注册

// src/main.ts const app = createApp(App) // 自定义指令 import * as directive from "@/directive"; Object.keys(directive).forEach(key => { app.directive(key, (directive as { [key: string]: Directive })[key]); });

3. 指令使用

// src/views/system/user/index.vue <el-button v-hasPerm="['sys:user:add']">新增</el-button> <el-button v-hasPerm="['sys:user:delete']">删除</el-button> Element-Plus国际化

官方教程: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>

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使用

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

官方的示例文档丰富和详细,且涵盖了 JavaScript 和 TypeScript 版本,使用非常简单。

<!-- src/views/dashboard/components/Chart/BarChart.vue --> <!-- 线 + 柱混合图 --> <template> <div :id="id" :class="className" :style="{height, width}" /> </template> <script setup lang="ts"> import {nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted} from "vue"; import {init, EChartsOption} from 'echarts' import * as echarts from 'echarts'; import resize from '@/utils/resize' const props = defineProps({ id: { type: String, default: 'barChart' }, className: { type: String, default: '' }, width: { type: String, default: '200px', required: true }, height: { type: String, default: '200px', required: true } }) const { mounted, chart, beforeDestroy, activated, deactivated } = resize() function initChart() { const barChart = init(document.getElementById(props.id) as HTMLDivElement) barChart.setOption({ title: { show: true, text: '业绩总览(2021年)', x: 'center', padding: 15, textStyle: { fontSize: 18, fontStyle: 'normal', fontWeight: 'bold', color: '#337ecc' } }, grid: { left: '2%', right: '2%', bottom: '10%', containLabel: true }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross', crossStyle: { color: '#999' } } }, legend: { x: 'center', y: 'bottom', data: ['收入', '毛利润', '收入增长率', '利润增长率'] }, xAxis: [ { type: 'category', data: ['上海', '北京', '浙江', '广东', '深圳', '四川', '湖北', '安徽'], axisPointer: { type: 'shadow' } } ], yAxis: [ { type: 'value', min: 0, max: 10000, interval: 2000, axisLabel: { formatter: '{value} ' } }, { type: 'value', min: 0, max: 100, interval: 20, axisLabel: { formatter: '{value}%' } } ], series: [ { name: '收入', type: 'bar', data: [ 8000, 8200, 7000, 6200, 6500, 5500, 4500, 4200, 3800, ], barWidth: 20, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#83bff6' }, { offset: 0.5, color: '#188df0' }, { offset: 1, color: '#188df0' } ]) } }, { name: '毛利润', type: 'bar', data: [ 6700, 6800, 6300, 5213, 4500, 4200, 4200, 3800 ], barWidth: 20, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#25d73c' }, { offset: 0.5, color: '#1bc23d' }, { offset: 1, color: '#179e61' } ]) } }, { name: '收入增长率', type: 'line', yAxisIndex: 1, data: [65, 67, 65, 53, 47, 45, 43, 42, 41], itemStyle: { color: '#67C23A' } }, { name: '利润增长率', type: 'line', yAxisIndex: 1, data: [80, 81, 78, 67, 65, 60, 56,51, 45 ], itemStyle: { color: '#409EFF' } } ] } as EChartsOption) chart.value = barChart } onBeforeUnmount(() => { beforeDestroy() }) onActivated(() => { activated() }) onDeactivated(() => { deactivated() }) onMounted(() => { mounted() nextTick(() => { initChart() }) }) </script>

项目源码 Gitee Github vue3-element-admin gitee.com/youlaiorg/vue3-element-admin github.com/youlaitech/vue3-element-admin 加入我们

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

开发人员 开发人员
标签:vue3