imean-service-engine-htmx-plugin 2.2.0 → 2.3.1

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.
package/docs/README.md CHANGED
@@ -69,16 +69,30 @@
69
69
 
70
70
  **适合**: 需要创建复杂交互式表单组件的开发者
71
71
 
72
- ### 🔧 [JSX + Alpine.js 最佳实践](./jsx-alpine-best-practices.md)
72
+ ### 🎯 [Alpine.js 交互式组件开发指南](./alpinejs-interactive-components.md)
73
73
 
74
- JSX 和 Alpine.js 结合使用指南,包含:
75
- - 问题分析
76
- - 渲染策略对比
77
- - 动态列表处理
78
- - 最佳实践总结
79
- - 注意事项
74
+ Alpine.js 交互式组件开发指南,包含:
75
+ - 核心设计原则
76
+ - 完整的开发步骤
77
+ - 数据同步机制
78
+ - 完整示例(字符串数组编辑器、Banner 编辑器)
79
+ - 关键要点和常见问题
80
+ - 检查清单
81
+
82
+ **适合**: 需要开发交互式表单字段渲染器的开发者
83
+
84
+ ### ✨ [Hono HTML 模板字符串最佳实践](./hono-html-best-practices.md)
85
+
86
+ Hono `html` 模板字符串使用指南,包含:
87
+ - 为什么使用 Hono `html` 模板字符串
88
+ - 核心原则和最佳实践
89
+ - `x-data` 属性的正确处理
90
+ - JavaScript 字符串值的安全插入
91
+ - 避免字符串拼接错误
92
+ - 完整示例和常见错误解决方案
93
+ - 检查清单
80
94
 
81
- **适合**: 需要深入理解 JSX 和 Alpine.js 结合使用的开发者
95
+ **适合**: 开发自定义表单字段渲染器的开发者
82
96
 
83
97
  ## 文档使用建议
84
98
 
@@ -0,0 +1,653 @@
1
+ # Alpine.js 交互式组件开发指南
2
+
3
+ 本文档介绍如何在 HtmxAdminPlugin 中开发使用 Alpine.js 的交互式表单字段渲染器组件。
4
+
5
+ ## 概述
6
+
7
+ Alpine.js 交互式组件用于创建复杂的表单字段,如:
8
+ - 动态列表编辑器(字符串数组、对象数组等)
9
+ - 对象编辑器(根据 Zod schema 自动渲染)
10
+ - 其他需要客户端交互的复杂表单控件
11
+
12
+ ## 核心设计原则
13
+
14
+ ### 1. 使用 Hono `html` 模板字符串
15
+
16
+ **始终使用 Hono 的 `html` 模板字符串生成所有 HTML**,避免字符串拼接:
17
+
18
+ ```typescript
19
+ import { html } from "hono/html";
20
+
21
+ export function MyComponent(props: MyComponentProps) {
22
+ return html`
23
+ <div class="container">
24
+ <input name="${props.fieldName}" value="${props.value}" />
25
+ </div>
26
+ `;
27
+ }
28
+ ```
29
+
30
+ **优势**:
31
+ - 自动转义 HTML,避免 XSS 攻击
32
+ - 类型检查和语法高亮
33
+ - 代码清晰易读
34
+
35
+ ### 2. 状态管理由 Alpine.js 负责
36
+
37
+ 所有交互状态都应该在 Alpine.js 的 `x-data` 中管理:
38
+
39
+ ```typescript
40
+ const xDataContent = `{
41
+ items: [],
42
+ init() {
43
+ // 初始化逻辑
44
+ },
45
+ addItem() {
46
+ // 添加项逻辑
47
+ },
48
+ removeItem(index) {
49
+ // 删除项逻辑
50
+ }
51
+ }`;
52
+ ```
53
+
54
+ ### 3. 渲染由 Alpine.js 的 `x-for` 负责
55
+
56
+ 对于动态列表,使用 Alpine.js 的 `template` + `x-for` 进行渲染:
57
+
58
+ ```typescript
59
+ return html`
60
+ <div x-data="${xDataContent}">
61
+ <template x-for="(item, index) in items" x-bind:key="index">
62
+ <div class="item">
63
+ <!-- 项内容 -->
64
+ </div>
65
+ </template>
66
+ </div>
67
+ `;
68
+ ```
69
+
70
+ ## 开发步骤
71
+
72
+ ### 步骤 1:定义组件接口
73
+
74
+ ```typescript
75
+ export interface MyComponentProps {
76
+ /** 字段定义 */
77
+ field: any;
78
+ /** 当前值(已解析) */
79
+ value: any;
80
+ /** 完整的初始数据 */
81
+ initialData?: any;
82
+ /** 字段名(用于更新隐藏字段) */
83
+ fieldName: string;
84
+ /** 其他自定义属性 */
85
+ placeholder?: string;
86
+ }
87
+ ```
88
+
89
+ ### 步骤 2:准备初始数据
90
+
91
+ ```typescript
92
+ export function MyComponent(props: MyComponentProps) {
93
+ const { value, fieldName, placeholder = "请输入" } = props;
94
+
95
+ // 处理初始值
96
+ const initialItems: string[] = value || [];
97
+ const initialValueJson = JSON.stringify(initialItems);
98
+
99
+ // ...
100
+ }
101
+ ```
102
+
103
+ ### 步骤 3:构建 `x-data` 字符串
104
+
105
+ ```typescript
106
+ const xDataContent = `{
107
+ items: ${initialValueJson},
108
+ fieldName: ${JSON.stringify(fieldName)},
109
+ placeholder: ${JSON.stringify(placeholder)},
110
+ init() {
111
+ const dataAttr = this.$el.getAttribute('data-initial-value');
112
+ if (dataAttr) {
113
+ try {
114
+ const parsed = JSON.parse(dataAttr);
115
+ if (Array.isArray(parsed)) {
116
+ this.items = parsed;
117
+ } else {
118
+ this.items = [];
119
+ }
120
+ } catch (e) {
121
+ console.error('Failed to parse initial value:', e);
122
+ this.items = [];
123
+ }
124
+ }
125
+ this.updateHiddenField();
126
+ },
127
+ updateHiddenField() {
128
+ const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
129
+ if (hiddenInput) {
130
+ hiddenInput.value = JSON.stringify(this.items);
131
+ }
132
+ },
133
+ addItem() {
134
+ this.items.push('');
135
+ this.updateHiddenField();
136
+ },
137
+ removeItem(index) {
138
+ this.items.splice(index, 1);
139
+ this.updateHiddenField();
140
+ },
141
+ updateItem(index, value) {
142
+ this.items[index] = value;
143
+ this.updateHiddenField();
144
+ }
145
+ }`;
146
+ ```
147
+
148
+ **关键点**:
149
+ - 使用 `JSON.stringify` 安全地插入字符串值
150
+ - 在 `init()` 中从 `data-initial-value` 属性读取初始值
151
+ - 每次数据变更后调用 `updateHiddenField()` 同步到隐藏字段
152
+
153
+ ### 步骤 4:使用 `html` 模板字符串生成 HTML
154
+
155
+ ```typescript
156
+ return html`
157
+ <div
158
+ x-data="${xDataContent}"
159
+ data-initial-value="${initialValueJson}"
160
+ x-init="init()"
161
+ class="space-y-3"
162
+ >
163
+ <input
164
+ type="hidden"
165
+ name="${fieldName}"
166
+ value=""
167
+ data-testid="hidden-${fieldName}"
168
+ />
169
+ <div class="space-y-3">
170
+ <!-- 组件内容 -->
171
+ </div>
172
+ </div>
173
+ `;
174
+ ```
175
+
176
+ **关键点**:
177
+ - **不要使用 `raw()` 包装 `x-data`**:直接使用 `${xDataContent}`
178
+ - 使用 `data-initial-value` 属性传递初始值
179
+ - 使用 `x-init="init()"` 初始化组件
180
+
181
+ ### 步骤 5:实现动态列表渲染
182
+
183
+ ```typescript
184
+ return html`
185
+ <div x-data="${xDataContent}" data-initial-value="${initialValueJson}" x-init="init()">
186
+ <div class="space-y-2" x-show="items.length > 0">
187
+ <template x-for="(item, index) in items" x-bind:key="index">
188
+ <div class="flex items-center gap-2">
189
+ <input
190
+ type="text"
191
+ x-bind:value="items[index] || ''"
192
+ x-on:input="updateItem(index, $event.target.value)"
193
+ x-bind:data-testid="fieldName + '-input-' + index"
194
+ />
195
+ <button
196
+ type="button"
197
+ x-on:click="removeItem(index)"
198
+ x-bind:data-testid="fieldName + '-remove-button-' + index"
199
+ >
200
+ 删除
201
+ </button>
202
+ </div>
203
+ </template>
204
+ </div>
205
+ </div>
206
+ `;
207
+ ```
208
+
209
+ **关键点**:
210
+ - 使用 `template` + `x-for` 进行动态渲染
211
+ - 在 Alpine.js 表达式中使用字符串拼接:`fieldName + '-input-' + index`
212
+ - 不要使用模板字符串:`` `${fieldName}-input-${index}` ``
213
+
214
+ ## 完整示例
215
+
216
+ ### 示例 1:字符串数组编辑器
217
+
218
+ ```typescript
219
+ import { html } from "hono/html";
220
+
221
+ export interface StringArrayEditorProps {
222
+ field: any;
223
+ value: string[] | null;
224
+ initialData?: any;
225
+ fieldName: string;
226
+ placeholder?: string;
227
+ allowEmpty?: boolean;
228
+ }
229
+
230
+ export function StringArrayEditor(props: StringArrayEditorProps) {
231
+ const {
232
+ value,
233
+ fieldName,
234
+ placeholder = "请输入内容",
235
+ allowEmpty = false,
236
+ } = props;
237
+ const initialItems: string[] = value || [];
238
+ const initialValueJson = JSON.stringify(initialItems);
239
+
240
+ const xDataContent = `{
241
+ items: ${initialValueJson},
242
+ fieldName: ${JSON.stringify(fieldName)},
243
+ placeholder: ${JSON.stringify(placeholder)},
244
+ allowEmpty: ${allowEmpty},
245
+ init() {
246
+ const dataAttr = this.$el.getAttribute('data-initial-value');
247
+ if (dataAttr) {
248
+ try {
249
+ const parsed = JSON.parse(dataAttr);
250
+ if (Array.isArray(parsed)) {
251
+ this.items = parsed;
252
+ } else {
253
+ this.items = [];
254
+ }
255
+ } catch (e) {
256
+ console.error('Failed to parse initial value:', e);
257
+ this.items = [];
258
+ }
259
+ }
260
+ this.updateHiddenField();
261
+ },
262
+ updateHiddenField() {
263
+ const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
264
+ if (hiddenInput) {
265
+ hiddenInput.value = JSON.stringify(this.items);
266
+ }
267
+ },
268
+ addItem() {
269
+ this.items.push('');
270
+ this.updateHiddenField();
271
+ this.$nextTick(() => {
272
+ const inputs = this.$el.querySelectorAll('input[data-testid*="-input-"]');
273
+ if (inputs.length > 0) {
274
+ const lastInput = inputs[inputs.length - 1];
275
+ if (lastInput && lastInput.focus) {
276
+ lastInput.focus();
277
+ }
278
+ }
279
+ });
280
+ },
281
+ removeItem(index) {
282
+ this.items.splice(index, 1);
283
+ this.updateHiddenField();
284
+ },
285
+ updateItem(index, value) {
286
+ this.items[index] = value;
287
+ this.updateHiddenField();
288
+ }
289
+ }`;
290
+
291
+ return html`
292
+ <div
293
+ x-data="${xDataContent}"
294
+ data-initial-value="${initialValueJson}"
295
+ x-init="init()"
296
+ class="space-y-3"
297
+ >
298
+ <input
299
+ type="hidden"
300
+ name="${fieldName}"
301
+ value=""
302
+ data-testid="hidden-${fieldName}"
303
+ />
304
+ <div class="space-y-3">
305
+ <div class="flex items-center justify-between">
306
+ <span class="text-sm text-gray-600">
307
+ 共 <span x-text="items.length">0</span> 项
308
+ </span>
309
+ <button
310
+ type="button"
311
+ x-on:click="addItem()"
312
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
313
+ data-testid="${fieldName}-add-button"
314
+ >
315
+ 添加项
316
+ </button>
317
+ </div>
318
+
319
+ <div class="space-y-2" x-show="items.length > 0">
320
+ <template x-for="(item, index) in items" x-bind:key="index">
321
+ <div class="flex items-center gap-2">
322
+ <input
323
+ type="text"
324
+ x-bind:value="items[index] || ''"
325
+ x-on:input="updateItem(index, $event.target.value)"
326
+ x-bind:placeholder="placeholder + ' ' + (index + 1)"
327
+ class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
328
+ x-bind:data-testid="fieldName + '-input-' + index"
329
+ x-bind:required="!allowEmpty"
330
+ />
331
+ <button
332
+ type="button"
333
+ x-on:click="removeItem(index)"
334
+ class="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg"
335
+ x-bind:data-testid="fieldName + '-remove-button-' + index"
336
+ >
337
+ 删除
338
+ </button>
339
+ </div>
340
+ </template>
341
+ </div>
342
+
343
+ <div
344
+ x-show="items.length === 0"
345
+ class="text-center py-8 text-gray-400 text-sm border border-dashed border-gray-300 rounded-lg"
346
+ >
347
+ 暂无项,点击"添加项"按钮添加
348
+ </div>
349
+ </div>
350
+ </div>
351
+ `;
352
+ }
353
+ ```
354
+
355
+ ### 示例 2:对象数组编辑器(Banner 编辑器)
356
+
357
+ ```typescript
358
+ import { html } from "hono/html";
359
+
360
+ interface Banner {
361
+ url: string;
362
+ alt: string;
363
+ order: number;
364
+ }
365
+
366
+ interface BannerEditorProps {
367
+ field: any;
368
+ value: Banner[] | null;
369
+ initialData?: any;
370
+ fieldName: string;
371
+ }
372
+
373
+ export function BannerEditor(props: BannerEditorProps) {
374
+ const { value, fieldName } = props;
375
+ const initialBanners: Banner[] = value || [];
376
+ const initialValueJson = JSON.stringify(initialBanners);
377
+
378
+ const xDataContent = `{
379
+ banners: ${initialValueJson},
380
+ fieldName: ${JSON.stringify(fieldName)},
381
+ init() {
382
+ const dataAttr = this.$el.getAttribute('data-initial-value');
383
+ if (dataAttr) {
384
+ try {
385
+ const parsed = JSON.parse(dataAttr);
386
+ if (Array.isArray(parsed)) {
387
+ this.banners = parsed;
388
+ } else {
389
+ this.banners = [];
390
+ }
391
+ } catch (e) {
392
+ console.error('Failed to parse initial value:', e);
393
+ this.banners = [];
394
+ }
395
+ }
396
+ this.updateHiddenField();
397
+ },
398
+ updateHiddenField() {
399
+ const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
400
+ if (hiddenInput) {
401
+ hiddenInput.value = JSON.stringify(this.banners);
402
+ }
403
+ },
404
+ addBanner() {
405
+ this.banners.push({
406
+ url: '',
407
+ alt: '',
408
+ order: this.banners.length
409
+ });
410
+ this.updateHiddenField();
411
+ },
412
+ removeBanner(index) {
413
+ this.banners.splice(index, 1);
414
+ this.banners.forEach((banner, i) => {
415
+ banner.order = i;
416
+ });
417
+ this.updateHiddenField();
418
+ }
419
+ }`;
420
+
421
+ return html`
422
+ <div
423
+ x-data="${xDataContent}"
424
+ data-initial-value="${initialValueJson}"
425
+ x-init="init()"
426
+ class="space-y-4"
427
+ >
428
+ <input
429
+ type="hidden"
430
+ name="${fieldName}"
431
+ value=""
432
+ data-testid="hidden-${fieldName}"
433
+ />
434
+ <div class="flex items-center justify-between">
435
+ <span class="text-sm text-gray-600">
436
+ 共 <span x-text="banners.length">0</span> 个 Banner
437
+ </span>
438
+ <button
439
+ type="button"
440
+ x-on:click="addBanner()"
441
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
442
+ data-testid="${fieldName}-add-button"
443
+ >
444
+ + 添加 Banner
445
+ </button>
446
+ </div>
447
+
448
+ <div class="space-y-4" x-show="banners.length > 0">
449
+ <template x-for="(banner, index) in banners" x-bind:key="index">
450
+ <div
451
+ class="border border-gray-200 rounded-lg p-4 space-y-3 bg-white"
452
+ x-bind:data-testid="fieldName + '-item-' + index"
453
+ >
454
+ <div class="flex items-center justify-between">
455
+ <span class="text-sm font-semibold text-gray-700">
456
+ Banner <span x-text="index + 1"></span>
457
+ </span>
458
+ <button
459
+ type="button"
460
+ x-on:click="removeBanner(index)"
461
+ class="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
462
+ x-bind:data-testid="fieldName + '-remove-button-' + index"
463
+ >
464
+ 删除
465
+ </button>
466
+ </div>
467
+
468
+ <div class="grid grid-cols-1 gap-3">
469
+ <div>
470
+ <label class="block text-xs font-medium text-gray-700 mb-1">
471
+ 图片地址 <span class="text-red-500">*</span>
472
+ </label>
473
+ <input
474
+ type="url"
475
+ x-model="banner.url"
476
+ placeholder="https://example.com/image.jpg"
477
+ required
478
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
479
+ x-bind:data-testid="fieldName + '-url-' + index"
480
+ />
481
+ </div>
482
+
483
+ <div>
484
+ <label class="block text-xs font-medium text-gray-700 mb-1">
485
+ 图片描述 <span class="text-red-500">*</span>
486
+ </label>
487
+ <input
488
+ type="text"
489
+ x-model="banner.alt"
490
+ placeholder="图片描述"
491
+ required
492
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
493
+ x-bind:data-testid="fieldName + '-alt-' + index"
494
+ />
495
+ </div>
496
+
497
+ <div>
498
+ <label class="block text-xs font-medium text-gray-700 mb-1">
499
+ 排序
500
+ </label>
501
+ <input
502
+ type="number"
503
+ x-model.number="banner.order"
504
+ min="0"
505
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
506
+ x-bind:data-testid="fieldName + '-order-' + index"
507
+ />
508
+ </div>
509
+ </div>
510
+
511
+ <div x-show="banner.url" class="mt-3">
512
+ <label class="block text-xs font-medium text-gray-700 mb-1">
513
+ 预览
514
+ </label>
515
+ <div class="border border-gray-200 rounded-lg overflow-hidden">
516
+ <img
517
+ x-bind:src="banner.url"
518
+ x-bind:alt="banner.alt"
519
+ class="w-full h-32 object-cover"
520
+ onerror="this.style.display='none'"
521
+ />
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </template>
526
+ </div>
527
+
528
+ <div
529
+ x-show="banners.length === 0"
530
+ class="text-center py-8 text-gray-400 text-sm border border-dashed border-gray-300 rounded-lg"
531
+ >
532
+ 暂无 Banner,点击"添加 Banner"按钮添加
533
+ </div>
534
+ </div>
535
+ `;
536
+ }
537
+ ```
538
+
539
+ ## 关键要点
540
+
541
+ ### 1. `x-data` 属性的处理
542
+
543
+ **❌ 错误**:使用 `raw()` 包装
544
+ ```typescript
545
+ import { html, raw } from "hono/html";
546
+ return html`<div x-data='${raw(xDataContent)}'>...</div>`;
547
+ ```
548
+
549
+ **✅ 正确**:直接使用
550
+ ```typescript
551
+ import { html } from "hono/html";
552
+ return html`<div x-data="${xDataContent}">...</div>`;
553
+ ```
554
+
555
+ ### 2. JavaScript 字符串值的安全插入
556
+
557
+ 使用 `JSON.stringify` 插入字符串值:
558
+
559
+ ```typescript
560
+ const xDataContent = `{
561
+ fieldName: ${JSON.stringify(fieldName)},
562
+ placeholder: ${JSON.stringify(placeholder)},
563
+ items: ${JSON.stringify(initialItems)}
564
+ }`;
565
+ ```
566
+
567
+ ### 3. Alpine.js 表达式中的字符串拼接
568
+
569
+ 在 Alpine.js 表达式中(如 `x-bind:data-testid`),使用字符串拼接:
570
+
571
+ **❌ 错误**:
572
+ ```typescript
573
+ x-bind:data-testid="`${fieldName}-item-${index}`"
574
+ ```
575
+
576
+ **✅ 正确**:
577
+ ```typescript
578
+ x-bind:data-testid="fieldName + '-item-' + index"
579
+ ```
580
+
581
+ ### 4. 数据同步机制
582
+
583
+ 每次数据变更后,必须调用 `updateHiddenField()` 同步到隐藏字段:
584
+
585
+ ```typescript
586
+ updateItem(index, value) {
587
+ this.items[index] = value;
588
+ this.updateHiddenField(); // 必须调用
589
+ }
590
+ ```
591
+
592
+ ### 5. 初始值处理
593
+
594
+ 在 `init()` 方法中从 `data-initial-value` 属性读取初始值:
595
+
596
+ ```typescript
597
+ init() {
598
+ const dataAttr = this.$el.getAttribute('data-initial-value');
599
+ if (dataAttr) {
600
+ try {
601
+ this.items = JSON.parse(dataAttr);
602
+ } catch (e) {
603
+ console.error('Failed to parse initial value:', e);
604
+ this.items = [];
605
+ }
606
+ }
607
+ this.updateHiddenField();
608
+ }
609
+ ```
610
+
611
+ ## 常见问题
612
+
613
+ ### Q: 为什么不能使用 `raw()` 包装 `x-data`?
614
+
615
+ A: Hono 会自动转义 HTML 属性值,使用 `raw()` 会破坏转义机制,可能导致 JavaScript 语法错误。
616
+
617
+ ### Q: 为什么在 Alpine.js 表达式中不能使用模板字符串?
618
+
619
+ A: Alpine.js 表达式是 JavaScript 代码,模板字符串在运行时可能无法正确解析。使用字符串拼接更可靠。
620
+
621
+ ### Q: 如何确保数据正确回填?
622
+
623
+ A:
624
+ 1. 在 `init()` 中从 `data-initial-value` 读取初始值
625
+ 2. 确保初始值格式正确(使用 `JSON.stringify`)
626
+ 3. 在 `x-data` 中提供默认值
627
+
628
+ ### Q: 如何调试 Alpine.js 组件?
629
+
630
+ A:
631
+ 1. 在浏览器控制台检查 `x-data` 是否正确设置
632
+ 2. 检查 `data-initial-value` 属性的值
633
+ 3. 在 `init()` 方法中添加 `console.log` 调试
634
+ 4. 使用 Alpine.js DevTools(如果可用)
635
+
636
+ ## 检查清单
637
+
638
+ 开发 Alpine.js 交互式组件时,确保:
639
+
640
+ - [ ] 使用 `html` 模板字符串生成所有 HTML
641
+ - [ ] 不在 `x-data` 属性上使用 `raw()`
642
+ - [ ] 在 `x-data` 中使用 `JSON.stringify` 插入字符串值
643
+ - [ ] 在 `init()` 中从 `data-initial-value` 读取初始值
644
+ - [ ] 每次数据变更后调用 `updateHiddenField()`
645
+ - [ ] 使用 `template` + `x-for` 进行动态列表渲染
646
+ - [ ] 在 Alpine.js 表达式中使用字符串拼接
647
+ - [ ] 为所有交互元素添加 `data-testid` 属性
648
+ - [ ] 处理空状态和错误情况
649
+
650
+ ## 相关文档
651
+
652
+ - [Hono HTML 模板字符串最佳实践](./hono-html-best-practices.md) - 详细的 `html` 模板字符串使用指南
653
+ - [自定义表单字段渲染器](./custom-form-field-renderers.md) - 表单字段渲染器的设计原则和接口说明