From ca2fa3543d0f0ff8c719df50ed5d158e6b4e4a4b Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Wed, 5 Nov 2025 15:57:11 +0800 Subject: [PATCH] u --- .../base-app/src/app/(tabs)/love.tsx | 38 +++- .../base-app/src/app/auth/register.tsx | 187 +++++++--------- .../src/components/common/FormWrapper.tsx | 210 ------------------ .../src/components/common/ao/Form.tsx | 94 ++++++++ .../src/components/common/ao/FormItem.tsx | 85 +++++++ .../src/components/common/ao/Input.tsx | 76 +++++++ .../src/components/common/ao/index.ts | 10 + .../src/components/custom/StepLayout.tsx | 9 +- .../src/components/ui/input/index.tsx | 114 +++++----- .../base-app/src/hooks/useFormValidation.ts | 97 ++++++++ 10 files changed, 538 insertions(+), 382 deletions(-) delete mode 100644 code/base-project/base-app/src/components/common/FormWrapper.tsx create mode 100644 code/base-project/base-app/src/components/common/ao/Form.tsx create mode 100644 code/base-project/base-app/src/components/common/ao/FormItem.tsx create mode 100644 code/base-project/base-app/src/components/common/ao/Input.tsx create mode 100644 code/base-project/base-app/src/components/common/ao/index.ts create mode 100644 code/base-project/base-app/src/hooks/useFormValidation.ts diff --git a/code/base-project/base-app/src/app/(tabs)/love.tsx b/code/base-project/base-app/src/app/(tabs)/love.tsx index a9294d2a..192bb388 100644 --- a/code/base-project/base-app/src/app/(tabs)/love.tsx +++ b/code/base-project/base-app/src/app/(tabs)/love.tsx @@ -1,9 +1,39 @@ -import { Text, View } from 'react-native' +import React, { useRef, useState } from 'react' +import { Button, ScrollView, Text, View } from 'react-native' + +export default function ScrollToExample() { + const scrollRef = useRef(null) + const [positions, setPositions] = useState>({}) + + const handleLayout = (name: string, event: any) => { + const { y } = event.nativeEvent.layout + setPositions(prev => ({ ...prev, [name]: y })) + } + + const scrollToField = (name: string) => { + const y = positions[name] + if (typeof y === 'number' && scrollRef.current) { + scrollRef.current.scrollTo({ y, animated: true }) + } + } -export default function Love() { return ( - - Love + + - + {/* 其他表单项 */} + {['a', 'b', 'c', 'd', 'e', 'f'].map(field => ( + + handleInputChange(field, text)} + /> + + ))} + + + + + ) } diff --git a/code/base-project/base-app/src/components/common/FormWrapper.tsx b/code/base-project/base-app/src/components/common/FormWrapper.tsx deleted file mode 100644 index 51334bc5..00000000 --- a/code/base-project/base-app/src/components/common/FormWrapper.tsx +++ /dev/null @@ -1,210 +0,0 @@ -// FormWrapper.tsx -import React, { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react' -import { - FormControl, - FormControlError, - FormControlErrorIcon, - FormControlErrorText, - FormControlLabel, - FormControlLabelText, -} from '@/components/ui/form-control' -import { AlertCircleIcon, EyeIcon, EyeOffIcon } from '@/components/ui/icon' -import { InputField, InputIcon, InputSlot, Input as NativeBaseInput } from '@/components/ui/input' - -export interface FormWrapperRef { - validate: () => boolean - getValues: () => Record -} - -// 创建 FormContext 来传递表单数据和验证器注册函数 -interface FormContextType { - form: T - registerValidator: (name: string, validator: () => boolean) => void - unregisterValidator: (name: string) => void -} - -const FormContext = createContext(undefined) - -// ---------------- FormItem ---------------- -export interface FormItemProps = any> { - name: keyof T - label?: string - required?: boolean - rules?: ((val: any) => string | null)[] - children: React.ReactNode -} - -export function FormItem>({ - name, - label, - required, - rules, - children, -}: FormItemProps) { - const formContext = useContext(FormContext) - if (!formContext) { - throw new Error('FormItem must be used within FormWrapper') - } - - const { form, registerValidator, unregisterValidator } = formContext - const [error, setError] = useState('') - - // 从 form 中根据 name 获取对应的值 - const value = form[name] - - const validate = (): boolean => { - let err = '' - - if (required && (value === undefined || value === '' || value === null)) { - err = `${label || String(name)} 不能为空` - } - else if (rules) { - for (const rule of rules) { - const msg = rule(value) - if (msg) { - err = msg - break - } - } - } - setError(err) - return !err - } - - // 注册验证函数到父组件 - useEffect(() => { - registerValidator(String(name), validate) - return () => unregisterValidator(String(name)) - }, [validate, registerValidator, unregisterValidator, name]) - - return ( - - {label && ( - - {label} - - )} - {children} - {error && ( - - - {error} - - )} - - ) -} - -// ---------------- FormInput ---------------- -export interface FormInputProps { - placeholder?: string - leftIcon?: React.ComponentType - isPassword?: boolean - value?: string - onChangeText?: (text: string) => void -} - -export function FormInput({ - placeholder, - leftIcon, - isPassword, - value, - onChangeText, -}: FormInputProps) { - const [showPassword, setShowPassword] = useState(false) - - return ( - - {leftIcon && ( - - - - )} - - {isPassword && ( - setShowPassword(prev => !prev)}> - - - )} - - ) -} - -// ---------------- FormWrapper ---------------- -interface FormWrapperProps> { - children: React.ReactNode - form: T -} - -// 定义组件接口 -interface FormWrapperComponent extends React.ForwardRefExoticComponent< - FormWrapperProps & React.RefAttributes -> { - FormItem: typeof FormItem - Input: typeof FormInput -} - -// 内部组件实现 -function FormWrapperInner>( - props: FormWrapperProps, - ref: React.Ref, -) { - const { children, form } = props - const validators = useRef boolean>>(new Map()) - - const registerValidator = (name: string, validator: () => boolean) => { - validators.current.set(name, validator) - } - - const unregisterValidator = (name: string) => { - validators.current.delete(name) - } - - const validate = (): boolean => { - let isValid = true - - // 然后调用所有验证函数 - validators.current.forEach((validator) => { - if (!validator()) { - isValid = false - } - }) - - return isValid - } - - const getValues = (): Record => form - - useImperativeHandle(ref, () => ({ validate, getValues })) - - const contextValue: FormContextType = { - form, - registerValidator, - unregisterValidator, - } - - return ( - - {children} - - ) -} - -// 创建基础组件 -const BaseFormWrapper = forwardRef(FormWrapperInner) as >( - props: FormWrapperProps & { ref?: React.Ref }, -) => React.JSX.Element - -// 创建带有静态属性的组件 -const FormWrapper = BaseFormWrapper as FormWrapperComponent - -// 添加静态属性 -FormWrapper.FormItem = FormItem -FormWrapper.Input = FormInput - -export default FormWrapper diff --git a/code/base-project/base-app/src/components/common/ao/Form.tsx b/code/base-project/base-app/src/components/common/ao/Form.tsx new file mode 100644 index 00000000..fd31ff15 --- /dev/null +++ b/code/base-project/base-app/src/components/common/ao/Form.tsx @@ -0,0 +1,94 @@ +import type { ScrollView } from 'react-native' +import React, { createContext, forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react' + +export interface FormRef { + validate: () => boolean + scrollToField: (name: string) => void // 新增的滚动方法 +} + +interface FormProps> { + children: React.ReactNode + form: T + scrollRef: React.RefObject +} + +interface FormContextType { + form: T + registerValidator: (name: string, validator: () => boolean) => void + unregisterValidator: (name: string) => void + registerFieldPosition: (name: string, y: number) => void +} + +export const FormContext = createContext(undefined) + +const Form = forwardRef>(({ children, form, scrollRef }, ref) => { + // 使用 Array 来代替 Map + const validatorsRef = useRef boolean }>>([]) // 持有验证器顺序 + const fieldPositions = useRef>(new Map()) // 用来存储每个字段的 Y 坐标 + + const registerValidator = useCallback((name: string, validator: () => boolean) => { + console.log(`注册:${name}`) + validatorsRef.current.push({ name, validator }) + }, []) + + const unregisterValidator = useCallback((name: string) => { + console.log(`移除:${name}`) + validatorsRef.current = validatorsRef.current.filter(validator => validator.name !== name) + }, []) + + const registerFieldPosition = useCallback((name: string, y: number) => { + fieldPositions.current.set(name, y) + }, []) + + const validate = (): boolean => { + let firstErrorField: string | null = null + let isValid = true + + // 按顺序遍历所有的验证器 + for (const { name, validator } of validatorsRef.current) { + const ok = validator() + + if (!ok && !firstErrorField) { + // 如果遇到第一个验证失败的字段,记录下来 + firstErrorField = name + isValid = false + } + } + + if (isValid) + return true + + // 滚动到第一个错误字段 + if (firstErrorField) { + scrollToField(firstErrorField) + } + + return false + } + + const scrollToField = (name: string) => { + if (scrollRef.current) { + const y = fieldPositions.current.get(name) + if (y !== undefined && scrollRef.current) { + scrollRef.current.scrollTo({ y, animated: true }) + } + } + } + + useImperativeHandle(ref, () => ({ validate, scrollToField })) + + return ( + + {children} + + ) +}) + +export default Form diff --git a/code/base-project/base-app/src/components/common/ao/FormItem.tsx b/code/base-project/base-app/src/components/common/ao/FormItem.tsx new file mode 100644 index 00000000..446e1266 --- /dev/null +++ b/code/base-project/base-app/src/components/common/ao/FormItem.tsx @@ -0,0 +1,85 @@ +import React, { useContext, useEffect, useState } from 'react' +import { View } from 'react-native' +import { + FormControl, + FormControlError, + FormControlErrorIcon, + FormControlErrorText, + FormControlLabel, + FormControlLabelText, +} from '@/components/ui/form-control' +import { AlertCircleIcon } from '@/components/ui/icon' +import { FormContext } from './Form' + +interface FormItemProps { + name: keyof T + label?: string + rules?: ((val: any) => string | null)[] + children: React.ReactNode + isRequired?: boolean +} + +function FormItem>({ + name, + label, + rules = [], + children, + isRequired = false, +}: FormItemProps) { + const context = useContext(FormContext) + const [error, setError] = useState('') + + useEffect(() => { + if (!context) + return + + const { registerValidator, unregisterValidator } = context + + const validator = () => { + const value = context.form[name] + + if (isRequired && (value === undefined || value === null || value === '' || value.length === 0)) { + setError(`${String(label || name)} 是必填项`) + return false + } + + for (const rule of rules) { + const msg = rule(value) + if (msg) { + setError(msg) + return false + } + } + + setError('') + return true + } + + registerValidator(String(name), validator) + return () => unregisterValidator(String(name)) + }, [name]) + + const handleLayout = (event: any) => { + const { y } = event.nativeEvent.layout + context?.registerFieldPosition(String(name), y) + } + + return ( + + {label && ( + + {label} + + )} + {children} + {error && ( + + + {error} + + )} + + ) +} + +export default FormItem diff --git a/code/base-project/base-app/src/components/common/ao/Input.tsx b/code/base-project/base-app/src/components/common/ao/Input.tsx new file mode 100644 index 00000000..f5fb2ae4 --- /dev/null +++ b/code/base-project/base-app/src/components/common/ao/Input.tsx @@ -0,0 +1,76 @@ +import type { ComponentType } from 'react' +import type { StyleProp, ViewStyle } from 'react-native' +import { useState } from 'react' +import { EyeIcon, EyeOffIcon } from '@/components/ui/icon' +import { InputField, InputIcon, InputSlot, Input as NativeInput } from '@/components/ui/input' + +export interface AInputProps { + value: string + onChange?: (text: string) => void + isPassword?: boolean + isDisabled?: boolean + isReadOnly?: boolean + isInvalid?: boolean + isHovered?: boolean + isFocused?: boolean + variant?: 'outline' | 'rounded' | 'underlined' + size?: 'sm' | 'md' | 'lg' | 'xl' + placeholder?: string + leftIcon?: ComponentType + className?: string + style?: StyleProp +} + +export default function Input({ + value, + onChange, + isPassword = false, + isDisabled = false, + isReadOnly = false, + isInvalid = false, + isHovered = false, + isFocused = false, + variant = 'outline', + size = 'md', + placeholder, + leftIcon, + className, + style, + ...rest +}: AInputProps) { + const [showPassword, setShowPassword] = useState(false) + + return ( + + {leftIcon + ? ( + + + + ) + : null} + + {isPassword && ( + setShowPassword(prev => !prev)}> + + + )} + + ) +} diff --git a/code/base-project/base-app/src/components/common/ao/index.ts b/code/base-project/base-app/src/components/common/ao/index.ts new file mode 100644 index 00000000..c9ec8334 --- /dev/null +++ b/code/base-project/base-app/src/components/common/ao/index.ts @@ -0,0 +1,10 @@ +import Form, { FormRef } from './Form' +import FormItem from './FormItem' +import Input from './Input' + +export { + Form as AForm, + FormItem as AFormItem, + FormRef as AFormRef, + Input as AInput, +} diff --git a/code/base-project/base-app/src/components/custom/StepLayout.tsx b/code/base-project/base-app/src/components/custom/StepLayout.tsx index 7883d388..57141d5c 100644 --- a/code/base-project/base-app/src/components/custom/StepLayout.tsx +++ b/code/base-project/base-app/src/components/custom/StepLayout.tsx @@ -65,14 +65,7 @@ export default function StepLayout({ steps, children, showHearder = true }: Step )} - - {children} - - + {children} ) diff --git a/code/base-project/base-app/src/components/ui/input/index.tsx b/code/base-project/base-app/src/components/ui/input/index.tsx index 03d46eb2..c1966d11 100644 --- a/code/base-project/base-app/src/components/ui/input/index.tsx +++ b/code/base-project/base-app/src/components/ui/input/index.tsx @@ -1,24 +1,21 @@ -'use client'; -import React from 'react'; -import { createInput } from '@gluestack-ui/core/input/creator'; -import { View, Pressable, TextInput } from 'react-native'; -import { tva } from '@gluestack-ui/utils/nativewind-utils'; -import { - withStyleContext, - useStyleContext, -} from '@gluestack-ui/utils/nativewind-utils'; -import { cssInterop } from 'nativewind'; -import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; -import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator'; +'use client' +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils' +import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator' +import { createInput } from '@gluestack-ui/core/input/creator' +import { tva, useStyleContext, withStyleContext } from '@gluestack-ui/utils/nativewind-utils' -const SCOPE = 'INPUT'; +import { cssInterop } from 'nativewind' +import React from 'react' +import { Pressable, TextInput, View } from 'react-native' + +const SCOPE = 'INPUT' const UIInput = createInput({ Root: withStyleContext(View, SCOPE), Icon: UIIcon, Slot: Pressable, Input: TextInput, -}); +}) cssInterop(PrimitiveIcon, { className: { @@ -31,7 +28,7 @@ cssInterop(PrimitiveIcon, { stroke: true, }, }, -}); +}) const inputStyle = tva({ base: 'border-background-300 flex-row overflow-hidden content-center data-[hover=true]:border-outline-400 data-[focus=true]:border-primary-700 data-[focus=true]:hover:border-primary-700 data-[disabled=true]:opacity-40 data-[disabled=true]:hover:border-background-300 items-center', @@ -55,7 +52,7 @@ const inputStyle = tva({ 'rounded-full border data-[invalid=true]:border-error-700 data-[invalid=true]:hover:border-error-700 data-[invalid=true]:data-[focus=true]:border-error-700 data-[invalid=true]:data-[focus=true]:hover:border-error-700 data-[invalid=true]:data-[disabled=true]:hover:border-error-700 data-[focus=true]:web:ring-1 data-[focus=true]:web:ring-inset data-[focus=true]:web:ring-indicator-primary data-[invalid=true]:web:ring-1 data-[invalid=true]:web:ring-inset data-[invalid=true]:web:ring-indicator-error data-[invalid=true]:data-[focus=true]:hover:web:ring-1 data-[invalid=true]:data-[focus=true]:hover:web:ring-inset data-[invalid=true]:data-[focus=true]:hover:web:ring-indicator-error data-[invalid=true]:data-[disabled=true]:hover:web:ring-1 data-[invalid=true]:data-[disabled=true]:hover:web:ring-inset data-[invalid=true]:data-[disabled=true]:hover:web:ring-indicator-error', }, }, -}); +}) const inputIconStyle = tva({ base: 'justify-center items-center text-typography-400 fill-none', @@ -69,11 +66,11 @@ const inputIconStyle = tva({ 'xl': 'h-6 w-6', }, }, -}); +}) const inputSlotStyle = tva({ base: 'justify-center items-center web:disabled:cursor-not-allowed', -}); +}) const inputFieldStyle = tva({ base: 'flex-1 text-typography-900 py-0 px-3 placeholder:text-typography-500 h-full ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed', @@ -99,15 +96,15 @@ const inputFieldStyle = tva({ '6xl': 'text-6xl', }, }, -}); +}) -type IInputProps = React.ComponentProps & - VariantProps & { className?: string }; +type IInputProps = React.ComponentProps + & VariantProps & { className?: string } const Input = React.forwardRef, IInputProps>( - function Input( + ( { className, variant = 'outline', size = 'md', ...props }, - ref - ) { + ref, + ) => { return ( , IInputProps>( className={inputStyle({ variant, size, class: className })} context={{ variant, size }} /> - ); - } -); + ) + }, +) -type IInputIconProps = React.ComponentProps & - VariantProps & { - className?: string; - height?: number; - width?: number; - }; +type IInputIconProps = React.ComponentProps + & VariantProps & { + className?: string + height?: number + width?: number + } const InputIcon = React.forwardRef< React.ComponentRef, IInputIconProps ->(function InputIcon({ className, size, ...props }, ref) { - const { size: parentSize } = useStyleContext(SCOPE); +>(({ className, size, ...props }, ref) => { + const { size: parentSize } = useStyleContext(SCOPE) if (typeof size === 'number') { return ( @@ -140,10 +137,11 @@ const InputIcon = React.forwardRef< className={inputIconStyle({ class: className })} size={size} /> - ); - } else if ( - (props.height !== undefined || props.width !== undefined) && - size === undefined + ) + } + else if ( + (props.height !== undefined || props.width !== undefined) + && size === undefined ) { return ( - ); + ) } return ( - ); -}); + ) +}) -type IInputSlotProps = React.ComponentProps & - VariantProps & { className?: string }; +type IInputSlotProps = React.ComponentProps + & VariantProps & { className?: string } const InputSlot = React.forwardRef< React.ComponentRef, IInputSlotProps ->(function InputSlot({ className, ...props }, ref) { +>(({ className, ...props }, ref) => { return ( - ); -}); + ) +}) -type IInputFieldProps = React.ComponentProps & - VariantProps & { className?: string }; +type IInputFieldProps = React.ComponentProps + & VariantProps & { className?: string } const InputField = React.forwardRef< React.ComponentRef, IInputFieldProps ->(function InputField({ className, ...props }, ref) { - const { variant: parentVariant, size: parentSize } = useStyleContext(SCOPE); +>(({ className, ...props }, ref) => { + const { variant: parentVariant, size: parentSize } = useStyleContext(SCOPE) return ( - ); -}); + ) +}) -Input.displayName = 'Input'; -InputIcon.displayName = 'InputIcon'; -InputSlot.displayName = 'InputSlot'; -InputField.displayName = 'InputField'; +Input.displayName = 'Input' +InputIcon.displayName = 'InputIcon' +InputSlot.displayName = 'InputSlot' +InputField.displayName = 'InputField' -export { Input, InputField, InputIcon, InputSlot }; +export { Input, InputField, InputIcon, InputSlot } diff --git a/code/base-project/base-app/src/hooks/useFormValidation.ts b/code/base-project/base-app/src/hooks/useFormValidation.ts new file mode 100644 index 00000000..9605d215 --- /dev/null +++ b/code/base-project/base-app/src/hooks/useFormValidation.ts @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' + +// 定义表单字段的验证规则类型 +interface ValidationRule { + required?: boolean + pattern?: RegExp + minLength?: number + maxLength?: number + validator?: (value: string) => boolean // 自定义验证规则 + message?: string // 自定义消息 +} + +// 定义返回的数据结构 +interface FormValidationResult { + [key: string]: { + isValid: boolean + errorMessage: string + } +} + +function useFormValidation(values: Record, rules: Record, autoValidate = false) { + const [validationResults, setValidationResults] = useState({}) + const [isValid, setIsValid] = useState(true) // 用于保存整体表单是否有效 + + // 验证逻辑 + const validate = () => { + const newValidationResults: FormValidationResult = {} + + for (const field in rules) { + const value = values[field] || '' + const fieldRules = rules[field] + let isValid = true + let errorMessage = '' + + // 默认错误消息的函数 + const getDefaultMessage = (type: string) => { + return `${field} 是${type}` + } + + // 验证 required + if (fieldRules.required && !value) { + isValid = false + errorMessage = fieldRules.message || getDefaultMessage('必填项') + } + + // 验证 pattern + if (isValid && fieldRules.pattern && !fieldRules.pattern.test(value)) { + isValid = false + errorMessage = fieldRules.message || '格式不正确' + } + + // 验证 minLength + if (isValid && fieldRules.minLength && value.length < fieldRules.minLength) { + isValid = false + errorMessage = fieldRules.message || `最少输入 ${fieldRules.minLength} 个字符` + } + + // 验证 maxLength + if (isValid && fieldRules.maxLength && value.length > fieldRules.maxLength) { + isValid = false + errorMessage = fieldRules.message || `最多输入 ${fieldRules.maxLength} 个字符` + } + + // 自定义验证 + if (isValid && fieldRules.validator && !fieldRules.validator(value)) { + isValid = false + errorMessage = fieldRules.message || '自定义验证未通过' + } + + newValidationResults[field] = { isValid, errorMessage } + } + + setValidationResults(newValidationResults) + + // 更新整体表单是否有效 + const allValid = !Object.values(newValidationResults).some(result => !result.isValid) + setIsValid(allValid) + + // 返回是否验证通过(返回true或false) + return allValid + } + + // 如果开启自动验证 + useEffect(() => { + if (autoValidate) { + validate() + } + }, [values, autoValidate]) + + return { + validationResults, + isValid, + validate, + } +} + +export default useFormValidation