From b459607d31e9b234133fb9d2a8b3de9b8f0accb5 Mon Sep 17 00:00:00 2001 From: liuyh Date: Tue, 3 Feb 2026 11:57:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E6=AD=A5?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/multi-step-form-dialog.tsx | 629 ++++++++++++------ 1 file changed, 423 insertions(+), 206 deletions(-) diff --git a/src/components/common/multi-step-form-dialog.tsx b/src/components/common/multi-step-form-dialog.tsx index bd25cac..af62c13 100644 --- a/src/components/common/multi-step-form-dialog.tsx +++ b/src/components/common/multi-step-form-dialog.tsx @@ -1,6 +1,6 @@ '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 { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' @@ -19,20 +19,43 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' -import { ChevronLeft, ChevronRight } from 'lucide-react' import { useIsMobile } from '@/hooks/use-mobile' 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 }) => React.ReactNode + className?: string +} + +// 步骤配置类型定义 +export interface StepConfig { + name: string + title: string + description?: string + fields: StepFieldConfig[] + className?: string // 步骤内容区域的网格样式 +} // MultiStepFormDialog Context export interface MultiStepFormDialogContextValue { - onCancel: () => void - onPrevious: () => void - onNext: () => void - isSubmitting: boolean - isValidating: boolean - submitButtonText: string + form: UseFormReturn + close: () => void + steps: StepConfig[] + currentStep: number + totalSteps: number isFirstStep: boolean isLastStep: boolean + goToStep: (step: number) => void + goNext: () => void + goPrev: () => void + isLoading?: boolean + canGoNext: () => Promise } const MultiStepFormDialogContext = createContext(null) @@ -45,91 +68,343 @@ export function useMultiStepFormDialogContext() { return context } -// 字段配置类型定义 -export interface FormFieldConfig { - name: string - label: string - required?: boolean - render: (props: { field: ControllerRenderProps }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理 - className?: string // 允许为单个字段指定自定义样式 +// 步骤指示器组件 +export interface StepIndicatorProps { + className?: string + showLabels?: boolean + clickable?: boolean } -// 步骤配置类型定义 -export interface StepConfig { - title: string - description?: string - fields: FormFieldConfig[] -} - -// 多步骤表单操作按钮栏组件 -export function MultiStepFormActionBar() { - const { - onCancel, - onPrevious, - onNext, - isSubmitting, - isValidating, - submitButtonText, - isFirstStep, - isLastStep - } = useMultiStepFormDialogContext() +export function StepIndicator({ className, showLabels = false, clickable = false }: StepIndicatorProps) { + const { steps, currentStep, goToStep, isLoading } = useMultiStepFormDialogContext() return ( -
-
- -
- -
- {!isFirstStep && ( - - )} - {!isLastStep && ( - - )} - -
+
+ {steps.map((step, index) => { + const isCompleted = index < currentStep + const isCurrent = index === currentStep + const isClickable = clickable && !isLoading && index <= currentStep + + return ( + + {index > 0 && ( +
+ )} + + + ) + })}
) } +// 步骤内容组件 +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 ( +
+ {currentStepConfig.fields.map((fieldConfig) => ( +
+ + +
+ ))} +
+ ) + } + + return ( +
+ {currentStepConfig.fields.map((fieldConfig) => ( + ( + + + {fieldConfig.label} + {fieldConfig.required && *} + + + {fieldConfig.render({ field })} + + + + )} + /> + ))} +
+ ) +} + +// 步骤标题组件(显示当前步骤的标题和描述) +export interface StepHeaderProps { + className?: string +} + +export function StepHeader({ className }: StepHeaderProps) { + const { steps, currentStep } = useMultiStepFormDialogContext() + const currentStepConfig = steps[currentStep] + + if (!currentStepConfig) return null + + return ( +
+

{currentStepConfig.title}

+ {currentStepConfig.description && ( +

{currentStepConfig.description}

+ )} +
+ ) +} + +// 上一步按钮组件 +export interface PrevStepActionProps extends Omit, '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 ( + + ) +} + +// 下一步按钮组件 +export interface NextStepActionProps extends Omit, 'onClick' | 'type'> { + children?: React.ReactNode + onNext?: () => Promise | 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 ( + + ) +} + +// 取消按钮组件 +export interface StepCancelActionProps extends Omit, '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 ( + + ) +} + +// 提交按钮组件(仅在最后一步显示) +export interface StepSubmitActionProps extends Omit, 'onClick' | 'type'> { + onSubmit: (data: any) => Promise | 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 ( + + ) +} + +// 操作按钮栏组件 +export interface StepActionBarProps { + children?: React.ReactNode + className?: string +} + +export function StepActionBar({ children, className }: StepActionBarProps) { + return ( +
+ {children} +
+ ) +} + +// 左侧操作区 +export function StepActionBarLeft({ children, className }: { children?: React.ReactNode; className?: string }) { + return
{children}
+} + +// 右侧操作区 +export function StepActionBarRight({ children, className }: { children?: React.ReactNode; className?: string }) { + return
{children}
+} + +// 主对话框组件 export interface MultiStepFormDialogProps { isOpen: boolean title: string description: string form: UseFormReturn - onSubmit: (data: any) => Promise | void steps: StepConfig[] - contentClassName?: string - gridClassName?: string - - /* action */ onClose: () => void - isSubmitting: boolean - submitButtonText: string + className?: string + formClassName?: string + children: React.ReactNode + isLoading?: boolean + initialStep?: number } export function MultiStepFormDialog({ @@ -137,28 +412,31 @@ export function MultiStepFormDialog({ title, description, form, - onSubmit, steps, - contentClassName = 'max-w-4xl', - gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4', onClose, - isSubmitting, - submitButtonText, + className = 'max-w-lg', + formClassName, + children, + isLoading = false, + initialStep = 0, }: MultiStepFormDialogProps) { const isMobile = useIsMobile() - const [currentStep, setCurrentStep] = useState(0) - const [isValidating, setIsValidating] = useState(false) const formRef = useRef(null) + const [currentStep, setCurrentStep] = useState(initialStep) - // 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件 + // 重置步骤当对话框关闭或重新打开时 useEffect(() => { if (isOpen) { - // 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上 + setCurrentStep(initialStep) + } + }, [isOpen, initialStep]) + + // 当对话框打开或步骤变化时,自动聚焦到第一个表单输入控件 + useEffect(() => { + if (isOpen && !isLoading) { (document.activeElement as HTMLElement)?.blur() - // 使用 setTimeout 确保 DOM 已完全渲染 const timer = setTimeout(() => { if (formRef.current) { - // 查找第一个可聚焦的输入元素 const firstInput = formRef.current.querySelector( '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) } - }, [isOpen, currentStep]) + }, [isOpen, isLoading, currentStep]) - const handleSubmit = async (data: any) => { - await onSubmit(data) - } - - const handleClose = () => { - setCurrentStep(0) + const close = () => { onClose() } - const handleNext = async () => { - 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 totalSteps = steps.length const isFirstStep = currentStep === 0 - const isLastStep = currentStep === steps.length - 1 - const currentStepConfig = steps[currentStep] + const isLastStep = currentStep === totalSteps - 1 - // 步骤指示器组件 - const stepIndicator = ( -
- {steps.map((step, index) => ( -
-
- {index + 1} -
-
-
- {step.title} -
-
- {index < steps.length - 1 && ( -
- )} -
- ))} -
- ) - - // Context 值 - const contextValue: MultiStepFormDialogContextValue = { - onCancel: handleClose, - onPrevious: handlePrevious, - onNext: handleNext, - isSubmitting, - isValidating, - submitButtonText, - isFirstStep, - isLastStep + const goToStep = (step: number) => { + if (step >= 0 && step < totalSteps) { + setCurrentStep(step) + } + } + + const goNext = () => { + if (!isLastStep) { + setCurrentStep((prev) => prev + 1) + } + } + + const goPrev = () => { + if (!isFirstStep) { + setCurrentStep((prev) => prev - 1) + } + } + + // 验证当前步骤的字段 + const canGoNext = async (): Promise => { + const currentStepConfig = steps[currentStep] + if (!currentStepConfig) return true + + const fieldNames = currentStepConfig.fields.map((f) => f.name) + const result = await form.trigger(fieldNames as any) + return result + } + + const contextValue: MultiStepFormDialogContextValue = { + form, + close, + steps, + currentStep, + totalSteps, + isFirstStep, + isLastStep, + goToStep, + goNext, + goPrev, + isLoading, + canGoNext, } - // 表单内容组件 const formContent = ( -
-
- - {/* 当前步骤标题和描述 */} -
-

{currentStepConfig.title}

- {currentStepConfig.description && ( -

{currentStepConfig.description}

- )} -
- - {/* 当前步骤的字段 */} -
- {currentStepConfig.fields.map((fieldConfig) => ( - ( - - - {fieldConfig.label} - {fieldConfig.required && *} - - - {fieldConfig.render({ field })} - - - - )} - /> - ))} -
- - {/* 操作按钮 */} - - - -
+
+ + {children} +
+
) - // 根据设备类型渲染不同的组件 if (isMobile) { return ( - - + !open && close()}> + {title} {description}
- {stepIndicator} {formContent}
@@ -310,17 +528,16 @@ export function MultiStepFormDialog({ } return ( - - + !open && close()}> + {title} {description} - {stepIndicator} -
+
{formContent}
) -} \ No newline at end of file +}