# vue3-admin-webpack5 **Repository Path**: simon9124/vue3-admin-webpack5 ## Basic Information - **Project Name**: vue3-admin-webpack5 - **Description**: 后台管理系统。前端基于:Webpack5(不使用脚手架)、Vue3.5、JavaScript、Vuex、ElementPlus;服务端基于:Koa3、MongoDB 8.0 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-08-06 - **Last Updated**: 2025-11-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README - 系统管理 - 用户管理 - 分组管理 ## 关于路由 ### 后端生成的动态路由 #### 初始路由:静态路由 - 只包含`login`、`403`、`404`三个页面 ```js export const constantRoutes = [ { path: "/", redirect: "login", }, { path: "/login", name: "login", component: importView("login"), }, { path: "/403", name: "notAuthenticated", component: importView("whiteList/403"), }, { path: "/:pathMatch(.*)*", name: "notFound", component: importView("whiteList/404"), }, ]; ``` #### 导航守卫:分为白名单、未登录、已登录 3 种情况 ```js // router/index.js router.beforeEach(async (to, from, next) => { const store = useStore(); const token = JSON.parse(localStorage.getItem("userInfo"))?.token; // console.log(to, from, next, token); if (whiteList.indexOf(to.name) > -1) { // 在免登录白名单 -> 直接进入 next(); } else if (!token) { // 未登录 if (to.name !== "login") { // 要跳转的页面不是登录页 -> 跳转到登录页 next({ name: "login", }); } else { // 要跳转的页面是登录页(或退出登录) -> 跳转到登录页 并 初始化路由(不含动态路由) next(); } } else if (token) { // 已登录 if (to.name === "login") { // 要跳转的页面是登录页 -> 跳转该角色的首页 next({ name: homeName, }); } else { // 要跳转的页面不是登录页 await store.dispatch("getRouters"); console.log(router.getRoutes(), router); next(); } } }); ``` #### 用户登录:生成**动态路由**和**动态菜单** - 用户登录成功后,调用`store.dispatch("userLogin")` ```js // login.vue formRef.value.validate(async (valid) => { if (valid) { await store.dispatch("userLogin", formData.value); router.replace({ name: "layout" }); } }); ``` ```js // store/user.js const state = { userInfo: { userName: "", userCode: "", token: "", }, }; const mutations = { setUserInfo: (state, data) => { state.userInfo = { userName: data.userName, userCode: 123, token: "user-token", }; localStorage.setItem("userInfo", JSON.stringify(state.userInfo)); }, }; const actions = { // 用户登录 async userLogin({ commit }, data) { await commit("setUserInfo", data); }, }; ``` - 之后调用`router.replace({ name: "layout" })`,此时会触发导航守卫的`store.dispatch("getRouters")`,获取动态数据(生成**动态路由**和**动态菜单**)后,进入登录后的首页 ```js // store/app.js const state = { dynamicRoute: [], // 动态路由数据 menuTree: [], // 左侧菜单 }; const mutations = { // 添加动态路由 SET_ROUTE(state, data) { state.dynamicRoute = data; state.dynamicRoute.forEach((route) => { if (route.parentRoute === "layout") { router.addRoute("layout", route); } else { router.addRoute(route); } }); // console.log(router, router.getRoutes()); }, // 添加动态菜单 SET_MENU(state, data) { state.menuTree = data; }, }; const actions = { // 获取路由和菜单数据 getRouters({ commit }) { const token = JSON.parse(localStorage.getItem("userInfo"))?.token; if (!state.menuTree.length && token) { console.log("getRouters"); const result = menuListHanding(menuList); commit("SET_ROUTE", result.dynamicRoute); commit("SET_MENU", result.menuTree); } }, }; ``` - `menuListHanding`方法,用递归的方式将平铺的菜单数据(例如后台获取的)处理成树形结构 ```js export const importView = (filePath) => { return () => import(/* webpackChunkName: "router-constant" */ `@/views/${filePath}.vue`); // 确保路径正确指向页面目录 }; // 将后台apiMenuList转化成动态路由和动态菜单 export const menuListHanding = (apiMenuList) => { let dynamicRoute = []; // 动态路由 let menuTree = []; // 动态菜单 // 根节点 apiMenuList.forEach((menu) => { if (menu.parentName === "layout") { // 菜单根节点 menuTree.push({ ...menu, children: [], }); // 路由根节点 if (menu.isBigScreen) { // 大屏 dynamicRoute.push({ path: menu.url, name: menu.name, component: importView(menu.component), }); } else if (menu.isOutSide) { // 外链 } else { dynamicRoute.push({ path: menu.url, name: menu.name, component: menu.component ? importView(menu.component) : "", children: [], parentRoute: "layout", }); } } }); // 非根节点递归:路由 const handleRouteItem = (dynamicRoute) => { dynamicRoute.forEach((subRoute) => { apiMenuList.forEach((menu) => { if (subRoute.name === menu.parentName) { if (menu.isBigScreen) { // 大屏 dynamicRoute.push({ path: menu.url, name: menu.name, component: importView(menu.component), }); } else if (menu.isOutSide) { // 外链 } else { subRoute.children.push({ path: menu.url, name: menu.name, component: menu.component ? importView(menu.component) : "", children: [], }); } } }); subRoute.children && subRoute.children.length && handleRouteItem(subRoute.children); }); }; handleRouteItem(dynamicRoute); // 非根节点递归:菜单 const handleMenuItem = (menuTree) => { menuTree.forEach((subTree) => { apiMenuList.forEach((menu) => { if (subTree.name === menu.parentName) { subTree.children.push({ ...menu, children: [], }); } }); subTree.children.length && handleMenuItem(subTree.children); }); }; handleMenuItem(menuTree); return { menuTree, dynamicRoute, }; }; ``` #### 用户退出:初始化路由,清空动态路由和动态菜单 ```js // 退出登录 const handleLogout = async () => { await store.dispatch("userLogout"); router.replace({ name: "login" }); }; ``` ```js // store/user.js const actions = { // 用户登出 async userLogout({ commit }) { localStorage.removeItem("userInfo"); localStorage.removeItem("activedMenu"); store.commit("SET_MENU", []); store.commit("DELETE_ROUTE"); }, }; ``` ```js // store/app.js const mutations = { // 删除动态路由 DELETE_ROUTE(state) { state.dynamicRoute.forEach((route) => { router.removeRoute(route.name); }); state.dynamicRoute = []; // console.log(router.getRoutes()); }, }; ``` - 再调用 #### 用户在登录状态刷新页面:重新加载动态路由 - 如果在动态路由页面刷新,会出现**页面白屏**或进入`404.vue`现象,这是因为**浏览器刷新会早于 router 导航守卫**,此时如果`404.vue`配置到了静态路由,则会进入该页面;反之则出现白屏现象 - 这里的解决方案也很简单,调整`main.js`即可 ```js // main.js const call = async () => { app.use(store); await store.dispatch("getUserInfo"); // 在router加载前,先获取动态路由即可 app.use(router).mount("#app"); }; call(); ``` #### 解决用户未登录时,能够进入 403 和 404 页面 - 初始静态路由去掉`403`和`404`,在`menuListHanding`方法中追加这两条数据(后续`store/app.js`中会根据用户登录/登出自动追加/删除这两条路由,在此不再赘述) ```js // 将后台apiMenuList转化成动态路由和动态菜单 export const menuListHanding = (apiMenuList) => { let dynamicRoute = []; // 动态路由 let menuTree = []; // 动态菜单 // ... dynamicRoute = dynamicRoute.concat([ { path: "/403", name: "notAuthenticated", component: importView("whiteList/403"), }, { path: "/:pathMatch(.*)*", name: "notFound", component: importView("whiteList/404"), }, ]); // ... return { menuTree, dynamicRoute, }; }; ``` ## 关于组件 ### element-plus 组件库 - 非动态组件,使用`unplugin-auto-import`和`unplugin-vue-components`实现自动按需加载 `npm i unplugin-auto-import unplugin-vue-components -D` ```js // webpack.config.js const AutoImport = require("unplugin-auto-import/webpack"); const Components = require("unplugin-vue-components/webpack"); const { ElementPlusResolver } = require("unplugin-vue-components/resolvers"); module.exports = { // ... plugins: [ AutoImport({ resolvers: [ ElementPlusResolver({}), // ElementPlus ], }), // 自动导入api Components({ resolvers: [ElementPlusResolver({})], // 解析器 }), // 自动导入组件 ], }; ``` ```js // main.js中无需引入elementplus了,可以在页面中直接使用 // import ElementPlus from "element-plus"; // import "element-plus/dist/index.css"; ``` - 动态组件无法被`unplugin-auto-import`识别,采用传统的按需加载 ### 顺带省略 vue 原生 api ```js // webpack.config.js module.exports = { // ... plugins: [ AutoImport({ imports: ["vue", "vuex", "vue-router"], // 引入的api(不用写import {ref} from 'vue'等) }), // 自动导入api ], }; ``` ## 关于图标 ### element-plus 原生方案 - 需写导入语句,动态组件需匹配映射表 ```vue ``` ### unplugin-icons 方案 - 无需导入语句(按需导入),动态组件匹配映射表 `npm i unplugin-icons -D` ```vue ``` ```js // webpack.config.js const IconsResolver = require("unplugin-icons/resolver"); const Icons = require("unplugin-icons/webpack"); module.exports = { // ... plugins: [ AutoImport({ resolvers: [IconsResolver.default({})], }), // 自动导入api Components({ resolvers: [ IconsResolver.default({ // prefix: "icon", // 修改Icon组件前缀,不设置则默认为i,禁用则设置为false enabledCollections: ["ep"], // 指定collection,elementplus图标集为ep }), ], // 解析器 }), // 自动导入组件 Icons.default({ autoInstall: true, // 自动安装 compiler: "vue3", // 使用vue方式编译图标 }), ], }; ``` ### iconify 方案(最终采用) - 用属性控制,无需映射表,参考文档 `npm i @iconify/vue @iconify-json/ep -D` - 使用`iconify`分为在线和离线(内网)2 种情况,在线时使用方式很简单 ```vue ``` - 离线时,需用`addCollection()方法`添加图标集 ```js import { Icon as IconifyIcon, addCollection } from "@iconify/vue/dist/offline"; addCollection(ep); ``` - 使用在线方案时,如遇到离线/内网,仍可与离线相同的方式添加图标集 ```js import { Icon as IconifyIcon, addCollection } from "@iconify/vue"; addCollection(ep); ``` ## 关于主题 ### element-plus 默认的暗黑模式 - `element-plus`默认的暗黑模式实现方法很简单,只需给顶部`html`追加`class="dark"`,并引入相关样式文件 ```html
``` ```js // main.js import "element-plus/theme-chalk/dark/css-vars.css"; ``` - 但这样一来,肯定会增大打包后的体积(全量导入暗黑模式的样式文件),同时也有悖于前面的**自动按需加载**原则(详见**关于组件**) ### scss 文件覆盖 element 主题 #### 整体直接覆盖 - 整体覆盖无需多言,将对应的样式覆盖即可 #### 根据基础色值和 scss 函数覆盖 - 此方法为整体覆盖的加强版,`element-plus`的色值变量`--el-color-primary-light-3、--el-color-primary-light-5、--el-color-primary-light-7、--el-color-primary-light-8、--el-color-primary-light-9、--el-color-primary-dark-2`均是由` --el-color-primary`基础色值生成的**浅色变体**和**深色变体**,可以用`sass`的函数生成相关色值 - `light-3`: 基础颜色与白色混合,透明度约 25% - `light-5`: 基础颜色与白色混合,透明度约 50% - `light-7`: 基础颜色与白色混合,透明度约 75% - `light-8`: 基础颜色与白色混合,透明度约 85% - `light-9`: 基础颜色与白色混合,透明度约 95% - `dark-2`: 基础颜色变暗约 20%生成 ```scss // Element Plus 自定义主题 - 使用Sass自动生成颜色变体 @use "sass:color"; @use "sass:map"; @use "sass:meta"; // 组件特定变量 @use "./components.scss"; // 基础颜色配置 $colors: ( "primary": #ff8343, "success": #59c9c5, "warning": #4148a6, "danger": #e74c3c, "error": #ff3860, "info": #3498db, ); // 生成颜色变体的函数 @function generate-color-variants($base-color) { $variants: (); // 基础颜色 $variants: map.merge( $variants, ( "base": $base-color, ) ); // Light variants (3, 5, 7, 8, 9) $variants: map.merge( $variants, ( "light-3": color.mix(#fff, $base-color, 15%), ) ); $variants: map.merge( $variants, ( "light-5": color.mix(#fff, $base-color, 15%), ) ); $variants: map.merge( $variants, ( "light-7": color.mix(#fff, $base-color, 30%), ) ); $variants: map.merge( $variants, ( "light-8": color.mix(#fff, $base-color, 50%), ) ); $variants: map.merge( $variants, ( "light-9": color.mix(#fff, $base-color, 70%), ) ); // Dark variants $variants: map.merge( $variants, ( "dark-2": color.mix(#000, $base-color, 20%), ) ); @return $variants; } // 生成RGB值的函数 @function get-rgb-values($color) { @return #{color.channel($color, "red")}, #{color.channel($color, "green")}, #{color.channel($color, "blue")}; } // 生成所有颜色的变体 $color-variants: (); @each $name, $color in $colors { $color-variants: map.merge( $color-variants, ( $name: generate-color-variants($color), ) ); } :root { // 为每种颜色生成所有变体 @each $color-name, $variants in $color-variants { @each $variant-name, $variant-color in $variants { --el-color-#{$color-name}#{if($variant-name != 'base', '-' + $variant-name, '')}: #{$variant-color} !important; } // 生成RGB值 --el-color-#{$color-name}-rgb: #{get-rgb-values( map.get($variants, "base") )} !important; } @each $variant in ("light-3", "light-5", "light-7", "light-8", "light-9", "dark-2") { --el-color-error-#{$variant}: var(--el-color-danger-#{$variant}) !important; } } ``` ```scss // components.scss body { // 错误 --el-color-error: var(--el-color-danger); --el-color-error-rgb: var(--el-color-danger-rgb); // 禁用 --el-disabled-bg-color: var(--el-fill-color-light); --el-disabled-text-color: var(--el-text-color-placeholder); --el-disabled-border-color: var(--el-border-color-light); // 按钮 --el-button-hover-text-color: var(--el-color-primary); --el-button-hover-bg-color: var(--el-color-primary-light-9); --el-button-hover-border-color: var(--el-color-primary-light-7); --el-button-active-text-color: var(--el-button-hover-text-color); --el-button-active-border-color: var(--el-color-primary); --el-button-active-bg-color: var(--el-button-hover-bg-color); // 菜单 --el-menu-active-color: var(--el-color-primary); --el-menu-hover-bg-color: var(--el-color-primary-light-9); --el-menu-hover-text-color: var(--el-color-primary); --el-table-row-hover-bg-color: var(--el-fill-color-light); --el-table-current-row-bg-color: var(--el-color-primary-light-9); --el-message-bg-color: var(--el-color-info-light-9); --el-message-border-color: var(--el-border-color-lighter); // 表单 --el-checkbox-checked-text-color: var(--el-color-primary); --el-checkbox-checked-input-border-color: var(--el-color-primary); --el-checkbox-checked-bg-color: var(--el-color-primary); --el-checkbox-checked-icon-color: var(--el-color-white); --el-radio-checked-text-color: var(--el-color-primary); --el-radio-checked-input-border-color: var(--el-color-primary); --el-radio-checked-icon-color: var(--el-color-primary); --el-select-option-selected-text-color: var(--el-color-primary); --el-select-input-focus-border-color: var(--el-color-primary); --el-switch-on-color: var(--el-color-primary); --el-link-hover-text-color: var(--el-color-primary); } ``` - 此方法能实现内置的主题变更,但局限是用户自由选择色值的**纯自定义主题仍不好实现**(就算在打包工具做配置,让`scss`文件读取`js`变量,也需考虑`scss`文件是否能读取到动态的值) ### js 生成自定义主题 - 思索良久,决定通过**修改顶部`html`文件的行内样式**来实现,其思路是前文方案的综合版: - 通过`js`生成(用户选择的)基础色值的变量,追加到顶部`html`文件中 - 此方法非常简洁,能轻松实现用户自定义主题 ```js /** * 切换自定义主题hooks * @param {String} color 基础色值 */ export function useElementTheme(color) { const color2rgb = (color) => { return color.startsWith("#") ? hex2rgb(color) : rgb2rgb(color); }; // rgb(255, 0, 0) | rgba(255, 0, 0) => [255, 0, 0] const rgb2rgb = (color) => { const colors = color.split("(")[1].split(")")[0].split(","); return colors.slice(0, 3).map((item) => parseInt(item.trim())); }; // #FF0000 => [255, 0, 0] const hex2rgb = (color) => { color = color.replace("#", ""); const matchs = color.match(/../g); const rgbs = []; for (let i = 0; i < matchs.length; i++) { rgbs[i] = parseInt(matchs[i], 16); } return rgbs; }; const rgb2hex = (r, g, b) => { const hexs = [r.toString(16), g.toString(16), b.toString(16)]; for (let i = 0; i < hexs.length; i++) { if (hexs[i].length === 1) { hexs[i] = "0" + hexs[i]; } } return "#" + hexs.join(""); }; // 颜色变亮 const lighten = (color, level) => { const rgbs = color2rgb(color); for (let i = 0; i < rgbs.length; i++) { rgbs[i] = Math.floor((255 - rgbs[i]) * level + rgbs[i]); } return rgb2hex(rgbs[0], rgbs[1], rgbs[2]); }; // 颜色变暗 const darken = (color, level) => { const rgbs = color2rgb(color); for (let i = 0; i < rgbs.length; i++) { rgbs[i] = Math.floor(rgbs[i] * (1 - level)); } return rgb2hex(rgbs[0], rgbs[1], rgbs[2]); }; const el = document.documentElement; el.style.setProperty("--el-color-primary", color); const lights = [3, 5, 7, 8, 9]; for (const light of lights) { el.style.setProperty( `--el-color-primary-light-${light}`, lighten(color, light / 10) ); } el.style.setProperty("--el-color-primary-dark-2", darken(color, 0.2)); } ``` ```html
``` #### 此方案下切换暗黑主题 - 1.将`element-plus`的`dark`样式文件(`node_modules\element-plus\theme-chalk\dark\css-vars.css`)放到项目中 - 2.追加少许未被覆盖的样式 ```scss [class="dark"] { --background-default: #212121; } body { background-color: var(--background-default); } ``` - 3.`js`控制顶部`html`切换`class` ```js // 切换白天/暗黑模式 handleToggleMode(mode) { if (mode === "moon") { document.documentElement.setAttribute("class", "dark"); } else { document.documentElement.setAttribute("class", "sun"); } }, ``` - 4.改造自定义切换主题方法,暗黑模式时调用`lighten`和`darken`方法**与白天模式相反**,从缓存中获取模式值即可 ```js el.style.setProperty("--el-color-primary", color); const lights = [3, 5, 7, 8, 9]; for (const light of lights) { el.style.setProperty( `--el-color-primary-light-${light}`, store.state.app.settingData.themeMode === "sun" ? lighten(color, light / 10) : darken(color, light / 10) ); } el.style.setProperty( "--el-color-primary-dark-2", store.state.app.settingData.themeMode === "sun" ? darken(color, 0.2) : lighten(color, 0.2) ); ``` - 因此切换白天/暗黑模式时,需**刷新一遍自定义主题** ```js /** * 切换白天/暗黑模式 * @param {String} mode 模式 sun or dark */ const handleToggleThemeMode = (mode) => { el.setAttribute("class", mode); handleCustomizeTheme(store.state.app.settingData.themeColor || "#409eff"); }; ``` - 5.项目尽量使用`element`原生组件(内置暗黑)、尽量不设置固定色值(用`element`的变量),未满足的部分则继续添加样式覆盖 ## 关于 hooks ### 复制到粘贴板 - 优先判断`navigator.clipboard`,不支持的情况下使用`document.execCommand` ```js // useClipboard.js export function useClipboard() { const copyText = ref(""); // 复制后的文本 /** * 复制文本到剪贴板 * @param {String} str 要复制的文本 * @returns 是否复制成功 */ const handleCopy = async (str, msg) => { if (navigator.clipboard) { // 支持 navigator.clipboard await navigator.clipboard.writeText(str); copyText.value = str; copySuccecc(msg); return; } else if (document.execCommand) { // 支持 document.execCommand const textarea = createTempTextarea(str); document.body.appendChild(textarea); textarea.select(); copyText.value = document.execCommand("copy"); document.body.removeChild(textarea); copySuccecc(msg); return; } else { ElMessage.error("抱歉,当前浏览器不支持该功能!"); } }; /** * 拷贝成功 * @param {String} msg 提示文案 */ const copySuccecc = (msg) => { msg && ElMessage.success(msg); }; /** * 创建临时的 textarea 文本域 * @param {String} str 预填充的文本内容 * @returns textarea 元素 */ const createTempTextarea = (str) => { const textarea = document.createElement("textarea"); textarea.style.position = "fixed"; textarea.style.opacity = "0"; textarea.value = str; return textarea; }; return { handleCopy, copyText }; } ``` ## 关于性能 ### 分包 ### 细节 #### 图标`iconify`方案中,`@iconify/vue/dist/offline`会比`@iconify/vue`打包体积略大,因此还需根据实际情况选择在线/离线方案 #### `mini-css-extract-plugin`插件报`[Warning] Conflicting order.` - 用`unplugin-auto-import/webpack`按需加载组件库,很可能导致打包时样式顺序“冲突”,在`MiniCssExtractPlugin`配置忽略警告即可 ```js // webpack.congif.js module.exports = { // ... plugins: [ new MiniCssExtractPlugin({ filename: "[name].[fullhash].css", chunkFilename: "[id].[fullhash].css", ignoreOrder: true, // 隐藏警告(css加载顺序问题) }), ], }; ``` ## 关于安全 ## 关于集成