预期效果:
功能描述:
1.初始化只展示一个按钮,通过按钮添加新的组,可删除,可清空
2.每个文案组都是独立的块,具体里面有几个文案,根据后端动态返回的内容进行渲染
3.可以选择已有标题列表中的标题,赋值到输入框中
4.内部有自己的校验,输入框赋值后也应触发校验,其中每个文案可能存在是否非必填、最大长度、最小长度的校验,以及文案格式的正则校验
实现思路:
1.组件参考antd表单文档中提供的【动态增减表单项】的代码进行实现(https://ant.design/components/form-cn/#components-form-demo-dynamic-form-item)
2.子组件设计为抽屉,由父组件的按钮触发
具体代码:
1.父组件代码:
import React, { useState } from 'react'; import { Button, Form, Input, Card, Row, Col, message } from 'antd'; import CopyTitle from './CopyTitle'; export interface Props { id?: string; value?: Record<string, any>[]; defaultValue?: Record<string, any>[]; onChange?: (values: Record<string, any>[]) => void; require?: boolean; placeholder?: string; // 输入提示 maxLength?: string; // columns?: API.FormListType[]; formRef?: any; } /** * 文案组组件 */ const CreativeCopywriting: React.FC<Props> = (props) => { const inputRef = React.useRef<any>(null); const { id, onChange, value, columns, formRef } = props; const [visible, setVisible] = useState<boolean>(false); const [titleMaxLength, setTitleMaxLength] = useState<number>(); const [titleMinLength, setTitleMinLength] = useState<number>(); const [copyName, setCopyName] = useState<number | string>(); const [copyId, setCopyId] = useState<string>(); // 选择已有标题-打开抽屉 const handleCopy = (formItem: API.FormListType, name: number | string, formItemId: string) => { setTitleMaxLength(formItem?.formItemProps?.maxLength); setTitleMinLength(formItem?.formItemProps?.minLength); setCopyName(name); setCopyId(formItemId); setVisible(true); }; // 确认选择标题 const onCopy = (title: string) => { const newValues = value?.map((item: any, index: number) => { if (index === copyName) { const valuesObj = { ...item }; valuesObj[`${copyId}`] = title; return valuesObj; } return item; }); formRef?.current?.setFieldsValue({ text_group: newValues }); formRef?.current?.validateFields(['text_group']); if (onChange) onChange(newValues || []); }; const handleClear = (name: number | string) => { const valuesObj = {}; columns?.forEach((item: API.FormListType) => { valuesObj[`${item.id}`] = ''; }); const newValues = value?.map((item: any, index: number) => { if (index === name) { return valuesObj; } return item; }); if (onChange) onChange(newValues || []); }; return ( <> <Form.List name={id || 'text_group'} rules={[ { validator: async () => { return Promise.resolve(); }, }, ]} > {(fields, { add, remove }) => ( <> <Button type="primary" onClick={() => { if (fields && fields.length < 5) { add(); } else { message.error('文案组最多5条'); } }} > 添加文案组 </Button> {fields.map(({ key, name, ...restField }) => ( <Card bodyStyle={{ padding: '8px' }} style={{ margin: '8px 0 0' }} key={`${id}_${key}_${name}`} > <div style={{ margin: '0 0 8px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', }} > <a onClick={() => remove(name)} style={{ display: 'inline-block', marginRight: '16px' }} key={`${id}_${key}_${name}_delete`} > 删除 </a> <a onClick={() => handleClear(name)} key={`${id}_${key}_${name}_clear`}> 清空 </a> </div> {columns && columns.length && columns.map((item: API.FormListType, index: number) => { return ( <Row key={`${id}_${key}_${name}_${index}_Row`}> <Col span={4} style={{ height: '32px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', paddingRight: '8px', }} > {item.label} </Col> <Col span={14}> <Form.Item {...restField} key={`${id}_${key}_${name}_${index}_${item.id}`} name={[name, `${item.id}`]} validateTrigger={['onChange', 'onBlur', 'onInput']} rules={[ { validator: (_, values) => { const { pattern } = item?.fieldProps?.rules[0]; if (item.required && !values) { return Promise.reject(new Error(`请输入${item.label}`)); } if (pattern) { const newReg = new RegExp(pattern); if (values && !newReg.test(values)) { return Promise.reject( new Error(item?.fieldProps?.rules[0].message), ); } } if ( values && values.length && item?.formItemProps?.minLength && values.length < item?.formItemProps?.minLength ) { return Promise.reject( new Error(`长度不能少于${item?.formItemProps?.minLength}个字`), ); } if ( values && values.length && item?.formItemProps?.maxLength && values.length > item?.formItemProps?.maxLength ) { return Promise.reject( new Error(`长度不能超过${item?.formItemProps?.maxLength}个字`), ); } return Promise.resolve(); }, }, ]} > <Input placeholder="请输入" ref={inputRef} id={`${name}${item.id}`} /> </Form.Item> </Col> <Col span={4}> <Button style={{ marginLeft: '16px' }} type="default" onClick={() => {if (item.id) handleCopy(item, name, item.id);}} key={`${id}_${key}_${name}_${index}_${item.id}_copy`} > 选择已有标题 </Button> </Col> </Row> ); })} </Card> ))} </> )} </Form.List> <CopyTitle key={`copyDrawer`} visible={visible} onSubmit={onCopy} onClose={() => { setVisible(false)}} maxLength={titleMaxLength} minLength={titleMinLength} /> </> ); }; export default CreativeCopywriting;
2.父组件样式代码:
.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; } } }
3.子组件代码顺手也贴一下:
import React, { useEffect, useState } from 'react'; import { Drawer, Button, message, Space, Spin } from 'antd'; import { useRequest } from 'umi'; import type { ProColumns } from '@ant-design/pro-table'; import type { ParamsType } from '@ant-design/pro-provider'; import TableList from '@/components/TableList'; import type { PaginationProps } from 'antd'; import { wxTitleInit, wxTitleList } from '../services'; export interface Props { visible: boolean; onSubmit?: (values: string) => void; onClose?: () => void; maxLength?: number; minLength?: number; } const CopyTitle: React.FC<Props> = (props) => { const { visible, onSubmit, onClose, maxLength, minLength = 1 } = props; const [searchData, setSearchData] = useState<API.FormListType[]>([]); const [tableData, setTableData] = useState<any[]>([]); const [tableColumns, setTableColumns] = useState<ProColumns[]>([]); const [tablePage, setTablePage] = useState<PaginationProps>({}); const [tableParams, setTableParams] = useState<ParamsType>({}); const [selectedList, setSelectedList] = useState<any[]>([]); // 已选择 // 获取页面初使化数据 const { loading: pageLoading, run: init } = useRequest(() => wxTitleInit(), { manual: true, onSuccess: (result) => { // 初使化数据赋值 const { searchList = [], pageDefault = {} } = result || {}; setSearchData(searchList); // 初使化完成后获取列表数据 if (pageDefault) setTableParams(pageDefault); }, }); const { loading: tableLoading, run: runTable } = useRequest( () => wxTitleList({ ...tableParams, minLength, maxLength, channelId: ['default', 'weixin'] }), { manual: true, onSuccess: (result) => { if (result) { setTableColumns([]); setTablePage({}); const { tableHeaderList = [], tableList = [], page } = result; setTableData(tableList); setTableColumns([ ...tableHeaderList.map((el) => { if (el.dataIndex === 'title') { return { ...el, 200 }; } if (el.dataIndex === 'game') { return { ...el, 100 }; } if (el.dataIndex === 'channel') { return { ...el, 50 }; } if (el.dataIndex === 'update_time') { return { ...el, 100 }; } return el; }), ]); if (page) setTablePage(page); } }, }, ); useEffect(() => { if (visible && tableParams) { setSelectedList([]); runTable(); } }, [tableParams, visible]); // 根据渠道获取页面初使化数据 useEffect(() => { setTableData([]); init(); }, []); return ( <Drawer width={800} visible={visible} title={`选择已有标题`} destroyOnClose footer={ <div style={{ display: 'flex', justifyContent: 'flex-end' }}> <Space> <Button onClick={() => { if (onClose) onClose(); setSelectedList([]); }} > 取 消 </Button> <Button type="primary" onClick={() => { if (selectedList.length === 0) { message.error(`至少选择一条标题`); } else { if (onSubmit) onSubmit(selectedList[0].title || ''); if (onClose) onClose(); } }} > 确 定 </Button> </Space> </div> } onClose={() => { if (onClose) onClose(); setSelectedList([]); }} > <Spin spinning={pageLoading}> <TableList loading={tableLoading} columns={tableColumns} dataSource={tableData} pagination={tablePage} search={searchData} tableAlertRender={false} toolBarRender={false} rowSelection={{ alwaysShowAlert: false, type: 'radio', onChange: (selectedRowKeys: React.Key[], selectedRows: any[]) => { setSelectedList(selectedRows); }, }} onChange={(params) => setTableParams(params)} /> </Spin> </Drawer> ); }; export default CopyTitle;
4.顺便附上后端接口返回格式:
{ "id": "text_group", "label": "文案组", "type": "textGroup", "required": true, "fieldProps": { "columns": [ { "id": "title", "label": "标题", "type": "text", "required": true, "formItemProps": { "minLength": 1, "maxLength": 12 }, "fieldProps": { "rules": [ { "pattern": "^[^\\<\\>\\&'\\\"\\/\\x08\\x09\\x0A\\x0D\\\\]+$", "message": "请输入正确标题" } ] } }, { "id": "description", "label": "首行文案", "type": "text", "required": true, "formItemProps": { "minLength": 1, "maxLength": 16 }, "fieldProps": { "rules": [ { "pattern": "^[^\\<\\>\\&'\\\"\\/\\x08\\x09\\x0A\\x0D\\\\]+$", "message": "请输入正确首行文案" } ] } }, { "id": "caption", "label": "次行文案", "type": "text", "required": true, "formItemProps": { "minLength": 1, "maxLength": 16 }, "fieldProps": { "rules": [ { "pattern": "^[^\\<\\>\\&'\\\"\\/\\x08\\x09\\x0A\\x0D\\\\]+$", "message": "请输入正确次行文案" } ] } }, { "id": "left_bottom_txt", "label": "第三行文案", "type": "text", "required": false, "formItemProps": { "minLength": 1, "maxLength": 16 }, "fieldProps": { "rules": [ { "pattern": "^[^\\<\\>\\&'\\\"\\/\\x08\\x09\\x0A\\x0D\\\\]+$", "message": "请输入正确第三行文案" } ] } } ] } }