1. 项目需求背景
最近接了一个需求、说要实现组件的拖拽效果。我们项目中没人接、我由于没做过比较感兴趣就接了。本项目实现了一个基于React DnD的断面组件拖拽功能,包含6个断面区域和左侧组件栏。系统支持不同类型组件的拖放限制和参数配置,实现了复杂的业务规则控制。幸不辱命,最终做出来了,先看下效果吧。
1.1 业务目标
- 实现组件的拖拽放置功能
- 支持不同断面的组件限制规则
- 提供组件参数配置界面
- 确保拖拽操作的流畅性和可靠性
2. 功能需求
2.1 基础需求
-
断面6左侧区域限制:
- 仅支持取水泵组件
- 最多接受一个组件
-
断面2-5限制:
- 支持所有类型组件
- 每个断面最多一个取水泵组件
-
断面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]);
回显参数效果如下
4总结
遇到需求不要怕。人和代码总有一个能跑,只有想不到的,没有做不到的。只要你想、相信就一定能如愿以偿。还有开发的时候心态一定要好。最终如愿做出来了、得到产品和主管的表扬。
留下一个问题 如果客户不提供服务器的情况下,前端怎么把前后端服务打包成一个exe文件。交付出去的时候客户打开exe就能跑这个计算工具。