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/dist/index.d.mts +76 -65
- package/dist/index.d.ts +76 -65
- package/dist/index.js +1046 -284
- package/dist/index.mjs +1044 -283
- package/docs/README.md +22 -8
- package/docs/alpinejs-interactive-components.md +653 -0
- package/docs/hono-html-best-practices.md +509 -0
- package/package.json +14 -13
- package/docs/jsx-alpine-best-practices.md +0 -197
package/docs/README.md
CHANGED
|
@@ -69,16 +69,30 @@
|
|
|
69
69
|
|
|
70
70
|
**适合**: 需要创建复杂交互式表单组件的开发者
|
|
71
71
|
|
|
72
|
-
###
|
|
72
|
+
### 🎯 [Alpine.js 交互式组件开发指南](./alpinejs-interactive-components.md)
|
|
73
73
|
|
|
74
|
-
|
|
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
|
-
**适合**:
|
|
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) - 表单字段渲染器的设计原则和接口说明
|