feat: 新增多步表单控件

This commit is contained in:
2026-02-03 11:57:06 +08:00
parent 796ffcfe00
commit b459607d31

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import React, { createContext, useContext, useState, useEffect, useRef } from 'react' import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form' import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
@@ -19,20 +19,43 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
// 字段配置类型定义
export interface StepFieldConfig {
name: string
label: string
required?: boolean
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode
className?: string
}
// 步骤配置类型定义
export interface StepConfig {
name: string
title: string
description?: string
fields: StepFieldConfig[]
className?: string // 步骤内容区域的网格样式
}
// MultiStepFormDialog Context // MultiStepFormDialog Context
export interface MultiStepFormDialogContextValue { export interface MultiStepFormDialogContextValue {
onCancel: () => void form: UseFormReturn<any>
onPrevious: () => void close: () => void
onNext: () => void steps: StepConfig[]
isSubmitting: boolean currentStep: number
isValidating: boolean totalSteps: number
submitButtonText: string
isFirstStep: boolean isFirstStep: boolean
isLastStep: boolean isLastStep: boolean
goToStep: (step: number) => void
goNext: () => void
goPrev: () => void
isLoading?: boolean
canGoNext: () => Promise<boolean>
} }
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null) const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
@@ -45,91 +68,343 @@ export function useMultiStepFormDialogContext() {
return context return context
} }
// 字段配置类型定义 // 步骤指示器组件
export interface FormFieldConfig { export interface StepIndicatorProps {
name: string className?: string
label: string showLabels?: boolean
required?: boolean clickable?: boolean
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
className?: string // 允许为单个字段指定自定义样式
} }
// 步骤配置类型定义 export function StepIndicator({ className, showLabels = false, clickable = false }: StepIndicatorProps) {
export interface StepConfig { const { steps, currentStep, goToStep, isLoading } = useMultiStepFormDialogContext()
title: string
description?: string
fields: FormFieldConfig[]
}
// 多步骤表单操作按钮栏组件
export function MultiStepFormActionBar() {
const {
onCancel,
onPrevious,
onNext,
isSubmitting,
isValidating,
submitButtonText,
isFirstStep,
isLastStep
} = useMultiStepFormDialogContext()
return ( return (
<div className="flex justify-between pt-6 border-t"> <div className={cn("flex items-center justify-center gap-2", className)}>
<div className="flex space-x-2"> {steps.map((step, index) => {
<Button type="button" variant="outline" onClick={onCancel}> const isCompleted = index < currentStep
const isCurrent = index === currentStep
</Button> const isClickable = clickable && !isLoading && index <= currentStep
</div>
<div className="flex space-x-2"> return (
{!isFirstStep && ( <React.Fragment key={step.name}>
<Button {index > 0 && (
type="button" <div
variant="outline" className={cn(
onClick={onPrevious} "h-0.5 w-8 transition-colors",
disabled={isSubmitting} index <= currentStep ? "bg-primary" : "bg-muted"
> )}
<ChevronLeft className="w-4 h-4 mr-1" /> />
)}
</Button> <button
)} type="button"
{!isLastStep && ( disabled={!isClickable}
<Button onClick={() => isClickable && goToStep(index)}
type="button" className={cn(
onClick={onNext} "flex items-center gap-2 transition-colors",
disabled={isSubmitting || isValidating} isClickable && "cursor-pointer hover:opacity-80",
> !isClickable && "cursor-default"
{isValidating ? '验证中...' : '下一步'} )}
<ChevronRight className="w-4 h-4 ml-1" /> >
</Button> <div
)} className={cn(
<Button "flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
type="submit" isCompleted && "border-primary bg-primary text-primary-foreground",
disabled={isSubmitting} isCurrent && "border-primary text-primary",
className={!isLastStep ? 'hidden' : ''} !isCompleted && !isCurrent && "border-muted text-muted-foreground"
> )}
{isSubmitting ? `${submitButtonText}中...` : submitButtonText} >
</Button> {isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</div> </div>
{showLabels && (
<span
className={cn(
"text-sm font-medium hidden sm:inline",
isCurrent && "text-primary",
!isCurrent && "text-muted-foreground"
)}
>
{step.title}
</span>
)}
</button>
</React.Fragment>
)
})}
</div> </div>
) )
} }
// 步骤内容组件
export interface StepContentProps {
className?: string
}
export function StepContent({ className = 'grid grid-cols-1 gap-4' }: StepContentProps) {
const { form, steps, currentStep, isLoading } = useMultiStepFormDialogContext()
const currentStepConfig = steps[currentStep]
if (!currentStepConfig) return null
// 如果正在加载,显示骨架屏
if (isLoading) {
return (
<div className={cn("p-1", className, currentStepConfig.className)}>
{currentStepConfig.fields.map((fieldConfig) => (
<div key={fieldConfig.name} className={cn("space-y-2", fieldConfig.className || '')}>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
)
}
return (
<div className={cn("p-1", className, currentStepConfig.className)}>
{currentStepConfig.fields.map((fieldConfig) => (
<FormField
key={fieldConfig.name}
control={form.control}
name={fieldConfig.name}
render={({ field }) => (
<FormItem className={fieldConfig.className || ''}>
<FormLabel className="flex items-center gap-1">
{fieldConfig.label}
{fieldConfig.required && <span className="text-red-500">*</span>}
</FormLabel>
<FormControl>
{fieldConfig.render({ field })}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
)
}
// 步骤标题组件(显示当前步骤的标题和描述)
export interface StepHeaderProps {
className?: string
}
export function StepHeader({ className }: StepHeaderProps) {
const { steps, currentStep } = useMultiStepFormDialogContext()
const currentStepConfig = steps[currentStep]
if (!currentStepConfig) return null
return (
<div className={cn("space-y-1", className)}>
<h4 className="font-medium">{currentStepConfig.title}</h4>
{currentStepConfig.description && (
<p className="text-sm text-muted-foreground">{currentStepConfig.description}</p>
)}
</div>
)
}
// 上一步按钮组件
export interface PrevStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
children?: React.ReactNode
onPrev?: () => void
}
export function PrevStepAction({
children,
variant = 'outline',
onPrev,
...props
}: PrevStepActionProps) {
const { goPrev, isFirstStep, isLoading } = useMultiStepFormDialogContext()
const handleClick = () => {
if (onPrev) {
onPrev()
} else {
goPrev()
}
}
return (
<Button
type="button"
variant={variant}
onClick={handleClick}
disabled={isFirstStep || isLoading || props.disabled}
{...props}
>
{children ?? (
<>
<ChevronLeft className="mr-1 h-4 w-4" />
</>
)}
</Button>
)
}
// 下一步按钮组件
export interface NextStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
children?: React.ReactNode
onNext?: () => Promise<boolean> | boolean
validateCurrentStep?: boolean
}
export function NextStepAction({
children,
variant = 'default',
onNext,
validateCurrentStep = true,
...props
}: NextStepActionProps) {
const { goNext, isLastStep, isLoading, canGoNext } = useMultiStepFormDialogContext()
const [isValidating, setIsValidating] = useState(false)
const handleClick = async () => {
setIsValidating(true)
try {
if (validateCurrentStep) {
const canProceed = await canGoNext()
if (!canProceed) {
return
}
}
if (onNext) {
const shouldProceed = await onNext()
if (!shouldProceed) {
return
}
}
goNext()
} finally {
setIsValidating(false)
}
}
if (isLastStep) return null
return (
<Button
type="button"
variant={variant}
onClick={handleClick}
disabled={isLoading || isValidating || props.disabled}
{...props}
>
{children ?? (
<>
<ChevronRight className="ml-1 h-4 w-4" />
</>
)}
{isValidating && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
</Button>
)
}
// 取消按钮组件
export interface StepCancelActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
children?: React.ReactNode
onCancel?: () => void
}
export function StepCancelAction({ children = '取消', variant = 'outline', onCancel, ...props }: StepCancelActionProps) {
const { close } = useMultiStepFormDialogContext()
const handleClick = () => {
if (onCancel) {
onCancel()
} else {
close()
}
}
return (
<Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
{children}
</Button>
)
}
// 提交按钮组件(仅在最后一步显示)
export interface StepSubmitActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
onSubmit: (data: any) => Promise<void> | void
children?: React.ReactNode
disabled?: boolean
isSubmitting?: boolean
showSpinningLoader?: boolean
showOnlyOnLastStep?: boolean
}
export function StepSubmitAction({
onSubmit,
children = '提交',
disabled = false,
isSubmitting = false,
showSpinningLoader = true,
showOnlyOnLastStep = true,
variant = 'default',
...props
}: StepSubmitActionProps) {
const { form, isLastStep, isLoading } = useMultiStepFormDialogContext()
if (showOnlyOnLastStep && !isLastStep) return null
return (
<Button
type="button"
variant={variant}
onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting || disabled || isLoading}
{...props}
>
{children}
{isSubmitting && showSpinningLoader && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
</Button>
)
}
// 操作按钮栏组件
export interface StepActionBarProps {
children?: React.ReactNode
className?: string
}
export function StepActionBar({ children, className }: StepActionBarProps) {
return (
<div className={cn("flex justify-between", className)}>
{children}
</div>
)
}
// 左侧操作区
export function StepActionBarLeft({ children, className }: { children?: React.ReactNode; className?: string }) {
return <div className={cn("flex gap-2", className)}>{children}</div>
}
// 右侧操作区
export function StepActionBarRight({ children, className }: { children?: React.ReactNode; className?: string }) {
return <div className={cn("flex gap-2", className)}>{children}</div>
}
// 主对话框组件
export interface MultiStepFormDialogProps { export interface MultiStepFormDialogProps {
isOpen: boolean isOpen: boolean
title: string title: string
description: string description: string
form: UseFormReturn<any> form: UseFormReturn<any>
onSubmit: (data: any) => Promise<void> | void
steps: StepConfig[] steps: StepConfig[]
contentClassName?: string
gridClassName?: string
/* action */
onClose: () => void onClose: () => void
isSubmitting: boolean className?: string
submitButtonText: string formClassName?: string
children: React.ReactNode
isLoading?: boolean
initialStep?: number
} }
export function MultiStepFormDialog({ export function MultiStepFormDialog({
@@ -137,28 +412,31 @@ export function MultiStepFormDialog({
title, title,
description, description,
form, form,
onSubmit,
steps, steps,
contentClassName = 'max-w-4xl',
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
onClose, onClose,
isSubmitting, className = 'max-w-lg',
submitButtonText, formClassName,
children,
isLoading = false,
initialStep = 0,
}: MultiStepFormDialogProps) { }: MultiStepFormDialogProps) {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [currentStep, setCurrentStep] = useState(0)
const [isValidating, setIsValidating] = useState(false)
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const [currentStep, setCurrentStep] = useState(initialStep)
// 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件 // 重置步骤当对话框关闭或重新打开时
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
// 使当前拥有焦点的元素通常是用来触发打开这个drawer的控件失去焦点不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上 setCurrentStep(initialStep)
}
}, [isOpen, initialStep])
// 当对话框打开或步骤变化时,自动聚焦到第一个表单输入控件
useEffect(() => {
if (isOpen && !isLoading) {
(document.activeElement as HTMLElement)?.blur() (document.activeElement as HTMLElement)?.blur()
// 使用 setTimeout 确保 DOM 已完全渲染
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (formRef.current) { if (formRef.current) {
// 查找第一个可聚焦的输入元素
const firstInput = formRef.current.querySelector<HTMLElement>( const firstInput = formRef.current.querySelector<HTMLElement>(
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])' 'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
) )
@@ -170,138 +448,78 @@ export function MultiStepFormDialog({
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [isOpen, currentStep]) }, [isOpen, isLoading, currentStep])
const handleSubmit = async (data: any) => { const close = () => {
await onSubmit(data)
}
const handleClose = () => {
setCurrentStep(0)
onClose() onClose()
} }
const handleNext = async () => { const totalSteps = steps.length
if (currentStep < steps.length - 1) {
setIsValidating(true)
try {
// 验证当前步骤的字段,只有验证通过才能跳转到下一步
const currentStepFields = currentStepConfig.fields.map(field => field.name)
const isValid = await form.trigger(currentStepFields)
if (isValid) {
setCurrentStep(currentStep + 1)
}
} finally {
setIsValidating(false)
}
}
}
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const isFirstStep = currentStep === 0 const isFirstStep = currentStep === 0
const isLastStep = currentStep === steps.length - 1 const isLastStep = currentStep === totalSteps - 1
const currentStepConfig = steps[currentStep]
// 步骤指示器组件 const goToStep = (step: number) => {
const stepIndicator = ( if (step >= 0 && step < totalSteps) {
<div className="flex items-center justify-between mb-6"> setCurrentStep(step)
{steps.map((step, index) => ( }
<div key={index} className="flex items-center"> }
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${ const goNext = () => {
index <= currentStep if (!isLastStep) {
? 'bg-primary text-primary-foreground' setCurrentStep((prev) => prev + 1)
: 'bg-muted text-muted-foreground' }
}`} }
>
{index + 1} const goPrev = () => {
</div> if (!isFirstStep) {
<div className="ml-2 text-sm"> setCurrentStep((prev) => prev - 1)
<div className={index <= currentStep ? 'text-primary font-medium' : 'text-muted-foreground'}> }
{step.title} }
</div>
</div> // 验证当前步骤的字段
{index < steps.length - 1 && ( const canGoNext = async (): Promise<boolean> => {
<div className={`w-12 h-0.5 mx-4 ${index < currentStep ? 'bg-primary' : 'bg-muted'}`} /> const currentStepConfig = steps[currentStep]
)} if (!currentStepConfig) return true
</div>
))} const fieldNames = currentStepConfig.fields.map((f) => f.name)
</div> const result = await form.trigger(fieldNames as any)
) return result
}
// Context 值
const contextValue: MultiStepFormDialogContextValue = { const contextValue: MultiStepFormDialogContextValue = {
onCancel: handleClose, form,
onPrevious: handlePrevious, close,
onNext: handleNext, steps,
isSubmitting, currentStep,
isValidating, totalSteps,
submitButtonText, isFirstStep,
isFirstStep, isLastStep,
isLastStep goToStep,
goNext,
goPrev,
isLoading,
canGoNext,
} }
// 表单内容组件
const formContent = ( const formContent = (
<MultiStepFormDialogContext.Provider value={contextValue}> <MultiStepFormDialogContext.Provider value={contextValue}>
<div className="space-y-4"> <Form {...form}>
<Form {...form}> <form ref={formRef} className={cn("space-y-6", formClassName)}>
<form ref={formRef} onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> {children}
{/* 当前步骤标题和描述 */} </form>
<div className="border-b pb-4"> </Form>
<h3 className="text-lg font-medium">{currentStepConfig.title}</h3>
{currentStepConfig.description && (
<p className="text-sm text-muted-foreground mt-1">{currentStepConfig.description}</p>
)}
</div>
{/* 当前步骤的字段 */}
<div className={cn("p-1", gridClassName)}>
{currentStepConfig.fields.map((fieldConfig) => (
<FormField
key={fieldConfig.name}
control={form.control}
name={fieldConfig.name}
render={({ field }) => (
<FormItem className={fieldConfig.className || ''}>
<FormLabel className="flex items-center gap-1">
{fieldConfig.label}
{fieldConfig.required && <span className="text-red-500">*</span>}
</FormLabel>
<FormControl>
{fieldConfig.render({ field })}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
{/* 操作按钮 */}
<MultiStepFormActionBar />
</form>
</Form>
</div>
</MultiStepFormDialogContext.Provider> </MultiStepFormDialogContext.Provider>
) )
// 根据设备类型渲染不同的组件
if (isMobile) { if (isMobile) {
return ( return (
<Drawer open={isOpen} onOpenChange={handleClose}> <Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
<DrawerContent> <DrawerContent className={className}>
<DrawerHeader> <DrawerHeader>
<DrawerTitle>{title}</DrawerTitle> <DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription> <DrawerDescription>{description}</DrawerDescription>
</DrawerHeader> </DrawerHeader>
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]"> <div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
{stepIndicator}
{formContent} {formContent}
</div> </div>
</DrawerContent> </DrawerContent>
@@ -310,14 +528,13 @@ export function MultiStepFormDialog({
} }
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
<DialogContent className={contentClassName}> <DialogContent className={className} showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
{stepIndicator} <div className="overflow-y-auto max-h-[70vh]">
<div className='overflow-y-auto max-h-[70vh]'>
{formContent} {formContent}
</div> </div>
</DialogContent> </DialogContent>