最佳实践
一个大表单要怎么完成,怎么解耦
我的思路是
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='编辑商品'
/>