最佳实践

一个大表单要怎么完成,怎么解耦

我的思路是

1、先将表单组件拆分,按照基础组件、自定义组件的原则
2、自定义组件一般为:内部需要实现一定的业务逻辑,或者由多个基础组件组合而成的组件
3、接下来就是依次实现对应的自定义组件
4、表单搭建的过程还需要判断表单中各个字段的关系,相似信息的业务字段是否可组合一个小模块【若表单很简单可掠过此处】
5、随后按照业务模块,将各个字段进行组合,最后将各个模块组合形成最终的表单

实例

按照以上思路,拆分后的组件为

  • 基础组件
    • 店铺名称、店铺类型、店长姓名、店铺电话、店铺特色
  • 自定义组件
    • 列表图、店铺照片、营业时间、店铺地址、商户营销位
  • 业务模块
    • 基本信息、营业信息、其他信息

表单文件夹

  • Field 为所有表单控件
  • Creative 为商户营销位组件

具体代码

// Field/index.tsx 导出所有自定义组件
import Address from './Address'
import Creative from './Creative'
import WorkTime from './WorkTime'

export { Address, Creative, WorkTime }
// WorkTime.tsx 营业时间组件
import { MinusCircleFilled, PlusCircleFilled } from '@ant-design/icons'
import { message, Select, TimePicker } from 'antd'
import dayjs from 'dayjs'
import { cloneDeep, isEmpty, isEqual } from 'lodash-es'
import React, { useEffect, useRef, useState } from 'react'

import globalEnum from '@/config/globalEnum'

interface TimeIntervalsType {
  startTime: string | number | dayjs.ConfigType
  endTime: string | number | dayjs.ConfigType
}
interface PropsType {
  readOnly?: boolean
  value?: {
    weeks: string[]
    timeIntervals: TimeIntervalsType[]
  }
  onChange?: (val: {
    weeks: string[]
    timeIntervals: TimeIntervalsType[]
  }) => void
  status?: 'error' | 'warning'
}

const { Option } = Select

const WorkTime = (props: PropsType) => {
  const { onChange, value, status } = props

  const weekType = globalEnum.transformOptions(globalEnum.getVal('weeksList'))
  const [weeks, setWeek] = useState<string[]>([])
  const [timeIntervals, setTimeIntervals] = useState<TimeIntervalsType[]>([
    { startTime: '', endTime: '' }
  ])
  const currentStartTimeRef = useRef<any>()

  const handleChange = (result: {
    weeks: string[]
    timeIntervals: TimeIntervalsType[]
  }) => {
    onChange && onChange(result)
  }

  const handleWeekChange = (val: string[]) => {
    setWeek(val)
    handleChange({
      weeks: weekType
        .filter((item) => (val || []).includes(item.value))
        .map((item) => item.value),
      timeIntervals
    })
  }

  // 重置时间的年月日
  const modifyDateTime = (
    dateTime: any,
    newYear?: number,
    newMonth?: number,
    newDay?: number
  ) => {
    const hour = dayjs(dateTime).get('hour')
    const minute = dayjs(dateTime).get('minute')
    const second = dayjs(dateTime).get('second')
    const newDate = dayjs()
      .set('year', newYear || 2023)
      .set('month', newMonth ? newMonth - 1 : 0)
      .set('date', newDay || 1)
      .set('hour', hour)
      .set('minute', minute)
      .set('second', second)

    return newDate
  }

  // 判断时间是否有交集
  const hasIntersection = (
    time1: { startTime: any; endTime: any },
    time2: { startTime: any; endTime: any }
  ) => {
    if (
      time1.endTime?.valueOf() < time2.startTime?.valueOf() ||
      time1.startTime?.valueOf() > time2.endTime?.valueOf()
    ) {
      return false // 两个时间段没有交集
    }
    return true // 两个时间段有交集
  }

  const handleTimeChange = (dates: any, index: number) => {
    const newTimes = cloneDeep(timeIntervals)
    const startTime = dates
      ? modifyDateTime(dates[0], 2023, 1, 1).valueOf()
      : ''
    const endTime = dates ? modifyDateTime(dates[1], 2023, 1, 1).valueOf() : ''
    if (startTime && endTime && startTime === endTime) {
      return message.warning('开始时间不能与结束时间相同')
    }
    newTimes[index] = {
      startTime,
      endTime
    }
    if (timeIntervals.length > 1 && hasIntersection(newTimes[0], newTimes[1])) {
      message.warning('所选时间存在交集,请重新选择!')
      return
    }
    setTimeIntervals(newTimes)
    handleChange({
      weeks,
      timeIntervals: newTimes
    })
  }

  const addTime = () => {
    const newTimes = cloneDeep(timeIntervals)
    newTimes.push({
      startTime: '',
      endTime: ''
    })
    setTimeIntervals(newTimes)
  }

  const deleteTime = (index: number) => {
    const newTimes = cloneDeep(timeIntervals)
    newTimes.splice(index, 1)
    setTimeIntervals(newTimes)
    handleChange({
      weeks,
      timeIntervals: newTimes
    })
  }

  const onCalendarChange = (dates: any, dateStrings: any, info: any) => {
    if (info.range === 'start') {
      currentStartTimeRef.current = dates[0]
    } else {
      currentStartTimeRef.current = null
    }
  }

  useEffect(() => {
    if (value) {
      if (value.weeks && !isEqual(weeks, value.weeks)) {
        setWeek(value.weeks)
      }
      if (value.timeIntervals && !isEqual(timeIntervals, value.timeIntervals)) {
        setTimeIntervals(
          value.timeIntervals.map((item: any) => {
            return {
              startTime: modifyDateTime(Number(item.startTime)).valueOf(),
              endTime: modifyDateTime(Number(item.endTime)).valueOf()
            }
          })
        )
      }
    }
  }, [value])

  return (
    <>
      <Select
        status={isEmpty(weeks) ? status : ''}
        placeholder='请选择商户营业时间'
        className='mb-4'
        value={weeks}
        mode='multiple'
        onChange={handleWeekChange}>
        {weekType.map((option) => (
          <Option value={option.value} key={option.value}>
            {option.label}
          </Option>
        ))}
      </Select>
      {timeIntervals.map((item, index) => {
        return (
          <div className='w-full relative' key={index}>
            <TimePicker.RangePicker
              status={
                isEmpty(timeIntervals?.filter((item: any) => item.startTime))
                  ? status
                  : ''
              }
              className={index !== timeIntervals.length - 1 ? 'mb-4' : ''}
              format='HH:mm'
              onCalendarChange={onCalendarChange}
              onChange={(dates: any) => handleTimeChange(dates, index)}
              // disabledTime={() => disabledTime(index)}
              value={
                item.startTime
                  ? [dayjs(Number(item.startTime)), dayjs(Number(item.endTime))]
                  : null
              }
            />
            {timeIntervals.length <= 1 ? (
              <PlusCircleFilled
                onClick={addTime}
                className='cursor-pointer absolute top-2 -right-6 text-primary text-base'
              />
            ) : (
              <MinusCircleFilled
                onClick={() => deleteTime(index)}
                className='cursor-pointer absolute top-2 -right-6 text-error text-base'
              />
            )}
          </div>
        )
      })}
      {status && <div className='text-error'>请填写完整营业时间</div>}
    </>
  )
}

export default WorkTime
// baseInfoModule.tsx 基础信息模块
import { FormItem, Input, Radio } from '@formily/antd-v5'
import { createSchemaField } from '@formily/react'
import React, { useEffect } from 'react'

import FormatInput from '@/components/FormatInput'
import MerchantList from '@/components/MerchantList'
import UploadImg from '@/components/UploadImg'
import { formatCnEnNum, formatPhone } from '@/utils'

const SchemaField: any = createSchemaField({
  components: {
    FormItem,
    Input,
    Radio,
    FormatInput,
    MerchantList,
    UploadImg
  }
})

/**
 * 基本信息
 */
const BaseInfoModule = () => {
  const schema: any = {
    type: 'object',
    properties: {}
  }
  const fieldList = [
    {
      name: 'name',
      type: 'string',
      title: '店铺名称',
      required: true,
      component: 'FormatInput',
      decoratorProps: {
        className: '!mb-5'
      },
      props: {
        maxLength: 20,
        showCount: true
      }
    },
    {
      name: 'merchant',
      type: 'string',
      title: '所属商户',
      required: true,
      component: 'MerchantList',
      decoratorProps: {
        className: '!mb-5'
      },
      props: {
        showSearch: true,
        allowClear: true
      }
    },
    {
      name: 'type',
      type: 'string',
      title: '店铺类型',
      required: true,
      component: 'Radio.Group',
      enum: [
        {
          label: '线下店铺',
          value: 'offline'
        },
        {
          label: '线上店铺',
          value: 'online'
        }
      ],
      default: 'offline',
      decoratorProps: {
        className: '!mb-3'
      }
    },
    {
      name: 'storeListPic',
      type: 'string',
      title: '列表图',
      component: 'UploadImg',
      decoratorProps: {
        className: '!mb-5'
      },
      'x-validator': [
        { required: true, message: '请上传图片' },
        {
          validator: (val: any) => {
            if (val?.fileList.length === 0) return '请上传图片'
          },
          triggerType: 'onBlur'
        }
      ],
      props: {
        width: 104,
        height: 104,
        placeholder: '上传照片',
        maxLength: 1,
        tipText:
          '建议尺寸200*200px,支持jpg、jpeg、png等格式,将会展示在店铺列表中'
      }
    },
    {
      name: 'storePic',
      type: 'string',
      title: '店铺照片',
      required: true,
      'x-validator': [
        {
          required: true,
          message: '请上传图片'
        }
      ],
      component: 'UploadImg',
      decoratorProps: {
        className: '!mb-5'
      },
      props: {
        mode: 'row',
        width: 104,
        height: 104,
        placeholder: '上传照片',
        maxLength: 10,
        tipText: '建议尺寸750*560px,支持jpg、jpeg、png等格式,最多支持上传10张'
      }
    },
    {
      name: 'managerName',
      type: 'string',
      title: '店长姓名',
      component: 'FormatInput',
      decoratorProps: {
        className: '!mb-5'
      },
      props: {
        maxLength: 8,
        showCount: true,
        format: formatCnEnNum
      }
    },
    {
      name: 'storeTelephone',
      type: 'string',
      title: '店铺电话',
      required: true,
      component: 'FormatInput',
      decoratorProps: {
        className: '!mb-5'
      },
      props: {
        format: formatPhone,
        maxLength: 13
      }
    },
    {
      name: 'storeFeature',
      type: 'string',
      title: '店铺特色',
      component: 'Input',
      decoratorProps: {
        className: '!mb-5'
      },
      props: {
        showCount: true,
        maxLength: 30,
        placeholder: '请输入店铺特色,将会在店铺列表中显示'
      }
    }
  ]

  fieldList.forEach((field: any) => {
    const {
      validator,
      name,
      type,
      title,
      required,
      component,
      decoratorProps,
      ...extra
    } = field
    schema.properties[field.name] = {
      type,
      title,
      required,
      'x-decorator': 'FormItem',
      'x-decorator-props': {
        className: '!mb-6',
        labelWidth: 84,
        // wrapperWidth: 640,
        ...(decoratorProps || {})
      },
      'x-component': component,
      'x-component-props': {
        ...(field.props || {}),
        placeholder:
          field.props?.placeholder ||
          (component === 'FormatInput' ? `请输入${title}` : '')
      },
      'x-validator': validator,
      ...extra
    }
  })

  useEffect(() => {}, [])

  return <SchemaField schema={schema} />
}

export default BaseInfoModule
// StoreForm/index.tsx
import { Form } from '@formily/antd-v5'
import { createForm, onFieldChange, onFieldValidateFailed } from '@formily/core'
import { Button, message, Spin } from 'antd'
import { isEmpty } from 'lodash-es'
import type React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom'

import ShenduDrawer from '@/components/ShenduDrawer'
import { mktmngApi } from '@/services/mktmng.api'
import { getUrlQuery, jsonPost } from '@/utils'

import BaseInfoModule from './baseInfoModule'
import BusinessModule from './BusinessModule'
import useCreative from './Field/Creative/useCreative'
import { useService } from './hooks/useService'
import OtherModule from './OtherModule'

interface PropsType {
  data?: any
  children?: React.ReactNode
  title?: string
  btnText?: string
  onOk?: () => void
}

/**
 *  店铺表单
 */
const StoreForm = (props: PropsType) => {
  const { btnText, children, title, onOk } = props
  const [visible, setVisible] = useState(false)
  const searchObj = getUrlQuery()
  const location = useLocation()
  const [loading, setLoading] = useState(false)

  const { detail, setDetail, getDetail } = useService({ ...props })
  const [form, setForm] = useState<any>()
  const [isShowBusiness, setIsShowBusiness] = useState(true)
  const { actions } = useCreative()

  useMemo(() => {
    if (visible) {
      setForm(
        createForm({
          validateFirst: true,
          initialValues: {},
          effects: () => {
            onFieldChange('storePic', ['value'], (field: any) => {
              if (field.componentProps.status) {
                field.componentProps.status = ''
                field.componentProps.errorMsg = ''
              }
            })
            onFieldChange('storeListPic', ['value'], (field: any) => {
              if (field.componentProps.status) {
                field.componentProps.status = ''
                field.componentProps.errorMsg = ''
              }
            })
            // 监听某个字段校验触发失败
            onFieldValidateFailed('storePic', (field) => {
              // 校验失败时给自定义组件设置状态,用于展示失败状态
              field.componentProps.status = 'error'
              field.componentProps.errorMsg = ''
            })
            onFieldValidateFailed('storeListPic', (field) => {
              field.componentProps.status = 'error'
              field.componentProps.errorMsg = ''
            })
            onFieldChange('address', ['value'], (field: any, form: any) => {
              const { province, detailAddress, longitude, latitude } =
                field.value || {}
              if (province && detailAddress && longitude && latitude) {
                form.setFieldState('address', (state: any) => {
                  state.componentProps.status = undefined
                })
              }
            })
            onFieldChange('workTime', ['value'], (field: any, form: any) => {
              const { weeks, timeIntervals } = field.value || {}
              const checkTimeIntervals = timeIntervals?.filter(
                (item: any) => item.startTime
              )
              if (weeks?.length > 0 && checkTimeIntervals?.length > 0) {
                form.setFieldState('workTime', (state: any) => {
                  state.componentProps.status = undefined
                })
              }
            })
            onFieldChange('type', ['value'], (field: any, form: any) => {
              form.setFieldState('workTime', (state: any) => {
                state.required = field.value === 'offline'
              })
              form.setFieldState('address', (state: any) => {
                state.required = field.value === 'offline'
              })

              // 等待form表单变更后,再进行状态修改操作
              setTimeout(() => setIsShowBusiness(field.value === 'offline'))
            })
          }
        })
      )
    }
  }, [visible])

  const save = (values: any) => {
    const { storeListPic, storePic, ...extar } = values
    let checkFlag = false

    if (values.type === 'offline') {
      // 校验地址
      if (
        isEmpty(values.address) ||
        !values.address.longitude ||
        !values.address.latitude ||
        !values.address.city ||
        !values.address.detailAddress
      ) {
        checkFlag = true
        form.setFieldState('address', (state: any) => {
          state.componentProps.status = 'error'
        })
      }

      // 校验时间
      const { weeks, timeIntervals } = values.workTime || {}
      const checkTimeIntervals = timeIntervals?.filter(
        (item: any) => item.startTime
      )
      if (isEmpty(weeks) || isEmpty(checkTimeIntervals)) {
        checkFlag = true
        form.setFieldState('workTime', (state: any) => {
          state.componentProps.status = 'error'
        })
      }
    }

    if (actions.checkRequire(values.mktMaterials)) {
      form.setFieldState('mktMaterials', (state: any) => {
        state.componentProps.status = 'error'
      })
      message.warning('商户营销位信息未填写完整!')
      return
    } else {
      form.setFieldState('mktMaterials', (state: any) => {
        state.componentProps.status = ''
      })
    }

    if (loading || checkFlag) return
    let api = mktmngApi.createStore
    const req = {
      ...extar,
      resourceItems: [
        {
          value: storeListPic?.fileList[0] || '',
          bizType: 'storeListPic',
          type: 'img',
          sort: 1
        }
      ].concat(
        storePic?.fileList.map((url: string, index: number) => {
          return {
            value: url,
            bizType: 'storePic',
            type: 'img',
            sort: index + 1
          }
        }) || []
      )
    }

    if (req.mktMaterials) {
      req.mktMaterials = req.mktMaterials.filter((item: any) => {
        return item.url
      })
      if (req.mktMaterials.length === 0) delete req.mktMaterials
    }

    if (detail.code) {
      api = mktmngApi.updateStore
      req.code = detail.code
      delete req.type
      delete req.merchant
    }
    if (req.type === 'online') {
      delete req.workTime
      delete req.address
    }
    delete req.storePic
    delete req.storeListPic

    setLoading(true)
    jsonPost(api, { json: req })
      .then((res: any) => {
        if (res) {
          message.success(detail.code ? '保存成功' : '提交成功')
          setLoading(false)
          formDrawerClose(true)
        } else {
          setLoading(false)
        }
      })
      .catch(() => setLoading(false))
  }

  const formDrawerClose = (isReload?: boolean) => {
    form?.reset()
    setDetail({})
    setVisible(false)
    if (isReload && onOk) {
      onOk()
    }
  }

  useEffect(() => {
    return () => {
      form?.reset()
    }
  }, [location])

  useEffect(() => {
    if (props?.data?.code && visible) {
      setLoading(true)
      getDetail().then((res) => {
        setLoading(false)
        if (res) {
          form.setFieldState('merchant', (state: any) => {
            state.disabled = true
          })
          form.setFieldState('type', (state: any) => {
            state.disabled = true
          })
          form.setValues(res)
        }
      })
    }
  }, [visible])

  return (
    <>
      <ShenduDrawer
        id='storeFormDrawer'
        width={800}
        open={visible}
        maskClosable={false}
        title={title || ''}
        footer={
          <>
            <Button
              className='!rounded !h-9'
              type='primary'
              disabled={loading}
              onClick={() => form.submit((val: any) => save(val))}
              htmlType='submit'>
              提交
            </Button>
          </>
        }
        onClose={() => formDrawerClose()}>
        <Spin spinning={loading}>
          <Form
            className='w-[608px]'
            form={form}
            onAutoSubmit={save}
            key={searchObj.type}>
            <div className='text-base mb-6 font-medium'>基本信息</div>
            <BaseInfoModule />
            {isShowBusiness && (
              <>
                <div className='text-base mb-6 mt-10 font-medium'>营业信息</div>
                <BusinessModule />
              </>
            )}
            <div className='text-base mb-6 mt-10 font-medium'>其他信息</div>
            <OtherModule />
          </Form>
        </Spin>
      </ShenduDrawer>
      {children ? (
        <span onClick={() => setVisible(true)}>{children}</span>
      ) : (
        <Button
          type='link'
          className='px-0 pr-2'
          onClick={() => setVisible(true)}>
          {btnText || '编辑'}
        </Button>
      )}
    </>
  )
}

export default StoreForm
// 使用
// 方式一
<StoreForm
  onOk={() => {}}
  data={null}
  title='新建店铺'>
  <Button type='primary' key='add' className='px-3'>
    <PlusOutlined className='mr-1' />
    新建店铺
  </Button>
</StoreForm>

// 方式二
<StoreForm
  onOk={() => {}}
  data={record}
  btnText='编辑'
  title='编辑商品'
/>
powered by Gitbook该文件修订时间: 2023-08-21 18:02:46

results matching ""

    No results matching ""