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