讲讲项目里的仪表盘编辑器(二)
应用场景
正常来说,编辑器应用场景应该包括:
- 编辑器-预览
- 编辑器
- 最终运行时
怎么去设计
上一篇推文,我们已经大概了解了编辑器场景。接下来,我们来看预览时的设计
编辑器-预览
点击预览按钮,执行以下逻辑:
/** @name 预览 **/async handlePreview() {...// 打开抽屉组件,并往里面放置运行时模块createDrawer(h => h(DashboardRuntime, { props: { dashboard: this.form } }),{title: '预览仪表盘',width: 'calc(100vw - 200px)',},);}
也就是说:
所以我们直接关注运行时表现
运行时设计
<template><HabitContext :habitKey="habitKey" @init="habitContextInit"><!-- loading框 --><a-spin v-if="loading" /><divv-else:style="styleCSSVariable"><background :background="themeBackground" :class="$style.background"><grid-layout v-bind="layoutProps"><dashboard-itemv-for="field in fields":key="field.pkId"/></grid-layout></background></div></HabitContext>
</template>
这里套了一层HabitContext框架,是用来应用和记录用户习惯的(后面讲)。a-spin是加载层。紧接着和设计器差不多,局部变量样式集里面套了个背景框架和grid-layout布局。
我们再看看dashboard-item的实现:
<template><grid-itemv-bind="layout"static>...</grid-item>
</template>
这里通过v-bind动态传入grid-item的属性(也就是拣选出来的x/y/w/h这些)。同时用static固定gird-item,使其无法缩放、拖动、被其他元素影响。
<template><grid-itemv-bind="layout"static><divv-if="showChart">...</div><!-- 没权限显示占位图 --><divv-elsestyle="height: 100%; width: 100%; display: flex; flex-direction: column"><div><span :style="titleCss">{{ field.name }}</span></div></div></grid-item>
</template>
这里就是简单的做了一个占位
<template><grid-itemv-bind="layout"static><divv-if="showChart"><div :class="$style.action"><template v-for="action in actions"><a-tooltip:key="action.key"placement="bottom":mouseLeaveDelay="0":title="action.name"><x-icon:type="action.icon"@click="execAction(action)"/></a-tooltip></template></div><component:is="component":field="field"/></div><!-- 没权限显示占位图 --><divv-elsestyle="height: 100%; width: 100%; display: flex; flex-direction: column"><div><span :style="titleCss">{{ field.name }}</span></div></div></grid-item>
</template>
浮层按钮还有具体的图表组件
数据流设计
到这里,我们已经看完了编辑器功能的大概设计。接下来该写写这套系统最核心的部分,数据流设计了。
创建一个仪表盘编辑器
点下新增按钮后,我们传入一些系统参数【应用id,功能类别等等,在这里我们并不需要关注】储存新建仪表盘在系统的位置和属性。
在接口储存完这些系统信息后,跳转到仪表盘页面进行最为关键的仪表盘初始化数据生成。
async handleAddForm(category) {// 弹窗让填写名称、图标等基础信息const result = await GroupForm.createModal({data: { parentId: this.groupId, appId: this.appId, category },},{title: this.getCategoryName(category),width: '427px',},);// 调用接口保存const formId = await add(result);this.$message.success(this.$t('common.tips.add'));// 保存完毕后跳转到页面switch (category) {case FormCategoryType.DASHBOARD:return this.$router.push(`/dashboard-design/${formId}`);...default:return this.$router.push(`/form-design/${formId}/form`);}}
这里是通过vue-router进行跳转。这里也简单贴出路由代码
import DashboardDesign from '@/views/dashboard-design';const DashboardDesignRoutes = [{path: '/dashboard-design/:id',component: DashboardDesign,},...
];export default DashboardDesignRoutes;
到这里结束,一个仪表盘编辑器已经创建完毕了。它只存储了系统数据,没有仪表盘的初始数据。而当我们进入仪表盘编辑器页面的时候,完成有效编辑之后,才会以正式数据存储下来。
当然这里指的是前端数据,后端还是会根据我们穿进去的系统参数生成一份默认的接口向的仪表盘数据模板(比如默认权限、默认刷新时间上面的)
进入仪表盘编辑器页面
先通过后端接口,拿到当前仪表盘编辑器id的接口数据
@formDesignModule.Action init;
async created() {...await this.init(this.formId).then(() => {...}
}
大概长这样,记录一些系统信息或默认属性 。这里的init是vuex的action操作。为了是把数据保存到前端本地。更多关于本项目的vuex方法请看我另外一篇文章的介绍
讲讲项目里的状态存储器vuex_AI3D_WebEngineer的博客-CSDN博客https://blog.csdn.net/weixin_42274805/article/details/133237271?spm=1001.2014.3001.5501
看看这个init的actions做了什么?
actions: {async init({ commit }, formId) {const form = await getFormData(formId);commit('saveForm', form);}
}
mutations: {saveForm(state, data) {state.form = data;...state.loading = false;state.changed = false;}
}
这里是通过调用接口获取当前仪表盘的数据,并把它存到当前的formDesignModule,也就是formDesign这个命名空间的仓库里。
仪表盘编辑页面的状态管理器
我们刚刚看到了代码,在编辑页面created里我们执行了init。其实就是非显示地获取数据。吧获取数据的过程从页面隐式地放到了状态管理器里的actions里面。并通过state返回关注的数据。这样子无论我们在仪表盘功能里怎么去跳转页面,都不需要再重新调用接口了,而是直接从仓库里拿。
@formDesignModule.State form;@formDesignModule.Action init;@formDesignModule.State loading;@formDesignModule.State selectedField;@formDesignModule.Getter fields;@formDesignModule.Mutation updateSelectedField;@formDesignModule.Mutation selectField;@formDesignModule.Mutation updateSetting;@formDesignModule.Mutation saveForm;@formDesignModule.Mutation updateDashboardConfig;@formDesignModule.Action save;
大概有这些属性和方法来完成编辑器的功能实现。看看就行了。紧接着我们来讲其中一些实现
点击添加组件到仪表盘
有两种添加方法:
① 点击组件按钮添加
② 拖拽组件添加
点击组件
handleClickAdd() {...// 初始化layoutconst layout = getDashboardLayoutByType(type);const layoutList = ensureArray(this.$refs.container.layout);layout.x = (layoutList.length * 2) % 60;layout.y = layoutList.length + 60;field.widget.layout = layout;// 初始化风格field = this.initFieldStyle(field);
}
getDashboardLayoutByType是根据你点击的组件生成默认的组件layout数据。比如图片组件定义的默认layout是:
export function getDashboardLayoutByType(type) {const layout = getDashboardControlMeta(type, 'layout');return { x: 0, y: 0, ...(typeof layout === 'function' ? layout() : layout) };
}
这时返回了一个初始化的layout即{w:30,h:15,minH:7,x:0,y:0}。
const layoutList = ensureArray(this.$refs.container.layout);这里是直接获取设计器组件里面的layout属性(它的data值)。这个layout目前是个空数组(因为是新建的仪表盘,里面没有组件)。
layout.x = (layoutList.length * 2) % 60;
layout.y = layoutList.length + 60;
field.widget.layout = layout;// 更新布局
this.$refs.container.syncLayout();
很好理解啦,我们吧初始化layout的横纵坐标调整到它应该在的位置上,并吧这个调整过的layout信息存储到新增组件的布局属性里(替换掉初始化layout)。讲讲为什么这么计算:
可以看到实例中这两个组件的x/y值并不像上面这个逻辑计算出来的。 如果按照上面那个逻辑计算出来,则应该是{x:0,y:60...}和{x:2,y:61...}。其实这个计算过程是为了保证第n+1个组件的x和y一定大于第n个。从而避免重叠出错,而至于精准的layout数据,是借助vue-layout-grid插件行自适应生成。具体怎么做,我们看代码:
/** @name 同步layout **/syncLayout() {this.layout = ensureArray(this.fields).map(field => ({...field.widget.layout,i: field.pkId,}));}
<grid-layoutref="layout":class="$style.layout":layout.sync="layout"
>...
</grid-layout>
很多人看到这里就要骂了,骗人,你这不是啥都没干?只是把layout重新赋值了一遍。让我们改下代码看看:
/** @name 同步layout **/async syncLayout() {this.layout = ensureArray(this.fields).map(field => ({...field.widget.layout,i: field.pkId,}));console.log(this.layout);await this.$nextTick();console.log(this.layout);}
第一个输出:
[{h: 10,w: 12,x: 0,y: 0},{h: 20,w: 60,x: 2,y: 61}
]
第二个输出:
[
{h: 10,w: 12,x: 0,y: 0,i: "39b19b29-c8ef-4fd3-8604-d7e168196ae6"
},
{h: 20,w: 60,x: 2,y: 10,i: "5d684834-26bd-4d35-b7ff-36d8de9d903e"
},
]
可以看到此时this.layout已经变了。这是因为<grid-layout>已经自适应了布局。
由此,我们的保存仪表盘布局方法也呼之欲出了:
save() {// 拿到同步后的this.layoutconst layout = this.$refs.container.layout;// 生成组件id和layout信息的映射表const layoutMap = generateMap(layout, 'i', item =>pick(item, 'x', 'y', 'w', 'h'),);...
}
先看到这里,这里要生成一份类似于:'amdous123623': {w:10,h:20,x:0,y:0...}这样的映射表,是整个仪表盘布局的储存并不是直接存储类似于girdLayout的这种数组,而是由一个个组件自身的layout属性(甚至无视组件排序)拣选出来生成this.layout。也就是说仪表盘的存储结构为Array<field>这样的。
save(fields) {const layout = this.$refs.container.layout;const layoutMap = generateMap(layout, 'i', item =>pick(item, 'x', 'y', 'w', 'h'),);this.privateUpdateFields((fields || this.fields).map(field => {if (!layoutMap[field.pkId]) return field;return {...field,widget: {...field.widget,layout:{...field.widget.layout,...layoutMap[field.pkId],}},};}),);}
拖拽添加组件到仪表盘
前面我们已经讲了拖拽添加组件的思路,和预防错位或重叠的处理。现在来讲讲具体代码实现。
之前讲过了,在control-list.vue也就是左边的组件列表拖拽出组件,触发@dragstart方法,同时往设计器里传入dragType。设计器里根据dragType找对对应的组件初始化layout
@Watch('dragType')handleDragTypeChange(type) {this.isInChildCom = false; // 重新拖动需要重置if (type) {this.dragLayout = {i: 'drag',...getDashboardLayoutByType(type),};} else {this.dragLayout = null;}}
假设此时拖拽元素已经拖拽到在设计器(也就是gird-layout)上面。触发@dragover.native="handleDrag"
handleDrag(ev) {if (this.isInChildCom) return; // 进入子元素范围则无需触发ev.preventDefault();this._handleDrag(ev);
}
@throttle(100)
_handleDrag(ev) {if (!this.dragType || !this.$el) return;if (this.dragContext.clientX === ev.clientX &&this.dragContext.clientY === ev.clientY)return;this.dragContext.clientX = ev.clientX;this.dragContext.clientY = ev.clientY;this.updateInside(ev);this.updateDrag(ev);
}
_handleDrag每100秒记录一次拖拽元素的位置,当拖拽元素发生变动时,更新设计器视图。
updateInside(ev) {if (!this.dragType || !this.$el) return;const rect = this.$el.getBoundingClientRect();const errorRate = 10;const inside =ev.clientX > rect.left + errorRate &&ev.clientX < rect.right - errorRate &&ev.clientY > rect.top + errorRate &&ev.clientY < rect.bottom - errorRate;if (inside && this.dragLayoutIndex === -1) {this.layout.push(this.dragLayout);}if (!inside && this.dragLayoutIndex !== -1) {this.layout.splice(this.dragLayoutIndex, 1);}}
这里是获取设计器边界的位置属性(errorRate为误差范围,你可以理解为设计器有padding),判断拖拽元素是否在设计器边界内,如果是,就往layout里面加入它(重复则不加入),如果已经超出设计器,则移除。
我们往编辑器拖拽移动,可以看到这个虚线框会一直跟随变动,可能你们就要问了,上面的代码里dragLayout一但被添加进layout,那么dragLayoutIndex就不会是-1,也就是说layout里面的dragLayout不会改变(x或y)。那这个虚框是怎么还在移动的?
其实啊,这个虚框并不由layout里的数据决定。而是由vue-grid-layout这个插件负责渲染的。在拖动的时候,this.layout是不会变的。我们只需要每100毫秒记录一次拖拽元素的当前位置this.dragLayout,直到放置生效之后,用this.dragLayout去覆盖this.layout里面的那个被拖动元素。
所以updateDrag是为了更新this.dragLayout。通过clientY/X换算成vue-grid-layout的x,y
const dragRef = this.getDragRef();if (!this.dragType || !dragRef) return;const rect = this.$el.getBoundingClientRect();const dragging = {top: this.dragContext.clientY - rect.top,left: this.dragContext.clientX - rect.left,};dragRef.dragging = dragging;const newLayout = dragRef.calcXY(dragging.top, dragging.left);this.dragLayout.x = newLayout.x;this.dragLayout.y = newLayout.y;}
getDragRef() {// vue-grid-layout默认在$children内存在一个组件实例了, 其实每次拖动直接取最后一个实例应该就可以了return this.$refs.layout.$children[this.$refs.layout.$children.length - 1];
}
当我们放手时,触发 <grid-layout>组件上的drop事件,我们来看看@drop.native="handleDrop"的handleDrop方法
async handleDrop() {if (this.isInChildCom) return; // 进入子元素范围则无需触发if (!this.dragType) return;...
}
重叠和空类型直接当做无效动作处理
async handleDrop() {if (this.isInChildCom) return; // 进入子元素范围则无需触发if (!this.dragType) return;try {...}catch (e) {this.layout.splice(this.dragLayoutIndex, 1);throw e;}finally {this.$emit('update:dragType', null);}
}
这个try catch我们之前已经讲过了。try里面的逻辑也很简单
try {let field = createDashboardField(this.dragType);...field.widget.layout = pick(this.dragLayout, 'x', 'y', 'w', 'h');...// 更新布局this.layout.splice(this.dragLayoutIndex, 1, {...field.widget.layout,i: field.pkId,});// 提交数据存储this.$emit('add', field);
}
拖拽移动组件位置
由插件处理,会自动更新到this.layout
放大缩小组件
由插件处理,会自动更新到this.layout
删除组件
async handleDelete(pkId) {const cloneFields = deepClone(this.fields);// 摘除删除的组件数据this.updateFields(cloneFields.filter(field => {return field.pkId !== pkId;}),);await this.$nextTick();this.$refs.container.syncLayout();
}
/** @name 同步layout **/async syncLayout() {this.layout = ensureArray(this.fields).map(field => ({...field.widget.layout,i: field.pkId,}));await this.$nextTick();}
额外讲一下选中组件对组件进行修改
当我们选中组件的时候,需要在vuex里登记一下当前的选中状态
<grid-itemv-for="layoutItem in layout"...@mousedown.native="handlePointerDown"@mouseup.native="handlePointerUp($event, layoutItem.i)"
>...
</grid-item>
加了一些位置判断,以防这个组件位置出错或已经不在布局里
/** @name 鼠标设备按下与抬起事件处理 **/_pointerContext = null;handlePointerDown(ev) {this._pointerContext = {x: ev.clientX,y: ev.clientY,};}handlePointerUp(ev, pkId) {if (!this._pointerContext || !this.fieldMap[pkId]) return;const { x, y } = this._pointerContext;if (x !== ev.clientX || y !== ev.clientY) return;this.selectField(this.fieldMap[pkId]);}@formDesignModule.Mutation selectField;
再来看看仓库的代码
// fromdesign.jsselectField(state, field) {state.selectedField = field;},
如果当前组件的内容或属性发送变更,则执行
commit('selectField', newField);
相关文章:

讲讲项目里的仪表盘编辑器(二)
应用场景 正常来说,编辑器应用场景应该包括: 编辑器-预览 编辑器 最终运行时 怎么去设计 上一篇推文,我们已经大概了解了编辑器场景。接下来,我们来看预览时的设计 编辑器-预览 点击预览按钮,执行以…...

文心一言 VS 讯飞星火 VS chatgpt (102)-- 算法导论9.3 8题
八、用go语言,设 X[1…n]和 Y[1…n]为两个数组,每个都包含n个有序的元素。请设计一个 O(lgn)时间的算法来找出数组 X和Y中所有 2n 个元素的中位数。 文心一言: 要在 O(lgn) 时间内找出两个有序数组 X 和 Y 中所有元素的中位数,可…...
axios添加缓存请求,防止多次请求,单页面多个同一组件造成多次请求解决方案
在axios中,添加 const cacheMap {};响应拦截添加 try {// 删除缓存const api res.config.url.replace(process.env.VUE_APP_BASE_API, "");if (cacheMap.hasOwnProperty(api)) {delete cacheMap[api];}} catch (err) {}创建两个请求方法 /*** Get缓存…...
Java包装类与自动拆箱装箱
有的时候博客内容会有变动,首发博客是最新的,其他博客地址可能会未同步,认准https://blog.zysicyj.top 首发博客地址[1] 面试题手册[2] 系列文章地址[3] 1. 什么是 Java 包装类和自动拆箱装箱? Java 中的基本数据类型(如 int、cha…...

基于SpringBoot网上超市的设计与实现【附万字文档(LW)和搭建文档】
主要功能 前台登录: 注册用户:用户名、密码、姓名、联系电话 用户: ①首页、商品信息推荐、商品资讯、查看更多 ②商品信息、商品详情、评论、点我收藏、添加购物车、立即购买 ③个人中心、余额、点我充值、更新信息、我的订单、我的地址、我…...

二、C++项目:仿muduo库实现并发服务器之时间轮的设计
文章目录 一、为什么要设计时间轮?(一)简单的秒级定时任务实现:(二)Linux提供给我们的定时器:1.原型2.例子 二、时间轮(一)思想(一)代码 一、为什…...

计算机竞赛 深度学习OCR中文识别 - opencv python
文章目录 0 前言1 课题背景2 实现效果3 文本区域检测网络-CTPN4 文本识别网络-CRNN5 最后 0 前言 🔥 优质竞赛项目系列,今天要分享的是 🚩 **基于深度学习OCR中文识别系统 ** 该项目较为新颖,适合作为竞赛课题方向,…...
蓝桥等考Python组别五级003
第一部分:选择题 1、Python L5 (15分) 表达式“a >= b”等价于下面哪个表达式?( ) a > b and a == ba > b or a == ba < b and a == ba < b or a > b正确答案:B 2、Python L5 (15分) 当x是偶数时,下面哪个表达式的值一定是True?( …...

学之思项目第一天-完成项目搭建
一、前端 拉下前端代码执行 npm i 然后执行npm run serve就行了 二、后端 搭建父子模块 因为这个涉及到前台以及后台管理所以使用父子模块 并且放置一个公共模块,放置公共的依赖以及公共的代码 2.1 搭建父子工程 这个可以使用直接一个个的maven模块ÿ…...

pandas--->CSV / JSON
csv CSV(Comma-Separated Values,逗号分隔值,有时也称为字符分隔值,因为分隔字符也可以不是逗号),其文件以纯文本形式存储表格数据(数字和文本)。 CSV 是一种通用的、相对简单的文…...

LeetCode算法二叉树—116. 填充每个节点的下一个右侧节点指针
目录 116. 填充每个节点的下一个右侧节点指针 题解: 代码: 运行结果: 给定一个 完美二叉树 ,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树定义如下: struct Node {int val;Node *left;N…...

二、2023.9.28.C++基础endC++内存end.2
文章目录 17、说说new和malloc的区别,各自底层实现原理。18、 说说const和define的区别。19、 说说C中函数指针和指针函数的区别?20、 说说const int *a, int const *a, const int a, int *const a, const int *const a分别是什么,有什么特点…...

DevSecOps 将会嵌入 DevOps
通常人们在一个项目行将结束时才会考虑到安全,这么做会导致很多问题;将安全融入到DevOps的工作流中已产生了积极结果。 DevSecOps:安全正当时 一直以来,开发人员在构建软件时认为功能需求优先于安全。虽然安全编码实践起着重要作…...

不同管径地下管线的地质雷达响应特征分析
不同管径地下管线的地质雷达响应特征分析 前言 以混凝土管线为例,建立了不同管径的城市地下管线模型,进行二维地质雷达正演模拟,分析不同管径管线的地质雷达响应特征。 文章目录 不同管径地下管线的地质雷达响应特征分析前言1、管径50cm2、…...

【接口测试学习】白盒测试 接口测试 自动化测试
一、什么是白盒测试 白盒测试是一种测试策略,这种策略允许我们检查程序的内部结构,对程序的逻辑结构进行检查,从中获取测试数据。白盒测试的对象基本是源程序,所以它又称为结构测试或逻辑驱动测试,白盒测试方法一般分为…...

7.网络原理之TCP_IP(下)
文章目录 4.传输层重点协议4.1TCP协议4.1.1TCP协议段格式4.1.2TCP原理4.1.2.1确认应答机制 ACK(安全机制)4.1.2.2超时重传机制(安全机制)4.1.2.3连接管理机制(安全机制)4.1.2.4滑动窗口(效率机制…...

Docker Dockerfile解析
Dockerfile是什么 Dockerfile是用来构建Docker镜像的文本文件,是由一条条构建镜像所需的指令和参数构成的脚本。 官网:Dockerfile reference | Docker Docs 构建三步骤: 编写Dockerfile文件docker build命令构建镜像docker run依镜像运行容…...

浏览器从输入URL到页面展示这个过程中都经历了什么
一. URL输入 URL是统一资源定位符,用于定位互联网上的资源,俗称网址。我们在地址栏输入网址后敲下回车,浏览器会对输入的信息进行以下判断: 1. 检查输入的内容是否是一个合法的URL连接 2. 如果合法的话,则会判断URL…...
2023-09-22 monetdb-事务管理-乐观并发控制-记录
摘要: 2023-09-22 monetdb-事务管理-记录 相关文档: Transaction Management | MonetDB Docs https://en.wikipedia.org/wiki/Optimistic_concurrency_control monetdb事务管理: MonetDB/SQL 支持以 START TRANSACTION 标记并以 COMMIT 或 ROLLBACK 关闭的多语句事务方案。如果…...
蓝桥等考Python组别四级008
第一部分:选择题 1、Python L4 (15分) 字符“D”的ASCII码值比字符“F”的ASCII码值小( )。 1234正确答案:B 2、Python L4 (15分) 下面的Python变量名正…...

深入浅出Asp.Net Core MVC应用开发系列-AspNetCore中的日志记录
ASP.NET Core 是一个跨平台的开源框架,用于在 Windows、macOS 或 Linux 上生成基于云的新式 Web 应用。 ASP.NET Core 中的日志记录 .NET 通过 ILogger API 支持高性能结构化日志记录,以帮助监视应用程序行为和诊断问题。 可以通过配置不同的记录提供程…...

vscode(仍待补充)
写于2025 6.9 主包将加入vscode这个更权威的圈子 vscode的基本使用 侧边栏 vscode还能连接ssh? debug时使用的launch文件 1.task.json {"tasks": [{"type": "cppbuild","label": "C/C: gcc.exe 生成活动文件"…...
AtCoder 第409场初级竞赛 A~E题解
A Conflict 【题目链接】 原题链接:A - Conflict 【考点】 枚举 【题目大意】 找到是否有两人都想要的物品。 【解析】 遍历两端字符串,只有在同时为 o 时输出 Yes 并结束程序,否则输出 No。 【难度】 GESP三级 【代码参考】 #i…...

selenium学习实战【Python爬虫】
selenium学习实战【Python爬虫】 文章目录 selenium学习实战【Python爬虫】一、声明二、学习目标三、安装依赖3.1 安装selenium库3.2 安装浏览器驱动3.2.1 查看Edge版本3.2.2 驱动安装 四、代码讲解4.1 配置浏览器4.2 加载更多4.3 寻找内容4.4 完整代码 五、报告文件爬取5.1 提…...

华硕a豆14 Air香氛版,美学与科技的馨香融合
在快节奏的现代生活中,我们渴望一个能激发创想、愉悦感官的工作与生活伙伴,它不仅是冰冷的科技工具,更能触动我们内心深处的细腻情感。正是在这样的期许下,华硕a豆14 Air香氛版翩然而至,它以一种前所未有的方式&#x…...

基于 TAPD 进行项目管理
起因 自己写了个小工具,仓库用的Github。之前在用markdown进行需求管理,现在随着功能的增加,感觉有点难以管理了,所以用TAPD这个工具进行需求、Bug管理。 操作流程 注册 TAPD,需要提供一个企业名新建一个项目&#…...

20个超级好用的 CSS 动画库
分享 20 个最佳 CSS 动画库。 它们中的大多数将生成纯 CSS 代码,而不需要任何外部库。 1.Animate.css 一个开箱即用型的跨浏览器动画库,可供你在项目中使用。 2.Magic Animations CSS3 一组简单的动画,可以包含在你的网页或应用项目中。 3.An…...
MySQL JOIN 表过多的优化思路
当 MySQL 查询涉及大量表 JOIN 时,性能会显著下降。以下是优化思路和简易实现方法: 一、核心优化思路 减少 JOIN 数量 数据冗余:添加必要的冗余字段(如订单表直接存储用户名)合并表:将频繁关联的小表合并成…...

AD学习(3)
1 PCB封装元素组成及简单的PCB封装创建 封装的组成部分: (1)PCB焊盘:表层的铜 ,top层的铜 (2)管脚序号:用来关联原理图中的管脚的序号,原理图的序号需要和PCB封装一一…...

【51单片机】4. 模块化编程与LCD1602Debug
1. 什么是模块化编程 传统编程会将所有函数放在main.c中,如果使用的模块多,一个文件内会有很多代码,不利于组织和管理 模块化编程则是将各个模块的代码放在不同的.c文件里,在.h文件里提供外部可调用函数声明,其他.c文…...