【React+antd】做一个动态增减文案组的组件
预期效果:

功能描述:
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 {
width: 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, width: 200 };
}
if (el.dataIndex === 'game') {
return { ...el, width: 100 };
}
if (el.dataIndex === 'channel') {
return { ...el, width: 50 };
}
if (el.dataIndex === 'update_time') {
return { ...el, width: 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": "请输入正确第三行文案"
}
]
}
}
]
}
}

浙公网安备 33010602011771号