【React+antd】做一个可自定义、可选择已有标签的标签组件(弹窗)

预期内容:

 

 

 

 

 

需求描述:(一期)

1.无数据时:点击按钮打开弹窗,展示【自定义模块】与【选择已有标签模块】,其中自定义模块可以通过输入+回车进行添加,限制条数与总字数并在下方体现,点击确定更新到外层。

2.已选数据需编辑时:点击修改打开弹窗,正确赋值并可以删改数据。

3.后端要求的格式为对象:

value = {
      LABEL_TYPE_COMMON: [], // 文字标签
      LABEL_TYPE_CUSTOMIZETEXT: [],// 自定义的文字标签
      LABEL_TYPE_ICON: [],// 一期不做的图片标签
}

 

 

实现思路:

 

1.父组件由两块内容组成:【无数据时的按钮 | 有数据时的列表+修改按钮】+ 弹窗

2.弹窗中为子组件,使用Tab组件展示最外层的标签类型(一期只实现文字标签)

3.文字标签的tab中包括:①自定义模块与已有标签选择模块,统一以【label:value】格式展示,保证布局整洁直观;②已选文字标签列表,展示已选/可选,并提供未选或超出标签个数限制时标红的警示,点击确定时判断标签个数限制与标签总字数是否满足限制条件

 

具体代码:

1.父组件代码:

/* eslint-disable @typescript-eslint/dot-notation */
import React, { useEffect, useState } from 'react';
import { Modal, Button, Tabs, message, Spin } from 'antd';
import ResultTags from './ResultTags';
import TextTagsForm from './TextTagsForm';
import { wxTagsList } from '../services';
import type { BackDataProps } from '../services';

export interface Props {
  value?: BackDataProps;
  defaultValue?: BackDataProps;
  onChange?: (values: BackDataProps) => void;
  require?: boolean;
  placeholder?: string; // 输入提示
  maxLength?: string; // 文字标签总字数
  minTagCount?: number | undefined; // 标签最少个数
  maxTagCount?: number | undefined; // 标签最大个数
  columns?: (API.FormListType & { fieldProps: { maxLength?: number; minLength?: number } })[];
}

export interface DataProps {
  self_tags?: string[];
}

export interface ResultDataProps {
  type?: number | string;
  value?: any[];
  categoryName?: string;
}

const { TabPane } = Tabs;
/**
 * 标签选择组件
 */
const CreateTags: React.FC<Props> = (props) => {
  const {
    onChange,
    defaultValue,
    value = {
      LABEL_TYPE_COMMON: [],
      LABEL_TYPE_CUSTOMIZETEXT: [],
      LABEL_TYPE_ICON: [],
    },
    require = true,
    minTagCount = 1,
    maxTagCount = 3,
    maxLength = 16,
    columns,
  } = props;
  const [loading, setLoading] = useState<boolean>(false);
  const [confirmLoading, setConfirmLoading] = useState<boolean>(false);
  const [textType, setTextType] = useState<number>(1);
  const [option, setOption] = useState<any[]>([]);
  const [data, setData] = useState<DataProps>(); // data的数据格式为表单格式,key&value对象
  const [totalData, setTotalData] = useState<ResultDataProps[]>([]); // 渲染/提交数据用的数组
  const [resultList, setResultList] = useState<string[]>([]); // 最下方已选择的数据展示
  const [showList, setShowList] = useState<string[]>([]); // 标签表单项已选择的数据展示
  const [visible, setVisible] = useState<boolean>(false);
  const [tagMaxLength, setTagMaxLength] = useState<number>();
  const [tagMinLength, setTagMinLength] = useState<number>();

  // 获得两个数组的相同元素,返回数组
  const getSame = (arrFirst: string[], arrSecond: string[]) => {
    const newArr: string[] = [];
    (arrSecond || []).forEach((itemSecond: string) => {
      (arrFirst || []).forEach((itemFirst: string) => {
        if (itemSecond === itemFirst) newArr.push(itemFirst);
      });
    });
    return newArr;
  };

  // 初使化获取腾讯返回的标签数据
  const getOptions = () => {
    setLoading(true);
    if (!wxTagsList) return;
    (async () => {
      const { result } = await wxTagsList({});
      if (result) {
        setOption(result);
        if (defaultValue || value) {
          const initDataValue = defaultValue || value;
          // 初始化数据格式
          const initValue: any = {};
          result.forEach((item: any) => {
            // 目前只有文字标签
            if (item.name === '文字标签') {
              // 如果是文字标签,则存储文字标签的type
              setTextType(item.type);
              item.list?.forEach((itemListValue: any, index: number) => {
                initValue[`label_category_${item.type}_${index}`] = getSame(
                  itemListValue.list,
                  initDataValue.LABEL_TYPE_COMMON,
                );
              });
              initValue.self_tags = initDataValue.LABEL_TYPE_CUSTOMIZETEXT;
              setData(initValue);
              setShowList([
                ...initDataValue.LABEL_TYPE_COMMON,
                ...initDataValue.LABEL_TYPE_CUSTOMIZETEXT,
              ]);
              const min = columns && columns.length && columns[0].required ? 2 : 1;
              setTagMaxLength(columns && columns.length ? columns[0].fieldProps?.maxLength : 15);
              setTagMinLength(columns && columns.length ? columns[0].fieldProps?.minLength : min);
            }
          });
        }
      }
      setLoading(false);
    })();
  };

  // 标签表单值改变
  const handleFormChange = (changedValues: any, values: any) => {
    const newData = { ...data, ...values };
    setData(newData);
  };

  // 自定义文字标签-回车添加
  const handlePressEnter = (inputValue: string) => {
    if (inputValue) {
      const newData = {
        ...data,
        self_tags: data && data.self_tags ? [...data.self_tags, inputValue] : [inputValue],
      };
      setData(newData);
    }
  };

  // 删除标签
  const deleteTag = (type: string | number, label: string, isEdit?: string) => {
    // 文字标签 data的数据处理
    if (type === textType) {
      const deleteList: ResultDataProps[] = totalData.filter((item: ResultDataProps) =>
        item.value?.includes(label),
      );
      if (deleteList && deleteList.length) {
        const dataObj: DataProps = { ...data };
        const deleteObj = deleteList[0];
        dataObj[`${deleteObj.categoryName}`] = dataObj[`${deleteObj.categoryName}`].filter(
          (item: string) => item !== label,
        );
        setData(dataObj);
        if (onChange && isEdit === 'edit') {
          const typeList = Object.keys(dataObj);
          const backData: BackDataProps = {
            LABEL_TYPE_COMMON: [],
            LABEL_TYPE_CUSTOMIZETEXT: [],
            LABEL_TYPE_ICON: [],
          };
          typeList.forEach((itemKeys: string) => {
            if (itemKeys !== 'self_tags') {
              backData.LABEL_TYPE_COMMON.push(...(dataObj[itemKeys] || []));
            } else {
              backData.LABEL_TYPE_CUSTOMIZETEXT.push(...(dataObj[itemKeys] || []));
            }
          });
          const keysList = Object.keys(backData);
          const resultStringList: string[] = [];
          keysList.forEach((item: string) => {
            resultStringList.push(...(backData[item] || []));
          });
          setShowList(resultStringList);
          onChange(backData);
        }
      }
    }
  };

  // 确定按钮
  const handleOk = () => {
    setConfirmLoading(true);
    let textLength = 0;
    resultList?.forEach((item: any) => {
      textLength += item.length;
    });
    // 必填校验
    if (require && textLength < (minTagCount || 1)) {
      setConfirmLoading(false);
      message.error('请选择标签');
      return;
    }
    // 最大个数校验
    if (resultList.length > maxTagCount) {
      setConfirmLoading(false);
      message.error(`最多可选 ${maxTagCount} 个标签,且标签总字数之和不超过 ${maxLength} 个字`);
      return;
    }
    // 字数校验
    if (textLength > maxLength) {
      setConfirmLoading(false);
      message.error(`最多可选 ${maxTagCount} 个标签,且标签总字数之和不超过 ${maxLength} 个字`);
      return;
    }
    // 处理前端数据为给后端的数据格式
    if (data) {
      const typeList = Object.keys(data);
      const resultStringList: string[] = [];
      typeList.forEach((item: string) => {
        resultStringList.push(...(data[item] || []));
      });
      // 弹窗下方已选择数据
      setResultList(resultStringList);
      // 给后端的数据
      const backData: BackDataProps = {
        LABEL_TYPE_COMMON: [],
        LABEL_TYPE_CUSTOMIZETEXT: [],
        LABEL_TYPE_ICON: [],
      };
      typeList.forEach((itemKeys: string) => {
        if (itemKeys !== 'self_tags') {
          backData.LABEL_TYPE_COMMON.push(...(data[itemKeys] || []));
        } else {
          backData.LABEL_TYPE_CUSTOMIZETEXT.push(...(data[itemKeys] || []));
        }
      });
      if (onChange) onChange(backData);
      setShowList(resultList);
      setConfirmLoading(false);
      setVisible(false);
    }
  };

  const handleCancel = () => {
    setVisible(false);
  };

  useEffect(() => {
    if (data) {
      // 根据data转化totalData与resultList(弹窗下方已选择文字标签)
      const keysList = Object.keys(data);
      const totalDataTransform: ResultDataProps[] = keysList.map((itemKeys: string) => {
        if (itemKeys !== 'self_tags') {
          return { type: textType, value: data[itemKeys], categoryName: itemKeys };
        }
        return { type: textType, value: data[itemKeys], categoryName: 'self_tags' };
      });
      setTotalData(totalDataTransform);
      const resultStringList: string[] = [];
      keysList.forEach((item: string) => {
        resultStringList.push(...(data[item] || []));
      });
      setResultList(resultStringList);
    }
  }, [data]);

  useEffect(() => {
    if (visible) {
      getOptions();
      setVisible(true);
    }
  }, [visible]);

  useEffect(() => {
    getOptions();
  }, []);

  return (
    <>
      {/* 因为目前只有文字标签,所以只展示已选的文字标签 */}
      {showList && showList.length ? (
        <ResultTags
          list={showList}
          type={textType || 1}
          onDelete={(type: number | string, element: string) => {
            deleteTag(type || textType, element, 'edit');
          }}
          onEdit={() => {
            setVisible(true);
          }}
        />
      ) : (
        <Button onClick={() => setVisible(true)}>+ 选择标签</Button>
      )}
      <Modal
        width={840}
        centered
        title="选择标签"
        visible={visible}
        destroyOnClose
        footer={
          <div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
            <span>
              <Button type="default" onClick={handleCancel}>
                取消
              </Button>
              <Button onClick={handleOk} type="primary" loading={confirmLoading}>
                确定
              </Button>
            </span>
          </div>
        }
        onCancel={handleCancel}
        bodyStyle={{ height: '700px', overflowY: 'hidden' }}
      >
        <Spin spinning={loading}>
          <Tabs type="card">
            {option &&
              option.length &&
              option.map((item: any) => {
                return (
                  <TabPane tab={item.name} key={item.type}>
                    {item.type === textType && (
                      <TextTagsForm
                        list={item.list}
                        onDelete={deleteTag}
                        type={item.type}
                        resultList={resultList}
                        totalData={totalData}
                        title={item.name}
                        maxTagCount={maxTagCount}
                        onValuesChange={handleFormChange}
                        data={data}
                        handlePressEnter={handlePressEnter}
                        min={
                          tagMinLength || (columns && columns.length && columns[0].required ? 2 : 1)
                        }
                        max={tagMaxLength || 15}
                        require={require}
                      />
                    )}
                  </TabPane>
                );
              })}
          </Tabs>
        </Spin>
      </Modal>
    </>
  );
};

export default CreateTags;

  

2.文字标签组件代码:

import React, { useState, useEffect, useRef } from 'react';
import { Form, Input, Divider, Row, Col, message } from 'antd';
import MultipleTag from '@/components/MultipleTag';
import ResultTags from './ResultTags';
import { trimAllBlank } from '@/utils/tools';
import type { DataProps, ResultDataProps } from './index';
import styles from './TextTagsForm.less';

export interface OptionProps {
  label_category?: string;
  list?: string[];
}

export interface Props {
  type?: number | string;
  list?: OptionProps[];
  data?: DataProps;
  title?: string;
  resultList?: string[];
  totalData?: ResultDataProps[];
  maxTagCount?: number;
  onDelete?: (type: number | string, element: string) => void;
  onValuesChange?: (changedValues: any, values: any) => void;
  handlePressEnter?: (values: string) => void;
  min: number;
  max: number;
  require: boolean;
}

/**
 * 文字标签模块
 */
const TextTagsForm: React.FC<Props> = (props) => {
  const {
    list,
    onDelete,
    type = 1,
    resultList,
    title,
    maxTagCount = 3,
    onValuesChange,
    data,
    handlePressEnter,
    min = 1,
    max,
    require = true,
  } = props;

  const formRef = useRef<any>(null);
  const [inputValue, setInputValue] = useState<string>('');
  const [option, setOption] = useState<OptionProps[]>([]);
  const formItemLayout = { labelCol: { span: 3 }, wrapperCol: { span: 21 } };

  const inputChange = (e: any) => {
    setInputValue(trimAllBlank(e.target.value));
  };
  const onPressEnter = () => {
    if (inputValue.length > max || inputValue.length < min) {
      message.error(`单标签仅支持 ${min}-${max} 字`);
      return;
    }
    if (handlePressEnter) handlePressEnter(inputValue);
    setInputValue('');
  };

  useEffect(() => {
    if (list) {
      const newList = list.map((item: OptionProps, index: number) => {
        return { ...item, id: index };
      });
      setOption(newList);
    }
  }, [list]);

  useEffect(() => {
    if (data) {
      formRef?.current.setFieldsValue(data);
    }
  }, [data]);

  return (
    <div>
      <>
        <Row style={{ marginBottom: '30px' }}>
          <Col span={3}>自定义: </Col>
          <Col span={21}>
            <Input
              placeholder={`请输入自定义标签文案,按回车键生成标签,单标签 ${min}-${max} 字`}
              onPressEnter={onPressEnter}
              onChange={inputChange}
              value={inputValue}
              maxLength={max}
              key={'self_tags'}
            />
          </Col>
        </Row>
        <Form
          ref={formRef}
          onValuesChange={(changedValues: any, values: any) => {
            if (onValuesChange) onValuesChange(changedValues, values);
          }}
          {...formItemLayout}
          labelAlign="left"
          className={styles['c-base-tag-form']}
          size="small"
        >
          {option &&
            option.length &&
            option.map((itemChild: any) => {
              return (
                <Form.Item
                  label={itemChild.label_category}
                  name={`label_category_${type}_${itemChild.id}`}
                  key={itemChild.label_category}
                  initialValue={data && data[`label_category_${type}_${itemChild.id}`]}
                >
                  <MultipleTag list={itemChild.list} />
                </Form.Item>
              );
            })}
        </Form>
      </>
      <Divider />
      <div className={styles['c-base-tag-result']}>
        <p>
          {title}:{' '}
          <span
            style={{
              color:
                (resultList?.length || 0) > maxTagCount ||
                (require && (!resultList || (resultList && resultList.length === 0)))
                  ? '#ff4d4f'
                  : 'rgba(0, 0, 0, 0.85)',
            }}
          >
            {resultList?.length || 0}
          </span>
          /{maxTagCount}
        </p>
        <ResultTags list={resultList} type={type} onDelete={onDelete} />
      </div>
    </div>
  );
};

export default TextTagsForm;

  

3.文字标签组件样式:

.c-base-tag {
  &-form {
    height: 440px;
    overflow-y: scroll;
  }
  &-result {
    height: 72px;
    overflow-y: scroll;
  }

  &-form,
  &-result {
    &::-webkit-scrollbar {
      width: 8px;
    }

    &::-webkit-scrollbar-thumb {
      background: #cfd1d5;
      border-radius: 10px;
    }

    &::-webkit-scrollbar-track-piece {
      background: transparent;
    }
  }
}

  

4.文字标签选择结果组件代码:

import { Tag, message } from 'antd';

export interface Props {
  type?: number | string;
  list?: string[];
  onDelete?: (type: number | string, element: string) => void;
  onEdit?: () => void;
}

/**
 * 已选标签结果
 */
const ResultTags: React.FC<Props> = (props) => {
  const { list, onDelete, type = 1, onEdit } = props;

  return (
    <div>
      {(list || []).map((element: string) => {
        return (
          <Tag
            closable
            onClose={(e: any) => {
              e.preventDefault();
              if (list && list.length === 1) {
                message.error('至少选择一项');
                return;
              }
              if (onDelete) onDelete(type, element);
            }}
            key={element}
            style={{ marginBottom: '8px' }}
          >
            {element}
          </Tag>
        );
      })}
      {onEdit && <a onClick={onEdit}>修改</a>}
    </div>
  );
};

export default ResultTags;

  

5.附上组件初始化接口返回数据:

{
    "id": "label",
    "label": "标签",
    "type": "createTags",
    "value": null,
    "required": true,
    "fieldProps": {
        "maxTagCount": 3,
        "minTagCount": 1,
        "columns": [
            {
                "id": "content",
                "label": "标签内容",
                "type": "text",
                "value": null,
                "required": true,
                "fieldProps": {
                    "minLength": 2,
                    "maxLength": 15
                },
                "formItemProps": {}
            }
        ],
        "maxLength": 15
    },
    "formItemProps": {}
}

  

6.已有标签的接口返回格式:

 

posted @ 2022-03-11 14:55  芝麻小仙女  阅读(1517)  评论(0编辑  收藏  举报