自定义ModalForm

作者:陈佳鑫
时间:2022-10-13

简单化普通表单模块新增及修改,减少编写 modal 的代码特此组件化

From 组件类型创建

新建文件modalFormItem.ts

import { Rule } from 'antd/lib/form'

export default interface ModalFormItem {
  title: string
  type:
    | 'input'
    | 'password'
    | 'inputNumber'
    | 'textArea'
    | 'select'
    | 'switch'
    | 'radio'
    | 'upload'
    | 'datePicker'
    | 'treeSelect'
    | 'render'
  name: string
  selectArr?: any[]
  disabled?: boolean
  uploadNumber?: number
  render?: () => React.ReactNode | React.ReactNode[]
  onChange?: (e: string | number) => void
}
属性类型说明
titlestring表单标题
typeinput password inputNumber textArea select switch radio upload datePicker treeSelect render渲染组件类型
namestringForm 表单 name 属性
rulesArray<Rule>Form 表单验证规则
selectArrArray<any>select cascader switch radio treeSelect 等组件 Option 数据
disableddisabled禁用当前组件
uploadNumbernumberupload 组件上传最大值
renderReact.ReactNode React.ReactNode[]render 函数可重写当前组件
onChange(e: string number) => void改变函数

创建组件文件OperateModal.tsx

创建 OperateModal 实例

import { ModalProps } from 'antd/lib/modal'
import { FormInstance } from 'antd/lib/form'

interface IOperateModal extends ModalProps {
  data?: any | null
  type?: 'add' | 'update'
  form?: [FormInstance]
  typeSetting?: 'one' | 'two'
  onSubmit: Function
  formItem: Array<ModalFormItem>
  modalFooter?: (ok: () => void, cancel: () => void) => ReactNode
  modalOkCall?: () => void
  modalCancelCall?: () => void
}
属性类型说明
dataany null Form数据
typeadd updateadd: 新增 update: 修改
formFormInstanceForm 对象
typeSettingone two每行显示组件个数
onSubmitFunctionForm 提交函数
formItemArray<ModalFormItem>Form 渲染对象
modalFooter(ok: () => void, cancel: () => void) => ReactNode自定义底部按钮
modalOkCall() => void自定义按钮 ok 事件,如果未传入将调用 onSubmit 方法
modalCancelCall() => void自定义按钮取消事件,如果为传入将调用 onCancel 事件

创建 OperateModal 组件

export const OperateModal: React.FC<IOperateModal> = ({
  data,
  type,
  form,
  typeSetting,
  formItem,
  onSubmit,
  modalFooter,
  modalOkCall,
  modalCancelCall,
  modalFooter,
  modalOkCall,
  modalCancelCall,
  ...modalProps
}) => { })

Form 组件渲染函数

通过 formItem 遍历生成相对应组件,特殊组件加入相应业务需求

/**
 * 表单渲染
 * @param item 渲染条件
 */
const moduleRendering = (item: ModalFormItem) => {
  switch (item.type) {
    case 'input':
      return (
        <Input placeholder={`请输入${item.title}`} disabled={item.disabled} />
      )
    case 'password':
      return (
        <Input.Password
          placeholder={`请输入${item.title}`}
          disabled={item.disabled}
        />
      )
    case 'inputNumber':
      return (
        <InputNumber
          placeholder={`请输入${item.title}`}
          style={{ width: '100%' }}
          min={0}
          disabled={item.disabled}
        />
      )
    case 'textArea':
      return <Input.TextArea rows={2} disabled={item.disabled} />
    case 'select':
      return (
        <Select
          id={item.name}
          showSearch
          allowClear={!item.rules}
          optionFilterProp="children"
          placeholder={`请选择${item.title}`}
          disabled={item.disabled}
          onChange={item.onChange}
          filterOption={(input, option) =>
            option?.props.children.toLowerCase().indexOf(input.toLowerCase()) >=
            0
          }
        >
          {item.selectArr?.map((selectItem) => {
            return (
              <Select.Option
                key={selectItem.dictKey}
                value={selectItem.dictKey}
              >
                {selectItem.dictValue}
              </Select.Option>
            )
          })}
        </Select>
      )
    case 'treeSelect':
      return (
        <TreeSelect
          placeholder={`请选择${item.title}`}
          treeData={item.selectArr}
          disabled={item.disabled}
          showCheckedStrategy={TreeSelect.SHOW_ALL}
          treeCheckable
          treeCheckStrictly={true}
        />
      )
    case 'switch':
      return (
        <Switch
          checkedChildren={item.selectArr ? item.selectArr[0].checked : ''}
          unCheckedChildren={item.selectArr ? item.selectArr[0].unchecked : ''}
          disabled={item.disabled}
        />
      )
    case 'radio':
      return (
        <Radio.Group>
          {item.selectArr?.map((selectItem) => {
            return (
              <Radio key={selectItem.dictKey} value={selectItem.dictKey}>
                {selectItem.dictValue}
              </Radio>
            )
          })}
        </Radio.Group>
      )
    case 'upload':
      return (
        <Upload
          withCredentials
          disabled={item.disabled}
          action={`${baseUrl}/rest/file/upload`}
          listType="picture-card"
          accept=".jpg,.png,.jpeg,.bmp"
          beforeUpload={(file) => {
            if (file.size / 1024 / 1024 > 2) {
              message.error('请选择大小在2M以内图片!')
              return false
            }
          }}
          onPreview={handlePreview}
          onChange={(file) => handleChange(file, item.name)}
          data={{
            rdSession: user.rdSession,
            now: Date.now(),
            sign: createRdSession({ rdSession: user.rdSession }, user.loginCode)
          }}
        >
          {uploadBottom
            ? uploadBottom[item.name]
              ? uploadBut
              : null
            : uploadBut}
        </Upload>
      )
    case 'datePicker':
      return (
        <DatePicker
          format="YYYY-MM-DD"
          disabled={item.disabled}
          style={{ width: '100%' }}
        />
      )
    case 'render':
      return item.render!()
  }
}

return 函数

return (
  <Modal
    {...modalProps}
    afterClose={modalProps.afterClose || (() => {
      usForm.resetFields()
    })}
    destroyOnClose
    footer={
      modalFooter
        ? modalFooter(
            modalOkCall ?? onFormSubmit,
            modalCancelCall ?? modalProps.onCancel
          )
        : modalFooter
    }
    onOk={onFormSubmit}
  >
    <Form
      {...Layout()}
      ref={formRef}
      form={usForm}
      name="register"
      scrollToFirstError
    >
      <Row>
        {formItem.map((item, index) => {
          return (
            <Col
              key={index}
              style={
                item.type === 'upload' ? { position: 'relative' } : undefined
              }
              span={typeSetting === 'one' ? 24 : 12}
            >
              <Form.Item
                name={item.name}
                label={item.title}
                rules={item.rules}
                valuePropName={propName(item)}
                getValueFromEvent={
                  item.type === 'upload'
                    ? (args) => normFile(args, item.name)
                    : undefined
                }
              >
                {moduleRendering(item)}
              </Form.Item>
              {item.type === 'upload' && (
                <div
                  style={{
                    position: 'absolute',
                    top: 110,
                    width: '100%',
                    paddingLeft: 110
                  }}
                >
                  只允许上传图片后缀为.jpg、.png、jpeg、.bmp的图片文件,文件大小不能超过2M
                </div>
              )}
            </Col>
          )
        })}
      </Row>
    </Form>
    <Modal
      visible={previewVisible}
      title={previewTitle}
      footer={null}
      onCancel={handleCancel}
    >
      <img alt="example" style={{ width: '100%' }} src={previewImage} />
    </Modal>
  </Modal>
)

整体 OperateModal 代码

import { baseUrl } from '@/config/config'
import ModalFormItem from '@/utils/modalFormItem'
import createRdSession from '@/utils/rdSession'
import { PlusOutlined } from '@ant-design/icons'
import _ from 'lodash'
import {
  Col,
  DatePicker,
  Form,
  Input,
  InputNumber,
  Modal,
  Radio,
  Row,
  Select,
  Switch,
  Upload,
  TreeSelect,
  message
} from 'antd'
import { FormInstance } from 'antd/lib/form'
import { useForm } from 'antd/lib/form/Form'
import { ModalProps } from 'antd/lib/modal'
import { UploadChangeParam } from 'antd/lib/upload'
import { UploadFile } from 'antd/lib/upload/interface'
import React, {
  ReactNode,
  useCallback,
  useEffect,
  useRef,
  useState
} from 'react'

interface IOperateModal extends ModalProps {
  idKey?: string
  data?: any | null
  form?: [FormInstance]
  typeSetting?: 'one' | 'two'
  onSubmit: Function
  formItem: Array<ModalFormItem>
  modalFooter?: (ok: () => void, cancel: () => void) => ReactNode
  modalOkCall?: () => void
  modalCancelCall?: () => void
}

const formItemMinLayout = {
  labelCol: {
    xs: { span: 24 },
    sm: { span: 6 }
  },
  wrapperCol: {
    xs: { span: 24 },
    sm: { span: 18 }
  }
}

const formItemMaxLayout = {
  labelCol: {
    xs: { span: 24 },
    sm: { span: 4 }
  },
  wrapperCol: {
    xs: { span: 24 },
    sm: { span: 20 }
  }
}

export const OperateModal: React.FC<IOperateModal> = ({
  idKey,
  data,
  form,
  typeSetting,
  formItem,
  onSubmit,
  modalFooter,
  modalOkCall,
  modalCancelCall,
  ...modalProps
}) => {
  const [previewVisible, setpreviewVisible] = useState<boolean>(false)
  const [previewImage, setPreviewImage] = useState<string | undefined>(
    undefined
  )
  const [previewTitle, setpreviewTitle] = useState<string | undefined>(
    undefined
  )
  const [uploadBottom, setUploadBottom] = useState<{ [key: string]: boolean }>(
    {}
  )

  const [usForm] = form ?? useForm()

  const formRef = useRef<FormInstance<any>>(null)

  const jsonData = JSON.parse(sessionStorage.getItem('initial_state') ?? '{}')
  const user = jsonData.users || {}

  useEffect(
    () => {
      const bottom: { [key: string]: boolean } = {}
      formItem
        ?.filter((item) => item.type === 'upload')
        .map((item) => {
          bottom[item.name] = true
          if (item.uploadNumber) {
            if (usForm.getFieldValue(item.name)) {
              bottom[item.name] =
                usForm.getFieldValue(item.name).length < item.uploadNumber
            }
          }
        })
      setUploadBottom(bottom)
    },
    formItem
      .filter((item) => item.type === 'upload')
      .map((item) => {
        return usForm.getFieldValue(item.name)
      })
  )

  useEffect(() => {
    usForm.setFieldsValue(data)
  }, [data])

  /**
   * form表单提交
   */
  const onFormSubmit = () => {
    usForm.validateFields().then((values) => {
      let params: { [key: string]: any } = { ...values }
      if (idKey) {
        params[idKey] = data[idKey]
      } else {
        params = {
          ...data,
          ...params
        }
      }
      onSubmit(params)
    })
  }

  // 图片上传按钮
  const uploadBut = (
    <div>
      <PlusOutlined />
      <div className="ant-upload-text">上传</div>
    </div>
  )

  // 上传图片
  const handleChange = (
    file: UploadChangeParam<UploadFile<any>>,
    name: string
  ) => {
    const fileList = file.fileList?.map((fileItem: any, index: number) => {
      if (fileItem.response && fileItem.response.code === 200) {
        return {
          uid: index,
          name: fileItem.name,
          status: fileItem.status,
          url: fileItem.response.data
        }
      }
    })
    if (fileList.length && !fileList.includes(undefined)) {
      usForm.setFieldsValue({ ...usForm.getFieldsValue(), [name]: fileList })
    }
  }

  //

  // 查看图片
  const handlePreview = (file: any) => {
    setPreviewImage(file.thumbUrl || file.url)
    setpreviewTitle(file.name)
    setpreviewVisible(true)
  }

  // 关闭图片查看
  const handleCancel = () => {
    setpreviewVisible(false)
  }

  // 设置如何将 event 的值转换成字段值
  const normFile = (e: any, name: string) => {
    if (Array.isArray(e)) {
      return e
    }
    if (e.file) {
      const item = formItem.find((item) => item.name === name)
      let butVis = true
      if (item?.uploadNumber) {
        if (e.fileList) {
          butVis = e.fileList.length < item.uploadNumber
        }
        setUploadBottom({ ...uploadBottom, [name]: butVis })
      }
      return e.fileList
    }
    return e
  }

  /**
   * 表单渲染
   * @param item 渲染条件
   */
  const moduleRendering = (item: ModalFormItem) => {
    switch (item.type) {
      case 'input':
        return (
          <Input placeholder={`请输入${item.title}`} disabled={item.disabled} />
        )
      case 'password':
        return (
          <Input.Password
            placeholder={`请输入${item.title}`}
            disabled={item.disabled}
          />
        )
      case 'inputNumber':
        return (
          <InputNumber
            placeholder={`请输入${item.title}`}
            style={{ width: '100%' }}
            min={0}
            disabled={item.disabled}
          />
        )
      case 'textArea':
        return <Input.TextArea rows={2} disabled={item.disabled} />
      case 'select':
        return (
          <Select
            id={item.name}
            showSearch
            allowClear={!item.rules}
            optionFilterProp="children"
            placeholder={`请选择${item.title}`}
            disabled={item.disabled}
            onChange={item.onChange}
            filterOption={(input, option) =>
              option?.props.children
                .toLowerCase()
                .indexOf(input.toLowerCase()) >= 0
            }
          >
            {item.selectArr?.map((selectItem) => {
              return (
                <Select.Option
                  key={selectItem.dictKey}
                  value={selectItem.dictKey}
                >
                  {selectItem.dictValue}
                </Select.Option>
              )
            })}
          </Select>
        )
      case 'treeSelect':
        return (
          <TreeSelect
            placeholder={`请选择${item.title}`}
            treeData={item.selectArr}
            disabled={item.disabled}
            showCheckedStrategy={TreeSelect.SHOW_ALL}
            treeCheckable
            treeCheckStrictly={true}
          />
        )
      case 'switch':
        return (
          <Switch
            checkedChildren={item.selectArr ? item.selectArr[0].checked : ''}
            unCheckedChildren={
              item.selectArr ? item.selectArr[0].unchecked : ''
            }
            disabled={item.disabled}
          />
        )
      case 'radio':
        return (
          <Radio.Group>
            {item.selectArr?.map((selectItem) => {
              return (
                <Radio key={selectItem.dictKey} value={selectItem.dictKey}>
                  {selectItem.dictValue}
                </Radio>
              )
            })}
          </Radio.Group>
        )
      case 'upload':
        return (
          <Upload
            withCredentials
            disabled={item.disabled}
            action={`${baseUrl}/rest/file/upload`}
            listType="picture-card"
            accept=".jpg,.png,.jpeg,.bmp"
            beforeUpload={(file) => {
              if (file.size / 1024 / 1024 > 2) {
                message.error('请选择大小在2M以内图片!')
                return false
              }
            }}
            onPreview={handlePreview}
            onChange={(file) => handleChange(file, item.name)}
            data={{
              rdSession: user.rdSession,
              now: Date.now(),
              sign: createRdSession(
                { rdSession: user.rdSession },
                user.loginCode
              )
            }}
          >
            {uploadBottom
              ? uploadBottom[item.name]
                ? uploadBut
                : null
              : uploadBut}
          </Upload>
        )
      case 'datePicker':
        return (
          <DatePicker
            format="YYYY-MM-DD"
            disabled={item.disabled}
            style={{ width: '100%' }}
          />
        )
      case 'render':
        return item.render!()
    }
  }

  /**
   * FormItem valuePropName 值
   * @param item 渲染条件
   */
  const propName = (item: ModalFormItem) => {
    switch (item.type) {
      case 'switch':
        return 'checked'
      case 'upload':
        return 'fileList'
      default:
        return undefined
    }
  }

  /**
   * 排版Layout
   */
  const Layout = () => {
    // @ts-ignore
    return modalProps.width > 500 ? formItemMaxLayout : formItemMinLayout
  }

  return (
    <Modal
      {...modalProps}
      afterClose={modalProps.afterClose || (() => {
        usForm.resetFields()
      })}
      destroyOnClose
      footer={
        modalFooter
          ? modalFooter(
              modalOkCall ?? onFormSubmit,
              modalCancelCall ?? modalProps.onCancel
            )
          : modalFooter
      }
      onOk={onFormSubmit}
    >
      <Form
        {...Layout()}
        ref={formRef}
        form={usForm}
        name="register"
        scrollToFirstError
      >
        <Row>
          {formItem.map((item, index) => {
            return (
              <Col
                key={index}
                style={
                  item.type === 'upload' ? { position: 'relative' } : undefined
                }
                span={typeSetting === 'one' ? 24 : 12}
              >
                <Form.Item
                  name={item.name}
                  label={item.title}
                  rules={item.rules}
                  valuePropName={propName(item)}
                  getValueFromEvent={
                    item.type === 'upload'
                      ? (args) => normFile(args, item.name)
                      : undefined
                  }
                >
                  {moduleRendering(item)}
                </Form.Item>
                {item.type === 'upload' && (
                  <div
                    style={{
                      position: 'absolute',
                      top: 110,
                      width: '100%',
                      paddingLeft: 110
                    }}
                  >
                    只允许上传图片后缀为.jpg、.png、jpeg、.bmp的图片文件,文件大小不能超过2M
                  </div>
                )}
              </Col>
            )
          })}
        </Row>
      </Form>
      <Modal
        visible={previewVisible}
        title={previewTitle}
        footer={null}
        onCancel={handleCancel}
      >
        <img alt="example" style={{ width: '100%' }} src={previewImage} />
      </Modal>
    </Modal>
  )
}

OperateModal.defaultProps = {
  typeSetting: 'one'
}

组件使用

<OperateModal
  title={{ add: '新增客户', update: '修改客户' }[operateModal.type!]}
  data={operateModal.data}
  type={operateModal.type!}
  visible={operateModal.visible}
  width={1000}
  formItem={formItem}
  onSubmit={(value: any) => onSubmit(value)}
  onCancel={operateModalController.hideModal}
  afterClose={operateModalController.cleanModalData}
/>

formItem 数据

const formItem: Array<ModalFormItem> = [
  {
    title: '商户名称',
    type: 'input',
    name: 'businessName',
    rules: [{ required: true, message: '商户名称不能为空' }]
  },
  { title: '商户简称', type: 'input', name: 'nickName' },
  { title: '客户编码', type: 'input', name: 'code' },
  { title: '联系人', type: 'input', name: 'contactName' },
  {
    title: '手机号码',
    type: 'input',
    name: 'telephone',
    rules: [{ required: true, message: '手机号码不能为空' }]
  },
  // { title: '区域', type: 'cascader', name: 'area' },
  { title: '位置', type: 'input', name: 'address', disabled: true },
  {
    title: '行业类型',
    type: 'select',
    name: 'industryType',
    selectArr: industryType
  },
  {
    title: '通路类型',
    type: 'select',
    name: 'pathType',
    selectArr: pathType
  },
  { title: '客户等级', type: 'select', name: 'grade', selectArr: gradeType },
  { title: '陈列面积', type: 'inputNumber', name: 'displayArea' },
  { title: '公司电话', type: 'input', name: 'companyPhone' },
  {
    title: '客户来源',
    type: 'select',
    name: 'customerSource',
    selectArr: customerSource
  },
  {
    title: '跟进进度',
    type: 'select',
    name: 'progress',
    selectArr: progressType
  },
  {
    title: '纬度',
    type: 'inputNumber',
    name: 'latitude',
    rules: [{ required: true, message: '纬度不能为空' }],
    disabled: true
  },
  {
    title: '经度',
    type: 'inputNumber',
    name: 'longitude',
    rules: [{ required: true, message: '经度不能为空' }],
    disabled: true
  },
  {
    title: '客户状态',
    type: 'switch',
    name: 'customerStatus',
    selectArr: [{ checked: '启用', unChecked: '禁用' }]
  },
  {
    title: '跟进人',
    type: 'select',
    name: 'employeeId',
    rules: [{ required: true, message: '跟进人不能为空' }]
  },
  { title: '陈列费', type: 'inputNumber', name: 'displayFee' },
  {
    title: '门头照',
    type: 'upload',
    name: 'doorPhoto',
    uploadNumber: 8,
    rules: [{ required: true, message: '门头照不能为空' }]
  }
]
Last Updated:
Contributors: 1102166242@qq.com, cjx