feat: 新增多步表单控件
This commit is contained in:
@@ -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>
|
|
||||||
|
return (
|
||||||
<div className="flex space-x-2">
|
<React.Fragment key={step.name}>
|
||||||
{!isFirstStep && (
|
{index > 0 && (
|
||||||
<Button
|
<div
|
||||||
type="button"
|
className={cn(
|
||||||
variant="outline"
|
"h-0.5 w-8 transition-colors",
|
||||||
onClick={onPrevious}
|
index <= currentStep ? "bg-primary" : "bg-muted"
|
||||||
disabled={isSubmitting}
|
)}
|
||||||
>
|
/>
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
)}
|
||||||
上一步
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
)}
|
disabled={!isClickable}
|
||||||
{!isLastStep && (
|
onClick={() => isClickable && goToStep(index)}
|
||||||
<Button
|
className={cn(
|
||||||
type="button"
|
"flex items-center gap-2 transition-colors",
|
||||||
onClick={onNext}
|
isClickable && "cursor-pointer hover:opacity-80",
|
||||||
disabled={isSubmitting || isValidating}
|
!isClickable && "cursor-default"
|
||||||
>
|
)}
|
||||||
{isValidating ? '验证中...' : '下一步'}
|
>
|
||||||
<ChevronRight className="w-4 h-4 ml-1" />
|
<div
|
||||||
</Button>
|
className={cn(
|
||||||
)}
|
"flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||||
<Button
|
isCompleted && "border-primary bg-primary text-primary-foreground",
|
||||||
type="submit"
|
isCurrent && "border-primary text-primary",
|
||||||
disabled={isSubmitting}
|
!isCompleted && !isCurrent && "border-muted text-muted-foreground"
|
||||||
className={!isLastStep ? 'hidden' : ''}
|
)}
|
||||||
>
|
>
|
||||||
{isSubmitting ? `${submitButtonText}中...` : submitButtonText}
|
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
||||||
</Button>
|
</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,17 +528,16 @@ 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>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user