@xbeeant/form-engine-react 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/cjs/components/FormController.cjs +1 -1
  2. package/dist/cjs/components/NexusField.cjs +1 -1
  3. package/dist/cjs/components/NexusForm.cjs +1 -1
  4. package/dist/cjs/components/form-controller.cjs +1 -0
  5. package/dist/cjs/components/nexus-field.cjs +1 -0
  6. package/dist/cjs/components/nexus-form-provider.cjs +1 -0
  7. package/dist/cjs/components/nexus-form.cjs +1 -0
  8. package/dist/cjs/components/nexus-layout.cjs +1 -0
  9. package/dist/cjs/components/nexus-object.cjs +1 -0
  10. package/dist/cjs/contexts/field-inherit-context.cjs +1 -0
  11. package/dist/cjs/contexts/grid-context.cjs +1 -0
  12. package/dist/cjs/contexts/layout-config-context.cjs +1 -0
  13. package/dist/cjs/contexts/nexus-context.cjs +1 -0
  14. package/dist/cjs/hooks/index.cjs +1 -1
  15. package/dist/cjs/hooks/use-engine.cjs +1 -0
  16. package/dist/cjs/hooks/use-field-state.cjs +1 -0
  17. package/dist/cjs/hooks/use-field-validator.cjs +1 -0
  18. package/dist/cjs/hooks/use-field-value.cjs +1 -0
  19. package/dist/cjs/hooks/use-form-config.cjs +1 -0
  20. package/dist/cjs/hooks/use-form-data.cjs +1 -0
  21. package/dist/cjs/hooks/use-form-submitting.cjs +1 -0
  22. package/dist/cjs/hooks/use-form.cjs +1 -0
  23. package/dist/cjs/hooks/use-nexus-context.cjs +1 -0
  24. package/dist/cjs/hooks/use-watch-all.cjs +1 -0
  25. package/dist/cjs/hooks/use-watch-multiple.cjs +1 -0
  26. package/dist/cjs/hooks/use-watch-state.cjs +1 -0
  27. package/dist/cjs/hooks/use-watch.cjs +1 -0
  28. package/dist/cjs/hooks/useForm.cjs +1 -1
  29. package/dist/cjs/hooks/useFormData.cjs +1 -1
  30. package/dist/cjs/hooks/useFormSubmitting.cjs +1 -0
  31. package/dist/cjs/index.cjs +1 -1
  32. package/dist/cjs/styles.css +1 -0
  33. package/dist/cjs/utils/omit-nil.cjs +1 -0
  34. package/dist/cjs/utils/omitNil.cjs +1 -0
  35. package/dist/cjs/utils/persist.cjs +1 -0
  36. package/dist/cjs/utils/render-tree-node.cjs +1 -0
  37. package/dist/cjs/utils/resolve-col-span.cjs +1 -0
  38. package/dist/es/components/FormController.d.ts +76 -18
  39. package/dist/es/components/FormController.js +227 -70
  40. package/dist/es/components/NexusField.js +63 -60
  41. package/dist/es/components/NexusForm.d.ts +40 -3
  42. package/dist/es/components/NexusForm.js +143 -89
  43. package/dist/es/components/form-controller.d.ts +129 -0
  44. package/dist/es/components/form-controller.js +310 -0
  45. package/dist/es/components/nexus-field.d.ts +9 -0
  46. package/dist/es/components/nexus-field.js +131 -0
  47. package/dist/es/components/nexus-form-provider.d.ts +12 -0
  48. package/dist/es/components/nexus-form-provider.js +18 -0
  49. package/dist/es/components/nexus-form.d.ts +114 -0
  50. package/dist/es/components/nexus-form.js +174 -0
  51. package/dist/es/components/nexus-layout.d.ts +9 -0
  52. package/dist/es/components/nexus-layout.js +48 -0
  53. package/dist/es/components/nexus-object.d.ts +16 -0
  54. package/dist/es/components/nexus-object.js +64 -0
  55. package/dist/es/contexts/NexusContext.d.ts +2 -0
  56. package/dist/es/contexts/field-inherit-context.d.ts +18 -0
  57. package/dist/es/contexts/field-inherit-context.js +5 -0
  58. package/dist/es/contexts/grid-context.d.ts +5 -0
  59. package/dist/es/contexts/grid-context.js +5 -0
  60. package/dist/es/contexts/layout-config-context.d.ts +4 -0
  61. package/dist/es/contexts/layout-config-context.js +5 -0
  62. package/dist/es/contexts/nexus-context.d.ts +29 -0
  63. package/dist/es/contexts/nexus-context.js +12 -0
  64. package/dist/es/hooks/index.d.ts +12 -11
  65. package/dist/es/hooks/index.js +20 -18
  66. package/dist/es/hooks/use-engine.d.ts +2 -0
  67. package/dist/es/hooks/use-engine.js +8 -0
  68. package/dist/es/hooks/use-field-state.d.ts +7 -0
  69. package/dist/es/hooks/use-field-state.js +13 -0
  70. package/dist/es/hooks/use-field-validator.d.ts +40 -0
  71. package/dist/es/hooks/use-field-validator.js +35 -0
  72. package/dist/es/hooks/use-field-value.d.ts +6 -0
  73. package/dist/es/hooks/use-field-value.js +13 -0
  74. package/dist/es/hooks/use-form-config.d.ts +5 -0
  75. package/dist/es/hooks/use-form-config.js +8 -0
  76. package/dist/es/hooks/use-form-data.d.ts +6 -0
  77. package/dist/es/hooks/use-form-data.js +13 -0
  78. package/dist/es/hooks/use-form-submitting.d.ts +7 -0
  79. package/dist/es/hooks/use-form-submitting.js +11 -0
  80. package/dist/es/hooks/use-form.d.ts +22 -0
  81. package/dist/es/hooks/use-form.js +12 -0
  82. package/dist/es/hooks/use-nexus-context.d.ts +11 -0
  83. package/dist/es/hooks/use-nexus-context.js +12 -0
  84. package/dist/es/hooks/use-watch-all.d.ts +19 -0
  85. package/dist/es/hooks/use-watch-all.js +29 -0
  86. package/dist/es/hooks/use-watch-multiple.d.ts +20 -0
  87. package/dist/es/hooks/use-watch-multiple.js +29 -0
  88. package/dist/es/hooks/use-watch-state.d.ts +18 -0
  89. package/dist/es/hooks/use-watch-state.js +19 -0
  90. package/dist/es/hooks/use-watch.d.ts +25 -0
  91. package/dist/es/hooks/use-watch.js +20 -0
  92. package/dist/es/hooks/useForm.d.ts +17 -1
  93. package/dist/es/hooks/useForm.js +9 -6
  94. package/dist/es/hooks/useFormData.js +9 -9
  95. package/dist/es/hooks/useFormSubmitting.d.ts +7 -0
  96. package/dist/es/hooks/useFormSubmitting.js +11 -0
  97. package/dist/es/index.d.ts +26 -24
  98. package/dist/es/index.js +43 -38
  99. package/dist/es/styles.css +1 -0
  100. package/dist/es/utils/omit-nil.d.ts +9 -0
  101. package/dist/es/utils/omit-nil.js +14 -0
  102. package/dist/es/utils/omitNil.d.ts +9 -0
  103. package/dist/es/utils/omitNil.js +14 -0
  104. package/dist/es/utils/persist.d.ts +24 -0
  105. package/dist/es/utils/persist.js +43 -0
  106. package/dist/es/utils/render-tree-node.d.ts +6 -0
  107. package/dist/es/utils/render-tree-node.js +17 -0
  108. package/dist/es/utils/resolve-col-span.d.ts +5 -0
  109. package/dist/es/utils/resolve-col-span.js +9 -0
  110. package/dist/umd/index.css +1 -0
  111. package/dist/umd/index.umd.cjs +1 -1
  112. package/package.json +5 -4
  113. package/src/components/form-controller.ts +584 -0
  114. package/src/components/{NexusField.tsx → nexus-field.tsx} +108 -31
  115. package/src/components/{NexusFormProvider.tsx → nexus-form-provider.tsx} +3 -3
  116. package/src/components/{NexusForm.tsx → nexus-form.tsx} +159 -18
  117. package/src/components/{NexusLayout.tsx → nexus-layout.tsx} +5 -5
  118. package/src/components/{NexusObject.tsx → nexus-object.tsx} +3 -3
  119. package/src/contexts/{NexusContext.ts → nexus-context.ts} +3 -1
  120. package/src/hooks/index.ts +12 -11
  121. package/src/hooks/{useEngine.ts → use-engine.ts} +1 -1
  122. package/src/hooks/{useFieldState.ts → use-field-state.ts} +1 -1
  123. package/src/hooks/{useFieldValidator.ts → use-field-validator.ts} +1 -1
  124. package/src/hooks/{useFieldValue.ts → use-field-value.ts} +1 -1
  125. package/src/hooks/{useFormConfig.ts → use-form-config.ts} +2 -2
  126. package/src/hooks/{useFormData.ts → use-form-data.ts} +4 -4
  127. package/src/hooks/use-form-submitting.ts +16 -0
  128. package/src/hooks/use-form.ts +36 -0
  129. package/src/hooks/{useNexusContext.ts → use-nexus-context.ts} +2 -2
  130. package/src/index.ts +28 -24
  131. package/src/styles.css +6 -0
  132. package/src/utils/omit-nil.ts +24 -0
  133. package/src/utils/persist.ts +88 -0
  134. package/src/utils/{renderTreeNode.tsx → render-tree-node.tsx} +3 -3
  135. package/src/utils/{resolveColSpan.ts → resolve-col-span.ts} +1 -1
  136. package/src/vite-env.d.ts +1 -0
  137. package/src/components/FormController.ts +0 -271
  138. package/src/hooks/useForm.ts +0 -15
  139. /package/src/contexts/{FieldInheritContext.ts → field-inherit-context.ts} +0 -0
  140. /package/src/contexts/{GridContext.ts → grid-context.ts} +0 -0
  141. /package/src/contexts/{LayoutConfigContext.ts → layout-config-context.ts} +0 -0
  142. /package/src/hooks/{useWatchAll.ts → use-watch-all.ts} +0 -0
  143. /package/src/hooks/{useWatchMultiple.ts → use-watch-multiple.ts} +0 -0
  144. /package/src/hooks/{useWatchState.ts → use-watch-state.ts} +0 -0
  145. /package/src/hooks/{useWatch.ts → use-watch.ts} +0 -0
@@ -1,10 +1,10 @@
1
- import type { CSSProperties, FocusEvent } from 'react';
1
+ import type { CSSProperties, FocusEvent, ReactElement } from 'react';
2
2
  import { useCallback, useContext, useMemo, useSyncExternalStore } from 'react';
3
- import { FieldInheritContext } from '../contexts/FieldInheritContext';
4
- import { GridContext } from '../contexts/GridContext';
5
- import { LayoutConfigContext } from '../contexts/LayoutConfigContext';
6
- import { useNexusContext } from '../contexts/NexusContext';
7
- import { resolveColSpan } from '../utils/resolveColSpan';
3
+ import { FieldInheritContext } from '../contexts/field-inherit-context';
4
+ import { GridContext } from '../contexts/grid-context';
5
+ import { LayoutConfigContext } from '../contexts/layout-config-context';
6
+ import { useNexusContext } from '../contexts/nexus-context';
7
+ import { resolveColSpan } from '../utils/resolve-col-span';
8
8
 
9
9
  interface NexusFieldProps {
10
10
  dataPath: string;
@@ -82,6 +82,49 @@ export function NexusField({ dataPath, layoutKey }: NexusFieldProps) {
82
82
  return values;
83
83
  }, [state?.reactions, engine]);
84
84
 
85
+ // x-render addons — 为 widget 组件提供统一的表单数据访问、校验、Schema 操作入口
86
+ // 对齐 x-render 自定义组件的 addons API
87
+ const addons = useMemo(() => {
88
+ const arrayPath = state?.meta.itemOf;
89
+ const isItemField = !!arrayPath;
90
+ const indexMatch = isItemField ? dataPath.match(/\[(\d+)\]/) : undefined;
91
+ const index = indexMatch ? Number(indexMatch[1]) : undefined;
92
+
93
+ return {
94
+ get formData() {
95
+ return form.getValues();
96
+ },
97
+ get rootValue() {
98
+ return form.getValues();
99
+ },
100
+ value: state?.value,
101
+ dataPath,
102
+ path: dataPath,
103
+ index,
104
+ parentValues: arrayPath ? form.getValueByPath(arrayPath) : undefined,
105
+ getValue: (p: string) => form.getValueByPath(p),
106
+ setValue: (p: string, v: unknown) => form.setValueByPath(p, v),
107
+ onItemChange: (p: string, v: unknown) => form.setValueByPath(p, v),
108
+ validate: async (p?: string) => {
109
+ if (p) {
110
+ await form.validateFields([p]);
111
+ } else {
112
+ await form.validateFields([dataPath]);
113
+ }
114
+ },
115
+ validateFields: async (paths?: string[]) => {
116
+ await form.validateFields(paths);
117
+ },
118
+ submit: () => form.submit(),
119
+ resetFields: () => form.resetFields(),
120
+ setSchema: (s: Record<string, unknown>) =>
121
+ form.setSchema(s as any),
122
+ setSchemaByPath: (p: string, patch: Record<string, unknown>) =>
123
+ form.setSchemaByPath(p, patch),
124
+ getSchema: () => form.getSchema(),
125
+ };
126
+ }, [form, dataPath, state?.value, state?.meta.itemOf]);
127
+
85
128
  if (!state) {
86
129
  // 仅当引擎已初始化(version > 0)但字段仍未找到时才发出警告
87
130
  // 初始化过程中的短暂空状态不应报警
@@ -146,37 +189,71 @@ export function NexusField({ dataPath, layoutKey }: NexusFieldProps) {
146
189
  ...(effectiveColSpan ? { gridColumn: `span ${effectiveColSpan}` } : {}),
147
190
  };
148
191
 
192
+ // 默认包裹:所有 widget 统一由 FieldWrapper 包裹(引擎注册,UI 层提供),
193
+ // 仅当 label === false(字段级或表单级)时 FieldWrapper 不包裹 Form.Item。
194
+ // 未注册 FieldWrapper(纯 react 无 ui 层)时直接渲染裸 widget。
195
+ const FieldWrapper = engine.getFieldWrapper();
196
+
197
+ // Form.Item 消费的元数据 props 剥离给 FieldWrapper,避免透传到底层 antd 控件:
198
+ // - required: 会让 <input required> 触发浏览器原生校验
199
+ // - errors/title/description/label/extra/width/displayType/labelWidth/column:
200
+ // 作为未知属性透传到 DOM 会产生 React 警告
201
+ // width / colSpan / displayType / labelWidth / column 是布局属性,
202
+ // 由 NexusField 外层 <div> 的 wrapperStyle 统一消费,不透传给 FieldWrapper(Form.Item),
203
+ // 避免外层 div 与 Form.Item 重复设置 width。
204
+ const fieldWrapperProps = {
205
+ label: state.meta.label,
206
+ title: state.meta.title,
207
+ description: state.meta.description,
208
+ tooltip: state.meta.tooltip,
209
+ errors: state.errors,
210
+ required: state.required,
211
+ extra: state.meta.extra,
212
+ displayType: fieldDisplayType,
213
+ labelWidth: fieldLabelWidth,
214
+ column: fieldColumn,
215
+ };
216
+
217
+ // widget 仅接收控件相关 props(value/onChange/状态/选项/表单引用/自有 props)
218
+ const widgetProps = {
219
+ dataPath,
220
+ path: dataPath,
221
+ value: state.value,
222
+ onChange: handleChange,
223
+ disabled,
224
+ readOnly,
225
+ loading: state.loading,
226
+ placeholder: state.meta.placeholder,
227
+ options,
228
+ form,
229
+ addons,
230
+ dependValues,
231
+ items: state.meta.items,
232
+ schema: state.meta.schema,
233
+ // 远程选项数据版本:reloadRemoteData 后变化,widget 据此跳过缓存重新请求
234
+ remoteVersion: engine.getRemoteDataVersion(dataPath),
235
+ ...state.props,
236
+ };
237
+
238
+ let control: ReactElement;
239
+ if (FieldWrapper) {
240
+ control = (
241
+ <FieldWrapper key={layoutKey} {...fieldWrapperProps}>
242
+ <Widget {...widgetProps} />
243
+ </FieldWrapper>
244
+ );
245
+ } else {
246
+ control = <Widget key={layoutKey} {...widgetProps} />;
247
+ }
248
+
149
249
  return (
150
250
  <div
151
251
  data-nexus-field={dataPath}
252
+ className={readOnly ? 'nexus-field-readonly' : undefined}
152
253
  onBlur={handleBlur}
153
254
  style={Object.keys(wrapperStyle).length > 0 ? wrapperStyle : undefined}
154
255
  >
155
- <Widget
156
- key={layoutKey}
157
- dataPath={dataPath}
158
- path={dataPath}
159
- value={state.value}
160
- onChange={handleChange}
161
- disabled={disabled}
162
- readOnly={readOnly}
163
- loading={state.loading}
164
- required={state.required}
165
- title={state.meta.title}
166
- description={state.meta.description}
167
- placeholder={state.meta.placeholder}
168
- label={state.meta.label}
169
- options={options}
170
- errors={state.errors}
171
- extra={state.meta.extra}
172
- displayType={fieldDisplayType}
173
- labelWidth={fieldLabelWidth}
174
- column={fieldColumn}
175
- form={form}
176
- dependValues={dependValues}
177
- items={state.meta.items}
178
- {...state.props}
179
- />
256
+ {control}
180
257
  </div>
181
258
  );
182
259
  }
@@ -1,9 +1,9 @@
1
1
  import type { NexusEngine } from '@xbeeant/form-engine';
2
2
  import type { ReactNode } from 'react';
3
3
  import { useMemo } from 'react';
4
- import { NexusContext } from '../contexts/NexusContext';
5
- import type { FormController } from './FormController';
6
- import type { NexusFormConfig } from './NexusForm';
4
+ import { NexusContext } from '../contexts/nexus-context';
5
+ import type { FormController } from './form-controller';
6
+ import type { NexusFormConfig } from './nexus-form';
7
7
 
8
8
  interface NexusFormProviderProps {
9
9
  engine: NexusEngine;
@@ -1,16 +1,23 @@
1
- import type { NexusSchema } from '@xbeeant/form-engine';
1
+ import type { NexusComponent, NexusSchema } from '@xbeeant/form-engine';
2
2
  import type { CSSProperties, ReactNode, SubmitEvent } from 'react';
3
3
  import {
4
4
  useCallback,
5
5
  useEffect,
6
+ useId,
6
7
  useMemo,
7
8
  useRef,
8
9
  useSyncExternalStore,
9
10
  } from 'react';
10
-
11
- import { renderTreeNode } from '../utils/renderTreeNode';
12
- import type { FormController } from './FormController';
13
- import { NexusFormProvider } from './NexusFormProvider';
11
+ import { useFormSubmitting } from '../hooks';
12
+ import {
13
+ clearPersisted,
14
+ loadPersisted,
15
+ type PersistOptions,
16
+ savePersisted,
17
+ } from '../utils/persist';
18
+ import { renderTreeNode } from '../utils/render-tree-node';
19
+ import type { FormController } from './form-controller';
20
+ import { NexusFormProvider } from './nexus-form-provider';
14
21
 
15
22
  // ────────────────────────────────────────────────────────────────────────────
16
23
  // Form 布局配置
@@ -31,10 +38,16 @@ export interface NexusFormConfig {
31
38
  readOnly?: boolean;
32
39
  /** 表单每行显示多少列 */
33
40
  column?: number;
41
+ /** 表单语言标识(如 'zh-CN' / 'en-US',ui 层消费:antd locale + 内置文案) */
42
+ locale?: string;
34
43
  }
35
44
 
36
45
  export interface NexusFormProps {
37
- /** Form 实例,由 useForm() 创建 */
46
+ /**
47
+ * Form 实例,由 useForm() 创建。同一 form 引用 = 同一引擎宿主(组件/插件注册共享);
48
+ * 每个 NexusForm 挂载自动获得独立实例(schema/值/订阅互不影响),
49
+ * form 的 API(getValues/setValues/submit...)聚合作用于全部实例。
50
+ */
38
51
  form: FormController;
39
52
  /** Schema 定义 */
40
53
  schema?: NexusSchema;
@@ -44,10 +57,19 @@ export interface NexusFormProps {
44
57
  widgets?: Record<string, (props: any) => ReactNode>;
45
58
  /** 额外注册的 layout */
46
59
  layouts?: Record<string, (props: any) => ReactNode>;
60
+ /** 字段包裹组件(UI 无关,Renderer 层注入;渲染层默认包裹所有 widget,label=false 时跳过) */
61
+ fieldWrapper?: NexusComponent;
47
62
  /** 提交成功回调 */
48
63
  onFinish?: (formData: Record<string, unknown>) => void | Promise<void>;
49
64
  /** 校验失败回调 */
50
65
  onFinishFailed?: (errors: Map<string, string[]>) => void;
66
+ /**
67
+ * 表单首次加载回调:非空 schema 首次传入并完成渲染后执行一次
68
+ * - undefined / null / {}(无 properties)均视为「空」schema,不触发
69
+ * - schema 由空变为非空时,于首个非空渲染提交后触发
70
+ * - 后续 schema 变更不重复触发
71
+ */
72
+ onMount?: () => void;
51
73
  /** 是否显示默认 footer(提交/重置按钮),或自定义 footer */
52
74
  footer?: boolean | ReactNode;
53
75
  /** 自定义类名 */
@@ -70,12 +92,37 @@ export interface NexusFormProps {
70
92
  changedPath?: string,
71
93
  ) => void;
72
94
  };
95
+ /**
96
+ * 表单值变化回调(x-render onValuesChange 对齐)
97
+ * 任意字段值变化时触发,参数为 (changedValue, allValues, changedPath)
98
+ * 与 watch: { '#': fn } 语义等价,但作为标准回调 API 提供
99
+ */
100
+ onValuesChange?: (
101
+ changedValue: unknown,
102
+ allValues: Record<string, unknown>,
103
+ changedPath: string,
104
+ ) => void;
73
105
  /**
74
106
  * 提交时是否移除 hidden 字段数据,默认 true
75
107
  * - true: submit/getValues 不包含 hidden 字段
76
108
  * - false: submit/getValues 包含所有字段(含 hidden)
77
109
  */
78
110
  removeHiddenData?: boolean;
111
+ /**
112
+ * 提交时递归移除空值(undefined/null/'',ProForm omitNil 对齐)
113
+ * 与 form.submit({ omitNil: true }) / form.getValues(paths, { omitNil: true }) 等价
114
+ */
115
+ omitNil?: boolean;
116
+ /** 表单语言标识(如 'zh-CN' / 'en-US',ui 层消费:antd locale + 内置文案) */
117
+ locale?: string;
118
+ /**
119
+ * 表单草稿持久化:值变化时自动保存到 Web Storage,下次挂载自动恢复
120
+ * @example
121
+ * ```tsx
122
+ * <NexusForm persist={{ key: 'apply-form', storage: 'localStorage' }} />
123
+ * ```
124
+ */
125
+ persist?: PersistOptions;
79
126
 
80
127
  // ── 表单布局配置 ──────────────────────────────────────────────────────
81
128
  /** label 列配置(由 ui 层 Form.Item 消费) */
@@ -105,8 +152,10 @@ export function NexusForm({
105
152
  initialValues,
106
153
  widgets,
107
154
  layouts,
155
+ fieldWrapper,
108
156
  onFinish,
109
157
  onFinishFailed,
158
+ onMount,
110
159
  footer = false,
111
160
  className,
112
161
  style,
@@ -119,10 +168,24 @@ export function NexusForm({
119
168
  readOnly,
120
169
  column,
121
170
  watch,
171
+ onValuesChange,
122
172
  removeHiddenData = true,
173
+ omitNil = false,
174
+ locale,
175
+ persist,
123
176
  }: NexusFormProps) {
124
- const engine = form._getEngine();
177
+ // 实例视图引擎:同一 form 挂载的每个 NexusForm 自动获得独立实例
178
+ // (schema/值/订阅互不影响);实例标识由 useId 内部生成,用户不感知 instanceId。
179
+ // 同一 form = 同一引擎宿主(组件/插件注册共享),不同 schema = 不同实例状态。
180
+ const instanceKey = useId();
181
+ const engine = useMemo(
182
+ () => form._useInstance(instanceKey),
183
+ [form, instanceKey],
184
+ );
125
185
  const formElRef = useRef<HTMLFormElement | null>(null);
186
+ // persist 配置经 ref 持有:保存回调不随配置对象变化重建订阅
187
+ const persistRef = useRef<PersistOptions | undefined>(persist);
188
+ persistRef.current = persist;
126
189
 
127
190
  // Schema 顶层配置作为默认值,props 优先级更高
128
191
  const finalDisplayType = displayType ?? schema?.displayType ?? 'row';
@@ -131,16 +194,20 @@ export function NexusForm({
131
194
  const finalLabelWidth = labelWidth ?? schema?.labelWidth;
132
195
  const finalReadOnly = readOnly ?? schema?.readOnly ?? false;
133
196
  const finalColumn = column ?? schema?.column;
197
+ const finalLocale = locale ?? schema?.locale ?? engine.getLocale();
134
198
 
135
199
  // 注册额外 widgets / layouts(仅首次或引用变化时)
136
200
  useEffect(() => {
137
201
  if (widgets) {
138
202
  engine.registerWidgets(widgets);
139
203
  }
204
+ if (fieldWrapper) {
205
+ engine.registerFieldWrapper(fieldWrapper);
206
+ }
140
207
  if (layouts) {
141
208
  engine.registerLayouts(layouts);
142
209
  }
143
- }, [engine, widgets, layouts]);
210
+ }, [engine, widgets, layouts, fieldWrapper]);
144
211
 
145
212
  // schema 变化时重新初始化
146
213
  // 注意:initialValues 仅在首次挂载时使用,避免每次渲染都 re-init 导致
@@ -152,7 +219,11 @@ export function NexusForm({
152
219
  return;
153
220
  }
154
221
  if (isFirstInitRef.current) {
155
- engine.init(schema, initialValuesRef.current);
222
+ // 草稿持久化:恢复已保存草稿(优先于 initialValues)
223
+ const persisted = persistRef.current
224
+ ? loadPersisted(persistRef.current)
225
+ : undefined;
226
+ engine.init(schema, persisted ?? initialValuesRef.current);
156
227
  isFirstInitRef.current = false;
157
228
  } else {
158
229
  // schema 变化:保留当前已填数据,而非重置为 initialValues
@@ -161,34 +232,99 @@ export function NexusForm({
161
232
  // eslint-disable-next-line react-hooks/exhaustive-deps
162
233
  }, [engine, schema]);
163
234
 
235
+ // 草稿持久化:store 版本变化(任意数据变更)时防抖保存
236
+ // 用持久化身份(storage:key)驱动订阅:开关切换 / 换 key 时重建订阅,
237
+ // 而 persist 对象本身每次渲染重建不影响订阅稳定性
238
+ const persistIdentity = persist
239
+ ? `${persist.storage ?? 'localStorage'}:${persist.key}`
240
+ : null;
241
+ useEffect(() => {
242
+ const options = persistIdentity ? persistRef.current : undefined;
243
+ if (!options) {
244
+ return;
245
+ }
246
+ let timer: ReturnType<typeof setTimeout> | null = null;
247
+ const debounce = options.debounce ?? 300;
248
+ const unsub = engine.subscribeStore(() => {
249
+ if (timer) {
250
+ clearTimeout(timer);
251
+ }
252
+ timer = setTimeout(() => {
253
+ savePersisted(options, engine.getFormData());
254
+ }, debounce);
255
+ });
256
+ return () => {
257
+ if (timer) {
258
+ clearTimeout(timer);
259
+ }
260
+ unsub();
261
+ };
262
+ }, [engine, persistIdentity]);
263
+
264
+ // onMount:首次传入「非空」schema 并完成渲染后执行一次
265
+ // undefined / null / {}(无任何键)均视为空 schema,不触发;
266
+ // schema 由空变为非空时,于首个非空渲染提交(useEffect)后触发。
267
+ const onMountRef = useRef(onMount);
268
+ onMountRef.current = onMount;
269
+ const onMountFiredRef = useRef(false);
270
+ const isSchemaEmpty =
271
+ !schema || (typeof schema === 'object' && Object.keys(schema).length === 0);
272
+ useEffect(() => {
273
+ if (onMountFiredRef.current || isSchemaEmpty) {
274
+ return;
275
+ }
276
+ onMountFiredRef.current = true;
277
+ onMountRef.current?.();
278
+ }, [isSchemaEmpty]);
279
+
164
280
  // 绑定 form controller
165
281
  // 使用 ref 持有 onFinish / onFinishFailed,避免每次 re-render 都造成绑定逻辑重复执行
166
282
  const onFinishRef =
167
283
  useRef<(data: Record<string, unknown>) => void | Promise<void>>(noop);
168
284
  const onFinishFailedRef =
169
285
  useRef<(errors: Map<string, string[]>) => void>(noop);
170
- onFinishRef.current = onFinish ?? noop;
286
+ onFinishRef.current = (data) => {
287
+ // persist 未配置时不参与提交流程;配置且 clearOnSubmit !== false 时清除草稿
288
+ const options = persistRef.current;
289
+ if (options && options.clearOnSubmit !== false) {
290
+ clearPersisted(options);
291
+ }
292
+ return onFinish?.(data);
293
+ };
171
294
  onFinishFailedRef.current = onFinishFailed ?? noop;
172
295
 
173
296
  // 只在挂载时绑定一次:传入「稳定的 getter」,让 FormController 在 submit 时读取最新回调
174
297
  useEffect(() => {
175
298
  form._bind(
299
+ instanceKey,
176
300
  formElRef.current,
177
301
  () => onFinishRef.current,
178
302
  () => onFinishFailedRef.current,
179
303
  );
180
304
  // eslint-disable-next-line react-hooks/exhaustive-deps
181
- }, [form]);
305
+ }, [form, instanceKey]);
182
306
 
183
- // watch / removeHiddenData 变化时单独同步(不重置其他绑定)
307
+ // watch / removeHiddenData / onValuesChange 变化时单独同步(不重置其他绑定)
308
+ // onValuesChange 经 ref 持有:回调内容变化不重建订阅,仅内部引用更新
309
+ const onValuesChangeRef = useRef(onValuesChange);
310
+ onValuesChangeRef.current = onValuesChange;
184
311
  useEffect(() => {
185
- form._syncConfig({ removeHiddenData, watch });
186
- }, [form, removeHiddenData, watch]);
312
+ form._syncConfig(instanceKey, {
313
+ removeHiddenData,
314
+ omitNil,
315
+ watch,
316
+ onValuesChange: (changedValue, allValues, changedPath) =>
317
+ onValuesChangeRef.current?.(changedValue, allValues, changedPath),
318
+ });
319
+ }, [form, instanceKey, removeHiddenData, omitNil, watch]);
187
320
 
321
+ // 渲染树版本订阅:仅 Schema 结构变化(init/setSchema/reset)时重算 renderTree。
322
+ // 字段值/错误等数据变化只 bump store 版本(useFormData 消费),
323
+ // 不会触发 NexusForm 重渲染——各字段经字段级版本订阅精准重渲染。
188
324
  const _version = useSyncExternalStore(
189
- engine.subscribeStore,
190
- engine.getSnapshot,
191
- engine.getSnapshot,
325
+ (cb) => engine.subscribeRender(cb),
326
+ () => engine.getRenderSnapshot(),
327
+ () => engine.getRenderSnapshot(),
192
328
  );
193
329
  // 依赖 _version:engine.init() / setSchema() 会 bump version,
194
330
  // 需要在此后重新读取 renderTree(首次渲染时 engine 尚未 init,renderTree 为空)
@@ -208,11 +344,14 @@ export function NexusForm({
208
344
  }, [form]);
209
345
 
210
346
  // footer 渲染
347
+ const submitting = useFormSubmitting(form);
211
348
  let footerNode: ReactNode = null;
212
349
  if (footer === true) {
213
350
  footerNode = (
214
351
  <div className='mt-4'>
215
- <button type='submit'>提交</button>{' '}
352
+ <button type='submit' disabled={submitting}>
353
+ {submitting ? '提交中...' : '提交'}
354
+ </button>{' '}
216
355
  <button type='button' onClick={handleReset}>
217
356
  重置
218
357
  </button>
@@ -250,6 +389,7 @@ export function NexusForm({
250
389
  displayType: finalDisplayType,
251
390
  readOnly: finalReadOnly,
252
391
  column: finalColumn,
392
+ locale: finalLocale,
253
393
  }),
254
394
  [
255
395
  mergedLabelCol,
@@ -259,6 +399,7 @@ export function NexusForm({
259
399
  finalDisplayType,
260
400
  finalReadOnly,
261
401
  finalColumn,
402
+ finalLocale,
262
403
  ],
263
404
  );
264
405
 
@@ -2,14 +2,14 @@ import type { RenderLayoutNode } from '@xbeeant/form-engine';
2
2
  import type { CSSProperties } from 'react';
3
3
  import { useContext, useMemo } from 'react';
4
4
 
5
- import { GridContext } from '../contexts/GridContext';
5
+ import { GridContext } from '../contexts/grid-context';
6
6
  import {
7
7
  LayoutConfigContext,
8
8
  type LayoutConfigContextValue,
9
- } from '../contexts/LayoutConfigContext';
10
- import { useNexusContext } from '../contexts/NexusContext';
11
- import { renderTreeNode } from '../utils/renderTreeNode';
12
- import { resolveColSpan } from '../utils/resolveColSpan';
9
+ } from '../contexts/layout-config-context';
10
+ import { useNexusContext } from '../contexts/nexus-context';
11
+ import { renderTreeNode } from '../utils/render-tree-node';
12
+ import { resolveColSpan } from '../utils/resolve-col-span';
13
13
 
14
14
  interface NexusLayoutProps {
15
15
  node: RenderLayoutNode;
@@ -2,9 +2,9 @@ import type { RenderObjectNode } from '@xbeeant/form-engine';
2
2
  import type { CSSProperties } from 'react';
3
3
  import { useContext, useState, useSyncExternalStore } from 'react';
4
4
 
5
- import { FieldInheritContext } from '../contexts/FieldInheritContext';
6
- import { useNexusContext } from '../contexts/NexusContext';
7
- import { renderTreeNode } from '../utils/renderTreeNode';
5
+ import { FieldInheritContext } from '../contexts/field-inherit-context';
6
+ import { useNexusContext } from '../contexts/nexus-context';
7
+ import { renderTreeNode } from '../utils/render-tree-node';
8
8
 
9
9
  interface NexusObjectProps {
10
10
  node: RenderObjectNode;
@@ -1,7 +1,7 @@
1
1
  import type { NexusEngine } from '@xbeeant/form-engine';
2
2
  import { createContext, type ReactNode, useContext } from 'react';
3
3
 
4
- import type { FormController } from '../components/FormController';
4
+ import type { FormController } from '../components/form-controller';
5
5
 
6
6
  // ────────────────────────────────────────────────────────────────────────────
7
7
  // Form 布局配置
@@ -22,6 +22,8 @@ export interface NexusFormConfig {
22
22
  readOnly?: boolean;
23
23
  /** 表单每行显示多少列 */
24
24
  column?: number;
25
+ /** 表单语言标识(如 'zh-CN' / 'en-US',ui 层消费:antd locale + 内置文案) */
26
+ locale?: string;
25
27
  }
26
28
 
27
29
  interface NexusContextValue {
@@ -1,11 +1,12 @@
1
- export { useEngine } from './useEngine';
2
- export { useFieldState } from './useFieldState';
3
- export { useFieldValidator } from './useFieldValidator';
4
- export { useFieldValue } from './useFieldValue';
5
- export { useForm } from './useForm';
6
- export { useFormConfig } from './useFormConfig';
7
- export { useFormData } from './useFormData';
8
- export { useWatch } from './useWatch';
9
- export { useWatchAll } from './useWatchAll';
10
- export { useWatchMultiple } from './useWatchMultiple';
11
- export { useWatchState } from './useWatchState';
1
+ export { useEngine } from './use-engine';
2
+ export { useFieldState } from './use-field-state';
3
+ export { useFieldValidator } from './use-field-validator';
4
+ export { useFieldValue } from './use-field-value';
5
+ export { useForm } from './use-form';
6
+ export { useFormConfig } from './use-form-config';
7
+ export { useFormData } from './use-form-data';
8
+ export { useFormSubmitting } from './use-form-submitting';
9
+ export { useWatch } from './use-watch';
10
+ export { useWatchAll } from './use-watch-all';
11
+ export { useWatchMultiple } from './use-watch-multiple';
12
+ export { useWatchState } from './use-watch-state';
@@ -1,6 +1,6 @@
1
1
  import type { NexusEngine } from '@xbeeant/form-engine';
2
2
 
3
- import { useNexusContext } from '../contexts/NexusContext';
3
+ import { useNexusContext } from '../contexts/nexus-context';
4
4
 
5
5
  export function useEngine(): NexusEngine {
6
6
  const { engine } = useNexusContext();
@@ -1,7 +1,7 @@
1
1
  import type { FieldState } from '@xbeeant/form-engine';
2
2
  import { useSyncExternalStore } from 'react';
3
3
 
4
- import { useNexusContext } from '../contexts/NexusContext';
4
+ import { useNexusContext } from '../contexts/nexus-context';
5
5
 
6
6
  /**
7
7
  * useFieldState — 精准订阅单个字段状态
@@ -24,7 +24,7 @@
24
24
 
25
25
  import type { NexusEngine, NexusFormInstance } from '@xbeeant/form-engine';
26
26
  import { useEffect, useRef } from 'react';
27
- import { useNexusContext } from '../contexts/NexusContext';
27
+ import { useNexusContext } from '../contexts/nexus-context';
28
28
 
29
29
  export type FieldValidator = (
30
30
  value: unknown,
@@ -1,6 +1,6 @@
1
1
  import { useSyncExternalStore } from 'react';
2
2
 
3
- import { useEngine } from './useEngine';
3
+ import { useEngine } from './use-engine';
4
4
 
5
5
  /**
6
6
  * useFieldValue — 精准订阅单个字段值
@@ -1,5 +1,5 @@
1
- import type { NexusFormConfig } from '../components/NexusForm';
2
- import { useNexusContext } from '../contexts/NexusContext';
1
+ import type { NexusFormConfig } from '../components/nexus-form';
2
+ import { useNexusContext } from '../contexts/nexus-context';
3
3
 
4
4
  /**
5
5
  * useFormConfig — 获取表单布局配置
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useSyncExternalStore } from 'react';
2
2
 
3
- import { useEngine } from './useEngine';
3
+ import { useEngine } from './use-engine';
4
4
 
5
5
  /**
6
6
  * useFormData — 订阅整个表单数据
@@ -11,9 +11,9 @@ export function useFormData(): Record<string, unknown> {
11
11
  const engine = useEngine();
12
12
 
13
13
  const _version = useSyncExternalStore(
14
- engine.subscribeStore,
15
- engine.getSnapshot,
16
- engine.getSnapshot,
14
+ (cb) => engine.subscribeStore(cb),
15
+ () => engine.getSnapshot(),
16
+ () => engine.getSnapshot(),
17
17
  );
18
18
  // biome-ignore lint/correctness/useExhaustiveDependencies: _version 是 formData 失效信号(engine 内部状态,静态分析不可见)
19
19
  return useMemo(() => engine.getFormData(), [engine, _version]);
@@ -0,0 +1,16 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ import type { FormController } from '../components/form-controller';
4
+
5
+ /**
6
+ * useFormSubmitting — 订阅表单提交中状态(formily submitting 对齐)
7
+ *
8
+ * submit() 全流程(校验 + onFinish)期间返回 true,供提交按钮展示 loading。
9
+ */
10
+ export function useFormSubmitting(form: FormController): boolean {
11
+ return useSyncExternalStore(
12
+ (cb) => form.onSubmittingChange(cb),
13
+ () => form.getSubmitting(),
14
+ () => form.getSubmitting(),
15
+ );
16
+ }