花一周写个拖拽交互

3,081 阅读4分钟

1. 项目需求背景

最近接了一个需求、说要实现组件的拖拽效果。我们项目中没人接、我由于没做过比较感兴趣就接了。本项目实现了一个基于React DnD的断面组件拖拽功能,包含6个断面区域和左侧组件栏。系统支持不同类型组件的拖放限制和参数配置,实现了复杂的业务规则控制。幸不辱命,最终做出来了,先看下效果吧。

test.gif

1.1 业务目标

  • 实现组件的拖拽放置功能
  • 支持不同断面的组件限制规则
  • 提供组件参数配置界面
  • 确保拖拽操作的流畅性和可靠性

2. 功能需求

2.1 基础需求

  1. 断面6左侧区域限制:

    • 仅支持取水泵组件
    • 最多接受一个组件
  2. 断面2-5限制:

    • 支持所有类型组件
    • 每个断面最多一个取水泵组件
  3. 断面1限制:

    • 不接受任何组件

2.2 组件配置需求

  • 支持组件参数的动态配置
  • 不同类型组件显示不同配置项
  • 参数修改实时生效
  • 每个断面设置的最大组件数为5个
  • 填完之后还要把每个断面的填的信息回显
  • 断面2-断面5每个断面有一个初始值组件
  • 取水泵组件要放在断面最上方

3. 代码设计

3.1 组件配置面板实现

左侧目前支持三个组件及取水泵组件的拖拽组件


const DraggableItem: React.FC<{ name: string; type: string }> = ({
  name,
  type,
}) => {
  const [{ isDragging }, dragRef] = useDrag<
    DragItem,
    unknown,
    { isDragging: boolean }
  >({
    type,
    item: { name, type: type as ComponentType },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  return (
    <div
      ref={dragRef}
      style={{
        padding: '8px 16px',
        margin: '8px',
        backgroundColor: isDragging ? '#f0f0f0' : '#ffffff',
        cursor: 'grab',
        opacity: isDragging ? 0.5 : 1,
        display: 'flex',
        flexWrap: 'wrap',
        flexDirection: 'column',
        justifyContent: 'center',
        alignItems: 'center',
        marginRight: type === 'comp1' ? 20 : 8,
      }}
    >
      <img style={{ width: 60 }} src={`/public/images/${type}.png`} alt="" />
      {name}
    </div>
  );
};

const DraggableItemList: React.FC = () => {
  return (
    <Col
      span={2}
      style={{
        border: '1px solid #217CEC',
        display: 'flex',
        justifyContent: 'space-between',
        flexDirection: 'column',
        marginLeft: 20,
      }}
    >
      <DraggableItem name="组件A" type="comp1" />
      <DraggableItem name="组件B" type="comp2" />
      <DraggableItem name="组件C" type="comp3" />
    </Col>
  );
};

export default DraggableItemList;

3.2 断面结构设计

断面数量是跟form表单有联动的、根据设置的个数生成对应的断面数。

  const { sections, containerConfigs } = useMemo(() => {
    // 根据 tabList 长度生成 sections
    const generatedSections = Array.from(
      { length: store.tabList.length - 1 },
      (_, i) => `${i + 1}-${i + 2}`,
    );

    // 生成 containerConfigs
    const generatedContainerConfigs = Array.from(
      { length: store.tabList.length },
      (_, i) => ({
        id: i + 1,
        // 最后一个容器不接受任何组件
        acceptTypes:
          i < store.tabList.length ? ['comp1', 'comp2', 'comp3', 'comp4'] : [],
      }),
    );

    return {
      sections: generatedSections,
      containerConfigs: generatedContainerConfigs.reverse(),
    };
  }, [store.tabList.length]);

3.3 拖拽源组件实现

拖拽用的是react-dnd插件。外层元素上加一个 DndProvider。DndProvider 的本质是一个由 React.createContext 创建一个上下文的组件,用于控制拖拽的行为,数据的共享,类似于react-redux的Provider。

      <DndProvider backend={HTML5Backend}>
        <Row
          style={{ marginTop: '16px', marginRight: '16px' }}
          justify="space-between"
        >
          {/* 可拖拽的组件 */}
          {store.isAllParams && <DraggableItemList />}

          {store.isAllParams && (
            <Col
            >
              <Row style={{ position: 'relative' }}>
                {containerConfigs.map((config) => (
                  <Col
                    key={config.id}
                    style={{
                      flex: !store.isAllParams
                        ? '0 0 100px'
                        // 给断面6左侧留出100px的拖拽区域供取水泵组件放置
                        : config.id === 1
                        ? '0 0 100px'
                        : '1 1 0%',
                      position: 'relative',
                      ...(config.id === containerConfigs.length && {
                        marginLeft: '100px',
                      }),
                    }}
                  >
                    <div
                      style={{
                        position: 'relative',
                        width: '100%',
                      }}
                    >
                      {config.id === containerConfigs.length && (
                        // 断面左侧区域 只接受取水泵
                        <PumpOnlyDropArea
                          containerId={config.id}
                          sideContainerState={sideContainerState}
                          setSideContainerState={setSideContainerState}
                          setSectionData={setSectionData}
                        />
                      )}
                      {/* 断面区域 */}
                      <DropContainer
                        containerId={config.id}
                        acceptTypes={config.acceptTypes as ComponentType[]}
                        sectionData={sectionData}
                        setSectionData={setSectionData}
                        sections={sections}
                        form={form}
                        containerConfigs={containerConfigs as ContainerConfig[]}
                      />
                    </div>
                  </Col>
                ))}
              </Row>
            </Col>
          )}

        </Row>
      </DndProvider>

设置每个断面拖拽组件最大值,设置canDrop逻辑、控制断面1不接受任何组件。


const MAX_ITEMS_PER_CONTAINER = 5;

accept: acceptTypes,
canDrop: (item) => {
if (containerId === 1) return false;
// 找到当前断面
const currentSection = sectionData.find(
  (section) => section.sectionId === containerId,
);
if (!currentSection) return true;

if (item.type === 'comp1') {
  return !currentSection.components.some((c) => c.type === 'comp1');
}

return currentSection.components.length < MAX_ITEMS_PER_CONTAINER;
},

初始化断面数据及设置断面2-断面5的初始化组件。

  const [sectionData, setSectionData] = useState<SectionData[]>(
    containerConfigs.map((config) => ({
      sectionId: config.id,
      components:
        config.id !== 1
          ? [
              {
                id: `initial-${config.id}`,
                name: '初始组件',
                type: 'comp4',
                // 用于接受组件配置参数
                params: {}, 
              },
            ]
          : [],
      ditchLength: 20,
    })),
  );

这样拖拽交互基本完成了、现在要设置每个组件的信息。我这个组件信息配置比较简单。所以我就根据组件类型获取对应的表单配置。

  const getFormConfig = (type: string) => {
    switch (type) {
      case 'comp1': // 取水泵
        return {
          title: '取水泵参数设置',
          fields: [
            {
              name: 'flowRate',
              label: '流量(m³/s)',
              type: 'number',
              rules: [{ required: true, message: '请输入额定流量' }],
            },
          ],
          canDel: true,
        };
      case 'comp2': // 拦污网
        return {
          title: `【断面${containerId - 1}-${containerId}】拦污网水损计算设置`,
          fields: [
            {
              name: 'material',
              label: '拦污网类型',
              type: 'select',
              options: [{ value: '1', label: '类型1' }],
              rules: [{ required: true, message: '请选择拦污网类型 ' }],
            },
            {
              name: 'lossOfBlock',
              label: '拦网局损(m)',
              type: 'number',
              rules: [{ required: true, message: '请输入拦网局损' }],
            },
          ],
          canDel: true,
        };
      case 'comp3': // 渠道变化
        const channelTypeFields = getChannelTypeFields(selectedType);
        return {
          title: `【断面${containerId - 1}-${containerId}】局部水损计算设置`,
          fields: [
            {
              name: 'type',
              label: '渠道类型',
              type: 'select',
              needImg: true,
              options: [
                {
                  value: 'fadeIn',
                  label: '明渠渐放',
                  img: '/public/images/type1.png',
                },
                {
                  value: 'shrinks',
                  label: '明渠渐缩',
                  img: '/public/images/type2.png',
                },
                {
                  value: 'suddenContraction',
                  label: '明渠突缩',
                  img: '/public/images/type3.png',
                },
                {
                  value: 'suddenExpansion',
                  label: '明渠突放',
                  img: '/public/images/type4.png',
                },
                {
                  value: 'rightAngleEntrance',
                  label: '直角入口',
                  img: '/public/images/type5.png',
                },
                {
                  value: 'surfaceEntry',
                  label: '曲面入口',
                  img: '/public/images/type6.png',
                },
                {
                  value: 'elbow',
                  label: '弯管',
                  img: '/public/images/type7.png',
                },
              ],
              rules: [{ required: true, message: '请选择渠道类型' }],
            },
            ...channelTypeFields,
          ],
          canDel: true,
        };
      default:
        return {
          title: '沿程水损计算设置',
          fields: [
            {
              name: 'roughnessSelector',
              component: <RoughnessSelector form={form} />,
              type: 'roughnessSelector',
            },
          ],
          canDel: false,
          showSections: true,
        };
    }
  };

然后根据读取配置去渲染form表单、如果有那种复杂的配置就单独拉组件出来。

  {config.fields.map((field: FileldType) => (
    <Col span={16} key={field.name}>
      <Form.Item
        name={field.name}
        label={field.label}
        rules={field.rules}
      >
        {renderFormItem(field)}
      </Form.Item>
    </Col>
  ))}

这样拖拽的交互基本就出来了、接下来是数据填完回显了。本来以为没什么难点就一个form回显而已。后来产品说如果一个断面拖多个相同的组件要用逗号隔开。当时火气就上来了。本来这个工期就很赶,要的又急。直接说不好做、等我研究一下。下班前给答复。后面冷静下来仔细想了一下就做出来了,就是写的很恶心。

  useEffect(() => {
  const formValues: FormValues = {};

  sections.forEach((section) => {
    // 找到对应的断面配置 ID
    const matchConfig = containerConfigs.find(
      (config) => section === `${config.id - 1}-${config.id}`,
    );

    if (matchConfig) {
      // 找到对应的断面数据
      const matchingSection = sectionData.find(
        (s) => s.sectionId === matchConfig.id,
      );

      if (matchingSection) {
        // 初始化表单值
        formValues[`section_${section}`] = {
          ditchLength: matchingSection.ditchLength,
        };

        // 用于存储相同类型组件的参数
        const componentParams = {
          waterIntakeArea: [] as number[],
          lossOfBlock: [] as number[],
          coefficientOfLess: [] as number[],
          waterIntakeCcoefficientOfLess: [] as number[],
          coefficientOfLessR: [] as number[],
          coefficientOfLessL: [] as number[],
          roughnessCoefficient: [] as number[],
        };

        // 遍历当前断面的所有组件
        matchingSection.components.forEach((component: Component) => {
          // 根据组件类型收集参数
          switch (component.type) {
            case 'comp2': // 拦污网
              if (component.params?.lossOfBlock) {
                componentParams.lossOfBlock.push(
                  component.params.lossOfBlock,
                );
              }
              break;
            case 'comp3': // 渠道变化
              if (component.params?.coefficientOfLess) {
                componentParams.coefficientOfLess.push(
                  component.params.coefficientOfLess,
                );
              }
              if (component.params?.waterIntakeCcoefficientOfLess) {
                componentParams.waterIntakeCcoefficientOfLess.push(
                  component.params.waterIntakeCcoefficientOfLess,
                );
              }
              if (component.params?.coefficientOfLessR) {
                componentParams.coefficientOfLessR.push(
                  component.params.coefficientOfLessR,
                );
              }
              if (component.params?.coefficientOfLessL) {
                componentParams.coefficientOfLessL.push(
                  component.params.coefficientOfLessL,
                );
              }
              if (component.params?.waterIntakeArea) {
                componentParams.waterIntakeArea.push(
                  component.params.waterIntakeArea,
                );
              }
              break;
            case 'comp4': // 初始组件
              if (component.params?.roughnessCoefficient) {
                componentParams.roughnessCoefficient.push(
                  component.params.roughnessCoefficient,
                );
              }
              break;
          }
        });

        // 将收集的参数转换为逗号分隔的字符串
        formValues[`section_${section}`] = {
          ...formValues[`section_${section}`],
          waterIntakeArea: componentParams.waterIntakeArea.join(','),
          lossOfBlock: componentParams.lossOfBlock.join(','),
          coefficientOfLess: componentParams.coefficientOfLess.join(','),
          waterIntakeCcoefficientOfLess:
            componentParams.waterIntakeCcoefficientOfLess.join(','),
          coefficientOfLessR: componentParams.coefficientOfLessR.join(','),
          coefficientOfLessL: componentParams.coefficientOfLessL.join(','),
          roughnessCoefficient:
            componentParams.roughnessCoefficient.join(','),
        };
      }
    }
  });

  // 设置表单值
  form.setFieldsValue(formValues);
}, [sectionData, form, sections, containerConfigs]);

回显参数效果如下

image.png

4总结

遇到需求不要怕。人和代码总有一个能跑,只有想不到的,没有做不到的。只要你想、相信就一定能如愿以偿。还有开发的时候心态一定要好。最终如愿做出来了、得到产品和主管的表扬。

留下一个问题 如果客户不提供服务器的情况下,前端怎么把前后端服务打包成一个exe文件。交付出去的时候客户打开exe就能跑这个计算工具。