預期內容:
需求描述:(一期)
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.已有標簽的接口返回格式: