# 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加载顺序问题)
}),
],
};
```
## 关于安全
## 关于集成