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


浙公网安备 33010602011771号