• 【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 {
           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.已有标签的接口返回格式:

  • 相关阅读:
    [NOI2019] 回家路线
    [NOIP2016] 天天爱跑步
    [CF1187D] Subarray Sorting
    [THUPC2018] 弗雷兹的玩具商店
    [AGC006C] Rabbit Exercise
    [AGC005F] Many Easy Problems
    [51Nod2558] 选址
    [BZOJ3771] Triple
    [APIO2019] 奇怪装置
    [CTSC2018] 假面
  • 原文地址:https://www.cnblogs.com/nangras/p/15993720.html
Copyright © 2020-2023  润新知