imean-service-engine-htmx-plugin 2.4.0 → 2.6.0

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.
@@ -1,541 +0,0 @@
1
- # 自定义表单字段渲染器开发指南
2
-
3
- ## 概述
4
-
5
- 自定义表单字段渲染器允许您为复杂的表单字段创建交互式编辑组件。这些组件使用 Alpine.js 管理内部状态,并通过隐藏字段与表单系统同步数据。
6
-
7
- **推荐使用工具函数**:我们提供了 `createFormFieldXData` 和 `FormFieldWrapper` 工具函数,可以大大简化开发工作,减少样板代码。详见[使用工具函数](#方式-1使用工具函数推荐)部分。
8
-
9
- ## 设计原则
10
-
11
- ### 1. 组件自包含
12
-
13
- **核心原则**:自定义组件应该完全自包含,包括:
14
- - 自己的状态管理(Alpine.js)
15
- - 自己的隐藏字段(用于表单提交)
16
- - 自己的 UI 交互逻辑
17
-
18
- **为什么?**
19
- - **可复用性**:组件可以在任何地方使用,不依赖外部结构
20
- - **解耦**:不依赖 `form-page.tsx` 的 DOM 结构
21
- - **职责清晰**:组件负责自己的数据提交
22
-
23
- ### 2. 状态管理分离
24
-
25
- - **Alpine.js**:管理组件的交互状态(如列表项、输入值等)
26
- - **隐藏字段**:同步状态到表单系统,用于提交
27
-
28
- ### 3. 数据流向
29
-
30
- ```
31
- 用户操作 → Alpine.js 状态更新 → 更新隐藏字段 → 表单提交
32
- ```
33
-
34
- ## 组件接口
35
-
36
- ### FormFieldRendererProps
37
-
38
- ```typescript
39
- export interface FormFieldRendererProps<T = any> {
40
- /** 字段定义 */
41
- field: FormField;
42
- /** 当前值(已解析的对象/数组,不是 JSON 字符串) */
43
- value: any;
44
- /** 完整的初始数据 */
45
- initialData?: Partial<T>;
46
- /** 字段名(用于更新隐藏字段) */
47
- fieldName: string;
48
- }
49
- ```
50
-
51
- ### 属性说明
52
-
53
- - **`field`**:字段的元数据(标签、类型、是否必填等)
54
- - **`value`**:当前字段的值,已经解析为 JavaScript 对象/数组(不是 JSON 字符串)
55
- - **`initialData`**:完整的表单初始数据(可用于访问其他字段的值)
56
- - **`fieldName`**:字段名,用于创建隐藏字段的 `name` 属性
57
-
58
- ## 基本结构
59
-
60
- ### 方式 1:使用工具函数(推荐)
61
-
62
- 使用 `createFormFieldXData` 和 `FormFieldWrapper` 工具函数,可以大大简化代码:
63
-
64
- ```tsx
65
- import { createFormFieldXData, FormFieldWrapper } from "../../src/utils/form-field-xdata";
66
-
67
- interface MyComponentProps {
68
- field: any;
69
- value: any;
70
- initialData?: any;
71
- fieldName: string;
72
- }
73
-
74
- export function MyComponent(props: MyComponentProps) {
75
- const { value, fieldName } = props;
76
- const initialValue = value || [];
77
-
78
- // 使用工具函数创建 x-data
79
- const xData = createFormFieldXData({
80
- fieldName,
81
- dataKey: "items",
82
- defaultValue: [],
83
- customMethods: {
84
- "addItem()": `{
85
- this.items.push({ name: '', value: '' });
86
- this.updateHiddenField();
87
- }`,
88
- "removeItem(index)": `{
89
- this.items.splice(index, 1);
90
- this.updateHiddenField();
91
- }`,
92
- },
93
- });
94
-
95
- // 使用 FormFieldWrapper 包装组件
96
- return (
97
- <FormFieldWrapper
98
- fieldName={fieldName}
99
- initialValue={initialValue}
100
- xData={xData}
101
- autoSync={true}
102
- >
103
- {/* 组件 UI */}
104
- {/* ... */}
105
- </FormFieldWrapper>
106
- );
107
- }
108
- ```
109
-
110
- ### 方式 2:手动编写(高级用法)
111
-
112
- 如果需要更多控制,也可以手动编写:
113
-
114
- ```tsx
115
- interface MyComponentProps {
116
- field: any;
117
- value: any;
118
- initialData?: any;
119
- fieldName: string;
120
- }
121
-
122
- export function MyComponent(props: MyComponentProps) {
123
- const { value, fieldName } = props;
124
-
125
- // 1. 准备初始值
126
- const initialValue = value || [];
127
- const initialValueJson = JSON.stringify(initialValue);
128
-
129
- // 2. 定义 Alpine.js 数据和方法
130
- const xData = `
131
- {
132
- items: [],
133
- init() {
134
- // 从 data 属性读取初始值
135
- const dataAttr = this.$el.getAttribute('data-initial-value');
136
- if (dataAttr) {
137
- this.items = JSON.parse(dataAttr);
138
- }
139
- this.updateHiddenField();
140
- },
141
- updateHiddenField() {
142
- // 更新隐藏字段
143
- const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
144
- if (hiddenInput) {
145
- hiddenInput.value = JSON.stringify(this.items);
146
- }
147
- }
148
- }`;
149
-
150
- // 3. 返回 JSX
151
- return (
152
- <div
153
- x-data={xData}
154
- data-initial-value={initialValueJson}
155
- x-init="init()"
156
- >
157
- {/* 隐藏字段 */}
158
- <input
159
- type="hidden"
160
- name={fieldName}
161
- value=""
162
- data-testid={`hidden-${fieldName}`}
163
- />
164
-
165
- {/* 组件 UI */}
166
- {/* ... */}
167
- </div>
168
- );
169
- }
170
- ```
171
-
172
- ### 2. 关键要素
173
-
174
- #### 隐藏字段
175
-
176
- ```tsx
177
- <input
178
- type="hidden"
179
- name={fieldName} // 必须与字段名一致
180
- value=""
181
- data-testid={`hidden-${fieldName}`}
182
- />
183
- ```
184
-
185
- **重要**:
186
- - 隐藏字段必须在 `x-data` 的根元素内
187
- - `name` 属性必须与 `fieldName` 一致
188
- - 初始 `value` 可以为空字符串,Alpine.js 会在初始化时更新
189
-
190
- #### Alpine.js 数据初始化
191
-
192
- ```tsx
193
- const xData = `
194
- {
195
- items: [],
196
- init() {
197
- // 从 data 属性读取初始值
198
- const dataAttr = this.$el.getAttribute('data-initial-value');
199
- if (dataAttr) {
200
- this.items = JSON.parse(dataAttr);
201
- }
202
- // 初始化后立即更新隐藏字段
203
- this.updateHiddenField();
204
- },
205
- // ... 其他方法
206
- }`;
207
- ```
208
-
209
- **为什么使用 `data-initial-value`?**
210
- - 避免在模板字符串中嵌入 JSON(可能有转义问题)
211
- - 更清晰的数据传递方式
212
- - 便于调试
213
-
214
- #### 状态同步
215
-
216
- ```tsx
217
- updateHiddenField() {
218
- // 使用 this.$el 限定作用域,避免选择器冲突
219
- const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
220
- if (hiddenInput) {
221
- hiddenInput.value = JSON.stringify(this.items);
222
- }
223
- }
224
- ```
225
-
226
- **关键点**:
227
- - 使用 `this.$el.querySelector` 而不是 `document.querySelector`,限定在组件作用域内
228
- - 每次状态变更后都要调用 `updateHiddenField()`
229
- - 使用 `JSON.stringify` 将状态序列化为字符串
230
-
231
- ## 完整示例:Banner 编辑器
232
-
233
- ```tsx
234
- /**
235
- * Banner 编辑器组件
236
- * 使用 Alpine.js 管理状态,支持动态添加/删除/排序
237
- */
238
-
239
- interface Banner {
240
- url: string;
241
- alt: string;
242
- order: number;
243
- }
244
-
245
- interface BannerEditorProps {
246
- field: any;
247
- value: Banner[] | null;
248
- initialData?: any;
249
- fieldName: string;
250
- }
251
-
252
- export function BannerEditor(props: BannerEditorProps) {
253
- const { value, fieldName } = props;
254
- const initialBanners: Banner[] = value || [];
255
- const bannersJson = JSON.stringify(initialBanners);
256
-
257
- const xData = `
258
- {
259
- banners: [],
260
- init() {
261
- const dataAttr = this.$el.getAttribute('data-initial-value');
262
- if (dataAttr) {
263
- this.banners = JSON.parse(dataAttr);
264
- }
265
- this.updateHiddenField();
266
- },
267
- addBanner() {
268
- this.banners.push({
269
- url: '',
270
- alt: '',
271
- order: this.banners.length
272
- });
273
- this.updateHiddenField();
274
- },
275
- removeBanner(index) {
276
- this.banners.splice(index, 1);
277
- // 重新排序
278
- this.banners.forEach((banner, i) => {
279
- banner.order = i;
280
- });
281
- this.updateHiddenField();
282
- },
283
- updateBanner(index, field, value) {
284
- this.banners[index][field] = value;
285
- this.updateHiddenField();
286
- },
287
- updateHiddenField() {
288
- const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
289
- if (hiddenInput) {
290
- hiddenInput.value = JSON.stringify(this.banners);
291
- }
292
- }
293
- }`;
294
-
295
- return (
296
- <div
297
- x-data={xData}
298
- data-initial-value={bannersJson}
299
- x-init="init()"
300
- x-effect="updateHiddenField()"
301
- className="space-y-4"
302
- >
303
- {/* 隐藏字段,由组件自己维护 */}
304
- <input
305
- type="hidden"
306
- name={fieldName}
307
- value=""
308
- data-testid={`hidden-${fieldName}`}
309
- />
310
-
311
- <div className="flex items-center justify-between">
312
- <span className="text-sm text-gray-600">
313
- 共 <span x-text="banners.length">0</span> 个 Banner
314
- </span>
315
- <button
316
- type="button"
317
- x-on:click="addBanner()"
318
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
319
- data-testid={`${fieldName}-add-button`}
320
- >
321
- + 添加 Banner
322
- </button>
323
- </div>
324
-
325
- {/* 使用 Alpine.js 的 x-for 动态渲染列表 */}
326
- <div className="space-y-4" x-show="banners.length > 0">
327
- <template x-for="(banner, index) in banners">
328
- <div className="border border-gray-200 rounded-lg p-4 space-y-3 bg-white">
329
- <div className="flex items-center justify-between">
330
- <span className="text-sm font-semibold text-gray-700">
331
- Banner <span x-text="index + 1"></span>
332
- </span>
333
- <button
334
- type="button"
335
- x-on:click="removeBanner(index)"
336
- className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded"
337
- >
338
- 删除
339
- </button>
340
- </div>
341
-
342
- <div className="grid grid-cols-1 gap-3">
343
- <div>
344
- <label className="block text-xs font-medium text-gray-700 mb-1">
345
- 图片地址 <span className="text-red-500">*</span>
346
- </label>
347
- <input
348
- type="url"
349
- x-model="banner.url"
350
- placeholder="https://example.com/image.jpg"
351
- required
352
- className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
353
- />
354
- </div>
355
-
356
- <div>
357
- <label className="block text-xs font-medium text-gray-700 mb-1">
358
- 图片描述 <span className="text-red-500">*</span>
359
- </label>
360
- <input
361
- type="text"
362
- x-model="banner.alt"
363
- placeholder="图片描述"
364
- required
365
- className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
366
- />
367
- </div>
368
- </div>
369
- </div>
370
- </template>
371
- </div>
372
-
373
- <div
374
- x-show="banners.length === 0"
375
- className="text-center py-8 text-gray-400 text-sm border border-dashed border-gray-300 rounded-lg"
376
- >
377
- 暂无 Banner,点击"添加 Banner"按钮添加
378
- </div>
379
- </div>
380
- );
381
- }
382
- ```
383
-
384
- ## 在 Feature 中注册
385
-
386
- ```typescript
387
- // examples-v2/pages/destination-page.tsx
388
- import { BannerEditor } from "../components/banner-editor";
389
-
390
- export class DestinationPageModel extends PageModel {
391
- constructor() {
392
- super("destinations", DestinationSchema);
393
-
394
- this.features.create(
395
- new DefaultCreateFeature({
396
- permissionPrefix: "destinations",
397
- createItem: destinationService.createDestination,
398
- formFieldRenderers: {
399
- banners: (props) => {
400
- return <BannerEditor {...props} />;
401
- },
402
- },
403
- })
404
- );
405
-
406
- this.features.edit(
407
- new DefaultEditFeature({
408
- permissionPrefix: "destinations",
409
- getItem: destinationService.getDestination,
410
- updateItem: destinationService.updateDestination,
411
- formFieldRenderers: {
412
- banners: (props) => {
413
- return <BannerEditor {...props} />;
414
- },
415
- },
416
- })
417
- );
418
- }
419
- }
420
- ```
421
-
422
- ## 最佳实践
423
-
424
- ### 1. 使用 `x-effect` 自动同步
425
-
426
- 对于复杂的状态变更,使用 `x-effect` 自动同步:
427
-
428
- ```tsx
429
- <div
430
- x-data={xData}
431
- x-effect="updateHiddenField()" // 状态变更时自动调用
432
- >
433
- ```
434
-
435
- **注意**:`x-effect` 会在每次状态变更时触发,对于频繁更新的场景,可能需要防抖。
436
-
437
- ### 2. 动态列表使用 `template` + `x-for`
438
-
439
- ```tsx
440
- <template x-for="(item, index) in items">
441
- <div>
442
- {/* 列表项内容 */}
443
- </div>
444
- </template>
445
- ```
446
-
447
- **为什么?**
448
- - Alpine.js 完全控制渲染
449
- - 支持动态添加/删除,无数量限制
450
- - 符合 Alpine.js 最佳实践
451
-
452
- ### 3. 使用 `x-bind` 动态属性
453
-
454
- ```tsx
455
- <div x-bind:data-testid="`item-${index}`">
456
- {/* 使用模板字符串 */}
457
- </div>
458
- ```
459
-
460
- ### 4. 测试属性
461
-
462
- 为所有交互元素添加 `data-testid`:
463
-
464
- ```tsx
465
- <button
466
- x-on:click="addItem()"
467
- data-testid={`${fieldName}-add-button`}
468
- >
469
- 添加
470
- </button>
471
- ```
472
-
473
- ### 5. 错误处理
474
-
475
- ```tsx
476
- init() {
477
- const dataAttr = this.$el.getAttribute('data-initial-value');
478
- if (dataAttr) {
479
- try {
480
- this.items = JSON.parse(dataAttr);
481
- } catch (e) {
482
- console.error('Failed to parse initial value:', e);
483
- this.items = [];
484
- }
485
- }
486
- this.updateHiddenField();
487
- }
488
- ```
489
-
490
- ## 常见问题
491
-
492
- ### Q1: 为什么隐藏字段在组件内部?
493
-
494
- **A**: 组件应该自包含,不依赖外部结构。隐藏字段是组件的一部分,应该由组件自己维护。
495
-
496
- ### Q2: 为什么使用 `this.$el.querySelector` 而不是 `document.querySelector`?
497
-
498
- **A**: `this.$el` 限定在组件作用域内,避免选择器冲突。如果页面上有多个相同组件,每个组件都能找到自己对应的隐藏字段。
499
-
500
- ### Q3: 为什么使用 `data-initial-value` 而不是直接在 `x-data` 中嵌入?
501
-
502
- **A**:
503
- - 避免 JSON 转义问题
504
- - 更清晰的数据传递方式
505
- - 便于调试(可以在 DOM 中直接查看)
506
-
507
- ### Q4: 什么时候使用 `x-effect`?
508
-
509
- **A**:
510
- - 简单场景:在方法中手动调用 `updateHiddenField()`
511
- - 复杂场景:使用 `x-effect="updateHiddenField()"` 自动同步
512
-
513
- **注意**:`x-effect` 会在每次状态变更时触发,对于频繁更新的场景,可能需要防抖。
514
-
515
- ### Q5: 如何处理表单验证?
516
-
517
- **A**:
518
- - HTML5 验证:在输入元素上使用 `required`、`pattern` 等属性
519
- - 自定义验证:在 `updateHiddenField()` 中检查数据有效性
520
- - 提交时验证:由后端 Zod schema 验证
521
-
522
- ### Q6: 如何访问其他字段的值?
523
-
524
- **A**: 通过 `initialData` 参数:
525
-
526
- ```tsx
527
- export function MyComponent(props: MyComponentProps) {
528
- const { value, initialData, fieldName } = props;
529
-
530
- // 访问其他字段
531
- const otherFieldValue = initialData?.otherField;
532
-
533
- // ...
534
- }
535
- ```
536
-
537
- ## 参考资源
538
-
539
- - [JSX + Alpine.js 最佳实践](./jsx-alpine-best-practices.md) - JSX 和 Alpine.js 结合使用的详细指南
540
- - [Alpine.js 文档](https://alpinejs.dev/) - Alpine.js 官方文档
541
- - [BannerEditor 示例](../../examples-v2/components/banner-editor.tsx) - 完整的实现示例