Vue3 + Vite + TypeScript + Element-Plus:从零到一构建企业级后台管理系统(前后端开源)(3)

简介: Vue3 + Vite + TypeScript + Element-Plus:从零到一构建企业级后台管理系统(前后端开源)(3)

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 ,获取当前登录用户的角色信息进行动态路由的初始化

4.png

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

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

接口获取得到的路由数据

5.png

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


src/layout/componets/Sidebar/index.vue

6.png

src/layout/componets/Sidebar/SidebarItem.vue

7.png

按钮权限


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


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


**自定义指令 **

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

全局注册自定义指令

// src/directive/index.ts
import type { App } from 'vue';
import { hasPerm } from './permission';
// 全局注册 directive 方法
export function setupDirective(app: App<Element>) {
  // 使 v-hasPerm 在所有组件中都可用
  app.directive('hasPerm', hasPerm);
}
// src/main.ts
import { setupDirective } from '@/directive';
const app = createApp(App);
// 全局注册 自定义指令(directive)
setupDirective(app);

组件使用自定义指令

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

国际化


国际化分为两个部分,Element Plus 框架国际化(官方提供了国际化方式)和自定义国际化(通过 vue-i18n 国际化插件)


Element Plus 国际化


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


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

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

定义 store

// src/store/modules/app.ts
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core';
import defaultSettings from '@/settings';
// 导入 Element Plus 中英文语言包
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import en from 'element-plus/es/locale/lang/en';
// setup
export const useAppStore = defineStore('app', () => {
  const language = useStorage('language', defaultSettings.language);
  /**
   * 根据语言标识读取对应的语言包
   */
  const locale = computed(() => {
    if (language?.value == 'en') {
      return en;
    } else {
      return zhCn;
    }
  });
  /**
   * 切换语言
   */
  function changeLanguage(val: string) {
    language.value = val;
  }
  return {
    language,
    locale,
    changeLanguage
  };
});

切换语言组件调用

<!-- src/components/LangSelect/index.vue -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useAppStore } from '@/store/modules/app';
const appStore = useAppStore();
const { locale } = useI18n();
function handleLanguageChange(lang: string) {
  locale.value = lang;
  appStore.changeLanguage(lang);
  if (lang == 'en') {
    ElMessage.success('Switch Language Successful!');
  } else {
    ElMessage.success('切换语言成功!');
  }
}
</script>
<template>
  <el-dropdown trigger="click" @command="handleLanguageChange">
    <div>
      <svg-icon icon-class="language" />
    </div>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item
          :disabled="appStore.language === 'zh-cn'"
          command="zh-cn"
        >
          中文
        </el-dropdown-item>
        <el-dropdown-item :disabled="appStore.language === 'en'" command="en">
          English
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

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

8.png9.png

vue-i18n 自定义国际化


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


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


安装 vue-i18n

npm install vue-i18n@9

定义语言包


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


中文语言包 zh-cn.ts10.png

英文语言包 en.ts

11.png

创建 i18n 实例

// src/lang/index.ts
import { createI18n } from 'vue-i18n';
import { useAppStore } from '@/store/modules/app';
const appStore = useAppStore();
// 本地语言包
import enLocale from './package/en';
import zhCnLocale from './package/zh-cn';
const messages = {
  'zh-cn': {
    ...zhCnLocale
  },
  en: {
    ...enLocale
  }
};
// 创建 i18n 实例
const i18n = createI18n({
  legacy: false,
  locale: appStore.language,
  messages: messages
});
// 导出 i18n 实例
export default i18n;

i18n 全局注册

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

登录页面国际化使用


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

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

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


效果预览

1.gif

暗黑模式


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


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


导入 Element Plus 暗黑模式变量

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

切换暗黑模式设置

<!-- src/layout/components/Settings/index.vue -->
<script setup lang="ts">
import IconEpSunny from '~icons/ep/sunny';
import IconEpMoon from '~icons/ep/moon';
/**
 * 暗黑模式
 */
const settingsStore = useSettingsStore();
const isDark = useDark();
const toggleDark = () => useToggle(isDark);
</script>
<template>
  <div class="settings-container">
    <h3 class="text-base font-bold">项目配置</h3>
    <el-divider>主题</el-divider>
    <div class="flex justify-center" @click.stop>
      <el-switch
        v-model="isDark"
        @change="toggleDark"
        inline-prompt
        :active-icon="IconEpMoon"
        :inactive-icon="IconEpSunny"
        active-color="var(--el-fill-color-dark)"
        inactive-color="var(--el-color-primary)"
      />
    </div>
  </div>
</template>

自定义变量


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

12.png

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


新建 src/styles/dark.scss

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

在 Element Plus 的样式之后导入它

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

效果预览

image.gif

组件封装


wangEditor 富文本


参考: wangEditor 官方文档


安装 wangEditor

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

wangEditor 组件封装

<!-- src/components/WangEditor/index.vue -->
<template>
  <div style="border: 1px solid #ccc">
    <!-- 工具栏 -->
    <Toolbar
      :editor="editorRef"
      :defaultConfig="toolbarConfig"
      style="border-bottom: 1px solid #ccc"
      :mode="mode"
    />
    <!-- 编辑器 -->
    <Editor
      :defaultConfig="editorConfig"
      v-model="defaultHtml"
      @onChange="handleChange"
      style="height: 500px; overflow-y: hidden"
      :mode="mode"
      @onCreated="handleCreated"
    />
  </div>
</template>
<script setup lang="ts">
import { 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>

效果预览

13.png

Echarts 图表


参考:📊 Echarts 官方示例


安装 Echarts

npm install echarts

组件封装

<!-- src/views/dashboard/components/Chart/BarChart.vue --> 
<template>
  <el-card>
    <template #header> 线 + 柱混合图 </template>
    <div :id="id" :class="className" :style="{ height, width }" />
  </el-card>
</template>
<script setup lang="ts">
import * as echarts from 'echarts';
const props = defineProps({
  id: {
    type: String,
    default: 'barChart'
  },
  className: {
    type: String,
    default: ''
  },
  width: {
    type: String,
    default: '200px',
    required: true
  },
  height: {
    type: String,
    default: '200px',
    required: true
  }
});
const options = {
  grid: {
    left: '2%',
    right: '2%',
    bottom: '10%',
    containLabel: true
  },
  tooltip: {
    trigger: 'axis',
    axisPointer: {
      type: 'cross',
      crossStyle: {
        color: '#999'
      }
    }
  },
  legend: {
    x: 'center',
    y: 'bottom',
    data: ['收入', '毛利润', '收入增长率', '利润增长率'],
    textStyle: {
      color: '#999'
    }
  },
  xAxis: [
    {
      type: 'category',
      data: ['浙江', '北京', '上海', '广东', '深圳'],
      axisPointer: {
        type: 'shadow'
      }
    }
  ],
  yAxis: [
    {
      type: 'value',
      min: 0,
      max: 10000,
      interval: 2000,
      axisLabel: {
        formatter: '{value} '
      }
    },
    {
      type: 'value',
      min: 0,
      max: 100,
      interval: 20,
      axisLabel: {
        formatter: '{value}%'
      }
    }
  ],
  series: [
    {
      name: '收入',
      type: 'bar',
      data: [7000, 7100, 7200, 7300, 7400],
      barWidth: 20,
      itemStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: '#83bff6' },
          { offset: 0.5, color: '#188df0' },
          { offset: 1, color: '#188df0' }
        ])
      }
    },
    {
      name: '毛利润',
      type: 'bar',
      data: [8000, 8200, 8400, 8600, 8800],
      barWidth: 20,
      itemStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: '#25d73c' },
          { offset: 0.5, color: '#1bc23d' },
          { offset: 1, color: '#179e61' }
        ])
      }
    },
    {
      name: '收入增长率',
      type: 'line',
      yAxisIndex: 1,
      data: [60, 65, 70, 75, 80],
      itemStyle: {
        color: '#67C23A'
      }
    },
    {
      name: '利润增长率',
      type: 'line',
      yAxisIndex: 1,
      data: [70, 75, 80, 85, 90],
      itemStyle: {
        color: '#409EFF'
      }
    }
  ]
};
onMounted(() => {
  // 图表初始化
  const chart = echarts.init(
    document.getElementById(props.id) as HTMLDivElement
  );
  chart.setOption(options);
  // 大小自适应
  window.addEventListener('resize', () => {
    chart.resize();
  });
});
</script>

组件使用

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

效果预览

14.png

图标选择器


组件封装

<!-- src/components/IconSelect/index.vue -->
<script setup lang="ts">
const props = defineProps({
  modelValue: {
    type: String,
    require: false
  }
});
const emit = defineEmits(['update:modelValue']);
const inputValue = toRef(props, 'modelValue');
const visible = ref(false); // 弹窗显示状态
const iconNames: string[] = []; // 所有的图标名称集合
const filterValue = ref(''); // 筛选的值
const filterIconNames = ref<string[]>([]); // 过滤后的图标名称集合
const iconSelectorRef = ref(null);
/**
 * 加载 ICON
 */
function loadIcons() {
  const icons = import.meta.glob('../../assets/icons/*.svg');
  for (const icon in icons) {
    const iconName = icon.split('assets/icons/')[1].split('.svg')[0];
    iconNames.push(iconName);
  }
  filterIconNames.value = iconNames;
}
/**
 * 筛选图标
 */
function handleFilter() {
  if (filterValue.value) {
    filterIconNames.value = iconNames.filter(iconName =>
      iconName.includes(filterValue.value)
    );
  } else {
    filterIconNames.value = iconNames;
  }
}
/**
 * 选择图标
 */
function handleSelect(iconName: string) {
  emit('update:modelValue', iconName);
  visible.value = false;
}
/**
 * 点击容器外的区域关闭弹窗 VueUse onClickOutside
 */
onClickOutside(iconSelectorRef, () => (visible.value = false));
onMounted(() => {
  loadIcons();
});
</script>
<template>
  <div class="iconselect-container" ref="iconSelectorRef">
    <el-input
      v-model="inputValue"
      readonly
      @click="visible = !visible"
      placeholder="点击选择图标"
    >
      <template #prepend>
        <svg-icon :icon-class="inputValue" />
      </template>
    </el-input>
    <el-popover
      shadow="none"
      :visible="visible"
      placement="bottom-end"
      trigger="click"
      width="400"
    >
      <template #reference>
        <div
          @click="visible = !visible"
          class="cursor-pointer text-[#999] absolute right-[10px] top-0 height-[32px] leading-[32px]"
        >
          <i-ep-caret-top v-show="visible"></i-ep-caret-top>
          <i-ep-caret-bottom v-show="!visible"></i-ep-caret-bottom>
        </div>
      </template>
      <!-- 下拉选择弹窗 -->
      <el-input
        class="p-2"
        v-model="filterValue"
        placeholder="搜索图标"
        clearable
        @input="handleFilter"
      />
      <el-divider border-style="dashed" />
      <el-scrollbar height="300px">
        <ul class="icon-list">
          <li
            class="icon-item"
            v-for="(iconName, index) in filterIconNames"
            :key="index"
            @click="handleSelect(iconName)"
          >
            <el-tooltip :content="iconName" placement="bottom" effect="light">
              <svg-icon
                color="var(--el-text-color-regular)"
                :icon-class="iconName"
              />
            </el-tooltip>
          </li>
        </ul>
      </el-scrollbar>
    </el-popover>
  </div>
</template>

效果预览

3.gif

规范配置


代码统一规范


【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

15.png

解决方案


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

16.png

安装 vue-eslint-parser 解析器

npm install -D vue-eslint-parser

17.png

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

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

重启 VSCode 已无报错提示

18.png

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


问题描述


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


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


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


解决方案升级 vite 4.3 版本


https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md

19.png

结语


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

相关文章
|
6天前
|
缓存 JavaScript 前端开发
【TypeScript技术专栏】TypeScript与Webpack构建优化
【4月更文挑战第30天】本文探讨了优化TypeScript与Webpack构建性能的策略。理解Webpack的解析、构建和生成阶段是关键。优化包括:调整tsconfig.json(关闭不必要的类型检查,适配目标环境)和webpack.config.js(配置entry、output、resolve,使用压缩插件)。启用Webpack缓存和增量构建,利用代码拆分与懒加载,能有效提升构建速度和开发效率。
|
14天前
|
JavaScript 前端开发 编译器
TypeScript的编译器、编辑器支持与工具链:构建高效开发环境的秘密武器
【4月更文挑战第23天】TypeScript的强大力量源于其编译器、编辑器支持和工具链,它们打造了高效的开发环境。编译器`tsc`进行类型检查、语法分析和代码转换;编辑器如VS Code提供智能提示、错误检查和格式化;工具链包括Webpack、Rollup等构建工具,Jest、Mocha等测试框架,以及代码质量和性能分析工具。这些组合使用能提升开发效率、保证代码质量和优化项目性能。
|
20天前
|
JavaScript
vue3+vite项目配置ESlint
vue3+vite项目配置ESlint
20 0
|
20天前
乾坤子应用配置(vue3+vite)
乾坤子应用配置(vue3+vite)
44 0
|
20天前
|
JavaScript
vite+typescript从入门到实战(二)
vite+typescript从入门到实战
39 0
|
21天前
|
JavaScript 前端开发 数据可视化
前端开发使用 Vue 3 + TypeScript + Pinia + Element Plus + ECharts
前端开发使用 Vue 3 + TypeScript + Pinia + Element Plus + ECharts
37 0
|
2月前
|
监控 JavaScript 数据挖掘
TypeScript代码示例:构建灵活可扩展的员工上网管控平台
使用TypeScript构建的员工上网监控平台示例展示了如何通过`InternetMonitoringPlatform`类实现实时监控、数据分析和数据自动提交。类包含`monitorInternetActivity`用于监控行为,`analyzeData`用于分析数据,`autoSubmitToWebsite`借助axios库将数据POST到网站。此平台旨在提高企业安全性和效率。
112 3
|
2月前
|
前端开发 JavaScript 安全
使用React、TypeScript和Ant Design构建现代化前端应用
使用React、TypeScript和Ant Design构建现代化前端应用
29 0
|
2月前
|
JavaScript 前端开发
在Vue中使用TypeScript的常见问题有哪些?
在Vue中使用TypeScript的常见问题有哪些?
29 2
|
2月前
|
JavaScript 前端开发
在Vue中使用TypeScript的优缺点是什么?
在Vue中使用TypeScript的优缺点是什么?
15 0