feat: 优化代码
This commit is contained in:
parent
6177e64684
commit
b9cf8bc55f
|
|
@ -26,11 +26,8 @@
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.65.0",
|
|
||||||
"react-virtuoso": "^4.14.1",
|
"react-virtuoso": "^4.14.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1"
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"zod": "^4.1.12"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|
|
||||||
|
|
@ -53,21 +53,12 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.1.0
|
specifier: 19.1.0
|
||||||
version: 19.1.0(react@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:
|
react-virtuoso:
|
||||||
specifier: ^4.14.1
|
specifier: ^4.14.1
|
||||||
version: 4.14.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 4.14.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3
|
specifier: ^3
|
||||||
|
|
@ -2859,12 +2850,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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:
|
which-boxed-primitive@1.1.1:
|
||||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2898,9 +2883,6 @@ packages:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
zod@4.1.12:
|
|
||||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
@ -5872,15 +5854,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
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:
|
which-boxed-primitive@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-bigint: 1.1.0
|
is-bigint: 1.1.0
|
||||||
|
|
@ -5931,5 +5904,3 @@ snapshots:
|
||||||
yallist@5.0.0: {}
|
yallist@5.0.0: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod@4.1.12: {}
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.0 KiB |
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Form, { FormItem } from '@/components/ui/form';
|
import Form, { FormItem } from '@/components/ui/radix-form';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
import { Select, Switch, Number, FontSize } from '@/components/ui/inputs';
|
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 BubbleSelectDialog from '@/components/feature/BubbleSelectDialog';
|
||||||
import IconFont from '@/components/ui/iconFont';
|
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<
|
const Title: React.FC<
|
||||||
{
|
{
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|
@ -29,25 +42,6 @@ const Title: React.FC<
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingForm = React.memo(() => {
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div
|
<div
|
||||||
|
|
@ -57,16 +51,20 @@ const SettingForm = React.memo(() => {
|
||||||
Setting
|
Setting
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto pr-10">
|
<div className="flex-1 overflow-y-auto pr-10">
|
||||||
<Form>
|
<Form<SettingFormValues>
|
||||||
|
onChange={(values) => {
|
||||||
|
console.log(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Title text="Switch Model">
|
<Title text="Switch Model">
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="name"
|
name="model"
|
||||||
render={({ value, onChange }) => (
|
render={({ value, onChange }) => (
|
||||||
<ModelSelectDialog value={value} onChange={onChange} />
|
<ModelSelectDialog value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="name1"
|
name="short_text"
|
||||||
render={({ value, onChange }) => (
|
render={({ value, onChange }) => (
|
||||||
<Switch
|
<Switch
|
||||||
icon={'/character/model_long_text.svg'}
|
icon={'/character/model_long_text.svg'}
|
||||||
|
|
@ -79,12 +77,14 @@ const SettingForm = React.memo(() => {
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Title text="Sound">
|
<Title text="Sound">
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="name12"
|
name="voiceActor"
|
||||||
render={({ value, onChange }) => <VoiceActorSelectDialog />}
|
render={({ value, onChange }) => (
|
||||||
|
<VoiceActorSelectDialog value={value} onChange={onChange} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="name12"
|
name="dialogueOnly"
|
||||||
render={({ value, onChange }) => (
|
render={({ value, onChange }) => (
|
||||||
<Switch
|
<Switch
|
||||||
icon={'/character/play_dialogue_only.svg'}
|
icon={'/character/play_dialogue_only.svg'}
|
||||||
|
|
@ -97,8 +97,8 @@ const SettingForm = React.memo(() => {
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Title text="Maximum number of response tokens">
|
<Title text="Maximum number of response tokens">
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="name13"
|
name="max_tokens"
|
||||||
render={({ value, onChange }) => (
|
render={({ value, onChange }) => (
|
||||||
<Number value={value} onChange={onChange} />
|
<Number value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -106,16 +106,18 @@ const SettingForm = React.memo(() => {
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Title text="Appearance">
|
<Title text="Appearance">
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="fontSize"
|
name="fontSize"
|
||||||
render={() => {
|
render={({ value, onChange }) => {
|
||||||
return <FontSize />;
|
return <FontSize value={value} onChange={onChange} />;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="name12"
|
name="chat_mode"
|
||||||
render={({ value, onChange }) => (
|
render={({ value, onChange }) => (
|
||||||
<Select
|
<Select
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
placeholder="Chat Mode"
|
placeholder="Chat Mode"
|
||||||
icon={'/character/chat_mode.svg'}
|
icon={'/character/chat_mode.svg'}
|
||||||
options={[
|
options={[
|
||||||
|
|
@ -125,14 +127,14 @@ const SettingForm = React.memo(() => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormItem
|
{/* TODO: BubbleSelectDialog 需要支持受控模式 */}
|
||||||
name="name12"
|
<div>
|
||||||
render={({ value, onChange }) => <BubbleSelectDialog />}
|
<BubbleSelectDialog />
|
||||||
/>
|
</div>
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Title text="Background">
|
<Title text="Background">
|
||||||
<FormItem
|
<FormItem<SettingFormValues>
|
||||||
name="background"
|
name="background"
|
||||||
render={({ value, onChange }) => (
|
render={({ value, onChange }) => (
|
||||||
<Background value={value} onChange={onChange} />
|
<Background value={value} onChange={onChange} />
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common';
|
||||||
|
|
||||||
export default function CharacterChat() {
|
export default function CharacterChat() {
|
||||||
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom);
|
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom);
|
||||||
console.log('settingOpen', settingOpen);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,9 @@ export default function RootLayout({
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<Script
|
<Script
|
||||||
src="//at.alicdn.com/t/c/font_5054282_ibxmours7r.js"
|
src="//at.alicdn.com/t/c/font_5054282_5o7sf0csg4w.js"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
|
async
|
||||||
/>
|
/>
|
||||||
<GlobalContainer>{children}</GlobalContainer>
|
<GlobalContainer>{children}</GlobalContainer>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,148 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib';
|
||||||
import { Select } from '../ui/inputs';
|
import { Select } from '../ui/inputs';
|
||||||
import Modal from '../ui/modal';
|
import Modal from '../ui/modal';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import IconFont from '../ui/iconFont';
|
||||||
|
|
||||||
type BubbleSelectDialogProps = {};
|
type BubbleSelectDialogProps = {};
|
||||||
export default function BubbleSelectDialog(props: 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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Chat Bubble"
|
title="Chat Bubble"
|
||||||
|
classNames={{
|
||||||
|
content: 'w-200',
|
||||||
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Select.View icon={'/character/chat_bubble.svg'} text="Chat Bubble" />
|
<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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Modal, Select } from '@/components';
|
import { Modal, Select } from '@/components';
|
||||||
|
import { cn } from '@/lib';
|
||||||
import { useControllableValue } from 'ahooks';
|
import { useControllableValue } from 'ahooks';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
type ModelSelectDialogProps = {
|
type ModelSelectDialogProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|
@ -13,6 +16,7 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) {
|
||||||
defaultValuePropName: 'defaultValue',
|
defaultValuePropName: 'defaultValue',
|
||||||
trigger: 'onChange',
|
trigger: 'onChange',
|
||||||
});
|
});
|
||||||
|
console.log('ModelSelectDialog', value);
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ label: 'Model 1', value: 'model1' },
|
{ label: 'Model 1', value: 'model1' },
|
||||||
|
|
@ -20,6 +24,8 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) {
|
||||||
{ label: 'Model 3', value: 'model3' },
|
{ label: 'Model 3', value: 'model3' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const isActive = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="REPLY"
|
title="REPLY"
|
||||||
|
|
@ -27,10 +33,43 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) {
|
||||||
<Select.View icon={'/character/model_switch.svg'} text="Model 1" />
|
<Select.View icon={'/character/model_switch.svg'} text="Model 1" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col pr-2.5">
|
<div className="flex flex-col">
|
||||||
{options?.map((i) => {
|
{/* items */}
|
||||||
return <div key={i.value}>{i.label}</div>;
|
<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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,15 @@ import { Select } from '../ui/inputs';
|
||||||
import Modal from '../ui/modal';
|
import Modal from '../ui/modal';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
type VoiceActorSelectDialogProps = {};
|
type VoiceActorSelectDialogProps = {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
};
|
||||||
export default function VoiceActorSelectDialog(
|
export default function VoiceActorSelectDialog(
|
||||||
props: VoiceActorSelectDialogProps
|
props: VoiceActorSelectDialogProps
|
||||||
) {
|
) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
console.log('VoiceActorSelectDialog', value);
|
||||||
const options = [
|
const options = [
|
||||||
{ label: 'Voice Actor 1', value: 'voiceActor1', gender: 'male' },
|
{ label: 'Voice Actor 1', value: 'voiceActor1', gender: 'male' },
|
||||||
{ label: 'Voice Actor 2', value: 'voiceActor2', gender: 'female' },
|
{ label: 'Voice Actor 2', value: 'voiceActor2', gender: 'female' },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export { default as Modal } from './ui/modal';
|
export { default as Modal } from './ui/modal';
|
||||||
export { default as Select } from './ui/inputs/select';
|
export { default as Select } from './ui/inputs/select';
|
||||||
export { default as Rate } from './ui/rate';
|
export { default as Rate } from './ui/rate';
|
||||||
export { default as Form } from './ui/form';
|
|
||||||
export { default as Icon } from './ui/icon';
|
export { default as Icon } from './ui/icon';
|
||||||
export { default as Drawer } from './ui/drawer';
|
export { default as Drawer } from './ui/drawer';
|
||||||
export { default as VirtualGrid } from './ui/VirtualGrid';
|
export { default as VirtualGrid } from './ui/VirtualGrid';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const InputLeft = (props: { icon?: any; text?: string }) => {
|
||||||
const { icon, text } = props;
|
const { icon, text } = props;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<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>
|
<span className="text-text-color/80 font-bold">{text}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,15 @@ type SwitchProps = {
|
||||||
} & React.HTMLAttributes<HTMLDivElement>;
|
} & React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export default function Switch(props: SwitchProps) {
|
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);
|
const [value, onChange] = useControllableValue(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...rest} className={cn('input-view justify-between', rest.className)}>
|
<div {...rest} className={cn('input-view justify-between', rest.className)}>
|
||||||
<InputLeft icon={icon} text={text} />
|
<InputLeft icon={icon} text={text} />
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { useControllableValue } from 'ahooks';
|
||||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import Image from 'next/image';
|
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
|
import IconFont from '../iconFont';
|
||||||
|
|
||||||
type ModalProps = {
|
type ModalProps = {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
|
@ -41,16 +41,14 @@ export default function Modal(props: ModalProps) {
|
||||||
className={cn('dialog-content w-125', classNames?.content)}
|
className={cn('dialog-content w-125', classNames?.content)}
|
||||||
>
|
>
|
||||||
<div className="mb-7 flex justify-between">
|
<div className="mb-7 flex justify-between">
|
||||||
<DialogPrimitive.Title>{title}</DialogPrimitive.Title>
|
<DialogPrimitive.Title className="text-xl font-black">
|
||||||
<DialogPrimitive.Close>
|
{title}
|
||||||
<Image
|
</DialogPrimitive.Title>
|
||||||
onClick={() => setOpen(false)}
|
<DialogPrimitive.Close
|
||||||
src="/component/close.svg"
|
onClick={() => setOpen(false)}
|
||||||
alt="close"
|
className="translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80"
|
||||||
width={30}
|
>
|
||||||
className="translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80"
|
<IconFont type="icon-guanbi" size={30} />
|
||||||
height={30}
|
|
||||||
/>
|
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 })}</>;
|
||||||
|
};
|
||||||
|
|
@ -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 })}</>;
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue