当前位置: 首页 > news >正文

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

业务方面

菜单管理

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;}
}

组织管理

组织拥有多层结构。绑定资源和用户概念。

用户、角色、查看范围、权限、路由

概念:

  1. 一个组织下有许多用户(用户只能有一个组织)。
  2. 用户的查看范围不止它的所属组织(可以查看多个组织)。
  3. 用户可以绑定多个角色。
  4. 每种角色可以绑定按钮权限和路由。

角色:

框架亮点

微内核+插件化架构设计

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

架构设计有几种模式

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)
http://www.dtcms.com/a/564889.html

相关文章:

  • android TAB切换
  • 免费试用网站源码上海网站建设穹拓
  • Linux的df和du
  • 【保姆级教程】Debian 服务器 MariaDB/Mysql 配置 Windows 远程连接全流程
  • JAVA算法练习题day58
  • linux-用户和组权限
  • 基于Vue+Python+Orange Pi Zero3的完整视频监控方案
  • 若依开源项目做导入数据时同步新增字典,页面下拉框与表格未同步更新问题
  • 网站权重多少4赤峰网站建设哪个服务好
  • 珠海seo海网站建设南京做网站建设搭建的公司
  • 仓储物流人力如何管理?实时看板动态展示进度,支持管理者即时调整人力
  • 系统架构设计师备考第62天——嵌入式系统软件架构设计方法
  • LeetCode 刷题【143. 重排链表】
  • 网站建设与管理工资wordpress仪表盘添加内容
  • 常见的分布式系统面试题清单
  • 基于 U-Net 的医学图像分割
  • 【图像处理基石】多频谱图像融合算法入门
  • 室温反应蒸发+200℃退火调控 MoOₓ/NiOₓ薄膜:光伏空穴传输材料性能优化与效率潜力(>25%)分析
  • 微算法科技(NASDAQ MLGO):DPoS驱动区块链治理与DAO机制融合,共筑Web3.0坚实基石
  • 视频直播点播平台EasyDSS:助力现代农业驶入数字科技“快车道”
  • 迈网科技 官方网站网站建设调研问卷
  • vue 实现自定义message 全局提示
  • 电商网站里的图片网站开发中 视频播放卡
  • [手机AI开发sdk] 模型冻结解冻.pb | `aidlite`加速AI模型
  • 2025 年热门 CV 会议论文【源码复现】:Neural Inverse Rendering from Propagating Light
  • 中小企业网站建设与管理南通网站排名团队
  • TypeScript 队列实战:从零实现简单、循环、双端、优先队列,附完整测试代码
  • LeetCode hot100:189 轮转数组:三种解法从入门到精通
  • 初识MYSQL —— 基本查询
  • 项目打包与部署 —— 把 Java 项目 “装瓶带走”(本地运行→服务器落地全流程)