当前位置: 首页 > 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:根据上面的思想,对设备宽度进行十等分,一个rem等于设备宽度/10

// main.tsconst 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 = setDomFontSize / 10 + 'px'document.getElementsByTagName('html')[0].style.fontSize = fontsize
}
setDomFontSize()const setDomFontSizeDebounce = _.debounce(setDomFontSize, 400)
window.addEventListener('resize', setDomFontSizeDebounce)

postcss配置:

核心配置rootValue,设计稿是750,则750/10,设计稿是300,则300/10

// postcss.config.js
'postcss-pxtorem': {rootValue: 75,              // 基于750设计稿,75 = 750/10unitPrecision: 6,           // 保留6位小数,确保精度propList: ['*'],            // 所有属性都转换selectorBlackList: [        // 不转换的选择器'.no-rem','van-',                   // Vant组件不转换'el-',                    // Element Plus不转换(如果有)],replace: true,              // 直接替换,不保留pxmediaQuery: true,           // 媒体查询中的px也转换minPixelValue: 1,           // 最小转换值,1px也转换exclude: /node_modules/i,   // 排除第三方库
},

autoprefixer

// postcss.config.jsmodule.exports = {plugins: {autoprefixer: {overrideBrowserslist: ['Android 4.1','iOS 7.1','Chrome > 31','not ie <= 11', //不考虑IE浏览器'ff >= 30', //仅新版本用“ff>=30'> 1%', //  全球统计有超过1%的使用率使用“>1%”;'last 2 versions', // 所有主流浏览器最近2个版本],grid: true, // 开启grid布局的兼容(浏览器IE除外其他都能兼容grid,可以关闭开启)}
}

转成浏览器适配代码

零拷贝、自动同步的canvas离屏渲染

webworker

canvas的离屏渲染将在worker线程中执行

ts类型声明

import dvsWaterfallCanvasWorker from '/@/aiot/worker/waterfallCanvas?worker&inline'
worker.value = new dvsWaterfallCanvasWorker()
离屏canvas + webWorker渲染
<template lang="pug">canvas(ref="gkDvsWaterfallCanvasRef")
</template><style>canvas {position: absolute;width: 100%;height: 100%;}
</style>

发送离屏渲染能力到后台worker

const width = gkDvsWaterfallContainerRef.value.offsetWidth
const height = gkDvsWaterfallContainerRef.value.offsetHeight
// 把canvas转成离屏绘画对象
const offscreen = gkDvsWaterfallCanvasRef.value.transferControlToOffscreen()
worker.value.postMessage({type: 'config',data: {canvas: offscreen,{width, height}},})

worker代码:

// self 是 Web Worker 中的全局对象,就像浏览器主线程中的 window 一样
self.onmessage = (event) => {if (event.data.type === 'config') {dataCanvasEl = data.canvasdataCtx = dataCanvasEl.getContext('2d')dataCtx.fillRect(0, 0, 100, 100);  // 绘制后自动显示在页面的 <canvas> 上!}else if ...
}// 向主线程发送信息
// self.postMessage({ type: 'camera', data: imgData })// Worker 线程
const ctx = offscreen.getContext('2d');
ctx.fillRect(0, 0, 100, 100);  // 绘制后自动显示在页面的 <canvas> 上!

注意,不需要通讯。在worker里绘画的话,通过共享内存,前台就能同步显示。

两种离屏画布的区别

transferControlToOffscreen和new OffscreenCanvas都是离屏画布,但是本质上有很大的区别!

transferControlToOffscreen:

new OffscreenCanvas:

http://www.dtcms.com/a/566098.html

相关文章:

  • 玩客云做网站建设网站的网站首页
  • 淘宝客导购网站怎么做运营推广seo招聘
  • 第一次全国水利普查公报的土壤保持部分
  • 爬虫数据清洗可视化链家房源
  • 2.1.1.HTML5
  • DP1363F 多协议NFC 兼容CLRC663开发资料
  • 2025-11-03 ZYZ28-NOIP模拟赛-Round1 hetao1733837的record
  • 建设网站怎样做如何利用互联网营销
  • Claude Code 原生安装教程
  • ps做游戏下载网站有哪些做网站备案都需要什么东西
  • Anaconda Prompt系统找不到指定路径
  • 聚类(Clustering)详解:让机器自己发现数据结构
  • cglib动态代理之MethodProxy
  • 网站公网安备链接怎么做网站建设需要多少资金
  • 重点专业建设网站网上接效果图平台
  • 海北网站建设队徽logo设计
  • 北京专业网站建设网站水头网站建设
  • Git hook pre-commit
  • 展示系统 网站模板广西新闻
  • Java基础语言进阶学习——2,对象创建机制与内存布局
  • 网站seo啥意思怎么做罗田县建设局网站
  • 基于yolov8的果蔬识别检测系统python源码+onnx模型+数据集+精美GUI界面
  • 网站建设合同电子版苏州网页制作报价
  • 廊坊网站建设方案策划民治做网站
  • 走路摆臂幅度大给人影响差-----坏习惯
  • TRO重磅消息 野生动物插画师Roger Hall跨境维权风暴来袭
  • ABB焊接机器人节气装置
  • Linux 孤儿进程和僵尸进程详解
  • jsp做网站用什么封装字符串餐饮加盟什么网站建设
  • 做网站挂谷歌广告赚钱吗电子商务网站模板html