Vue3 + Vite + TypeScript + Element-Plus:从零到一构建企业级后台管理系统(前后端开源)(2):
https://developer.aliyun.com/article/1395769
动态路由
安装 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 ,获取当前登录用户的角色信息进行动态路由的初始化
最终调用 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 }; });
接口获取得到的路由数据
根据路由数据 (routes)生成菜单的关键代码
src/layout/componets/Sidebar/index.vue
src/layout/componets/Sidebar/SidebarItem.vue
按钮权限
除了 Vue 内置的一系列指令 (比如 v-model 或 v-show) 之外,Vue 还允许你注册自定义的指令 (Custom Directives),以下就通过自定义指令的方式实现按钮权限控制。
**自定义指令 **
// 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 分页组件看下国际化的效果
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
创建 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 查看如何使用
效果预览
暗黑模式
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 组件样式之外,应用中还有很多自定义的组件和样式,像这样的:
应对自定义组件样式实现暗黑模式步骤如下:
新建 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';
效果预览
组件封装
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 { onBeforeUnmount, shallowRef, reactive, toRefs } from 'vue'; 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']); // 编辑器实例,必须用 shallowRef const editorRef = shallowRef(); const state = reactive({ toolbarConfig: {}, editorConfig: { placeholder: '请输入内容...', MENU_CONF: { uploadImage: { // 自定义图片上传 async customUpload(file: any, insertFn: any) { uploadFileApi(file).then(response => { const url = response.data.url; 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>
使用案例
<!-- 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>
效果预览
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>
效果预览
图标选择器
组件封装
<!-- 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>
效果预览
规范配置
代码统一规范
【vue3-element-admin】ESLint+Prettier+Stylelint+EditorConfig 约束和统一前端代码规范
Git 提交规范
【vue3-element-admin】Husky + Lint-staged + Commitlint + Commitizen + cz-git 配置 Git 提交规范
启动部署
项目启动
# 安装 pnpm npm install pnpm -g # 安装依赖 pnpm install # 项目运行 pnpm run dev
生成的静态文件在工程根目录 dist 文件夹
FAQ
1: defineProps is not defined
- 问题描述
‘defineProps’ is not defined.eslint no-undef
解决方案
根据 Eslint 官方解决方案描述,解析器使用 vue-eslint-parser v9.0.0 + 版本
安装 vue-eslint-parser 解析器
npm install -D vue-eslint-parser
.eslintrc.js 关键配置( v9.0.0 及以上版本无需配置编译宏 vue/setup-compiler-macros)如下 :
parser: 'vue-eslint-parser', extends: [ 'eslint:recommended', // ... ],
重启 VSCode 已无报错提示
2: Vite 首屏加载慢(白屏久)
问题描述
Vite 项目启动很快,但首次打开界面加载慢?
参考文章:为什么有人说 vite 快,有人却说 vite 慢
vite 启动时,并不像 webpack 那样做一个全量的打包构建,所以启动速度非常快。启动以后,浏览器发起请求时, Dev Server 要把请求需要的资源发送给浏览器,中间需要经历预构建、对请求文件做路径解析、加载源文件、对源文件做转换,然后才能把内容返回给浏览器,这个时间耗时蛮久的,导致白屏时间较长。
解决方案升级 vite 4.3 版本
https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md
结语
本篇从项目介绍、环境准备、VSCode 的代码规范配置 、整合各种框架 、再到最后的启动部署,完整讲述如何基于 Vue3 + Vite4 + TypeScript + Element Plus 等主流技术栈从 0 到 1构建一个企业应用级管理前端框架。