diff --git a/package.json b/package.json index bbbb0e4..eff92d3 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,8 @@ "radix-ui": "^1.4.3", "react": "19.1.0", "react-dom": "19.1.0", - "react-hook-form": "^7.65.0", "react-virtuoso": "^4.14.1", - "tailwind-merge": "^3.3.1", - "vaul": "^1.1.2", - "zod": "^4.1.12" + "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 073b1c0..7a5608c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,21 +53,12 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) - react-hook-form: - specifier: ^7.65.0 - version: 7.65.0(react@19.1.0) react-virtuoso: specifier: ^4.14.1 version: 4.14.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 - vaul: - specifier: ^1.1.2 - version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - zod: - specifier: ^4.1.12 - version: 4.1.12 devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -2859,12 +2850,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - vaul@1.1.2: - resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2898,9 +2883,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} - snapshots: '@alloc/quick-lru@5.2.0': {} @@ -5872,15 +5854,6 @@ snapshots: dependencies: react: 19.1.0 - vaul@1.1.2(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5931,5 +5904,3 @@ snapshots: yallist@5.0.0: {} yocto-queue@0.1.0: {} - - zod@4.1.12: {} diff --git a/public/component/close.svg b/public/component/close.svg deleted file mode 100644 index a5a0d03..0000000 --- a/public/component/close.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/public/component/currency.svg b/public/component/currency.svg new file mode 100644 index 0000000..b23a5e0 --- /dev/null +++ b/public/component/currency.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/component/vip.svg b/public/component/vip.svg new file mode 100644 index 0000000..a2c9a0a --- /dev/null +++ b/public/component/vip.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/app/(main)/character/[id]/chat/Right/index.tsx b/src/app/(main)/character/[id]/chat/Right/index.tsx index 9f98a58..2052210 100644 --- a/src/app/(main)/character/[id]/chat/Right/index.tsx +++ b/src/app/(main)/character/[id]/chat/Right/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import Form, { FormItem } from '@/components/ui/form'; +import Form, { FormItem } from '@/components/ui/radix-form'; import React from 'react'; import { cn } from '@/lib'; import { Select, Switch, Number, FontSize } from '@/components/ui/inputs'; @@ -11,6 +11,19 @@ import VoiceActorSelectDialog from '@/components/feature/VoiceActorSelectDialog' import BubbleSelectDialog from '@/components/feature/BubbleSelectDialog'; import IconFont from '@/components/ui/iconFont'; +// 表单数据类型定义 +type SettingFormValues = { + model?: string; + short_text?: boolean; + voiceActor?: string; + dialogueOnly?: boolean; + max_tokens?: number; + fontSize?: number; + chat_mode?: string; + chat_bubble?: string; + background?: string; +}; + const Title: React.FC< { text?: string; @@ -29,25 +42,6 @@ const Title: React.FC< }; const SettingForm = React.memo(() => { - const options = [ - { - label: 'Model 1', - value: 'model1', - }, - { - label: 'Model 2', - value: 'model2', - }, - { - label: 'Model 3', - value: 'model3', - }, - { - label: 'Model 4', - value: 'model4', - }, - ]; - return (
{ Setting
-
+ + onChange={(values) => { + console.log(values); + }} + > - <FormItem - name="name" + <FormItem<SettingFormValues> + name="model" render={({ value, onChange }) => ( <ModelSelectDialog value={value} onChange={onChange} /> )} /> - <FormItem - name="name1" + <FormItem<SettingFormValues> + name="short_text" render={({ value, onChange }) => ( <Switch icon={'/character/model_long_text.svg'} @@ -79,12 +77,14 @@ const SettingForm = React.memo(() => { - <FormItem - name="name12" - render={({ value, onChange }) => <VoiceActorSelectDialog />} + <FormItem<SettingFormValues> + name="voiceActor" + render={({ value, onChange }) => ( + <VoiceActorSelectDialog value={value} onChange={onChange} /> + )} /> - <FormItem - name="name12" + <FormItem<SettingFormValues> + name="dialogueOnly" render={({ value, onChange }) => ( <Switch icon={'/character/play_dialogue_only.svg'} @@ -97,8 +97,8 @@ const SettingForm = React.memo(() => { - <FormItem - name="name13" + <FormItem<SettingFormValues> + name="max_tokens" render={({ value, onChange }) => ( <Number value={value} onChange={onChange} /> )} @@ -106,16 +106,18 @@ const SettingForm = React.memo(() => { - <FormItem + <FormItem<SettingFormValues> name="fontSize" - render={() => { - return <FontSize />; + render={({ value, onChange }) => { + return <FontSize value={value} onChange={onChange} />; }} /> - <FormItem - name="name12" + <FormItem<SettingFormValues> + name="chat_mode" render={({ value, onChange }) => ( <Select + value={value} + onChange={onChange} placeholder="Chat Mode" icon={'/character/chat_mode.svg'} options={[ @@ -125,14 +127,14 @@ const SettingForm = React.memo(() => { /> )} /> - <FormItem - name="name12" - render={({ value, onChange }) => <BubbleSelectDialog />} - /> + {/* TODO: BubbleSelectDialog 需要支持受控模式 */} + <div> + <BubbleSelectDialog /> + </div> - <FormItem + <FormItem<SettingFormValues> name="background" render={({ value, onChange }) => ( <Background value={value} onChange={onChange} /> diff --git a/src/app/(main)/character/[id]/chat/page.tsx b/src/app/(main)/character/[id]/chat/page.tsx index ab51d0e..e6dc791 100644 --- a/src/app/(main)/character/[id]/chat/page.tsx +++ b/src/app/(main)/character/[id]/chat/page.tsx @@ -12,7 +12,6 @@ import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common'; export default function CharacterChat() { const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom); - console.log('settingOpen', settingOpen); return ( <div diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3248b4c..e940ab1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -30,8 +30,9 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <Script - src="//at.alicdn.com/t/c/font_5054282_ibxmours7r.js" + src="//at.alicdn.com/t/c/font_5054282_5o7sf0csg4w.js" strategy="afterInteractive" + async /> <GlobalContainer>{children}</GlobalContainer> </body> diff --git a/src/components/feature/BubbleSelectDialog.tsx b/src/components/feature/BubbleSelectDialog.tsx index 7140071..2f4fcbb 100644 --- a/src/components/feature/BubbleSelectDialog.tsx +++ b/src/components/feature/BubbleSelectDialog.tsx @@ -1,18 +1,148 @@ 'use client'; +import { cn } from '@/lib'; import { Select } from '../ui/inputs'; import Modal from '../ui/modal'; +import Image from 'next/image'; +import IconFont from '../ui/iconFont'; type BubbleSelectDialogProps = {}; export default function BubbleSelectDialog(props: BubbleSelectDialogProps) { + const isVip = true; + const value = '1'; + + const options = [ + { + disabled: false, + value: '1', + label: 'Bubble 1', + type: 'free', + }, + { + value: '2', + label: 'Bubble 2', + type: 'vip', + }, + { + value: '3', + label: 'Bubble 3', + type: 'purchase', + unlocked: false, + price: 10, + }, + { + value: '4', + label: 'Bubble 4', + type: 'purchase', + unlocked: true, + price: 10, + }, + ]; + + const getBg = (item: any) => { + if (item.type === 'free') { + return 'bg-[rgba(43,34,70,1)]'; + } + // vip 或 需要购买 + if (item.active) { + return 'bg-[rgba(49,33,92,1)]'; + } + return 'bg-[rgba(23,15,44,1)] hover:bg-[rgba(49,33,92,1)]'; + }; + + const handlePurchase = () => {}; + return ( <Modal title="Chat Bubble" + classNames={{ + content: 'w-200', + }} trigger={ <Select.View icon={'/character/chat_bubble.svg'} text="Chat Bubble" /> } > - BubbleSelectDialog + <div className="grid grid-cols-5 gap-5"> + {/* items */} + {options?.map((i) => { + const checked = value === i.value; + return ( + <div + key={i.value} + className={cn( + 'group relative flex h-33 cursor-pointer flex-col rounded-[10px]', + `${getBg(i)}` + )} + > + {/* header */} + <div + className={cn( + 'm-2.5 flex h-6 items-center', + i.type === 'free' ? 'justify-end' : 'justify-between' + )} + > + {i.type === 'vip' && ( + <Image + src={'/component/vip.svg'} + width={24} + height={24} + alt="currency" + /> + )} + {i.type === 'purchase' && ( + <span className="inline-flex items-center gap-1"> + <Image + src={'/component/currency.svg'} + width={24} + height={24} + alt="currency" + /> + <span className="text-lg font-bold">{i.price}</span> + </span> + )} + {i.unlocked ? ( + <div className="text-white/50 group-hover:text-white"> + <IconFont type="icon-lock" size={18} /> + </div> + ) : ( + <div className="flex-center h-4.5 w-4.5 rounded-full bg-black"> + {checked && ( + <div className="h-3 w-3 rounded-full bg-green-600"></div> + )} + </div> + )} + </div> + + {/* content */} + <div className="flex h-full flex-1 flex-col items-center justify-between pb-2.5"> + <div + className="flex-center h-9.5 w-12.5 text-sm text-black" + style={{ + backgroundImage: `url(/bubble/default.png)`, + backgroundSize: 'contain', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + }} + > + Hi + </div> + <div className="font-blod">{'NAME 1'}</div> + </div> + + <div + onClick={() => handlePurchase()} + style={{ + backgroundImage: + 'linear-gradient(92.76deg, rgba(166, 83, 255, 1) 0%, rgba(0, 101, 255, 1) 108.06%, rgba(0, 157, 255, 1) 135.08%)', + }} + className="absolute bottom-0 hidden h-7.5 w-full items-center justify-center rounded-full group-hover:flex" + > + GET + </div> + </div> + ); + })} + </div> </Modal> ); } diff --git a/src/components/feature/ModelSelectDialog.tsx b/src/components/feature/ModelSelectDialog.tsx index 1c42512..215579c 100644 --- a/src/components/feature/ModelSelectDialog.tsx +++ b/src/components/feature/ModelSelectDialog.tsx @@ -1,7 +1,10 @@ 'use client'; import { Modal, Select } from '@/components'; +import { cn } from '@/lib'; import { useControllableValue } from 'ahooks'; +import Image from 'next/image'; + type ModelSelectDialogProps = { value?: string; onChange?: (value: string) => void; @@ -13,6 +16,7 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) { defaultValuePropName: 'defaultValue', trigger: 'onChange', }); + console.log('ModelSelectDialog', value); const options = [ { label: 'Model 1', value: 'model1' }, @@ -20,6 +24,8 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) { { label: 'Model 3', value: 'model3' }, ]; + const isActive = true; + return ( <Modal title="REPLY" @@ -27,10 +33,43 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) { <Select.View icon={'/character/model_switch.svg'} text="Model 1" /> } > - <div className="flex flex-col pr-2.5"> - {options?.map((i) => { - return <div key={i.value}>{i.label}</div>; - })} + <div className="flex flex-col"> + {/* items */} + <div + className={cn( + 'flex items-center justify-between rounded-[10px] p-4', + isActive ? 'bg-white/10' : 'hover:bg-white/5', + 'cursor-pointer' + )} + > + <div className="flex flex-1 items-center gap-3"> + <Image + className="flex-shrink-0 rounded-full object-cover" + src={'/avator.png'} + alt="avator" + width={50} + height={50} + /> + <div> + <div>Max-0618</div> + <div className="text-xs text-white/60"> + Previous-generation large model + </div> + <div + style={{ color: 'rgba(0, 102, 255, 1)' }} + className="text-sm" + > + 0.3 points / 1K tokens{' '} + <span className="text-green-600">(Recommended)</span> + </div> + </div> + </div> + <div className="flex-center h-4.5 w-4.5 rounded-full bg-black"> + {isActive && ( + <div className="h-3 w-3 rounded-full bg-green-600"></div> + )} + </div> + </div> </div> </Modal> ); diff --git a/src/components/feature/VoiceActorSelectDialog.tsx b/src/components/feature/VoiceActorSelectDialog.tsx index 7d81f18..8916ac1 100644 --- a/src/components/feature/VoiceActorSelectDialog.tsx +++ b/src/components/feature/VoiceActorSelectDialog.tsx @@ -4,10 +4,15 @@ import { Select } from '../ui/inputs'; import Modal from '../ui/modal'; import Image from 'next/image'; -type VoiceActorSelectDialogProps = {}; +type VoiceActorSelectDialogProps = { + value?: string; + onChange?: (value: string) => void; +}; export default function VoiceActorSelectDialog( props: VoiceActorSelectDialogProps ) { + const { value, onChange } = props; + console.log('VoiceActorSelectDialog', value); const options = [ { label: 'Voice Actor 1', value: 'voiceActor1', gender: 'male' }, { label: 'Voice Actor 2', value: 'voiceActor2', gender: 'female' }, diff --git a/src/components/index.tsx b/src/components/index.tsx index e6bacc4..da2727b 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -1,7 +1,6 @@ export { default as Modal } from './ui/modal'; export { default as Select } from './ui/inputs/select'; export { default as Rate } from './ui/rate'; -export { default as Form } from './ui/form'; export { default as Icon } from './ui/icon'; export { default as Drawer } from './ui/drawer'; export { default as VirtualGrid } from './ui/VirtualGrid'; diff --git a/src/components/ui/form/index.tsx b/src/components/ui/form/index.tsx deleted file mode 100644 index d4b2c66..0000000 --- a/src/components/ui/form/index.tsx +++ /dev/null @@ -1,280 +0,0 @@ -'use client'; -import React from 'react'; -import { - FormProvider, - useForm, - useFormContext, - Controller, - useWatch, -} from 'react-hook-form'; -import type { - FieldValues, - SubmitErrorHandler, - Path, - PathValue, -} from 'react-hook-form'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; - -// 增强的类型定义 -type FormProps<TValues extends FieldValues = FieldValues> = - React.PropsWithChildren<{ - initialValues?: Partial<TValues>; - onSubmit?: (data: TValues) => void | Promise<void>; - onChange?: (data: Partial<TValues>) => void; - /** 使用 zod 校验规则时传入 */ - schema?: z.ZodType<TValues>; - /** 表单提交时的加载状态 */ - loading?: boolean; - /** 是否禁用整个表单 */ - disabled?: boolean; - }>; - -// 简化的 FormAction 类型 - 基于 react-hook-form 的 methods + 自定义 validateFields -type FormAction<TValues extends FieldValues = FieldValues> = ReturnType< - typeof useForm<TValues> -> & { - /** 增强的校验方法,返回校验通过的数据或 null */ - validateFields: () => Promise<TValues | null>; -}; - -const Form = <TValues extends FieldValues = FieldValues>( - props: FormProps<TValues>, - ref: React.Ref<FormAction<TValues>> -) => { - const { - initialValues, - onSubmit = () => {}, - onChange, - schema, - loading = false, - disabled = false, - children, - } = props; - - const methods = useForm({ - defaultValues: initialValues as any, - resolver: schema ? zodResolver(schema as any) : undefined, - mode: 'onSubmit', - reValidateMode: 'onSubmit', - disabled, - }); - - // 提交状态管理 - const [isSubmitting, setIsSubmitting] = React.useState(false); - - React.useImperativeHandle( - ref, - (): FormAction<TValues> => ({ - ...methods, - validateFields: async () => { - const isValid = await methods.trigger(); - if (isValid) return methods.getValues() as TValues; - return null; - }, - }), - [methods] - ); - - // 值变更回调 - React.useEffect(() => { - if (!onChange) return; - const subscription = methods.watch((data) => onChange(data as TValues)); - return () => subscription.unsubscribe(); - }, [methods, onChange]); - - // 增强的提交处理 - const handleSubmit = React.useCallback( - async (data: TValues) => { - if (loading || isSubmitting) return; - - try { - setIsSubmitting(true); - await onSubmit(data); - } catch (error) { - console.error('Form submission error:', error); - // 可以在这里添加全局错误处理 - } finally { - setIsSubmitting(false); - } - }, - [onSubmit, loading, isSubmitting] - ); - - const handleInvalid: SubmitErrorHandler<TValues> = React.useCallback( - (errors) => { - console.warn('Form validation failed:', errors); - // 可以在这里添加全局错误处理,比如滚动到第一个错误字段 - }, - [] - ); - - return ( - <FormProvider {...methods}> - <form onSubmit={methods.handleSubmit(handleSubmit as any, handleInvalid)}> - <fieldset - disabled={disabled || loading || isSubmitting} - style={{ border: 'none', padding: 0, margin: 0 }} - > - {children} - </fieldset> - </form> - </FormProvider> - ); -}; - -// 使用 forwardRef 包装以支持泛型 -const FormWithRef = React.forwardRef(Form) as < - TValues extends FieldValues = FieldValues, ->( - props: FormProps<TValues> & { ref?: React.Ref<FormAction<TValues>> } -) => React.ReactElement; - -// 增强的 FormItemProps -type FormItemProps<TValues extends FieldValues = FieldValues> = { - /** 校验触发时机 */ - trigger?: 'onChange' | 'onBlur'; - /** 字段名,支持嵌套路径如 'user.name' */ - name: Path<TValues>; - /** 渲染函数 */ - render: (props: { - value: PathValue<TValues, Path<TValues>>; - onChange: (value: PathValue<TValues, Path<TValues>>) => void; - onBlur: () => void; - error: string | undefined; - }) => React.ReactNode; - /** 是否禁用该字段 */ - disabled?: boolean; -}; - -function getByPath(obj: any, path: string): any { - const segments = path.split('.'); - let current = obj; - for (const key of segments) { - if (current == null) return undefined; - current = current[key]; - } - return current; -} - -// 增强的 FormItem 组件 -export const FormItem = <TValues extends FieldValues = FieldValues>( - props: FormItemProps<TValues> -) => { - const { name, render, trigger = 'onChange', disabled = false } = props; - const { - control, - formState, - trigger: triggerValidation, - } = useFormContext<TValues>(); - - return ( - <Controller - name={name} - control={control} - disabled={disabled} - render={({ field, fieldState }) => { - const errorFromField = fieldState.error?.message as string | undefined; - const errorFromForm = getByPath(formState.errors, name)?.message as - | string - | undefined; - const error = errorFromField || errorFromForm; - - // 变更处理函数 - const handleChange = (value: PathValue<TValues, Path<TValues>>) => { - field.onChange(value); - if (trigger === 'onChange') { - triggerValidation(name); - } - }; - - const handleBlur = () => { - field.onBlur(); - if (trigger === 'onBlur') { - triggerValidation(name); - } - }; - - return ( - <> - {render({ - value: field.value, - onChange: handleChange, - onBlur: handleBlur, - error, - })} - </> - ); - }} - /> - ); -}; - -// 增强的 FormErrorProps -type FormErrorProps<TValues extends FieldValues = FieldValues> = { - /** 要监听的字段名数组 */ - name: Path<TValues>[]; - /** 渲染函数 */ - render: (props: { errors: Record<string, string> }) => React.ReactNode; -}; -// 增强的 FormError 组件 -export const FormError = <TValues extends FieldValues = FieldValues>( - props: FormErrorProps<TValues> -) => { - const { name, render } = props; - const { formState } = useFormContext<TValues>(); - // 收集指定字段的错误信息 - const errors: Record<string, string> = {}; - let hasError = false; - name.forEach((fieldName) => { - const error = getByPath(formState.errors, fieldName); - if (error?.message) { - errors[fieldName as string] = error.message; - hasError = true; - } - }); - // 只有存在错误时才渲染 - if (!hasError) { - return null; - } - return <>{render({ errors })}</>; -}; - -// 简化的 FormDependencyProps -type FormDependencyProps<TValues extends FieldValues = FieldValues> = { - /** 依赖的字段名数组 */ - dependencies: Path<TValues>[]; - /** 渲染函数 */ - render: (props: { - values: Record<string, PathValue<TValues, Path<TValues>>>; - }) => React.ReactNode; -}; - -// 增强的 FormDependency 组件 -export const FormDependency = <TValues extends FieldValues = FieldValues>( - props: FormDependencyProps<TValues> -) => { - const { dependencies, render } = props; - const { control } = useFormContext<TValues>(); - - // 使用 useWatch 监听多个字段 - const watchedValues = useWatch({ - control, - name: dependencies as readonly Path<TValues>[], - }); - - // 将监听到的值组合成对象 - const values = React.useMemo(() => { - const result: Record<string, any> = {}; - dependencies.forEach((dep, index) => { - result[dep as string] = Array.isArray(watchedValues) - ? watchedValues[index] - : watchedValues; - }); - return result; - }, [dependencies, watchedValues]); - - return <>{render({ values })}</>; -}; - -export default FormWithRef; diff --git a/src/components/ui/inputs/index.tsx b/src/components/ui/inputs/index.tsx index e97eb46..913a2da 100644 --- a/src/components/ui/inputs/index.tsx +++ b/src/components/ui/inputs/index.tsx @@ -6,7 +6,7 @@ export const InputLeft = (props: { icon?: any; text?: string }) => { const { icon, text } = props; return ( <div className="flex items-center gap-2.5"> - <Image src={icon} width={20} height={20} alt="icon" /> + <Image src={icon || '/vite.svg'} width={20} height={20} alt="icon" /> <span className="text-text-color/80 font-bold">{text}</span> </div> ); diff --git a/src/components/ui/inputs/switch.tsx b/src/components/ui/inputs/switch.tsx index 491e802..79d573a 100644 --- a/src/components/ui/inputs/switch.tsx +++ b/src/components/ui/inputs/switch.tsx @@ -14,8 +14,15 @@ type SwitchProps = { } & React.HTMLAttributes<HTMLDivElement>; export default function Switch(props: SwitchProps) { - const { icon, text, ...rest } = props; + const { + icon, + text, + onChange: onChangeProps, + value: valueProps, + ...rest + } = props; const [value, onChange] = useControllableValue(props); + return ( <div {...rest} className={cn('input-view justify-between', rest.className)}> <InputLeft icon={icon} text={text} /> diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/index.tsx index d8389cc..c318f44 100644 --- a/src/components/ui/modal/index.tsx +++ b/src/components/ui/modal/index.tsx @@ -3,8 +3,8 @@ import { useControllableValue } from 'ahooks'; import { Dialog as DialogPrimitive } from 'radix-ui'; import './index.css'; -import Image from 'next/image'; import { cn } from '@/lib'; +import IconFont from '../iconFont'; type ModalProps = { open?: boolean; @@ -41,16 +41,14 @@ export default function Modal(props: ModalProps) { className={cn('dialog-content w-125', classNames?.content)} > <div className="mb-7 flex justify-between"> - <DialogPrimitive.Title>{title}</DialogPrimitive.Title> - <DialogPrimitive.Close> - <Image - onClick={() => setOpen(false)} - src="/component/close.svg" - alt="close" - width={30} - className="translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80" - height={30} - /> + <DialogPrimitive.Title className="text-xl font-black"> + {title} + </DialogPrimitive.Title> + <DialogPrimitive.Close + onClick={() => setOpen(false)} + className="translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80" + > + <IconFont type="icon-guanbi" size={30} /> </DialogPrimitive.Close> </div> {children} diff --git a/src/components/ui/radix-form/Form.tsx b/src/components/ui/radix-form/Form.tsx new file mode 100644 index 0000000..198a197 --- /dev/null +++ b/src/components/ui/radix-form/Form.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { Form as RadixForm } from 'radix-ui'; +import React from 'react'; +import { FormContext } from './context'; +import { getByPath, setByPath, validateRule } from './utils'; +import type { FormProps, FormAction, FormContextValue, Rules } from './types'; + +const FormComponent = < + TValues extends Record<string, any> = Record<string, any>, +>( + props: FormProps<TValues>, + ref: React.Ref<FormAction<TValues>> +) => { + const { + initialValues, + onSubmit = () => {}, + onChange, + combineValidate, + loading = false, + disabled = false, + children, + } = props; + + // 使用 ref 存储表单值,避免触发全局重新渲染 + const valuesRef = React.useRef<TValues>((initialValues || {}) as TValues); + + // 使用 ref 存储表单错误 + const errorsRef = React.useRef<Record<string, string>>({}); + + // 用于强制更新错误的 state(只有错误变化时才触发重新渲染) + const [errorsVersion, setErrorsVersion] = React.useState(0); + + // 字段订阅器(用于性能优化) + const valueSubscribersRef = React.useRef<Record<string, Set<() => void>>>({}); + const errorSubscribersRef = React.useRef<Record<string, Set<() => void>>>({}); + + // 提交状态 + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 表单引用(用于原生表单提交) + const formRef = React.useRef<HTMLFormElement>(null); + + // 字段规则映射(由 FormItem 注册) + const fieldRulesRef = React.useRef<Record<string, Rules>>({}); + + // 注册字段规则(供 FormItem 使用) + const registerFieldRules = React.useCallback((name: string, rules: Rules) => { + fieldRulesRef.current[name] = rules; + }, []); + + // 订阅字段值变化 + const subscribe = React.useCallback((name: string, callback: () => void) => { + if (!valueSubscribersRef.current[name]) { + valueSubscribersRef.current[name] = new Set(); + } + valueSubscribersRef.current[name].add(callback); + + // 返回取消订阅函数 + return () => { + valueSubscribersRef.current[name]?.delete(callback); + }; + }, []); + + // 通知字段值订阅者 + const notifyValueSubscribers = React.useCallback((name: string) => { + valueSubscribersRef.current[name]?.forEach((callback) => callback()); + }, []); + + // 通知字段错误订阅者 + const notifyErrorSubscribers = React.useCallback((name: string) => { + errorSubscribersRef.current[name]?.forEach((callback) => callback()); + }, []); + + // 订阅字段错误变化 + const subscribeError = React.useCallback( + (name: string, callback: () => void) => { + if (!errorSubscribersRef.current[name]) { + errorSubscribersRef.current[name] = new Set(); + } + errorSubscribersRef.current[name].add(callback); + + return () => { + errorSubscribersRef.current[name]?.delete(callback); + }; + }, + [] + ); + + // 组合订阅函数(同时订阅值和错误变化),使用 useCallback 确保引用稳定 + const subscribeField = React.useCallback( + (name: string, callback: () => void) => { + // 同时订阅值和错误的变化 + const unsubscribeValue = subscribe(name, callback); + const unsubscribeError = subscribeError(name, callback); + return () => { + unsubscribeValue(); + unsubscribeError(); + }; + }, + [subscribe, subscribeError] + ); + + // 设置字段值 + const setFieldValue = React.useCallback( + (name: string, value: any) => { + setByPath(valuesRef.current, name, value); + + // 通知订阅该字段的组件 + notifyValueSubscribers(name); + + // 触发 onChange 回调 + if (onChange) { + onChange({ ...valuesRef.current }); + } + }, + [onChange, notifyValueSubscribers] + ); + + // 获取字段值(从 ref 读取,不触发重新渲染) + const getFieldValue = React.useCallback((name: string) => { + return getByPath(valuesRef.current, name); + }, []); + + // 设置字段错误 + const setFieldError = React.useCallback( + (name: string, error: string | undefined) => { + if (error) { + errorsRef.current[name] = error; + } else { + delete errorsRef.current[name]; + } + + // 更新错误版本号,触发重新渲染 + setErrorsVersion((prev) => prev + 1); + + // 通知订阅该字段错误的组件 + notifyErrorSubscribers(name); + }, + [notifyErrorSubscribers] + ); + + // 获取字段错误 + const getFieldError = React.useCallback((name: string) => { + return errorsRef.current[name]; + }, []); + + // 校验单个字段 + const validateField = React.useCallback( + async (name: string): Promise<boolean> => { + const rules = fieldRulesRef.current[name]; + if (!rules || rules.length === 0) { + setFieldError(name, undefined); + return true; + } + + const value = getFieldValue(name); + const currentValues = { ...valuesRef.current }; + + // 依次校验每个规则 + for (const rule of rules) { + const error = await validateRule(rule, value, currentValues); + if (error) { + setFieldError(name, error); + return false; + } + } + + // 所有规则都通过 + setFieldError(name, undefined); + return true; + }, + [getFieldValue, setFieldError] + ); + + // 校验所有字段 + const validateAll = React.useCallback(async (): Promise<boolean> => { + const fieldNames = Object.keys(fieldRulesRef.current); + const results = await Promise.all( + fieldNames.map((name) => validateField(name)) + ); + + // 如果所有字段校验都通过,再进行组合校验 + if (results.every((result) => result === true) && combineValidate) { + const combineResult = await combineValidate(valuesRef.current); + if (combineResult) { + // 组合校验失败,设置错误到指定字段 + setFieldError(combineResult.key, combineResult.message); + return false; + } + } + + return results.every((result) => result === true); + }, [validateField, combineValidate, setFieldError]); + + // 获取所有表单值 + const getValues = React.useCallback(() => { + return { ...valuesRef.current }; + }, []); + + // 重置表单 + const reset = React.useCallback( + (newValues?: Partial<TValues>) => { + valuesRef.current = (newValues || initialValues || {}) as TValues; + errorsRef.current = {}; + setErrorsVersion((prev) => prev + 1); + + // 通知所有订阅者 + Object.keys(valueSubscribersRef.current).forEach((name) => { + notifyValueSubscribers(name); + }); + Object.keys(errorSubscribersRef.current).forEach((name) => { + notifyErrorSubscribers(name); + }); + }, + [initialValues, notifyValueSubscribers, notifyErrorSubscribers] + ); + + // 暴露 ref 方法 + React.useImperativeHandle( + ref, + (): FormAction<TValues> => ({ + validateFields: async () => { + const isValid = await validateAll(); + return isValid ? getValues() : null; + }, + getValues, + reset, + setFieldValue, + setFieldError, + }), + [validateAll, getValues, reset, setFieldValue, setFieldError] + ); + + // 表单提交处理 + const handleSubmit = React.useCallback( + async (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + + if (loading || isSubmitting) return; + + // 校验所有字段(包括组合校验) + const isValid = await validateAll(); + if (!isValid) { + return; + } + + try { + setIsSubmitting(true); + await onSubmit(valuesRef.current); + } catch (error) { + console.error('Form submission error:', error); + } finally { + setIsSubmitting(false); + } + }, + [validateAll, onSubmit, loading, isSubmitting] + ); + + // Context 值 + const contextValue = React.useMemo<FormContextValue<TValues>>( + () => ({ + valuesRef, + errorsRef, + setFieldValue, + getFieldValue, + setFieldError, + getFieldError, + validateField, + validateAll, + getValues, + reset, + disabled: disabled || loading || isSubmitting, + registerFieldRules, + subscribe: subscribeField, + }), + [ + setFieldValue, + getFieldValue, + setFieldError, + getFieldError, + validateField, + validateAll, + getValues, + reset, + disabled, + loading, + isSubmitting, + registerFieldRules, + subscribeField, + // 移除 errorsVersion,避免错误变化时导致所有 FormItem 重新渲染 + // 错误变化已通过订阅机制通知到对应的 FormItem + ] + ); + + return ( + <FormContext.Provider value={contextValue}> + <RadixForm.Root ref={formRef} onSubmit={handleSubmit}> + <fieldset + disabled={disabled || loading || isSubmitting} + style={{ border: 'none', padding: 0, margin: 0 }} + > + {children} + </fieldset> + </RadixForm.Root> + </FormContext.Provider> + ); +}; + +export const Form = React.forwardRef(FormComponent) as < + TValues extends Record<string, any> = Record<string, any>, +>( + props: FormProps<TValues> & { ref?: React.Ref<FormAction<TValues>> } +) => React.ReactElement; diff --git a/src/components/ui/radix-form/FormDependency.tsx b/src/components/ui/radix-form/FormDependency.tsx new file mode 100644 index 0000000..604df64 --- /dev/null +++ b/src/components/ui/radix-form/FormDependency.tsx @@ -0,0 +1,54 @@ +'use client'; + +import React from 'react'; +import { useFormContext } from './context'; +import type { FormDependencyProps } from './types'; + +export const FormDependency = < + TValues extends Record<string, any> = Record<string, any>, +>( + props: FormDependencyProps<TValues> +) => { + const { dependencies, render } = props; + const { getFieldValue, subscribe } = useFormContext<TValues>(); + + // 使用 useState 管理依赖字段的值 + const [dependencyValues, setDependencyValues] = React.useState< + Record<string, any> + >(() => { + const result: Record<string, any> = {}; + dependencies.forEach((dep) => { + result[dep] = getFieldValue(dep); + }); + return result; + }); + + // 使用 useEffect 订阅依赖字段的变化 + React.useEffect(() => { + const unsubscribes = dependencies.map((dep) => + subscribe(dep, () => { + // 当依赖字段变化时,更新状态 + setDependencyValues((prev) => { + const newValues: Record<string, any> = { ...prev }; + let hasChanged = false; + + dependencies.forEach((depName) => { + const newValue = getFieldValue(depName); + if (newValues[depName] !== newValue) { + newValues[depName] = newValue; + hasChanged = true; + } + }); + + return hasChanged ? newValues : prev; + }); + }) + ); + + return () => { + unsubscribes.forEach((unsub) => unsub()); + }; + }, [dependencies, subscribe, getFieldValue]); + + return <>{render({ values: dependencyValues })}</>; +}; diff --git a/src/components/ui/radix-form/FormError.tsx b/src/components/ui/radix-form/FormError.tsx new file mode 100644 index 0000000..98c0e17 --- /dev/null +++ b/src/components/ui/radix-form/FormError.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React from 'react'; +import { useFormContext } from './context'; +import type { FormErrorProps } from './types'; + +export const FormError = < + TValues extends Record<string, any> = Record<string, any>, +>( + props: FormErrorProps<TValues> +) => { + const { name, render } = props; + const { getFieldError, subscribe } = useFormContext<TValues>(); + + // 使用 useState 管理字段错误状态 + const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>( + () => { + const errors: Record<string, string> = {}; + name.forEach((fieldName) => { + const error = getFieldError(fieldName); + if (error) { + errors[fieldName] = error; + } + }); + return errors; + } + ); + + // 使用 useEffect 订阅字段错误的变化 + React.useEffect(() => { + const unsubscribes = name.map((fieldName) => + subscribe(fieldName, () => { + // 当字段错误变化时,更新状态 + setFieldErrors((prev) => { + const newErrors: Record<string, string> = { ...prev }; + let hasChanged = false; + + name.forEach((field) => { + const error = getFieldError(field); + if (error) { + if (newErrors[field] !== error) { + newErrors[field] = error; + hasChanged = true; + } + } else { + if (field in newErrors) { + delete newErrors[field]; + hasChanged = true; + } + } + }); + + return hasChanged ? newErrors : prev; + }); + }) + ); + + return () => { + unsubscribes.forEach((unsub) => unsub()); + }; + }, [name, subscribe, getFieldError]); + + // 检查是否有错误 + const hasError = Object.keys(fieldErrors).length > 0; + + // 只有存在错误时才渲染 + if (!hasError) { + return null; + } + + return <>{render({ errors: fieldErrors })}</>; +}; diff --git a/src/components/ui/radix-form/FormItem.tsx b/src/components/ui/radix-form/FormItem.tsx new file mode 100644 index 0000000..25134c9 --- /dev/null +++ b/src/components/ui/radix-form/FormItem.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React from 'react'; +import { useFormContext } from './context'; +import type { FormItemProps } from './types'; + +export const FormItem = < + TValues extends Record<string, any> = Record<string, any>, +>( + props: FormItemProps<TValues> +) => { + const { + name, + render, + trigger: triggerType = 'onChange', + disabled = false, + rules = [], + } = props; + + const { + getFieldValue, + setFieldValue, + getFieldError, + validateField, + registerFieldRules, + subscribe, + } = useFormContext<TValues>(); + + // 使用 useState 管理字段值和错误状态 + const [fieldValue, setLocalFieldValue] = React.useState(() => + getFieldValue(name) + ); + const [error, setLocalError] = React.useState(() => getFieldError(name)); + + // 使用 ref 存储最新的函数引用,避免订阅回调中的闭包问题 + const getFieldValueRef = React.useRef(getFieldValue); + const getFieldErrorRef = React.useRef(getFieldError); + + React.useEffect(() => { + getFieldValueRef.current = getFieldValue; + getFieldErrorRef.current = getFieldError; + }, [getFieldValue, getFieldError]); + + // 使用 useEffect 订阅字段变化 + React.useEffect(() => { + const unsubscribe = subscribe(name, () => { + // 当字段变化时,更新状态(使用 ref 获取最新值) + const newValue = getFieldValueRef.current(name); + const newError = getFieldErrorRef.current(name); + + setLocalFieldValue((prev: any) => (prev !== newValue ? newValue : prev)); + setLocalError((prev: string | undefined) => + prev !== newError ? newError : prev + ); + }); + + return unsubscribe; + }, [name, subscribe]); + + // 注册字段规则 + React.useEffect(() => { + if (rules.length > 0) { + registerFieldRules(name, rules); + } + }, [name, rules, registerFieldRules]); + + // onChange 处理 + const handleChange = React.useCallback( + (value: any) => { + setFieldValue(name, value); + + // 根据 trigger 类型决定是否立即校验 + if (triggerType === 'onChange') { + validateField(name); + } + }, + [name, setFieldValue, triggerType, validateField] + ); + + // onBlur 处理 + const handleBlur = React.useCallback(() => { + // 如果设置为 onBlur 校验,失去焦点时触发校验 + if (triggerType === 'onBlur') { + validateField(name); + } + }, [triggerType, name, validateField]); + + return ( + <> + {render( + { + value: fieldValue, + onChange: handleChange, + onBlur: handleBlur, + }, + error + )} + </> + ); +}; diff --git a/src/components/ui/radix-form/context.tsx b/src/components/ui/radix-form/context.tsx new file mode 100644 index 0000000..90a3956 --- /dev/null +++ b/src/components/ui/radix-form/context.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React from 'react'; +import type { FormContextValue } from './types'; + +// ==================== Form Context ==================== + +const FormContext = React.createContext<FormContextValue<any> | null>(null); + +export function useFormContext<TValues extends Record<string, any>>() { + const context = React.useContext(FormContext); + if (!context) { + throw new Error('Form components must be used within Form'); + } + return context as FormContextValue<TValues>; +} + +export { FormContext }; diff --git a/src/components/ui/radix-form/index.ts b/src/components/ui/radix-form/index.ts new file mode 100644 index 0000000..d5c1d35 --- /dev/null +++ b/src/components/ui/radix-form/index.ts @@ -0,0 +1,29 @@ +'use client'; + +// 导出所有组件 +export { Form } from './Form'; +export { FormItem } from './FormItem'; +export { FormDependency } from './FormDependency'; +export { FormError } from './FormError'; + +// 导出 Context Hook +export { useFormContext } from './context'; + +// 导出所有类型 +export type { + Rule, + Rules, + FormContextValue, + CombineValidateResult, + FormProps, + FormAction, + FormItemProps, + FormDependencyProps, + FormErrorProps, +} from './types'; + +// 导出工具函数 +export { validateRule, getByPath, setByPath, getLength } from './utils'; + +// 默认导出 Form 组件 +export { Form as default } from './Form'; diff --git a/src/components/ui/radix-form/types.ts b/src/components/ui/radix-form/types.ts new file mode 100644 index 0000000..2e63115 --- /dev/null +++ b/src/components/ui/radix-form/types.ts @@ -0,0 +1,164 @@ +// ==================== 校验规则类型定义 ==================== + +// 基础校验规则 +type BaseRule = { + /** 错误提示信息 */ + message?: string; +}; + +// 必填规则 +type RequiredRule = BaseRule & { + required: true; +}; + +// 最小值规则(数字) +type MinRule = BaseRule & { + min: number; +}; + +// 最大值规则(数字) +type MaxRule = BaseRule & { + max: number; +}; + +// 最小长度规则(字符串/数组) +type MinLengthRule = BaseRule & { + minLength: number; +}; + +// 最大长度规则(字符串/数组) +type MaxLengthRule = BaseRule & { + maxLength: number; +}; + +// 自定义校验函数规则 +type ValidatorRule<TValue = any> = BaseRule & { + /** 自定义校验函数,返回错误信息或 Promise<错误信息> */ + validator: ( + value: TValue, + formValues: Record<string, any> + ) => string | Promise<string> | void | Promise<void>; +}; + +// 所有规则类型的联合 +export type Rule<TValue = any> = + | RequiredRule + | MinRule + | MaxRule + | MinLengthRule + | MaxLengthRule + | ValidatorRule<TValue>; + +// 规则数组类型 +export type Rules<TValue = any> = Rule<TValue>[]; + +import type React from 'react'; + +// ==================== Form Context 类型 ==================== + +export type FormContextValue<TValues extends Record<string, any>> = { + // 表单值 ref(不触发重新渲染) + valuesRef: React.MutableRefObject<TValues>; + // 表单错误 ref(不触发重新渲染) + errorsRef: React.MutableRefObject<Record<string, string>>; + // 设置字段值 + setFieldValue: (name: string, value: any) => void; + // 获取字段值(从 ref 读取) + getFieldValue: (name: string) => any; + // 设置字段错误 + setFieldError: (name: string, error: string | undefined) => void; + // 获取字段错误 + getFieldError: (name: string) => string | undefined; + // 校验字段 + validateField: (name: string) => Promise<boolean>; + // 校验所有字段 + validateAll: () => Promise<boolean>; + // 获取所有表单值 + getValues: () => TValues; + // 重置表单 + reset: (values?: Partial<TValues>) => void; + // 表单禁用状态 + disabled: boolean; + // 注册字段规则(内部使用) + registerFieldRules: (name: string, rules: Rules) => void; + // 订阅字段变化(用于性能优化) + subscribe: (name: string, callback: () => void) => () => void; +}; + +// ==================== Form 组件类型 ==================== + +export type CombineValidateResult = { + key: string; + message: string; +} | null; + +export type FormProps< + TValues extends Record<string, any> = Record<string, any>, +> = { + initialValues?: Partial<TValues>; + onSubmit?: (data: TValues) => void | Promise<void>; + onChange?: (data: Partial<TValues>) => void; + /** 组合校验函数,用于校验多个字段的组合条件 */ + combineValidate?: ( + values: TValues + ) => CombineValidateResult | Promise<CombineValidateResult>; + loading?: boolean; + disabled?: boolean; + children: React.ReactNode; +}; + +export type FormAction< + TValues extends Record<string, any> = Record<string, any>, +> = { + validateFields: () => Promise<TValues | null>; + getValues: () => TValues; + reset: (values?: Partial<TValues>) => void; + setFieldValue: (name: string, value: any) => void; + setFieldError: (name: string, error: string | undefined) => void; +}; + +// ==================== FormItem 组件类型 ==================== + +export type FormItemProps< + TValues extends Record<string, any> = Record<string, any>, +> = { + /** 校验触发时机 */ + trigger?: 'onChange' | 'onBlur'; + /** 字段名,支持嵌套路径如 'user.name' */ + name: string; + /** 校验规则数组 */ + rules?: Rules; + /** 渲染函数 */ + render: ( + props: { + value: any; + onChange: (value: any) => void; + onBlur: () => void; + }, + error: string | undefined + ) => React.ReactNode; + /** 是否禁用该字段 */ + disabled?: boolean; +}; + +// ==================== FormDependency 组件类型 ==================== + +export type FormDependencyProps< + TValues extends Record<string, any> = Record<string, any>, +> = { + /** 依赖的字段名数组 */ + dependencies: string[]; + /** 渲染函数 */ + render: (props: { values: Record<string, any> }) => React.ReactNode; +}; + +// ==================== FormError 组件类型 ==================== + +export type FormErrorProps< + TValues extends Record<string, any> = Record<string, any>, +> = { + /** 要监听的字段名数组 */ + name: string[]; + /** 渲染函数 */ + render: (props: { errors: Record<string, string> }) => React.ReactNode; +}; diff --git a/src/components/ui/radix-form/utils.ts b/src/components/ui/radix-form/utils.ts new file mode 100644 index 0000000..d3e700b --- /dev/null +++ b/src/components/ui/radix-form/utils.ts @@ -0,0 +1,95 @@ +import type { Rule } from './types'; + +// ==================== 工具函数 ==================== + +// 获取值长度(支持字符串和数组) +export function getLength(value: any): number { + if (typeof value === 'string') return value.length; + if (Array.isArray(value)) return value.length; + return 0; +} + +// 校验函数 +export async function validateRule<TValue>( + rule: Rule<TValue>, + value: TValue, + formValues: Record<string, any> +): Promise<string | undefined> { + // 必填校验 + if ('required' in rule && rule.required) { + if (value === undefined || value === null || value === '') { + return rule.message || '此字段为必填项'; + } + } + + // 最小值校验(数字) + if ('min' in rule && typeof value === 'number') { + if (value < rule.min) { + return rule.message || `值不能小于 ${rule.min}`; + } + } + + // 最大值校验(数字) + if ('max' in rule && typeof value === 'number') { + if (value > rule.max) { + return rule.message || `值不能大于 ${rule.max}`; + } + } + + // 最小长度校验 + if ('minLength' in rule) { + const length = getLength(value); + if (length < rule.minLength) { + return rule.message || `长度不能少于 ${rule.minLength} 个字符`; + } + } + + // 最大长度校验 + if ('maxLength' in rule) { + const length = getLength(value); + if (length > rule.maxLength) { + return rule.message || `长度不能超过 ${rule.maxLength} 个字符`; + } + } + + // 自定义校验函数 + if ('validator' in rule) { + try { + const result = await rule.validator(value, formValues); + if (result) { + return result; + } + } catch (error) { + if (typeof error === 'string') { + return error; + } + return rule.message || '校验失败'; + } + } + + return undefined; +} + +// 工具函数:根据路径获取/设置对象值 +export function getByPath(obj: any, path: string): any { + const segments = path.split('.'); + let current = obj; + for (const key of segments) { + if (current == null) return undefined; + current = current[key]; + } + return current; +} + +export function setByPath(obj: any, path: string, value: any): void { + const segments = path.split('.'); + let current = obj; + for (let i = 0; i < segments.length - 1; i++) { + const key = segments[i]; + if (current[key] == null) { + current[key] = {}; + } + current = current[key]; + } + current[segments[segments.length - 1]] = value; +}