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 +18 -2
- package/dist/index.mjs +18 -2
- package/docs/AI-GUIDE.md +1030 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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);
|
package/docs/AI-GUIDE.md
ADDED
|
@@ -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
|
+
```
|