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

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.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { logger, PluginPriority } from 'imean-service-engine';
1
+ import { jsx, jsxs, Fragment } from 'hono/jsx/jsx-runtime';
2
2
  import { promises } from 'fs';
3
+ import { logger, PluginPriority } from 'imean-service-engine';
3
4
  import { join } from 'path';
4
- import { jsx, jsxs, Fragment } from 'hono/jsx/jsx-runtime';
5
5
  import { getCookie } from 'hono/cookie';
6
6
  import { html } from 'hono/html';
7
7
 
@@ -14,6 +14,170 @@ var __export = (target, all) => {
14
14
  for (var name in all)
15
15
  __defProp(target, name, { get: all[name], enumerable: true });
16
16
  };
17
+ function Button(props) {
18
+ const {
19
+ children,
20
+ variant = "primary",
21
+ size = "md",
22
+ disabled = false,
23
+ className = "",
24
+ ...rest
25
+ } = props;
26
+ const baseClasses = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
27
+ const variantClasses = {
28
+ primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
29
+ secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500",
30
+ danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
31
+ ghost: "bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500"
32
+ };
33
+ const sizeClasses = {
34
+ sm: "px-3 py-1.5 text-sm",
35
+ md: "px-4 py-2 text-sm",
36
+ lg: "px-6 py-3 text-base"
37
+ };
38
+ const classes = [
39
+ baseClasses,
40
+ variantClasses[variant],
41
+ sizeClasses[size],
42
+ disabled ? "opacity-50 cursor-not-allowed" : "",
43
+ className
44
+ ].filter(Boolean).join(" ");
45
+ const href = rest.href ?? "#";
46
+ return /* @__PURE__ */ jsx(
47
+ "a",
48
+ {
49
+ className: classes,
50
+ disabled,
51
+ href,
52
+ ...rest,
53
+ children
54
+ }
55
+ );
56
+ }
57
+ var init_button = __esm({
58
+ "src/components/button.tsx"() {
59
+ }
60
+ });
61
+
62
+ // src/utils/action-button-renderer.tsx
63
+ var action_button_renderer_exports = {};
64
+ __export(action_button_renderer_exports, {
65
+ renderActionButton: () => renderActionButton,
66
+ renderActionButtons: () => renderActionButtons
67
+ });
68
+ function renderActionButton(action, index) {
69
+ const {
70
+ label,
71
+ href,
72
+ hxGet,
73
+ hxPost,
74
+ hxPut,
75
+ hxDelete,
76
+ variant = "primary",
77
+ confirm,
78
+ close,
79
+ submit,
80
+ formId,
81
+ onClick,
82
+ className = "",
83
+ target
84
+ } = action;
85
+ if (submit && formId) {
86
+ const variantStyles = {
87
+ primary: "bg-blue-600 text-white hover:bg-blue-700",
88
+ secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
89
+ danger: "bg-red-600 text-white hover:bg-red-700",
90
+ ghost: "bg-transparent text-gray-700 hover:bg-gray-100"
91
+ };
92
+ const buttonStyle = variantStyles[variant] || variantStyles.primary;
93
+ const testId2 = label === "\u521B\u5EFA" || label === "\u66F4\u65B0" ? "submit-button" : `action-${label}`;
94
+ return /* @__PURE__ */ jsx(
95
+ "button",
96
+ {
97
+ type: "submit",
98
+ form: formId,
99
+ className: `px-4 py-2 rounded transition-colors font-medium ${buttonStyle} ${className}`,
100
+ "data-testid": testId2,
101
+ ...confirm && { "data-confirm": confirm },
102
+ children: label
103
+ },
104
+ index
105
+ );
106
+ }
107
+ const finalOnClick = close ? CLOSE_DIALOG_SCRIPT : onClick;
108
+ if (finalOnClick) {
109
+ const variantStyles = {
110
+ primary: "bg-blue-600 text-white hover:bg-blue-700",
111
+ secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
112
+ danger: "bg-red-600 text-white hover:bg-red-700",
113
+ ghost: "bg-transparent text-gray-700 hover:bg-gray-100"
114
+ };
115
+ const buttonStyle = variantStyles[variant] || variantStyles.secondary;
116
+ let testId2 = `action-${label}`;
117
+ if (label === "\u53D6\u6D88") {
118
+ testId2 = "cancel-button";
119
+ } else if (label === "\u5173\u95ED") {
120
+ testId2 = "close-button";
121
+ }
122
+ return /* @__PURE__ */ jsx(
123
+ "button",
124
+ {
125
+ type: "button",
126
+ _: finalOnClick,
127
+ className: `px-4 py-2 rounded transition-colors font-medium ${buttonStyle} ${className}`,
128
+ "data-testid": testId2,
129
+ ...confirm && { "data-confirm": confirm },
130
+ children: label
131
+ },
132
+ index
133
+ );
134
+ }
135
+ let testId = `action-${label}`;
136
+ if (label === "\u65B0\u5EFA" || label === "\u521B\u5EFA") {
137
+ testId = "create-button";
138
+ } else if (label === "\u53D6\u6D88") {
139
+ testId = "cancel-button";
140
+ } else if (label === "\u5173\u95ED") {
141
+ testId = "close-button";
142
+ }
143
+ const isNewWindow = target === "_blank";
144
+ const htmxAttrs = {};
145
+ if (!isNewWindow) {
146
+ if (hxGet) htmxAttrs["hx-get"] = hxGet;
147
+ if (hxPost) htmxAttrs["hx-post"] = hxPost;
148
+ if (hxPut) htmxAttrs["hx-put"] = hxPut;
149
+ if (hxDelete) htmxAttrs["hx-delete"] = hxDelete;
150
+ }
151
+ if (confirm) htmxAttrs["hx-confirm"] = confirm;
152
+ return /* @__PURE__ */ jsx(
153
+ Button,
154
+ {
155
+ variant,
156
+ href,
157
+ className,
158
+ "data-testid": testId,
159
+ target,
160
+ rel: target === "_blank" ? "noopener noreferrer" : void 0,
161
+ ...htmxAttrs,
162
+ children: label
163
+ },
164
+ index
165
+ );
166
+ }
167
+ function renderActionButtons(actions) {
168
+ return actions.map((action, index) => renderActionButton(action, index));
169
+ }
170
+ var CLOSE_DIALOG_SCRIPT;
171
+ var init_action_button_renderer = __esm({
172
+ "src/utils/action-button-renderer.tsx"() {
173
+ init_button();
174
+ CLOSE_DIALOG_SCRIPT = `on click
175
+ add .dialog-exit to .dialog-backdrop
176
+ add .dialog-content-exit to .dialog-content
177
+ wait 200ms
178
+ set #dialog-container's innerHTML to '' end`;
179
+ }
180
+ });
17
181
 
18
182
  // src/utils/cdn-cache.ts
19
183
  var cdn_cache_exports = {};
@@ -60,7 +224,11 @@ async function saveToFileCache(name, content, mimeType) {
60
224
  promises.writeFile(contentPath, content, "utf-8"),
61
225
  promises.writeFile(
62
226
  metadataPath,
63
- JSON.stringify({ mimeType, cachedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
227
+ JSON.stringify(
228
+ { mimeType, cachedAt: (/* @__PURE__ */ new Date()).toISOString() },
229
+ null,
230
+ 2
231
+ ),
64
232
  "utf-8"
65
233
  )
66
234
  ]);
@@ -73,14 +241,18 @@ async function fetchAndCacheResource(resource) {
73
241
  try {
74
242
  const fileCache = await readFromFileCache(resource.name);
75
243
  if (fileCache) {
76
- logger.info(`[CDNCache] \u4ECE\u6587\u4EF6\u7F13\u5B58\u52A0\u8F7D\u8D44\u6E90: ${resource.name} (${fileCache.content.length} \u5B57\u8282)`);
244
+ logger.info(
245
+ `[CDNCache] \u4ECE\u6587\u4EF6\u7F13\u5B58\u52A0\u8F7D\u8D44\u6E90: ${resource.name} (${fileCache.content.length} \u5B57\u8282)`
246
+ );
77
247
  cache.set(resource.name, fileCache);
78
248
  return;
79
249
  }
80
250
  logger.info(`[CDNCache] \u6B63\u5728\u4E0B\u8F7D\u8D44\u6E90: ${resource.name} (${resource.url})`);
81
251
  const response = await fetch(resource.url);
82
252
  if (!response.ok) {
83
- throw new Error(`Failed to fetch ${resource.url}: ${response.statusText}`);
253
+ throw new Error(
254
+ `Failed to fetch ${resource.url}: ${response.statusText}`
255
+ );
84
256
  }
85
257
  const content = await response.text();
86
258
  const cached = {
@@ -88,10 +260,14 @@ async function fetchAndCacheResource(resource) {
88
260
  mimeType: resource.mimeType
89
261
  };
90
262
  cache.set(resource.name, cached);
91
- saveToFileCache(resource.name, content, resource.mimeType).catch((error) => {
92
- logger.warn(`[CDNCache] \u4FDD\u5B58\u6587\u4EF6\u7F13\u5B58\u5931\u8D25: ${resource.name}`, error);
93
- });
94
- logger.info(`[CDNCache] \u8D44\u6E90\u5DF2\u4E0B\u8F7D\u5E76\u7F13\u5B58: ${resource.name} (${content.length} \u5B57\u8282)`);
263
+ saveToFileCache(resource.name, content, resource.mimeType).catch(
264
+ (error) => {
265
+ logger.warn(`[CDNCache] \u4FDD\u5B58\u6587\u4EF6\u7F13\u5B58\u5931\u8D25: ${resource.name}`, error);
266
+ }
267
+ );
268
+ logger.info(
269
+ `[CDNCache] \u8D44\u6E90\u5DF2\u4E0B\u8F7D\u5E76\u7F13\u5B58: ${resource.name} (${content.length} \u5B57\u8282)`
270
+ );
95
271
  } catch (error) {
96
272
  logger.error(`[CDNCache] \u4E0B\u8F7D\u8D44\u6E90\u5931\u8D25: ${resource.name}`, error);
97
273
  }
@@ -143,6 +319,16 @@ var init_cdn_cache = __esm({
143
319
  name: "alpinejs",
144
320
  url: "https://unpkg.com/alpinejs@latest/dist/cdn.min.js",
145
321
  mimeType: "application/javascript"
322
+ },
323
+ {
324
+ name: "idiomorph",
325
+ url: "https://unpkg.com/idiomorph@0.7.4/dist/idiomorph-ext.min.js",
326
+ mimeType: "application/javascript"
327
+ },
328
+ {
329
+ name: "sortablejs",
330
+ url: "https://unpkg.com/sortablejs@latest/Sortable.min.js",
331
+ mimeType: "application/javascript"
146
332
  }
147
333
  ];
148
334
  cache = /* @__PURE__ */ new Map();
@@ -281,11 +467,17 @@ var BaseFeature = class {
281
467
  return metadata.description;
282
468
  }
283
469
  /**
284
- * 获取操作按钮(默认实现:返回空数组)
285
- * 子类可以覆盖此方法以提供操作按钮
470
+ * 获取操作按钮(默认实现:返回 null)
471
+ * 子类可以覆盖此方法以提供操作按钮(返回 JSX)
286
472
  */
287
473
  async getActions(context) {
288
- return [];
474
+ return null;
475
+ }
476
+ /**
477
+ * 处理请求
478
+ */
479
+ async handle(context) {
480
+ return await this.render?.(context) ?? null;
289
481
  }
290
482
  };
291
483
  function getFieldValue(field, initialData) {
@@ -437,7 +629,7 @@ function renderFormField(field, initialData, formFieldRenderers) {
437
629
  name: field.name,
438
630
  required: field.required,
439
631
  placeholder: field.placeholder || (isJsonString(value) ? "JSON \u683C\u5F0F\u6570\u636E" : ""),
440
- rows: isJsonString(value) ? 10 : 4,
632
+ rows: isJsonString(value) ? Math.max(10, value.split("\n").length) : Math.max(5, Math.ceil(value.length / 100)),
441
633
  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",
442
634
  "data-testid": `input-${field.name}`,
443
635
  children: isJsonString(value) ? formatJsonString(value) : value
@@ -588,10 +780,10 @@ function FormPage(props) {
588
780
  id: finalFormId,
589
781
  method: method === "put" ? "post" : method,
590
782
  action: submitUrl,
591
- "hx-boost": "true",
592
783
  ...method === "put" ? { "hx-put": submitUrl } : {},
593
784
  "hx-indicator": "#form-loading-indicator",
594
785
  "data-testid": "form",
786
+ "hx-sync": "this:abort",
595
787
  className: isDialog ? "p-6" : "mt-6",
596
788
  children: /* @__PURE__ */ jsx("div", { className: "bg-white rounded-lg border border-gray-200 shadow-sm", children: /* @__PURE__ */ jsx("div", { className: "p-6", children: groups.map((group, index) => /* @__PURE__ */ jsx(
597
789
  "div",
@@ -616,6 +808,7 @@ function FormPage(props) {
616
808
  method: method === "put" ? "post" : method,
617
809
  action: submitUrl,
618
810
  "hx-boost": "true",
811
+ "hx-trigger": "click from:#form-submit-button",
619
812
  ...method === "put" ? { "hx-put": submitUrl } : {},
620
813
  "hx-indicator": "#form-loading-indicator",
621
814
  className: "space-y-6",
@@ -631,13 +824,195 @@ function FormPage(props) {
631
824
  );
632
825
  }
633
826
 
827
+ // src/utils/json-form-parser.ts
828
+ function parseNestedFormData(formData) {
829
+ const result = {};
830
+ const arrayIndicesMap = /* @__PURE__ */ new Map();
831
+ const arrayIndicesSet = /* @__PURE__ */ new Map();
832
+ for (const [key] of Object.entries(formData)) {
833
+ const parts = parseKey(key);
834
+ collectArrayIndices(parts, arrayIndicesSet);
835
+ }
836
+ for (const [pathKey, indices] of arrayIndicesSet.entries()) {
837
+ const sortedIndices = Array.from(indices).sort((a, b) => a - b);
838
+ const indexMap = /* @__PURE__ */ new Map();
839
+ for (let i = 0; i < sortedIndices.length; i++) {
840
+ indexMap.set(sortedIndices[i], i);
841
+ }
842
+ arrayIndicesMap.set(pathKey, indexMap);
843
+ }
844
+ for (const [key, value] of Object.entries(formData)) {
845
+ const parts = parseKey(key);
846
+ buildNestedObject(result, parts, value, arrayIndicesMap);
847
+ }
848
+ compressArrays(result);
849
+ return result;
850
+ }
851
+ function collectArrayIndices(parts, arrayIndicesSet) {
852
+ for (let i = 0; i < parts.length; i++) {
853
+ const part = parts[i];
854
+ if (typeof part === "number") {
855
+ const pathKey = getArrayPathKey(parts, i);
856
+ if (!arrayIndicesSet.has(pathKey)) {
857
+ arrayIndicesSet.set(pathKey, /* @__PURE__ */ new Set());
858
+ }
859
+ arrayIndicesSet.get(pathKey).add(part);
860
+ }
861
+ }
862
+ }
863
+ function parseKey(key) {
864
+ const result = [];
865
+ let current = "";
866
+ let inBrackets = false;
867
+ let bracketContent = "";
868
+ for (let i = 0; i < key.length; i++) {
869
+ const char = key[i];
870
+ if (char === "[" && !inBrackets) {
871
+ if (current) {
872
+ result.push(current);
873
+ current = "";
874
+ }
875
+ inBrackets = true;
876
+ } else if (char === "]" && inBrackets) {
877
+ inBrackets = false;
878
+ const index = parseInt(bracketContent, 10);
879
+ if (!isNaN(index)) {
880
+ result.push(index);
881
+ }
882
+ bracketContent = "";
883
+ } else if (char === "." && !inBrackets) {
884
+ if (current) {
885
+ result.push(current);
886
+ current = "";
887
+ }
888
+ } else if (inBrackets) {
889
+ bracketContent += char;
890
+ } else {
891
+ current += char;
892
+ }
893
+ }
894
+ if (current) {
895
+ result.push(current);
896
+ }
897
+ return result;
898
+ }
899
+ function getArrayPathKey(parts, endIndex) {
900
+ const pathParts = [];
901
+ for (let i = 0; i < endIndex; i++) {
902
+ const part = parts[i];
903
+ if (typeof part === "string") {
904
+ pathParts.push(part);
905
+ } else {
906
+ pathParts.push(`[${part}]`);
907
+ }
908
+ }
909
+ return pathParts.join(".");
910
+ }
911
+ function getCompressedIndex(pathKey, originalIndex, arrayIndicesMap) {
912
+ if (!arrayIndicesMap.has(pathKey)) {
913
+ arrayIndicesMap.set(pathKey, /* @__PURE__ */ new Map());
914
+ }
915
+ const indexMap = arrayIndicesMap.get(pathKey);
916
+ let compressedIndex = indexMap.get(originalIndex);
917
+ if (compressedIndex === void 0) {
918
+ compressedIndex = indexMap.size;
919
+ indexMap.set(originalIndex, compressedIndex);
920
+ }
921
+ return compressedIndex;
922
+ }
923
+ function buildNestedObject(obj, parts, value, arrayIndicesMap) {
924
+ let current = obj;
925
+ for (let i = 0; i < parts.length - 1; i++) {
926
+ const part = parts[i];
927
+ const nextPart = parts[i + 1];
928
+ if (typeof part === "number") {
929
+ if (!Array.isArray(current)) {
930
+ throw new Error(`Expected array at index ${i}, but got ${typeof current}`);
931
+ }
932
+ const pathKey = getArrayPathKey(parts, i);
933
+ const compressedIndex = getCompressedIndex(
934
+ pathKey,
935
+ part,
936
+ arrayIndicesMap
937
+ );
938
+ while (current.length <= compressedIndex) {
939
+ if (typeof nextPart === "number") {
940
+ current.push([]);
941
+ } else {
942
+ current.push({});
943
+ }
944
+ }
945
+ current = current[compressedIndex];
946
+ } else {
947
+ if (typeof nextPart === "number") {
948
+ if (!current.hasOwnProperty(part) || !Array.isArray(current[part])) {
949
+ current[part] = [];
950
+ }
951
+ } else {
952
+ if (!current.hasOwnProperty(part) || (typeof current[part] !== "object" || current[part] === null || Array.isArray(current[part]))) {
953
+ current[part] = {};
954
+ }
955
+ }
956
+ current = current[part];
957
+ }
958
+ }
959
+ const lastPart = parts[parts.length - 1];
960
+ if (typeof lastPart === "number") {
961
+ const pathKey = getArrayPathKey(parts, parts.length - 1);
962
+ const compressedIndex = getCompressedIndex(
963
+ pathKey,
964
+ lastPart,
965
+ arrayIndicesMap
966
+ );
967
+ if (!Array.isArray(current)) {
968
+ throw new Error(`Expected array for last part, but got ${typeof current}`);
969
+ }
970
+ while (current.length <= compressedIndex) {
971
+ current.push(void 0);
972
+ }
973
+ current[compressedIndex] = value;
974
+ } else {
975
+ if (Array.isArray(current)) {
976
+ throw new Error(`Cannot set property on array: ${lastPart}`);
977
+ }
978
+ if (typeof current !== "object" || current === null) {
979
+ current = {};
980
+ }
981
+ current[lastPart] = value;
982
+ }
983
+ }
984
+ function compressArrays(obj, arrayIndicesMap) {
985
+ if (Array.isArray(obj)) {
986
+ const newArray = [];
987
+ for (let i = 0; i < obj.length; i++) {
988
+ if (obj[i] !== void 0) {
989
+ newArray.push(obj[i]);
990
+ if (typeof obj[i] === "object" && obj[i] !== null) {
991
+ compressArrays(obj[i]);
992
+ }
993
+ }
994
+ }
995
+ obj.length = 0;
996
+ obj.push(...newArray);
997
+ } else if (typeof obj === "object" && obj !== null) {
998
+ for (const key in obj) {
999
+ if (obj.hasOwnProperty(key)) {
1000
+ compressArrays(obj[key]);
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+
634
1006
  // src/utils/form-data-processor.ts
1007
+ function parseNestedFormData2(flatData) {
1008
+ return parseNestedFormData(flatData);
1009
+ }
635
1010
  function preprocessFormData(data, zodSchema) {
636
1011
  if (!zodSchema) {
637
1012
  return data;
638
1013
  }
639
1014
  const processed = { ...data };
640
- const def = zodSchema._def;
1015
+ const def = zodSchema._zod?.def || zodSchema._def;
641
1016
  const shape = typeof def.shape === "function" ? def.shape() : def.shape;
642
1017
  if (!shape || typeof shape !== "object") {
643
1018
  return processed;
@@ -655,16 +1030,18 @@ function preprocessFormData(data, zodSchema) {
655
1030
  processed[fieldName] = void 0;
656
1031
  continue;
657
1032
  }
658
- const fieldDef = fieldSchema._def;
659
- let typeName = fieldDef?.type || fieldDef?.typeName;
660
- if (typeName === "optional" || typeName === "ZodOptional") {
661
- const innerType = fieldDef.innerType;
1033
+ const fieldDef = fieldSchema._zod?.def || fieldSchema._def;
1034
+ let typeName = fieldDef?.type;
1035
+ let actualSchema = fieldSchema;
1036
+ if (typeName === "optional") {
1037
+ const innerType = fieldDef?.innerType;
662
1038
  if (innerType) {
663
- const innerDef = innerType._def;
664
- typeName = innerDef?.type || innerDef?.typeName;
1039
+ actualSchema = innerType;
1040
+ const innerDef = innerType._zod?.def || innerType._def;
1041
+ typeName = innerDef?.type;
665
1042
  }
666
1043
  }
667
- if (typeName === "number" || typeName === "ZodNumber") {
1044
+ if (typeName === "number" || typeName === "int") {
668
1045
  if (typeof value === "string") {
669
1046
  const trimmed = value.trim();
670
1047
  if (trimmed !== "") {
@@ -677,7 +1054,7 @@ function preprocessFormData(data, zodSchema) {
677
1054
  processed[fieldName] = value;
678
1055
  }
679
1056
  }
680
- if (typeName === "boolean" || typeName === "ZodBoolean") {
1057
+ if (typeName === "boolean") {
681
1058
  if (typeof value === "string") {
682
1059
  const trimmed = value.trim().toLowerCase();
683
1060
  if (trimmed === "true" || trimmed === "1" || trimmed === "on") {
@@ -689,8 +1066,64 @@ function preprocessFormData(data, zodSchema) {
689
1066
  processed[fieldName] = value;
690
1067
  }
691
1068
  }
692
- if (typeName === "array" || typeName === "ZodArray") {
693
- if (typeof value === "string") {
1069
+ if (typeName === "array") {
1070
+ if (Array.isArray(value)) {
1071
+ const arraySchema = actualSchema;
1072
+ const arrayDef = arraySchema._zod?.def || arraySchema._def;
1073
+ let elementType = arrayDef?.element;
1074
+ if (!elementType) {
1075
+ processed[fieldName] = value;
1076
+ } else {
1077
+ const elementDef = elementType._zod?.def || elementType._def;
1078
+ let elementTypeName = elementDef?.type;
1079
+ if (elementTypeName === "optional") {
1080
+ const innerElementType = elementDef?.innerType;
1081
+ if (innerElementType) {
1082
+ elementType = innerElementType;
1083
+ const innerElementDef = innerElementType._zod?.def || innerElementType._def;
1084
+ elementTypeName = innerElementDef?.type;
1085
+ }
1086
+ }
1087
+ processed[fieldName] = value.map((item) => {
1088
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
1089
+ if (elementTypeName === "object") {
1090
+ const processedItem = preprocessFormData(
1091
+ item,
1092
+ elementType
1093
+ );
1094
+ return { ...item, ...processedItem };
1095
+ }
1096
+ return item;
1097
+ }
1098
+ if (typeof item === "string") {
1099
+ return convertValueByType(item, elementType);
1100
+ }
1101
+ if (Array.isArray(item)) {
1102
+ const elementDef2 = elementType._zod?.def || elementType._def;
1103
+ const nestedInnerType = elementDef2?.element;
1104
+ if (nestedInnerType) {
1105
+ return item.map((subItem) => {
1106
+ if (typeof subItem === "string") {
1107
+ return convertValueByType(subItem, nestedInnerType);
1108
+ }
1109
+ if (typeof subItem === "object" && subItem !== null) {
1110
+ const subItemDef = nestedInnerType._zod?.def || nestedInnerType._def;
1111
+ if (subItemDef?.type === "object") {
1112
+ return preprocessFormData(
1113
+ subItem,
1114
+ nestedInnerType
1115
+ );
1116
+ }
1117
+ }
1118
+ return subItem;
1119
+ });
1120
+ }
1121
+ return item;
1122
+ }
1123
+ return item;
1124
+ });
1125
+ }
1126
+ } else if (typeof value === "string") {
694
1127
  const trimmed = value.trim();
695
1128
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
696
1129
  try {
@@ -704,8 +1137,11 @@ function preprocessFormData(data, zodSchema) {
704
1137
  }
705
1138
  }
706
1139
  }
707
- if (typeName === "object" || typeName === "ZodObject") {
708
- if (typeof value === "string" && value.trim() !== "") {
1140
+ if (typeName === "object") {
1141
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1142
+ const objectSchema = actualSchema;
1143
+ processed[fieldName] = preprocessFormData(value, objectSchema);
1144
+ } else if (typeof value === "string" && value.trim() !== "") {
709
1145
  try {
710
1146
  const trimmed = value.trim();
711
1147
  if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
@@ -716,7 +1152,7 @@ function preprocessFormData(data, zodSchema) {
716
1152
  }
717
1153
  }
718
1154
  }
719
- if (typeName === "any" || typeName === "ZodAny") {
1155
+ if (typeName === "any" || typeName === "unknown") {
720
1156
  if (typeof value === "string" && value.trim() !== "") {
721
1157
  try {
722
1158
  const trimmed = value.trim();
@@ -731,6 +1167,36 @@ function preprocessFormData(data, zodSchema) {
731
1167
  }
732
1168
  return processed;
733
1169
  }
1170
+ function convertValueByType(value, schema) {
1171
+ const def = schema._zod?.def || schema._def;
1172
+ let typeName = def?.type;
1173
+ if (typeName === "optional") {
1174
+ const innerType = def?.innerType;
1175
+ if (innerType) {
1176
+ const innerDef = innerType._zod?.def || innerType._def;
1177
+ typeName = innerDef?.type;
1178
+ }
1179
+ }
1180
+ if (typeName === "number" || typeName === "int") {
1181
+ const trimmed = value.trim();
1182
+ if (trimmed !== "") {
1183
+ const numValue = Number(trimmed);
1184
+ if (!isNaN(numValue)) {
1185
+ return numValue;
1186
+ }
1187
+ }
1188
+ return value;
1189
+ } else if (typeName === "boolean") {
1190
+ const trimmed = value.trim().toLowerCase();
1191
+ if (trimmed === "true" || trimmed === "1" || trimmed === "on") {
1192
+ return true;
1193
+ } else if (trimmed === "false" || trimmed === "0" || trimmed === "off" || trimmed === "") {
1194
+ return false;
1195
+ }
1196
+ return value;
1197
+ }
1198
+ return value;
1199
+ }
734
1200
 
735
1201
  // src/utils/schema-utils.ts
736
1202
  function parseSchemaToFields(schema) {
@@ -1021,22 +1487,9 @@ var BaseFormFeature = class extends BaseFeature {
1021
1487
  if (ctx.req.method === "GET") {
1022
1488
  return this.render(context);
1023
1489
  } else if (ctx.req.method === "POST" || ctx.req.method === "PUT" || ctx.req.method === "PATCH") {
1024
- const methodOverride = ctx.req.header("X-HTTP-Method-Override");
1025
- const actualMethod = methodOverride || ctx.req.method;
1026
- const expectedMethod = this.getFormAction() === "edit" ? "PUT" : "POST";
1027
- if (actualMethod.toUpperCase() !== expectedMethod) {
1028
- logger.warn(
1029
- `[BaseFormFeature] Method mismatch: expected ${expectedMethod}, got ${actualMethod} (request method: ${ctx.req.method}, X-HTTP-Method-Override: ${methodOverride || "none"})`
1030
- );
1031
- }
1032
1490
  const originalData = { ...context.body };
1033
- logger.info(
1034
- `[BaseFormFeature] Original body data: ${JSON.stringify(originalData)}`
1035
- );
1036
- let data = this.preprocessFormData(context.body);
1037
- logger.info(
1038
- `[BaseFormFeature] Preprocessed data: ${JSON.stringify(data)}`
1039
- );
1491
+ const nestedData = parseNestedFormData2(context.body);
1492
+ let data = this.preprocessFormData(nestedData);
1040
1493
  if (!this.schema) {
1041
1494
  throw new Error("Schema is required for form validation");
1042
1495
  }
@@ -1048,9 +1501,6 @@ var BaseFormFeature = class extends BaseFeature {
1048
1501
  const errorMessage = firstError.message;
1049
1502
  const errorText = fieldName ? `${fieldName}: ${errorMessage}` : errorMessage;
1050
1503
  context.sendError("\u9A8C\u8BC1\u5931\u8D25", errorText);
1051
- logger.info(
1052
- `[BaseFormFeature] Validation failed, returning form with originalData: ${JSON.stringify(originalData)}`
1053
- );
1054
1504
  return this.render(context, originalData);
1055
1505
  }
1056
1506
  const item = await this.handleSubmit(
@@ -1070,9 +1520,6 @@ var BaseFormFeature = class extends BaseFeature {
1070
1520
  `${context.model.getMetadata().title}\u5DF2\u6210\u529F${actionText}`
1071
1521
  );
1072
1522
  if (context.isDialog) {
1073
- logger.info(
1074
- `[BaseFormFeature] Dialog mode: setting refresh to close dialog and refresh list`
1075
- );
1076
1523
  context.setRefresh(true);
1077
1524
  if (context.redirectUrl) {
1078
1525
  context.redirectUrl = void 0;
@@ -1080,9 +1527,6 @@ var BaseFormFeature = class extends BaseFeature {
1080
1527
  return null;
1081
1528
  } else {
1082
1529
  const redirectUrl = this.getSuccessRedirectUrl(context, item);
1083
- logger.info(
1084
- `[BaseFormFeature] Page mode: Setting redirect URL: ${redirectUrl} (isHtmxRequest: ${context.isHtmxRequest})`
1085
- );
1086
1530
  context.redirect(redirectUrl);
1087
1531
  return null;
1088
1532
  }
@@ -1104,9 +1548,6 @@ var BaseFormFeature = class extends BaseFeature {
1104
1548
  } else {
1105
1549
  formData = initialData;
1106
1550
  }
1107
- logger.info(
1108
- `[BaseFormFeature] render: initialData=${JSON.stringify(initialData)}, formData=${JSON.stringify(formData)}`
1109
- );
1110
1551
  let submitUrl = this.getSubmitUrl(context);
1111
1552
  if (context.isDialog) {
1112
1553
  const url = new URL(submitUrl, "http://localhost");
@@ -1173,7 +1614,7 @@ var BaseFormFeature = class extends BaseFeature {
1173
1614
  );
1174
1615
  }
1175
1616
  /**
1176
- * 获取操作按钮
1617
+ * 获取操作按钮(返回 JSX)
1177
1618
  */
1178
1619
  async getActions(context) {
1179
1620
  const actions = [];
@@ -1201,7 +1642,8 @@ var BaseFormFeature = class extends BaseFeature {
1201
1642
  });
1202
1643
  }
1203
1644
  }
1204
- return actions;
1645
+ const { renderActionButtons: renderActionButtons2 } = await Promise.resolve().then(() => (init_action_button_renderer(), action_button_renderer_exports));
1646
+ return renderActionButtons2(actions);
1205
1647
  }
1206
1648
  };
1207
1649
 
@@ -1250,7 +1692,7 @@ var CustomFeature = class extends BaseFeature {
1250
1692
  if (this.handlerFn) {
1251
1693
  return await this.handlerFn(context);
1252
1694
  }
1253
- return void 0;
1695
+ return await this.render(context);
1254
1696
  }
1255
1697
  async render(context) {
1256
1698
  if (this.renderFn) {
@@ -1393,7 +1835,13 @@ function renderDefaultValue(value) {
1393
1835
  return /* @__PURE__ */ jsx("pre", { className: "bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-x-auto", children: JSON.stringify(value, null, 2) });
1394
1836
  }
1395
1837
  if (typeof value === "boolean") {
1396
- return /* @__PURE__ */ jsx("span", { className: `px-2 py-1 rounded text-sm font-medium ${value ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`, children: value ? "\u662F" : "\u5426" });
1838
+ return /* @__PURE__ */ jsx(
1839
+ "span",
1840
+ {
1841
+ className: `px-2 py-1 rounded text-sm font-medium ${value ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`,
1842
+ children: value ? "\u662F" : "\u5426"
1843
+ }
1844
+ );
1397
1845
  }
1398
1846
  if (typeof value === "number") {
1399
1847
  return /* @__PURE__ */ jsx("span", { children: value.toLocaleString() });
@@ -1419,8 +1867,20 @@ function renderField(field, value, item) {
1419
1867
  {
1420
1868
  className: "\r\n px-4 py-4 sm:px-6 sm:py-5\r\n flex flex-col\r\n hover:bg-gray-50/50 transition-colors duration-150\r\n group\r\n gap-1.5\r\n ",
1421
1869
  children: [
1422
- /* @__PURE__ */ jsx("dt", { className: "\r\n text-xs sm:text-sm font-semibold text-gray-600 sm:text-gray-700\r\n flex items-start\r\n leading-tight sm:leading-5\r\n tracking-wide\r\n ", children: /* @__PURE__ */ jsx("span", { className: "min-w-0 uppercase sm:normal-case", children: field.label }) }),
1423
- /* @__PURE__ */ jsx("dd", { className: "\r\n text-sm sm:text-base text-gray-900\r\n break-words\r\n leading-relaxed\r\n min-w-0\r\n ", children: /* @__PURE__ */ jsx("div", { className: "min-w-0", children: content }) })
1870
+ /* @__PURE__ */ jsx(
1871
+ "dt",
1872
+ {
1873
+ className: "\r\n text-xs sm:text-sm font-semibold text-gray-600 sm:text-gray-700\r\n flex items-start\r\n leading-tight sm:leading-5\r\n tracking-wide\r\n ",
1874
+ children: /* @__PURE__ */ jsx("span", { className: "min-w-0 uppercase sm:normal-case", children: field.label })
1875
+ }
1876
+ ),
1877
+ /* @__PURE__ */ jsx(
1878
+ "dd",
1879
+ {
1880
+ className: "\r\n text-sm sm:text-base text-gray-900\r\n break-words\r\n leading-relaxed\r\n min-w-0\r\n ",
1881
+ children: /* @__PURE__ */ jsx("div", { className: "min-w-0", children: content })
1882
+ }
1883
+ )
1424
1884
  ]
1425
1885
  },
1426
1886
  field.key
@@ -1454,7 +1914,6 @@ function DetailPage(props) {
1454
1914
  }
1455
1915
  var DefaultDetailFeature = class extends BaseFeature {
1456
1916
  getItem;
1457
- deleteItem;
1458
1917
  titleGetter;
1459
1918
  descriptionGetter;
1460
1919
  detailFieldNames;
@@ -1471,7 +1930,6 @@ var DefaultDetailFeature = class extends BaseFeature {
1471
1930
  this.schema = options.schema;
1472
1931
  this.fields = parseSchemaToFields(options.schema);
1473
1932
  this.getItem = options.getItem;
1474
- this.deleteItem = options.deleteItem;
1475
1933
  this.titleGetter = options.getTitle;
1476
1934
  this.descriptionGetter = options.getDescription;
1477
1935
  this.detailFieldNames = options.detailFieldNames;
@@ -1513,32 +1971,40 @@ var DefaultDetailFeature = class extends BaseFeature {
1513
1971
  }
1514
1972
  const schema = this.schema;
1515
1973
  const groupSchemas = this.groups.map((group) => {
1516
- const pickObject = group.fields.reduce((acc, fieldName) => {
1517
- acc[fieldName] = true;
1518
- return acc;
1519
- }, {});
1974
+ const pickObject = group.fields.reduce(
1975
+ (acc, fieldName) => {
1976
+ acc[fieldName] = true;
1977
+ return acc;
1978
+ },
1979
+ {}
1980
+ );
1520
1981
  return {
1521
1982
  label: group.label,
1522
1983
  schema: schema.pick(pickObject),
1523
1984
  fields: group.fields
1524
1985
  };
1525
1986
  });
1526
- const groupFields = groupSchemas.map(({ label, schema: schema2, fields: fieldNames }) => {
1527
- const groupFields2 = parseSchemaToFields(schema2);
1528
- const detailFields2 = groupFields2.map((field) => ({
1529
- key: field.name,
1530
- label: field.label,
1531
- render: this.fieldRenderers?.[field.name]
1532
- }));
1533
- return {
1534
- label,
1535
- fields: detailFields2,
1536
- values: fieldNames.reduce((acc, fieldName) => {
1537
- acc[fieldName] = item[fieldName];
1538
- return acc;
1539
- }, {})
1540
- };
1541
- });
1987
+ const groupFields = groupSchemas.map(
1988
+ ({ label, schema: schema2, fields: fieldNames }) => {
1989
+ const groupFields2 = parseSchemaToFields(schema2);
1990
+ const detailFields2 = groupFields2.map((field) => ({
1991
+ key: field.name,
1992
+ label: field.label,
1993
+ render: this.fieldRenderers?.[field.name]
1994
+ }));
1995
+ return {
1996
+ label,
1997
+ fields: detailFields2,
1998
+ values: fieldNames.reduce(
1999
+ (acc, fieldName) => {
2000
+ acc[fieldName] = item[fieldName];
2001
+ return acc;
2002
+ },
2003
+ {}
2004
+ )
2005
+ };
2006
+ }
2007
+ );
1542
2008
  return /* @__PURE__ */ jsx(DetailPage, { item, groups: groupFields });
1543
2009
  }
1544
2010
  const detailFields = this.detailFieldNames ? filterFieldsByNames(this.fields || [], this.detailFieldNames) : this.fields || [];
@@ -1569,7 +2035,7 @@ var DefaultDetailFeature = class extends BaseFeature {
1569
2035
  const id = context.params.id;
1570
2036
  const item = await this.getItem(id);
1571
2037
  if (!item) {
1572
- return [];
2038
+ return null;
1573
2039
  }
1574
2040
  const model = context.model;
1575
2041
  const prefix = context.prefix || "";
@@ -1606,7 +2072,8 @@ var DefaultDetailFeature = class extends BaseFeature {
1606
2072
  });
1607
2073
  }
1608
2074
  }
1609
- return actions;
2075
+ const { renderActionButtons: renderActionButtons2 } = await Promise.resolve().then(() => (init_action_button_renderer(), action_button_renderer_exports));
2076
+ return renderActionButtons2(actions);
1610
2077
  }
1611
2078
  };
1612
2079
 
@@ -1812,77 +2279,16 @@ function FilterForm(props) {
1812
2279
  }
1813
2280
  ) });
1814
2281
  }
1815
- function Button(props) {
1816
- const {
1817
- children,
1818
- variant = "primary",
1819
- size = "md",
1820
- disabled = false,
1821
- className = "",
1822
- hxGet,
1823
- hxPost,
1824
- hxPut,
1825
- hxDelete,
1826
- hxTarget,
1827
- hxSwap,
1828
- hxPushUrl,
1829
- hxIndicator,
1830
- hxConfirm,
1831
- hxHeaders,
1832
- ...rest
1833
- } = props;
1834
- const baseClasses = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
1835
- const variantClasses = {
1836
- primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
1837
- secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500",
1838
- danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
1839
- ghost: "bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500"
1840
- };
1841
- const sizeClasses = {
1842
- sm: "px-3 py-1.5 text-sm",
1843
- md: "px-4 py-2 text-sm",
1844
- lg: "px-6 py-3 text-base"
1845
- };
1846
- const classes = [
1847
- baseClasses,
1848
- variantClasses[variant],
1849
- sizeClasses[size],
1850
- disabled ? "opacity-50 cursor-not-allowed" : "",
1851
- className
1852
- ].filter(Boolean).join(" ");
1853
- const isNewWindow = rest.target === "_blank";
1854
- const htmxAttrs = {};
1855
- if (!isNewWindow) {
1856
- if (hxGet) htmxAttrs["hx-get"] = hxGet;
1857
- if (hxPost) htmxAttrs["hx-post"] = hxPost;
1858
- if (hxPut) htmxAttrs["hx-put"] = hxPut;
1859
- if (hxDelete) htmxAttrs["hx-delete"] = hxDelete;
1860
- if (hxTarget) htmxAttrs["hx-target"] = hxTarget;
1861
- if (hxSwap) htmxAttrs["hx-swap"] = hxSwap;
1862
- if (hxPushUrl !== void 0)
1863
- htmxAttrs["hx-push-url"] = hxPushUrl === true ? "true" : hxPushUrl;
1864
- if (hxIndicator) htmxAttrs["hx-indicator"] = hxIndicator;
1865
- if (hxConfirm) htmxAttrs["hx-confirm"] = hxConfirm;
1866
- if (hxHeaders) htmxAttrs["hx-headers"] = hxHeaders;
1867
- }
1868
- const href = rest.href ?? hxGet ?? "#";
1869
- const { className: _, ...otherRest } = rest;
1870
- return /* @__PURE__ */ jsx(
1871
- "a",
1872
- {
1873
- className: classes,
1874
- disabled,
1875
- href,
1876
- ...htmxAttrs,
1877
- ...otherRest,
1878
- children
1879
- }
1880
- );
1881
- }
2282
+
2283
+ // src/components/table.tsx
2284
+ init_button();
1882
2285
  function EmptyState(props) {
1883
2286
  const { message = "\u6682\u65E0\u6570\u636E", children } = props;
1884
2287
  return /* @__PURE__ */ jsx("div", { className: "text-center py-12", children: children || /* @__PURE__ */ jsx("p", { className: "text-gray-500 text-sm", children: message }) });
1885
2288
  }
2289
+
2290
+ // src/components/pagination.tsx
2291
+ init_button();
1886
2292
  function Pagination(props) {
1887
2293
  const {
1888
2294
  page,
@@ -1925,8 +2331,8 @@ function Pagination(props) {
1925
2331
  {
1926
2332
  variant: "secondary",
1927
2333
  size: "sm",
1928
- hxGet: buildUrl(page - 1),
1929
2334
  href: buildUrl(page - 1),
2335
+ "hx-get": buildUrl(page - 1),
1930
2336
  "data-testid": "pagination-prev",
1931
2337
  "aria-label": "\u4E0A\u4E00\u9875",
1932
2338
  children: "\u4E0A\u4E00\u9875"
@@ -1937,8 +2343,8 @@ function Pagination(props) {
1937
2343
  {
1938
2344
  variant: "secondary",
1939
2345
  size: "sm",
1940
- hxGet: buildUrl(page + 1),
1941
2346
  href: buildUrl(page + 1),
2347
+ "hx-get": buildUrl(page + 1),
1942
2348
  "data-testid": "pagination-next",
1943
2349
  "aria-label": "\u4E0B\u4E00\u9875",
1944
2350
  children: "\u4E0B\u4E00\u9875"
@@ -1996,10 +2402,10 @@ function TableHeader(props) {
1996
2402
  {
1997
2403
  variant: action.variant || "secondary",
1998
2404
  href: action.href,
1999
- hxGet: action.hxGet,
2000
- hxPost: action.hxPost,
2001
- hxDelete: action.hxDelete,
2002
- hxConfirm: action.hxConfirm,
2405
+ "hx-get": action.hxGet,
2406
+ "hx-post": action.hxPost,
2407
+ "hx-delete": action.hxDelete,
2408
+ "hx-confirm": action.hxConfirm,
2003
2409
  "data-testid": testId,
2004
2410
  children: action.label
2005
2411
  },
@@ -2396,7 +2802,288 @@ var DefaultListFeature = class extends BaseFeature {
2396
2802
  });
2397
2803
  }
2398
2804
  }
2399
- return actions;
2805
+ const { renderActionButtons: renderActionButtons2 } = await Promise.resolve().then(() => (init_action_button_renderer(), action_button_renderer_exports));
2806
+ return renderActionButtons2(actions);
2807
+ }
2808
+ };
2809
+
2810
+ // src/component-system/store.ts
2811
+ var STATE_EXPIRATION_TIME = 864e5;
2812
+ var StateStore = class _StateStore {
2813
+ static instance;
2814
+ state;
2815
+ constructor() {
2816
+ this.state = /* @__PURE__ */ new Map();
2817
+ setInterval(() => {
2818
+ this.state.forEach((state) => {
2819
+ if (state.lastUpdated < Date.now() - STATE_EXPIRATION_TIME) {
2820
+ this.state.delete(state.instanceId);
2821
+ }
2822
+ });
2823
+ }, 3e4);
2824
+ }
2825
+ static get() {
2826
+ if (!_StateStore.instance) {
2827
+ _StateStore.instance = new _StateStore();
2828
+ }
2829
+ return _StateStore.instance;
2830
+ }
2831
+ /** 获取实例状态 */
2832
+ getState(instanceId) {
2833
+ const state = this.state.get(instanceId);
2834
+ if (state) {
2835
+ return state;
2836
+ }
2837
+ const newState = {
2838
+ instanceId,
2839
+ data: {
2840
+ props: {},
2841
+ state: {}
2842
+ },
2843
+ lastUpdated: Date.now()
2844
+ };
2845
+ this.state.set(instanceId, newState);
2846
+ return newState;
2847
+ }
2848
+ /** 设置实例状态 */
2849
+ setState(instanceId, state) {
2850
+ const instanceState = this.getState(instanceId);
2851
+ instanceState.data = Object.assign(instanceState.data.state, state);
2852
+ instanceState.lastUpdated = Date.now();
2853
+ }
2854
+ };
2855
+
2856
+ // src/component-system/utils.ts
2857
+ var globalIdCounter = 0;
2858
+ function generateUniqueId() {
2859
+ return `htmx-cid-${globalIdCounter++}`;
2860
+ }
2861
+ var HTMX_COMPONENT_PREFIX = "/_htmx_components";
2862
+
2863
+ // src/component-system/context.tsx
2864
+ var RenderContext = class {
2865
+ constructor(prefix, instanceId, componentName) {
2866
+ this.prefix = prefix;
2867
+ this.instanceId = instanceId;
2868
+ this.componentName = componentName;
2869
+ }
2870
+ // 生成唯一 ID(使用全局共享计数器,避免冲突)
2871
+ $id() {
2872
+ return generateUniqueId();
2873
+ }
2874
+ setState(state) {
2875
+ StateStore.get().setState(this.instanceId, state);
2876
+ }
2877
+ get state() {
2878
+ return StateStore.get().getState(this.instanceId).data.state;
2879
+ }
2880
+ get props() {
2881
+ return StateStore.get().getState(this.instanceId).data.props;
2882
+ }
2883
+ // 生成方法 URL(使用当前 instanceId)
2884
+ url(methodName, params) {
2885
+ const baseUrl = `${this.prefix}/${HTMX_COMPONENT_PREFIX}/${this.componentName}/${this.instanceId}/${methodName}`;
2886
+ if (params && Object.keys(params).length > 0) {
2887
+ const queryString = new URLSearchParams(
2888
+ Object.entries(params).reduce(
2889
+ (acc, [key, value]) => {
2890
+ acc[key] = String(value);
2891
+ return acc;
2892
+ },
2893
+ {}
2894
+ )
2895
+ ).toString();
2896
+ return `${baseUrl}?${queryString}`;
2897
+ }
2898
+ return baseUrl;
2899
+ }
2900
+ callMethod(methodName, params) {
2901
+ const selectors = Object.entries(params).map(([name, expression]) => `${name}:${expression}`).join(",");
2902
+ return {
2903
+ "hx-post": this.url(methodName),
2904
+ "hx-vals": `js:{_params_:{${selectors}}}`,
2905
+ "hx-params": "_state_,_params_,_this_value_"
2906
+ };
2907
+ }
2908
+ };
2909
+ var ComponentContext = class extends RenderContext {
2910
+ constructor(prefix, ctx, componentName) {
2911
+ const routeParams = ctx.req.param();
2912
+ const instanceId = String(routeParams.instanceId || "");
2913
+ super(prefix, instanceId, componentName);
2914
+ this.ctx = ctx;
2915
+ }
2916
+ // 获取所有参数(统一接口:聚合 query string 和 body)
2917
+ async params() {
2918
+ const params = {};
2919
+ const routeParams = this.ctx.req.param();
2920
+ Object.assign(params, routeParams);
2921
+ const url = new URL(this.ctx.req.url);
2922
+ for (const [key, value] of url.searchParams.entries()) {
2923
+ params[key] = value;
2924
+ }
2925
+ const contentType = this.ctx.req.header("Content-Type") || "";
2926
+ if (contentType.includes("application/json")) {
2927
+ try {
2928
+ const body = await this.ctx.req.json();
2929
+ Object.assign(params, body);
2930
+ } catch (e) {
2931
+ console.warn("[ComponentContext] Failed to parse JSON body:", e);
2932
+ }
2933
+ } else if (this.ctx.req.method === "POST" || this.ctx.req.method === "PUT" || this.ctx.req.method === "PATCH" || this.ctx.req.method === "DELETE") {
2934
+ try {
2935
+ const formData = await this.ctx.req.formData();
2936
+ for (const [key, value] of formData.entries()) {
2937
+ params[key] = value instanceof File ? value : value.toString();
2938
+ }
2939
+ } catch (e) {
2940
+ console.warn("[ComponentContext] Failed to parse form data:", e);
2941
+ }
2942
+ }
2943
+ return params;
2944
+ }
2945
+ // 获取查询参数
2946
+ query() {
2947
+ const url = new URL(this.ctx.req.url);
2948
+ return Object.fromEntries(url.searchParams.entries());
2949
+ }
2950
+ // 获取请求体
2951
+ async body() {
2952
+ const contentType = this.ctx.req.header("Content-Type") || "";
2953
+ if (contentType.includes("application/json")) {
2954
+ try {
2955
+ return await this.ctx.req.json();
2956
+ } catch (e) {
2957
+ console.warn("[ComponentContext] Failed to parse JSON body:", e);
2958
+ return {};
2959
+ }
2960
+ }
2961
+ try {
2962
+ const formData = await this.ctx.req.formData();
2963
+ const body = {};
2964
+ for (const [key, value] of formData.entries()) {
2965
+ body[key] = value instanceof File ? value : value.toString();
2966
+ }
2967
+ return body;
2968
+ } catch (e) {
2969
+ console.warn("[ComponentContext] Failed to parse form data:", e);
2970
+ return {};
2971
+ }
2972
+ }
2973
+ };
2974
+
2975
+ // src/component-system/component.tsx
2976
+ var METHOD_METADATA_KEY = /* @__PURE__ */ Symbol("htmx:method");
2977
+ function Method(config) {
2978
+ return function(target, propertyKey, _descriptor) {
2979
+ if (!target[METHOD_METADATA_KEY]) {
2980
+ target[METHOD_METADATA_KEY] = /* @__PURE__ */ new Map();
2981
+ }
2982
+ target[METHOD_METADATA_KEY].set(propertyKey, {
2983
+ method: config?.method || "get",
2984
+ path: config?.path
2985
+ });
2986
+ };
2987
+ }
2988
+ var HtmxComponent = class {
2989
+ constructor(name) {
2990
+ this.name = name;
2991
+ }
2992
+ prefix;
2993
+ // 组件函数
2994
+ Component = (props) => {
2995
+ const instanceId = generateUniqueId();
2996
+ const state = StateStore.get().getState(instanceId).data;
2997
+ state.props = props;
2998
+ state.state = {};
2999
+ const renderCtx = new RenderContext(
3000
+ this.prefix,
3001
+ instanceId,
3002
+ this.name
3003
+ );
3004
+ return this.render(renderCtx, props);
3005
+ };
3006
+ // 返回 JSX script 元素
3007
+ // 获取所有标记为 @Method() 的方法
3008
+ // 注意:handler 不再绑定 this,因为方法会接收 ComponentContext 作为第一个参数
3009
+ static getMethods(component) {
3010
+ const methods = /* @__PURE__ */ new Map();
3011
+ const metadata = component[METHOD_METADATA_KEY];
3012
+ if (!metadata) return methods;
3013
+ for (const [methodName, config] of metadata.entries()) {
3014
+ const handler = component[methodName];
3015
+ methods.set(methodName, {
3016
+ method: config.method,
3017
+ path: config.path,
3018
+ handler
3019
+ });
3020
+ }
3021
+ return methods;
3022
+ }
3023
+ };
3024
+ var HtmxComponentHandler = class {
3025
+ constructor(hono, prefix, components) {
3026
+ this.hono = hono;
3027
+ this.prefix = prefix;
3028
+ this.components = components;
3029
+ for (const component of this.components) {
3030
+ component.prefix = this.prefix;
3031
+ this.registerHandler(component);
3032
+ }
3033
+ }
3034
+ registerHandler(component) {
3035
+ const methods = HtmxComponent.getMethods(component);
3036
+ for (const [methodName, methodConfig] of methods) {
3037
+ const routePath = `${this.prefix}/${HTMX_COMPONENT_PREFIX}/${component.name}/:instanceId/${methodName}`;
3038
+ logger.info(
3039
+ `[HtmxComponent] Registering handler ${methodConfig.method} ${routePath}`
3040
+ );
3041
+ this.hono[methodConfig.method](routePath, async (ctx) => {
3042
+ return this.handleComponentMethod(
3043
+ ctx,
3044
+ component,
3045
+ methodConfig.handler.bind(component)
3046
+ );
3047
+ });
3048
+ }
3049
+ }
3050
+ async handleComponentMethod(ctx, component, handler) {
3051
+ const componentContext = new ComponentContext(
3052
+ this.prefix,
3053
+ ctx,
3054
+ component.name
3055
+ );
3056
+ const result = await handler(componentContext);
3057
+ if (result instanceof Object && ("target" in result || "swap" in result || "body" in result || "oobs" in result)) {
3058
+ const { target, swap, body, oobs, trigger } = result;
3059
+ const headers = {};
3060
+ let bodyContent = body;
3061
+ if (target) headers["HX-Retarget"] = target;
3062
+ if (swap) headers["HX-Reswap"] = swap;
3063
+ if (trigger) headers["HX-Trigger"] = trigger;
3064
+ if (oobs) {
3065
+ oobs.forEach((oob) => {
3066
+ oob.props["hx-swap-oob"] = "true";
3067
+ });
3068
+ if (!body) {
3069
+ headers["HX-Reswap"] = "delete";
3070
+ }
3071
+ bodyContent = /* @__PURE__ */ jsxs(Fragment, { children: [
3072
+ body,
3073
+ oobs
3074
+ ] });
3075
+ }
3076
+ return ctx.html(bodyContent, 200, headers);
3077
+ }
3078
+ if (result === null) {
3079
+ return ctx.html(/* @__PURE__ */ jsx("div", {}), 200, {
3080
+ "HX-Reswap": "none"
3081
+ });
3082
+ }
3083
+ if (result instanceof Response) {
3084
+ return result;
3085
+ }
3086
+ return ctx.html(result, 200);
2400
3087
  }
2401
3088
  };
2402
3089
 
@@ -2696,131 +3383,35 @@ async function createFeatureContext(ctx, page, feature, user, options) {
2696
3383
  };
2697
3384
  return featureContext;
2698
3385
  }
2699
- var CLOSE_DIALOG_SCRIPT = `on click
2700
- add .dialog-exit to .dialog-backdrop
2701
- add .dialog-content-exit to .dialog-content
2702
- wait 200ms
2703
- set #dialog-container's innerHTML to '' end`;
2704
- function renderActionButton(action, index) {
3386
+ function Dialog(props) {
2705
3387
  const {
2706
- label,
2707
- href,
2708
- hxGet,
2709
- hxPost,
2710
- hxPut,
2711
- hxDelete,
2712
- variant = "primary",
2713
- confirm,
2714
- close,
2715
- submit,
2716
- formId,
2717
- onClick,
2718
- className = ""
2719
- } = action;
2720
- if (submit && formId) {
2721
- const variantStyles = {
2722
- primary: "bg-blue-600 text-white hover:bg-blue-700",
2723
- secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
2724
- danger: "bg-red-600 text-white hover:bg-red-700",
2725
- ghost: "bg-transparent text-gray-700 hover:bg-gray-100"
2726
- };
2727
- const buttonStyle = variantStyles[variant] || variantStyles.primary;
2728
- const testId2 = label === "\u521B\u5EFA" || label === "\u66F4\u65B0" ? "submit-button" : `action-${label}`;
2729
- return /* @__PURE__ */ jsx(
2730
- "button",
2731
- {
2732
- type: "submit",
2733
- form: formId,
2734
- className: `px-4 py-2 rounded transition-colors font-medium ${buttonStyle} ${className}`,
2735
- "data-testid": testId2,
2736
- ...confirm && { "data-confirm": confirm },
2737
- children: label
2738
- },
2739
- index
2740
- );
2741
- }
2742
- const finalOnClick = close ? CLOSE_DIALOG_SCRIPT : onClick;
2743
- if (finalOnClick) {
2744
- const variantStyles = {
2745
- primary: "bg-blue-600 text-white hover:bg-blue-700",
2746
- secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
2747
- danger: "bg-red-600 text-white hover:bg-red-700",
2748
- ghost: "bg-transparent text-gray-700 hover:bg-gray-100"
2749
- };
2750
- const buttonStyle = variantStyles[variant] || variantStyles.secondary;
2751
- let testId2 = `action-${label}`;
2752
- if (label === "\u53D6\u6D88") {
2753
- testId2 = "cancel-button";
2754
- } else if (label === "\u5173\u95ED") {
2755
- testId2 = "close-button";
2756
- }
2757
- return /* @__PURE__ */ jsx(
2758
- "button",
2759
- {
2760
- type: "button",
2761
- _: finalOnClick,
2762
- className: `px-4 py-2 rounded transition-colors font-medium ${buttonStyle} ${className}`,
2763
- "data-testid": testId2,
2764
- ...confirm && { "data-confirm": confirm },
2765
- children: label
2766
- },
2767
- index
2768
- );
2769
- }
2770
- let testId = `action-${label}`;
2771
- if (label === "\u65B0\u5EFA" || label === "\u521B\u5EFA") {
2772
- testId = "create-button";
2773
- } else if (label === "\u53D6\u6D88") {
2774
- testId = "cancel-button";
2775
- } else if (label === "\u5173\u95ED") {
2776
- testId = "close-button";
2777
- }
2778
- return /* @__PURE__ */ jsx(
2779
- Button,
2780
- {
2781
- variant,
2782
- href,
2783
- hxGet,
2784
- hxPost,
2785
- hxPut,
2786
- hxDelete,
2787
- hxConfirm: confirm,
2788
- className,
2789
- "data-testid": testId,
2790
- children: label
2791
- },
2792
- index
2793
- );
2794
- }
2795
- function Dialog(props) {
2796
- const {
2797
- title,
2798
- children,
2799
- showClose = true,
2800
- className = "",
2801
- size = "lg",
2802
- closeOnBackdropClick = true,
2803
- actions = [],
2804
- fixedContentHeight = false
2805
- } = props;
2806
- const sizeClasses = {
2807
- sm: "max-w-md",
2808
- md: "max-w-lg",
2809
- lg: "max-w-2xl",
2810
- xl: "max-w-4xl",
2811
- full: "max-w-7xl"
2812
- };
2813
- const backdropClickHandler = closeOnBackdropClick ? `on click if event.target is me
2814
- add .dialog-exit to me
2815
- add .dialog-content-exit to .dialog-content
2816
- wait 200ms
2817
- set #dialog-container's innerHTML to '' end` : "";
2818
- return /* @__PURE__ */ jsx(
2819
- "div",
2820
- {
2821
- className: "fixed inset-0 bg-black bg-opacity-50 z-[100] flex items-center justify-center p-4 dialog-backdrop",
2822
- style: {
2823
- animation: "fadeIn 0.2s ease-out"
3388
+ title,
3389
+ children,
3390
+ showClose = true,
3391
+ className = "",
3392
+ size = "lg",
3393
+ closeOnBackdropClick = true,
3394
+ actions = [],
3395
+ fixedContentHeight = false
3396
+ } = props;
3397
+ const sizeClasses = {
3398
+ sm: "max-w-md",
3399
+ md: "max-w-lg",
3400
+ lg: "max-w-2xl",
3401
+ xl: "max-w-4xl",
3402
+ full: "max-w-7xl"
3403
+ };
3404
+ const backdropClickHandler = closeOnBackdropClick ? `on click if event.target is me
3405
+ add .dialog-exit to me
3406
+ add .dialog-content-exit to .dialog-content
3407
+ wait 200ms
3408
+ set #dialog-container's innerHTML to '' end` : "";
3409
+ return /* @__PURE__ */ jsx(
3410
+ "div",
3411
+ {
3412
+ className: "fixed inset-0 bg-black bg-opacity-50 z-[100] flex items-center justify-center p-4 dialog-backdrop",
3413
+ style: {
3414
+ animation: "fadeIn 0.2s ease-out"
2824
3415
  },
2825
3416
  _: backdropClickHandler,
2826
3417
  children: /* @__PURE__ */ jsxs(
@@ -2867,13 +3458,16 @@ function Dialog(props) {
2867
3458
  children
2868
3459
  }
2869
3460
  ),
2870
- actions.length > 0 && /* @__PURE__ */ jsx("div", { className: "px-6 py-4 border-t border-gray-200 bg-white flex justify-end gap-2", children: actions.map((action, index) => renderActionButton(action, index)) })
3461
+ actions && /* @__PURE__ */ jsx("div", { className: "px-6 py-4 border-t border-gray-200 bg-white flex justify-end gap-2", children: actions })
2871
3462
  ]
2872
3463
  }
2873
3464
  )
2874
3465
  }
2875
3466
  );
2876
3467
  }
3468
+
3469
+ // src/components/permission-denied.tsx
3470
+ init_button();
2877
3471
  function PermissionDeniedContent(props) {
2878
3472
  const {
2879
3473
  operationId,
@@ -2951,6 +3545,178 @@ function PermissionDeniedPage(props) {
2951
3545
  }
2952
3546
  ) }) });
2953
3547
  }
3548
+ var getResourceUrl = (prefix, name) => {
3549
+ return `${prefix}/_cdn/${name}`;
3550
+ };
3551
+ function globalScripts(prefix) {
3552
+ return html`
3553
+ <script src=${getResourceUrl(prefix, "htmx")}></script>
3554
+ <script src=${getResourceUrl(prefix, "htmx-ext-form-json")}></script>
3555
+ <script src=${getResourceUrl(prefix, "hyperscript")}></script>
3556
+ <script src=${getResourceUrl(prefix, "tailwindcss")}></script>
3557
+ <script src=${getResourceUrl(prefix, "alpinejs")} defer></script>
3558
+ <script src=${getResourceUrl(prefix, "sortablejs")}></script>
3559
+ <script src=${getResourceUrl(prefix, "idiomorph")}></script>
3560
+ <script type="module" src=${getResourceUrl(prefix, "datastar")}></script>
3561
+ `;
3562
+ }
3563
+ function globalStyles() {
3564
+ return html`<style>
3565
+ @keyframes fadeIn {
3566
+ from {
3567
+ opacity: 0;
3568
+ }
3569
+ to {
3570
+ opacity: 1;
3571
+ }
3572
+ }
3573
+
3574
+ @keyframes slideIn {
3575
+ from {
3576
+ opacity: 0;
3577
+ transform: scale(0.95) translateY(-10px);
3578
+ }
3579
+ to {
3580
+ opacity: 1;
3581
+ transform: scale(1) translateY(0);
3582
+ }
3583
+ }
3584
+
3585
+ @keyframes slideInRight {
3586
+ from {
3587
+ opacity: 0;
3588
+ transform: translateX(100%);
3589
+ }
3590
+ to {
3591
+ opacity: 1;
3592
+ transform: translateX(0);
3593
+ }
3594
+ }
3595
+
3596
+ @keyframes slideOutRight {
3597
+ from {
3598
+ opacity: 1;
3599
+ transform: translateX(0);
3600
+ }
3601
+ to {
3602
+ opacity: 0;
3603
+ transform: translateX(100%);
3604
+ }
3605
+ }
3606
+
3607
+ @keyframes fadeOut {
3608
+ from {
3609
+ opacity: 1;
3610
+ }
3611
+ to {
3612
+ opacity: 0;
3613
+ }
3614
+ }
3615
+
3616
+ @keyframes scaleOut {
3617
+ from {
3618
+ opacity: 1;
3619
+ transform: scale(1) translateY(0);
3620
+ }
3621
+ to {
3622
+ opacity: 0;
3623
+ transform: scale(0.95) translateY(-10px);
3624
+ }
3625
+ }
3626
+
3627
+ /* Dialog 退出动画 */
3628
+ .dialog-exit {
3629
+ animation: fadeOut 0.2s ease-in forwards !important;
3630
+ }
3631
+
3632
+ .dialog-content-exit {
3633
+ animation: scaleOut 0.2s ease-in forwards !important;
3634
+ }
3635
+
3636
+ .error-alert-exit {
3637
+ animation:
3638
+ slideOutRight 0.3s ease-in forwards,
3639
+ fadeOut 0.3s ease-in forwards;
3640
+ }
3641
+
3642
+ /* SortableList 拖拽样式 */
3643
+ /* 使用伪元素创建覆盖层,不影响原始布局 */
3644
+ .sortable-dragging {
3645
+ position: relative !important;
3646
+ opacity: 0.6 !important;
3647
+ transform: scale(0.98) !important;
3648
+ transition: all 0.2s ease-in-out !important;
3649
+ outline: 2px solid rgb(96, 165, 250) !important; /* blue-400 */
3650
+ outline-offset: 2px !important;
3651
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
3652
+ }
3653
+
3654
+ .sortable-dragging::before {
3655
+ content: '' !important;
3656
+ position: absolute !important;
3657
+ top: 0 !important;
3658
+ left: 0 !important;
3659
+ right: 0 !important;
3660
+ bottom: 0 !important;
3661
+ background-color: rgba(59, 130, 246, 0.1) !important; /* blue-500 with opacity */
3662
+ border-radius: 0.375rem !important;
3663
+ z-index: 1 !important;
3664
+ pointer-events: none !important;
3665
+ }
3666
+
3667
+ /* 拖拽悬停样式 */
3668
+ .sortable-drag-over {
3669
+ position: relative !important;
3670
+ transition: all 0.2s ease-in-out !important;
3671
+ outline: 2px solid rgb(59, 130, 246) !important; /* blue-500 */
3672
+ outline-offset: 2px !important;
3673
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
3674
+ }
3675
+
3676
+ .sortable-drag-over::before {
3677
+ content: '' !important;
3678
+ position: absolute !important;
3679
+ top: 0 !important;
3680
+ left: 0 !important;
3681
+ right: 0 !important;
3682
+ bottom: 0 !important;
3683
+ background-color: rgba(59, 130, 246, 0.15) !important; /* blue-500 with more opacity */
3684
+ border-radius: 0.375rem !important;
3685
+ z-index: 1 !important;
3686
+ pointer-events: none !important;
3687
+ }
3688
+ </style>`;
3689
+ }
3690
+ function sortableScript() {
3691
+ html`<script>
3692
+ if (typeof htmx !== "undefined" && typeof Sortable !== "undefined") {
3693
+ htmx.onLoad(function (content) {
3694
+ var sortables = content.querySelectorAll(".sortable");
3695
+ for (var i = 0; i < sortables.length; i++) {
3696
+ var sortable = sortables[i];
3697
+ // 检查是否已经初始化
3698
+ if (sortable.sortableInstance) {
3699
+ continue;
3700
+ }
3701
+ var sortableInstance = new Sortable(sortable, {
3702
+ animation: 150,
3703
+ ghostClass: "sortable-ghost",
3704
+ filter: ".htmx-indicator",
3705
+ onMove: function (evt) {
3706
+ return evt.related.className.indexOf("htmx-indicator") === -1;
3707
+ },
3708
+ });
3709
+ sortable.sortableInstance = sortableInstance;
3710
+ sortable.addEventListener("htmx:afterSwap", function () {
3711
+ if (sortable.sortableInstance) {
3712
+ sortable.sortableInstance.option("disabled", false);
3713
+ }
3714
+ });
3715
+ }
3716
+ });
3717
+ }
3718
+ </script>`;
3719
+ }
2954
3720
  function Breadcrumb(props) {
2955
3721
  const { items } = props;
2956
3722
  if (items.length === 0) {
@@ -3078,316 +3844,1061 @@ function LoadingBar() {
3078
3844
  ` })
3079
3845
  ] });
3080
3846
  }
3081
- function BaseLayout(props) {
3082
- const { cdnProxyPrefix = "/admin/_cdn" } = props;
3083
- const getResourceUrl = (name, cdnUrl) => {
3084
- return `${cdnProxyPrefix}/${name}`;
3085
- };
3086
- const htmxUrl = "https://unpkg.com/htmx.org@latest";
3087
- const hyperscriptUrl = "https://unpkg.com/hyperscript.org@latest";
3088
- const tailwindUrl = "https://cdn.tailwindcss.com";
3089
- const alpinejsUrl = "https://unpkg.com/alpinejs@latest/dist/cdn.min.js";
3090
- return /* @__PURE__ */ jsxs("html", { children: [
3091
- /* @__PURE__ */ jsxs("head", { children: [
3092
- /* @__PURE__ */ jsx("meta", { charSet: "UTF-8" }),
3093
- /* @__PURE__ */ jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0" }),
3094
- /* @__PURE__ */ jsx("title", { children: props.title }),
3095
- props.description && /* @__PURE__ */ jsx("meta", { name: "description", content: props.description }),
3096
- /* @__PURE__ */ jsx(
3097
- "script",
3098
- {
3099
- src: getResourceUrl("htmx"),
3100
- onerror: `this.onerror=null;this.src='${htmxUrl}'`
3101
- }
3102
- ),
3103
- /* @__PURE__ */ jsx(
3104
- "script",
3105
- {
3106
- src: getResourceUrl("hyperscript"),
3107
- onerror: `this.onerror=null;this.src='${hyperscriptUrl}'`
3847
+
3848
+ // src/components/index.ts
3849
+ init_button();
3850
+ function SortableList(props) {
3851
+ const {
3852
+ children,
3853
+ className = "",
3854
+ handle,
3855
+ draggingClass = "sortable-dragging",
3856
+ dragOverClass = "sortable-drag-over",
3857
+ onSortableChange,
3858
+ ...rest
3859
+ } = props;
3860
+ const xDataContent = `{
3861
+ draggedIndex: null,
3862
+ dragOverIndex: null,
3863
+ handleSelector: ${JSON.stringify(handle || null)},
3864
+ draggingClass: ${JSON.stringify(draggingClass)},
3865
+ dragOverClass: ${JSON.stringify(dragOverClass)},
3866
+ handleDragStart(event) {
3867
+ // \u4E8B\u4EF6\u59D4\u6258\uFF1A\u5904\u7406\u6240\u6709\u5B50\u5143\u7D20\u7684\u62D6\u62FD\u5F00\u59CB\u4E8B\u4EF6
3868
+ var target = event.target;
3869
+ // \u5982\u679C\u70B9\u51FB\u7684\u662F\u62D6\u62FD\u624B\u67C4\uFF0C\u9700\u8981\u627E\u5230\u7236\u5143\u7D20
3870
+ var item = target.closest('[data-sortable-index]');
3871
+ // \u5982\u679C\u6CA1\u627E\u5230\uFF0C\u53EF\u80FD\u662F\u70B9\u51FB\u4E86\u624B\u67C4\uFF0C\u9700\u8981\u5411\u4E0A\u67E5\u627E
3872
+ if (!item && target.hasAttribute('data-sortable-handle')) {
3873
+ item = target.closest('[data-sortable-index]') || target.parentElement;
3874
+ }
3875
+ if (item && item.hasAttribute('data-sortable-index')) {
3876
+ var index = parseInt(item.getAttribute('data-sortable-index'));
3877
+ this.draggedIndex = index;
3878
+ event.dataTransfer.effectAllowed = 'move';
3879
+ event.dataTransfer.setData('text/html', '');
3880
+ // \u6DFB\u52A0\u62D6\u62FD\u6837\u5F0F\u7C7B\uFF08\u652F\u6301\u591A\u4E2A\u7C7B\u540D\uFF09
3881
+ if (this.draggingClass) {
3882
+ var classes = this.draggingClass.split(' ').filter(function(c) { return c.trim(); });
3883
+ classes.forEach(function(cls) {
3884
+ if (cls) {
3885
+ item.classList.add(cls);
3886
+ console.log('Added dragging class:', cls, 'to element:', item);
3887
+ }
3888
+ });
3108
3889
  }
3109
- ),
3110
- /* @__PURE__ */ jsx(
3111
- "script",
3112
- {
3113
- src: getResourceUrl("tailwindcss"),
3114
- onerror: `this.onerror=null;this.src='${tailwindUrl}'`
3890
+ }
3891
+ },
3892
+ handleDragEnd(event) {
3893
+ // \u4E8B\u4EF6\u59D4\u6258\uFF1A\u5904\u7406\u6240\u6709\u5B50\u5143\u7D20\u7684\u62D6\u62FD\u7ED3\u675F\u4E8B\u4EF6
3894
+ var target = event.target;
3895
+ var item = target.closest('[data-sortable-index]');
3896
+ if (item) {
3897
+ // \u79FB\u9664\u62D6\u62FD\u6837\u5F0F\u7C7B\uFF08\u652F\u6301\u591A\u4E2A\u7C7B\u540D\uFF09
3898
+ if (this.draggingClass) {
3899
+ var draggingClasses = this.draggingClass.split(' ').filter(function(c) { return c.trim(); });
3900
+ draggingClasses.forEach(function(cls) {
3901
+ item.classList.remove(cls);
3902
+ });
3115
3903
  }
3116
- ),
3117
- /* @__PURE__ */ jsx(
3118
- "script",
3119
- {
3120
- src: getResourceUrl("alpinejs"),
3121
- defer: true,
3122
- onerror: `this.onerror=null;this.src='${alpinejsUrl}'`
3904
+ this.draggedIndex = null;
3905
+ this.dragOverIndex = null;
3906
+ // \u6E05\u9664\u6240\u6709\u62D6\u62FD\u60AC\u505C\u6837\u5F0F
3907
+ if (this.dragOverClass) {
3908
+ var dragOverClasses = this.dragOverClass.split(' ').filter(function(c) { return c.trim(); });
3909
+ Array.from($el.children).forEach(function(c) {
3910
+ dragOverClasses.forEach(function(cls) {
3911
+ c.classList.remove(cls);
3912
+ });
3913
+ });
3123
3914
  }
3124
- ),
3125
- /* @__PURE__ */ jsx(
3126
- "style",
3127
- {
3128
- dangerouslySetInnerHTML: {
3129
- __html: `
3130
- /* \u5BB9\u5668\u67E5\u8BE2\u652F\u6301 - \u5982\u679C Tailwind CDN \u4E0D\u652F\u6301\uFF0C\u4F7F\u7528\u539F\u751F CSS \u5BB9\u5668\u67E5\u8BE2 */
3131
- @supports (container-type: inline-size) {
3132
- .\\@container {
3133
- container-type: inline-size;
3915
+ }
3916
+ },
3917
+ handleDragOver(event) {
3918
+ // \u4E8B\u4EF6\u59D4\u6258\uFF1A\u5904\u7406\u6240\u6709\u5B50\u5143\u7D20\u7684\u62D6\u62FD\u60AC\u505C\u4E8B\u4EF6
3919
+ var target = event.target;
3920
+ var item = target.closest('[data-sortable-index]');
3921
+ if (item && this.draggedIndex !== null) {
3922
+ var index = parseInt(item.getAttribute('data-sortable-index'));
3923
+ if (this.draggedIndex !== index) {
3924
+ event.preventDefault();
3925
+ event.dataTransfer.dropEffect = 'move';
3926
+ // \u6E05\u9664\u4E4B\u524D\u60AC\u505C\u5143\u7D20\u7684\u6837\u5F0F
3927
+ if (this.dragOverIndex !== null && this.dragOverIndex !== index && this.dragOverClass) {
3928
+ var self = this;
3929
+ var prevItem = Array.from($el.children).find(function(c) {
3930
+ return c.getAttribute('data-sortable-index') === String(self.dragOverIndex);
3931
+ });
3932
+ if (prevItem) {
3933
+ var dragOverClasses = this.dragOverClass.split(' ').filter(function(c) { return c.trim(); });
3934
+ dragOverClasses.forEach(function(cls) {
3935
+ prevItem.classList.remove(cls);
3936
+ });
3134
3937
  }
3135
3938
  }
3136
-
3137
- @keyframes fadeIn {
3138
- from { opacity: 0;}
3139
- to { opacity: 1;}
3140
- }
3141
-
3142
- @keyframes slideIn {
3143
- from { opacity: 0; transform: scale(0.95) translateY(-10px); }
3144
- to { opacity: 1; transform: scale(1) translateY(0); }
3145
- }
3146
-
3147
- @keyframes slideInRight {
3148
- from { opacity: 0; transform: translateX(100%); }
3149
- to { opacity: 1; transform: translateX(0); }
3150
- }
3151
-
3152
- @keyframes slideOutRight {
3153
- from { opacity: 1; transform: translateX(0); }
3154
- to { opacity: 0; transform: translateX(100%); }
3155
- }
3156
-
3157
- @keyframes fadeOut {
3158
- from { opacity: 1; }
3159
- to { opacity: 0; }
3160
- }
3161
-
3162
- @keyframes scaleOut {
3163
- from { opacity: 1; transform: scale(1) translateY(0); }
3164
- to { opacity: 0; transform: scale(0.95) translateY(-10px); }
3939
+ this.dragOverIndex = index;
3940
+ // \u6DFB\u52A0\u62D6\u62FD\u60AC\u505C\u6837\u5F0F\u7C7B\uFF08\u652F\u6301\u591A\u4E2A\u7C7B\u540D\uFF09
3941
+ if (this.dragOverClass) {
3942
+ var dragOverClasses = this.dragOverClass.split(' ').filter(function(c) { return c.trim(); });
3943
+ dragOverClasses.forEach(function(cls) {
3944
+ item.classList.add(cls);
3945
+ });
3165
3946
  }
3166
-
3167
- /* Dialog \u9000\u51FA\u52A8\u753B */
3168
- .dialog-exit {
3169
- animation: fadeOut 0.2s ease-in forwards !important;
3947
+ }
3948
+ }
3949
+ },
3950
+ handleDragLeave(event) {
3951
+ // \u4E8B\u4EF6\u59D4\u6258\uFF1A\u5904\u7406\u6240\u6709\u5B50\u5143\u7D20\u7684\u62D6\u62FD\u79BB\u5F00\u4E8B\u4EF6
3952
+ // \u53EA\u6709\u5F53\u79BB\u5F00\u7684\u5143\u7D20\u662F\u5F53\u524D\u60AC\u505C\u7684\u5143\u7D20\u65F6\u624D\u79FB\u9664\u6837\u5F0F
3953
+ var target = event.target;
3954
+ var item = target.closest('[data-sortable-index]');
3955
+ if (item && this.dragOverIndex !== null) {
3956
+ var index = parseInt(item.getAttribute('data-sortable-index'));
3957
+ if (index === this.dragOverIndex) {
3958
+ // \u68C0\u67E5\u662F\u5426\u771F\u7684\u79BB\u5F00\u4E86\u5143\u7D20\uFF08\u800C\u4E0D\u662F\u8FDB\u5165\u5B50\u5143\u7D20\uFF09
3959
+ var relatedTarget = event.relatedTarget;
3960
+ if (!item.contains(relatedTarget)) {
3961
+ if (this.dragOverClass) {
3962
+ var dragOverClasses = this.dragOverClass.split(' ').filter(function(c) { return c.trim(); });
3963
+ dragOverClasses.forEach(function(cls) {
3964
+ item.classList.remove(cls);
3965
+ });
3966
+ }
3967
+ this.dragOverIndex = null;
3170
3968
  }
3171
-
3172
- .dialog-content-exit {
3173
- animation: scaleOut 0.2s ease-in forwards !important;
3969
+ }
3970
+ }
3971
+ },
3972
+ handleDrop(event) {
3973
+ // \u4E8B\u4EF6\u59D4\u6258\uFF1A\u5904\u7406\u6240\u6709\u5B50\u5143\u7D20\u7684\u653E\u7F6E\u4E8B\u4EF6
3974
+ var target = event.target;
3975
+ var item = target.closest('[data-sortable-index]');
3976
+ if (item && this.draggedIndex !== null) {
3977
+ event.preventDefault();
3978
+ var index = parseInt(item.getAttribute('data-sortable-index'));
3979
+ if (this.draggedIndex !== index) {
3980
+ // \u4E0D\u79FB\u52A8 DOM\uFF0C\u53EA\u53D1\u51FA\u4EA4\u6362\u4E8B\u4EF6\uFF0C\u8BA9\u7236\u7EA7\uFF08Alpine.js\uFF09\u5904\u7406\u6570\u7EC4\u4EA4\u6362
3981
+ // \u89E6\u53D1\u81EA\u5B9A\u4E49\u4E8B\u4EF6\uFF0C\u901A\u77E5\u7236\u7EC4\u4EF6\u9700\u8981\u4EA4\u6362\u4F4D\u7F6E
3982
+ var changeEvent = new CustomEvent('sortable:change', {
3983
+ bubbles: true,
3984
+ cancelable: true,
3985
+ detail: {
3986
+ oldIndex: this.draggedIndex,
3987
+ newIndex: index,
3988
+ }
3989
+ });
3990
+ $el.dispatchEvent(changeEvent);
3991
+ }
3992
+
3993
+ if (this.dragOverClass) {
3994
+ var dragOverClasses = this.dragOverClass.split(' ').filter(function(c) { return c.trim(); });
3995
+ dragOverClasses.forEach(function(cls) {
3996
+ item.classList.remove(cls);
3997
+ });
3998
+ }
3999
+ this.draggedIndex = null;
4000
+ this.dragOverIndex = null;
4001
+ }
4002
+ },
4003
+ init() {
4004
+ // \u4E3A\u6240\u6709\u76F4\u63A5\u5B50\u5143\u7D20\u6DFB\u52A0\u62D6\u62FD\u652F\u6301
4005
+ var container = $el;
4006
+ var self = this;
4007
+
4008
+ var initSortable = function() {
4009
+ // \u53EA\u5904\u7406\u5B9E\u9645\u6E32\u67D3\u7684\u5143\u7D20\uFF0C\u6392\u9664 template \u5143\u7D20
4010
+ // Alpine.js \u7684 x-for \u4F1A\u5728 template \u7684\u4F4D\u7F6E\u63D2\u5165\u5B9E\u9645\u6E32\u67D3\u7684\u5143\u7D20
4011
+ var children = Array.from(container.children).filter(function(c) {
4012
+ return c.tagName !== 'TEMPLATE';
4013
+ });
4014
+ children.forEach(function(child, index) {
4015
+ child.setAttribute('data-sortable-index', index);
4016
+ // \u6DFB\u52A0\u8FC7\u6E21\u52A8\u753B\uFF0C\u8BA9\u62D6\u653E\u6548\u679C\u66F4\u5E73\u6ED1
4017
+ if (!child.style.transition) {
4018
+ child.style.transition = 'all 0.2s ease-in-out';
3174
4019
  }
3175
4020
 
3176
- /* ErrorAlert \u9000\u51FA\u52A8\u753B */
3177
- .error-alert-exit {
3178
- animation: slideOutRight 0.3s ease-in forwards, fadeOut 0.3s ease-in forwards;
3179
- }
3180
- `
4021
+ // \u5982\u679C\u6307\u5B9A\u4E86\u62D6\u62FD\u624B\u67C4\uFF0C\u53EA\u6709\u624B\u67C4\u53EF\u62D6\u62FD
4022
+ if (self.handleSelector) {
4023
+ var handleElement = child.querySelector(self.handleSelector);
4024
+ if (handleElement) {
4025
+ child.setAttribute('draggable', 'false');
4026
+ handleElement.setAttribute('draggable', 'true');
4027
+ handleElement.setAttribute('data-sortable-handle', 'true');
4028
+ handleElement.style.cursor = 'move';
4029
+ }
4030
+ } else {
4031
+ // \u6574\u4E2A\u5143\u7D20\u53EF\u62D6\u62FD
4032
+ child.setAttribute('draggable', 'true');
4033
+ child.style.cursor = 'move';
3181
4034
  }
3182
- }
3183
- )
3184
- ] }),
3185
- /* @__PURE__ */ jsxs("body", { className: "bg-gray-50", "hx-indicator": "#loading-bar", children: [
3186
- /* @__PURE__ */ jsx(LoadingBar, {}),
3187
- props.children,
3188
- /* @__PURE__ */ jsx(
3189
- "div",
3190
- {
3191
- id: "error-container",
3192
- className: "fixed top-4 right-4 z-[200] w-full max-w-2xl px-4"
3193
- }
3194
- ),
3195
- /* @__PURE__ */ jsx("div", { id: "dialog-container" })
3196
- ] })
3197
- ] });
3198
- }
3199
- function renderNavItem(item, currentPath, index) {
3200
- const isActive = currentPath === item.href || currentPath && currentPath.startsWith(item.href + "/");
3201
- const hasActiveChild = item.children?.some(
3202
- (child) => currentPath === child.href || currentPath && currentPath.startsWith(child.href + "/")
4035
+ });
4036
+ };
4037
+
4038
+ // \u521D\u59CB\u8BBE\u7F6E
4039
+ initSortable();
4040
+
4041
+ // \u4F7F\u7528 MutationObserver \u76D1\u542C\u5B50\u5143\u7D20\u53D8\u5316
4042
+ var observer = new MutationObserver(function() {
4043
+ initSortable();
4044
+ });
4045
+
4046
+ observer.observe(container, {
4047
+ childList: true,
4048
+ subtree: false,
4049
+ });
4050
+ }
4051
+ }`;
4052
+ return /* @__PURE__ */ jsx(
4053
+ "div",
4054
+ {
4055
+ className,
4056
+ ...{
4057
+ "x-data": xDataContent
4058
+ },
4059
+ ...{
4060
+ "x-init": "init()",
4061
+ "x-on:dragstart": "handleDragStart($event)",
4062
+ "x-on:dragend": "handleDragEnd($event)",
4063
+ "x-on:dragover": "handleDragOver($event)",
4064
+ "x-on:dragleave": "handleDragLeave($event)",
4065
+ "x-on:drop": "handleDrop($event)"
4066
+ },
4067
+ ...rest,
4068
+ children
4069
+ }
3203
4070
  );
3204
- return /* @__PURE__ */ jsxs("li", { className: "relative group", "data-testid": `nav-item-${index}`, children: [
3205
- /* @__PURE__ */ jsxs(
3206
- "a",
3207
- {
3208
- href: item.href,
3209
- "hx-get": item.href,
3210
- className: `flex items-center px-4 py-2.5 rounded-lg transition-all duration-200 ${isActive || hasActiveChild ? "bg-blue-600 text-white shadow-md font-medium" : "text-gray-300 hover:bg-gray-700 hover:text-white"}`,
3211
- "data-testid": `nav-link-${item.label}`,
3212
- "aria-current": isActive ? "page" : void 0,
3213
- "aria-label": `\u5BFC\u822A\u5230 ${item.label}`,
3214
- children: [
3215
- item.icon && /* @__PURE__ */ jsx("span", { className: "mr-2.5 text-lg", "aria-hidden": "true", children: item.icon }),
3216
- /* @__PURE__ */ jsx("span", { className: "whitespace-nowrap overflow-hidden text-ellipsis", children: item.label })
3217
- ]
3218
- }
3219
- ),
3220
- item.children && item.children.length > 0 && /* @__PURE__ */ jsx("ul", { className: "ml-4 mt-1 space-y-1", "data-testid": `nav-submenu-${index}`, children: item.children.map((child, childIndex) => {
3221
- const isChildActive = currentPath === child.href || currentPath && currentPath.startsWith(child.href + "/");
3222
- return /* @__PURE__ */ jsx("li", { "data-testid": `nav-subitem-${index}-${childIndex}`, children: /* @__PURE__ */ jsxs(
3223
- "a",
3224
- {
3225
- href: child.href,
3226
- "hx-get": child.href,
3227
- className: `flex items-center px-4 py-2 rounded-lg text-sm transition-all duration-200 ${isChildActive ? "bg-blue-500 text-white font-medium" : "text-gray-400 hover:bg-gray-700 hover:text-white"}`,
3228
- "data-testid": `nav-sublink-${child.label}`,
3229
- "aria-current": isChildActive ? "page" : void 0,
3230
- "aria-label": `\u5BFC\u822A\u5230 ${child.label}`,
3231
- children: [
3232
- child.icon && /* @__PURE__ */ jsx("span", { className: "mr-2", "aria-hidden": "true", children: child.icon }),
3233
- /* @__PURE__ */ jsx("span", { className: "whitespace-nowrap overflow-hidden text-ellipsis", children: child.label })
3234
- ]
3235
- }
3236
- ) }, childIndex);
3237
- }) })
3238
- ] }, index);
3239
4071
  }
3240
- function renderActionButton2(action, index) {
4072
+ function StringArrayEditor(props) {
3241
4073
  const {
3242
- label,
3243
- href,
3244
- hxGet,
3245
- hxPost,
3246
- hxPut,
3247
- hxDelete,
3248
- variant = "primary",
3249
- confirm,
3250
- submit,
3251
- formId,
3252
- onClick,
3253
- className = "",
3254
- target
3255
- } = action;
3256
- if (submit && formId) {
3257
- const variantStyles = {
3258
- primary: "bg-blue-600 text-white hover:bg-blue-700",
3259
- secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
3260
- danger: "bg-red-600 text-white hover:bg-red-700",
3261
- ghost: "bg-transparent text-gray-700 hover:bg-gray-100"
3262
- };
3263
- const buttonStyle = variantStyles[variant] || variantStyles.primary;
3264
- const testId2 = label === "\u521B\u5EFA" || label === "\u66F4\u65B0" ? "submit-button" : `action-${label}`;
3265
- return /* @__PURE__ */ jsx(
3266
- "button",
3267
- {
3268
- type: "submit",
3269
- form: formId,
3270
- className: `px-4 py-2 rounded transition-colors font-medium ${buttonStyle} ${className}`,
3271
- "data-testid": testId2,
3272
- ...confirm && { "data-confirm": confirm },
3273
- children: label
3274
- },
3275
- index
3276
- );
3277
- }
3278
- if (onClick) {
3279
- const variantStyles = {
3280
- primary: "bg-blue-600 text-white hover:bg-blue-700",
3281
- secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
3282
- danger: "bg-red-600 text-white hover:bg-red-700",
3283
- ghost: "bg-transparent text-gray-700 hover:bg-gray-100"
3284
- };
3285
- const buttonStyle = variantStyles[variant] || variantStyles.secondary;
3286
- const testId2 = label === "\u53D6\u6D88" ? "cancel-button" : `action-${label}`;
3287
- return /* @__PURE__ */ jsx(
3288
- "button",
3289
- {
3290
- type: "button",
3291
- _: onClick,
3292
- className: `px-4 py-2 rounded transition-colors font-medium ${buttonStyle} ${className}`,
3293
- "data-testid": testId2,
3294
- ...confirm && { "data-confirm": confirm },
3295
- children: label
3296
- },
3297
- index
3298
- );
3299
- }
3300
- let testId = `action-${label}`;
3301
- if (label === "\u65B0\u5EFA" || label === "\u521B\u5EFA") {
3302
- testId = "create-button";
3303
- } else if (label === "\u53D6\u6D88") {
3304
- testId = "cancel-button";
3305
- }
3306
- const isNewWindow = target === "_blank";
3307
- return /* @__PURE__ */ jsx(
3308
- Button,
4074
+ value,
4075
+ fieldName,
4076
+ placeholder = "\u8BF7\u8F93\u5165\u5185\u5BB9",
4077
+ allowEmpty = false,
4078
+ rows = 1
4079
+ } = props;
4080
+ const initialItems = value || [];
4081
+ const initialDataJson = JSON.stringify({
4082
+ items: initialItems.map((item) => item || ""),
4083
+ fieldName,
4084
+ placeholder,
4085
+ allowEmpty,
4086
+ rows
4087
+ });
4088
+ return /* @__PURE__ */ jsxs(
4089
+ "div",
3309
4090
  {
3310
- variant,
3311
- href,
3312
- hxGet: !isNewWindow ? hxGet : void 0,
3313
- hxPost: !isNewWindow ? hxPost : void 0,
3314
- hxPut: !isNewWindow ? hxPut : void 0,
3315
- hxDelete: !isNewWindow ? hxDelete : void 0,
3316
- hxConfirm: !isNewWindow ? confirm : void 0,
3317
- className,
3318
- "data-testid": testId,
3319
- target,
3320
- rel: target === "_blank" ? "noopener noreferrer" : void 0,
3321
- children: label
3322
- },
3323
- index
4091
+ className: "space-y-3",
4092
+ "x-data": initialDataJson,
4093
+ children: [
4094
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-end", children: /* @__PURE__ */ jsxs(
4095
+ "button",
4096
+ {
4097
+ type: "button",
4098
+ ...{
4099
+ "x-on:click": `
4100
+ items.push('');
4101
+ `
4102
+ },
4103
+ className: "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2",
4104
+ "data-testid": `${fieldName}-add-button`,
4105
+ children: [
4106
+ /* @__PURE__ */ jsx(
4107
+ "svg",
4108
+ {
4109
+ className: "w-4 h-4",
4110
+ fill: "none",
4111
+ stroke: "currentColor",
4112
+ viewBox: "0 0 24 24",
4113
+ children: /* @__PURE__ */ jsx(
4114
+ "path",
4115
+ {
4116
+ strokeLinecap: "round",
4117
+ strokeLinejoin: "round",
4118
+ strokeWidth: "2",
4119
+ d: "M12 4v16m8-8H4"
4120
+ }
4121
+ )
4122
+ }
4123
+ ),
4124
+ "\u6DFB\u52A0\u9879"
4125
+ ]
4126
+ }
4127
+ ) }),
4128
+ /* @__PURE__ */ jsx(
4129
+ "div",
4130
+ {
4131
+ "x-show": "items.length > 0",
4132
+ "data-testid": `${fieldName}-list-container`,
4133
+ ...{
4134
+ "@sortable:change.stop": `
4135
+ (function() {
4136
+ const { oldIndex, newIndex } = $event.detail;
4137
+ [items[oldIndex], items[newIndex]] = [items[newIndex], items[oldIndex]];
4138
+ })();
4139
+ `
4140
+ },
4141
+ children: /* @__PURE__ */ jsx(SortableList, { className: "space-y-2", handle: "[data-drag-handle]", children: /* @__PURE__ */ jsx("template", { "x-for": "(item, index) in items", "x-bind:key": "index", children: /* @__PURE__ */ jsx(ArrayItem, { fieldName, rows }) }) })
4142
+ }
4143
+ ),
4144
+ /* @__PURE__ */ jsx(
4145
+ "div",
4146
+ {
4147
+ className: "empty-state text-center py-8 text-gray-400 text-sm border border-dashed border-gray-300 rounded-lg",
4148
+ "x-show": "items.length === 0",
4149
+ "data-testid": `${fieldName}-empty-state`,
4150
+ children: '\u6682\u65E0\u9879\uFF0C\u70B9\u51FB"\u6DFB\u52A0\u9879"\u6309\u94AE\u6DFB\u52A0'
4151
+ }
4152
+ )
4153
+ ]
4154
+ }
3324
4155
  );
3325
4156
  }
3326
- function AdminLayout(props) {
3327
- const {
3328
- title,
3329
- description,
3330
- options,
3331
- children,
3332
- currentPath,
3333
- userInfo,
3334
- breadcrumbs,
3335
- actions = []
3336
- } = props;
3337
- const navItems = options.navigation || [];
3338
- const logoutUrl = options.authProvider?.logoutUrl;
3339
- return /* @__PURE__ */ jsxs("div", { className: "flex h-screen", id: "main-content", children: [
3340
- /* @__PURE__ */ jsx("aside", { className: "w-64 bg-gradient-to-b from-gray-900 to-gray-800 text-white shadow-xl", children: /* @__PURE__ */ jsxs("div", { className: "p-6 h-full flex flex-col", children: [
3341
- /* @__PURE__ */ jsx("div", { className: "mb-8", children: options.logo ? /* @__PURE__ */ jsx("img", { src: options.logo, alt: "Logo", className: "h-10 mb-2" }) : /* @__PURE__ */ jsx("h1", { className: "text-xl font-bold text-white whitespace-nowrap overflow-hidden text-ellipsis", children: options.title || "\u7BA1\u7406\u540E\u53F0" }) }),
3342
- /* @__PURE__ */ jsx("nav", { className: "flex-1 overflow-y-auto", "data-testid": "main-navigation", "aria-label": "\u4E3B\u5BFC\u822A", children: /* @__PURE__ */ jsx("ul", { className: "space-y-1", "data-testid": "nav-list", children: navItems.length > 0 ? navItems.map(
3343
- (item, index) => renderNavItem(item, currentPath, index)
3344
- ) : /* @__PURE__ */ jsx("li", { className: "px-4 py-2 text-gray-400 text-sm", "data-testid": "nav-empty", children: "\u6682\u65E0\u5BFC\u822A\u9879" }) }) })
3345
- ] }) }),
3346
- /* @__PURE__ */ jsxs("div", { className: "flex-1 flex flex-col overflow-hidden", children: [
4157
+ function ArrayItem({ fieldName, rows = 1 }) {
4158
+ return /* @__PURE__ */ jsxs(
4159
+ "div",
4160
+ {
4161
+ "data-array-item": true,
4162
+ className: "flex items-center gap-2 group",
4163
+ children: [
4164
+ /* @__PURE__ */ jsx(
4165
+ "div",
4166
+ {
4167
+ className: "flex-shrink-0 cursor-move text-gray-400 hover:text-gray-600 transition-colors p-1",
4168
+ "data-drag-handle": true,
4169
+ "data-testid": `${fieldName}-drag-handle`,
4170
+ title: "\u62D6\u62FD\u6392\u5E8F",
4171
+ children: /* @__PURE__ */ jsx(
4172
+ "svg",
4173
+ {
4174
+ className: "w-5 h-5",
4175
+ fill: "none",
4176
+ stroke: "currentColor",
4177
+ viewBox: "0 0 24 24",
4178
+ children: /* @__PURE__ */ jsx(
4179
+ "path",
4180
+ {
4181
+ strokeLinecap: "round",
4182
+ strokeLinejoin: "round",
4183
+ strokeWidth: "2",
4184
+ d: "M4 8h16M4 16h16"
4185
+ }
4186
+ )
4187
+ }
4188
+ )
4189
+ }
4190
+ ),
4191
+ rows === 1 ? /* @__PURE__ */ jsx(
4192
+ "input",
4193
+ {
4194
+ type: "text",
4195
+ "x-model": "items[index]",
4196
+ "x-bind:placeholder": "placeholder + ' ' + (index + 1)",
4197
+ className: "flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
4198
+ "data-testid": `${fieldName}-input`,
4199
+ "x-bind:required": "!allowEmpty"
4200
+ }
4201
+ ) : /* @__PURE__ */ jsx(
4202
+ "textarea",
4203
+ {
4204
+ "x-model": "items[index]",
4205
+ "x-bind:placeholder": "placeholder + ' ' + (index + 1)",
4206
+ rows,
4207
+ className: "flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y",
4208
+ "data-testid": `${fieldName}-input`,
4209
+ "x-bind:required": "!allowEmpty"
4210
+ }
4211
+ ),
4212
+ /* @__PURE__ */ jsx(
4213
+ "input",
4214
+ {
4215
+ type: "hidden",
4216
+ "x-bind:name": "fieldName + '[' + index + ']'",
4217
+ "x-bind:value": "item"
4218
+ }
4219
+ ),
4220
+ /* @__PURE__ */ jsx(
4221
+ "button",
4222
+ {
4223
+ type: "button",
4224
+ ...{
4225
+ "x-on:click": `
4226
+ items.splice(index, 1);
4227
+ `
4228
+ },
4229
+ className: "flex-shrink-0 px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors",
4230
+ "data-testid": `${fieldName}-remove-button`,
4231
+ title: "\u5220\u9664\u6B64\u9879",
4232
+ children: /* @__PURE__ */ jsx(
4233
+ "svg",
4234
+ {
4235
+ className: "w-5 h-5",
4236
+ fill: "none",
4237
+ stroke: "currentColor",
4238
+ viewBox: "0 0 24 24",
4239
+ children: /* @__PURE__ */ jsx(
4240
+ "path",
4241
+ {
4242
+ strokeLinecap: "round",
4243
+ strokeLinejoin: "round",
4244
+ strokeWidth: "2",
4245
+ d: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
4246
+ }
4247
+ )
4248
+ }
4249
+ )
4250
+ }
4251
+ )
4252
+ ]
4253
+ }
4254
+ );
4255
+ }
4256
+ function TagsEditor(props) {
4257
+ const { value, fieldName, placeholder = "\u8F93\u5165\u6807\u7B7E\u540E\u6309\u56DE\u8F66\u6DFB\u52A0" } = props;
4258
+ const initialTags = value || [];
4259
+ const initialDataJson = JSON.stringify({
4260
+ tags: initialTags.map((tag) => tag || ""),
4261
+ fieldName,
4262
+ newTag: "",
4263
+ editingIndex: null,
4264
+ editingValue: "",
4265
+ error: ""
4266
+ });
4267
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-2", "x-data": initialDataJson, children: [
4268
+ /* @__PURE__ */ jsxs("div", { children: [
3347
4269
  /* @__PURE__ */ jsx(
3348
- Header,
4270
+ "input",
3349
4271
  {
3350
- title,
3351
- breadcrumbs,
3352
- userInfo,
3353
- logoutUrl
4272
+ type: "text",
4273
+ "x-model": "newTag",
4274
+ ...{
4275
+ "x-on:keydown.enter.prevent": `
4276
+ if (newTag.trim()) {
4277
+ tags.push(newTag.trim());
4278
+ newTag = '';
4279
+ error = '';
4280
+ } else {
4281
+ error = '\u6807\u7B7E\u4E0D\u80FD\u4E3A\u7A7A';
4282
+ }
4283
+ `
4284
+ },
4285
+ placeholder,
4286
+ autocomplete: "off",
4287
+ className: "w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
4288
+ "data-testid": `${fieldName}-input`
3354
4289
  }
3355
4290
  ),
3356
- (title || description || actions.length > 0) && /* @__PURE__ */ jsx("div", { className: "bg-white border-b border-gray-200 px-6 py-3", children: /* @__PURE__ */ jsxs("div", { className: "flex justify-between items-center gap-4", children: [
3357
- /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0", children: [
3358
- title && /* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold text-gray-900 mb-0.5 leading-tight", children: title }),
3359
- description && /* @__PURE__ */ jsx("p", { className: "text-gray-600 text-xs leading-snug", children: description })
3360
- ] }),
3361
- actions.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex gap-2 flex-shrink-0", "data-testid": "page-actions", children: actions.map(
3362
- (action, index) => renderActionButton2(action, index)
3363
- ) })
3364
- ] }) }),
3365
- /* @__PURE__ */ jsx("main", { className: "flex-1 overflow-auto bg-gray-50", children: /* @__PURE__ */ jsx("div", { className: "p-6", children }) })
3366
- ] })
4291
+ /* @__PURE__ */ jsx(
4292
+ "div",
4293
+ {
4294
+ "x-show": "error && editingIndex === null",
4295
+ "x-text": "error",
4296
+ className: "text-red-600 text-sm p-2",
4297
+ id: `${fieldName}-error`
4298
+ }
4299
+ )
4300
+ ] }),
4301
+ /* @__PURE__ */ jsx(
4302
+ "div",
4303
+ {
4304
+ "x-show": "tags.length > 0",
4305
+ "data-testid": `${fieldName}-tags-container`,
4306
+ ...{
4307
+ "@sortable:change.stop": `
4308
+ (function() {
4309
+ const { oldIndex, newIndex } = $event.detail;
4310
+ [tags[oldIndex], tags[newIndex]] = [tags[newIndex], tags[oldIndex]];
4311
+ })();
4312
+ `
4313
+ },
4314
+ children: /* @__PURE__ */ jsx(
4315
+ SortableList,
4316
+ {
4317
+ className: "flex flex-wrap gap-2",
4318
+ handle: "[data-drag-handle]",
4319
+ children: /* @__PURE__ */ jsx("template", { "x-for": "(tag, index) in tags", "x-bind:key": "index", children: /* @__PURE__ */ jsxs("div", { className: "inline-flex", children: [
4320
+ /* @__PURE__ */ jsx(
4321
+ "input",
4322
+ {
4323
+ type: "hidden",
4324
+ "x-bind:name": `fieldName + '[' + index + ']'`,
4325
+ "x-bind:value": "editingIndex === index ? editingValue : tag"
4326
+ }
4327
+ ),
4328
+ /* @__PURE__ */ jsx(TagItem, { fieldName }),
4329
+ /* @__PURE__ */ jsx(TagItemEdit, { fieldName })
4330
+ ] }) })
4331
+ }
4332
+ )
4333
+ }
4334
+ ),
4335
+ /* @__PURE__ */ jsx(
4336
+ "div",
4337
+ {
4338
+ className: "empty-state text-center py-4 text-gray-400 text-sm border border-dashed border-gray-300 rounded-md",
4339
+ "x-show": "tags.length === 0",
4340
+ "data-testid": `${fieldName}-empty-state`,
4341
+ children: "\u6682\u65E0\u6807\u7B7E\uFF0C\u5728\u4E0A\u65B9\u8F93\u5165\u6846\u4E2D\u8F93\u5165\u6807\u7B7E\u540E\u6309\u56DE\u8F66\u6DFB\u52A0"
4342
+ }
4343
+ )
3367
4344
  ] });
3368
4345
  }
3369
- function NoLayout(props) {
3370
- return /* @__PURE__ */ jsx("div", { id: "main-content", children: props.children });
4346
+ function TagItem({ fieldName }) {
4347
+ return /* @__PURE__ */ jsxs(
4348
+ "div",
4349
+ {
4350
+ className: "inline-flex items-center gap-1 px-2 py-1 bg-blue-50 border border-blue-200 rounded-md text-sm group",
4351
+ "x-show": "editingIndex !== index",
4352
+ children: [
4353
+ /* @__PURE__ */ jsx(
4354
+ "div",
4355
+ {
4356
+ className: "flex-shrink-0 cursor-move text-blue-400 hover:text-blue-600 transition-colors",
4357
+ "data-drag-handle": true,
4358
+ "data-testid": `${fieldName}-drag-handle`,
4359
+ title: "\u62D6\u62FD\u6392\u5E8F",
4360
+ children: /* @__PURE__ */ jsx(
4361
+ "svg",
4362
+ {
4363
+ className: "w-3 h-3",
4364
+ fill: "none",
4365
+ stroke: "currentColor",
4366
+ viewBox: "0 0 24 24",
4367
+ children: /* @__PURE__ */ jsx(
4368
+ "path",
4369
+ {
4370
+ strokeLinecap: "round",
4371
+ strokeLinejoin: "round",
4372
+ strokeWidth: "2",
4373
+ d: "M4 8h16M4 16h16"
4374
+ }
4375
+ )
4376
+ }
4377
+ )
4378
+ }
4379
+ ),
4380
+ /* @__PURE__ */ jsx(
4381
+ "span",
4382
+ {
4383
+ className: "flex-1 text-blue-900 cursor-pointer",
4384
+ "data-testid": `${fieldName}-tag-text`,
4385
+ ...{
4386
+ "x-on:click.stop": `
4387
+ editingIndex = index;
4388
+ editingValue = tag;
4389
+ `
4390
+ },
4391
+ children: /* @__PURE__ */ jsx("span", { "x-text": "tag" })
4392
+ }
4393
+ ),
4394
+ /* @__PURE__ */ jsx(
4395
+ "button",
4396
+ {
4397
+ type: "button",
4398
+ ...{ "x-on:click.stop": "tags.splice(index, 1)" },
4399
+ className: "flex-shrink-0 text-blue-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors p-0.5 delete-tag-button",
4400
+ "data-testid": `${fieldName}-tag-remove`,
4401
+ title: "\u5220\u9664\u6807\u7B7E",
4402
+ children: /* @__PURE__ */ jsx(
4403
+ "svg",
4404
+ {
4405
+ className: "w-3.5 h-3.5",
4406
+ fill: "none",
4407
+ stroke: "currentColor",
4408
+ viewBox: "0 0 24 24",
4409
+ children: /* @__PURE__ */ jsx(
4410
+ "path",
4411
+ {
4412
+ strokeLinecap: "round",
4413
+ strokeLinejoin: "round",
4414
+ strokeWidth: "2",
4415
+ d: "M6 18L18 6M6 6l12 12"
4416
+ }
4417
+ )
4418
+ }
4419
+ )
4420
+ }
4421
+ )
4422
+ ]
4423
+ }
4424
+ );
3371
4425
  }
3372
-
3373
- // src/utils/permissions.ts
3374
- function checkUserPermission(requiredPermission, userPermissions) {
3375
- if (!requiredPermission) {
3376
- return { allowed: true, reason: "\u65E0\u9700\u6743\u9650" };
3377
- }
3378
- if (!userPermissions || userPermissions.length === 0) {
3379
- return {
3380
- allowed: false,
3381
- reason: "\u7528\u6237\u65E0\u4EFB\u4F55\u6743\u9650",
3382
- matchedPermission: "none"
3383
- };
3384
- }
3385
- const denyResult = checkDenyPermissions(requiredPermission, userPermissions);
3386
- if (!denyResult.allowed) {
3387
- return denyResult;
3388
- }
3389
- const allowResult = checkAllowPermissions(
3390
- requiredPermission,
4426
+ function TagItemEdit({ fieldName }) {
4427
+ return /* @__PURE__ */ jsxs(
4428
+ "div",
4429
+ {
4430
+ className: "inline-flex items-center gap-1 px-2 py-1 bg-blue-50 border border-blue-300 rounded-md text-sm",
4431
+ "x-show": "editingIndex === index",
4432
+ children: [
4433
+ /* @__PURE__ */ jsx(
4434
+ "div",
4435
+ {
4436
+ className: "flex-shrink-0 cursor-move text-blue-400 hover:text-blue-600 transition-colors",
4437
+ "data-drag-handle": true,
4438
+ "data-testid": `${fieldName}-drag-handle`,
4439
+ title: "\u62D6\u62FD\u6392\u5E8F",
4440
+ children: /* @__PURE__ */ jsx(
4441
+ "svg",
4442
+ {
4443
+ className: "w-3 h-3",
4444
+ fill: "none",
4445
+ stroke: "currentColor",
4446
+ viewBox: "0 0 24 24",
4447
+ children: /* @__PURE__ */ jsx(
4448
+ "path",
4449
+ {
4450
+ strokeLinecap: "round",
4451
+ strokeLinejoin: "round",
4452
+ strokeWidth: "2",
4453
+ d: "M4 8h16M4 16h16"
4454
+ }
4455
+ )
4456
+ }
4457
+ )
4458
+ }
4459
+ ),
4460
+ /* @__PURE__ */ jsx(
4461
+ "input",
4462
+ {
4463
+ type: "text",
4464
+ "x-model": "editingValue",
4465
+ ...{
4466
+ "x-on:keydown.enter.prevent": `
4467
+ if (editingValue.trim()) {
4468
+ tags[index] = editingValue.trim();
4469
+ editingIndex = null;
4470
+ editingValue = '';
4471
+ error = '';
4472
+ } else {
4473
+ error = '\u6807\u7B7E\u4E0D\u80FD\u4E3A\u7A7A';
4474
+ }
4475
+ `,
4476
+ "x-on:blur": `
4477
+ if (editingValue.trim()) {
4478
+ tags[index] = editingValue.trim();
4479
+ editingIndex = null;
4480
+ editingValue = '';
4481
+ error = '';
4482
+ } else {
4483
+ error = '\u6807\u7B7E\u4E0D\u80FD\u4E3A\u7A7A';
4484
+ }
4485
+ `
4486
+ },
4487
+ "x-bind:required": "editingIndex === index",
4488
+ className: "flex-1 px-1 py-0.5 border border-blue-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-[60px]",
4489
+ "data-testid": `${fieldName}-tag-edit-input`
4490
+ }
4491
+ ),
4492
+ /* @__PURE__ */ jsx(
4493
+ "button",
4494
+ {
4495
+ type: "button",
4496
+ ...{
4497
+ "x-on:click.stop": `
4498
+ editingIndex = null;
4499
+ editingValue = '';
4500
+ error = '';
4501
+ `
4502
+ },
4503
+ className: "px-1.5 py-0.5 text-xs bg-gray-300 text-gray-700 rounded hover:bg-gray-400 transition-colors",
4504
+ "data-testid": `${fieldName}-tag-cancel`,
4505
+ children: "\u53D6\u6D88"
4506
+ }
4507
+ ),
4508
+ /* @__PURE__ */ jsx(
4509
+ "div",
4510
+ {
4511
+ "x-show": "error && editingIndex === index",
4512
+ "x-text": "error",
4513
+ className: "text-red-600 text-xs mt-1"
4514
+ }
4515
+ )
4516
+ ]
4517
+ }
4518
+ );
4519
+ }
4520
+ function ObjectEditor(props) {
4521
+ const { value, fieldName, objectSchema } = props;
4522
+ if (!objectSchema) {
4523
+ return /* @__PURE__ */ jsx("div", { className: "p-4 border border-yellow-300 rounded-lg bg-yellow-50 text-yellow-800 text-sm", children: "\u8BF7\u63D0\u4F9B objectSchema \u53C2\u6570\u4EE5\u4F7F\u7528\u5BF9\u8C61\u7F16\u8F91\u5668" });
4524
+ }
4525
+ const fields = parseSchemaToFields(objectSchema);
4526
+ const initialObject = value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
4527
+ fields.forEach((field) => {
4528
+ if (!(field.name in initialObject)) {
4529
+ if (field.type === "number") {
4530
+ initialObject[field.name] = field.required ? 0 : void 0;
4531
+ } else if (field.type === "checkbox") {
4532
+ initialObject[field.name] = field.required ? false : void 0;
4533
+ } else {
4534
+ initialObject[field.name] = field.required ? "" : void 0;
4535
+ }
4536
+ }
4537
+ });
4538
+ const initialValueJson = JSON.stringify(initialObject);
4539
+ JSON.stringify(fields.map((f) => f.name));
4540
+ const generateField = (field) => {
4541
+ const fieldId = `${fieldName}-${field.name}`;
4542
+ const fieldValue = initialObject[field.name];
4543
+ const fieldValueStr = fieldValue === void 0 || fieldValue === null ? "" : typeof fieldValue === "object" ? JSON.stringify(fieldValue) : String(fieldValue);
4544
+ const requiredAttr = field.required ? "required" : "";
4545
+ let inputElement;
4546
+ if (field.type === "text") {
4547
+ inputElement = html`
4548
+ <input
4549
+ type="text"
4550
+ id="${fieldId}"
4551
+ name="${fieldName}.${field.name}"
4552
+ value="${fieldValueStr}"
4553
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4554
+ data-testid="${fieldName}-input-${field.name}"
4555
+ oninput="updateObjectField('${fieldName}', '${field.name}', this.value, 'text', ${field.required})"
4556
+ ${requiredAttr}
4557
+ />
4558
+ `;
4559
+ } else if (field.type === "textarea") {
4560
+ inputElement = html`
4561
+ <textarea
4562
+ id="${fieldId}"
4563
+ name="${fieldName}.${field.name}"
4564
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y"
4565
+ rows="4"
4566
+ data-testid="${fieldName}-input-${field.name}"
4567
+ oninput="updateObjectField('${fieldName}', '${field.name}', this.value, 'text', ${field.required})"
4568
+ ${requiredAttr}
4569
+ >${fieldValueStr}</textarea>
4570
+ `;
4571
+ } else if (field.type === "number") {
4572
+ const step = field.step || (field.step === void 0 ? "1" : "any");
4573
+ inputElement = html`
4574
+ <input
4575
+ type="number"
4576
+ id="${fieldId}"
4577
+ name="${fieldName}.${field.name}"
4578
+ value="${fieldValueStr}"
4579
+ step="${step}"
4580
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4581
+ data-testid="${fieldName}-input-${field.name}"
4582
+ oninput="updateObjectField('${fieldName}', '${field.name}', this.value, 'number', ${field.required})"
4583
+ ${requiredAttr}
4584
+ />
4585
+ `;
4586
+ } else if (field.type === "date") {
4587
+ inputElement = html`
4588
+ <input
4589
+ type="date"
4590
+ id="${fieldId}"
4591
+ name="${fieldName}.${field.name}"
4592
+ value="${fieldValueStr}"
4593
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4594
+ data-testid="${fieldName}-input-${field.name}"
4595
+ oninput="updateObjectField('${fieldName}', '${field.name}', this.value, 'date', ${field.required})"
4596
+ ${requiredAttr}
4597
+ />
4598
+ `;
4599
+ } else if (field.type === "email") {
4600
+ inputElement = html`
4601
+ <input
4602
+ type="email"
4603
+ id="${fieldId}"
4604
+ name="${fieldName}.${field.name}"
4605
+ value="${fieldValueStr}"
4606
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4607
+ data-testid="${fieldName}-input-${field.name}"
4608
+ oninput="updateObjectField('${fieldName}', '${field.name}', this.value, 'text', ${field.required})"
4609
+ ${requiredAttr}
4610
+ />
4611
+ `;
4612
+ } else if (field.type === "select" && field.options) {
4613
+ inputElement = html`
4614
+ <select
4615
+ id="${fieldId}"
4616
+ name="${fieldName}.${field.name}"
4617
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
4618
+ data-testid="${fieldName}-select-${field.name}"
4619
+ onchange="updateObjectField('${fieldName}', '${field.name}', this.value, 'text', ${field.required})"
4620
+ ${requiredAttr}
4621
+ >
4622
+ ${!field.required ? html`<option value="">请选择</option>` : ""}
4623
+ ${field.options.map(
4624
+ (option) => html`
4625
+ <option
4626
+ value="${String(option.value)}"
4627
+ ${fieldValueStr === String(option.value) ? "selected" : ""}
4628
+ >
4629
+ ${option.label}
4630
+ </option>
4631
+ `
4632
+ )}
4633
+ </select>
4634
+ `;
4635
+ } else if (field.type === "checkbox") {
4636
+ const checked = fieldValue === true || fieldValue === "true" || fieldValue === 1 || fieldValue === "1";
4637
+ inputElement = html`
4638
+ <div class="flex items-center">
4639
+ <input
4640
+ type="checkbox"
4641
+ id="${fieldId}"
4642
+ name="${fieldName}.${field.name}"
4643
+ ${checked ? "checked" : ""}
4644
+ class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
4645
+ data-testid="${fieldName}-checkbox-${field.name}"
4646
+ onchange="updateObjectField('${fieldName}', '${field.name}', this.checked, 'checkbox', ${field.required}')"
4647
+ />
4648
+ <label for="${fieldId}" class="ml-2 text-sm text-gray-700">
4649
+ ${field.label}
4650
+ </label>
4651
+ </div>
4652
+ `;
4653
+ }
4654
+ return html`
4655
+ <div class="space-y-2" data-testid="${fieldName}-field-${field.name}">
4656
+ ${field.type !== "checkbox" ? html`
4657
+ <label
4658
+ for="${fieldId}"
4659
+ class="block text-sm font-semibold text-gray-700"
4660
+ data-testid="${fieldName}-label-${field.name}"
4661
+ >
4662
+ ${field.label}
4663
+ ${field.required ? html`<span class="text-red-500 ml-1">*</span>` : ""}
4664
+ </label>
4665
+ ` : ""}
4666
+ ${inputElement}
4667
+ </div>
4668
+ `;
4669
+ };
4670
+ return html`
4671
+ <div
4672
+ id="object-editor-${fieldName}"
4673
+ class="space-y-4"
4674
+ data-initial-value="${initialValueJson}"
4675
+ >
4676
+ <input
4677
+ type="hidden"
4678
+ name="${fieldName}"
4679
+ value="${initialValueJson}"
4680
+ data-testid="hidden-${fieldName}"
4681
+ />
4682
+ <div class="space-y-4">
4683
+ ${fields.map((field) => generateField(field))}
4684
+ </div>
4685
+ </div>
4686
+ <script>
4687
+ (function() {
4688
+ // 更新对象字段
4689
+ function updateObjectField(fieldName, subFieldName, value, fieldType, required) {
4690
+ const container = document.getElementById('object-editor-' + fieldName);
4691
+ if (!container) return;
4692
+
4693
+ const hiddenInput = container.querySelector('input[name="' + fieldName + '"][type="hidden"]');
4694
+ if (!hiddenInput) return;
4695
+
4696
+ try {
4697
+ const obj = JSON.parse(hiddenInput.value || '{}');
4698
+
4699
+ // 类型转换
4700
+ let convertedValue = value;
4701
+ if (fieldType === 'number') {
4702
+ convertedValue = value === '' ? (required ? 0 : undefined) : Number(value);
4703
+ if (isNaN(convertedValue)) convertedValue = required ? 0 : undefined;
4704
+ } else if (fieldType === 'checkbox') {
4705
+ convertedValue = value === 'true' || value === true || value === '1' || value === 1;
4706
+ } else {
4707
+ convertedValue = value || (required ? '' : undefined);
4708
+ }
4709
+
4710
+ // 更新对象
4711
+ if (convertedValue === undefined && !required) {
4712
+ delete obj[subFieldName];
4713
+ } else {
4714
+ obj[subFieldName] = convertedValue;
4715
+ }
4716
+
4717
+ // 更新隐藏字段
4718
+ hiddenInput.value = JSON.stringify(obj);
4719
+ } catch (e) {
4720
+ console.error('Failed to update object field:', e);
4721
+ }
4722
+ }
4723
+
4724
+ // 将函数暴露到全局作用域
4725
+ if (typeof window !== 'undefined') {
4726
+ window.updateObjectField = updateObjectField;
4727
+ }
4728
+ })();
4729
+ </script>
4730
+ `;
4731
+ }
4732
+ function BaseLayout(props) {
4733
+ return html`
4734
+ <html>
4735
+ <head>
4736
+ <meta charset="UTF-8" />
4737
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4738
+ <title>${props.title}</title>
4739
+ <meta name="description" content="${props.description || ""}" />
4740
+ ${globalScripts(props.prefix)} ${globalStyles()}
4741
+ </head>
4742
+ <body hx-ext="morph" className="bg-gray-50" hx-indicator="#loading-bar">
4743
+ ${LoadingBar()} ${props.children}
4744
+ <div
4745
+ id="error-container"
4746
+ className="fixed top-4 right-4 z-[200] w-full max-w-2xl px-4"
4747
+ ></div>
4748
+ <div id="dialog-container"></div>
4749
+ ${sortableScript()}
4750
+ </body>
4751
+ </html>
4752
+ `;
4753
+ }
4754
+ function renderNavItem(item, currentPath, index) {
4755
+ const isActive = currentPath === item.href || currentPath && currentPath.startsWith(item.href + "/");
4756
+ const hasActiveChild = item.children?.some(
4757
+ (child) => currentPath === child.href || currentPath && currentPath.startsWith(child.href + "/")
4758
+ );
4759
+ return /* @__PURE__ */ jsxs(
4760
+ "li",
4761
+ {
4762
+ className: "relative group",
4763
+ "data-testid": `nav-item-${index}`,
4764
+ children: [
4765
+ /* @__PURE__ */ jsxs(
4766
+ "a",
4767
+ {
4768
+ href: item.href,
4769
+ "hx-get": item.href,
4770
+ className: `flex items-center px-4 py-2.5 rounded-lg transition-all duration-200 ${isActive || hasActiveChild ? "bg-blue-600 text-white shadow-md font-medium" : "text-gray-300 hover:bg-gray-700 hover:text-white"}`,
4771
+ "data-testid": `nav-link-${item.label}`,
4772
+ "aria-current": isActive ? "page" : void 0,
4773
+ "aria-label": `\u5BFC\u822A\u5230 ${item.label}`,
4774
+ children: [
4775
+ item.icon && /* @__PURE__ */ jsx("span", { className: "mr-2.5 text-lg", "aria-hidden": "true", children: item.icon }),
4776
+ /* @__PURE__ */ jsx("span", { className: "whitespace-nowrap overflow-hidden text-ellipsis", children: item.label })
4777
+ ]
4778
+ }
4779
+ ),
4780
+ item.children && item.children.length > 0 && /* @__PURE__ */ jsx(
4781
+ "ul",
4782
+ {
4783
+ className: "ml-4 mt-1 space-y-1",
4784
+ "data-testid": `nav-submenu-${index}`,
4785
+ children: item.children.map((child, childIndex) => {
4786
+ const isChildActive = currentPath === child.href || currentPath && currentPath.startsWith(child.href + "/");
4787
+ return /* @__PURE__ */ jsx(
4788
+ "li",
4789
+ {
4790
+ "data-testid": `nav-subitem-${index}-${childIndex}`,
4791
+ children: /* @__PURE__ */ jsxs(
4792
+ "a",
4793
+ {
4794
+ href: child.href,
4795
+ "hx-get": child.href,
4796
+ className: `flex items-center px-4 py-2 rounded-lg text-sm transition-all duration-200 ${isChildActive ? "bg-blue-500 text-white font-medium" : "text-gray-400 hover:bg-gray-700 hover:text-white"}`,
4797
+ "data-testid": `nav-sublink-${child.label}`,
4798
+ "aria-current": isChildActive ? "page" : void 0,
4799
+ "aria-label": `\u5BFC\u822A\u5230 ${child.label}`,
4800
+ children: [
4801
+ child.icon && /* @__PURE__ */ jsx("span", { className: "mr-2", "aria-hidden": "true", children: child.icon }),
4802
+ /* @__PURE__ */ jsx("span", { className: "whitespace-nowrap overflow-hidden text-ellipsis", children: child.label })
4803
+ ]
4804
+ }
4805
+ )
4806
+ },
4807
+ childIndex
4808
+ );
4809
+ })
4810
+ }
4811
+ )
4812
+ ]
4813
+ },
4814
+ index
4815
+ );
4816
+ }
4817
+ function AdminLayout(props) {
4818
+ const {
4819
+ title,
4820
+ description,
4821
+ options,
4822
+ children,
4823
+ currentPath,
4824
+ userInfo,
4825
+ breadcrumbs,
4826
+ actions
4827
+ } = props;
4828
+ const navItems = options.navigation || [];
4829
+ const logoutUrl = options.authProvider?.logoutUrl;
4830
+ return /* @__PURE__ */ jsxs("div", { className: "flex h-screen", id: "main-content", children: [
4831
+ /* @__PURE__ */ jsx("aside", { className: "w-64 bg-gradient-to-b from-gray-900 to-gray-800 text-white shadow-xl", children: /* @__PURE__ */ jsxs("div", { className: "p-6 h-full flex flex-col", children: [
4832
+ /* @__PURE__ */ jsx("div", { className: "mb-8", children: options.logo ? /* @__PURE__ */ jsx("img", { src: options.logo, alt: "Logo", className: "h-10 mb-2" }) : /* @__PURE__ */ jsx("h1", { className: "text-xl font-bold text-white whitespace-nowrap overflow-hidden text-ellipsis", children: options.title || "\u7BA1\u7406\u540E\u53F0" }) }),
4833
+ /* @__PURE__ */ jsx(
4834
+ "nav",
4835
+ {
4836
+ className: "flex-1 overflow-y-auto",
4837
+ "data-testid": "main-navigation",
4838
+ "aria-label": "\u4E3B\u5BFC\u822A",
4839
+ children: /* @__PURE__ */ jsx("ul", { className: "space-y-1", "data-testid": "nav-list", children: navItems.length > 0 ? navItems.map(
4840
+ (item, index) => renderNavItem(item, currentPath, index)
4841
+ ) : /* @__PURE__ */ jsx(
4842
+ "li",
4843
+ {
4844
+ className: "px-4 py-2 text-gray-400 text-sm",
4845
+ "data-testid": "nav-empty",
4846
+ children: "\u6682\u65E0\u5BFC\u822A\u9879"
4847
+ }
4848
+ ) })
4849
+ }
4850
+ )
4851
+ ] }) }),
4852
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 flex flex-col overflow-hidden", children: [
4853
+ /* @__PURE__ */ jsx(
4854
+ Header,
4855
+ {
4856
+ title,
4857
+ breadcrumbs,
4858
+ userInfo,
4859
+ logoutUrl
4860
+ }
4861
+ ),
4862
+ (title || description || actions) && /* @__PURE__ */ jsx("div", { className: "bg-white border-b border-gray-200 px-6 py-3", children: /* @__PURE__ */ jsxs("div", { className: "flex justify-between items-center gap-4", children: [
4863
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0", children: [
4864
+ title && /* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold text-gray-900 mb-0.5 leading-tight", children: title }),
4865
+ description && /* @__PURE__ */ jsx("p", { className: "text-gray-600 text-xs leading-snug", children: description })
4866
+ ] }),
4867
+ actions && /* @__PURE__ */ jsx(
4868
+ "div",
4869
+ {
4870
+ className: "flex gap-2 flex-shrink-0",
4871
+ "data-testid": "page-actions",
4872
+ children: actions
4873
+ }
4874
+ )
4875
+ ] }) }),
4876
+ /* @__PURE__ */ jsx("main", { className: "flex-1 overflow-auto bg-gray-50", children: /* @__PURE__ */ jsx("div", { className: "p-6", children }) })
4877
+ ] })
4878
+ ] });
4879
+ }
4880
+ function NoLayout(props) {
4881
+ return /* @__PURE__ */ jsx("div", { id: "main-content", children: props.children });
4882
+ }
4883
+
4884
+ // src/utils/permissions.ts
4885
+ function checkUserPermission(requiredPermission, userPermissions) {
4886
+ if (!requiredPermission) {
4887
+ return { allowed: true, reason: "\u65E0\u9700\u6743\u9650" };
4888
+ }
4889
+ if (!userPermissions || userPermissions.length === 0) {
4890
+ return {
4891
+ allowed: false,
4892
+ reason: "\u7528\u6237\u65E0\u4EFB\u4F55\u6743\u9650",
4893
+ matchedPermission: "none"
4894
+ };
4895
+ }
4896
+ const denyResult = checkDenyPermissions(requiredPermission, userPermissions);
4897
+ if (!denyResult.allowed) {
4898
+ return denyResult;
4899
+ }
4900
+ const allowResult = checkAllowPermissions(
4901
+ requiredPermission,
3391
4902
  userPermissions
3392
4903
  );
3393
4904
  return allowResult;
@@ -3453,1367 +4964,661 @@ function matchWithWildcard(required, pattern) {
3453
4964
  if (patternPart === "*") {
3454
4965
  continue;
3455
4966
  }
3456
- if (!patternPart && pattern.endsWith("*")) {
3457
- return true;
3458
- }
3459
- if (!requiredPart) {
3460
- return false;
3461
- }
3462
- if (requiredPart !== patternPart) {
3463
- return false;
3464
- }
3465
- }
3466
- return true;
3467
- }
3468
- async function checkPermission(user, permission, ctx, options) {
3469
- if (options.authProvider?.checkPermission) {
3470
- return await options.authProvider.checkPermission(user, permission, ctx);
3471
- }
3472
- const userPermissions = user?.permissions || [];
3473
- const result = checkUserPermission(permission, userPermissions);
3474
- return {
3475
- allowed: result.allowed,
3476
- message: result.reason,
3477
- operationId: permission
3478
- };
3479
- }
3480
- async function handlePermissionDenied(ctx, result, options) {
3481
- const isHtmxRequest = ctx.req.header("HX-Request") === "true";
3482
- const loginUrl = options.authProvider?.loginUrl || "/admin/login";
3483
- const user = await getUserInfo(ctx, options.authProvider);
3484
- if (!user && !isHtmxRequest) {
3485
- return ctx.redirect(loginUrl);
3486
- }
3487
- if (isHtmxRequest) {
3488
- const headers = {
3489
- "HX-Retarget": "#dialog-container",
3490
- "HX-Reswap": "innerHTML",
3491
- "X-Permission-Denied": "true"
3492
- };
3493
- return ctx.html(
3494
- /* @__PURE__ */ jsx(Dialog, { title: "\u6743\u9650\u4E0D\u8DB3", size: "lg", children: /* @__PURE__ */ jsx(
3495
- PermissionDeniedContent,
3496
- {
3497
- operationId: result.operationId,
3498
- fromPath: ctx.req.path,
3499
- message: result.message,
3500
- userInfo: user,
3501
- pluginOptions: options,
3502
- isDialog: true
3503
- }
3504
- ) }),
3505
- 200,
3506
- headers
3507
- );
3508
- }
3509
- const cdnProxyPrefix = `${options.prefix}/_cdn`;
3510
- return ctx.html(
3511
- /* @__PURE__ */ jsx(
3512
- BaseLayout,
3513
- {
3514
- title: `\u6743\u9650\u4E0D\u8DB3 - ${options.title}`,
3515
- description: "\u60A8\u6CA1\u6709\u6743\u9650\u8BBF\u95EE\u6B64\u8D44\u6E90",
3516
- cdnProxyPrefix,
3517
- children: /* @__PURE__ */ jsx(
3518
- PermissionDeniedPage,
3519
- {
3520
- operationId: result.operationId,
3521
- fromPath: ctx.req.path,
3522
- message: result.message,
3523
- userInfo: user,
3524
- pluginOptions: options
3525
- }
3526
- )
3527
- }
3528
- ),
3529
- 403
3530
- );
3531
- }
3532
- function buildNotificationFragments(notifications) {
3533
- if (notifications.length === 0) {
3534
- return null;
3535
- }
3536
- return notifications.map((notification, index) => /* @__PURE__ */ jsx(
3537
- "div",
3538
- {
3539
- id: "error-container",
3540
- "hx-swap-oob": "beforeend",
3541
- children: /* @__PURE__ */ jsx(
3542
- ErrorAlert,
3543
- {
3544
- type: notification.type,
3545
- title: notification.title,
3546
- message: notification.message
3547
- }
3548
- )
3549
- },
3550
- `notification-${index}`
3551
- ));
3552
- }
3553
- function isEmptyContent(result) {
3554
- if (result === null || result === void 0) return true;
3555
- if (typeof result === "string" && result.trim() === "") return true;
3556
- if (result && typeof result === "object" && "type" in result) {
3557
- if (result.type === "div") {
3558
- const props = result.props || {};
3559
- const children = props.children;
3560
- if (!children) return true;
3561
- if (Array.isArray(children) && children.length === 0) return true;
3562
- if (typeof children === "string" && children.trim() === "") return true;
3563
- }
3564
- }
3565
- return false;
3566
- }
3567
- async function isEmptyResponse(response) {
3568
- const clonedResponse = response.clone();
3569
- const text = await clonedResponse.text();
3570
- return !text || text.trim() === "" || text.trim() === "<div></div>" || text.trim().match(/^<div[^>]*>\s*<\/div>$/) !== null;
3571
- }
3572
- async function getDynamicMetadata(feature, context, defaultMetadata) {
3573
- if (feature?.getTitle) {
3574
- const title = await feature.getTitle(context);
3575
- const description = feature.getDescription ? await feature.getDescription(context) : defaultMetadata.description;
3576
- return { title, description };
3577
- }
3578
- return defaultMetadata;
3579
- }
3580
- async function getActions(feature, context) {
3581
- if (feature?.getActions) {
3582
- return await feature.getActions(context);
3583
- }
3584
- return [];
3585
- }
3586
- async function renderResult(ctx, context, result, renderOptions) {
3587
- const { options, metadata, currentPath, breadcrumbs, user, feature } = renderOptions;
3588
- const { notifications } = context;
3589
- const dynamicMetadata = await getDynamicMetadata(feature, context, metadata);
3590
- if (result instanceof Response) {
3591
- const status = result.status;
3592
- if (status === 302 || status === 301) {
3593
- const location = result.headers.get("Location");
3594
- if (location) {
3595
- if (context.isHtmxRequest) {
3596
- return ctx.html(/* @__PURE__ */ jsx("div", {}), 200, {
3597
- "HX-Redirect": location
3598
- });
3599
- }
3600
- return result;
3601
- }
3602
- }
3603
- if (status >= 300 && status < 400) {
3604
- const location = result.headers.get("Location");
3605
- if (location) {
3606
- if (context.isHtmxRequest) {
3607
- return ctx.html(/* @__PURE__ */ jsx("div", {}), 200, {
3608
- "HX-Redirect": location
3609
- });
3610
- }
3611
- return result;
3612
- }
4967
+ if (!patternPart && pattern.endsWith("*")) {
4968
+ return true;
3613
4969
  }
3614
- if (notifications.length > 0) {
3615
- const empty = await isEmptyResponse(result);
3616
- if (empty) {
3617
- const notificationFragments = buildNotificationFragments(notifications);
3618
- const titleFragment = /* @__PURE__ */ jsx("title", { children: dynamicMetadata.title });
3619
- return ctx.html(
3620
- /* @__PURE__ */ jsxs(Fragment, { children: [
3621
- notificationFragments,
3622
- titleFragment
3623
- ] }),
3624
- 200,
3625
- {
3626
- "HX-Reswap": "none"
3627
- // 关键:阻止 HTMX 替换任何内容
3628
- }
3629
- );
3630
- }
4970
+ if (!requiredPart) {
4971
+ return false;
4972
+ }
4973
+ if (requiredPart !== patternPart) {
4974
+ return false;
3631
4975
  }
3632
- return result;
3633
4976
  }
3634
- if (context.refresh) {
3635
- logger.info(
3636
- `[ResponseRenderer] Refresh requested (isDialog: ${context.isDialog}, isHtmxRequest: ${context.isHtmxRequest})`
3637
- );
4977
+ return true;
4978
+ }
4979
+ async function checkPermission(user, permission, ctx, options) {
4980
+ if (options.authProvider?.checkPermission) {
4981
+ return await options.authProvider.checkPermission(user, permission, ctx);
4982
+ }
4983
+ const userPermissions = user?.permissions || [];
4984
+ const result = checkUserPermission(permission, userPermissions);
4985
+ return {
4986
+ allowed: result.allowed,
4987
+ message: result.reason,
4988
+ operationId: permission
4989
+ };
4990
+ }
4991
+ async function handlePermissionDenied(ctx, result, options) {
4992
+ const isHtmxRequest = ctx.req.header("HX-Request") === "true";
4993
+ const loginUrl = options.authProvider?.loginUrl || "/admin/login";
4994
+ const user = await getUserInfo(ctx, options.authProvider);
4995
+ if (!user && !isHtmxRequest) {
4996
+ return ctx.redirect(loginUrl);
4997
+ }
4998
+ if (isHtmxRequest) {
3638
4999
  const headers = {
3639
- "HX-Refresh": "true"
5000
+ "HX-Retarget": "#dialog-container",
5001
+ "HX-Reswap": "innerHTML",
5002
+ "X-Permission-Denied": "true"
3640
5003
  };
3641
- const notificationFragments = buildNotificationFragments(notifications);
3642
- if (context.isDialog) {
3643
- return ctx.html(
3644
- /* @__PURE__ */ jsxs(Fragment, { children: [
3645
- /* @__PURE__ */ jsx("div", { id: "dialog-container", "hx-swap-oob": "innerHTML" }),
3646
- notificationFragments,
3647
- /* @__PURE__ */ jsx("title", { children: metadata.title })
3648
- ] }),
3649
- 200,
3650
- headers
3651
- );
3652
- }
3653
5004
  return ctx.html(
3654
- /* @__PURE__ */ jsxs(Fragment, { children: [
3655
- /* @__PURE__ */ jsx("div", {}),
3656
- notificationFragments,
3657
- /* @__PURE__ */ jsx("title", { children: metadata.title })
3658
- ] }),
5005
+ /* @__PURE__ */ jsx(Dialog, { title: "\u6743\u9650\u4E0D\u8DB3", size: "lg", children: /* @__PURE__ */ jsx(
5006
+ PermissionDeniedContent,
5007
+ {
5008
+ operationId: result.operationId,
5009
+ fromPath: ctx.req.path,
5010
+ message: result.message,
5011
+ userInfo: user,
5012
+ pluginOptions: options,
5013
+ isDialog: true
5014
+ }
5015
+ ) }),
3659
5016
  200,
3660
5017
  headers
3661
5018
  );
3662
5019
  }
3663
- if (context.redirectUrl && !context.refresh) {
3664
- logger.info(
3665
- `[ResponseRenderer] Redirect URL found: ${context.redirectUrl} (isHtmxRequest: ${context.isHtmxRequest}, isDialog: ${context.isDialog})`
3666
- );
3667
- if (context.isHtmxRequest) {
3668
- return ctx.html(/* @__PURE__ */ jsx("div", {}), 200, {
3669
- "HX-Redirect": context.redirectUrl
3670
- });
3671
- } else {
3672
- return ctx.redirect(context.redirectUrl);
3673
- }
3674
- } else if (context.redirectUrl && context.refresh) {
3675
- logger.info(
3676
- `[ResponseRenderer] Both redirect URL and refresh are set, using refresh (isDialog: ${context.isDialog})`
3677
- );
3678
- } else if (!context.redirectUrl) {
3679
- logger.info(
3680
- `[ResponseRenderer] No redirect URL found (result: ${result === null ? "null" : typeof result}, isHtmxRequest: ${context.isHtmxRequest})`
3681
- );
3682
- }
3683
- if (context.isHtmxRequest) {
3684
- const notificationFragments = buildNotificationFragments(notifications);
3685
- const titleFragment = /* @__PURE__ */ jsx("title", { children: metadata.title });
3686
- const empty = isEmptyContent(result);
3687
- if (empty && notifications.length > 0 && !context.redirectUrl) {
3688
- return ctx.html(
3689
- /* @__PURE__ */ jsxs(Fragment, { children: [
3690
- notificationFragments,
3691
- titleFragment
3692
- ] }),
3693
- 200,
3694
- {
3695
- "HX-Reswap": "none"
3696
- // 关键:阻止 HTMX 替换任何内容,即使 body 上有 hx-target
3697
- }
3698
- );
3699
- }
3700
- const target = context.isDialog ? "#dialog-container" : "#main-content";
3701
- const swap = context.isDialog ? "innerHTML" : "outerHTML";
3702
- const headers = {
3703
- "HX-Retarget": target,
3704
- "HX-Reswap": swap
3705
- };
3706
- if (!context.isDialog) {
3707
- const hasError = notifications.some((n) => n.type === "error");
3708
- if (hasError) {
3709
- const referer = ctx.req.header("Referer");
3710
- if (referer) {
3711
- try {
3712
- const refererUrl = new URL(referer);
3713
- headers["HX-Push-Url"] = refererUrl.pathname + refererUrl.search;
3714
- } catch {
3715
- headers["HX-Push-Url"] = "false";
3716
- }
3717
- } else {
3718
- headers["HX-Push-Url"] = "false";
3719
- }
3720
- } else {
3721
- const url = new URL(ctx.req.url);
3722
- headers["HX-Push-Url"] = url.pathname + url.search;
3723
- }
3724
- }
3725
- const actions = await getActions(renderOptions.feature, context);
3726
- if (context.isDialog) {
3727
- const dialogSize = renderOptions.feature?.dialogSize || "lg";
3728
- const closeOnBackdropClick = renderOptions.feature?.closeOnBackdropClick ?? true;
3729
- const isFormFeature = renderOptions.feature?.type === "create" || renderOptions.feature?.type === "edit";
3730
- const fixedContentHeight = isFormFeature;
3731
- return ctx.html(
3732
- /* @__PURE__ */ jsxs(Fragment, { children: [
3733
- /* @__PURE__ */ jsx(
3734
- Dialog,
3735
- {
3736
- title: dynamicMetadata.title,
3737
- size: dialogSize,
3738
- closeOnBackdropClick,
3739
- actions,
3740
- fixedContentHeight,
3741
- children: result
3742
- }
3743
- ),
3744
- notificationFragments,
3745
- titleFragment
3746
- ] }),
3747
- 200,
3748
- headers
3749
- );
3750
- } else {
3751
- const useAdminLayout = metadata.useAdminLayout !== false;
3752
- if (useAdminLayout) {
3753
- return ctx.html(
3754
- /* @__PURE__ */ jsxs(Fragment, { children: [
3755
- /* @__PURE__ */ jsx(
3756
- AdminLayout,
3757
- {
3758
- title: dynamicMetadata.title,
3759
- description: dynamicMetadata.description,
3760
- options,
3761
- useAdminLayout,
3762
- currentPath,
3763
- userInfo: user,
3764
- breadcrumbs,
3765
- actions,
3766
- children: result
3767
- }
3768
- ),
3769
- notificationFragments,
3770
- titleFragment
3771
- ] }),
3772
- 200,
3773
- headers
3774
- );
3775
- } else {
3776
- return ctx.html(
3777
- /* @__PURE__ */ jsxs(Fragment, { children: [
3778
- /* @__PURE__ */ jsx(NoLayout, { children: result }),
3779
- notificationFragments,
3780
- titleFragment
3781
- ] }),
3782
- 200,
3783
- headers
3784
- );
3785
- }
3786
- }
3787
- } else {
3788
- const cdnProxyPrefix = `${options.prefix}/_cdn`;
3789
- const useAdminLayout = metadata.useAdminLayout !== false;
3790
- const actions = await getActions(renderOptions.feature, context);
3791
- if (useAdminLayout) {
3792
- return ctx.html(
3793
- /* @__PURE__ */ jsx(
3794
- BaseLayout,
3795
- {
3796
- title: dynamicMetadata.title,
3797
- description: dynamicMetadata.description,
3798
- cdnProxyPrefix,
3799
- children: /* @__PURE__ */ jsx(
3800
- AdminLayout,
3801
- {
3802
- title: dynamicMetadata.title,
3803
- description: dynamicMetadata.description,
3804
- options,
3805
- useAdminLayout,
3806
- currentPath,
3807
- userInfo: user,
3808
- breadcrumbs,
3809
- actions,
3810
- children: result
3811
- }
3812
- )
3813
- }
3814
- )
3815
- );
3816
- } else {
3817
- return ctx.html(
3818
- /* @__PURE__ */ jsx(
3819
- BaseLayout,
5020
+ return ctx.html(
5021
+ /* @__PURE__ */ jsx(
5022
+ BaseLayout,
5023
+ {
5024
+ prefix: options.prefix,
5025
+ title: `\u6743\u9650\u4E0D\u8DB3 - ${options.title}`,
5026
+ description: "\u60A8\u6CA1\u6709\u6743\u9650\u8BBF\u95EE\u6B64\u8D44\u6E90",
5027
+ children: /* @__PURE__ */ jsx(
5028
+ PermissionDeniedPage,
3820
5029
  {
3821
- title: dynamicMetadata.title,
3822
- description: dynamicMetadata.description,
3823
- cdnProxyPrefix,
3824
- children: /* @__PURE__ */ jsx(NoLayout, { children: result })
5030
+ operationId: result.operationId,
5031
+ fromPath: ctx.req.path,
5032
+ message: result.message,
5033
+ userInfo: user,
5034
+ pluginOptions: options
3825
5035
  }
3826
5036
  )
3827
- );
3828
- }
5037
+ }
5038
+ ),
5039
+ 403
5040
+ );
5041
+ }
5042
+ function buildNotificationFragments(notifications) {
5043
+ if (notifications.length === 0) {
5044
+ return null;
3829
5045
  }
5046
+ return notifications.map((notification, index) => /* @__PURE__ */ jsx(
5047
+ "div",
5048
+ {
5049
+ id: "error-container",
5050
+ "hx-swap-oob": "beforeend",
5051
+ children: /* @__PURE__ */ jsx(
5052
+ ErrorAlert,
5053
+ {
5054
+ type: notification.type,
5055
+ title: notification.title,
5056
+ message: notification.message
5057
+ }
5058
+ )
5059
+ },
5060
+ `notification-${index}`
5061
+ ));
3830
5062
  }
3831
- function buildBreadcrumbs(currentPath, homePath, pageTitle) {
3832
- const breadcrumbs = [];
3833
- if (homePath) {
3834
- if (currentPath === homePath) {
3835
- breadcrumbs.push({ label: pageTitle, href: void 0 });
3836
- } else {
3837
- breadcrumbs.push({ label: "\u9996\u9875", href: homePath });
3838
- breadcrumbs.push({ label: pageTitle, href: void 0 });
5063
+ function isEmptyContent(result) {
5064
+ if (result === null || result === void 0) return true;
5065
+ if (typeof result === "string" && result.trim() === "") return true;
5066
+ if (result && typeof result === "object" && "type" in result) {
5067
+ if (result.type === "div") {
5068
+ const props = result.props || {};
5069
+ const children = props.children;
5070
+ if (!children) return true;
5071
+ if (Array.isArray(children) && children.length === 0) return true;
5072
+ if (typeof children === "string" && children.trim() === "") return true;
3839
5073
  }
3840
- } else {
3841
- breadcrumbs.push({ label: pageTitle, href: void 0 });
3842
5074
  }
3843
- return breadcrumbs;
5075
+ return false;
3844
5076
  }
3845
- function getCurrentPath(ctx) {
3846
- const referer = ctx.req.header("Referer");
3847
- let currentPath = ctx.req.path;
3848
- if (referer) {
3849
- try {
3850
- const refererUrl = new URL(referer);
3851
- const method = ctx.req.method;
3852
- if (["POST", "PUT", "DELETE"].includes(method)) {
3853
- currentPath = refererUrl.pathname;
3854
- }
3855
- } catch (e) {
3856
- }
5077
+ async function isEmptyResponse(response) {
5078
+ const clonedResponse = response.clone();
5079
+ const text = await clonedResponse.text();
5080
+ return !text || text.trim() === "" || text.trim() === "<div></div>" || text.trim().match(/^<div[^>]*>\s*<\/div>$/) !== null;
5081
+ }
5082
+ async function getDynamicMetadata(feature, context, defaultMetadata) {
5083
+ if (feature?.getTitle) {
5084
+ const title = await feature.getTitle(context);
5085
+ const description = feature.getDescription ? await feature.getDescription(context) : defaultMetadata.description;
5086
+ return { title, description };
3857
5087
  }
3858
- return currentPath;
5088
+ return defaultMetadata;
3859
5089
  }
3860
- async function handleRequest(ctx, page, feature, handlerOptions) {
3861
- try {
3862
- logger.info(
3863
- `[HtmxAdminPlugin] Handling request: ${ctx.req.method} ${ctx.req.path} (feature: ${feature.name}, page: ${page.modelName})`
3864
- );
3865
- const user = await getUserInfo(ctx, handlerOptions.options.authProvider);
3866
- if (feature.permission !== null && handlerOptions.options.authProvider) {
3867
- const permissionResult = await checkPermission(
3868
- user,
3869
- feature.permission,
3870
- ctx,
3871
- {
3872
- authProvider: handlerOptions.options.authProvider,
3873
- prefix: handlerOptions.options.prefix,
3874
- title: handlerOptions.options.title
5090
+ async function getActions(feature, context) {
5091
+ if (feature?.getActions) {
5092
+ return await feature.getActions(context);
5093
+ }
5094
+ return null;
5095
+ }
5096
+ async function renderResult(ctx, context, result, renderOptions) {
5097
+ const { options, metadata, currentPath, breadcrumbs, user, feature } = renderOptions;
5098
+ const { notifications } = context;
5099
+ const dynamicMetadata = await getDynamicMetadata(feature, context, metadata);
5100
+ if (result instanceof Response) {
5101
+ const status = result.status;
5102
+ if (status === 302 || status === 301) {
5103
+ const location = result.headers.get("Location");
5104
+ if (location) {
5105
+ if (context.isHtmxRequest) {
5106
+ return ctx.html(/* @__PURE__ */ jsx("div", {}), 200, {
5107
+ "HX-Redirect": location
5108
+ });
3875
5109
  }
3876
- );
3877
- if (!permissionResult.allowed) {
3878
- return await handlePermissionDenied(ctx, permissionResult, {
3879
- authProvider: handlerOptions.options.authProvider,
3880
- prefix: handlerOptions.options.prefix,
3881
- title: handlerOptions.options.title
3882
- });
3883
- }
3884
- }
3885
- const context = await createFeatureContext(ctx, page, feature, user, {
3886
- prefix: handlerOptions.options.prefix
3887
- });
3888
- const metadata = page.getMetadata();
3889
- const currentPath = getCurrentPath(ctx);
3890
- const breadcrumbs = buildBreadcrumbs(
3891
- currentPath,
3892
- handlerOptions.options.homePath,
3893
- metadata.title
3894
- );
3895
- if (feature.handle) {
3896
- const result = await feature.handle(context);
3897
- if (result !== void 0) {
3898
- return await renderResult(ctx, context, result, {
3899
- options: handlerOptions.options,
3900
- metadata,
3901
- feature,
3902
- currentPath,
3903
- breadcrumbs,
3904
- user
3905
- });
5110
+ return result;
3906
5111
  }
3907
5112
  }
3908
- if (feature.render) {
3909
- const result = await feature.render(context);
3910
- return await renderResult(ctx, context, result, {
3911
- options: handlerOptions.options,
3912
- metadata,
3913
- feature,
3914
- currentPath,
3915
- breadcrumbs,
3916
- user
3917
- });
3918
- }
3919
- const isHtmxRequest = ctx.req.header("HX-Request") === "true";
3920
- if (isHtmxRequest) {
3921
- return ctx.html(
3922
- /* @__PURE__ */ jsxs(Fragment, { children: [
3923
- /* @__PURE__ */ jsx("div", { id: "error-container", "hx-swap-oob": "beforeend", children: /* @__PURE__ */ jsx(
3924
- ErrorAlert,
3925
- {
3926
- type: "error",
3927
- title: "\u8BF7\u6C42\u5931\u8D25",
3928
- message: "Feature has no handler or render method"
3929
- }
3930
- ) }),
3931
- /* @__PURE__ */ jsx("title", { children: "\u9519\u8BEF" })
3932
- ] }),
3933
- 500,
3934
- {
3935
- "HX-Reswap": "none"
3936
- // 关键:不替换任何内容
3937
- }
3938
- );
3939
- }
3940
- return ctx.json({ error: "Feature has no handler or render method" }, 500);
3941
- } catch (error) {
3942
- logger.error(`[HtmxAdminPlugin] Error handling request:`, error);
3943
- const isHtmxRequest = ctx.req.header("HX-Request") === "true";
3944
- if (isHtmxRequest) {
3945
- const errorMessage = error instanceof Error ? error.message : "Internal server error";
3946
- return ctx.html(
3947
- /* @__PURE__ */ jsxs(Fragment, { children: [
3948
- /* @__PURE__ */ jsx("div", { id: "error-container", "hx-swap-oob": "beforeend", children: /* @__PURE__ */ jsx(ErrorAlert, { type: "error", title: "\u8BF7\u6C42\u5931\u8D25", message: errorMessage }) }),
3949
- /* @__PURE__ */ jsx("title", { children: "\u9519\u8BEF" })
3950
- ] }),
3951
- 500,
3952
- {
3953
- "HX-Reswap": "none"
3954
- // 关键:不替换任何内容,只显示通知
5113
+ if (status >= 300 && status < 400) {
5114
+ const location = result.headers.get("Location");
5115
+ if (location) {
5116
+ if (context.isHtmxRequest) {
5117
+ return ctx.html(/* @__PURE__ */ jsx("div", {}), 200, {
5118
+ "HX-Redirect": location
5119
+ });
3955
5120
  }
3956
- );
3957
- }
3958
- return ctx.json(
3959
- {
3960
- error: error instanceof Error ? error.message : "Internal server error"
3961
- },
3962
- 500
3963
- );
3964
- }
3965
- }
3966
-
3967
- // src/internal/route-registrar.ts
3968
- function registerCdnCacheRoutes(options) {
3969
- const { prefix } = options.options;
3970
- options.hono.get(`${prefix}/_cdn/:name`, async (ctx) => {
3971
- const name = ctx.req.param("name");
3972
- const { getCachedResource: getCachedResource2 } = await Promise.resolve().then(() => (init_cdn_cache(), cdn_cache_exports));
3973
- const cached = getCachedResource2(name);
3974
- if (cached) {
3975
- return ctx.body(cached.content, 200, {
3976
- "Content-Type": cached.mimeType,
3977
- "Cache-Control": "public, max-age=31536000, immutable"
3978
- // 缓存 1 年
3979
- });
5121
+ return result;
5122
+ }
3980
5123
  }
3981
- return ctx.text(`Resource "${name}" not cached`, 404);
3982
- });
3983
- logger.info(`[HtmxAdminPlugin] CDN \u7F13\u5B58\u4EE3\u7406\u8DEF\u7531\u5DF2\u6CE8\u518C: ${prefix}/_cdn/:name`);
3984
- }
3985
- function registerPageRoutes(page, options) {
3986
- const basePath = `${options.options.prefix}${modelNameToPath(page.modelName)}`;
3987
- const features = page.features.getAll();
3988
- for (const feature of features) {
3989
- const routes = feature.getRoutes();
3990
- for (const route of routes) {
3991
- const fullPath = `${basePath}${route.path}`;
3992
- logger.info(
3993
- `[HtmxAdminPlugin] Registering route: ${route.method.toUpperCase()} ${fullPath} (feature: ${feature.name}, permission: ${feature.permission || "none"})`
3994
- );
3995
- const handler = async (ctx) => {
3996
- return handleRequest(ctx, page, feature, {
3997
- options: options.options
3998
- });
3999
- };
4000
- options.hono[route.method](fullPath, handler);
4001
- if (route.method === "put" || route.method === "delete") {
4002
- const postHandler = async (ctx) => {
4003
- const methodOverride = ctx.req.header("X-HTTP-Method-Override");
4004
- const expectedMethod = route.method.toUpperCase();
4005
- if (methodOverride === expectedMethod) {
4006
- logger.info(
4007
- `[HtmxAdminPlugin] Method override detected: POST ${fullPath} -> ${expectedMethod} (feature: ${feature.name})`
4008
- );
4009
- return handleRequest(ctx, page, feature, {
4010
- options: options.options
4011
- });
5124
+ if (notifications.length > 0) {
5125
+ const empty = await isEmptyResponse(result);
5126
+ if (empty) {
5127
+ const notificationFragments = buildNotificationFragments(notifications);
5128
+ const titleFragment = /* @__PURE__ */ jsx("title", { children: dynamicMetadata.title });
5129
+ return ctx.html(
5130
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5131
+ notificationFragments,
5132
+ titleFragment
5133
+ ] }),
5134
+ 200,
5135
+ {
5136
+ "HX-Reswap": "none"
5137
+ // 关键:阻止 HTMX 替换任何内容
4012
5138
  }
4013
- logger.warn(
4014
- `[HtmxAdminPlugin] POST request to ${fullPath} without matching X-HTTP-Method-Override header (got: ${methodOverride || "none"}, expected: ${expectedMethod})`
4015
- );
4016
- return ctx.text("Method Not Allowed", 405);
4017
- };
4018
- logger.info(
4019
- `[HtmxAdminPlugin] Registering POST route for method override: POST ${fullPath} (actual method: ${route.method.toUpperCase()}, feature: ${feature.name})`
4020
5139
  );
4021
- options.hono.post(fullPath, postHandler);
4022
5140
  }
4023
5141
  }
5142
+ return result;
4024
5143
  }
4025
- }
4026
- function registerHomeRedirect(pages, options) {
4027
- const { prefix, homePath } = options.options;
4028
- if (homePath) {
4029
- options.hono.get(prefix, async (ctx) => {
4030
- return ctx.redirect(homePath);
4031
- });
4032
- } else if (pages.size > 0) {
4033
- const firstPage = Array.from(pages.values())[0];
4034
- const firstPath = `${prefix}${modelNameToPath(firstPage.modelName)}`;
4035
- options.hono.get(prefix, async (ctx) => {
4036
- return ctx.redirect(firstPath);
4037
- });
4038
- }
4039
- }
4040
-
4041
- // src/plugin.tsx
4042
- init_cdn_cache();
4043
- var HtmxAdminPlugin = class {
4044
- name = "htmx-admin-plugin";
4045
- priority = PluginPriority.ROUTE;
4046
- engine;
4047
- hono;
4048
- options;
4049
- serviceName = "";
4050
- pages = /* @__PURE__ */ new Map();
4051
- constructor(options) {
4052
- this.options = {
4053
- title: options?.title || "\u7BA1\u7406\u540E\u53F0",
4054
- logo: options?.logo || "",
4055
- prefix: options?.prefix || "/admin",
4056
- homePath: options?.homePath || "",
4057
- navigation: options?.navigation ?? [],
4058
- authProvider: options?.authProvider
5144
+ if (context.refresh) {
5145
+ logger.info(
5146
+ `[ResponseRenderer] Refresh requested (isDialog: ${context.isDialog}, isHtmxRequest: ${context.isHtmxRequest})`
5147
+ );
5148
+ const headers = {
5149
+ "HX-Refresh": "true"
4059
5150
  };
4060
- }
4061
- /**
4062
- * 注册页面(统一接口,支持有模型和无模型)
4063
- */
4064
- registerPage(page) {
4065
- if (this.pages.has(page.modelName)) {
4066
- throw new Error(
4067
- `Page with name "${page.modelName}" is already registered`
5151
+ const notificationFragments = buildNotificationFragments(notifications);
5152
+ if (context.isDialog) {
5153
+ return ctx.html(
5154
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5155
+ /* @__PURE__ */ jsx("div", { id: "dialog-container", "hx-swap-oob": "innerHTML" }),
5156
+ notificationFragments,
5157
+ /* @__PURE__ */ jsx("title", { children: metadata.title })
5158
+ ] }),
5159
+ 200,
5160
+ headers
4068
5161
  );
4069
5162
  }
4070
- this.pages.set(page.modelName, page);
4071
- logger.info(`[HtmxAdminPlugin] Registered page: ${page.modelName}`);
4072
- return this;
5163
+ return ctx.html(
5164
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5165
+ /* @__PURE__ */ jsx("div", {}),
5166
+ notificationFragments,
5167
+ /* @__PURE__ */ jsx("title", { children: metadata.title })
5168
+ ] }),
5169
+ 200,
5170
+ headers
5171
+ );
4073
5172
  }
4074
- /**
4075
- * 批量注册页面
4076
- */
4077
- registerPages(...pages) {
4078
- for (const page of pages) {
4079
- this.registerPage(page);
5173
+ if (context.redirectUrl && !context.refresh) {
5174
+ logger.info(
5175
+ `[ResponseRenderer] Redirect URL found: ${context.redirectUrl} (isHtmxRequest: ${context.isHtmxRequest}, isDialog: ${context.isDialog})`
5176
+ );
5177
+ if (context.isHtmxRequest) {
5178
+ return ctx.html(/* @__PURE__ */ jsx("div", {}), 200, {
5179
+ "HX-Redirect": context.redirectUrl
5180
+ });
5181
+ } else {
5182
+ return ctx.redirect(context.redirectUrl);
4080
5183
  }
4081
- return this;
4082
- }
4083
- /**
4084
- * 引擎初始化钩子
4085
- */
4086
- onInit(engine) {
4087
- this.engine = engine;
4088
- this.hono = engine.getHono();
4089
- this.serviceName = engine.options.name;
5184
+ } else if (context.redirectUrl && context.refresh) {
4090
5185
  logger.info(
4091
- `HtmxAdminPlugin initialized${this.serviceName ? ` (service: ${this.serviceName})` : ""}`
5186
+ `[ResponseRenderer] Both redirect URL and refresh are set, using refresh (isDialog: ${context.isDialog})`
5187
+ );
5188
+ } else if (!context.redirectUrl) {
5189
+ logger.info(
5190
+ `[ResponseRenderer] No redirect URL found (result: ${result === null ? "null" : typeof result}, isHtmxRequest: ${context.isHtmxRequest})`
4092
5191
  );
4093
- initializeCdnCache().catch((error) => {
4094
- logger.error("[HtmxAdminPlugin] CDN \u7F13\u5B58\u521D\u59CB\u5316\u5931\u8D25", error);
4095
- });
4096
5192
  }
4097
- /**
4098
- * 引擎启动后注册路由
4099
- */
4100
- onAfterStart(engine) {
4101
- const routeOptions = {
4102
- options: this.options,
4103
- hono: this.hono
5193
+ if (context.isHtmxRequest) {
5194
+ const notificationFragments = buildNotificationFragments(notifications);
5195
+ const titleFragment = /* @__PURE__ */ jsx("title", { children: metadata.title });
5196
+ const empty = isEmptyContent(result);
5197
+ if (empty && notifications.length > 0 && !context.redirectUrl) {
5198
+ return ctx.html(
5199
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5200
+ notificationFragments,
5201
+ titleFragment
5202
+ ] }),
5203
+ 200,
5204
+ {
5205
+ "HX-Reswap": "none"
5206
+ // 关键:阻止 HTMX 替换任何内容,即使 body 上有 hx-target
5207
+ }
5208
+ );
5209
+ }
5210
+ const target = context.isDialog ? "#dialog-container" : "#main-content";
5211
+ const swap = context.isDialog ? "innerHTML" : "outerHTML";
5212
+ const headers = {
5213
+ "HX-Retarget": target,
5214
+ "HX-Reswap": swap
4104
5215
  };
4105
- registerCdnCacheRoutes(routeOptions);
4106
- for (const [modelName, page] of this.pages) {
4107
- registerPageRoutes(page, routeOptions);
5216
+ if (!context.isDialog) {
5217
+ const hasError = notifications.some((n) => n.type === "error");
5218
+ if (hasError) {
5219
+ const referer = ctx.req.header("Referer");
5220
+ if (referer) {
5221
+ try {
5222
+ const refererUrl = new URL(referer);
5223
+ headers["HX-Push-Url"] = refererUrl.pathname + refererUrl.search;
5224
+ } catch {
5225
+ headers["HX-Push-Url"] = "false";
5226
+ }
5227
+ } else {
5228
+ headers["HX-Push-Url"] = "false";
5229
+ }
5230
+ } else {
5231
+ const url = new URL(ctx.req.url);
5232
+ headers["HX-Push-Url"] = url.pathname + url.search;
5233
+ }
5234
+ }
5235
+ const actions = await getActions(renderOptions.feature, context);
5236
+ if (context.isDialog) {
5237
+ const dialogSize = renderOptions.feature?.dialogSize || "lg";
5238
+ const closeOnBackdropClick = renderOptions.feature?.closeOnBackdropClick ?? true;
5239
+ const isFormFeature = renderOptions.feature?.type === "create" || renderOptions.feature?.type === "edit";
5240
+ const fixedContentHeight = isFormFeature;
5241
+ return ctx.html(
5242
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5243
+ /* @__PURE__ */ jsx(
5244
+ Dialog,
5245
+ {
5246
+ title: dynamicMetadata.title,
5247
+ size: dialogSize,
5248
+ closeOnBackdropClick,
5249
+ actions,
5250
+ fixedContentHeight,
5251
+ children: result
5252
+ }
5253
+ ),
5254
+ notificationFragments,
5255
+ titleFragment
5256
+ ] }),
5257
+ 200,
5258
+ headers
5259
+ );
5260
+ } else {
5261
+ const useAdminLayout = metadata.useAdminLayout !== false;
5262
+ if (useAdminLayout) {
5263
+ return ctx.html(
5264
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5265
+ /* @__PURE__ */ jsx(
5266
+ AdminLayout,
5267
+ {
5268
+ title: dynamicMetadata.title,
5269
+ description: dynamicMetadata.description,
5270
+ options,
5271
+ useAdminLayout,
5272
+ currentPath,
5273
+ userInfo: user,
5274
+ componentRegistry: renderOptions.componentRegistry,
5275
+ breadcrumbs,
5276
+ actions,
5277
+ children: result
5278
+ }
5279
+ ),
5280
+ notificationFragments,
5281
+ titleFragment
5282
+ ] }),
5283
+ 200,
5284
+ headers
5285
+ );
5286
+ } else {
5287
+ return ctx.html(
5288
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5289
+ /* @__PURE__ */ jsx(NoLayout, { children: result }),
5290
+ notificationFragments,
5291
+ titleFragment
5292
+ ] }),
5293
+ 200,
5294
+ headers
5295
+ );
5296
+ }
5297
+ }
5298
+ } else {
5299
+ `${options.prefix}/_cdn`;
5300
+ const useAdminLayout = metadata.useAdminLayout !== false;
5301
+ const actions = await getActions(renderOptions.feature, context);
5302
+ if (useAdminLayout) {
5303
+ return ctx.html(
5304
+ /* @__PURE__ */ jsx(
5305
+ BaseLayout,
5306
+ {
5307
+ prefix: options.prefix,
5308
+ title: dynamicMetadata.title,
5309
+ description: dynamicMetadata.description,
5310
+ componentRegistry: renderOptions.componentRegistry,
5311
+ children: /* @__PURE__ */ jsx(
5312
+ AdminLayout,
5313
+ {
5314
+ title: dynamicMetadata.title,
5315
+ description: dynamicMetadata.description,
5316
+ options,
5317
+ useAdminLayout,
5318
+ currentPath,
5319
+ userInfo: user,
5320
+ breadcrumbs,
5321
+ actions,
5322
+ componentRegistry: renderOptions.componentRegistry,
5323
+ children: result
5324
+ }
5325
+ )
5326
+ }
5327
+ )
5328
+ );
5329
+ } else {
5330
+ return ctx.html(
5331
+ /* @__PURE__ */ jsx(
5332
+ BaseLayout,
5333
+ {
5334
+ prefix: options.prefix,
5335
+ title: dynamicMetadata.title,
5336
+ description: dynamicMetadata.description,
5337
+ componentRegistry: renderOptions.componentRegistry,
5338
+ children: /* @__PURE__ */ jsx(NoLayout, { children: result })
5339
+ }
5340
+ )
5341
+ );
4108
5342
  }
4109
- registerHomeRedirect(this.pages, routeOptions);
4110
5343
  }
4111
- };
4112
- function ObjectEditor(props) {
4113
- const { value, fieldName, objectSchema } = props;
4114
- if (!objectSchema) {
4115
- return /* @__PURE__ */ jsx("div", { className: "p-4 border border-yellow-300 rounded-lg bg-yellow-50 text-yellow-800 text-sm", children: "\u8BF7\u63D0\u4F9B objectSchema \u53C2\u6570\u4EE5\u4F7F\u7528\u5BF9\u8C61\u7F16\u8F91\u5668" });
5344
+ }
5345
+ function buildBreadcrumbs(currentPath, homePath, pageTitle) {
5346
+ const breadcrumbs = [];
5347
+ if (homePath) {
5348
+ if (currentPath === homePath) {
5349
+ breadcrumbs.push({ label: pageTitle, href: void 0 });
5350
+ } else {
5351
+ breadcrumbs.push({ label: "\u9996\u9875", href: homePath });
5352
+ breadcrumbs.push({ label: pageTitle, href: void 0 });
5353
+ }
5354
+ } else {
5355
+ breadcrumbs.push({ label: pageTitle, href: void 0 });
4116
5356
  }
4117
- const fields = parseSchemaToFields(objectSchema);
4118
- const initialObject = value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
4119
- fields.forEach((field) => {
4120
- if (!(field.name in initialObject)) {
4121
- if (field.type === "number") {
4122
- initialObject[field.name] = field.required ? 0 : void 0;
4123
- } else if (field.type === "checkbox") {
4124
- initialObject[field.name] = field.required ? false : void 0;
4125
- } else {
4126
- initialObject[field.name] = field.required ? "" : void 0;
5357
+ return breadcrumbs;
5358
+ }
5359
+ function getCurrentPath(ctx) {
5360
+ const referer = ctx.req.header("Referer");
5361
+ let currentPath = ctx.req.path;
5362
+ if (referer) {
5363
+ try {
5364
+ const refererUrl = new URL(referer);
5365
+ const method = ctx.req.method;
5366
+ if (["POST", "PUT", "DELETE"].includes(method)) {
5367
+ currentPath = refererUrl.pathname;
4127
5368
  }
5369
+ } catch (e) {
4128
5370
  }
4129
- });
4130
- const fieldNames = fields.map((f) => f.name);
4131
- const fieldNamesJson = JSON.stringify(fieldNames);
4132
- const initialValueJson = JSON.stringify(initialObject);
4133
- const xDataContent = `{
4134
- obj: {},
4135
- init() {
4136
- const dataAttr = this.$el.getAttribute('data-initial-value');
4137
- if (dataAttr) {
4138
- try {
4139
- this.obj = JSON.parse(dataAttr);
4140
- } catch (e) {
4141
- console.error('Failed to parse initial value:', e);
4142
- this.obj = {};
4143
- }
4144
- }
4145
- const fieldNames = ${fieldNamesJson};
4146
- fieldNames.forEach(fieldName => {
4147
- if (!(fieldName in this.obj)) {
4148
- this.obj[fieldName] = undefined;
5371
+ }
5372
+ return currentPath;
5373
+ }
5374
+ async function handleRequest(ctx, page, feature, handlerOptions) {
5375
+ try {
5376
+ logger.info(
5377
+ `[HtmxAdminPlugin] Handling request: ${ctx.req.method} ${ctx.req.path} (feature: ${feature.name}, page: ${page.modelName})`
5378
+ );
5379
+ const user = await getUserInfo(ctx, handlerOptions.options.authProvider);
5380
+ if (feature.permission !== null && handlerOptions.options.authProvider) {
5381
+ const permissionResult = await checkPermission(
5382
+ user,
5383
+ feature.permission,
5384
+ ctx,
5385
+ {
5386
+ authProvider: handlerOptions.options.authProvider,
5387
+ prefix: handlerOptions.options.prefix,
5388
+ title: handlerOptions.options.title
4149
5389
  }
4150
- });
4151
- this.updateHiddenField();
4152
- },
4153
- updateHiddenField() {
4154
- const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
4155
- if (hiddenInput) {
4156
- hiddenInput.value = JSON.stringify(this.obj);
4157
- }
4158
- },
4159
- updateField(fieldName, value, fieldType, required) {
4160
- let convertedValue = value;
4161
- if (fieldType === 'number') {
4162
- convertedValue = value === '' ? (required ? 0 : undefined) : Number(value);
4163
- if (isNaN(convertedValue)) convertedValue = required ? 0 : undefined;
4164
- } else if (fieldType === 'checkbox') {
4165
- convertedValue = value === 'true' || value === true || value === '1' || value === 1;
4166
- } else {
4167
- convertedValue = value || (required ? '' : undefined);
4168
- }
4169
- if (convertedValue === undefined && !required) {
4170
- delete this.obj[fieldName];
4171
- } else {
4172
- this.obj[fieldName] = convertedValue;
5390
+ );
5391
+ if (!permissionResult.allowed) {
5392
+ return await handlePermissionDenied(ctx, permissionResult, {
5393
+ authProvider: handlerOptions.options.authProvider,
5394
+ prefix: handlerOptions.options.prefix,
5395
+ title: handlerOptions.options.title
5396
+ });
4173
5397
  }
4174
- this.updateHiddenField();
4175
5398
  }
4176
- }`;
4177
- const generateField = (field) => {
4178
- const fieldId = `${fieldName}-${field.name}`;
4179
- const fieldNameVar = `obj.${field.name}`;
4180
- const requiredValue = field.required ? "true" : "false";
4181
- const fieldNameForJs = JSON.stringify(field.name);
4182
- let inputElement;
4183
- if (field.type === "text") {
4184
- inputElement = html`
4185
- <input
4186
- type="text"
4187
- id="${fieldId}"
4188
- x-bind:value="${fieldNameVar} || ''"
4189
- x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4190
- class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4191
- data-testid="${fieldName}-input-${field.name}"
4192
- ${field.required ? "required" : ""}
4193
- />
4194
- `;
4195
- } else if (field.type === "textarea") {
4196
- inputElement = html`
4197
- <textarea
4198
- id="${fieldId}"
4199
- x-bind:value="${fieldNameVar} || ''"
4200
- x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4201
- rows="4"
4202
- class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y"
4203
- data-testid="${fieldName}-input-${field.name}"
4204
- ${field.required ? "required" : ""}
4205
- ></textarea>
4206
- `;
4207
- } else if (field.type === "number") {
4208
- const step = field.step || (field.step === void 0 ? "1" : "any");
4209
- inputElement = html`
4210
- <input
4211
- type="number"
4212
- id="${fieldId}"
4213
- x-bind:value="${fieldNameVar} != null ? ${fieldNameVar} : ''"
4214
- x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'number', ${requiredValue})"
4215
- step="${step}"
4216
- class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4217
- data-testid="${fieldName}-input-${field.name}"
4218
- ${field.required ? "required" : ""}
4219
- />
4220
- `;
4221
- } else if (field.type === "date") {
4222
- inputElement = html`
4223
- <input
4224
- type="date"
4225
- id="${fieldId}"
4226
- x-bind:value="${fieldNameVar} || ''"
4227
- x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'date', ${requiredValue})"
4228
- class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4229
- data-testid="${fieldName}-input-${field.name}"
4230
- ${field.required ? "required" : ""}
4231
- />
4232
- `;
4233
- } else if (field.type === "email") {
4234
- inputElement = html`
4235
- <input
4236
- type="email"
4237
- id="${fieldId}"
4238
- x-bind:value="${fieldNameVar} || ''"
4239
- x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4240
- class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4241
- data-testid="${fieldName}-input-${field.name}"
4242
- ${field.required ? "required" : ""}
4243
- />
4244
- `;
4245
- } else if (field.type === "select" && field.options) {
4246
- inputElement = html`
4247
- <select
4248
- id="${fieldId}"
4249
- x-bind:value="${fieldNameVar} || ''"
4250
- x-on:change="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4251
- class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
4252
- data-testid="${fieldName}-select-${field.name}"
4253
- ${field.required ? "required" : ""}
4254
- >
4255
- ${!field.required ? html`<option value="">请选择</option>` : ""}
4256
- ${field.options.map(
4257
- (option) => html`
4258
- <option value="${String(option.value)}">${option.label}</option>
4259
- `
4260
- )}
4261
- </select>
4262
- `;
4263
- } else if (field.type === "checkbox") {
4264
- inputElement = html`
4265
- <div class="flex items-center">
4266
- <input
4267
- type="checkbox"
4268
- id="${fieldId}"
4269
- x-bind:checked="${fieldNameVar} === true || ${fieldNameVar} === 'true' || ${fieldNameVar} === 1 || ${fieldNameVar} === '1'"
4270
- x-on:change="updateField(${fieldNameForJs}, $event.target.checked, 'checkbox', ${requiredValue})"
4271
- class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
4272
- data-testid="${fieldName}-checkbox-${field.name}"
4273
- />
4274
- <label for="${fieldId}" class="ml-2 text-sm text-gray-700">
4275
- ${field.label}
4276
- </label>
4277
- </div>
4278
- `;
5399
+ const context = await createFeatureContext(ctx, page, feature, user, {
5400
+ prefix: handlerOptions.options.prefix
5401
+ });
5402
+ const metadata = page.getMetadata();
5403
+ const currentPath = getCurrentPath(ctx);
5404
+ const breadcrumbs = buildBreadcrumbs(
5405
+ currentPath,
5406
+ handlerOptions.options.homePath,
5407
+ metadata.title
5408
+ );
5409
+ if (feature.handle) {
5410
+ const result = await feature.handle(context);
5411
+ if (result !== void 0) {
5412
+ return await renderResult(ctx, context, result, {
5413
+ options: handlerOptions.options,
5414
+ metadata,
5415
+ feature,
5416
+ currentPath,
5417
+ breadcrumbs,
5418
+ user
5419
+ });
5420
+ }
4279
5421
  }
4280
- return html`
4281
- <div class="space-y-2" data-testid="${fieldName}-field-${field.name}">
4282
- ${field.type !== "checkbox" ? html`
4283
- <label
4284
- for="${fieldId}"
4285
- class="block text-sm font-semibold text-gray-700"
4286
- data-testid="${fieldName}-label-${field.name}"
4287
- >
4288
- ${field.label}
4289
- ${field.required ? html`<span class="text-red-500 ml-1">*</span>` : ""}
4290
- </label>
4291
- ` : ""}
4292
- ${inputElement}
4293
- </div>
4294
- `;
4295
- };
4296
- return html`
4297
- <div
4298
- x-data="${xDataContent}"
4299
- data-initial-value="${initialValueJson}"
4300
- x-init="init()"
4301
- class="space-y-4"
4302
- >
4303
- <input
4304
- type="hidden"
4305
- name="${fieldName}"
4306
- value=""
4307
- data-testid="hidden-${fieldName}"
4308
- />
4309
- <div class="space-y-4">
4310
- ${fields.map((field) => generateField(field))}
4311
- </div>
4312
- </div>
4313
- `;
4314
- }
4315
- function StringArrayEditor(props) {
4316
- const {
4317
- value,
4318
- fieldName,
4319
- placeholder = "\u8BF7\u8F93\u5165\u5185\u5BB9",
4320
- allowEmpty = false
4321
- } = props;
4322
- const initialItems = value || [];
4323
- const initialValueJson = JSON.stringify(initialItems);
4324
- const xDataContent = `{
4325
- items: ${initialValueJson},
4326
- draggedIndex: null,
4327
- draggedOverIndex: null,
4328
- fieldName: ${JSON.stringify(fieldName)},
4329
- placeholder: ${JSON.stringify(placeholder)},
4330
- allowEmpty: ${allowEmpty},
4331
- init() {
4332
- const dataAttr = this.$el.getAttribute('data-initial-value');
4333
- if (dataAttr) {
4334
- try {
4335
- const parsed = JSON.parse(dataAttr);
4336
- if (Array.isArray(parsed)) {
4337
- this.items = parsed;
4338
- } else {
4339
- this.items = [];
4340
- }
4341
- } catch (e) {
4342
- console.error('Failed to parse initial value:', e);
4343
- this.items = [];
5422
+ const isHtmxRequest = ctx.req.header("HX-Request") === "true";
5423
+ if (isHtmxRequest) {
5424
+ return ctx.html(
5425
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5426
+ /* @__PURE__ */ jsx("div", { id: "error-container", "hx-swap-oob": "beforeend", children: /* @__PURE__ */ jsx(
5427
+ ErrorAlert,
5428
+ {
5429
+ type: "error",
5430
+ title: "\u8BF7\u6C42\u5931\u8D25",
5431
+ message: "Feature has no handler or render method"
5432
+ }
5433
+ ) }),
5434
+ /* @__PURE__ */ jsx("title", { children: "\u9519\u8BEF" })
5435
+ ] }),
5436
+ 500,
5437
+ {
5438
+ "HX-Reswap": "none"
5439
+ // 关键:不替换任何内容
4344
5440
  }
4345
- }
4346
- this.updateHiddenField();
4347
- },
4348
- updateHiddenField() {
4349
- const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
4350
- if (hiddenInput) {
4351
- hiddenInput.value = JSON.stringify(this.items);
4352
- }
4353
- },
4354
- addItem() {
4355
- this.items.push('');
4356
- this.updateHiddenField();
4357
- this.$nextTick(() => {
4358
- const keyInputs = this.$el.querySelectorAll('input[data-testid*="-input-"]');
4359
- if (keyInputs.length > 0) {
4360
- const lastInput = keyInputs[keyInputs.length - 1];
4361
- if (lastInput && lastInput.focus) {
4362
- lastInput.focus();
4363
- }
5441
+ );
5442
+ }
5443
+ return ctx.json({ error: "Feature has no handler or render method" }, 500);
5444
+ } catch (error) {
5445
+ logger.error(`[HtmxAdminPlugin] Error handling request:`, error);
5446
+ const isHtmxRequest = ctx.req.header("HX-Request") === "true";
5447
+ if (isHtmxRequest) {
5448
+ const errorMessage = error instanceof Error ? error.message : "Internal server error";
5449
+ return ctx.html(
5450
+ /* @__PURE__ */ jsxs(Fragment, { children: [
5451
+ /* @__PURE__ */ jsx("div", { id: "error-container", "hx-swap-oob": "beforeend", children: /* @__PURE__ */ jsx(ErrorAlert, { type: "error", title: "\u8BF7\u6C42\u5931\u8D25", message: errorMessage }) }),
5452
+ /* @__PURE__ */ jsx("title", { children: "\u9519\u8BEF" })
5453
+ ] }),
5454
+ 500,
5455
+ {
5456
+ "HX-Reswap": "none"
5457
+ // 关键:不替换任何内容,只显示通知
4364
5458
  }
4365
- });
4366
- },
4367
- removeItem(index) {
4368
- this.items.splice(index, 1);
4369
- this.updateHiddenField();
4370
- },
4371
- updateItem(index, value) {
4372
- this.items[index] = value;
4373
- this.updateHiddenField();
4374
- },
4375
- handleDragStart(index, event) {
4376
- this.draggedIndex = index;
4377
- event.dataTransfer.effectAllowed = 'move';
4378
- event.dataTransfer.setData('text/plain', index.toString());
4379
- const target = event.currentTarget || event.target.closest('[draggable="true"]');
4380
- if (target) {
4381
- target.style.opacity = '0.5';
4382
- }
4383
- },
4384
- handleDragEnd(event) {
4385
- const target = event.currentTarget || event.target.closest('[draggable="true"]');
4386
- if (target) {
4387
- target.style.opacity = '';
4388
- }
4389
- this.draggedIndex = null;
4390
- this.draggedOverIndex = null;
4391
- },
4392
- handleDragOver(index, event) {
4393
- event.preventDefault();
4394
- event.dataTransfer.dropEffect = 'move';
4395
- this.draggedOverIndex = index;
4396
- },
4397
- handleDragLeave() {
4398
- this.draggedOverIndex = null;
4399
- },
4400
- handleDrop(index, event) {
4401
- event.preventDefault();
4402
- if (this.draggedIndex !== null && this.draggedIndex !== index) {
4403
- const draggedItem = this.items[this.draggedIndex];
4404
- this.items.splice(this.draggedIndex, 1);
4405
- this.items.splice(index, 0, draggedItem);
4406
- this.updateHiddenField();
4407
- }
4408
- this.draggedIndex = null;
4409
- this.draggedOverIndex = null;
5459
+ );
4410
5460
  }
4411
- }`;
4412
- return html`
4413
- <div
4414
- x-data="${xDataContent}"
4415
- data-initial-value="${initialValueJson}"
4416
- x-init="init()"
4417
- class="space-y-3"
4418
- >
4419
- <input
4420
- type="hidden"
4421
- name="${fieldName}"
4422
- value=""
4423
- data-testid="hidden-${fieldName}"
4424
- />
4425
- <div class="space-y-3">
4426
- <!-- 头部:显示数量和添加按钮 -->
4427
- <div class="flex items-center justify-between">
4428
- <span class="text-sm text-gray-600">
4429
- 共 <span x-text="items.length">0</span> 项
4430
- </span>
4431
- <button
4432
- type="button"
4433
- x-on:click="addItem()"
4434
- class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
4435
- data-testid="${fieldName}-add-button"
4436
- >
4437
- <svg
4438
- class="w-4 h-4"
4439
- fill="none"
4440
- stroke="currentColor"
4441
- viewBox="0 0 24 24"
4442
- >
4443
- <path
4444
- stroke-linecap="round"
4445
- stroke-linejoin="round"
4446
- stroke-width="2"
4447
- d="M12 4v16m8-8H4"
4448
- />
4449
- </svg>
4450
- 添加项
4451
- </button>
4452
- </div>
4453
-
4454
- <!-- 列表项 -->
4455
- <div class="space-y-2" x-show="items.length > 0">
4456
- <template x-for="(item, index) in items" x-bind:key="index">
4457
- <div
4458
- class="flex items-center gap-2 group"
4459
- x-bind:class="{
4460
- 'opacity-50': draggedIndex === index,
4461
- 'border-blue-300 bg-blue-50': draggedOverIndex === index && draggedIndex !== null && draggedIndex !== index
4462
- }"
4463
- draggable="true"
4464
- x-on:dragstart="handleDragStart(index, $event)"
4465
- x-on:dragend="handleDragEnd($event)"
4466
- x-on:dragover="handleDragOver(index, $event)"
4467
- x-on:dragleave="handleDragLeave()"
4468
- x-on:drop="handleDrop(index, $event)"
4469
- x-bind:data-testid="fieldName + '-item-' + index"
4470
- >
4471
- <!-- 拖拽手柄 -->
4472
- <div
4473
- class="flex-shrink-0 cursor-move text-gray-400 hover:text-gray-600 transition-colors p-1"
4474
- x-bind:data-testid="fieldName + '-drag-handle-' + index"
4475
- title="拖拽排序"
4476
- >
4477
- <svg
4478
- class="w-5 h-5"
4479
- fill="none"
4480
- stroke="currentColor"
4481
- viewBox="0 0 24 24"
4482
- >
4483
- <path
4484
- stroke-linecap="round"
4485
- stroke-linejoin="round"
4486
- stroke-width="2"
4487
- d="M4 8h16M4 16h16"
4488
- />
4489
- </svg>
4490
- </div>
4491
-
4492
- <!-- 输入框 -->
4493
- <input
4494
- type="text"
4495
- x-bind:value="items[index] || ''"
4496
- x-on:input="updateItem(index, $event.target.value)"
4497
- x-bind:placeholder="placeholder + ' ' + (index + 1)"
4498
- class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4499
- x-bind:data-testid="fieldName + '-input-' + index"
4500
- x-bind:required="!allowEmpty"
4501
- />
4502
-
4503
- <!-- 删除按钮 -->
4504
- <button
4505
- type="button"
4506
- x-on:click="removeItem(index)"
4507
- class="flex-shrink-0 px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
4508
- x-bind:data-testid="fieldName + '-remove-button-' + index"
4509
- title="删除此项"
4510
- >
4511
- <svg
4512
- class="w-5 h-5"
4513
- fill="none"
4514
- stroke="currentColor"
4515
- viewBox="0 0 24 24"
4516
- >
4517
- <path
4518
- stroke-linecap="round"
4519
- stroke-linejoin="round"
4520
- stroke-width="2"
4521
- d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
4522
- />
4523
- </svg>
4524
- </button>
4525
- </div>
4526
- </template>
4527
- </div>
5461
+ return ctx.json(
5462
+ {
5463
+ error: error instanceof Error ? error.message : "Internal server error"
5464
+ },
5465
+ 500
5466
+ );
5467
+ }
5468
+ }
4528
5469
 
4529
- <!-- 空状态提示 -->
4530
- <div
4531
- x-show="items.length === 0"
4532
- class="text-center py-8 text-gray-400 text-sm border border-dashed border-gray-300 rounded-lg"
4533
- >
4534
- 暂无项,点击"添加项"按钮添加
4535
- </div>
4536
- </div>
4537
- </div>
4538
- `;
5470
+ // src/internal/route-registrar.ts
5471
+ function registerCdnCacheRoutes(options) {
5472
+ const { prefix } = options.options;
5473
+ options.hono.get(`${prefix}/_cdn/:name`, async (ctx) => {
5474
+ const name = ctx.req.param("name");
5475
+ const { getCachedResource: getCachedResource2 } = await Promise.resolve().then(() => (init_cdn_cache(), cdn_cache_exports));
5476
+ const cached = getCachedResource2(name);
5477
+ if (cached) {
5478
+ return ctx.body(cached.content, 200, {
5479
+ "Content-Type": cached.mimeType,
5480
+ "Cache-Control": "public, max-age=31536000, immutable"
5481
+ // 缓存 1 年
5482
+ });
5483
+ }
5484
+ return ctx.text(`Resource "${name}" not cached`, 404);
5485
+ });
5486
+ logger.info(`[HtmxAdminPlugin] CDN \u7F13\u5B58\u4EE3\u7406\u8DEF\u7531\u5DF2\u6CE8\u518C: ${prefix}/_cdn/:name`);
4539
5487
  }
4540
- function TagsEditor(props) {
4541
- const {
4542
- value,
4543
- fieldName,
4544
- placeholder = "\u8F93\u5165\u6807\u7B7E\u540E\u6309\u56DE\u8F66\u6DFB\u52A0",
4545
- allowEmpty = false
4546
- } = props;
4547
- const initialItems = value || [];
4548
- const initialValueJson = JSON.stringify(initialItems);
4549
- const xDataContent = `{
4550
- items: ${initialValueJson},
4551
- draggedIndex: null,
4552
- draggedOverIndex: null,
4553
- newTag: '',
4554
- editingIndex: null,
4555
- editingValue: '',
4556
- fieldName: ${JSON.stringify(fieldName)},
4557
- placeholder: ${JSON.stringify(placeholder)},
4558
- allowEmpty: ${allowEmpty},
4559
- init() {
4560
- const dataAttr = this.$el.getAttribute('data-initial-value');
4561
- if (dataAttr) {
4562
- try {
4563
- const parsed = JSON.parse(dataAttr);
4564
- if (Array.isArray(parsed)) {
4565
- this.items = parsed;
4566
- } else {
4567
- this.items = [];
5488
+ function registerPageRoutes(page, options) {
5489
+ const basePath = `${options.options.prefix}${modelNameToPath(page.modelName)}`;
5490
+ const features = page.features.getAll();
5491
+ for (const feature of features) {
5492
+ const routes = feature.getRoutes();
5493
+ for (const route of routes) {
5494
+ const fullPath = `${basePath}${route.path}`;
5495
+ logger.info(
5496
+ `[HtmxAdminPlugin] Registering route: ${route.method.toUpperCase()} ${fullPath} (feature: ${feature.name}, permission: ${feature.permission || "none"})`
5497
+ );
5498
+ const handler = async (ctx) => {
5499
+ return handleRequest(ctx, page, feature, {
5500
+ options: options.options,
5501
+ plugin: options.plugin
5502
+ });
5503
+ };
5504
+ options.hono[route.method](fullPath, handler);
5505
+ if (route.method === "put" || route.method === "delete") {
5506
+ const postHandler = async (ctx) => {
5507
+ const methodOverride = ctx.req.header("X-HTTP-Method-Override");
5508
+ const expectedMethod = route.method.toUpperCase();
5509
+ if (methodOverride === expectedMethod) {
5510
+ logger.info(
5511
+ `[HtmxAdminPlugin] Method override detected: POST ${fullPath} -> ${expectedMethod} (feature: ${feature.name})`
5512
+ );
5513
+ return handleRequest(ctx, page, feature, {
5514
+ options: options.options,
5515
+ plugin: options.plugin
5516
+ });
4568
5517
  }
4569
- } catch (e) {
4570
- console.error('Failed to parse initial value:', e);
4571
- this.items = [];
4572
- }
4573
- }
4574
- this.updateHiddenField();
4575
- },
4576
- updateHiddenField() {
4577
- const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
4578
- if (hiddenInput) {
4579
- hiddenInput.value = JSON.stringify(this.items);
4580
- }
4581
- },
4582
- addTag() {
4583
- const trimmed = this.newTag.trim();
4584
- if (trimmed && !this.items.includes(trimmed)) {
4585
- this.items.push(trimmed);
4586
- this.newTag = '';
4587
- this.updateHiddenField();
4588
- }
4589
- },
4590
- handleKeyDown(event) {
4591
- if (event.key === 'Enter') {
4592
- event.preventDefault();
4593
- this.addTag();
4594
- }
4595
- },
4596
- removeTag(index) {
4597
- this.items.splice(index, 1);
4598
- this.updateHiddenField();
4599
- },
4600
- startEdit(index) {
4601
- this.editingIndex = index;
4602
- this.editingValue = this.items[index];
4603
- this.$nextTick(() => {
4604
- const input = this.$el.querySelector('input[data-testid="${fieldName}-tag-edit-input-' + index + '"]');
4605
- if (input && input.focus) {
4606
- input.focus();
4607
- input.select();
4608
- }
4609
- });
4610
- },
4611
- saveEdit(index) {
4612
- const trimmed = this.editingValue.trim();
4613
- if (trimmed) {
4614
- // \u68C0\u67E5\u662F\u5426\u4E0E\u5176\u4ED6\u6807\u7B7E\u91CD\u590D\uFF08\u6392\u9664\u5F53\u524D\u7F16\u8F91\u7684\u6807\u7B7E\uFF09
4615
- const isDuplicate = this.items.some((item, i) => i !== index && item === trimmed);
4616
- if (!isDuplicate) {
4617
- this.items[index] = trimmed;
4618
- this.updateHiddenField();
4619
- }
4620
- }
4621
- this.cancelEdit();
4622
- },
4623
- cancelEdit() {
4624
- this.editingIndex = null;
4625
- this.editingValue = '';
4626
- },
4627
- handleEditKeyDown(index, event) {
4628
- if (event.key === 'Enter') {
4629
- event.preventDefault();
4630
- this.saveEdit(index);
4631
- } else if (event.key === 'Escape') {
4632
- event.preventDefault();
4633
- this.cancelEdit();
4634
- }
4635
- },
4636
- handleDragStart(index, event) {
4637
- this.draggedIndex = index;
4638
- event.dataTransfer.effectAllowed = 'move';
4639
- event.dataTransfer.setData('text/plain', index.toString());
4640
- const target = event.currentTarget || event.target.closest('[draggable="true"]');
4641
- if (target) {
4642
- target.style.opacity = '0.5';
4643
- }
4644
- },
4645
- handleDragEnd(event) {
4646
- const target = event.currentTarget || event.target.closest('[draggable="true"]');
4647
- if (target) {
4648
- target.style.opacity = '';
4649
- }
4650
- this.draggedIndex = null;
4651
- this.draggedOverIndex = null;
4652
- },
4653
- handleDragOver(index, event) {
4654
- event.preventDefault();
4655
- event.dataTransfer.dropEffect = 'move';
4656
- this.draggedOverIndex = index;
4657
- },
4658
- handleDragLeave() {
4659
- this.draggedOverIndex = null;
4660
- },
4661
- handleDrop(index, event) {
4662
- event.preventDefault();
4663
- if (this.draggedIndex !== null && this.draggedIndex !== index) {
4664
- const draggedItem = this.items[this.draggedIndex];
4665
- this.items.splice(this.draggedIndex, 1);
4666
- this.items.splice(index, 0, draggedItem);
4667
- this.updateHiddenField();
5518
+ logger.warn(
5519
+ `[HtmxAdminPlugin] POST request to ${fullPath} without matching X-HTTP-Method-Override header (got: ${methodOverride || "none"}, expected: ${expectedMethod})`
5520
+ );
5521
+ return ctx.text("Method Not Allowed", 405);
5522
+ };
5523
+ logger.info(
5524
+ `[HtmxAdminPlugin] Registering POST route for method override: POST ${fullPath} (actual method: ${route.method.toUpperCase()}, feature: ${feature.name})`
5525
+ );
5526
+ options.hono.post(fullPath, postHandler);
4668
5527
  }
4669
- this.draggedIndex = null;
4670
- this.draggedOverIndex = null;
4671
5528
  }
4672
- }`;
4673
- return html`
4674
- <div
4675
- x-data="${xDataContent}"
4676
- data-initial-value="${initialValueJson}"
4677
- x-init="init()"
4678
- class="space-y-2"
4679
- >
4680
- <input
4681
- type="hidden"
4682
- name="${fieldName}"
4683
- x-bind:value="JSON.stringify(items)"
4684
- data-testid="hidden-${fieldName}"
4685
- />
4686
-
4687
- <!-- 输入框:用于添加新标签 -->
4688
- <div class="flex items-center gap-2">
4689
- <input
4690
- type="text"
4691
- x-model="newTag"
4692
- x-on:keydown="handleKeyDown($event)"
4693
- x-bind:placeholder="placeholder"
4694
- class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
4695
- data-testid="${fieldName}-input"
4696
- />
4697
- <button
4698
- type="button"
4699
- x-on:click="addTag()"
4700
- class="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-1"
4701
- data-testid="${fieldName}-add-button"
4702
- >
4703
- <svg
4704
- class="w-4 h-4"
4705
- fill="none"
4706
- stroke="currentColor"
4707
- viewBox="0 0 24 24"
4708
- >
4709
- <path
4710
- stroke-linecap="round"
4711
- stroke-linejoin="round"
4712
- stroke-width="2"
4713
- d="M12 4v16m8-8H4"
4714
- />
4715
- </svg>
4716
- 添加
4717
- </button>
4718
- </div>
4719
-
4720
- <!-- 标签列表 -->
4721
- <div
4722
- class="flex flex-wrap gap-2"
4723
- x-show="items.length > 0"
4724
- data-testid="${fieldName}-tags-container"
4725
- >
4726
- <template x-for="(item, index) in items" x-bind:key="index">
4727
- <div
4728
- class="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 border border-blue-200 rounded-md text-sm group"
4729
- x-bind:class="{
4730
- 'opacity-50': draggedIndex === index,
4731
- 'ring-2 ring-blue-400': draggedOverIndex === index && draggedIndex !== null && draggedIndex !== index
4732
- }"
4733
- draggable="true"
4734
- x-on:dragstart="handleDragStart(index, $event)"
4735
- x-on:dragend="handleDragEnd($event)"
4736
- x-on:dragover="handleDragOver(index, $event)"
4737
- x-on:dragleave="handleDragLeave()"
4738
- x-on:drop="handleDrop(index, $event)"
4739
- x-bind:data-testid="fieldName + '-tag-' + index"
4740
- >
4741
- <!-- 拖拽手柄 -->
4742
- <div
4743
- class="flex-shrink-0 cursor-move text-blue-400 hover:text-blue-600 transition-colors"
4744
- x-bind:data-testid="fieldName + '-drag-handle-' + index"
4745
- title="拖拽排序"
4746
- >
4747
- <svg
4748
- class="w-3 h-3"
4749
- fill="none"
4750
- stroke="currentColor"
4751
- viewBox="0 0 24 24"
4752
- >
4753
- <path
4754
- stroke-linecap="round"
4755
- stroke-linejoin="round"
4756
- stroke-width="2"
4757
- d="M4 8h16M4 16h16"
4758
- />
4759
- </svg>
4760
- </div>
4761
-
4762
- <!-- 标签内容:显示模式 -->
4763
- <span
4764
- x-show="editingIndex !== index"
4765
- class="flex-1 text-blue-900 cursor-pointer"
4766
- x-on:click="startEdit(index)"
4767
- x-text="item"
4768
- x-bind:data-testid="fieldName + '-tag-text-' + index"
4769
- ></span>
4770
- <!-- 标签内容:编辑模式 -->
4771
- <input
4772
- x-show="editingIndex === index"
4773
- type="text"
4774
- x-model="editingValue"
4775
- x-on:keydown="handleEditKeyDown(index, $event)"
4776
- x-on:blur="saveEdit(index)"
4777
- class="flex-1 px-1 py-0.5 border border-blue-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-[60px]"
4778
- x-bind:data-testid="fieldName + '-tag-edit-input-' + index"
4779
- />
4780
-
4781
- <!-- 删除按钮 -->
4782
- <button
4783
- type="button"
4784
- x-on:click="removeTag(index)"
4785
- class="flex-shrink-0 text-blue-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors p-0.5"
4786
- x-bind:data-testid="fieldName + '-tag-remove-' + index"
4787
- title="删除标签"
4788
- >
4789
- <svg
4790
- class="w-3.5 h-3.5"
4791
- fill="none"
4792
- stroke="currentColor"
4793
- viewBox="0 0 24 24"
4794
- >
4795
- <path
4796
- stroke-linecap="round"
4797
- stroke-linejoin="round"
4798
- stroke-width="2"
4799
- d="M6 18L18 6M6 6l12 12"
4800
- />
4801
- </svg>
4802
- </button>
4803
- </div>
4804
- </template>
4805
- </div>
4806
-
4807
- <!-- 空状态提示 -->
4808
- <div
4809
- x-show="items.length === 0"
4810
- class="text-center py-4 text-gray-400 text-sm border border-dashed border-gray-300 rounded-md"
4811
- data-testid="${fieldName}-empty-state"
4812
- >
4813
- 暂无标签,在上方输入框中输入标签后按回车或点击"添加"按钮
4814
- </div>
4815
- </div>
4816
- `;
5529
+ }
5530
+ }
5531
+ function registerHomeRedirect(pages, options) {
5532
+ const { prefix, homePath } = options.options;
5533
+ if (homePath) {
5534
+ options.hono.get(prefix, async (ctx) => {
5535
+ return ctx.redirect(homePath);
5536
+ });
5537
+ } else if (pages.size > 0) {
5538
+ const firstPage = Array.from(pages.values())[0];
5539
+ const firstPath = `${prefix}${modelNameToPath(firstPage.modelName)}`;
5540
+ options.hono.get(prefix, async (ctx) => {
5541
+ return ctx.redirect(firstPath);
5542
+ });
5543
+ }
4817
5544
  }
4818
5545
 
4819
- export { BaseFeature, CustomFeature, DefaultCreateFeature, DefaultDeleteFeature, DefaultDetailFeature, DefaultEditFeature, DefaultListFeature, Dialog, ErrorAlert, HtmxAdminPlugin, LoadingBar, ObjectEditor, PageModel, StringArrayEditor, TagsEditor, checkUserPermission, getUserInfo, modelNameToPath, parseListParams };
5546
+ // src/plugin.tsx
5547
+ init_cdn_cache();
5548
+ var HtmxAdminPlugin = class {
5549
+ name = "htmx-admin-plugin";
5550
+ priority = PluginPriority.ROUTE;
5551
+ engine;
5552
+ hono;
5553
+ options;
5554
+ serviceName = "";
5555
+ pages = /* @__PURE__ */ new Map();
5556
+ componentHandler;
5557
+ constructor(options) {
5558
+ this.options = {
5559
+ title: options?.title || "\u7BA1\u7406\u540E\u53F0",
5560
+ logo: options?.logo || "",
5561
+ prefix: options?.prefix || "/admin",
5562
+ homePath: options?.homePath || "",
5563
+ navigation: options?.navigation ?? [],
5564
+ authProvider: options?.authProvider,
5565
+ pages: options?.pages ?? [],
5566
+ components: options?.components ?? []
5567
+ };
5568
+ this.initPages();
5569
+ }
5570
+ initPages() {
5571
+ for (const page of this.options.pages) {
5572
+ this.registerPage(page);
5573
+ }
5574
+ }
5575
+ /**
5576
+ * 注册页面
5577
+ */
5578
+ registerPage(page) {
5579
+ if (this.pages.has(page.modelName)) {
5580
+ throw new Error(
5581
+ `Page with name "${page.modelName}" is already registered`
5582
+ );
5583
+ }
5584
+ this.pages.set(page.modelName, page);
5585
+ logger.info(`[HtmxAdminPlugin] Registered page: ${page.modelName}`);
5586
+ return this;
5587
+ }
5588
+ /**
5589
+ * 引擎初始化钩子
5590
+ */
5591
+ onInit(engine) {
5592
+ this.engine = engine;
5593
+ this.hono = engine.getHono();
5594
+ this.serviceName = engine.options.name;
5595
+ logger.info(
5596
+ `HtmxAdminPlugin initialized${this.serviceName ? ` (service: ${this.serviceName})` : ""}`
5597
+ );
5598
+ this.componentHandler = new HtmxComponentHandler(
5599
+ this.hono,
5600
+ this.options.prefix,
5601
+ this.options.components
5602
+ );
5603
+ initializeCdnCache().catch((error) => {
5604
+ logger.error("[HtmxAdminPlugin] CDN \u7F13\u5B58\u521D\u59CB\u5316\u5931\u8D25", error);
5605
+ });
5606
+ }
5607
+ /**
5608
+ * 引擎启动后注册路由
5609
+ */
5610
+ onAfterStart(engine) {
5611
+ const routeOptions = {
5612
+ options: this.options,
5613
+ hono: this.hono,
5614
+ plugin: this
5615
+ };
5616
+ registerCdnCacheRoutes(routeOptions);
5617
+ for (const [_modelName, page] of this.pages) {
5618
+ registerPageRoutes(page, routeOptions);
5619
+ }
5620
+ registerHomeRedirect(this.pages, routeOptions);
5621
+ }
5622
+ };
5623
+
5624
+ export { BaseFeature, ComponentContext, CustomFeature, DefaultCreateFeature, DefaultDeleteFeature, DefaultDetailFeature, DefaultEditFeature, DefaultListFeature, Dialog, ErrorAlert, HtmxAdminPlugin, HtmxComponent, LoadingBar, Method, ObjectEditor, PageModel, RenderContext, SortableList, StringArrayEditor, TagsEditor, checkUserPermission, getUserInfo, modelNameToPath, parseListParams };