預期效果:

功能描述:
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": "請輸入正確第三行文案"
}
]
}
}
]
}
}
