目录
1. 环境搭建1.1 新建一个vite搭建的vue3项目1.2 选择项目框架 vue1.3 选择语言类型 ts1.4 执行命令进入到新建的项目文件中1.5 下载依赖1.6 完善项目目录结构以及环境配置1.7 因为考虑是纯前端模拟后端给的路由数据2. 在views文件夹下新建文件夹login3. layout中制作动态路由菜单总结1. 环境搭建
1.1 新建一个vite搭建的vue3项目
先执行以下命令
npm create vite@latest my-project(你的项目名)
1.2 选择项目框架 vue
1.3 选择语言类型 ts
1.4 执行命令进入到新建的项目文件中
cd my-project
1.5 下载依赖
npm i
下载项目中需要使用到的环境
npm install vue-router@4 pinia element-plus @element-plus/icons-vue
1.6 完善项目目录结构以及环境配置
1.6.1 先清空App.vue文件中内容,增加router-view作为路由出口
<template> <router-view /></template><script setup lang="ts"></script><style scoped lang="scss">#app { width: 100vw; height: 100vh; display: flex; justify-content: space-around;}</style>
1.6.2 在src目录下新建文件夹layout,在该文件中新建文件AppLayout.vue (文件名看自己)
1.6.3 在src目录下分别新建文件夹store和router分别用来pinia状态管理和路由管理
1.6.3.1 router文件夹中新建两个文件一个index.ts用来初始化路由和存放静态路由一个dynamicRoutes.ts存放处理动态路由
// router/dynamicRoutes.ts// 更新 initDynamicRoutes,确保 dynamicRoutes 被更新import router from './index';import { useRouteStore } from '@/store/index'; // 导入 storeimport type { RouteRecordRaw, RouteRecordRedirectOption } from 'vue-router';// 定义菜单项类型,确保 `name` 是 `string`type MenuItem = Omit<RouteRecordRaw, 'component' | 'children' | 'redirect'> & { name: string; // 必须有 name 属性 path: string; // 必须有 path 属性 component?: () => Promise<Component>; // 用于动态加载组件的路径 children?: MenuItem[]; // 子路由类型 redirect?: string; // 调整 redirect 为更简单的 string 类型 meta?: { title: string; };};// Vite 支持使用特殊的 import.meta.glob 函数从文件系统导入多个模块const modules: Record<string, () => Promise<Component>> = import.meta.glob('../views/**/**.vue');// 初始化动态路由export const initDynamicRoutes = (menuData: MenuItem[]) => { const routeStore = useRouteStore(); // 获取 store const routerList: MenuItem[] = []; const addedRoutes = new Set(); // 用于跟踪已添加的路由,防止重复添加 // 递归处理路由 const processRoutes = (routes: MenuItem[]): MenuItem[] => { return routes.map((item) => { if (addedRoutes.has(item.name)) return null; // 防止重复处理 addedRoutes.add(item.name); // 标记路由为已处理 const componentLoader = modules[`../views${item.component}.vue`]; const route: MenuItem = { path: item.path, name: item.name as string, component: componentLoader , // 提供默认组件以防找不到 meta: item.meta, }; // 如果有子路由,递归处理 if (item.children && item.children.length > 0) { route.children = processRoutes(item.children); route.redirect = route.children[0]?.path; // 默认重定向到第一个子路由 } else { route.children = undefined; // 明确设置为 undefined } return route; }).filter((route) => route !== null) as MenuItem[]; // 过滤掉 null 项 }; // 顶级路由处理 const parentRouter = processRoutes(menuData); // 根路由配置 routerList.push({ path: '/', name: 'home', component: () => import('../layout/AppLayout.vue'), children: parentRouter, // 顶级路由作为子路由 redirect: parentRouter[0]?.path || '/', // 确保有默认重定向路径 }); // 将路由存储到 store 中 routeStore.dynamicRoutes = routerList; // 添加路由到 Vue Router routerList.forEach((route) => { router.addRoute(route as RouteRecordRaw); });};
// router/index.tsimport { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";import { useRouteStore } from "@/store";// 静态路由const routes: RouteRecordRaw[] = [ { path: "/login", name: "login", component: () => import("@/views/login/index.vue"), }, { path: "/404", component: () => import("@/views/error-page/404.vue"), }, { path: "/401", component: () => import("@/views/error-page/401.vue"), }, // 匹配所有路径 { path: "/:pathMatch(.*)", redirect: "/login" },];// 创建路由const router = createRouter({ history: createWebHashHistory(), // 路由模式 routes, // 静态路由});// 路由守卫:初始化时跳转到上次访问的页面window.addEventListener('DOMContentLoaded', () => { const routeStore = useRouteStore() const beforeReloadRoute = sessionStorage.getItem('beforeReloadRoute') if (beforeReloadRoute) { const to = JSON.parse(beforeReloadRoute) routeStore.beforeRouter = to.path // 清除保存的路由信息 sessionStorage.removeItem('beforeReloadRoute') // 导航回刷新前的路由 router.replace(to) const keys = Object.keys(to) if (keys.includes('name')) { sessionStorage.setItem('roterPath', JSON.stringify(to.name)) } }})// 在页面即将刷新时保存当前路由信息window.addEventListener('beforeunload', () => { const currentRoute = JSON.stringify(router.currentRoute.value) sessionStorage.setItem('beforeReloadRoute', currentRoute)})export default router;
1.6.3.2 实现路由持久化和白名单,需要在src目录下新建一个permission.ts文件
import { createVNode, render } from 'vue';import { initDynamicRoutes } from '@/router/dynamicRoutes';import router from './router/index';import loadingBar from '@/component/loadingBar.vue';import Cookies from 'js-cookie'; // 引入 js-cookieimport { useRouteStore } from '@/store/index';import menuData from '/public/dynamicRouter.json'; // 导入动态菜单数据const whileList = ['/login']; // 白名单const Vnode = createVNode(loadingBar);render(Vnode, document.body);router.beforeEach(async (to, from, next) => { const routeStore = useRouteStore(); // 获取 Pinia 中的路由状态 const token = Cookies.get('token'); // 从 cookie 获取 token // 判断是否有 token,存在则说明用户已登录 if (token) { // 检查是否已经加载过动态路由 if (routeStore.dynamicRoutes.length === 0) { // 检查是否有持久化的动态路由 const persistedRoutes = sessionStorage.getItem('dynamicRoutes'); // 使用 sessionStorage if (persistedRoutes) { // 如果有持久化的动态路由,直接从 sessionStorage 加载 const routerList = JSON.parse(persistedRoutes); initDynamicRoutes(routerList); // 动态初始化路由 routeStore.setDynamicRoutes(routerList); // 将动态路由存入 Pinia next({ ...to, replace: true }); // 确保动态路由加载后再跳转 Vnode.component?.exposed?.startLoading(); // 启动加载条 } else { // 如果没有持久化的动态路由,则使用静态的 dynamicRouter.json const dynamicRoutes = initDynamicRoutes(menuData); // 动态初始化路由 if (dynamicRoutes !== undefined) { routeStore.setDynamicRoutes(dynamicRoutes); // 将动态路由存入 Pinia sessionStorage.setItem('dynamicRoutes', JSON.stringify(dynamicRoutes)); // 存储动态路由到 sessionStorage next({ ...to, replace: true }); // 确保动态路由加载后再跳转 Vnode.component?.exposed?.startLoading(); // 启动加载条 } else { next('/login'); // 如果没有动态路由信息,跳转到登录页面 } } } else { next(); // 如果已经加载过动态路由,直接跳转 } } else { // 如果没有 token,判断是否在白名单中 if (whileList.includes(to.path)) { next(); // 白名单路由放行 } else { next('/login'); // 否则跳转到登录页 } }});router.afterEach(() => { Vnode.component?.exposed?.endLoading(); // 结束加载条});
1.6.3.2 store文件夹下新建文件index.ts初始化pinia仓
// store/index.tsimport { createPinia } from 'pinia';import { useRouteStore } from './useRouteStore';import { useUserStore } from './tokenStore';// 创建 pinia 实例const pinia = createPinia();// 将所有 store 模块暴露export { pinia, useRouteStore, useUserStore };
1.6.3.2 store文件夹下新建文件useRouteStore.ts处理存储动态路由文件
import { defineStore } from 'pinia';import { ref } from 'vue';import { initDynamicRoutes } from "@/router/dynamicRoutes"; // 导入初始化动态路由的方法import type { RouteRecordRaw, RouteRecordRedirectOption } from 'vue-router';// 定义菜单项类型,确保 `name` 是 `string`type MenuItem = Omit<RouteRecordRaw, 'component' | 'children' | 'redirect'> & { name: string; // 必须有 name 属性 path: string; // 必须有 path 属性 component?: () => Promise<Component>; // 用于动态加载组件的路径 children?: MenuItem[]; // 子路由类型 redirect?: string; // 调整 redirect 为更简单的 string 类型 meta?: { title: string; };};// 定义路由数据 Storeexport const useRouteStore = defineStore('route', () => { // 存储菜单数据 const menuData = ref<MenuItem[]>([]); // 根据你的菜单数据结构调整类型 // 存储动态路由数据 const dynamicRoutes = ref<MenuItem[]>([]); // 存储是否已初始化路由的状态 const isRoutesInitialized = ref<boolean>(false); // 存储上一次页面刷新的路由 const beforeRouter = ref<string>(''); // 初始化动态路由 const setDynamicRoutes = (menu: any[]) => { // 只在未初始化路由时执行 if (!isRoutesInitialized.value) { // 调用 initDynamicRoutes 函数来生成动态路由 initDynamicRoutes(menu); // 将菜单数据存储到状态中 menuData.value = menu; // 设置已初始化状态 isRoutesInitialized.value = true; } }; // 获取动态路由 const getDynamicRoutes = () => { return dynamicRoutes.value; }; // 更新动态路由 const setUpdatedDynamicRoutes = (routes: MenuItem[]) => { dynamicRoutes.value = routes; }; return { menuData, dynamicRoutes, isRoutesInitialized, // 公开这个状态,方便其他地方判断 setDynamicRoutes, getDynamicRoutes, setUpdatedDynamicRoutes, // 更新动态路由的函数 beforeRouter };});
1.6.4 在src目录下新建文件夹plugins,在该文件夹中新建文件element-plus.ts
/* Element-plus组件库 */import ElementPlus from 'element-plus'import 'element-plus/dist/index.css'import zhCn from 'element-plus/es/locale/lang/zh-cn'import { App } from 'vue'export default { install (app: App) { app.use(ElementPlus, { locale: zhCn }) }}
1.6.5 需要来配置main.ts,vite.config.ts以及tsconfig.json
1.6.5.1 main.ts配置
import { createApp } from "vue";import App from "./App.vue";import router from "./router/index";import ElementPlus from "./plugins/element-plus";import * as ElementPlusIconsVue from "@element-plus/icons-vue";import { pinia } from '@/store/index'; // 导入 store// 创建 Pinia 实例// 路由拦截 路由发生变化修改页面titlerouter.beforeEach((to, from, next) => { if (to.meta.title) { document.title = to.meta.title; } next();});const app = createApp(App);// // 自动注册全局组件app.use(router).use(ElementPlus).use(pinia).mount("#app");for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component);}
1.6.5.2 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(__dirname, 'src'), } }})
1.6.5.3 tsconfig.json配置
{ "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Node", "strict": true, "jsx": "preserve", "sourceMap": true, "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, "lib": ["ESNext", "DOM"], "skipLibCheck": true, "noEmit": true, "paths": { "@/*": ["./src/*"] // 配置路径别名,不做配置会报错 } //就是这个没有设置导致的 }, // "extends": "./tsconfig.extends.json", "include": ["src/**/*.tsx","src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }]}
1.6.5.4 此外vue3在插件引入时有些时候会报错无法找到模块“xxx”的声明文件,此时需要在src目录下新建一个env.d.ts文件
/// <reference types="vite/client" />// 类型补充、环境变量declare module "*.vue" { import type { DefineComponent } from "vue"; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types const component: DefineComponent<{}, {}, any>; export default component;}// eslint-disable-next-line no-unused-varsinterface ImportMetaEnv { readonly VITE_APP_TITLE: string; readonly VITE_API_BASEURL: string; // 更多环境变量...}// 如果遇到路径缺失找不到的情况// 无法找到模块“xxx”的声明文件,就将该模块加入到下列代码中进行补充声明declare module "xxxx";
1.7 因为考虑是纯前端模拟后端给的路由数据
所以我自己模拟一个json文件,需在public文件夹中新建dynamicRouter.json来存放模拟后端返回的路由数据,后期从接口获取可进行更改
[ { "path": "/principle", "name": "principle", "component": "/principle/index", "meta": { "title": "Vue3响应式原理" } }, { "path": "/ref", "name": "ref", "meta": { "title": "ref类" }, "children": [ { "path": "/ref/index", "name": "ref", "component": "/ref/common/ref", "meta": { "title": "ref" } }, { "path": "/ref/toRaw", "name": "toRaw", "component": "/ref/common/toRaw", "meta": { "title": "toRaw" } }, { "path": "/ref/toRef", "name": "toRef", "component": "/ref/common/toRef", "meta": { "title": "toRef" } }, { "path": "/ref/toRefs", "name": "toRefs", "component": "/ref/common/toRefs", "meta": { "title": "toRefs" } }, { "path": "/ref/isRef", "name": "isRef", "component": "/ref/no-common/isRef", "meta": { "title": "isRef" } }, { "path": "/ref/Ref", "name": "Ref", "component": "/ref/no-common/Ref", "meta": { "title": "Ref" } }, { "path": "/ref/shallowRef", "name": "shallowRef", "component": "/ref/no-common/shallowRef", "meta": { "title": "shallowRef" } }, { "path": "/ref/triggerRef", "name": "triggerRef", "component": "/ref/no-common/triggerRef", "meta": { "title": "triggerRef" } } ] }]
如下是文件对应的位置
到目前为止整体的环境已经搭建完善,大概结构如下
2. 在views文件夹下新建文件夹login
在其中新建文件index.vue
<template> <div class="login"> //登录框 <div class="loginPart"> <h2>用户登录</h2> <el-form ref="ruleFormRef" :model="user" status-icon :rules="rules" label-width="100px" class="demo-ruleForm" style="transform: translate(-30px)" > <el-form-item label="账号:" prop="account" > <el-input v-model="user.account" placeholder="请输入账号" maxlength="20" clearable /> </el-form-item> <el-form-item label="密码:" prop="password" > <el-input v-model="user.password" type="password" placeholder="请输入密码" maxlength="20" show-password clearable /> </el-form-item> <el-button class="btn" type="primary" @click="onSubmit(ruleFormRef)" > 登录 </el-button> </el-form> </div> </div></template><script setup lang="ts">import { reactive, ref, onMounted } from "vue";//导入模拟的动态路由数据import menuData from '/public/dynamicRouter.json'; // 导入动态菜单数据import { ElMessage, type FormInstance } from "element-plus";import { useRouter } from "vue-router";import { initDynamicRoutes } from "@/router/dynamicRoutes"; // 导入初始化动态路由的方法import { useRouteStore, useUserStore} from "@/store";const router = useRouter();const routeStore = useRouteStore();const UserStore = useUserStore();type loginReq = { account: string; password: string;};onMounted(() => {});//from表单校验const ruleFormRef = ref<FormInstance>();// 这里存放数据const user = reactive<loginReq>({ account: "admin", password: "123456",});const users = reactive<loginReq>({ account: "admin", password: "123456",});//校验const validatePassword = (rule: any, value: any, callback: any) => { if (value === "") { callback(new Error("请输入密码")); } else { callback(); }};const validateAccount = (rule: any, value: any, callback: any) => { if (value === "") { callback(new Error("请输入账号")); } else { callback(); }};//校验const rules = reactive({ password: [{ validator: validatePassword, trigger: "blur" }], account: [{ validator: validateAccount, trigger: "blur" }],});const changeRegist = () => { router.replace("/regist");};const onSubmit = (formEl: FormInstance | undefined) => { if (!formEl) return; formEl.validate((valid) => { if (valid) { // 如果需要保存 token 或账户信息,存储在全局状态 // 假设这里会调用登录 API 返回一个 token const token = "mock_token"; // 模拟登录后返回的 token // 使用 Pinia 保存 token,并设置到 cookie 中 // 存储在 cookie 中的 token 可以配合 httpOnly 和 Secure 标志来增强安全性, // 这样可以防止 XSS 攻击并确保 token 只有在通过 HTTPS 协议时才会被发送 UserStore.setToken(token); ElMessage.success("登录成功"); // 获取菜单数据 if(routeStore.isRoutesInitialized){ // 使用 nextTick 确保路由添加完成后再进行跳转 nextTick(() => { // 跳转到首页或其他路由 router.push('/') // 假设 'home' 是你动态路由中的一个页面名称 .then(() => { console.log('跳转成功'); }) .catch((error) => { console.error('跳转失败', error); }); }); }else{ initDynamicRoutes(menuData) // 标记路由已初始化 routeStore.isRoutesInitialized = true // 使用 nextTick 确保路由添加完成后再进行跳转 nextTick(() => { // 跳转到首页或其他路由 router.push('/') // 假设 'home' 是你动态路由中的一个页面名称 .then(() => { console.log('跳转成功'); }) .catch((error) => { console.error('跳转失败', error); }); }); } } })};</script><style scoped lang="scss">.login { height: 100%; width: 100%; overflow: hidden;}.login__particles { height: 100%; width: 100%; background-size: cover; background-repeat: no-repeat; background-image: url("@/assets/0001.jpg"); opacity: 0.9; position: fixed; pointer-events: none;}h2 { margin: 0 0 30px; padding: 0; color: #fff; text-align: center; /*文字居中*/}.btn { transform: translate(170px); width: 80px; height: 40px; font-size: 15px;}</style>
3. layout中制作动态路由菜单
<!-- 自定义编辑的样式不采用element-plus --><template> <div class="app-container"> <header> <div class="menu"> <el-col :span="24"> <el-menu :router="true" active-text-color="#ffd04b" background-color="#545c64" class="el-menu-vertical-demo" text-color="#fff" @open="handleOpen" @close="handleClose" :unique-opened="true" :default-active="defaultPath" > <div v-for="(item, index) in menuList[0].children" :key="index" > <el-menu-item v-if="!item.children" :index="item.path" :route="item.path" > <!-- <el-icon><setting /></el-icon> --> <span>{{ item.meta?.title }}</span> </el-menu-item> <el-sub-menu v-if="item.children" :index="item.path" > <template #title> <!-- <el-icon><setting /></el-icon> --> <span>{{ item.meta?.title }}</span> </template> <el-menu-item-group> <el-menu-item v-for="(child, childIndex) in item.children" :key="childIndex" :index="child.path" > {{ child.meta?.title }} </el-menu-item> </el-menu-item-group> </el-sub-menu> </div> </el-menu> </el-col> </div> </header> <main> <router-view /> </main> </div></template><script lang="ts" setup>import { computed, onMounted } from "vue";import { useRouteStore } from '@/store'; // 调整路径为实际 Store 文件位置import { useRouter } from "vue-router";const router = useRouter()const routeStore = useRouteStore();const menuList = computed(() => routeStore.dynamicRoutes);const defaultPath = ref<string>('')const handleOpen = (key: string, keyPath: string[]) => { console.log('key', key); console.log('keyPathpen', keyPath);};const handleClose = (key: string, keyPath: string[]) => { console.log('close', key, keyPath);};onMounted(() => { defaultPath.value = routeStore.beforeRouter});</script><style lang="scss" scoped>* { margin: 0; padding: 0; box-sizing: border-box; /* 确保所有元素都遵循边框盒模型 */ touch-action: none;}.app-container { display: flex; /* 使用 flexbox 创建左右布局 */ justify-content: space-between; height: 100vh; /* 设置容器高度为视口高度 */ background-color: #fff;}header { width: 150px; /* 固定宽度,左侧菜单栏宽度,可以根据需要调整 */ // background-color: #f8f9fa; /* 设置背景颜色 */ box-shadow: 2px 0px 10px rgba(0, 0, 0, 0.1); /* 给左侧菜单栏添加阴影 */ overflow-y: auto; /* 左侧菜单支持垂直滚动 */ height: 100vh; /* 使 header 高度占满整个屏幕 */ .el-menu { height: 100vh; /* 高度占满全屏 */ width: 100%; /* 设置菜单宽度为220px,避免过窄 */ font-size: 0.175rem; font-weight: bold; color: #fff; overflow-y: scroll; /* 确保菜单项宽度一致 */ .el-menu-item, .el-sub-menu { width: 100%; /* 确保菜单项和子菜单项的宽度自适应 */ // padding-left: 0.25rem; /* 为每个菜单项增加左侧的内边距 */ } /* 一级菜单项 */ .el-menu-item { padding: 10px 20px; /* 设置内边距,避免菜单项过于拥挤 */ text-align: left; /* 左对齐文本 */ font-size: 0.175rem; /* 调整字体大小 */ } /* 二级菜单项(子菜单) */ .el-sub-menu { // padding-left: 10px; /* 为二级菜单增加缩进 */ background-color: #434d56; /* 给二级菜单背景设置一个较深的颜色 */ /* 子菜单项的缩进 */ .el-menu-item { padding-left: 1rem; /* 设置二级菜单项的缩进,区别于一级菜单 */ } } /* 子菜单的展开箭头样式 */ :deep(.el-sub-menu__icon-arrow) { color: #ffd04b; /* 设置箭头颜色为黄色 */ } /* 设置展开状态时,子菜单的背景色变化 */ .el-sub-menu.is-opened { background-color: #3a424a; /* 打开时的背景色 */ } /* 设置菜单项和子菜单项的 hover 状态 */ .el-menu-item:hover, .el-sub-menu:hover { background-color: #333c44; /* 鼠标悬浮时的背景色 */ } /* 设置当前激活的菜单项的背景颜色 */ .el-menu-item.is-active { background-color: #ff6600; /* 激活状态的背景色 */ } } .el-menu::-webkit-scrollbar{ display: none; } /* 自定义子菜单图标大小 */ .el-menu-item .el-icon, .el-sub-menu .el-icon { font-size: 1.2rem; /* 调整图标的大小 */ margin-right: 0.5rem; /* 给图标增加右侧的间距 */ } :deep(.el-sub-menu__title){ width: 100%!important; } .el-menu-item span, .el-sub-menu span { font-size: 10px; /* 设置文本的字体大小 */ font-weight: bold; /* 设置文本加粗 */ } :deep(.el-sub-menu__icon-arrow){ left: 50px!important; }}main { display: flex; flex: 1; /* main 占据剩余空间 */ // margin-left: 250px; /* 给 main 留出与 header 相同的空间 */ padding: 20px; overflow-y: auto; /* 支持内容区域滚动 */ background-image: linear-gradient(135deg, #102363, #346177, #3fa489, #34ec98); flex-direction: column;}header::-webkit-scrollbar, main::-webkit-scrollbar { display: none; /* 隐藏滚动条 */}/* 小屏幕适配 */@media (max-width: 768px) { .app-container { flex-direction: column; /* 在小屏幕下转换为上下布局 */ } header { width: 100%; /* 屏幕小于768px时,左侧菜单占满全宽 */ position: relative; /* 取消固定定位,方便移动 */ height: auto; /* 自动高度 */ } main { margin-left: 0; /* 小屏幕时不需要左侧留白 */ }}</style>