预期内容:
需求描述:(一期)
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.已有标签的接口返回格式: