二三维表单如何实现机柜拖拽上下架?| 图扑智慧机房

By | 2026年6月29日

在数据中心运维的日常中,设备上下架管理一直是个“经典难题”:运维人员需要反复在 Excel 表格、U 位图纸和物理机柜之间来回切换。整个流程不仅耗时耗力,运维效率低下,还易出现机位错放、资产账实不符等问题。

  • 例如定位 A01423 服务器时,需先后核对表格、机柜图纸,确认其 B7 机柜 14U-16U 机位后,再去机房现场精确查找。

本文提出二维表单联动三维机柜可视化解决方案,通过图扑创新式可视化管控系统,让设备管理从“表格查找”升级为“空间操作”。

该案例依托拖拽交互模式,实现表格设备直接上架、三维场景设备反向下架。系统搭建一体化交互架构,基于共享数据模型实现二三维视图数据实时同步,通过事件驱动保障双向拖拽精准定位,搭配可自适应视角与窗口变化的动态连接线、空闲 U 位智能推荐算法,以及多层交互动画优化整体操作体验。下文将对系统的设计思路与具体实现方式展开详细阐述。

系统分析

01 设备上下架交互

设备上架流程

在传统 Excel 式管理中,一台服务器仅表现为一行数字与文字。而在本系统中,设备具备完整的“可拖拽”实体属性,上架过程如下:

01 权限校验

系统首先检查用户是否处于编辑模式——防止误操作影响生产环境。该机制类似于物理机房需凭工单操作,确保安全。

// 检查编辑权限
if (!isEditMode && event.kind !== 'sortChange') return;

02 触发拖拽与机位高亮

用户从表格中拖拽一台“未上架”设备时,系统标记拖拽方向,并生成自定义拖拽图标。同时在 3D 机柜中高亮所有符合尺寸要求的空闲 U 位。拖拽图标通过独立 JSON 文件配置,支持按需自定义。

// 标记拖拽方向为2D→3D
isDraggingFromTable = true;
const rowData = event.info.data;
// 创建拖拽视觉元素
event.elementInfo = {
    element: ht.Default.toCanvas('device-drag-icon.json', 24, 24),
    offsetX: 0,
    offsetY: 0
};
// 通知3D视图创建高亮块
event.fire("createHighlightBlock",rowData.a('unit') );

拖拽过程中,目标机柜上所有可用的连续 U 位会以高亮块形式呈现,类似影院空座位标识。

03 实时位置计算与悬停高亮

鼠标移动时,系统实时计算当前悬停的 U 位槽位,并动态调整高亮状态,实现精准点位指引。

const targetSlot = rack3DView.getDataAt(event.nativeEvent);
if (targetSlot && targetSlot.getParent() === highlightBlock) {
    highlightBlock.eachChild(slot => {
        // 高亮当前悬停的槽位
        slot.s('highlight.group', slot === targetSlot ? 1 : undefined);
        slot.s('highlight.mode', slot === targetSlot);
    });
}

针对服务器、交换机等占用多联 U 位的设备,系统内置算法,可根据设备尺寸、机柜总容量及已占用机位,快速筛选全部可用连续空闲 U 位。

/**
 * 寻找机柜中可用的连续U位
 * @param {number}deviceUnit - 设备所需U位数(必须大于0)
 * @param {number}maxUnits - 机柜总U位数(必须大于0)
 * @param {Array<number>}occupiedUnits - 已占用U位数组,范围应在[1, maxUnits]之间
 * @returns {Array<number>}可用的起始U位数组,按升序排列
 * @example
 * // 返回 [1, 7, 12]
 * findAvailablePositions(2, 24, [3, 4, 5, 6, 9, 10]);
 */
functionfindAvailablePositions(deviceUnit, maxUnits, occupiedUnits) {
    // 1. 参数验证
    if (typeof deviceUnit !== 'number' || deviceUnit <= 0 ||
         typeof maxUnits !== 'number' || maxUnits <= 0 ||
        !Array.isArray(occupiedUnits)) {
        console.warn('findAvailablePositions: 参数无效', { deviceUnit, maxUnits, occupiedUnits });
        return [];
    }
    // 设备需求超过机柜总容量,直接返回空数组
    if (deviceUnit > maxUnits) {
        return [];
    }
    // 2. 准备数据:过滤无效的占用位置并创建查找集合
    const validOccupiedUnits = occupiedUnits.filter(
        position =>Number.isInteger(position) && position >= 1 && position <= maxUnits
    );
    const occupiedSet = newSet(validOccupiedUnits);
    // 3. 查找可用位置
    const availableStartPositions = [];
    let consecutiveFreeSlots = 0;
    for (let position = 1; position <= maxUnits; position++) {
        if (occupiedSet.has(position)) {
            // 当前位置被占用,重置连续空闲计数
            consecutiveFreeSlots = 0;
            continue;
        }
        consecutiveFreeSlots++;
        // 找到满足设备需求的连续空位
        if (consecutiveFreeSlots >= deviceUnit) {
            const startPosition = position - deviceUnit + 1;
            availableStartPositions.push(startPosition);
        }
    }
    // 4. 按约定返回结果(已经是升序)
    return availableStartPositions;
}

04 放置确认与数据同步

用户松开鼠标时,系统执行原子性数据更新:在 3D 机柜创建设备模型、修改 2D 表格设备状态(已上架/未上架)、记录机位信息,并同步至上架设备清单。所有操作要么全部成功,要么全部回滚,保证数据一致性。

if (slot === targetSlot) {
    const deviceInfo = rowData.getAttrObject();
    const startU = slot.a('slotIndexes')[0];
    // 更新设备占用信息
    deviceInfo.occupy = [startU];
    // 在3D视图中创建设备模型
    mountDevice(deviceInfo);
    // 更新设备状态为已上架
    event.info.data.a('status', '已上架');
    // 记录到已上架设备列表
    mountedDeviceList.push({
        ...deviceInfo,
        startU: startU,
        position: `${startU}U-${startU + deviceInfo.unit}U`
    });
}

05 界面状态收尾

上架完成后,系统自动触发统计面板更新、当前选中设备高亮刷新、连接线重绘以及临时高亮块清理。

// 更新统计面板
updateStatisticsPanel();
// 更新当前选中信息
rowInfo = deviceInfo;
// 让3D视图高亮显示新设备
highlightSelectedDevice();
// 重新绘制连接线
updateConnectionLine();
// 恢复鼠标指针
view.setCursor(null);
// 清理高亮块
rack3DView.dm().remove(highlightBlock);

06 表格排序与机内迁移

表格排序适配:运维人员可按设备类型、负载、上架时间等维度排序表格。系统等待表格渲染完成后,自动重新计算并刷新连接线,保证 2D 与 3D 设备对应关系不变。

if (event.kind === 'sortChange') {
    // 等待表格渲染完成后重新计算连接线
    ht.Default.callLater(() => {
        calculateLineStartPoint();
        updateConnectionLine();
    });
}

机柜内设备迁移:对于已在机柜中的设备,系统同样支持拖拽重定位。拖拽开始时保存原始位置,并临时创建可用 U 位高亮块;拖拽结束后更新占用信息,拖拽取消则恢复原位。

/**
 * 处理设备拖拽开始事件
 * @param {Object}event - 拖拽事件对象
 */
functiononBeginDrag(event) {
    // 检查编辑权限
    if (!isEditMode) return;
    // 验证拖拽事件类型
    if (event.type !== 'data')  return;
    const deviceData = event.data;
    const deviceShape = deviceData.s('shape3d');
    // 验证是否为可拖拽的设备模型
    if (!deviceModels.includes(deviceShape)) return;
    // 记录拖拽信息
    dragInfo = {
        draggingElementInfo: {
            element: ht.Default.toCanvas('device-drag-icon.json', 24, 24),
        },
        view: view,
        data: deviceData
    };
    // 保存原始位置以便取消操作
    const originalPosition = deviceData.p3();
    deviceData.a('originalPosition', originalPosition);
    try {
        // 启动拖拽
        ht.drawing.Dragger.startDragging(dragInfo, event.event);
        // 获取设备信息用于高亮提示
        const unitSize = deviceData.a('unit');
        const occupyArray = deviceData.a('occupy');
        // 创建高亮块显示可用位置
        if (unitSize && occupyArray && occupyArray.length > 0) {
            const minOccupy = Math.min(...occupyArray);
            createHighlightBlock(unitSize, minOccupy);
        }
        // 临时取消设备高亮
        deviceData.s('highlight.group', undefined);
        deviceData.s('highlight.mode', false);
    } catch (error) {
        console.error('拖拽操作失败:', error);
        // 清理拖拽状态
        dragInfo = null;
        deviceData.a('originalPosition', null);
    }
}

07 拖拽状态重置

拖拽动作结束后,清空方向标记,区分上下架两类拖拽逻辑。isDraggingFromTable 标志位很关键,用于区分拖拽来源,确保后续逻辑(如下架)正确处理。

if (event.kind === 'endDrag') {
    isDraggingFromTable = false;
}

技术亮点

  • 状态驱动交互:通过 isEditMode 控制整个系统的可操作性,确保生产安全;
  • 实时视觉反馈:拖拽过程中的高亮变化、放置时的状态更新,都提供了清晰的交互引导;
  • 全域数据同步:2D 表格、3D 模型、统计数据通过统一的数据模型保持同步;
  • 完善异常兜底:无效拖拽、放置失败等场景支持自动回退,避免界面异常;
  • 高性能设计:结合事件委托、异步执行机制,海量设备场景下仍可流畅运行。

设备下架流程

系统支持反向拖拽实现从 3D 机柜拖回表格,采用状态标记替代物理删除的设计,保留设备全量数据,支持操作追溯与复原。

if (event.kind === 'crossDrop') {
    const deviceData = event.info.data;
    const deviceName = deviceData.a('name');
    // 1. 修改设备状态
    deviceData.a('status', '未上架');
    // 2. 从已上架设备列表中移除
    const deviceIndex = mountedDeviceList.findIndex(device => device.name === deviceName);
    if (deviceIndex === -1) {
        console.warn(`设备 ${deviceName} 未在上架列表中找到`);
        return; // 或进行相应处理
    } else {
        // 如果当前选中的设备是正在下架的设备,清除选中状态
        if (selectedEdge && selectedEdge.a('name') === deviceName) {
            selectedEdge = null;
            hideConnectionLine();
        }
        // 从已上架列表移除
        mountedDeviceList.splice(deviceIndex, 1);
    }
    // 3. 如果是从3D拖到2D,从表格数据模型中移除对应行
    if (!isDraggingFromTable) {
        const tableDataModel = data.a('dataModel');
        const tableRows = tableDataModel.toDatas().toArray();
        for (let i = 0; i < tableRows.length; i++) {
            const tableRow = tableRows[i];
            if (tableRow.a('name') === deviceName) {
                tableDataModel.remove(tableRow);
                break; // 找到后立即跳出循环
            }
        }
    }
    // 4. 更新统计面板
    updateStatisticsPanel();
}

流程遵循

  • 状态驱动而非删除:通过状态变更而非物理删除,保留了操作的历史痕迹;
  • 数据一致性保障:更新状态的同时,同步更新相关的统计数据面板;
  • 智能状态清理:自动清理冗余界面元素,优化视觉体验;
  • 拖拽方向感知:通过 isDraggingFromTable 标志区分拖拽方向,确保逻辑正确处理。

02 关于动态连接线

连接线生成逻辑

选中表格内已上架设备时,系统会自动生成一条动态连接线,作为连接 2D 表单与 3D 机柜的视觉桥梁。连接线的生成,依托精准的坐标换算与路径计算,适配表格滚动、视图旋转等各类界面变化。

01 计算连接线起点

起点定位需综合表格表头高度、行高、行间距、纵向滚动偏移、表格排序结果等多重因素,精准定位选中表格行的中心坐标。

/** * 计算连接线起点位置(表格行中心) * @returns {Object|null}返回起点坐标对象 {x, y},如果无法计算则返回null */functioncalculateLineStartPoint() {    // 1. 获取表格节点    const tableNode = dm.getDataByTag('table');    // 2. 检查是否有选中的行信息    if (!rowInfo || !rowInfo.name) {        console.warn('未选中表格行,无法计算连接线起点');        returnnull;    }    // 3. 获取表格相关属性    const tableWidth = tableNode.getWidth();    const tableHeight = tableNode.getHeight();    const tableCenterX = tableNode.p().x + tableWidth / 2;    const headRowHeight = tableNode.a('headRowHeight') || 0;    const rowHeight = tableNode.a('rowHeight') || 0;    const rowLineWidth = (tableNode.a('rowLineStyle') || {}).width || 0;    const verticalScrollOffset = tableNode.a('table.translateY') || 0;    // 4. 计算表格数据区域第一行的起始Y坐标    const tableTopY = tableNode.p().y - tableHeight / 2;    const firstRowCenterY = tableTopY + headRowHeight + rowHeight / 2;    // 5. 获取设备在当前排序后的行索引    const rowUIs = getTableRowUIs(view, tableNode);    if (!rowUIs || rowUIs.length === 0) {        console.warn('无法获取表格行UI信息');        returnnull;    }    const rowIndex = getRowIndexByName(rowUIs, rowInfo.name);    if (rowIndex === -1) {        console.warn('未找到对应的表格行:', rowInfo.name);        returnnull;    }    // 6. 计算该行的Y坐标(考虑行高和行间距)    const rowCenterY = firstRowCenterY + rowIndex * (rowHeight + rowLineWidth) + verticalScrollOffset;    return {        x: tableCenterX,        y: rowCenterY    };}/** * 获取表格行UI实例 * @param {Object}view - 视图对象 * @param {Object}tableNode - 表格节点 * @returns {Array}表格行UI数组 */functiongetTableRowUIs(view, tableNode) {    const drawingInstances = ht.drawing.getDrawingInstances(view, tableNode);    return drawingInstances.length > 0 ? drawingInstances[0].ui.rowUIs : [];}/** * 根据设备名称获取行索引 * @param {Array}rowUIs - 行UI数组 * @param {string}deviceName - 设备名称 * @returns {number}行索引,未找到返回-1 */functiongetRowIndexByName(rowUIs, deviceName) {    for (let i = 0; i < rowUIs.length; i++) {        const rowData = rowUIs[i].data;        if (rowData && rowData.a('name') === deviceName) {            return i;        }    }    return -1;}

02 计算连接线终点

终点需要完成 3D 世界坐标→屏幕坐标→2D 逻辑坐标两层转换,结合 3D 视图当前视角、相机位置等因素,将三维设备位置映射到二维界面中。

/** * 计算连接线终点(3D设备二维投影) * @returns {Object|null} 坐标对象 {x, y},计算失败返回null */function calculateLineEndPoint() {    if (!selectedEdge) {        console.warn('未选中3D设备,无法计算终点');        return null;    }    const rack3DView = ht.Default.findView('3d');    const device3DPosition = selectedEdge.p3();    try {        // 3D世界坐标转屏幕坐标        const screenPosition = rack3DView.toViewPosition(device3DPosition);        if (!screenPosition) {            console.warn('3D坐标转屏幕坐标失败');            return null;        }        // 屏幕坐标转2D逻辑坐标        const logicalPosition = view.getLogicalPoint(screenPosition);        if (!logicalPosition) {            console.warn('屏幕坐标转逻辑坐标失败');            return null;        }        return {            x: logicalPosition.x,            y: logicalPosition.y        };    } catch (error) {        console.error('计算连接线终点异常:', error);        return null;    }}

03 生成自适应贝塞尔曲线

获取起止坐标后,系统不使用生硬直线,而是动态生成贝塞尔曲线。控制点会根据两点水平距离自动适配,兼顾短距离不尖锐、长距离比例协调的视觉效果。

/** * 更新连接线显示状态 * @param {boolean}shouldShow - 是否显示连接线 */functionupdateConnectionLine(shouldShow) {    if (!shouldShow) {        // 隐藏所有连接线元素        connectionLine.s('2d.visible', false);        startMarker.s('2d.visible', false);        endMarker.s('2d.visible', false);        return;    }    // 显示起点和终点标记    startMarker.s('2d.visible', true);    endMarker.s('2d.visible', true);    const startPoint = calculateLineStartPoint();  // 表格行中心    const endPoint = calculateLineEndPoint();      // 3D设备投影位置    // 创建贝塞尔曲线控制点数组    let controlPoints = [];    const horizontalDistance = Math.abs(endPoint.x - startPoint.x);    // 6个控制点构成的贝塞尔曲线    controlPoints.push(startPoint);                                    // 起点    controlPoints.push({ x: startPoint.x + 12, y: startPoint.y });    // 水平右移12px    controlPoints.push({         x: startPoint.x + Math.max(40, horizontalDistance / 2),         y: startPoint.y     });                                                                // 动态控制点    controlPoints.push({         x: endPoint.x - Math.max(40, horizontalDistance / 2),         y: endPoint.y     });                                                                // 接近终点    controlPoints.push({ x: endPoint.x - 12, y: endPoint.y });        // 水平左移12px    controlPoints.push(endPoint);                                      // 终点    // 更新连接线显示    connectionLine.s('2d.visible', true);    connectionLine.setPoints(controlPoints);    connectionLine.setSegments([1, 2, 4, 2]); // 控制曲线分段:直线-曲线-直线}

技术亮点

  • 动态控制点:根据起点和终点的水平距离动态调整控制点位置,确保无论距离远近,曲线都保持优雅的比例;
  • 最小水平偏移:使用 Math.max(40, horizontalDistance / 2) 确保连接线有足够的水平延伸,避免短距离时曲线过于尖锐;
  • 平滑过渡:Segments 参数[1, 2, 4, 2]控制曲线分段方式,创建自然的”S”形过渡;
  • 视觉美感:通过在起点和终点附近设置 12 像素的水平延伸,创建优雅的曲线入口和出口。

跨维度快速检索

依托连接线实现表格与 3D 设备双向联动查询,运维人员可从任意一端发起查找,快速定位对应目标,彻底摆脱人工脑补换算的繁琐。

01 选中表格条目,定位 3D 设备位置

点击表格中的设备行,系统自动记录设备信息,同步在 3D 视图中匹配对应设备并高亮,同时拉起连接线完成关联展示。若设备处于未上架状态,则不触发查找逻辑。

// 监听表格选中状态变化let tableDm = getDataAttr('table', 'dataModel');let tableSm = tableDm.sm();tableSm.ms((event) => {    const selectedRowData = tableSm.ld();    if (selectedRowData) {        // 存储选中行信息        rowInfo = selectedRowData.getAttrObject();        // 如果设备未上架,则清除选中边界        if (rowInfo.status === '未上架') {            selectedEdge = null;        }    } else {        // 清除选中信息        rowInfo = {};        selectedEdge = null;    }    // 更新3D选中状态并重绘连接线    getSelectedEdge();    updateConnectionLine();});

该模式优势显著:无需人工对照编号查找,视觉引导一目了然,同时自动识别设备上架状态,规避无效查找。

02 点击 3D 设备,定位表格数据

反向操作同样顺畅:点击机柜内的 3D 设备,系统根据设备名称反向匹配表格数据,自动将对应行滚动至视野中心并高亮,两端同步标记,实现双向互通。

functiongetSelectedEdge() { // 设置当前选中的3d设备高亮效果    var g3d = ht.Default.findView('3d');    var upDevices = g3d.dm().getDataByTag('upDevices');    upDevices.eachChild((data) => {        const upInfo = data.getAttrObject();        const selected = upInfo.name === rowInfo.name;        if (selected) {            selectedEdge = data;        }        data.s('highlight.group', selected ? 1 : undefined);        data.s('highlight.mode', selected);    });}

双向查找机制,真正建立起数据记录与物理设备的强关联,让抽象数据拥有直观的空间形态,降低运维查找成本

视角与窗口变化实时处理

连接线需要实时跟随两种用户操作:3D 视角旋转(旋转/平移/缩放)和浏览器窗口尺寸变化。通过精心设计的监听与调度机制,系统确保了 2D 表格与 3D 设备之间的连接线始终准确、流畅。

双重监听与性能优化

系统建立了两个关键的事件监听器,覆盖可能影响连接线位置的变化。采用基于 requestAnimationFrame 的智能节流机制:

// 性能优化相关变量let animationFrameId = null;let isUpdating = false;/** * 调度连接线更新任务 */functionscheduleConnectionLineUpdate() {    // 如果已有更新任务在执行,跳过    if (isUpdating) return;    // 取消未执行的更新任务    if (animationFrameId) {        cancelAnimationFrame(animationFrameId);    }    // 使用requestAnimationFrame调度更新    animationFrameId = requestAnimationFrame(() => {        isUpdating = true;        try {            updateConnectionLine();  // 重新计算并绘制连接线        } finally {            isUpdating = false;            animationFrameId = null;        }    });}// 监听3D视角变化(旋转、平移、缩放)view.mp((event) => {    // 仅响应视角相关的变化    if (event.property !== 'eye' && event.property !== 'center') return;    scheduleConnectionLineUpdate();});// 监听窗口大小变化addEventListener('resize', () => {    scheduleConnectionLineUpdate();});

采用智能节流策略

  • 防抖设计:通过 isUpdating 标志防止重复计算,确保同一时间只有一个更新任务在执行;
  • 请求合并:使用 cancelAnimationFrame 取消未执行的更新请求,避免任务堆积;
  • 帧率同步:借助 requestAnimationFrame 确保更新与浏览器刷新同步,保持 60fps 流畅体验。

实际效果

  • 快速旋转 3D 视图 → 连接线平滑跟随,无卡顿或闪烁;
  • 拖动窗口边界 → 连接线立即重新计算并精准对齐;
  • 高频率触发事件 → 系统资源不浪费,避免不必要的重绘。

总结

本文所述的可视化方案,本质上是将抽象的设备记录与具体的物理位置通过直观的交互和视觉反馈连接起来。它解决了传统运维中的几个关键痛点:

  • 位置感知:不再需要脑补设备在机柜里的位置;
  • 操作直观:拖拽比填表格更符合直觉;
  • 实时反馈:任何操作都能立即看到结果;
  • 降低门槛:新员工也能快速上手。

成熟的方案绝非单纯代码堆叠,而是兼顾使用体验的人性化设计;动画不追求视觉特效,而是引导用户视线;拖拽交互并非简化开发,而是模拟真实物理操作逻辑;连线图形不以装饰为目的,搭建多视图间的数据认知关联。

图扑已落地海量智慧机房、数据中心运维管控可视化项目,覆盖园区、机房、机柜、设备多层级场景,积累了丰富的资产、动环、能耗一体化开发经验。