Skip to content

智慧教室系统

更新: 2025/3/13 12:08:48 字数: 0 字

1. 系统介绍

  该系统主要是在学校期间,为新的教务系统做的一个智慧管理界面,我主要负责智慧教室信息的录入和展示, 负责前端的页面设计,以及页面布局数据的存储。
  介绍一下该项目的作用,以图形的方式录入整个教学楼的教室信息,并以图形的方式展示出来,方便查看每间教室的使用情况, 并且可以操控每间教室的开关灯,开关电脑设施,开关门等功能。

2. 需求分析

  1. 对系统进行升级,实现动态录入教学楼教室的布局信息,并且可以将其按照存储的布局渲染出来,最终展示的界面会在教学楼的电视中展示出来。
  2. 实现稳定的 websocket 连接,实现对布局中的每个教室信息进行实时监测和控制,可以展示每个教室当前的状态,以及控制其设备。
  3. 要封装请求,实现对教室信息的实时控制,比如 开关灯,开关电脑,开关门 等。

3. 使用技术

前端:vue.jsvue-routerelement-plusaxiosvite 等 vue 生态。
后端:spring-bootsql-servermybatis-plus 等后端环境。

  前端可能使用的组件库:grid-layout-plus 这种类型的布局组件库、 logic-flow 画布流程组件库。实在不行就自己做一个。

  初始效果:使用 grid-layout-plus 组件库来收录教室布局信息,教室的位置是可配置的,并且可以拖动。

  在做该系统的时候,主要就是要解决教学楼中教室信息的录入以及展示,录入要实现可控拖拽录入,并且可以随时进行编辑。 经过思考,我决定使用 grid-layout-plus 组件库,因为这个栅格布局系统可以对元素进行拖拽,可以实现我们所需要的功能。
  其实在录入的过程中,我们只需要将对应的元素拖拽到对应的位置,后面这个系统会生成对应的数据,后端数据库存储数据即可。
  这个效果其实我们很常见,比如 iTab 这个浏览器插件,他也可以说实现对应的效果,只不过他是通过 grid 布局来实现,但是 grid 布局可能存在兼容性问题,所以就使用 grid-layout-plus 组件库。

4. 存储布局数据类型

  以 pointId 智慧教室点为一组数据,每组数据的 pointId 相同,通过 type 字段来区别类型, 如果是 图例 那么type1教室2 ,占位图形为 3x,y,w,h 为坐标信息。这样就可以将布局给存储起来了。

java
@Data
public class  SmartPointLayout {
    /**
    * 主键 id
    */
    private Integer id;
    
    // 教室 id
    private Integer jsId;

    /**
     * 房间号
     */
    private String fjh;

    /**
     * 名称
     */
    private String name;

    /**
     * type
     */
    private Integer type;

    /**
     * 起始点x坐标
     */
    private Integer x;
    /**
     * 起始点y坐标
     */
    private Integer y;
    /**
     * 宽度上占多少个距离
     */
    private Integer w;
    /**
     * 高度上占多少个距离
     */
    private Integer h;

    /**
     * 使用的图形的形状
     */
    private String shape;

    /**
     * 智慧教室点
     */
    private Integer pointId;
}

  这个就是我们需要存储的数据,他就描述了一个智慧教室点 pointId 的布局信息。

{id: 1, jsId: null, fjh: null, name: null, type: 1, x: 6, y: 0, w: 3, h: 1, shape: "rectangle", pointId: 51}
{id: 2, jsId: 285, fjh: "5A104", name: null, type: 2, x: 4, y: 5, w: 1, h: 1, shape: "rectangle", pointId: 51}
{id: 3, jsId: 280, fjh: "5A101", name: null, type: 2, x: 0, y: 5, w: 1, h: 1, shape: "rectangle", pointId: 51}
{id: 4, jsId: 290, fjh: "5A207", name: null, type: 2, x: 7, y: 1, w: 2, h: 2, shape: "rectangle", pointId: 51}
{id: 5, jsId: 292, fjh: "5A302", name: null, type: 2, x: 1, y: 3, w: 1, h: 1, shape: "rectangle", pointId: 51}
{id: 6, jsId: null, fjh: null, name: "学生事务大厅", type: 3, x: 6, y: 4, w: 1, h: 1, shape: "rectangle", pointId: 51}
{id: 7, jsId: 307, fjh: "5A606", name: null, type: 2, x: 4, y: 0, w: 1, h: 1, shape: "rectangle", pointId: 51}
{id: 8, jsId: 304, fjh: "5A601", name: null, type: 2, x: 0, y: 0, w: 1, h: 1, shape: "rectangle", pointId: 51}
{id: 9, jsId: 305, fjh: "5A602", name: null, type: 2, x: 1, y: 0, w: 1, h: 1, shape: "rectangle", pointId: 51}
{id: 10, jsId: 306, fjh: "5A605", name: null, type: 2, x: 3, y: 0, w: 1, h: 1, shape: "rectangle", pointId: 51}

5. 初稿页面展示

  以 grid-layout-plus 组件库为例做的初稿。
  智慧教室系统展示,下面的表格展示的是教学楼栋信息,我们可以看到每个教学楼栋的教室信息,并且可以进行编辑和删除。

  编辑楼栋信息,可以向整个楼栋添加对应的教室信息,并且可以进行编辑和删除。

  展示楼栋信息,通过 grid-layout-plus 组件库,可以展示教室的位置,通过 websocket 可以看到每个教室的使用情况,并且可以控制教室的设备信息。

6. 实现思路和具体过程

websocket 连接

  自己封装了一个 websocket 连接,在连接的时候,会自动进行心跳包,如果断开连接,会自动进行重连 (断网重连机制)。

js
var ws;
var stop = false;
var pointID; //业务点id
var editFlag = 0; //通知通告编辑页面标记
var Type; //业务点类型
var url = "";  // websocket服务器连接点url
var needRefresh = false;
var heartCheck = {
    timeout: 60000,//60s
    timeoutObj: null,
    state: 1, // 1-超时断网        2-重连
    serverTimeoutObj: null,
    reset: function (value) {
        // 清除计时器
        clearTimeout(this.timeoutObj);
        clearTimeout(this.serverTimeoutObj);
        this.start(value);
    },
    start: function (value) {
        var self = this;
        this.state = value;
        // 60s 后发送心跳包
        this.timeoutObj = setTimeout(function () {
            ws.send('{"type":"heart"}');
            // 60s 后没有收到服务器的响应,则判断为超时
            self.serverTimeoutObj = setTimeout(function () {
                if (1 == heartCheck.state) {
                    heartCheck.state = 2;
                    ws.close();
                } else {
                    connect(); //断网重连
                }
            }, self.timeout)
        }, this.timeout)
    },
}

/**
 * 通过参数获取pointid的连接方式
 * @param type 服务点类型
 * @returns
 */
function startConnect(point, type) {
    console.log("页面渲染完毕")
    setURL();
    pointID = point;
    Type = type;
    connect();
}

/**
 * 设置websocket连接的URL
 */
function setURL() {
    url = "ws://xxx..."
}

function connect() {
    if (stop == true) {
        return;
    }
    if ('WebSocket' in window) {
        ws = new WebSocket(url);
    } else if ('MozWebSocket' in window) {
        alert("创建MozWebSocket连接");
        ws = new MozWebSocket(url);
    } else {
        alert('WebSocket is not supported by this browser.');
        return;
    }

    // websocket 连接成功回调
    ws.onmessage = function (event) {
        var obj = JSON.parse(event.data);
        if (obj.type == "notice") {
            //通知通告信息
        } else if (obj.type == "monitor") {
            // TODO 对接数据, 信息展示
            control(obj);
        } else if (obj.type == "heart") {
            //心跳包
        }
        // 发送心跳包
        heartCheck.reset(1);
    };

    ws.onopen = function (event) {
        console.log("yes");
        heartCheck.start(1);
        //monitor notice
        ws.send('{"type":"' + Type + '","point":' + pointID + ',"cmd":1}');
        //获取权限
        getPower();
    }

    // 连接关闭
    ws.onclose = function () {
        // 触发重连
        heartCheck.reset(2);
    }

    // 连接出错,会关闭 webSocket 连接
    ws.onerror = function () {
        ws.close();
    };

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,
    // 防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function () {
        stop = true;
        ws.close();
    }

}

// 发送消息
function send() {
    // TODO 这里需要替换成实际发送的内容
    const value = ""
    ws.send(value);
}

渲染展示

  我们通过grid-layout-plus组件库,保存了每个教室的位置,但是在渲染的时候,我们必须控制每个教室渲染的大小,于是我就自己写了个算法, 来将录入的信息重新渲染出来。

  在下面组件中我们使用 createBoxStyle 来根据 layout 来生成每个盒子的样式。最后通过相对定位和绝对定位来确定每个盒子的位置。

vue

<script setup>
  import {shangKeList, weiKaiFangList, zhiXiList} from '../websocket'

  const $emit = defineEmits(['update:selectClass'])
  const props = defineProps({
    layout: {},
    selectClass: {
      type: Object,
    }
  })

  // 生成盒子样式
  const createBoxStyle = (item) => {
    const laX = item.x / 1 > 0 ? item.x / 1 : 0
    const laY = item.y / 1 > 0 ? item.y / 1 : 0
    const _w = item.w / 1
    const _h = item.h / 1
    return {
      marginLeft: item.x * 150 + laX * 8 + `px`,
      marginTop: item.y * 100 + laY * 8 + `px`,
      width: (150 * _w) + (_w - 1) * 8 + `px`,
      height: (100 * _h) + (_h - 1) * 8 + `px`,
    }
  }

  const handleClick = (item) => {
    $emit('update:selectClass', item)
  }
</script>

<template>
  <div id="mainContainer" class="view_container">
    <div class="base_box" v-for="(item, index) in layout" :style="createBoxStyle(item)" :key="item.id">
      <div v-if="item.type === 1" class="content_box_1">
        <el-space :size="36">
          <div>
            <el-button style="background-color: #fc5e58"/>
            <span style="margin-left: 8px">上课中</span>
          </div>
          <div>
            <el-button style="background-color: #0cc492"/>
            <span style="margin-left: 8px">自习中</span>
          </div>
          <div>
            <el-button style="background-color: #e6e6e6"/>
            <span style="margin-left: 8px">未开放</span>
          </div>
        </el-space>
      </div>

      <div v-else-if="item.type === 2" class="content_box_2"
           :class="{
              active_color_01: shangKeList.has(item.jsId),
              active_color_02: zhiXiList.has(item.jsId),
              active_color_03: weiKaiFangList.has(item.jsId),
            }"
           @click="handleClick(item)">
        <div v-if="zhiXiList.has(item.jsId)">
          <p>{{item.fjh }}</p>
          <p>自习室</p>
        </div>
        <div v-if="shangKeList.has(item.jsId)">
          <p>{{item.fjh }}</p>
          <p>{{shangKeList.get(item.jsId).course}}</p>course
        </div>
        <div v-if="weiKaiFangList.has(item.jsId)">
          <p>{{item.fjh }}</p>
        </div>
      </div>
      <div v-else-if="item.type === 3" class="content_box_3">
        {{item.name}}
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
  .view_container {
    position: relative;

    .base_box {
      display: flex;
      justify-content: center;
      align-items: center;
      position: absolute;
      border-radius: 5px;
      background-color: #c0c0c0;
      color: #fafafa;

      .content_box_1 {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: space-evenly;
        border: 1px solid #999;
        color: black;
        font-size: 18px;
        border-radius: 5px;
        background-color: #fbf8ef;
      }

      .content_box_2 {
        height: 100%;
        width: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        border-radius: 5px;
        /*行间距*/
        flex-direction: column;

        p {
          text-align: center;
          line-height: 20px;
          margin: 0;

          &:first-child {
            font-size: 20px;
          }
        }

        &.active_color_01 {
          background-color: #fc5e58 !important;
        }

        &.active_color_02 {
          color: white;
          background-color: #0cc492 !important;
        }

        &.active_color_03 {
          color: black;
          background-color: #e6e6e6 !important;
        }

        &:hover {
          box-shadow: 0 0 10px #57717d;
        }
      }

      .content_box_3 {
        height: 100%;
        width: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
      }

    }
  }
</style>

  但是这样有个问题,就是他没有居中,给元素加上宽度使其居中。

vue
<script setup>
  const BoxWidth = ref(0)
  let maxW = 0
  const createBoxStyle = (item) => {
    const laX = item.x / 1 > 0 ? item.x / 1 : 0
    const laY = item.y / 1 > 0 ? item.y / 1 : 0
    const _w = item.w / 1
    const _h = item.h / 1
    if(maxW < laX){
      maxW = laX
      BoxWidth.value = laX * 8 + 150 * item.x + (150 * item.w + (item.w - 1) * 8)
    }
    return {
      marginLeft: item.x * 150 + laX * 8 + `px`,
      marginTop: item.y * 100 + laY * 8 + `px`,
      width: (150 * _w) + (_w - 1) * 8 + `px`,
      height: (100 * _h)  + (_h - 1) * 8 + `px`,
    }
  }
  
  
</script>

<template>
  <div  class="view_container" :style="{
    width: BoxWidth + 'px',
    height: '100%',
  }">
    
  </div>
</template>

  综上所述,做的很好了,但是,有一个问题,就是与原本的布局相对比,原本的左边的和右边是分开的, 但是按照我们现在的思路来看,忽略了这一点,那么我们重新去思考,有没有一个组件可以 实现我们的需求,他有背景网格,每次拖拽放大和缩小,都是在网格线上。

7. 新版本开发

旧版本的不足

  1. 最终的展示是要在电视上进行展示的,展示的时候得和电视屏幕相匹配。
  2. 要实现的是所见及所得,我拖拽时的布局是什么样子,展示时就是什么布局。
  3. 主要就是为了方便实时修改展示的效果,如果发现自己的布局的展示效果在电视大屏上展示的不好,可以随时进行修改。

新版本的细节需求

  我之前做过流程设计器,有一个叫做 logcflow 的组件库,他就能够实现我们类似的效果。 起初我是想使用 logcflow 这个组件库,但是后来发现,这个组件库有些问题(可能是我阅读文档得到问题吧),有一些对应的函数没找到。 就比如说我想让其元素在拖拽的时候移动的单位长度必须是 5 的倍数,但是 logcflow 这个组件库并没有提供这个功能。 我自己也尝试修改过其 drag 逻辑,但是效果总是差强人意,而且它的说明文档不是很全面。其二,项目又要导入新的依赖,这样打包提交又会变大。
  于是乎,我自己做一个不就得了,感觉也不是很难,主要解决以下问题:

需求

  1. 最好要有背景,并且是网格纸或者是点阵纸,这个是参考坐标,x为其网格之间的距离。
  2. 生成的元素,边都要在网格上,也就是说如果是矩形,那么它的边都要在网格上,并且是x的倍数
  3. 在对元素进行缩放时,缩放后的宽高也要是x的倍数
  4. 在对元素进行拖拽时,拖拽后的位置也要是x的倍数
  5. 画布很大,允许拖拽画布。
  6. 鼠标右键可以进行多选,被选中的元素可以一起进行拖拽
  7. 删除和缩放元素功能要比较显眼好用。
  8. ...

代码实现

点我查看代码
vue
<script setup>
  import {computed, ref} from "vue";
  import {useVModel} from "@vueuse/core";
  import ClassBox from "./model/ClassBox.vue";
  import TuLiBox from "./model/TuLiBox.vue";
  import RectBox from "./model/RectBox.vue";

const canvasWrapperRef = ref(null)
const canvasGrid = ref(null)

const canvasOffsetX = ref(0)
const canvasOffsetY = ref(0)
const scale = ref(1)

const canvasStyle = computed(() => ({
transform: `translate(${canvasOffsetX.value}px, ${canvasOffsetY.value}px) scale(${scale.value})`,
transformOrigin: '0 0',
}))

const props = defineProps({
modelValue: {
type: Array,
required: true,
default: () => []
}
})
const $emit = defineEmits(['update:modelValue'])
const _modelValue = useVModel(props, 'modelValue', $emit)

// 被选中的元素集合
const selectedElementIndices = ref([])
const selectedElementIndex = ref([])

const getComponentByType = (type) => {
switch (type){
case 1:
return ClassBox
case 2:
return TuLiBox
case 3:
return RectBox
}
}

function elementClass(type) {
switch (type) {
case 1:
return 'element_box1'
case 2:
return 'element_box2'
case 3:
return 'placeholder'
default:
return ''
}
}


function onCanvasWheel(event) {
event.preventDefault()
const scaleChange = event.deltaY * -0.001;
const newScale = Math.min(Math.max(scale.value + scaleChange, 0.5), 2);

    // 计算鼠标位置相对于画布的位置
    const rect = canvasWrapperRef.value.getBoundingClientRect();
    const mouseX = (event.clientX - rect.left - canvasOffsetX.value) / scale.value;
    const mouseY = (event.clientY - rect.top - canvasOffsetY.value) / scale.value;

    // 计算新的偏移量
    canvasOffsetX.value -= mouseX * (newScale - scale.value);
    canvasOffsetY.value -= mouseY * (newScale - scale.value);

    scale.value = newScale;
}

function zoomIn() {
scale.value = Math.min(scale.value + 0.1, 2)
}

function zoomOut() {
scale.value = Math.max(scale.value - 0.1, 0.5)
}

const offsetX = ref(0)
const offsetY = ref(0)
const draggingIndex = ref(null)
function startDrag(index, event) {
if (event.button === 2) return; // Ignore right-click drag
if (selectedElementIndices.value.includes(index)) {
// 开始多选拖动
startMultiDrag(event);
} else {
draggingIndex.value = index
offsetX.value = (event.clientX - _modelValue.value[index].x * scale.value) / scale.value
offsetY.value = (event.clientY - _modelValue.value[index].y * scale.value) / scale.value
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
}

function startMultiDrag(event) {
const initialPositions = selectedElementIndices.value.map(index => ({
index,
x: _modelValue.value[index].x,
y: _modelValue.value[index].y,
}));
offsetX.value = event.clientX;
offsetY.value = event.clientY;
document.addEventListener('mousemove', onMultiDrag);
document.addEventListener('mouseup', stopMultiDrag);

    function onMultiDrag(event) {
      const deltaX = (event.clientX - offsetX.value) / scale.value;
      const deltaY = (event.clientY - offsetY.value) / scale.value;
      initialPositions.forEach(pos => {
        _modelValue.value[pos.index].x = pos.x + deltaX;
        _modelValue.value[pos.index].y = pos.y + deltaY;
      });
    }

    function stopMultiDrag() {
      document.removeEventListener('mousemove', onMultiDrag);
      document.removeEventListener('mouseup', stopMultiDrag);
    }
}


function onDrag(event) {
if (draggingIndex.value !== null) {
const x = Math.round((event.clientX / scale.value - offsetX.value) / 5) * 5
const y = Math.round((event.clientY / scale.value - offsetY.value) / 5) * 5
_modelValue.value[draggingIndex.value].x = x
_modelValue.value[draggingIndex.value].y = y
}
}

function stopDrag() {
draggingIndex.value = null
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}

function selectElement(event, index) {
event.preventDefault()
event.stopPropagation()
selectedElementIndex.value = index
}
function deselectElement() {
selectedElementIndex.value = null
selectedElementIndices.value = []
}
// 删除对应的文件
function deleteElement(index) {
_modelValue.value.splice(index, 1)
}


const resizingIndex = ref(null); // 当前正在缩放的元素索引
const initialWidth = ref(0); // 初始宽度
const initialHeight = ref(0); // 初始高度
/**
* 缩放
  */
  function startResize(index, event) {
  resizingIndex.value = index;
  initialWidth.value = _modelValue.value[index].width;
  initialHeight.value = _modelValue.value[index].height;
  offsetX.value = event.clientX;
  offsetY.value = event.clientY;

    function onResize(event) {
      if (resizingIndex.value !== null) {
        const width = Math.round((initialWidth.value + (event.clientX - offsetX.value) / scale.value) / 5) * 5;
        const height = Math.round((initialHeight.value + (event.clientY - offsetY.value) / scale.value) / 5) * 5;
        _modelValue.value[resizingIndex.value].width = width > 0 ? width : 5;
        _modelValue.value[resizingIndex.value].height = height > 0 ? height : 5;
      }
    }

    function stopResize() {
      resizingIndex.value = null
      document.removeEventListener('mousemove', onResize)
      document.removeEventListener('mouseup', stopResize)
    }


    document.addEventListener('mousemove', onResize);
    document.addEventListener('mouseup', stopResize);
}

const canvasDragging = ref(false);
const canvasStartX = ref(0);
const canvasStartY = ref(0);


// 画布移动
function startCanvasDrag(event) {
event.preventDefault();
event.stopPropagation();
canvasDragging.value = true
canvasStartX.value = event.clientX - canvasOffsetX.value
canvasStartY.value = event.clientY - canvasOffsetY.value

    function onCanvasDrag(event) {
      if (canvasDragging.value) {
        canvasOffsetX.value = event.clientX - canvasStartX.value
        canvasOffsetY.value = event.clientY - canvasStartY.value
      }
    }

    function stopCanvasDrag() {
      canvasDragging.value = false
      document.removeEventListener('mousemove', onCanvasDrag)
      document.removeEventListener('mouseup', stopCanvasDrag)
    }

    document.addEventListener('mousemove', onCanvasDrag)
    document.addEventListener('mouseup', stopCanvasDrag)
}


// 选择框
const isMouseDown = ref(false);
const selectionBox = ref({
visible: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
})
const selectionBoxStyle = computed(() => ({
left: Math.min(selectionBox.value.startX, selectionBox.value.endX) + 'px',
top: Math.min(selectionBox.value.startY, selectionBox.value.endY) + 'px',
width: Math.abs(selectionBox.value.startX - selectionBox.value.endX) + 'px',
height: Math.abs(selectionBox.value.startY - selectionBox.value.endY) + 'px',
}))

function startSelection(event) {
event.preventDefault();
event.stopPropagation();
if (event.button === 2) return; // Ignore right-click for selection
isMouseDown.value = true;
selectionBox.value.visible = true;
const canvasWrapperRect = canvasWrapperRef.value.getBoundingClientRect();
selectionBox.value.startX = (event.clientX - canvasWrapperRect.left - canvasOffsetX.value) / scale.value;
selectionBox.value.startY = (event.clientY - canvasWrapperRect.top - canvasOffsetY.value) / scale.value;
selectionBox.value.endX = selectionBox.value.startX;
selectionBox.value.endY = selectionBox.value.startY;

    function onSelection(event) {
      if (!isMouseDown.value) return;
      const canvasWrapperRect = canvasWrapperRef.value.getBoundingClientRect();
      selectionBox.value.endX = (event.clientX - canvasWrapperRect.left - canvasOffsetX.value) / scale.value;
      selectionBox.value.endY = (event.clientY - canvasWrapperRect.top - canvasOffsetY.value) / scale.value;
    }

    function stopSelection(event) {
      if (event.button === 2) return; // Ignore right-click for stopping selection
      isMouseDown.value = false;
      selectionBox.value.visible = false;
      selectedElementIndices.value = [];
      const boxLeft = Math.min(selectionBox.value.startX, selectionBox.value.endX);
      const boxRight = Math.max(selectionBox.value.startX, selectionBox.value.endX);
      const boxTop = Math.min(selectionBox.value.startY, selectionBox.value.endY);
      const boxBottom = Math.max(selectionBox.value.startY, selectionBox.value.endY);

      _modelValue.value.forEach((element, index) => {
        const elementLeft = element.x;
        const elementRight = elementLeft + element.width;
        const elementTop = element.y;
        const elementBottom = elementTop + element.height;

        if (elementLeft < boxRight && elementRight > boxLeft && elementTop < boxBottom && elementBottom > boxTop) {
          console.log('=log=stopSelection=', element.id)
          selectedElementIndices.value.push(index);
        }
      });

      document.removeEventListener('mousemove', onSelection);
      document.removeEventListener('mouseup', stopSelection);
    }

    document.addEventListener('mousemove', onSelection);
    document.addEventListener('mouseup', stopSelection);
}

</script>

<template>
  <div class="container" @dblclick.left="deselectElement" >
    <!-- 外侧画板  -->
    <div class="canvas-wrapper"  ref="canvasWrapperRef" @contextmenu.prevent  @wheel="onCanvasWheel"
         @mousedown.left="startSelection($event)"
         @mousedown.right="startCanvasDrag($event)">
      <!-- 内侧画布     -->
      <div class="canvas-grid" ref="canvasGrid" :style="canvasStyle">
        <div
            v-for="(element, index) in _modelValue"
            :key="index"
            :class="[elementClass(element.type), {selected: selectedElementIndex === index || selectedElementIndices.includes(index) }]"
            :style="{
            left: element.x + 'px',
            top: element.y + 'px',
            width: element.width + 'px',
            height: element.height + 'px',

          }"
            class="base_box"
            @click.left="selectElement($event, index)"
            @mousedown.stop="startDrag(index, $event)"
        >
          <Component :is="getComponentByType(element.type)"/>
          <div class="resize-handle" @mousedown.stop="startResize(index, $event)"></div>
          <div class="delete-handle" @click.stop="deleteElement(index)">X</div>
        </div>

        <div v-show="selectionBox.visible" class="selection-box" :style="selectionBoxStyle"></div>
      </div>


      <div class="zoom-controls">
        <button @click="zoomOut">-</button>
        <span>{{ Math.round(scale * 100) }}%</span>
        <button @click="zoomIn">+</button>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
  .container {
    display: flex;
    height: 600px;
    user-select: none;
  }

  /*对应的画布信息*/
  .canvas-wrapper {
    flex: 1;
    overflow: hidden;
    position: relative;
    background-color: #fbf8ef;
    height: 600px;
    border: 1px solid #ccc;
    .zoom-controls {
      position: absolute;
      right: 26px;
      bottom: 26px;
    }
  }

  /*画板网格*/
  .canvas-grid {
    position: absolute;
    top: 0;
    left: 0;
    width: 10000px;
    height: 10000px;
    background-image: linear-gradient(rgba(224, 224, 224, 0.6) 1px, transparent 1px), linear-gradient(90deg, rgba(224, 224, 224, 0.6) 1px, transparent 1px), linear-gradient(rgba(160, 160, 160, 0.4) 1px, transparent 1px), linear-gradient(90deg, rgba(160, 160, 160, 0.4) 1px, transparent 1px);
    background-size:
        5px 5px,
        5px 5px,
        25px 25px,
        25px 25px,
        125px 125px,
        125px 125px;

    .base_box {
      display: flex;
      align-items: center;
      justify-content: center;
      p {
        text-align: center;
        margin: 6px 0;
      }
    }
  }




  .element_box1 {
    position: absolute;
    background-color: rgba(0, 123, 255, 0.5);
    border: 1px solid #007bff;
    cursor: move;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
  }

  .element_box1.selected {
    border: 2px dashed #ff0000;
  }

  .element_box2 {
    position: absolute;
    background-color: rgba(255, 165, 0, 0.5);
    border: 1px solid #ffa500;
    cursor: move;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
  }

  .element_box2.selected {
    border: 2px dashed #ff0000;
  }


  .placeholder {
    position: absolute;
    background-color: rgba(0, 255, 0, 0.5);
    border: 1px solid #00ff00;
    cursor: move;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
  }

  .placeholder.selected {
    border: 2px dashed #ff0000;
  }


  .resize-handle {
    position: absolute;
    width: 10px;
    height: 10px;
    background-color: #007bff;
    bottom: 0;
    right: 0;
    cursor: se-resize;
    border-radius: 5px;
  }

  .delete-handle {
    position: absolute;
    top: 0;
    right: 0;
    width: 20px;
    height: 20px;
    background-color: #ff0000;
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    border-radius: 5px;
  }

  .drag-panel {
    width: 200px;
    background-color: #f8f9fa;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 10px;
    border-left: 1px solid #dee2e6;
  }

  .selection-box {
    position: absolute;
    border: 1px dashed #007bff;
    background-color: rgba(0, 123, 255, 0.2);
  }
</style>
vue
<script setup>
  const props = defineProps({
    layout: {
      type: Array,
      required: true,
      default: () => []
    }
  })

  function elementClass(type) {
    switch (type) {
      case 1:
        return 'box_01';
      case 2:
        return 'box_02';
      case 3:
        return 'box_03';
      default:
        return '';
    }
  }
  function getType(type) {
    switch (type) {
      case 1:
        return '教室';
      case 2:
        return '图例';
      case 3:
        return '占位矩形';
      default:
        return '未知';
    }
  }
</script>

<template>
  <div class="canvas-grid">
    <div v-for="(element, index) in layout" :key="index" :class="elementClass(element.type)" :style="{
          left: element.x + 'px',
          top: element.y + 'px',
          width: element.width + 'px',
          height: element.height + 'px'
        }">
      {{ getType(element.type)  }} <br/>
      x: {{ element.x }}
      y: {{ element.y }}<br/>
      w: {{ element.width }}
      h: {{ element.height }}
    </div>
  </div>
</template>

<style scoped lang="scss">
  .canvas-grid {
    position: relative;
    width: 100%;
    height: 600px;
    overflow: hidden;
    border: 1px solid #ccc;
    background-image: linear-gradient(rgba(224, 224, 224, 0.6) 1px, transparent 1px), linear-gradient(90deg, rgba(224, 224, 224, 0.6) 1px, transparent 1px), linear-gradient(rgba(160, 160, 160, 0.4) 1px, transparent 1px), linear-gradient(90deg, rgba(160, 160, 160, 0.4) 1px, transparent 1px);
    background-size:
        5px 5px,
        5px 5px,
        25px 25px,
        25px 25px,
        125px 125px,
        125px 125px;

    .box1_mini {
      width: 100%;
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 5px;
      p {
        line-height: 12px;
        height: 12px;
        margin: 6px 0;
        text-align: center;
      }
      &.active_color_01 {
        background-color: #fc5e58 !important;
      }

      &.active_color_02 {
        color: white;
        background-color: #0cc492 !important;
      }

      &.active_color_03 {
        color: black;
        background-color: #e6e6e6 !important;
      }
      &:hover {
        box-shadow: 0 0 10px #57717d;
      }
    }
  }

  .box_01 {
    position: absolute;
    background-color: rgba(0, 123, 255, 0.5);
    border: 1px solid #007bff;
    cursor: move;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
  }

  .box_02 {
    position: absolute;
    border: 1px solid #999;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
  }

  .box_03 {
    position: absolute;
    background-color: #c0c0c0;
    color: #fafafa;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
  }
</style>

组件展示效果(实现示例)

  下面的是我封装的两个组件的效果,一个进行布局的组件,一个进行数据解析的组件。

编辑智慧教室布局
教室
X
图例
X
占位矩形
X
100%

智慧教室布局展示
教室
x: 0 y: 0
w: 150 h: 100
图例
x: 0 y: 150
w: 460 h: 100
占位矩形
x: 0 y: 300
w: 150 h: 100

操作介绍

  1. 分画板和画布。
  2. 按住右键,可以拖动画布。
  3. 画板中的元素可以拖动,缩放,删除。
  4. 鼠标滚轮可以放大缩小画布。
  5. 鼠标左键可以框选中多个元素,然后进行多元素拖拽。

道友再会.