Vue无代码可视化项目—补充内容
背景介绍、方案设计
- 总结项目经验、业务价值
平常写代码内容、能力都是具备的,但是很多没有将代码背后的经验、业务背后的内容没有总结下来, - 公司中低代码项目难度较高,一般不会参与,因此这个无代码可视化项目为了让大家练手,有一个体系架构
- 项目核心:
数据源管理与加工、页面组装、流程引擎、低代码编辑器
- 要有复用、封装的思想
写好的轮子要后面的人更轻松的使用。所以要时时刻刻有组件封装和开源的意识,每天只在做代码,写代码都没有成长呢,是因为没有思考,没有思考怎么让代码更完善,让代码更好。哪怕是一个简单的登录注册,里面都有很多可优化的点。
- 表单
- 校验
- 密码加密
- 验证码
登录注册真这么简单吗?
- 微信校验(authing、auth2) 做第三方鉴权的
- 项目发展到一定体量之后,会有单独的
用户中心
,专门做登录、鉴权等处理的
- 框架的选型
- 只要涉及到拖拽,会下意识的反应是drag事件,但其实经过我们实践,drag事件有很多浏览器受限的时候,因此,我们选择使用的是基于move事件封装vue draggable
- 自由拖拽,在做的低代码平台不考虑分辨率的兼容,就可以用这种方案。但是如果要考虑分辨率的兼容,这种方式是不太好处理的。因此,在之后做架构设计的时候,要考虑他背后的本质逻辑是什么,而不是停留在技术本身,要对每一项技术说出优劣势,能说出各个技术栈,各个技术点,各个框架库他的用武之地是什么,这样才算一个优秀的架构师,放在平常的时候,一定要刻意练习才行。
- 低代码平台参考借鉴的是国外的平台:Glide
- 发布之后的操作是什么?
一般无代码平台都不涉及到私有化部署
,所以都是基于公有云部署
,这样所有的用户都使用我们这一套代码,这样就不会牵扯按量计费
的逻辑
国内涉及到的部署,牵扯到两个问题,主机ECS
和证书、域名
,主机用的是本公司的服务器,但是,在域名和证书的时候,很多用户想要的是自定义域名,自定义域名涉及到域名的备案和域名证书的申请,域名备案跟随的是自己的公司主体,证书问题可以使用Caddy,可以自动的分发https,一般都会借助docker caddy
Canvas Table
针对普通的table,数据量特别大的时候,要怎么做呢?
- 需求:加分页、滚动加载
- 虚拟表格(列表) vue-virtual-scroller
- canvas table,因为画布性能好,他的性能瓶颈取决于你的绘图算法
创建一个新的vue项目
pnpm create vue@latest
cd vue-canvas-table
pnpm i
pnpm format
pnpm dev
普通表格的效果
<template>
<div>
<!-- 简单表格的渲染 -->
<table>
<thead>
<tr>
<th v-for="column in data.columns" :key="column.key">{{column.title}}</th>
</tr>
</thead>
<tbody>
<tr v-for="record in data.dataSource" :key="record.key">
<td v-for="column in data.columns" :key="column.key">
{{ record[column.key] }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
// 确认表格的数据
const data = {
columns:[{
title: '姓名',
key: 'name',
width: 100
},{
title: '年龄',
key: 'age',
width: 100
}],
dataSource: Array.from({length: 100000}).map((item,index)=>({
key:index,
name:`name-${index}`,
age: Math.floor(Math.random() * 100)
}))
}
</script>
<style scoped>
table{
border-collapse: collapse;
}
td{
border: 1px solid #ccc;
width: 100px;
}
</style>
Canvas上手
<template>
<div>
<!-- 当window.devicePixelRatio为2的时候 -->
<!-- <canvas ref="canvas" width="1600" height="1200">对不起,您的浏览器不支持</canvas> -->
<canvas ref="canvas" width="800" height="600">对不起,您的浏览器不支持</canvas>
</div>
</template>
<script setup>
// 为了获取到canvas dom
import {ref,onMounted} from 'vue'
// 获取canvas dom引用
// 注意一定不要用dom操作
// const canvas = document.querySelector("#canvas")
//ts写法:
// const canvas = ref<HTMLCanvasElement|null>(null)
// 这里直接
const canvas = ref(null)
onMounted(() => {
// 1.获取canvas dom
// 2.获取绘制上下文
const ctx=canvas.value.getContext('2d') //还可以是webgl(这里参考three.js)
// 画笔的概念
// 假设你现在在用毛笔练书法
// 按下去
ctx.beginPath()
// 开始写字
ctx.moveTo(0,0)
// ctx.lineTo(200,200) //当window.devicePixelRatio为2的时候
ctx.lineTo(100,100)
// 设置画笔
// fill 填充
// stroke 描边
ctx.strokeStyle='red'
// 画文字
ctx.font="48px serif"
ctx.fillText("Hello world",100,200) //content,x,y
ctx.stroke()
// canvas是位图,需要处理缩放的问题,很多图表或图画,渲染后会模糊,因此一般都是*2或*3这样
// svg是矢量图
// 怎么获取浏览器设备的像素比
// console.log(window.devicePixelRatio);
// 回锋收笔
ctx.closePath()
});
</script>
<style scoped>
canvas{
background-color: #eee;
width: 800px; /* 这个是正常宽度 */
height: 600px; /* 这个是正常高度 */
}
</style>
Canvas画表格-画基本表格
<template>
<div>
<!-- 当window.devicePixelRatio为2的时候 -->
<!-- <canvas ref="canvas" width="1600" height="1200">对不起,您的浏览器不支持</canvas> -->
<canvas ref="canvas" width="800" height="600">对不起,您的浏览器不支持</canvas>
</div>
</template>
<script setup>
// 为了获取到canvas dom
import {ref,onMounted} from 'vue'
// 获取canvas dom引用
// 注意一定不要用dom操作
// const canvas = document.querySelector("#canvas")
//ts写法:
// const canvas = ref<HTMLCanvasElement|null>(null)
// 这里直接
const canvas = ref(null)
const data = {
columns:[{
title: '姓名',
key: 'name',
width: 100
},{
title: '年龄',
key: 'age',
width: 100
}],
dataSource: Array.from({length: 10}).map((item,index)=>({
key:index,
name:`name-${index}`,
age: Math.floor(Math.random() * 100)
}))
}
onMounted(() => {
const ctx=canvas.value.getContext('2d') //还可以是webgl(这里参考three.js)
const {columns,dataSource}=data
const pixelRatio=window.devicePixelRatio
const cellWidth = 100 * pixelRatio
const cellHeight = 50 * pixelRatio
const padding = 10
// 画表格
// 画表头
for (let i=0; i<columns.length; i++){
const column=columns[i]
ctx.font =`bold ${16*pixelRatio}px serif`
ctx.fillText(column.title,i*cellWidth+padding,cellHeight/2)
console.log("column",column);
}
// 表格
for (let i=0; i<dataSource.length; i++){
const record=dataSource[i]
for (let j=0; j<columns.length; j++){
const column=columns[j]
ctx.font =`${16*pixelRatio}px serif`
ctx.fillText(record[column.key],j*cellWidth+padding,cellHeight*(i+1)+cellHeight/2)
}
}
ctx.beginPath()
ctx.closePath()
});
</script>
<style scoped>
canvas{
background-color: #eee;
width: 800px; /* 这个是正常宽度 */
height: 600px; /* 这个是正常高度 */
}
</style>
CanvasTable处理事件系统
Canvas是没有事件机制的,因此这个事件(鼠标点击事件等)需要我们自己代理去做,把这个事件计算,拿到位置等后处理
选中单元格
<template>
<div>
<!-- 当window.devicePixelRatio为2的时候 -->
<!-- <canvas ref="canvas" width="1600" height="1200">对不起,您的浏览器不支持</canvas> -->
<!-- <canvas ref="canvas" width="800" height="600">对不起,您的浏览器不支持</canvas> -->
<!-- 当window.devicePixelRatio为1.25的时候 -->
<canvas ref="canvas" width="1000" height="750">对不起,您的浏览器不支持</canvas>
</div>
</template>
<script setup>
// 为了获取到canvas dom
import {ref,onMounted,onUnmounted,reactive} from 'vue'
// 定义事件
defineEmits('click')
// 获取canvas dom引用
// 注意一定不要用dom操作
// const canvas = document.querySelector("#canvas")
//ts写法:
// const canvas = ref<HTMLCanvasElement|null>(null)
// 这里直接
const canvas = ref(null)
const pixelRatio=window.devicePixelRatio
console.log("pixelRatio",pixelRatio);
const cellWidth = 100 * pixelRatio
const cellHeight = 50 * pixelRatio
const selectedCell = reactive({row:-1,column:-1})
const data = {
columns:[{
title: '姓名',
key: 'name',
width: 100
},{
title: '年龄',
key: 'age',
width: 100
}],
dataSource: Array.from({length: 10}).map((item,index)=>({
key:index,
name:`name-${index}`,
age: Math.floor(Math.random() * 100)
}))
}
const handleClick=(ev)=>{
// 当前点了哪里
const {left,top}=canvas.value.getBoundingClientRect() //画布距离屏幕左边,顶部的距离
const x=ev.clientX-left //鼠标距离屏幕左边的距离-画布距离屏幕左边的距离=》鼠标距离画布左侧的长度
const y=ev.clientY-top //鼠标距离屏幕顶部的距离-画布距离屏幕顶部的长度=》鼠标距离画布顶部的长度
// 判断我点的x和y,落到了哪个单元格下 碰撞检测的问题
const rowIndex=Math.floor(y*1.25/cellHeight)-1 //再减去一个表格头部(头部不算)
console.log("x,y",x,y);
const colIndex=Math.floor(x*1.25/cellWidth)
console.log("rowIndex,colIndex",rowIndex,colIndex);
// 我只需要判断 rowIndex >= 0 rowIndex < data.dataSource.length 说明被选中
if(rowIndex>=0 && rowIndex<data.dataSource.length){
// 说明有单元格被选中
selectedCell.row=rowIndex
selectedCell.column=colIndex
// this.$emit('click')
// 重绘表格
drawTable()
}
}
const randomColor=()=>{
const random = Math.random()
if(random>0&&random<0.3){
return 'red'
}else if(random>0.3&&random<0.6){
return 'blue'
}else{
return 'yellow'
}
// return `rgba(${random*255},${random*255},${random*255},1)`
}
const drawTable=()=>{
canvas.value.addEventListener('click',handleClick)
const ctx=canvas.value.getContext('2d') //还可以是webgl(这里参考three.js)
const {columns,dataSource}=data
// 清除选中
ctx.clearRect(0,0,1000,750)
const padding = 10
ctx.beginPath()
// 画表格
// 画表头
for (let i=0; i<columns.length; i++){
const column=columns[i]
ctx.font =`bold ${16*pixelRatio}px serif`
ctx.fillText(column.title,i*cellWidth+padding,cellHeight/2)
console.log("column",column);
}
// 表格
for (let i=0; i<dataSource.length; i++){
const record=dataSource[i]
for (let j=0; j<columns.length; j++){
// 做判断,如果被选中了,那么就需要画矩形来高亮单元格
const column=columns[j]
// 先画背景
// 画矩形其实是一个fill填充操作
if(selectedCell.row===i&&selectedCell.column===j){
ctx.fillStyle = randomColor()
ctx.fillRect(j*cellWidth,cellHeight*(i+1),cellWidth,cellHeight)
ctx.fillStyle = 'black'
}
// 再写文字,文字填充
ctx.font =`${16*pixelRatio}px serif`
ctx.fillText(record[column.key],j*cellWidth+padding,cellHeight*(i+1)+cellHeight/2)
}
}
ctx.closePath()
}
onMounted(() => {
drawTable()
});
onUnmounted(() => {
canvas.value?.removeEventListener('click',handleClick)
})
</script>
<style scoped>
canvas{
background-color: #eee;
width: 800px; /* 这个是正常宽度 */
height: 600px; /* 这个是正常高度 */
}
</style>
CanvasTable表格滚动
<template>
<div>
<input type="text" @input="handleSearch">
<!-- 当window.devicePixelRatio为2的时候 -->
<!-- <canvas ref="canvas" width="1600" height="1200">对不起,您的浏览器不支持</canvas> -->
<canvas ref="canvas" width="800" height="600">对不起,您的浏览器不支持</canvas>
<!-- 当window.devicePixelRatio为1.25的时候 -->
<!-- <canvas ref="canvas" width="1000" height="750">对不起,您的浏览器不支持</canvas> -->
</div>
</template>
<script setup>
// 为了获取到canvas dom
import {ref,onMounted,onUnmounted,reactive,watch} from 'vue'
// 定义事件
defineEmits('click')
// 获取canvas dom引用
// 注意一定不要用dom操作
// const canvas = document.querySelector("#canvas")
//ts写法:
// const canvas = ref<HTMLCanvasElement|null>(null)
// 这里直接
const canvas = ref(null)
const pixelRatio=window.devicePixelRatio
console.log("pixelRatio",pixelRatio);
const cellWidth = 100 * pixelRatio
const cellHeight = 50 * pixelRatio
// 当前可视区域
let startRow=0;
const visableRows = Math.ceil(600*pixelRatio/cellHeight)-1 //可视区域:高度/单元格宽度-1(表头那一行)
const selectedCell = reactive({row:-1,column:-1})
// 响应式数据
// 数据跟UI本身没有关系
// 我们做筛选,影响的是数据
const d={
columns:[{
title: '姓名',
key: 'name',
width: 100
},{
title: '年龄',
key: 'age',
width: 100
}],
dataSource: Array.from({length: 1000}).map((item,index)=>({
key:index,
name:`name-${index}`,
age: Math.floor(Math.random() * 100)
})),
}
const data = reactive({
...d,
tmpDataSource:d.dataSource,
})
const handleClick=(ev)=>{
// 当前点了哪里
const {left,top}=canvas.value.getBoundingClientRect() //画布距离屏幕左边,顶部的距离
const x=ev.clientX-left //鼠标距离屏幕左边的距离-画布距离屏幕左边的距离=》鼠标距离画布左侧的长度
const y=ev.clientY-top //鼠标距离屏幕顶部的距离-画布距离屏幕顶部的长度=》鼠标距离画布顶部的长度
// 判断我点的x和y,落到了哪个单元格下 碰撞检测的问题
// const rowIndex=Math.floor(y*1.25/cellHeight)-1 //再减去一个表格头部(头部不算)
const rowIndex=Math.floor(y*pixelRatio/cellHeight)-1+startRow //再减去一个表格头部(头部不算) 加startRow是为了要知道她真实行的index
console.log("x,y",x,y);
// const colIndex=Math.floor(x*1.25/cellWidth)
const colIndex=Math.floor(x*pixelRatio/cellWidth)
console.log("rowIndex,colIndex",rowIndex,colIndex);
// 我只需要判断 rowIndex >= 0 rowIndex < data.dataSource.length 说明被选中
if(rowIndex>=0 && rowIndex<data.dataSource.length){
// 说明有单元格被选中
selectedCell.row=rowIndex
selectedCell.column=colIndex
// this.$emit('click')
// 重绘表格
drawTable()
}
}
const randomColor=()=>{
const random = Math.random()
if(random>0&&random<0.3){
return 'red'
}else if(random>0.3&&random<0.6){
return 'blue'
}else{
return 'yellow'
}
// return `rgba(${random*255},${random*255},${random*255},1)`
}
const drawTable=()=>{
canvas.value.addEventListener('click',handleClick)
const ctx=canvas.value.getContext('2d') //还可以是webgl(这里参考three.js)
const {columns,dataSource}=data
// 清除选中
ctx.clearRect(0,0,1000,750)
const padding = 10
ctx.beginPath()
// 画表格
// 画表头
for (let i=0; i<columns.length; i++){
const column=columns[i]
ctx.font =`bold ${16*pixelRatio}px serif`
ctx.fillText(column.title,i*cellWidth+padding,cellHeight/2)
console.log("column",column);
}
// 表格
for (let i=startRow; i<startRow+visableRows; i++){
const record=dataSource[i]
for (let j=0; j<columns.length; j++){
// 做判断,如果被选中了,那么就需要画矩形来高亮单元格
const column=columns[j]
// 先画背景
// 画矩形其实是一个fill填充操作
if(selectedCell.row===i&&selectedCell.column===j){
ctx.fillStyle = randomColor()
ctx.fillRect(j*cellWidth,cellHeight*(i-startRow+1),cellWidth,cellHeight) //减startRow是因为视窗大小限制,要判断他的相对位置
ctx.fillStyle = 'black'
}
// 再写文字,文字填充
ctx.font =`${16*pixelRatio}px serif`
ctx.fillText(record[column.key],j*cellWidth+padding,cellHeight*(i-startRow+1)+cellHeight/2)
}
}
ctx.closePath()
}
// 加滚动条
const handleWheel = ()=>{
document.addEventListener("wheel", (ev)=>{
console.log(ev);
const {deltaY}=ev
if(deltaY<0){
// startRow-=1
// if(startRow<0){
// startRow=0
// }
startRow=Math.max(0,startRow-1)
}else{
// startRow+=1
// if(startRow+visableRows>data.dataSource.length){
// startRow=data.dataSource.length-visableRows
// }
startRow=Math.min(data.dataSource.length-visableRows,startRow+1)
}
drawTable()
},false)
}
const handleSearch=(ev)=>{
const {value}=ev.target
// data.dataSource.filter((item)=>item.name.includes(value))
// vue中间数组的操作,一定要注意
// 太大变动会导致Vue重新渲染(rerender)
// 但是我们现在不同,因为我们使用的是canvas Table的方案
data.dataSource=data.tmpDataSource.filter((item)=>item.name.includes(value))
}
onMounted(() => {
drawTable()
handleWheel()
});
watch(
// 监听data中的dataSource,并重新绘制
() => data.dataSource,
()=>drawTable()
)
onUnmounted(() => {
canvas.value?.removeEventListener('click',handleClick)
})
</script>
<style scoped>
canvas{
background-color: #eee;
width: 800px; /* 这个是正常宽度 */
height: 600px; /* 这个是正常高度 */
}
</style>
Vue组件封装思想
拖拽组件 —smooth-dnd
现在smooth-dnd属于是年久失修的一个状态了,因为目前已经不支持vue3的这部分内容了,所以我们使用的时候,需要先在他基础上拓展一下
将拖拽的逻辑提取出来DndContainer.js文件,做封装
DndDemo1.vue
<template>
<div class="content">
<div class="material-panel" ref="materialContainer">
<div class="material-card" v-for="material in materials" :key="material.type">
{{ material.title }}
</div>
</div>
<div class="layout" ref="layoutContainer">
<div class="layout-item" v-for="(d, i) in data" :key="i">{{ d.content }}</div>
</div>
</div>
<DndContainer>
<div>123</div>
</DndContainer>
</template>
<script setup>
import { onMounted, ref, toRaw } from 'vue'
import { smoothDnD, dropHandlers } from 'smooth-dnd'
import { DndContainer } from './DndContainer'
smoothDnD.dropHandler = dropHandlers.reactDropHandler().handler
const materials = [
{ title: '图片', type: 'image' },
{ title: '文本', type: 'text' },
{ title: '按钮', type: 'button' },
{ title: '输入框', type: 'input' },
{ title: '下拉框', type: 'select' },
{ title: '单选框', type: 'radio' }
]
const data = ref([
{
id: 1,
type: 'image',
x: 0,
y: 0,
width: 100,
height: 100,
content:
'https://img.alicdn.com/imgextra/i4/O1CN01QYQ4QI1qZQYQYQ4QI_!!6000000001382-2-tps-200-200.png'
},
{
id: 2,
type: 'text',
x: 0,
y: 0,
width: 100,
height: 100,
content: '文本'
},
{
id: 3,
type: 'button',
x: 0,
y: 0,
width: 100,
height: 100,
content: '按钮'
},
{
id: 4,
type: 'input',
x: 0,
y: 0,
width: 100,
height: 100,
content: '输入框'
},
{
id: 5,
type: 'select',
x: 0,
y: 0,
width: 100,
height: 100,
content: '下拉框'
},
{
id: 6,
type: 'radio',
x: 0,
y: 0,
width: 100,
height: 100,
content: '单选框'
}
])
const materialContainer = ref(null)
const layoutContainer = ref(null)
function arrayMove(array, from, to) {
const newArray = array.slice()
newArray.splice(to < 0 ? newArray.length + to : to, 0, newArray.splice(from, 1)[0])
return newArray
}
const applyDrag = (arr, dragResult) => {
const { removedIndex, addedIndex, payload } = dragResult
const result = [...arr]
// 没做操作
if (addedIndex === null) return result
// 添加
if (addedIndex !== null && removedIndex === null) {
result.splice(addedIndex, 0, {
id: `${Math.random()}`,
...payload
})
}
// 移动
if (addedIndex !== null && removedIndex !== null) {
return arrayMove(result, removedIndex, addedIndex)
}
return result
}
onMounted(() => {
// 物料区
smoothDnD(materialContainer.value, {
groupName: 'material',
behaviour: 'copy',
getChildPayload(index) {
return {
id: data.value.length + 1,
type: materials[index].type,
title: materials[index].title,
content: materials[index].title,
x: 0,
y: 0,
width: 100,
height: 100
}
}
})
// 编排区
smoothDnD(layoutContainer.value, {
groupName: 'material',
onDrop(dropResult) {
const result = applyDrag(toRaw(data.value), dropResult)
data.value = result
// 问题怎么引起, DOMException: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
// dom 复用引起
console.log('🚀 ~ file: DndDemo1.vue:140 ~ onDrop ~ result:', result)
}
})
})
</script>
<style scoped>
.content {
color: #fff;
display: flex;
background-color: #000;
}
.material-panel {
width: 200px;
padding: 10px;
box-sizing: border-box;
overflow-y: auto;
}
.material-card {
width: 100%;
height: 50px;
border: 1px solid #ccc;
margin-bottom: 10px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
user-select: none;
}
.layout-item {
width: 100%;
height: 100px;
background-color: #002222;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
user-select: none;
/* border: 1px solid transparent; */
&:hover {
/* border-color: #ccc; */
box-shadow: inset 0 0 1px 1px #ccc;
}
}
</style>
DndContainer.js
import { defineComponent, h } from 'vue'
import { smoothDnD } from 'smooth-dnd'
export const DndContainer = defineComponent({
name: 'DndContainer',
setup() {
return { dndContainer: null }
},
mounted() {
const containerElement = this.$refs.container || this.$el
this.container = smoothDnD(containerElement, { groupName: 'material' })
},
render() {
return h(
'div',
{
ref: 'container',
class: 'dnd-container',
style: {
color: 'pink',
fontSize: '20px'
}
},
this.$slots.default?.()
)
}
})
CanvasTable封装
CanvasTable.js
import { defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'
export const CanvasTable = defineComponent({
name: 'CanvasTable',
// 组件初始化
setup() {
const canvas = ref(null)
const container = ref(null)
const data = ref()
const handleClick = () => {}
const drawTable = () => {
console.log('drawTable')
}
onMounted(() => {
drawTable()
})
onUnmounted(() => {})
watch(data, () => {
drawTable()
})
return {
handleClick,
canvas,
container
}
},
// 渲染
render() {
return h('div', {}, this.$slots.default?.())
}
})