feat: 优化代码

This commit is contained in:
liuyonghe0111 2025-11-04 18:42:16 +08:00
parent 6177e64684
commit b9cf8bc55f
24 changed files with 1128 additions and 381 deletions

View File

@ -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",

View File

@ -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: {}

View File

@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 8L16.4853 16.4853" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M16 8L7.51472 16.4853" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 280 B

View File

@ -0,0 +1,29 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_319_4403" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="22" height="22">
<path d="M11.5493 1.69703C11.704 1.27914 12.295 1.27913 12.4497 1.69703L12.8131 2.67914C14.2716 6.6207 17.3793 9.72838 21.3208 11.1869L22.3029 11.5503C22.7208 11.7049 22.7208 12.296 22.3029 12.4506L21.3208 12.8141C17.3793 14.2726 14.2716 17.3802 12.8131 21.3218L12.4497 22.3039C12.295 22.7218 11.704 22.7218 11.5493 22.3039L11.1859 21.3218C9.7274 17.3802 6.61973 14.2726 2.67817 12.8141L1.69606 12.4506C1.27816 12.296 1.27816 11.7049 1.69606 11.5503L2.67817 11.1869C6.61972 9.72838 9.7274 6.6207 11.1859 2.67914L11.5493 1.69703Z" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_319_4403)">
<path d="M2.67817 11.1869L0.479492 12.0005H11.9995V0.480469L11.1859 2.67915C9.7274 6.6207 6.61973 9.72838 2.67817 11.1869Z" fill="url(#paint0_linear_319_4403)"/>
<path d="M2.67817 12.8131L0.479492 11.9995H11.9995V23.5195L11.1859 21.3209C9.7274 17.3793 6.61973 14.2716 2.67817 12.8131Z" fill="url(#paint1_linear_319_4403)"/>
<path d="M21.3218 11.1869L23.5205 12.0005H12.0005V0.480469L12.8141 2.67915C14.2726 6.6207 17.3803 9.72838 21.3218 11.1869Z" fill="url(#paint2_linear_319_4403)"/>
<path d="M21.3218 12.8131L23.5205 11.9995H12.0005V23.5195L12.8141 21.3209C14.2726 17.3793 17.3803 14.2716 21.3218 12.8131Z" fill="url(#paint3_linear_319_4403)"/>
</g>
<defs>
<linearGradient id="paint0_linear_319_4403" x1="0.479492" y1="6.24047" x2="11.9995" y2="6.24047" gradientUnits="userSpaceOnUse">
<stop stop-color="#00AAFF"/>
<stop offset="1" stop-color="#0048FF"/>
</linearGradient>
<linearGradient id="paint1_linear_319_4403" x1="0.479492" y1="17.7595" x2="11.9995" y2="11.9995" gradientUnits="userSpaceOnUse">
<stop stop-color="#002070"/>
<stop offset="1" stop-color="#0042D5"/>
</linearGradient>
<linearGradient id="paint2_linear_319_4403" x1="23.5205" y1="6.24047" x2="12.0005" y2="6.24047" gradientUnits="userSpaceOnUse">
<stop stop-color="#0048FF"/>
<stop offset="1" stop-color="#00EEFF"/>
</linearGradient>
<linearGradient id="paint3_linear_319_4403" x1="17.7605" y1="23.5195" x2="12.0005" y2="11.9995" gradientUnits="userSpaceOnUse">
<stop stop-color="#00AAFF"/>
<stop offset="1" stop-color="#0048FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

8
public/component/vip.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -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 (
<div className="flex h-full flex-col">
<div
@ -57,16 +51,20 @@ const SettingForm = React.memo(() => {
Setting
</div>
<div className="flex-1 overflow-y-auto pr-10">
<Form>
<Form<SettingFormValues>
onChange={(values) => {
console.log(values);
}}
>
<Title text="Switch Model">
<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(() => {
</Title>
<Title text="Sound">
<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(() => {
</Title>
<Title text="Maximum number of response tokens">
<FormItem
name="name13"
<FormItem<SettingFormValues>
name="max_tokens"
render={({ value, onChange }) => (
<Number value={value} onChange={onChange} />
)}
@ -106,16 +106,18 @@ const SettingForm = React.memo(() => {
</Title>
<Title text="Appearance">
<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>
</Title>
<Title text="Background">
<FormItem
<FormItem<SettingFormValues>
name="background"
render={({ value, onChange }) => (
<Background value={value} onChange={onChange} />

View File

@ -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

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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' },

View File

@ -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';

View File

@ -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;

View File

@ -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>
);

View File

@ -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} />

View File

@ -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}

View File

@ -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;

View File

@ -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 })}</>;
};

View File

@ -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 })}</>;
};

View File

@ -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
)}
</>
);
};

View File

@ -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 };

View File

@ -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';

View File

@ -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;
};

View File

@ -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;
}