imean-service-engine-htmx-plugin 2.9.5 → 2.10.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.js CHANGED
@@ -2036,6 +2036,13 @@ function FilterForm(props) {
2036
2036
  }
2037
2037
  return String(value);
2038
2038
  }
2039
+ function getCheckboxValue(field) {
2040
+ const value = currentFilters[field.name];
2041
+ if (value === "true" || value === true) {
2042
+ return true;
2043
+ }
2044
+ return false;
2045
+ }
2039
2046
  if (fields.length === 0) {
2040
2047
  return null;
2041
2048
  }
@@ -2048,7 +2055,29 @@ function FilterForm(props) {
2048
2055
  className: "space-y-4",
2049
2056
  "data-testid": "filter-form",
2050
2057
  children: [
2051
- fields.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: fields.map((field) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", "data-testid": `filter-field-${field.name}`, children: [
2058
+ fields.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: fields.map((field) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "space-y-2", "data-testid": `filter-field-${field.name}`, children: field.type === "checkbox" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 pt-7", children: [
2059
+ /* @__PURE__ */ jsxRuntime.jsx(
2060
+ "input",
2061
+ {
2062
+ id: `filter-${field.name}`,
2063
+ name: field.name,
2064
+ type: "checkbox",
2065
+ value: "true",
2066
+ checked: getCheckboxValue(field),
2067
+ className: "w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 focus:ring-2",
2068
+ "data-testid": `filter-checkbox-${field.name}`
2069
+ }
2070
+ ),
2071
+ /* @__PURE__ */ jsxRuntime.jsx(
2072
+ "label",
2073
+ {
2074
+ htmlFor: `filter-${field.name}`,
2075
+ className: "text-sm font-semibold text-gray-700 cursor-pointer",
2076
+ "data-testid": `filter-label-${field.name}`,
2077
+ children: field.label
2078
+ }
2079
+ )
2080
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2052
2081
  /* @__PURE__ */ jsxRuntime.jsx(
2053
2082
  "label",
2054
2083
  {
@@ -2092,6 +2121,18 @@ function FilterForm(props) {
2092
2121
  type: "number",
2093
2122
  value: getFieldValue(field),
2094
2123
  placeholder: field.placeholder || `\u8BF7\u8F93\u5165${field.label}`,
2124
+ step: field.step,
2125
+ 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 bg-white",
2126
+ "data-testid": `filter-input-${field.name}`
2127
+ }
2128
+ ) : field.type === "email" ? /* @__PURE__ */ jsxRuntime.jsx(
2129
+ "input",
2130
+ {
2131
+ id: `filter-${field.name}`,
2132
+ name: field.name,
2133
+ type: "email",
2134
+ value: getFieldValue(field),
2135
+ placeholder: field.placeholder || `\u8BF7\u8F93\u5165${field.label}`,
2095
2136
  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 bg-white",
2096
2137
  "data-testid": `filter-input-${field.name}`
2097
2138
  }
@@ -2107,7 +2148,7 @@ function FilterForm(props) {
2107
2148
  "data-testid": `filter-input-${field.name}`
2108
2149
  }
2109
2150
  )
2110
- ] }, field.name)) }),
2151
+ ] }) }, field.name)) }),
2111
2152
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3 pt-2", children: [
2112
2153
  /* @__PURE__ */ jsxRuntime.jsx(
2113
2154
  "button",
@@ -2536,10 +2577,20 @@ function ListPage(props) {
2536
2577
  ...params.filters
2537
2578
  // 包含所有筛选条件
2538
2579
  };
2580
+ const refreshUrl = (() => {
2581
+ const queryParams = new URLSearchParams();
2582
+ Object.entries(currentParams).forEach(([key, value]) => {
2583
+ if (value !== void 0 && value !== null && value !== "") {
2584
+ queryParams.append(key, String(value));
2585
+ }
2586
+ });
2587
+ const queryString = queryParams.toString();
2588
+ return queryString ? `${listPath}?${queryString}` : listPath;
2589
+ })();
2539
2590
  const tableActions = [
2540
2591
  {
2541
2592
  label: "\u5237\u65B0",
2542
- hxGet: listPath,
2593
+ hxGet: refreshUrl,
2543
2594
  variant: "primary"
2544
2595
  }
2545
2596
  ];
package/dist/index.mjs CHANGED
@@ -2034,6 +2034,13 @@ function FilterForm(props) {
2034
2034
  }
2035
2035
  return String(value);
2036
2036
  }
2037
+ function getCheckboxValue(field) {
2038
+ const value = currentFilters[field.name];
2039
+ if (value === "true" || value === true) {
2040
+ return true;
2041
+ }
2042
+ return false;
2043
+ }
2037
2044
  if (fields.length === 0) {
2038
2045
  return null;
2039
2046
  }
@@ -2046,7 +2053,29 @@ function FilterForm(props) {
2046
2053
  className: "space-y-4",
2047
2054
  "data-testid": "filter-form",
2048
2055
  children: [
2049
- fields.length > 0 && /* @__PURE__ */ jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: fields.map((field) => /* @__PURE__ */ jsxs("div", { className: "space-y-2", "data-testid": `filter-field-${field.name}`, children: [
2056
+ fields.length > 0 && /* @__PURE__ */ jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: fields.map((field) => /* @__PURE__ */ jsx("div", { className: "space-y-2", "data-testid": `filter-field-${field.name}`, children: field.type === "checkbox" ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 pt-7", children: [
2057
+ /* @__PURE__ */ jsx(
2058
+ "input",
2059
+ {
2060
+ id: `filter-${field.name}`,
2061
+ name: field.name,
2062
+ type: "checkbox",
2063
+ value: "true",
2064
+ checked: getCheckboxValue(field),
2065
+ className: "w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 focus:ring-2",
2066
+ "data-testid": `filter-checkbox-${field.name}`
2067
+ }
2068
+ ),
2069
+ /* @__PURE__ */ jsx(
2070
+ "label",
2071
+ {
2072
+ htmlFor: `filter-${field.name}`,
2073
+ className: "text-sm font-semibold text-gray-700 cursor-pointer",
2074
+ "data-testid": `filter-label-${field.name}`,
2075
+ children: field.label
2076
+ }
2077
+ )
2078
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
2050
2079
  /* @__PURE__ */ jsx(
2051
2080
  "label",
2052
2081
  {
@@ -2090,6 +2119,18 @@ function FilterForm(props) {
2090
2119
  type: "number",
2091
2120
  value: getFieldValue(field),
2092
2121
  placeholder: field.placeholder || `\u8BF7\u8F93\u5165${field.label}`,
2122
+ step: field.step,
2123
+ 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 bg-white",
2124
+ "data-testid": `filter-input-${field.name}`
2125
+ }
2126
+ ) : field.type === "email" ? /* @__PURE__ */ jsx(
2127
+ "input",
2128
+ {
2129
+ id: `filter-${field.name}`,
2130
+ name: field.name,
2131
+ type: "email",
2132
+ value: getFieldValue(field),
2133
+ placeholder: field.placeholder || `\u8BF7\u8F93\u5165${field.label}`,
2093
2134
  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 bg-white",
2094
2135
  "data-testid": `filter-input-${field.name}`
2095
2136
  }
@@ -2105,7 +2146,7 @@ function FilterForm(props) {
2105
2146
  "data-testid": `filter-input-${field.name}`
2106
2147
  }
2107
2148
  )
2108
- ] }, field.name)) }),
2149
+ ] }) }, field.name)) }),
2109
2150
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 pt-2", children: [
2110
2151
  /* @__PURE__ */ jsx(
2111
2152
  "button",
@@ -2534,10 +2575,20 @@ function ListPage(props) {
2534
2575
  ...params.filters
2535
2576
  // 包含所有筛选条件
2536
2577
  };
2578
+ const refreshUrl = (() => {
2579
+ const queryParams = new URLSearchParams();
2580
+ Object.entries(currentParams).forEach(([key, value]) => {
2581
+ if (value !== void 0 && value !== null && value !== "") {
2582
+ queryParams.append(key, String(value));
2583
+ }
2584
+ });
2585
+ const queryString = queryParams.toString();
2586
+ return queryString ? `${listPath}?${queryString}` : listPath;
2587
+ })();
2537
2588
  const tableActions = [
2538
2589
  {
2539
2590
  label: "\u5237\u65B0",
2540
- hxGet: listPath,
2591
+ hxGet: refreshUrl,
2541
2592
  variant: "primary"
2542
2593
  }
2543
2594
  ];
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "imean-service-engine-htmx-plugin",
3
- "version": "2.9.5",
3
+ "version": "2.10.1",
4
4
  "description": "HtmxAdminPlugin for IMean Service Engine",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
8
8
  "build": "tsup",
9
- "dev": "tsx examples-v2/index.ts",
9
+ "dev": "tsx watch examples-v2/index.ts",
10
10
  "test": "vitest --run",
11
11
  "test:ui": "vitest --ui",
12
12
  "test:coverage": "vitest --run --coverage",
package/docs/README.md DELETED
@@ -1,88 +0,0 @@
1
- # HtmxAdminPlugin 文档索引
2
-
3
- ## 文档列表
4
-
5
- ### 📖 [完整文档](../README.md)
6
-
7
- 完整的使用文档,包含:
8
- - 概述和核心特性
9
- - 快速开始指南
10
- - 模块类型详解
11
- - 数据源说明
12
- - 组件系统
13
- - 路由系统
14
- - 响应机制
15
- - 错误处理
16
- - 高级功能
17
- - API 参考
18
- - 最佳实践
19
- - 示例项目
20
-
21
- **适合**: 初次使用或需要全面了解插件的开发者
22
-
23
- ### 🏗️ [架构设计文档](./architecture.md)
24
-
25
- 详细的架构设计文档,包含:
26
- - 架构概述
27
- - 核心设计原则
28
- - 统一响应架构
29
- - 模块系统
30
- - 路由系统
31
- - 组件系统
32
- - 数据流
33
- - 响应处理流程
34
- - 关键实现细节
35
- - 扩展点
36
- - 性能考虑
37
- - 安全性考虑
38
-
39
- **适合**: 需要深入理解插件架构或进行二次开发的开发者
40
-
41
- ### ⚡ [快速参考指南](./quick-reference.md)
42
-
43
- 快速参考指南,包含:
44
- - 插件配置速查
45
- - 模块定义模板
46
- - 数据源示例
47
- - 常用方法速查
48
- - 组件速查表
49
- - 响应头速查
50
- - 常见场景示例
51
- - 类型定义速查
52
- - 路由速查
53
- - 调试技巧
54
- - 常见问题
55
-
56
- **适合**: 已经熟悉插件,需要快速查找信息的开发者
57
-
58
-
59
- ## 文档使用建议
60
-
61
- ### 新手入门
62
-
63
- 1. 阅读 [完整文档](../README.md) 的"快速开始"部分
64
- 2. 查看示例项目了解实际用法
65
- 3. 参考 [快速参考指南](./quick-reference.md) 的常见场景
66
-
67
- ### 深入理解
68
-
69
- 1. 阅读 [架构设计文档](./architecture.md) 了解设计理念
70
- 2. 阅读 [完整文档](../README.md) 的"高级功能"部分
71
- 3. 查看源代码了解实现细节
72
-
73
- ### 日常开发
74
-
75
- 1. 使用 [快速参考指南](./quick-reference.md) 查找常用功能
76
- 2. 参考 [完整文档](../README.md) 的 API 参考
77
- 3. 查看示例项目了解最佳实践
78
-
79
- ## 相关资源
80
-
81
- - **示例项目**: `examples/` 目录
82
- - **源代码**: `src/plugins/htmx-admin/` 目录
83
- - **类型定义**: `src/plugins/htmx-admin/types.ts`
84
-
85
- ## 反馈和建议
86
-
87
- 如有问题或建议,欢迎提交 Issue 或 Pull Request。
88
-
@@ -1,594 +0,0 @@
1
- # HtmxAdminPlugin 架构设计文档
2
-
3
- ## 目录
4
-
5
- - [架构概述](#架构概述)
6
- - [核心设计原则](#核心设计原则)
7
- - [统一响应架构](#统一响应架构)
8
- - [模块系统](#模块系统)
9
- - [路由系统](#路由系统)
10
- - [权限系统](#权限系统)
11
- - [组件系统](#组件系统)
12
- - [数据流](#数据流)
13
- - [响应处理流程](#响应处理流程)
14
-
15
- ## 架构概述
16
-
17
- HtmxAdminPlugin 采用后端驱动的统一响应架构,通过 `RouteHandler` 统一处理所有请求。所有模块都通过 `__handle()` 方法作为统一入口,简化了请求处理流程。所有响应都通过后端响应头明确指定目标容器,前端无需指定 `hx-target` 或 `hx-swap`。
18
-
19
- ### 架构层次
20
-
21
- ```
22
- ┌─────────────────────────────────────────┐
23
- │ HtmxAdminPlugin │
24
- │ (插件入口,模块注册和路由管理) │
25
- └─────────────────────────────────────────┘
26
-
27
- ┌───────────┼───────────┐
28
- │ │ │
29
- ┌───────▼──────┐ ┌─▼──────┐ ┌─▼──────────┐
30
- │ 模块系统 │ │ 路由系统│ │ 组件系统 │
31
- │ (Base Classes)│ │(Routes) │ │(Components)│
32
- └───────┬──────┘ └─┬──────┘ └─┬──────────┘
33
- │ │ │
34
- └───────────┼───────────┘
35
-
36
- ┌───────────┼───────────┐
37
- │ │ │
38
- ┌───────▼──────┐ ┌─▼───────────▼──────────┐
39
- │ 权限系统 │ │ 响应处理系统 │
40
- │ (Auth & Perm)│ │ (OOB Response Builder) │
41
- └───────┬──────┘ └────────────────────────┘
42
-
43
- ┌───────▼─────────────────────────────────┐
44
- │ utils/ │
45
- │ - auth.ts (认证和权限校验) │
46
- │ - operation.ts (操作ID生成) │
47
- │ - permission-handler.tsx (权限拒绝处理) │
48
- │ - permissions.ts (权限匹配) │
49
- └──────────────────────────────────────────┘
50
- ```
51
-
52
- ## 核心设计原则
53
-
54
- ### 1. 后端驱动
55
-
56
- **原则**: 所有响应行为由后端控制,前端只负责触发请求。
57
-
58
- **实现**:
59
- - 所有响应都通过 `HX-Retarget` 响应头明确指定目标容器
60
- - `HX-Push-Url` 由后端控制,决定是否更新 URL
61
- - 前端请求不指定 `hx-target` 或 `hx-swap`
62
-
63
- **优势**:
64
- - 简化前端代码
65
- - 统一响应处理逻辑
66
- - 易于维护和调试
67
-
68
- ### 2. 统一响应架构
69
-
70
- **原则**: 所有响应使用统一的 OOB 交换机制。
71
-
72
- **实现**:
73
- - 主要内容作为非 OOB 内容返回
74
- - 导航更新等通过 OOB 元素更新
75
- - 使用 `OOBResponseBuilder` 统一构建响应
76
-
77
- **优势**:
78
- - 一致的响应格式
79
- - 易于扩展
80
- - 减少代码重复
81
-
82
- ### 3. 类型安全
83
-
84
- **原则**: 完整的 TypeScript 类型定义。
85
-
86
- **实现**:
87
- - 所有接口和类型都有明确的定义
88
- - 数据源接口类型化
89
- - 组件 Props 类型化
90
-
91
- **优势**:
92
- - 编译时错误检查
93
- - 更好的 IDE 支持
94
- - 减少运行时错误
95
-
96
- ### 4. 可扩展性
97
-
98
- **原则**: 通过继承和重写实现扩展。
99
-
100
- **实现**:
101
- - 基类提供默认实现
102
- - 子类可以重写任何方法
103
- - 支持完全自定义渲染
104
-
105
- **优势**:
106
- - 灵活的定制能力
107
- - 保持代码简洁
108
- - 易于理解和使用
109
-
110
- ## 统一响应架构
111
-
112
- ### 响应目标检测
113
-
114
- 响应目标检测在 `HtmxAdminContext` 构造函数中实现:
115
-
116
- ```typescript
117
- // 在 HtmxAdminContext 构造函数中
118
- const url = new URL(ctx.req.url);
119
- this.isFragment = ctx.req.header("HX-Request") === "true";
120
- this.isDialog =
121
- url.searchParams.get("dialog") === "true" ||
122
- ctx.req.header("HX-Target") === "#dialog-container" ||
123
- (ctx.req.header("Referer") || "").includes("dialog=true");
124
-
125
- // 确定响应目标
126
- this.target = this.isDialog ? "#dialog-container" : "#main-content";
127
- this.swap = this.isDialog ? "innerHTML" : "innerHTML";
128
- ```
129
-
130
- ### 响应头设置
131
-
132
- 所有响应都会自动添加 `HX-Retarget` 响应头(在 `RouteHandler.renderFragment()` 中):
133
-
134
- ```typescript
135
- const target = adminContext.isDialog
136
- ? "#dialog-container"
137
- : "#admin-layout";
138
- const swap = adminContext.isDialog ? "innerHTML" : "outerHTML";
139
-
140
- const headers = {
141
- "HX-Retarget": target,
142
- "HX-Reswap": swap,
143
- };
144
- ```
145
-
146
- ### 响应构建
147
-
148
- 响应构建在 `RouteHandler` 中实现:
149
-
150
- **完整页面响应**:
151
- ```typescript
152
- return ctx.html(
153
- <BaseLayout title={adminContext.title} description={adminContext.description}>
154
- <AdminLayout adminContext={adminContext}>
155
- {adminContext.content}
156
- </AdminLayout>
157
- </BaseLayout>
158
- );
159
- ```
160
-
161
- **片段响应**:
162
- ```typescript
163
- const fragments = [
164
- // 通知(通过 OOB 更新)
165
- adminContext.notifications.map((notification) => (
166
- <ErrorAlert type={notification.type} title={notification.title} message={notification.message} />
167
- )),
168
- <title>{adminContext.title}</title>,
169
- ];
170
-
171
- return ctx.html(
172
- <>
173
- <AdminLayout adminContext={adminContext}>
174
- {adminContext.content}
175
- </AdminLayout>
176
- {fragments}
177
- </>,
178
- 200,
179
- { "HX-Retarget": target, "HX-Reswap": swap }
180
- );
181
- ```
182
-
183
- ## 模块系统
184
-
185
- ### 模块类型
186
-
187
- 插件支持四种模块类型,所有模块类型都继承自 `PageModule` 基类:
188
-
189
- ```
190
- PageModule (基类)
191
- ├── ListPageModule (列表页)
192
- ├── DetailPageModule (详情页)
193
- ├── FormPageModule (表单页)
194
- └── PageModule (自定义页面,直接继承)
195
- ```
196
-
197
- #### 架构优势
198
-
199
- - **统一基类**: 所有页面模块都继承自 `PageModule`,具有相同的处理过程和结构
200
- - **默认行为**: 每种类型提供默认的渲染逻辑,满足大部分场景
201
- - **灵活扩展**: 可以重写 `render()` 方法完全自定义渲染,即使使用 `list`、`detail`、`form` 类型
202
- - **代码复用**: 通用属性(`basePath`、`__moduleName`、`__moduleMetadata`、`getBreadcrumbs`)在基类中统一管理
203
-
204
- ### 模块注册流程
205
-
206
- ```
207
- 1. 插件初始化 (onInit)
208
- └─> 存储插件配置
209
- └─> 获取 Hono 实例
210
-
211
- 2. 模块加载 (onModuleLoad)
212
- └─> 检查模块类型约束(继承关系)
213
- └─> 构建模块类型映射(moduleTypeMap)
214
- └─> 为每个模块类型创建 RouteHandler 实例
215
- └─> 注册路由(list, detail, form, custom)
216
- └─> 注册首页重定向
217
- ```
218
-
219
- ### 模块元数据
220
-
221
- 每个模块都有元数据,记录该模块的完整信息:
222
-
223
- ```typescript
224
- interface ModuleTypeInfo {
225
- title: string; // 模块标题
226
- description: string; // 模块描述
227
- basePath: string; // 模块基础路径
228
- hasList: boolean; // 是否有列表页
229
- hasDetail: boolean; // 是否有详情页
230
- hasForm: boolean; // 是否有表单页
231
- hasCustom: boolean; // 是否有自定义页
232
- }
233
- ```
234
-
235
- 模块可以通过 `this.context.moduleMetadata` 访问元数据:
236
-
237
- ## 路由系统
238
-
239
- ### 路由注册
240
-
241
- 插件根据模块类型自动注册路由:
242
-
243
- ```
244
- List 模块:
245
- GET /{basePath}/list → 列表页
246
- DELETE /{basePath}/:id → 删除操作
247
-
248
- Detail 模块:
249
- GET /{basePath}/detail/:id → 详情页
250
- DELETE /{basePath}/detail/:id → 删除操作
251
-
252
- Form 模块:
253
- GET /{basePath}/new → 新建表单
254
- POST /{basePath} → 创建操作
255
- GET /{basePath}/edit/:id → 编辑表单
256
- PUT /{basePath}/:id → 更新操作
257
-
258
- Custom 模块:
259
- GET /{basePath} → 自定义页面
260
- ```
261
-
262
- ### 路由处理流程
263
-
264
- ```
265
- 1. RouteHandler.handle(ctx)
266
- └─> 获取用户信息(通过 AuthProvider.tokenToUser)
267
- └─> 创建 HtmxAdminContext
268
- └─> 创建模块实例(new moduleClass())
269
- └─> 初始化模块实例(moduleInstance.__init(context))
270
- └─> 权限校验(如果提供了 AuthProvider)
271
-
272
- 2. 处理请求
273
- └─> 调用 moduleInstance.__handle()
274
- └─> 默认调用 moduleInstance.render()
275
- └─> FormPageModule 重写此方法处理不同 HTTP method
276
-
277
- 3. 设置页面元数据
278
- └─> adminContext.title = moduleInstance.getTitle()
279
- └─> adminContext.description = moduleInstance.getDescription()
280
- └─> adminContext.breadcrumbs = moduleInstance.getBreadcrumbs()
281
-
282
- 4. 构建响应
283
- └─> 如果是完整页面请求 → renderFullPage()
284
- └─> 如果是片段请求 → renderFragment()
285
- └─> 自动添加 HX-Retarget 响应头
286
- └─> 设置 HX-Push-Url(如果 context.pushUrl 存在)
287
-
288
- 5. 返回响应
289
- └─> ctx.html(responseContent, status, headers)
290
- ```
291
-
292
- ## 组件系统
293
-
294
- ### 组件分类
295
-
296
- #### 内部组件(不导出)
297
-
298
- - `Layout`: 主布局组件
299
- - `NavItem`: 导航项组件
300
- - `Header`: 页面头部组件
301
- - `Breadcrumb`: 面包屑组件
302
- - `LoadingBar`: 加载指示器
303
- - `Table`: 表格组件
304
-
305
- #### 外部组件(通过 Components 命名空间导出)
306
-
307
- - **页面组件**: `Detail`, `Form`, `PageHeader`, `EmptyState`, `Dialog`, `ErrorAlert`
308
- - **布局组件**: `Card`, `Button`, `ActionButton`, `Pagination`, `FilterCard`
309
- - **数据展示**: `StatCard`, `ActivityCard`, `SystemStatusCard`, `Badge`
310
- - **表单组件**: `FormField`, `Input`, `Textarea`, `Select`, `DateInput`
311
-
312
- ### 组件设计原则
313
-
314
- 1. **单一职责**: 每个组件只负责一个功能
315
- 2. **可组合**: 组件可以组合使用
316
- 3. **类型安全**: 所有组件都有完整的类型定义
317
- 4. **样式统一**: 使用 Tailwind CSS 统一样式
318
-
319
- ## 数据流
320
-
321
- ### 列表页数据流
322
-
323
- ```
324
- 用户请求 → 路由处理 → 解析参数 → 调用模块方法 → 数据源查询 → 渲染组件 → 返回响应
325
- ```
326
-
327
- ### 表单提交数据流
328
-
329
- ```
330
- 用户提交 → 路由处理 → 解析表单数据 → 验证数据 → 调用模块方法 → 数据源操作 → 返回响应
331
- ```
332
-
333
- ### 错误处理数据流
334
-
335
- ```
336
- 异常发生 → 捕获异常 → createErrorResponse → 构建错误响应 → 设置 HX-Retarget → 返回错误响应
337
- ```
338
-
339
- ## 响应处理流程
340
-
341
- ### 正常响应流程
342
-
343
- ```
344
- 1. RouteHandler.handle(ctx)
345
- └─> 创建 HtmxAdminContext
346
- └─> 创建并初始化模块实例
347
- └─> 调用 moduleInstance.__handle()
348
- └─> 默认调用 moduleInstance.render()
349
-
350
- 2. 设置页面元数据
351
- └─> adminContext.title = moduleInstance.getTitle()
352
- └─> adminContext.description = moduleInstance.getDescription()
353
- └─> adminContext.breadcrumbs = moduleInstance.getBreadcrumbs()
354
-
355
- 3. 构建响应
356
- └─> 如果是完整页面 → renderFullPage()
357
- └─> 如果是片段请求 → renderFragment()
358
- └─> 自动设置 HX-Retarget 响应头
359
-
360
- 4. 返回响应
361
- └─> ctx.html(responseContent, status, headers)
362
- ```
363
-
364
- ### Dialog 模式响应流程
365
-
366
- ```
367
- 1. 检测 Dialog 模式(在 HtmxAdminContext 构造函数中)
368
- └─> URL 参数 dialog=true
369
- └─> 或 HX-Target = #dialog-container
370
- └─> adminContext.isDialog = true
371
- └─> adminContext.target = "#dialog-container"
372
-
373
- 2. 处理请求(与正常流程相同)
374
- └─> 调用 moduleInstance.__handle()
375
-
376
- 3. 构建响应
377
- └─> renderFragment() 检测到 isDialog
378
- └─> HX-Retarget: #dialog-container
379
- └─> 不设置 HX-Push-Url(保持当前 URL)
380
-
381
- 4. 返回响应
382
- └─> ctx.html(responseContent, 200, headers)
383
- ```
384
-
385
- ### 错误响应流程
386
-
387
- ```
388
- 1. 捕获异常(在 RouteHandler.handle() 中)
389
- └─> try-catch 捕获业务错误
390
- └─> handleError(adminContext, error)
391
-
392
- 2. 处理错误
393
- └─> adminContext.content = 错误内容
394
- └─> adminContext.sendNotification("error", "错误", errorMessage)
395
-
396
- 3. 构建响应
397
- └─> 正常构建响应(错误内容 + 通知)
398
- └─> 通知通过 OOB 更新到错误容器
399
-
400
- 4. 返回响应
401
- └─> ctx.html(responseContent, status, headers)
402
- ```
403
-
404
- ## 关键实现细节
405
-
406
- ### 1. 响应目标检测
407
-
408
- 响应目标检测逻辑考虑了多种情况:
409
-
410
- - URL 参数 `dialog=true`
411
- - 请求头 `HX-Target`
412
- - Referer 中的 `dialog=true`
413
-
414
- 这样可以确保 Dialog 模式在各种场景下都能正确工作。
415
-
416
- ### 2. OOB 交换策略
417
-
418
- OOB 交换策略:
419
-
420
- - **主要内容**: 作为非 OOB 内容返回,通过 `HX-Retarget` 指定目标
421
- - **导航更新**: 通过 OOB 元素更新,只更新状态改变的导航项
422
- - **错误清空**: 成功响应时通过 OOB 清空错误容器
423
-
424
- 这样可以实现多区域同时更新,而不需要多个请求。
425
-
426
- ### 3. URL 状态管理
427
-
428
- URL 状态管理策略:
429
-
430
- - 筛选条件记录在 URL 查询参数中
431
- - 分页信息记录在 URL 查询参数中
432
- - 排序信息记录在 URL 查询参数中
433
- - 刷新页面后状态自动恢复
434
-
435
- 这样可以实现书签和分享功能。
436
-
437
- ### 4. 错误处理机制
438
-
439
- 错误处理机制:
440
-
441
- - 统一的错误响应格式
442
- - 全局错误通知区域
443
- - 支持多个错误消息同时显示
444
- - 每个错误消息可以单独关闭
445
-
446
- 这样可以提供良好的用户体验。
447
-
448
- ## 扩展点
449
-
450
- ### 1. 自定义数据源
451
-
452
- 实现对应的数据源接口即可:
453
-
454
- ```typescript
455
- class CustomDatasource<T> implements ListDatasource<T> {
456
- async getList(params: ListParams): Promise<ListResult<T>> {
457
- // 自定义实现
458
- }
459
- }
460
- ```
461
-
462
- ### 2. 自定义组件
463
-
464
- 可以在自定义页面中使用任何组件:
465
-
466
- ```typescript
467
- async render() {
468
- return (
469
- <div>
470
- {/* 使用任何组件 */}
471
- </div>
472
- );
473
- }
474
- ```
475
-
476
- ### 3. 自定义请求处理
477
-
478
- 可以重写 `__handle()` 方法处理不同的请求逻辑:
479
-
480
- ```typescript
481
- async __handle() {
482
- const method = this.context.ctx.req.method;
483
- if (method === "POST") {
484
- // 处理 POST 请求
485
- }
486
- return await this.render();
487
- }
488
- ```
489
-
490
- ### 4. 自定义响应处理
491
-
492
- 可以通过 `HtmxAdminContext` 控制响应行为:
493
-
494
- ```typescript
495
- // 设置推送 URL
496
- this.context.setPushUrl("/custom/path");
497
-
498
- // 发送通知
499
- this.context.sendSuccess("操作成功", "数据已保存");
500
- ```
501
-
502
- ## 性能考虑
503
-
504
- ### 1. 分页
505
-
506
- - 列表页默认使用分页
507
- - 数据源应该实现分页逻辑
508
- - 避免加载过多数据
509
-
510
- ### 2. 缓存
511
-
512
- - 可以在数据源层面实现缓存
513
- - 使用适当的缓存策略
514
- - 注意缓存失效
515
-
516
- ### 3. 异步处理
517
-
518
- - 所有数据操作都是异步的
519
- - 使用 Promise 处理异步操作
520
- - 避免阻塞主线程
521
-
522
- ## 安全性考虑
523
-
524
- ### 1. 输入验证
525
-
526
- - 在数据源层面验证输入
527
- - 使用参数化查询
528
- - 防止 SQL 注入
529
-
530
- ### 2. 认证和权限控制
531
-
532
- - **认证**: 通过 `AuthProvider.tokenToUser` 从 Cookie 中获取用户信息
533
- - **权限检查**: 基于操作ID进行细粒度权限控制
534
- - **默认权限**: 模块可以声明所需权限,未声明则开放访问
535
- - **权限匹配**: 支持通配符和禁止权限,提供灵活的权限管理
536
- - **权限拒绝**: 局部请求使用弹框,整页请求重定向,避免干扰用户体验
537
-
538
- ### 3. 错误处理
539
-
540
- - 不暴露敏感信息
541
- - 提供有意义的错误消息
542
- - 记录错误日志
543
-
544
- ## 总结
545
-
546
- HtmxAdminPlugin 采用后端驱动的统一响应架构,通过 `RouteHandler` 统一处理所有请求。所有模块都通过 `__handle()` 方法作为统一入口,简化了请求处理流程。
547
-
548
- ### 核心设计原则
549
-
550
- - **后端驱动**: 所有响应行为由后端控制,前端只负责触发请求
551
- - **统一处理**: 通过 `RouteHandler` 统一处理所有页面类型的请求
552
- - **上下文封装**: 通过 `HtmxAdminContext` 封装请求状态和操作
553
- - **类型安全**: 完整的 TypeScript 类型定义
554
- - **可扩展性**: 通过继承和重写实现扩展
555
- - **权限控制**: 基于操作ID的细粒度权限管理,支持通配符和禁止权限
556
-
557
- ### 核心架构特点
558
-
559
- - **模块初始化**: 通过 `__init(context)` 注入上下文,而不是选项对象
560
- - **统一入口**: `__handle()` 方法作为统一入口,`render()` 专注于渲染
561
- - **路径助手**: `PathHelper` 简化路径生成
562
- - **通知机制**: 统一的通知管理机制
563
- - **权限系统**:
564
- - 基于操作ID的权限检查
565
- - 模块级别的权限声明
566
- - 内置权限提示页面
567
- - 支持局部请求弹框和整页重定向
568
-
569
- ### 代码组织
570
-
571
- 插件采用模块化的代码组织方式:
572
-
573
- ```
574
- src/
575
- plugin.tsx # 插件主逻辑
576
- handler.tsx # 请求处理流水线
577
- base/ # 模块基类
578
- base-page.ts # PageModule 基类
579
- base-list.tsx # ListPageModule
580
- base-detail.tsx # DetailPageModule
581
- base-form.tsx # FormPageModule
582
- components/ # UI 组件
583
- utils/ # 工具函数
584
- auth.ts # 认证和权限校验
585
- operation.ts # 操作ID生成
586
- permission-handler.tsx # 权限拒绝处理
587
- permissions.ts # 权限匹配
588
- context.tsx # 上下文对象
589
- ...
590
- types.ts # 类型定义
591
- ```
592
-
593
- 通过这些原则和架构,插件提供了一个灵活、易用、可扩展的管理后台解决方案。
594
-
@@ -1,102 +0,0 @@
1
- # 设计原则
2
-
3
- ## 后端控制交换目标
4
-
5
- ### 核心原则
6
-
7
- **交换目标的控制应该完全交由后端控制,前端不应该设置 `hx-target` 和 `hx-swap` 属性。**
8
-
9
- ### 为什么?
10
-
11
- 1. **简洁性**:提供一个简单易用的后台管理框架,代码应该简洁
12
- 2. **灵活性**:后端可以根据请求参数(如 `?dialog=true`)动态决定交换目标
13
- 3. **错误处理**:如果请求失败,后端可以返回错误通知而不替换任何内容,不影响用户的现有工作状态
14
-
15
- ### 示例场景
16
-
17
- #### 场景 1:弹框模式
18
-
19
- 用户点击一个链接,链接是 `?dialog=true`,后端应该:
20
- - 检测到 `dialog=true` 参数
21
- - 设置 `HX-Retarget: #dialog-container` 和 `HX-Reswap: innerHTML`
22
- - 返回 Dialog 组件包装的内容
23
-
24
- **前端代码**:
25
- ```tsx
26
- // ✅ 正确:使用 hx-get 触发 HTMX 请求,保留 href 用于普通跳转或新窗口打开
27
- <a
28
- href="/admin/articles/detail/1?dialog=true"
29
- hx-get="/admin/articles/detail/1?dialog=true"
30
- >
31
- 在弹框中打开文章详情
32
- </a>
33
-
34
- // ❌ 错误:不应该在前端设置 hx-target,后端会根据 ?dialog=true 自动设置
35
- <a
36
- href="/admin/articles/detail/1?dialog=true"
37
- hx-get="/admin/articles/detail/1?dialog=true"
38
- hx-target="#dialog-container"
39
- hx-swap="innerHTML"
40
- >
41
- 在弹框中打开文章详情
42
- </a>
43
-
44
- // ✅ 正确:只使用 href,用于普通跳转或新窗口打开(右键菜单)
45
- <a href="/admin/articles/detail/1">
46
- 在新窗口打开文章详情
47
- </a>
48
- ```
49
-
50
- #### 场景 2:错误处理
51
-
52
- 如果请求失败(例如权限不足、服务器错误等),后端应该:
53
- - 返回错误通知(OOB swap)
54
- - 设置 `HX-Reswap: none` 阻止替换任何内容
55
- - 不影响用户的现有工作状态
56
-
57
- **后端代码**:
58
- ```typescript
59
- // HTMX 请求失败时
60
- if (isHtmxRequest) {
61
- return ctx.html(
62
- <>
63
- <div id="error-container" hx-swap-oob="beforeend">
64
- <ErrorAlert
65
- type="error"
66
- title="请求失败"
67
- message={errorMessage}
68
- />
69
- </div>
70
- <title>错误</title>
71
- </>,
72
- 500,
73
- {
74
- "HX-Reswap": "none", // 关键:不替换任何内容
75
- }
76
- );
77
- }
78
- ```
79
-
80
- ### 实现细节
81
-
82
- 1. **检测弹框模式**:
83
- - 通过 `?dialog=true` 查询参数
84
- - 通过 `HX-Target: #dialog-container` 请求头
85
- - 通过 `Referer` 头中的 `dialog=true`
86
-
87
- 2. **设置响应头**:
88
- - 弹框模式:`HX-Retarget: #dialog-container`, `HX-Reswap: innerHTML`
89
- - 普通模式:`HX-Retarget: #main-content`, `HX-Reswap: outerHTML`
90
- - 错误/通知:`HX-Reswap: none`(不替换任何内容)
91
-
92
- 3. **错误处理**:
93
- - 所有错误都应该返回通知而不是替换内容
94
- - 使用 OOB swap 将通知插入到 `#error-container`
95
- - 设置 `HX-Reswap: none` 阻止内容替换
96
-
97
- ### 优势
98
-
99
- 1. **代码简洁**:前端代码不需要关心交换目标,只需要提供链接
100
- 2. **灵活控制**:后端可以根据业务逻辑动态决定交换目标
101
- 3. **错误友好**:错误不会破坏用户的当前工作状态
102
- 4. **易于维护**:交换逻辑集中在一个地方,易于修改和测试