邏輯升級,深度解析如何實現業務中的且或元件

袋鼠云数栈發表於2024-05-20

在業務實現的過程中,時常會出現且或關係邏輯的拼接。邏輯運算的組合使用,是實現複雜業務規則和決策支援系統的關鍵技術。

目前袋鼠雲的指標管理平臺客戶資料洞察平臺資料資產平臺都有在使用。並且,且或元件已經在 RC 5.0 中新增到元件庫,企業現在可以更加靈活地構建和實施複雜的業務規則。

file

本文將從前期分析、元件封裝、具體實現三個維度深入探討如何實現業務中的且或元件

前期分析

01 確定好資料結構

因為是巢狀結構,可以透過 ➕➖ 來增加層級或者資料,因此採用樹形結構來儲存資料。

export interface IFilterValue<T> {
  key: string;
  level?: number; // 當前節點的層級,用於判斷一些按鈕的展示
  type?: number; // 當前節點的條件關係,1 | 2
  rowValues?: T; // Form 節點的相關的資訊(子節點無條件節點時才有)
  children?: IFilterValue<T>[]; // 子節點的資訊(子節點存在條件節點時才有)
}

上述的圖片的資料為:

 {
    "key": "qTipLrlUt",
    "level": 1,
    "children": [
        {
            "key": "B6Jrbqcfof",
            "type": 2,
            "level": 2,
            "children": [
                {
                    "rowValues": {
                        "condition": 1,
                        "rowPermission": ""
                    },
                    "key": "deg8x8UgZ",
                    "level": 2
                },
                {
                    "key": "_sczw_1h8H",
                    "type": 1,
                    "level": 3,
                    "children": [
                        {
                            "key": "Z5UkUPJoA",
                            "rowValues": {
                                "condition": 1,
                                "rowPermission": ""
                            },
                            "level": 3
                        },
                        {
                            "key": "MbpJILqHGx",
                            "rowValues": {
                                "condition": 1,
                                "rowPermission": ""
                            },
                            "level": 3
                        }
                    ]
                }
            ]
        },
        {
            "rowValues": {
                "condition": 1,
                "rowPermission": ""
            },
            "key": "qx6bG0o5H",
            "level": 1
        }
    ],
    "type": 1
}

02 明確每個操作按鈕的實現

03 明確元件的封裝

· 元件只希望實現條件節點/線條/操作按鈕的展示,因此後面的元件需要作為引數 component 傳入

· 元件對層級有一個控制,支援 maxLevel 來控制

· 每一次新增資料的時候,預設值需要傳入 initValues

· 支援兩種模式:「編輯狀態」和「檢視狀態」

· 支援受控和非受控兩種模式

元件封裝

01 FilterRules

提供給使用者使用的元件,實現資料的增刪改查操作,可以採用受控和非受控兩種模式。它接受的引數如下:

interface IProps<T> {
  value?: IFilterValue<T>;
  disabled?: boolean;
  maxLevel?: number;
  initValues: T;
  notEmpty?: { data: boolean; message?: string };
  component: (props: IComponentProps<T>) => React.ReactNode;
  onChange?: (value: IFilterValue<T> | undefined) => void;
}
export const FilterRules = <T>(props: IProps<T>) => {
  const {
    component,
    maxLevel = 5,
    disabled = false,
    notEmpty = { data: true, message: '必須有一條資料' },
    value,
    initValues,
    onChange,
  } = props;
  // 查詢當前操作的節點
  const finRelationNode = (
    parentData: IFilterValue<T>,
    targetKey: string,
    needCurrent?: boolean,
  ): IFilterValue<T> | null | undefined => {};
  const handleAddCondition = (keyObj: { key: string; isOut?: boolean }) => {};
  // 增加新的資料,判斷是在當前節點下新增或者新生成一個條件節點
  const addCondition = (
    treeNode: any,
    keyObj: { key: string; isOut?: boolean },
    initRowValue: T,
  ) => {};
  const handleDeleteCondition = (key: string) => {};
  // 刪除節點,刪除當前節點下的一條資料或者是刪除一個條件節點
  const deleteCondition = (parentData: IFilterValue<T>, key: string) => {};
  // 刪除一個條件節點時,更新當前資料的層級
  const updateLevel = (node: IFilterValue<T>) => {};
  // 更改條件節點的條件
  const handleChangeCondition = (
    key: string,
    type: ROW_PERMISSION_RELATION,
  ) => {};
  // 改變節點的的資料
  const handleChangeRowValues = (key: string, values: T) => {};
  return (
    <RulesController<T>
      maxLevel={maxLevel}
      disabled={disabled}
      value={value}
      component={component}
      onAddCondition={handleAddCondition}
      onDeleteCondition={handleDeleteCondition}
      onChangeCondition={handleChangeCondition}
      onChangeRowValues={handleChangeRowValues}
    />
  );
};

● 編輯情況

· 非受控元件使用

<Form form={form}>
  <Form.Item name={'condition'}>
    <FilterRules<IRowValue>
      component={(props) => (
        <RowColumnConfig columns={record?.columns ?? []} {...props} />
      )}
      maxLevel={MAX_LEVEL}
      initValues={INIT_ROW_VALUES}
    />
  </Form.Item>
</Form>;

// RowColumnConfig 實現,name 可能是 children[0].formValues
<Form.Item
  name={['condition', ...name, 'column']}
  rules={[{ message: '請選擇欄位', required: true }]}
  initialValue={column}
>
  <Select placeholder="請選擇欄位">
    {columns.map((item) => (
      <Option key={item} value={item}>
        {item}
      </Option>
    ))}
  </Select>
</Form.Item>;

// 最後透過 form.validateFields() 拿到的和上述的資料結構一致

· 受控元件使用

const [ruleData, setRuleData] = useState({
  key: shortid(),
  level: 0,
  rowValues: {
    column: first.column,
    condition: first.condition,
    rowPermission: first?.value,
  },
});

<FilterRules<IRowValue>
  value={ruleData}
  component={(props) => (
    <RowColumnConfig columns={record?.columns ?? []} {...props} />
  )}
  maxLevel={MAX_LEVEL}
  initValues={INIT_ROW_VALUES}
  onChange={setRuleData}
/>;
// 透過 ruleData 就能夠拿到最後的結果

● 檢視使用

<FilterRules
  component={(props) => <RowColumnConfig columns={[]} {...props} />}
  disabled
  value={value}
/>

● 編輯檢視使用(後續新增)

file

上圖為最後實現的效果,適用於部分資料禁用且可以編輯其他資料。常見業務情景:上一次儲存的資料不可修改,但需要在當前基礎上繼續新增資料。

在這種使用模式下,FilterRules 元件上的 props 依舊為 false,透過設定 value 中每一個節點的 disabled 屬性來實現上述功能。

// 修改 IFilterValue 的型別
// 💭注意,如果當前節點是條件節點,children 內節點的狀態和當前節點的 disabled 息息相關
export interface IFilterValue<T> {
    key: string;
    level?: number;                   // 當前節點的層級,用於判斷一些按鈕的展示
    type?: number;                    // 當前節點的條件關係,1 | 2
  + disabled?: boolean;               // 當前節點的狀態  
    rowValues?: T;                    // Form 節點的相關的資訊(子節點無條件節點時才有)
    children?: IFilterValue<T>[];     // 子節點的資訊(子節點存在條件節點時才有)
}

上述圖片的資料結構如下:

const INIT_CHECK_DATA = {
    key: shortid(),
    level: 0,
    type: 1,
    children: [
        {
            rowValues: {
                input: '',
            },
            disabled: true,
            key: shortid(),
            level: 1,
        },
        {
            key: shortid(),
            type: 1,
            level: 2,
            disabled: true,
            children: [
                {
                    rowValues: {
                        input: '',
                    },
                    key: shortid(),
                    level: 2,
                },
                {
                    key: shortid(),
                    rowValues: {
                        input: '',
                    },
                    level: 2,
                },
            ],
        },
        {
            rowValues: {
                input: '',
            },
            key: shortid(),
            level: 1,
        },
        {
            rowValues: {
                input: '',
            },
            key: shortid(),
            level: 1,
        },
    ],
};

在這種模式下,要去計算對應的高度和渲染正確的樣式時,對於其 disabled 的計算需要改為 FilterRules 的 disabled 和當前節點的 disabled 做整合,disabled || !!item.disabled。

02 RulesController

做節點的展示,渲染正確的元件。

具體實現

01 編輯時高度計算

● 計算每個節點的高度

file

· 如果是普通節點(藍色),它的高度為 ITEM_HEIGHT + MARGIN (輸入框的高度 + marginBottom)

· 如果是條件節點(灰色),它的高度為 children 中每一個節點的高度 + 新增節點的高度 ITEM_HEIGHT

const calculateTreeItemHeight = (item, isEdit) => {
  if (!item?.children)
    return weakMap.set(item, {
      height: ITEM_HEIGHT + MARGIN,
      lineHeight: ITEM_HEIGHT,
    });
  item.children.map((child) => calculateTreeItemHeight(child, disabled));
  const height = item.children.reduce(
    (prev, curr) => prev + weakMap.get(curr).height,
    ITEM_HEIGHT,
  );
  weakMap.set(item, { height });
};

● 計算每個節點的連線高度

file

· 如果是最後一個條件節點

線條長度(紅色線條)為:塊級高度 - (第一個節點高度 - MARGIN)/2 - 最後一個節點/2

· 如果不是最後一個條件節點

線條長度為:firstNodeLineHeight + 剩餘子節點高度 + 新增節點/2

a.第一個子節點是普通節點(藍色線條):firstNodeLineHeight = 節點高度/2 + MARGIN

b.第一個子節點是條件節點(綠色線條):firstNodeLineHeight = 子節點線條高度 + 新增節點/2

const calculateTreeItemHeight = (item: IFilterValue<T>, disabled: boolean) => {
  if (!item?.children)
    return weakMap.set(item, {
      height: ITEM_HEIGHT + MARGIN,
      lineHeight: ITEM_HEIGHT,
    });
  item.children.map((child) => calculateTreeItemHeight(child, disabled));
  const isLastCondition = !item.children.some(isCondition);
  const firstNodeIsCondition = isCondition(item.children[0]);
  const height = item.children.reduce(
    (prev, curr) => prev + weakMap.get(curr).height,
    ITEM_HEIGHT,
  );
  let lineHeight;
  // 如果當前節點是最後的判斷節點
  if (isLastCondition) {
    const firstNodeLineHeight = weakMap.get(item.children[0]).height - MARGIN;
    const lastNodeHeight = ITEM_HEIGHT;
    lineHeight = height - firstNodeLineHeight / 2 - lastNodeHeight / 2;
  } else {
    const firstNodeLineHeight = firstNodeIsCondition
      ? weakMap.get(item.children[0]).lineHeight / 2 + ITEM_HEIGHT / 2
      : ITEM_HEIGHT / 2 + MARGIN;
    lineHeight =
      firstNodeLineHeight +
      item.children
        ?.slice(1)
        .reduce(
          (prev, curr) => prev + weakMap.get(curr).height,
          ITEM_HEIGHT / 2,
        );
  }
  weakMap.set(item, { height, lineHeight });
};

02 檢視時高度計算

● 計算每個節點的高度

節點高度,等於每一個節點的高度之和。

const calculateTreeItemHeight = (item: IFilterValue<T>, disabled: boolean) => {
  if (!item?.children)
    return weakMap.set(item, {
      height: ITEM_HEIGHT + MARGIN,
      lineHeight: ITEM_HEIGHT,
    });
  item.children.map((child) => calculateTreeItemHeight(child, disabled));
  const height = item.children.reduce(
    (prev, curr) => prev + weakMap.get(curr).height,
    0,
  );
  weakMap.set(item, { height });
};

具體的高度圖如下圖所示:

file

● 計算每個節點的連線高度

連線高度為:firstNodeLineHeight + 中間節點高度 + lastNodeLineHeight

· 如果是最後一個條件節點

lineHeight(紅色)= 塊級高度(藍色)- MARGIN - ITEM_HEIGHT/2 - ITEM_HEIGHT/2(紫色)

file

· 如果不是最後一個條件節點,需要根據其子節點再做計算

file

對於上述這種情況,我們需要遞迴計算當前條件節點的第一個節點應該減去的高度和最後節點應該減去的高度(藍色部分)。

const firstNodeLineHeight = firstNode.height - getNodeReduceHeight(item, true);
const lastNodeLineHeight =
  lastNode.height - MARGIN - getNodeReduceHeight(item, false);

// 如果是普通節點,返回值為 ITEM_HEIGHT / 2
// 如果是條件節點,返回值 currentNode.lineHeight /2 + getNodeReduceHeight(currentNode, isFirst)。需要遞迴遍歷對應的節點算出總共要減去的高度

const getNodeReduceHeight = (item: IFilterValue<T>, isFirst) => {
  const currentNode = isFirst
    ? item?.children?.[0]
    : item?.children?.[item?.children?.length - 1];
  if (!currentNode) return ITEM_HEIGHT / 2;
  const currentNodeIsCondition = isCondition(currentNode);
  if (currentNodeIsCondition) {
    return (
      currentNode.lineHeight / 2 + getNodeReduceHeight(currentNode, isFirst)
    );
  }
  return ITEM_HEIGHT / 2;
};

03 新增新內容

file

· 最外層的新增(紅色)

直接操作當前層級(最外層)的 children,新增一組 INIT_ROW_VALUES

· 巢狀層的最下新增按鈕(黃色)

獲取到當前層的 children,新增一組 INIT_ROW_VALUES

· 巢狀層的每一行新增按鈕(紫色)

會新增一個巢狀關係

// 根據點選的按鈕,來獲取相關的 Node,對於紅色/黃色按鈕來說獲取當前層級 Node
const finRelationNode = (
  parentData: IFilterValue<T>,
  targetKey: string,
  needCurrent?: boolean,
) => {
  const parentDataTemp = parentData;
  if (parentDataTemp.key === targetKey) return parentDataTemp;
  if (!parentDataTemp.children?.length) return null;
  for (let i = 0; i < parentDataTemp.children.length; i++) {
    const current = parentDataTemp.children[i];
    if (current.key === targetKey)
      return needCurrent ? current : parentDataTemp;
    const node: IFilterValue<T> | null | undefined = finRelationNode(
      current,
      targetKey,
      needCurrent,
    );
    if (node) return node;
  }
};

const handleAddCondition = (keyObj: { key: string; isOut?: boolean }) => {
  const cloneData = clone(value);
  const appendNode = finRelationNode(
    cloneData as IFilterValue<T>,
    keyObj.key,
    keyObj.isOut,
  );
  addCondition(appendNode, keyObj, initValues as T);
  onChange?.(cloneData);
};

const addCondition = (
  treeNode: any,
  keyObj: { key: string; isOut?: boolean },
  initRowValue: T,
) => {
  const key = keyObj.key;
  if (keyObj.isOut)
    return treeNode.children.push(
      Object.assign(
        {},
        { rowValues: initRowValue },
        { key: shortId(), level: treeNode.level },
      ),
    );
  const children = treeNode?.children;
  if (!children) {
    const newNode = {
      key: treeNode.key,
      level: treeNode.level + 1,
      type: ROW_PERMISSION_RELATION.AND,
      children: [
        {
          rowValues: treeNode.rowValues,
          key: shortId(),
          level: treeNode?.level + 1,
        },
        { rowValues: initRowValue, key: shortId(), level: treeNode?.level + 1 },
      ],
    };
    delete treeNode.rowValues;
    Object.assign(treeNode, newNode);
    return;
  }
  for (let i = 0; i < children.length; i += 1) {
    if (children[i].key !== key) continue;
    if (treeNode?.level <= maxLevel) {
      children[i] = {
        key: children[i].key,
        type: ROW_PERMISSION_RELATION.AND,
        level: treeNode?.level + 1,
        children: [
          Object.assign({}, children[i], {
            key: shortId(),
            level: treeNode?.level + 1,
          }),
          Object.assign({
            key: shortId(),
            rowValues: initRowValue,
            level: treeNode?.level + 1,
          }),
        ],
      };
    }
  }
};

04 點選刪除內容

file

· 點選紫色按鈕,第二個條件節點只剩一個 children,需要刪除第二個條件節點,且重新計算每一行的層級

· 點選黃色按鈕,當前條件節點的 children 刪除一行資料

const deleteCondition = (parentData: IFilterValue<T>, key: string) => {
  let parentDataTemp = parentData;
  parentDataTemp.children = parentDataTemp?.children?.filter(
    (item) => item.key !== key,
  );
  if (parentDataTemp?.children?.length === 1) {
    const newChild = updateLevel(parentDataTemp.children[0]);
    const key = parentDataTemp.key;
    delete parentDataTemp.children;
    delete parentDataTemp.type;
    parentDataTemp = Object.assign(parentDataTemp, {
      ...newChild,
      key,
      level: newChild.level,
    });
  }
};

const updateLevel = (node: IFilterValue<T>) => {
  let newChildren;
  if (node.children)
    newChildren = node.children.map((element) => updateLevel(element));
  const newNode: IFilterValue<T> = {
    ...node,
    children: newChildren,
    level: (node?.level as number) - 1,
  };
  return newNode;
};

05 切換條件節點

獲取到當前層級的節點,改變對應的 type 值。

const handleChangeCondition = (key: string, type: ROW_PERMISSION_RELATION) => {
  const cloneData = clone(value);
  const changeNode = finRelationNode(cloneData, key, true);
  changeNode.type =
    type === ROW_PERMISSION_RELATION.AND
      ? ROW_PERMISSION_RELATION.OR
      : ROW_PERMISSION_RELATION.AND;
  onChange?.(cloneData);
};

06 改變元件資料

const handleChangeRowValues = (key: string, values: T) => {
  const cloneData = clone(value);
  const changeNode = finRelationNode(cloneData, key, true);
  changeNode.rowValues = {
    ...(changeNode.rowValues ?? {}),
    ...values,
  };
  onChange?.(cloneData);
};

至此,且或元件已經實現完成,FilterRules 主要是運算元據,RuleController 主要是條件/線條/元件的渲染,支援使用者自定義 component 傳入 FilterRules。

《行業指標體系白皮書》下載地址:https://www.dtstack.com/resources/1057?src=szsm

《數棧產品白皮書》下載地址:https://www.dtstack.com/resources/1004?src=szsm

《資料治理行業實踐白皮書》下載地址:https://www.dtstack.com/resources/1001?src=szsm

想了解或諮詢更多有關大資料產品、行業解決方案、客戶案例的朋友,瀏覽袋鼠雲官網:https://www.dtstack.com/?src=szbky

相關文章