imean-service-engine-htmx-plugin 2.9.2 → 2.9.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.
package/dist/index.js CHANGED
@@ -436,7 +436,7 @@ function renderFormField(field, initialData, formFieldRenderers) {
436
436
  name: field.name,
437
437
  required: field.required,
438
438
  placeholder: fieldSchema.description || (isJsonString(value) ? "JSON \u683C\u5F0F\u6570\u636E" : ""),
439
- rows: isJsonString(value) ? Math.max(10, value.split("\n").length) : Math.max(5, Math.ceil(value?.length || 0 / 100)),
439
+ rows: isJsonString(value) ? Math.max(10, value.split("\n").length) : Math.max(5, Math.min(Math.ceil(value?.length || 0 / 100), 10)),
440
440
  className: "w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 resize-y font-mono text-sm",
441
441
  "data-testid": `input-${field.name}`,
442
442
  children: isJsonString(value) ? formatJsonString(value) : value
@@ -1449,7 +1449,23 @@ var DefaultCreateFeature = class extends BaseFormFeature {
1449
1449
  return basePath;
1450
1450
  }
1451
1451
  async getInitialData(context) {
1452
- return void 0;
1452
+ const query = context.query || {};
1453
+ const systemParams = /* @__PURE__ */ new Set(["dialog", "_t"]);
1454
+ const filteredQuery = {};
1455
+ for (const [key, value] of Object.entries(query)) {
1456
+ if (!systemParams.has(key) && value !== void 0 && value !== null && value !== "") {
1457
+ filteredQuery[key] = value;
1458
+ }
1459
+ }
1460
+ if (Object.keys(filteredQuery).length === 0) {
1461
+ return void 0;
1462
+ }
1463
+ const nestedData = parseNestedFormData2(filteredQuery);
1464
+ const processedData = preprocessFormData(
1465
+ nestedData,
1466
+ this.schema
1467
+ );
1468
+ return processedData;
1453
1469
  }
1454
1470
  async handleSubmit(context, validatedData) {
1455
1471
  return await this.createItem(validatedData);
package/dist/index.mjs CHANGED
@@ -434,7 +434,7 @@ function renderFormField(field, initialData, formFieldRenderers) {
434
434
  name: field.name,
435
435
  required: field.required,
436
436
  placeholder: fieldSchema.description || (isJsonString(value) ? "JSON \u683C\u5F0F\u6570\u636E" : ""),
437
- rows: isJsonString(value) ? Math.max(10, value.split("\n").length) : Math.max(5, Math.ceil(value?.length || 0 / 100)),
437
+ rows: isJsonString(value) ? Math.max(10, value.split("\n").length) : Math.max(5, Math.min(Math.ceil(value?.length || 0 / 100), 10)),
438
438
  className: "w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 resize-y font-mono text-sm",
439
439
  "data-testid": `input-${field.name}`,
440
440
  children: isJsonString(value) ? formatJsonString(value) : value
@@ -1447,7 +1447,23 @@ var DefaultCreateFeature = class extends BaseFormFeature {
1447
1447
  return basePath;
1448
1448
  }
1449
1449
  async getInitialData(context) {
1450
- return void 0;
1450
+ const query = context.query || {};
1451
+ const systemParams = /* @__PURE__ */ new Set(["dialog", "_t"]);
1452
+ const filteredQuery = {};
1453
+ for (const [key, value] of Object.entries(query)) {
1454
+ if (!systemParams.has(key) && value !== void 0 && value !== null && value !== "") {
1455
+ filteredQuery[key] = value;
1456
+ }
1457
+ }
1458
+ if (Object.keys(filteredQuery).length === 0) {
1459
+ return void 0;
1460
+ }
1461
+ const nestedData = parseNestedFormData2(filteredQuery);
1462
+ const processedData = preprocessFormData(
1463
+ nestedData,
1464
+ this.schema
1465
+ );
1466
+ return processedData;
1451
1467
  }
1452
1468
  async handleSubmit(context, validatedData) {
1453
1469
  return await this.createItem(validatedData);
@@ -0,0 +1,1030 @@
1
+ # imean-service-engine-htmx-plugin AI 使用指南
2
+
3
+ 本文档面向 AI 编码助手,指导如何使用 `imean-service-engine-htmx-plugin` 快速构建 HTMX 驱动的管理后台。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install imean-service-engine imean-service-engine-htmx-plugin
9
+ ```
10
+
11
+ ## 核心概念
12
+
13
+ - **HtmxAdminPlugin**: 插件主类,负责配置和注册页面
14
+ - **PageModel**: 页面模型,定义页面的名称、元数据和功能
15
+ - **Feature**: 功能模块,如列表、详情、创建、编辑、删除、自定义
16
+ - **Zod Schema**: 使用 Zod 定义数据结构,自动生成表单和验证
17
+
18
+ ## 快速开始
19
+
20
+ ```typescript
21
+ import { Factory } from "imean-service-engine";
22
+ import { z } from "zod";
23
+ import {
24
+ HtmxAdminPlugin,
25
+ PageModel,
26
+ DefaultListFeature,
27
+ DefaultDetailFeature,
28
+ DefaultCreateFeature,
29
+ DefaultEditFeature,
30
+ DefaultDeleteFeature,
31
+ CustomFeature,
32
+ } from "imean-service-engine-htmx-plugin";
33
+
34
+ // 1. 定义 Zod Schema
35
+ const ArticleSchema = z.object({
36
+ id: z.number().optional(),
37
+ title: z.string().min(1).describe("标题"),
38
+ content: z.string().describe("内容"),
39
+ status: z.enum(["draft", "published"]).describe("状态"),
40
+ });
41
+
42
+ // 2. 创建 PageModel
43
+ class ArticlePageModel extends PageModel {
44
+ constructor() {
45
+ super("articles", {
46
+ title: "文章管理",
47
+ description: "管理文章内容",
48
+ });
49
+
50
+ // 注册列表功能
51
+ this.features.list(
52
+ new DefaultListFeature({
53
+ schema: ArticleSchema,
54
+ getList: async (params) => {
55
+ // 返回 { items, total, page, pageSize, totalPages }
56
+ },
57
+ fieldNames: ["title", "status"], // 列表显示的字段
58
+ })
59
+ );
60
+
61
+ // 注册详情功能
62
+ this.features.detail(
63
+ new DefaultDetailFeature({
64
+ schema: ArticleSchema,
65
+ getItem: async (id) => {
66
+ // 返回单条数据
67
+ },
68
+ })
69
+ );
70
+
71
+ // 注册创建功能
72
+ this.features.create(
73
+ new DefaultCreateFeature({
74
+ schema: ArticleSchema,
75
+ createItem: async (data) => {
76
+ // 创建并返回新数据
77
+ },
78
+ })
79
+ );
80
+
81
+ // 注册编辑功能
82
+ this.features.edit(
83
+ new DefaultEditFeature({
84
+ schema: ArticleSchema,
85
+ getItem: async (id) => {
86
+ // 获取数据
87
+ },
88
+ updateItem: async (id, data) => {
89
+ // 更新数据
90
+ },
91
+ })
92
+ );
93
+
94
+ // 注册删除功能
95
+ this.features.delete(
96
+ new DefaultDeleteFeature({
97
+ deleteItem: async (id) => {
98
+ // 删除数据,返回 boolean
99
+ },
100
+ })
101
+ );
102
+ }
103
+ }
104
+
105
+ // 3. 配置插件
106
+ const adminPlugin = new HtmxAdminPlugin({
107
+ title: "管理后台",
108
+ prefix: "/admin",
109
+ homePath: "/admin/dashboard",
110
+ pages: [new ArticlePageModel()],
111
+ navigation: [
112
+ { label: "文章管理", icon: "📝", href: "/admin/articles/list" },
113
+ ],
114
+ });
115
+
116
+ // 4. 启动服务
117
+ const { Microservice } = Factory.create(adminPlugin);
118
+ const engine = new Microservice({ name: "my-admin", version: "1.0.0" });
119
+ await engine.start(3000);
120
+ ```
121
+
122
+ ## Schema 定义最佳实践
123
+
124
+ ### 基本规范
125
+
126
+ - **必须使用 `.describe()`**:设置字段的中文标签,用于表单和列表显示
127
+ - **id 字段设为 optional**:创建时无 id,由后端生成
128
+ - **枚举用 `z.enum()`**:自动生成下拉选项
129
+ - **时间戳用 string**:便于展示和传输
130
+
131
+ ```typescript
132
+ const ProductSchema = z.object({
133
+ id: z.number().optional(),
134
+ name: z.string().min(1).describe("商品名称"),
135
+ price: z.number().min(0).describe("价格"),
136
+ category: z.enum(["电子", "服装", "食品"]).describe("分类"),
137
+ description: z.string().optional().describe("描述"),
138
+ isActive: z.boolean().optional().describe("是否上架"),
139
+ tags: z.array(z.string()).optional().describe("标签"),
140
+ createdAt: z.string().optional().describe("创建时间"),
141
+ });
142
+ ```
143
+
144
+ ### 复杂结构定义
145
+
146
+ 嵌套对象和数组可正常定义,配合自定义渲染器使用:
147
+
148
+ ```typescript
149
+ const DestinationSchema = z.object({
150
+ id: z.number().optional(),
151
+ name: z.string().min(1).describe("名称"),
152
+
153
+ // 嵌套数组对象
154
+ banners: z.array(z.object({
155
+ url: z.url().describe("图片地址"),
156
+ alt: z.string().describe("图片描述"),
157
+ order: z.number().int().min(0).describe("排序"),
158
+ })).default([]).describe("Banner图片"),
159
+
160
+ // 嵌套对象
161
+ metadata: z.object({
162
+ timezone: z.string().optional().describe("时区"),
163
+ currency: z.string().optional().describe("货币"),
164
+ language: z.array(z.string()).optional().describe("语言"),
165
+ }).optional().describe("元数据"),
166
+ });
167
+ ```
168
+
169
+ ### 筛选 Schema(独立定义)
170
+
171
+ 列表筛选字段可能与数据模型不完全对应,建议独立定义:
172
+
173
+ ```typescript
174
+ const ArticleFilterSchema = z.object({
175
+ category: z.enum(["技术", "产品", "设计"]).optional().describe("分类"),
176
+ status: z.enum(["draft", "published"]).optional().describe("状态"),
177
+ author: z.string().optional().describe("作者"),
178
+ });
179
+ ```
180
+
181
+ ## URL 路由规则
182
+
183
+ 每个 Feature 会自动生成对应的 URL 路由,规则如下:
184
+
185
+ ```
186
+ {prefix}/{modelName}/{featurePath}
187
+ ```
188
+
189
+ ### 默认路由表
190
+
191
+ | Feature | 方法 | 路径 | 示例(modelName="users") |
192
+ |---------|------|------|---------------------------|
193
+ | list | GET | `/list` | `/admin/users/list` |
194
+ | detail | GET | `/detail/:id` | `/admin/users/detail/123` |
195
+ | create | GET | `/new` | `/admin/users/new` |
196
+ | create | POST | `/new` | `/admin/users/new` |
197
+ | edit | GET | `/edit/:id` | `/admin/users/edit/123` |
198
+ | edit | POST | `/edit/:id` | `/admin/users/edit/123` |
199
+ | delete | DELETE | `/delete/:id` | `/admin/users/delete/123` |
200
+ | custom | 自定义 | 自定义 | `/admin/users/export` |
201
+
202
+ ### 弹窗模式(Dialog Mode)
203
+
204
+ 几乎所有页面都同时支持**整页渲染**和**弹窗渲染**,通过 URL 参数 `?dialog=true` 切换:
205
+
206
+ ```typescript
207
+ // 整页模式
208
+ /admin/users/detail/123
209
+
210
+ // 弹窗模式(在当前页面弹窗中打开)
211
+ /admin/users/detail/123?dialog=true
212
+ ```
213
+
214
+ 在列表页中,可以通过 `openMode` 配置各操作的默认打开方式:
215
+
216
+ ```typescript
217
+ new DefaultListFeature({
218
+ openMode: {
219
+ create: "dialog", // 新建在弹窗中打开
220
+ detail: "dialog", // 详情在弹窗中打开
221
+ edit: "page", // 编辑跳转整页
222
+ },
223
+ });
224
+ ```
225
+
226
+ ### 跨模块链接
227
+
228
+ 利用路由规则,可以在不同业务模块间建立链接:
229
+
230
+ ```typescript
231
+ // 在用户列表中添加"查看留言"链接
232
+ new DefaultListFeature({
233
+ schema: UserSchema,
234
+ getList: userService.getList,
235
+ fieldNames: ["name", "email", "messageCount"],
236
+ fieldRenderers: {
237
+ messageCount: ({ value, item }) => (
238
+ <a
239
+ href={`/admin/messages/list?userId=${item.id}`}
240
+ hx-get={`/admin/messages/list?userId=${item.id}&dialog=true`}
241
+ class="text-blue-600 hover:underline"
242
+ >
243
+ {value} 条留言
244
+ </a>
245
+ ),
246
+ },
247
+ });
248
+ ```
249
+
250
+ ## Schema 复用与扩展
251
+
252
+ 使用 Zod 的 `pick`、`omit`、`extend` 实现 Schema 复用:
253
+
254
+ ```typescript
255
+ // 基础 Schema(完整数据结构)
256
+ const UserBaseSchema = z.object({
257
+ id: z.number().optional(),
258
+ name: z.string().min(1).describe("姓名"),
259
+ email: z.string().email().describe("邮箱"),
260
+ phone: z.string().optional().describe("电话"),
261
+ avatar: z.string().optional().describe("头像"),
262
+ role: z.enum(["admin", "editor", "viewer"]).describe("角色"),
263
+ department: z.string().optional().describe("部门"),
264
+ createdAt: z.string().optional().describe("创建时间"),
265
+ updatedAt: z.string().optional().describe("更新时间"),
266
+ });
267
+
268
+ // 列表 Schema(仅显示关键字段)
269
+ const UserListSchema = UserBaseSchema.pick({
270
+ id: true,
271
+ name: true,
272
+ email: true,
273
+ role: true,
274
+ createdAt: true,
275
+ });
276
+
277
+ // 创建表单 Schema(排除自动生成字段)
278
+ const UserCreateSchema = UserBaseSchema.omit({
279
+ id: true,
280
+ createdAt: true,
281
+ updatedAt: true,
282
+ });
283
+
284
+ // 详情 Schema(完整信息 + 关联数据)
285
+ const UserDetailSchema = UserBaseSchema.extend({
286
+ messageCount: z.number().optional().describe("留言数"),
287
+ lastLoginAt: z.string().optional().describe("最后登录"),
288
+ });
289
+
290
+ // 筛选 Schema
291
+ const UserFilterSchema = z.object({
292
+ role: z.enum(["admin", "editor", "viewer"]).optional().describe("角色"),
293
+ department: z.string().optional().describe("部门"),
294
+ });
295
+ ```
296
+
297
+ ### 在 PageModel 中使用不同 Schema
298
+
299
+ ```typescript
300
+ class UserPageModel extends PageModel {
301
+ constructor() {
302
+ super("users", { title: "用户管理" });
303
+
304
+ this.features.list(
305
+ new DefaultListFeature({
306
+ schema: UserListSchema, // 列表用精简 Schema
307
+ getList: userService.getList,
308
+ filterSchema: UserFilterSchema,
309
+ fieldRenderers: {
310
+ // 添加跨模块链接
311
+ name: ({ value, item }) => (
312
+ <a
313
+ href={`/admin/messages/list?userId=${item.id}`}
314
+ hx-get={`/admin/messages/list?userId=${item.id}&dialog=true`}
315
+ class="text-blue-600 hover:underline"
316
+ >
317
+ {value} 的留言
318
+ </a>
319
+ ),
320
+ },
321
+ })
322
+ );
323
+
324
+ this.features.detail(
325
+ new DefaultDetailFeature({
326
+ schema: UserDetailSchema, // 详情用扩展 Schema
327
+ getItem: userService.getDetail,
328
+ })
329
+ );
330
+
331
+ this.features.create(
332
+ new DefaultCreateFeature({
333
+ schema: UserCreateSchema, // 创建用精简 Schema
334
+ createItem: userService.create,
335
+ })
336
+ );
337
+
338
+ this.features.edit(
339
+ new DefaultEditFeature({
340
+ schema: UserCreateSchema, // 编辑复用创建 Schema
341
+ getItem: userService.get,
342
+ updateItem: userService.update,
343
+ })
344
+ );
345
+
346
+ this.features.delete(
347
+ new DefaultDeleteFeature({
348
+ deleteItem: userService.delete,
349
+ })
350
+ );
351
+ }
352
+ }
353
+ ```
354
+
355
+ ## Feature 配置详解
356
+
357
+ ### DefaultListFeature
358
+
359
+ ```typescript
360
+ new DefaultListFeature({
361
+ schema: ArticleSchema,
362
+ idKey: "id", // 主键字段,默认 "id"
363
+ getList: async (params) => { /* ListParams => ListResult<T> */ },
364
+ deleteItem: async (id) => { /* 可选,启用行内删除 */ },
365
+
366
+ // 显示字段
367
+ fieldNames: ["title", "status", "createdAt"],
368
+
369
+ // 筛选配置(可选,使用独立 Schema)
370
+ filterSchema: ArticleFilterSchema,
371
+
372
+ // 打开方式配置
373
+ openMode: {
374
+ create: "dialog", // 新建:弹窗
375
+ detail: "newWindow", // 详情:新窗口
376
+ edit: "page", // 编辑:整页
377
+ },
378
+
379
+ // 自定义字段渲染
380
+ fieldRenderers: {
381
+ status: ({ value }) => (
382
+ <span class={value === "published" ? "text-green-600" : "text-gray-600"}>
383
+ {value === "published" ? "已发布" : "草稿"}
384
+ </span>
385
+ ),
386
+ },
387
+
388
+ // 自定义 Header
389
+ header: (context) => (
390
+ <div class="p-4">
391
+ <h2>自定义标题区域</h2>
392
+ </div>
393
+ ),
394
+ });
395
+ ```
396
+
397
+ ### DefaultDetailFeature
398
+
399
+ ```typescript
400
+ new DefaultDetailFeature({
401
+ schema: ArticleSchema,
402
+ getItem: async (id) => { /* 返回单条数据 */ },
403
+
404
+ // 弹窗配置
405
+ dialogSize: "xl", // "sm" | "md" | "lg" | "xl" | "full"
406
+ closeOnBackdropClick: true,
407
+
408
+ // 动态标题
409
+ title: (context, item) => item?.title || "详情",
410
+ description: (context, item) => `作者:${item?.author}`,
411
+
412
+ // 显示字段
413
+ fieldNames: ["title", "content", "author", "status"],
414
+
415
+ // 自定义字段渲染
416
+ fieldRenderers: {
417
+ content: ({ value }) => (
418
+ <div class="whitespace-pre-wrap">{value}</div>
419
+ ),
420
+ },
421
+ });
422
+ ```
423
+
424
+ ### DefaultCreateFeature / DefaultEditFeature
425
+
426
+ ```typescript
427
+ new DefaultCreateFeature({
428
+ schema: ArticleSchema,
429
+ createItem: async (data) => { /* 返回创建的数据 */ },
430
+ dialogSize: "xl",
431
+ closeOnBackdropClick: false, // 编辑表单建议禁用遮罩关闭
432
+
433
+ // 自定义表单字段渲染器
434
+ fieldRenderers: {
435
+ tags: TagsEditor, // 使用内置的标签编辑器
436
+ },
437
+ });
438
+
439
+ new DefaultEditFeature({
440
+ schema: ArticleSchema,
441
+ getItem: async (id) => { /* 获取数据 */ },
442
+ updateItem: async (id, data) => { /* 更新数据 */ },
443
+ title: (context, item) => `编辑:${item?.title}`,
444
+ });
445
+ ```
446
+
447
+ ### DefaultDeleteFeature
448
+
449
+ ```typescript
450
+ new DefaultDeleteFeature({
451
+ deleteItem: async (id) => { /* 返回 boolean */ },
452
+ });
453
+ ```
454
+
455
+ ### CustomFeature(自定义页面)
456
+
457
+ ```typescript
458
+ new CustomFeature({
459
+ name: "dashboard",
460
+ routes: [{ method: "get", path: "" }],
461
+ render: async (context) => (
462
+ <div class="p-6">
463
+ <h1>仪表盘</h1>
464
+ <p>自定义页面内容</p>
465
+ </div>
466
+ ),
467
+ });
468
+ ```
469
+
470
+ ## 表单处理机制(重要)
471
+
472
+ 表单处理是插件最复杂的部分,理解其原理对正确使用至关重要。
473
+
474
+ ### Schema 定义决定表单行为
475
+
476
+ Schema 定义直接影响:
477
+ 1. **表单字段生成**:根据字段类型自动生成 input/select/textarea
478
+ 2. **必填校验**:非 optional 字段必须提供值,否则 Zod 校验失败
479
+ 3. **类型转换**:表单提交的字符串会根据 Schema 类型自动转换
480
+
481
+ ```typescript
482
+ // 必填字段:表单必须提供值
483
+ name: z.string().min(1).describe("名称"),
484
+
485
+ // 可选字段:可以不填
486
+ description: z.string().optional().describe("描述"),
487
+
488
+ // 带默认值:不填时使用默认值
489
+ status: z.enum(["draft", "published"]).default("draft").describe("状态"),
490
+ ```
491
+
492
+ ### 表单数据处理流程
493
+
494
+ ```
495
+ FormData (扁平键值对)
496
+ ↓ 第一阶段:解析
497
+ { "title": "文章", "banners[0].url": "http://...", "banners[0].alt": "图片" }
498
+ ↓ 第二阶段:还原嵌套结构
499
+ { "title": "文章", "banners": [{ "url": "http://...", "alt": "图片" }] }
500
+ ↓ 第三阶段:类型转换(根据 Schema)
501
+ { "title": "文章", "banners": [{ "url": "http://...", "alt": "图片" }] }
502
+ ↓ 第四阶段:Zod 校验
503
+ 校验通过 → 调用 createItem/updateItem
504
+ 校验失败 → 返回错误信息,回填表单
505
+ ```
506
+
507
+ ### 数组字段的表单命名规则
508
+
509
+ 数组字段必须使用带索引的命名格式,系统会自动还原为数组结构:
510
+
511
+ ```html
512
+ <!-- 简单数组:tags = ["技术", "前端"] -->
513
+ <input name="tags[0]" value="技术" />
514
+ <input name="tags[1]" value="前端" />
515
+
516
+ <!-- 对象数组:banners = [{ url: "...", alt: "..." }] -->
517
+ <input name="banners[0].url" value="http://example.com/1.jpg" />
518
+ <input name="banners[0].alt" value="图片1" />
519
+ <input name="banners[1].url" value="http://example.com/2.jpg" />
520
+ <input name="banners[1].alt" value="图片2" />
521
+ ```
522
+
523
+ **重要**:索引可以不连续(如删除中间项后变成 `[0], [2], [5]`),系统会自动压缩为连续索引 `[0], [1], [2]`。
524
+
525
+ ### 复杂表单的两种实践方式
526
+
527
+ #### 方式一:拍平字段 + 专用表单 Schema(推荐用于固定结构)
528
+
529
+ 适用场景:嵌套对象结构固定,如用户资料中的地址信息。
530
+
531
+ ```typescript
532
+ // 数据 Schema(包含嵌套对象)
533
+ const UserSchema = z.object({
534
+ id: z.number().optional(),
535
+ name: z.string().describe("姓名"),
536
+ address: z.object({
537
+ province: z.string().describe("省份"),
538
+ city: z.string().describe("城市"),
539
+ street: z.string().describe("街道"),
540
+ }).describe("地址"),
541
+ });
542
+
543
+ // 表单 Schema(拍平结构)
544
+ const UserFormSchema = z.object({
545
+ name: z.string().min(1).describe("姓名"),
546
+ "address.province": z.string().min(1).describe("省份"),
547
+ "address.city": z.string().min(1).describe("城市"),
548
+ "address.street": z.string().min(1).describe("街道"),
549
+ });
550
+
551
+ // 使用表单 Schema
552
+ new DefaultCreateFeature({
553
+ schema: UserFormSchema, // 使用拍平的表单 Schema
554
+ createItem: async (data) => {
555
+ // 在 service 层还原嵌套结构
556
+ const user = {
557
+ name: data.name,
558
+ address: {
559
+ province: data["address.province"],
560
+ city: data["address.city"],
561
+ street: data["address.street"],
562
+ },
563
+ };
564
+ return userService.create(user);
565
+ },
566
+ });
567
+ ```
568
+
569
+ #### 方式二:自定义编辑器组件(推荐用于动态数组)
570
+
571
+ 适用场景:动态数组对象,如 banners、faqs 等可增删的列表。
572
+
573
+ ```typescript
574
+ // 数据 Schema
575
+ const DestinationSchema = z.object({
576
+ name: z.string().describe("名称"),
577
+ banners: z.array(z.object({
578
+ url: z.url().describe("图片地址"),
579
+ alt: z.string().describe("描述"),
580
+ })).default([]).describe("Banner图片"),
581
+ });
582
+
583
+ // 使用自定义编辑器
584
+ new DefaultCreateFeature({
585
+ schema: DestinationSchema,
586
+ createItem: destinationService.create,
587
+ fieldRenderers: {
588
+ banners: BannerEditor, // 自定义编辑器负责生成正确的表单结构
589
+ },
590
+ });
591
+ ```
592
+
593
+ 自定义编辑器必须:
594
+ 1. 使用 Alpine.js 管理动态状态
595
+ 2. 为每个字段生成正确命名的隐藏 input(如 `banners[0].url`)
596
+ 3. 处理增删操作时维护正确的索引
597
+
598
+ ### 常见问题
599
+
600
+ #### 必填字段校验失败
601
+ - 检查 Schema 定义,非 optional 字段必须提供值
602
+ - 复杂嵌套字段如果必填,需要使用自定义编辑器确保数据完整
603
+
604
+ #### 数组字段为空
605
+ - 确保隐藏 input 使用了正确的数组索引格式:`fieldName[index]` 或 `fieldName[index].property`
606
+ - 使用 Alpine.js 的 `x-for` 渲染列表时,隐藏字段的 name 需要动态绑定
607
+
608
+ #### 类型转换错误
609
+ - 数字字段:表单提交的是字符串,系统会自动转换为 number
610
+ - 布尔字段:`"true"/"1"/"on"` → `true`,`"false"/"0"/"off"/""` → `false`
611
+ - 如果转换失败,Zod 校验会报错
612
+
613
+ ## 认证与权限
614
+
615
+ ```typescript
616
+ const authProvider = {
617
+ cookieKey: "auth_token",
618
+ loginUrl: "/admin/login",
619
+ logoutUrl: "/admin/logout",
620
+
621
+ async tokenToUser(token, ctx) {
622
+ // 验证 token,返回用户信息
623
+ return {
624
+ id: 1,
625
+ name: "管理员",
626
+ permissions: ["articles.*", "deny:users.delete"],
627
+ };
628
+ },
629
+ };
630
+
631
+ const adminPlugin = new HtmxAdminPlugin({
632
+ authProvider,
633
+ // ...
634
+ });
635
+ ```
636
+
637
+ 权限格式:
638
+ - `articles.list` - 具体权限
639
+ - `articles.*` - 通配符
640
+ - `deny:articles.delete` - 明确禁止
641
+
642
+ ## 服务层接口
643
+
644
+ ### ListParams
645
+
646
+ ```typescript
647
+ interface ListParams {
648
+ page?: number;
649
+ pageSize?: number;
650
+ sortBy?: string;
651
+ sortOrder?: "asc" | "desc";
652
+ filters?: Record<string, any>;
653
+ }
654
+ ```
655
+
656
+ ### ListResult
657
+
658
+ ```typescript
659
+ interface ListResult<T> {
660
+ items: T[];
661
+ total: number;
662
+ page: number;
663
+ pageSize: number;
664
+ totalPages: number;
665
+ }
666
+ ```
667
+
668
+ ## 通知系统
669
+
670
+ 在 Feature 的 handler 或 render 中使用 context 发送通知:
671
+
672
+ ```typescript
673
+ render: async (context) => {
674
+ context.sendSuccess("操作成功", "数据已保存");
675
+ context.sendError("操作失败", "请检查输入");
676
+ context.sendWarning("警告", "该操作不可撤销");
677
+ context.sendInfo("提示", "处理中...");
678
+
679
+ return <div>...</div>;
680
+ };
681
+ ```
682
+
683
+ ## 重定向与刷新
684
+
685
+ ```typescript
686
+ render: async (context) => {
687
+ // 重定向
688
+ context.redirect("/admin/articles/list");
689
+
690
+ // 刷新当前页面
691
+ context.setRefresh(true);
692
+
693
+ return <div>操作完成</div>;
694
+ };
695
+ ```
696
+
697
+ ## 完整示例:只读页面
698
+
699
+ ```typescript
700
+ class ArticleReadonlyPageModel extends PageModel {
701
+ constructor() {
702
+ super("articles-readonly", {
703
+ title: "文章查看",
704
+ description: "只读模式",
705
+ });
706
+
707
+ // 只注册列表和详情,不注册创建、编辑、删除
708
+ this.features.list(
709
+ new DefaultListFeature({
710
+ schema: ArticleSchema,
711
+ getList: articleService.getArticleList,
712
+ fieldNames: ["title", "author", "status"],
713
+ })
714
+ );
715
+
716
+ this.features.detail(
717
+ new DefaultDetailFeature({
718
+ schema: ArticleSchema,
719
+ getItem: articleService.getArticle,
720
+ })
721
+ );
722
+ }
723
+ }
724
+ ```
725
+
726
+ ## 自定义字段渲染器
727
+
728
+ ### FieldRendererProps 类型
729
+
730
+ 所有渲染器接收统一的 props:
731
+
732
+ ```typescript
733
+ interface FieldRendererProps<T = any> {
734
+ value: any; // 当前字段值
735
+ item: Partial<T>; // 完整数据项
736
+ field: FormField; // 字段定义(含 name、label、type 等)
737
+ }
738
+
739
+ interface FormField {
740
+ name: string;
741
+ label: string;
742
+ type?: "text" | "textarea" | "select" | "date" | "number" | "email" | "url" | "checkbox";
743
+ required?: boolean;
744
+ options?: Array<{ value: string | number; label: string }>;
745
+ }
746
+ ```
747
+
748
+ ### 展示性渲染器(用于列表/详情)
749
+
750
+ 用于自定义字段在列表列或详情页的展示方式:
751
+
752
+ ```typescript
753
+ // 简单示例:评分渲染器
754
+ function RatingRenderer(props: FieldRendererProps) {
755
+ const { value } = props;
756
+ if (value == null) return "-";
757
+ return <span>{Number(value).toFixed(1)} ⭐</span>;
758
+ }
759
+
760
+ // 使用
761
+ new DefaultListFeature({
762
+ fieldRenderers: {
763
+ rating: RatingRenderer,
764
+ status: ({ value }) => (
765
+ <span class={value === "published" ? "text-green-600" : "text-gray-600"}>
766
+ {value === "published" ? "已发布" : "草稿"}
767
+ </span>
768
+ ),
769
+ },
770
+ });
771
+ ```
772
+
773
+ ### 表单编辑器(用于创建/编辑)
774
+
775
+ 表单编辑器使用 Alpine.js 管理状态,通过隐藏字段同步数据到表单:
776
+
777
+ ```typescript
778
+ function MyArrayEditor(props: FieldRendererProps) {
779
+ const { value, field } = props;
780
+ const name = field.name;
781
+ const initialItems = value || [];
782
+
783
+ // Alpine.js 状态初始化
784
+ const initialData = JSON.stringify({
785
+ items: initialItems,
786
+ newItem: "",
787
+ });
788
+
789
+ return (
790
+ <div x-data={initialData}>
791
+ {/* 输入框 */}
792
+ <input
793
+ type="text"
794
+ x-model="newItem"
795
+ x-on:keydown.enter.prevent="items.push(newItem); newItem = ''"
796
+ placeholder="输入后按回车添加"
797
+ />
798
+
799
+ {/* 列表渲染 */}
800
+ <template x-for="(item, index) in items" x-bind:key="index">
801
+ <div>
802
+ {/* 隐藏字段:用于表单提交 */}
803
+ <input
804
+ type="hidden"
805
+ x-bind:name={`'${name}[' + index + ']'`}
806
+ x-bind:value="item"
807
+ />
808
+ <span x-text="item"></span>
809
+ <button type="button" x-on:click="items.splice(index, 1)">删除</button>
810
+ </div>
811
+ </template>
812
+
813
+ {/* 空状态 */}
814
+ <div x-show="items.length === 0">暂无数据</div>
815
+ </div>
816
+ );
817
+ }
818
+
819
+ // 使用
820
+ new DefaultCreateFeature({
821
+ fieldRenderers: {
822
+ tags: MyArrayEditor,
823
+ },
824
+ });
825
+ ```
826
+
827
+ ### 复杂对象编辑器
828
+
829
+ 对于嵌套对象数组(如 banners),隐藏字段需使用点号路径:
830
+
831
+ ```typescript
832
+ function BannerEditor(props: FieldRendererProps) {
833
+ const { value, field } = props;
834
+ const name = field.name;
835
+ const initialData = JSON.stringify({
836
+ banners: value || [],
837
+ });
838
+
839
+ return (
840
+ <div x-data={initialData}>
841
+ <button type="button" x-on:click="banners.push({ url: '', alt: '' })">
842
+ 添加
843
+ </button>
844
+
845
+ <template x-for="(banner, index) in banners" x-bind:key="index">
846
+ <div>
847
+ <input type="text" x-model="banner.url" placeholder="图片地址" />
848
+ <input
849
+ type="hidden"
850
+ x-bind:name={`'${name}[' + index + '].url'`}
851
+ x-bind:value="banner.url"
852
+ />
853
+
854
+ <input type="text" x-model="banner.alt" placeholder="描述" />
855
+ <input
856
+ type="hidden"
857
+ x-bind:name={`'${name}[' + index + '].alt'`}
858
+ x-bind:value="banner.alt"
859
+ />
860
+
861
+ <button type="button" x-on:click="banners.splice(index, 1)">删除</button>
862
+ </div>
863
+ </template>
864
+ </div>
865
+ );
866
+ }
867
+ ```
868
+
869
+ ### 内置编辑器
870
+
871
+ 插件提供以下内置编辑器:
872
+
873
+ ```typescript
874
+ import {
875
+ TagsEditor, // 标签编辑器(支持拖拽排序)
876
+ StringArrayEditor, // 字符串数组编辑器
877
+ ObjectEditor, // 对象编辑器(根据 Zod Schema 自动生成字段)
878
+ } from "imean-service-engine-htmx-plugin";
879
+
880
+ // 使用
881
+ new DefaultEditFeature({
882
+ fieldRenderers: {
883
+ tags: TagsEditor,
884
+ highlights: (props) => <StringArrayEditor {...props} placeholder="输入亮点" />,
885
+ metadata: (props) => {
886
+ const metadataSchema = DestinationSchema.shape.metadata.unwrap();
887
+ return <ObjectEditor {...props} objectSchema={metadataSchema} />;
888
+ },
889
+ },
890
+ });
891
+ ```
892
+
893
+ ## 常见用例
894
+
895
+ ### 1. 字段分组展示
896
+
897
+ 将表单或详情页字段分组显示:
898
+
899
+ ```typescript
900
+ new DefaultDetailFeature({
901
+ schema: DestinationSchema,
902
+ getItem: destinationService.getDestination,
903
+ groups: [
904
+ { label: "基本信息", fields: ["name", "code", "type", "description"] },
905
+ { label: "媒体资源", fields: ["banners"] },
906
+ { label: "统计信息", fields: ["views", "rating"] },
907
+ ],
908
+ });
909
+ ```
910
+
911
+ ### 2. 不同打开方式
912
+
913
+ 控制新建、详情、编辑的打开方式:
914
+
915
+ ```typescript
916
+ new DefaultListFeature({
917
+ openMode: {
918
+ create: "dialog", // 弹窗
919
+ detail: "newWindow", // 新窗口
920
+ edit: "page", // 整页跳转
921
+ },
922
+ });
923
+ ```
924
+
925
+ ### 3. 只读页面
926
+
927
+ 只注册列表和详情,不注册创建、编辑、删除:
928
+
929
+ ```typescript
930
+ class ReadonlyPageModel extends PageModel {
931
+ constructor() {
932
+ super("readonly-articles", { title: "文章查看" });
933
+ this.features.list(new DefaultListFeature({ /* ... */ }));
934
+ this.features.detail(new DefaultDetailFeature({ /* ... */ }));
935
+ // 不注册 create、edit、delete
936
+ }
937
+ }
938
+ ```
939
+
940
+ ### 4. 动态标题
941
+
942
+ 根据数据动态设置页面标题:
943
+
944
+ ```typescript
945
+ new DefaultDetailFeature({
946
+ title: (context, item) => item?.title || "详情",
947
+ description: (context, item) => `作者:${item?.author}`,
948
+ });
949
+
950
+ new DefaultEditFeature({
951
+ title: (context, item) => `编辑:${item?.title}`,
952
+ });
953
+ ```
954
+
955
+ ### 5. 防止表单误关闭
956
+
957
+ 编辑表单禁用点击遮罩关闭:
958
+
959
+ ```typescript
960
+ new DefaultEditFeature({
961
+ dialogSize: "xl",
962
+ closeOnBackdropClick: false, // 只能通过关闭按钮关闭
963
+ });
964
+ ```
965
+
966
+ ### 6. 自定义列表 Header
967
+
968
+ 完全自定义列表页的头部区域:
969
+
970
+ ```typescript
971
+ new DefaultListFeature({
972
+ header: (context) => (
973
+ <div class="px-6 py-3 flex justify-between items-center">
974
+ <div>
975
+ <h2 class="text-lg font-semibold">📰 文章管理</h2>
976
+ <span class="text-sm text-gray-600">共 100 篇</span>
977
+ </div>
978
+ <button
979
+ class="px-4 py-2 bg-blue-600 text-white rounded"
980
+ hx-get={`${context.prefix}/articles/new?dialog=true`}
981
+ >
982
+ 新建文章
983
+ </button>
984
+ </div>
985
+ ),
986
+ });
987
+ ```
988
+
989
+ ## 导出的类型和工具
990
+
991
+ ```typescript
992
+ import {
993
+ // 核心类
994
+ HtmxAdminPlugin,
995
+ PageModel,
996
+
997
+ // Feature 类
998
+ DefaultListFeature,
999
+ DefaultDetailFeature,
1000
+ DefaultCreateFeature,
1001
+ DefaultEditFeature,
1002
+ DefaultDeleteFeature,
1003
+ CustomFeature,
1004
+
1005
+ // 内置编辑器
1006
+ TagsEditor,
1007
+ StringArrayEditor,
1008
+ ObjectEditor,
1009
+
1010
+ // 类型
1011
+ HtmxAdminPluginOptions,
1012
+ PageMetadata,
1013
+ FeatureContext,
1014
+ ListParams,
1015
+ ListResult,
1016
+ UserInfo,
1017
+ AuthProvider,
1018
+ DialogSize,
1019
+ OpenMode,
1020
+ ActionButton,
1021
+ FieldRendererProps,
1022
+ FormField,
1023
+ FieldGroup,
1024
+
1025
+ // 工具函数
1026
+ getUserInfo,
1027
+ parseListParams,
1028
+ checkUserPermission,
1029
+ } from "imean-service-engine-htmx-plugin";
1030
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imean-service-engine-htmx-plugin",
3
- "version": "2.9.2",
3
+ "version": "2.9.4",
4
4
  "description": "HtmxAdminPlugin for IMean Service Engine",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",