企业级管理平台项目设计、架构、业务全解之平台篇
业务方面
菜单管理

7.0系统的菜单只有两级(第二级可以拥有子页面):
一级(比如http://localhost:3000/#/index)一定是菜单不是有效路由。会被重定向到二级路由(http://localhost:3000/#/index/home)

二级路由才是有效路由。

点击详情会进入所谓的三级路由(其实就是二级路由,在路由里注册了,菜单视图里却没有注册)

菜单管理二级路由的操作栏没有添加子路由按钮是怎么实现的?
 

const actions = [...,{label: t('list.添加子路由'),sign: row =>hasPermission('sys:route:add') && row.pcodes.split(',').length < 3,click: add}...
] 
这里的sign传的不是布尔值而是函数,而且这个函数还接受了一个参数。回到组件库看看:
<el-table-columnv-if="actions.filter(rr => !!rr.sign).length > 0":label="t('message.操作')"<template #default="scope"><el-button v-for="(info, index) in getActions(scope.row)" :key="index">{{ info?.label || '' }}</el-button></template>
<el-table-column/> 
  const getActions = (row: any) =>props.actions.filter(t =>typeof t.sign === 'function' ? t.sign(row) : t.sign); 
 
按钮权限

根据路由分类按钮权限。在登录后会通过接口获取当前用户的所有权限集,并存储在状态管理器里。
import { usePermissionStoreHook } from '@/store/modules/router';
const permissionStore = usePermissionStoreHook();
export function hasPermission(key: string) {const jurisdictionArr: Array<string> = permissionStore.appPermission;if (jurisdictionArr && jurisdictionArr.length) {return (jurisdictionArr.indexOf(key) > -1 || jurisdictionArr.indexOf('all') > -1);} else {// 无权限return false;}
} 
 
组织管理


组织拥有多层结构。绑定资源和用户概念。
用户、角色、查看范围、权限、路由
概念:
- 一个组织下有许多用户(用户只能有一个组织)。
 - 用户的查看范围不止它的所属组织(可以查看多个组织)。
 - 用户可以绑定多个角色。
 - 每种角色可以绑定按钮权限和路由。
 

角色:

框架亮点
微内核+插件化架构设计

怎么理解组件库组件提供了统一的设计语言和交互模式

架构设计有几种模式
Web 开发 7 年,八千字浅谈前端架构设计与工程化结合自己多年的 Web 开发实际项目经验,分享一些我对前端架构设计、 - 掘金
前端设计模式有哪几种
盘点前端开发中最常见的几种设计模式设计模式介绍 设计模式是开发的过程中,遇到一些问题时的解决方案,这些方案是通过大量试验 - 掘金
项目中涉及到的几种前端设计模式举例
单例模式
单例模式的作用
资源管理: 确保关键资源只有一个实例
配置统一: 避免重复配置
内存优化: 减少不必要的对象创建

工厂模式
动态创建: 根据参数动态创建对象
解耦: 客户端不需要知道具体的创建过程
扩展性: 易于添加新的产品类型
工厂模式(Factory Pattern) 是一种创建型设计模式,它提供了一种创建对象的最佳方式。工厂模式的核心思想是:
不直接使用 new 操作符创建对象
通过工厂方法来创建对象
将对象的创建逻辑封装起来


简单来说就是用户只需要传入配置,就能享受到工厂根据配置在内部加工后产出的产品了,用户看不到工厂内部做了什么,也不关心它做了什么。
装饰器模式
装饰器模式的作用
功能扩展: 在不修改原有代码的基础上添加新功能
职责分离: 将不同的功能分离到不同的装饰器中
动态组合: 可以动态地组合不同的装饰器
应用层和组件库的国际化处理方案
组件库和应用库的国际化分开管理
分包自己维护一套i18n
平台的语言包状态生效后,调用组件库里提供的方法切换语言包
切换分包里的i18n状态就行了
颜色主题无缝切换
业务包:修改css变量

组件库:
无需改动,用的也是跟主包一样的css变量名。到时引入后会继承docuemnt最顶层也就是主包里的css变量值

脚本工具梳理
mac权限

初始化注入husky
"prepare": "husky install" 

确保在每一次安装依赖后husky都能被正常安装

"postinstall": "npm run prepare && cd script && node prepareHusky" 
prepare会被执行两次(一次是pnpm install一次是husky install),但是没办法,prepare的顺序在postinstall之后。我必须确保husky已经安装上。
把自定义的检查文件移动到.husky文件夹中:


实际流程:


引用本地npm组件
"localBase": "pnpm uninstall yzy-base && cd script && node localBase", 

语言包差异比较
找到两个语言包的地址并解析文件内容,比较差异
const zhCnPath = path.join(path.resolve('..'), 'src/lang/package/zh-cn.ts');
const enPath = path.join(path.resolve('..'), 'src/lang/package/en.ts');// 把文件里面的键值对导出到node.js里来(由文件变成实际数据)
const zhCnData = parseLangFile(zhCnPath);
const enData = parseLangFile(enPath);function parseLangFile(filePath) {// 使用 UTF-8 编码同步读取指定路径的文件内容const content = fs.readFileSync(filePath, 'utf8');// (['"]?) - 第一个捕获组:匹配可选的引号(单引号或双引号)// ([^'":\s]+) - 第二个捕获组:匹配键名,不能包含引号、冒号或空格// \1 - 反向引用:确保键名两边的引号类型一致// \s*:\s* - 匹配冒号前后的可选空格// ['"]([^'"]*)['"] - 第三个捕获组:匹配被引号包围的值const regex = /(['"]?)([^'":\s]+)\1\s*:\s*['"]([^'"]*)['"]/g;let match;while ((match = regex.exec(content)) !== null) {const key = match[2];const value = match[3];if (key && value) {keyValuePairs[key] = value;}}return keyValuePairs;
}console.log(`zh-cn.ts 文件包含 ${Object.keys(zhCnData).length} 个键值对`);
console.log(`en.ts 文件包含 ${Object.keys(enData).length} 个键值对\n`); 
设置基准对象(一般就是中文包,判断中文包里的键名英文包里是否拥有),进行比较
// 找出缺少的键const missingKeys = findMissingKeys(zhCnData, enData);// 比较两个对象,找出缺少的键
function findMissingKeys(baseObj, compareObj) {const missingKeys = [];for (const key in baseObj) {if (!(key in compareObj)) {missingKeys.push({key: key,value: baseObj[key]});}}return missingKeys;
} 
判断missingKeys长度是否大于0,大于0输出这个键值对就行了
如果想输出为文本形式,则拼接为文本字符串,并输出:
const outputPath = path.join(__dirname, 'logs/missing-keys.txt');
const outputContent = missingKeys.map(item => `'${item.key}': '${item.value}',`).join('\n');
fs.writeFileSync(outputPath, outputContent, 'utf8') 
 
Websocket相关
心跳

当链接建立或收到消息后,3秒后发ping,6秒后检查连接状态。如果在3-6秒之间又收到消息(pong或ws消息),就会取消检查连接状态,并开始重复上面操作。如果在3秒内收到,就会取消ping发送和连接状态。如果没有收到,就会主动检查ws.readyState(实时的,但有延迟)。如果没有异常,则继续执行心跳。
// 在链接成功和收到消息时执行心跳检查
class Ws {...ws.onopen = e => {...this.heartCheck();}// 消息接收ws.onmessage = e => {// 心跳this.heartCheck();// 回调this.onmessage(e);};// 心跳检查private heartCheck() {// 清除心跳、检查连接接定时器this.h_timer && clearTimeout(this.h_timer);this.c_timer && clearTimeout(this.c_timer);// this.h_timer = setTimeout(() => {// 发送ping(this.ws as WebSocket).send('ping');// 检查连接状态定时器,确认连接状态this.c_timer = setTimeout(() => {// 连接失败,进行关闭if ((this.ws as WebSocket).readyState !== 1) {this.close();}else {// 心跳this.heartCheck();}}, 3000);}, 3000);}...
} 
 
HTTP拦截器
在axios提供的请求/响应拦截器注入系统的逻辑就好了。
初始化请求对象:

请求拦截器:
响应拦截器:

错误处理(一般是响应状态码不对),接上面的error函数

路由鉴权
路由守卫设计
import NProgress from 'nprogress'; //进度条插件
import 'nprogress/nprogress.css';// 白名单路由 不需要登录即可访问
const whiteList = ['/login'];router.beforeEach(async (to, from, next) => {// 进度条启动NProgress.start();const hasToken = sessionStorage.getItem('accessToken');// 没有token,则没有登录if (!hasToken) {// 未登录可以访问白名单页面if (whiteList.indexOf(to.path) >= 0) {next();} else {// redirect是登录后顺着原路跳转回去的路径next(`/login?redirect=${to.path === '/login' ? from.path : to.path}`);NProgress.done();}} else {// 查看权限路由是否已经生成。如果生成了则放行if (permissionStore.routes.length === 0) {// 路由鉴权核心逻辑:生成账号权限路由const accessRoutes = await permissionStore.generateRoutes();accessRoutes?.forEach((route: any) => {// router看下面router.addRoute(route);});next({ ...to, replace: true });} else {next();}NProgress.done();}
}) 
为什么判断有权限路由就给放行呢?因为一旦是跳转的页面已经注册了路由,那么可以正常跳转。如果没有注册,则会统一跳到404页面去,所以不需要额外的判断
路由初始化注册
沿用了路由懒加载(vite会根据动态引入进行分chunk)
// hash模式
import { createRouter, createWebHashHistory } from 'vue-router';/** @name 创建路由 */
const router = createRouter({history: createWebHashHistory(),routes: constantRoutes as RouteRecordRaw[],// 刷新时,滚动条位置还原scrollBehavior: () => ({ left: 0, top: 0 })
});export const constantRoutes = [{path: '/login',component: () => import('@/views/login/index.vue'),name: 'Login',meta: { hidden: true }},// 404{path: '/:pathMatch(.*)',component: () => import('@/views/error/404.vue')},// 初始入口'/'重定向为'/index'{path: '/',component: Layout,redirect: '/index',children: [{path: '/index/task_Center',component: () => import('@/views/task_Center/index.vue'),name: 'task_Center',meta: {keepAlive: false,title: t('list.任务中心')}}]},
]export default router;// main.ts
app.use(router) 
上面是初始化router,只有三个。生成权限路由后会通过router.addRoute(route)挂载到路由里去。
生成权限路由
原理是将后端返回的路由与本地的路由表比对后生成route格式的路由进行挂载。
const accessRoutes = await permissionStore.generateRoutes();
accessRoutes?.forEach((route: any) => {router.addRoute(route);
});
 
 
面包屑
面包学就是红色框,显示了当前页面标签和历史页面标签。可以通过切换或关闭进行控制。

layout

这是页面骨架。这个系统只有二级路由才是有效路由(结构:一级路由/二级路由)

面包屑实现

 <q-tags-view:routers="visitedViews"      // 面包屑列表:route="route"               // 当前面包屑@change="changeView"         // 切换面包屑
/> 
业务组件只是个视图容器而已。我们关注visitedViews、route、changeView怎么生成就好了。
visitedViews
在状态管理器里维护,在路由守卫里触发新增

// store
const visitedViews = ref<TagView[]>([]);
function addVisitedView(view: TagView) {// 没有标题无法正常显示if (!view.meta || !view.meta.title) return;// 已经有重复路由了if (visitedViews.value.some(v => v.path === view.path)) {replaceVisitedViewQuery(view);return;}visitedViews.value.push(view)
}// 处理重复路由
function replaceVisitedViewQuery(view: TagView) {const viewInfo = visitedViews.value.find(v => v.path === view.path);if (viewInfo && viewInfo.query !== view.query) {viewInfo.query = view.query;}
} 
route
 import { useRoute } from 'vue-router';const route: any = useRoute();// 组件库判断当前路径
function isActive(tag: Router) {return tag.path === props.route?.path;
} 
changeView
这个方法监听了视图组件库的一系列变化:点击、关闭当前面包屑、关闭其他面包屑
const changeView = (tag: any) => {const index = visitedViews.value.findIndex((v: { path: string }) => v.path === tag.router.path);// 发生操作后用户应该跳转到哪个页面// ① 如果不是最后一个,那么无论是发生点击还是删除,用户下一个目标页一定是index// ② 如果是最后一个,那么如果是点击就不变,删除就是往前推一个const currIndex =index < visitedViews.value.length - 1? index: tag.type === 'click'? index: visitedViews.value.length - 2;} 

页面缓存
<!-- layout.vue --><!-- 精简写法 -->
<el-main class="main" id="v7main"><router-view v-slot="{ Component }"><keep-alive :include="caches"><component :is="Component" /></keep-alive></router-view></el-main><!-- 完整写法 -->
<router-view v-slot="slotProps"><keep-alive :include="caches"><component :is="slotProps.Component" /></keep-alive>
</router-view><!-- 更完整写法 -->
<router-view><template v-slot:default="slotProps"><keep-alive :include="caches"><component :is="slotProps.Component" /></keep-alive></template>
</router-view> 
当使用 v-slot 时,如果不指定插槽名称,Vue 会将其视为默认插槽。对于默认插槽,可以直接在组件标签上使用 v-slot,而不需要包装在 <template> 中。
// cacheHook.ts
import { useRoute } from 'vue-router';
// 一级路由不计入缓存
const ignoreGathers = ['base-layout'];
const caches = ref<string[]>([]);
export default function useRouteCache() {const route = useRoute();function gatherCaches() {watch(() => route.path, storeRouteCaches, {immediate: true});}// 对当前路由进行缓存function storeRouteCaches() {// route.matched会包含从根路由到当前路由的所有匹配的路由记// 例如:[// { path: '/user', component: UserLayout, meta: { keepAlive: true } },// { path: '/user/profile', component: ProfileLayout, meta: { keepAlive: false } },// { path: '/user/profile/settings', component: SettingsPage, meta: { keepAlive: true }}]route.matched.forEach(routeMatch => {const componentDef: any = routeMatch.components?.default;const componentName = componentDef?.name || componentDef?.__name;if (ignoreGathers.includes(componentName)) return;// 配置了meta.keepAlive的路由组件添加到缓存if (routeMatch.meta.keepAlive) {if (!componentName) {// eslint-disable-next-line no-consoleconsole.warn(`${routeMatch.path} 路由的组件名称name为空`);return;}caches.value.push(componentName as string);}})}}// APP.vue 一级路由是router-view
<template><ElConfigProvider ref="el" :locale="appStore.locale" :size="appStore.size"><router-view /></ElConfigProvider>
</template><script lang="ts" setup>...const { gatherCaches } = useRouteCache();// 开始缓存收集gatherCaches();
</script> 
 
TS global全局声明

/src/types/global.d.ts
声明在系统中全局类型接口,用于业务逻辑


/src/vite-env.d.ts
声明Vite环境和模块类型,用于构建工具集成
vite是运行在node环境下的,它遵循tsconfig.node.json里的配置。这里面要求ts在node环境下读取
vite-env.d.ts的声明。

declare module 'element-plus';
declare module 'yzy-base'; 

系统打包
应用层的打包,依旧是build属性配置:
  target: 'es2015',outDir: 'dist', 
这块在组件库的打包里讲过。
 sourcemap: env.BUILD_SOURCEMAP === 'true', 
根据环境变量配置。
sourcemap


生成js.map文件


.map文件包含了从压缩代码到原始源代码的映射关系(主要是mappings属性,包括了原始文件和编译文件的行列对应关系)。

举例:
开启了sourceMap:
浏览器控制台还原“源映射”后:


关闭sourcemap后浏览器显示:、


H5平台
针对首屏加载,采用路由懒加载+KeepAlive+ManualChunks方案

KeepAlive跟上面web平台一样,通过路由元信息判断,这里不讲了。
ManualChunks

// vite.config.ts
import { configManualChunk } from './config/vite/optimizer'
...rollupOptions: {output: {manualChunks: configManualChunk,},}, 
这个实现起来比较复杂,可以看面试篇
// ./config/vite/optimizer// 分包策略执行
export const configManualChunk = (id: string) => {if (/[\\/]node_modules[\\/]/.test(id)) {const matchItem = vendorLibs.find((item) => {const reg = new RegExp(`[\\/]node_modules[\\/]_?(${item.match.join('|')})(.*)`, 'ig')return reg.test(id)})return matchItem ? matchItem.output : null}
}// 分包策略
const vendorLibs: { match: string[]; output: string }[] = [// Vue 核心库(必须优先加载){match: ['vue', 'vue-router', 'pinia'],output: 'vue-vendor'},// UI 组件库(体积较大){match: ['vant'],output: 'vant-vendor'},// 地图相关(按需加载){match: ['@amap'],output: 'amap-vendor'},// 工具库{match: ['axios', 'dayjs', 'lodash'],output: 'utils-vendor'},// 图表库(体积大,且不是首屏必需){match: ['echarts'],output: 'echarts-vendor'}
] 

基于postcss-pxtorem完成PX到REM的自动转换,动态设置根字体
<metaname="viewport"content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
/> 
width=device-width:视口宽度等于设备宽度
initial-scale=1:初始缩放比例为 1
maximum-scale=1, minimum-scale=1:禁止缩放
user-scalable=no:禁止用户手动缩放


PostCSS
在开发阶段和生产构建阶段皆参与转换。

开发阶段:

构建阶段:

postcss-pxtorem:用于移动端响应式适配的 PostCSS 插件配置,它会自动将 CSS 中的 px 单位转换为 rem 单位,实现不同屏幕尺寸的自适应布局。
为什么移动端上rem单位比px单位好呢?


原生H5的移动端项目是否可以直接用rem呢?
可以,但是需要手动设置基准值(root元素的font-size)。
项目里对移动端响应式适配的处理
通过 postcss-pxtorem(构建时自动转 px 到 rem) + setRootFontSize(运行时动态设置基准值) 两部分配合

setDomFontSize:
// main.ts
const setDomFontSize = (): void => {const width = document.documentElement.clientWidth 
|| document.body.clientWidth// 如果屏幕375宽 那么fontSize是37.5 则rem * 37.5 = px与设计图刚好契合// 如果屏幕是750宽,那么fontSize是75  则rem * 75 = 2倍px 刚好对设计图放大了两倍const fontsize = width / 10 + 'px'document.getElementsByTagName('html')[0].style.fontSize = fontsize
}
setDomFontSize()const setDomFontSizeDebounce = _.debounce(setDomFontSize, 400)
window.addEventListener('resize', setDomFontSizeDebounce)