imean-service-engine-htmx-plugin 2.1.1 → 2.3.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.js CHANGED
@@ -5,6 +5,7 @@ var fs = require('fs');
5
5
  var path = require('path');
6
6
  var jsxRuntime = require('hono/jsx/jsx-runtime');
7
7
  var cookie = require('hono/cookie');
8
+ var html = require('hono/html');
8
9
 
9
10
  var __defProp = Object.defineProperty;
10
11
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -295,144 +296,350 @@ function getFieldValue(field, initialData) {
295
296
  if (value === null || value === void 0) {
296
297
  return "";
297
298
  }
299
+ if (typeof value === "object") {
300
+ if (value instanceof Date) {
301
+ return value.toISOString();
302
+ }
303
+ try {
304
+ return JSON.stringify(value);
305
+ } catch (e) {
306
+ return "";
307
+ }
308
+ }
298
309
  return String(value);
299
310
  }
300
311
  return "";
301
312
  }
313
+ function isJsonString(value) {
314
+ if (!value || typeof value !== "string") {
315
+ return false;
316
+ }
317
+ const trimmed = value.trim();
318
+ return trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]");
319
+ }
320
+ function formatJsonString(value) {
321
+ if (!value || typeof value !== "string") {
322
+ return value;
323
+ }
324
+ try {
325
+ const parsed = JSON.parse(value);
326
+ return JSON.stringify(parsed, null, 2);
327
+ } catch (e) {
328
+ return value;
329
+ }
330
+ }
331
+ function renderFormField(field, initialData, formFieldRenderers) {
332
+ const value = getFieldValue(field, initialData);
333
+ const customRenderer = formFieldRenderers?.[field.name];
334
+ if (customRenderer) {
335
+ let parsedValue = null;
336
+ if (value && isJsonString(value)) {
337
+ try {
338
+ parsedValue = JSON.parse(value);
339
+ } catch (e) {
340
+ parsedValue = null;
341
+ }
342
+ } else if (value) {
343
+ parsedValue = value;
344
+ }
345
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", "data-testid": `field-${field.name}`, children: [
346
+ /* @__PURE__ */ jsxRuntime.jsxs(
347
+ "label",
348
+ {
349
+ htmlFor: field.name,
350
+ className: "block text-sm font-semibold text-gray-700",
351
+ "data-testid": `label-${field.name}`,
352
+ children: [
353
+ field.label,
354
+ field.required && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-red-500 ml-1", children: "*" })
355
+ ]
356
+ }
357
+ ),
358
+ /* @__PURE__ */ jsxRuntime.jsx("div", { children: customRenderer({
359
+ field,
360
+ value: parsedValue,
361
+ initialData,
362
+ fieldName: field.name
363
+ }) }),
364
+ field.description && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 mt-1", children: field.description })
365
+ ] }, field.name);
366
+ }
367
+ const shouldUseTextarea = field.type === "textarea" || value && isJsonString(value) && field.type !== "select";
368
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", "data-testid": `field-${field.name}`, children: [
369
+ /* @__PURE__ */ jsxRuntime.jsxs(
370
+ "label",
371
+ {
372
+ htmlFor: field.name,
373
+ className: "block text-sm font-semibold text-gray-700",
374
+ "data-testid": `label-${field.name}`,
375
+ children: [
376
+ field.label,
377
+ field.required && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-red-500 ml-1", children: "*" })
378
+ ]
379
+ }
380
+ ),
381
+ shouldUseTextarea ? /* @__PURE__ */ jsxRuntime.jsx(
382
+ "textarea",
383
+ {
384
+ id: field.name,
385
+ name: field.name,
386
+ required: field.required,
387
+ placeholder: field.placeholder || (isJsonString(value) ? "JSON \u683C\u5F0F\u6570\u636E" : ""),
388
+ rows: isJsonString(value) ? 10 : 4,
389
+ 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",
390
+ "data-testid": `input-${field.name}`,
391
+ children: isJsonString(value) ? formatJsonString(value) : value
392
+ },
393
+ `${field.name}-${value}`
394
+ ) : field.type === "select" ? /* @__PURE__ */ jsxRuntime.jsxs(
395
+ "select",
396
+ {
397
+ id: field.name,
398
+ name: field.name,
399
+ required: field.required,
400
+ className: "w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 bg-white",
401
+ "data-testid": `select-${field.name}`,
402
+ children: [
403
+ !field.required && /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", selected: !value || value === "", children: "\u8BF7\u9009\u62E9" }),
404
+ field.options && field.options.length > 0 ? field.options.map((option) => {
405
+ const optionValue = String(option.value);
406
+ const isSelected = value === optionValue;
407
+ return /* @__PURE__ */ jsxRuntime.jsx(
408
+ "option",
409
+ {
410
+ value: optionValue,
411
+ selected: isSelected,
412
+ children: option.label
413
+ },
414
+ optionValue
415
+ );
416
+ }) : /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: true, children: "\u6682\u65E0\u9009\u9879" })
417
+ ]
418
+ },
419
+ `${field.name}-${value || ""}`
420
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
421
+ "input",
422
+ {
423
+ type: field.type || "text",
424
+ id: field.name,
425
+ name: field.name,
426
+ required: field.required,
427
+ placeholder: field.placeholder,
428
+ step: field.type === "number" ? field.step ?? void 0 : void 0,
429
+ 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",
430
+ value,
431
+ "data-testid": `input-${field.name}`
432
+ },
433
+ `${field.name}-${value}`
434
+ ),
435
+ field.description && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 mt-1", children: field.description })
436
+ ] }, field.name);
437
+ }
302
438
  function FormPage(props) {
303
- const { fields, submitUrl, method = "post", initialData, formId } = props;
439
+ const { fields, groups, submitUrl, method = "post", initialData, formId, isDialog = false, formFieldRenderers } = props;
304
440
  const finalFormId = formId || `form-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
305
441
  if (process.env.NODE_ENV === "development" && initialData) {
442
+ const fieldNames = fields ? fields.map((f) => f.name).join(", ") : groups ? groups.map((g) => g.fields.map((f) => f.name).join(", ")).join(" | ") : "";
306
443
  console.log(
307
- `[FormPage] initialData: ${JSON.stringify(initialData)}, fields: ${fields.map((f) => f.name).join(", ")}`
444
+ `[FormPage] initialData: ${JSON.stringify(initialData)}, fields: ${fieldNames}`
308
445
  );
309
446
  }
310
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-full", "data-testid": "form-container", children: /* @__PURE__ */ jsxRuntime.jsxs(
311
- "form",
312
- {
313
- id: finalFormId,
314
- method: method === "put" ? "post" : method,
315
- action: submitUrl,
316
- "hx-boost": "true",
317
- "hx-method": method === "put" ? "put" : void 0,
318
- "hx-indicator": "#form-loading-indicator",
319
- className: "space-y-6",
320
- "data-testid": "form",
321
- children: [
322
- /* @__PURE__ */ jsxRuntime.jsxs(
323
- "div",
324
- {
325
- id: "form-loading-indicator",
326
- className: "htmx-indicator fixed top-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 z-50",
327
- children: [
328
- /* @__PURE__ */ jsxRuntime.jsxs(
329
- "svg",
330
- {
331
- className: "animate-spin h-4 w-4",
332
- xmlns: "http://www.w3.org/2000/svg",
333
- fill: "none",
334
- viewBox: "0 0 24 24",
335
- children: [
336
- /* @__PURE__ */ jsxRuntime.jsx(
337
- "circle",
338
- {
339
- className: "opacity-25",
340
- cx: "12",
341
- cy: "12",
342
- r: "10",
343
- stroke: "currentColor",
344
- strokeWidth: "4"
345
- }
346
- ),
347
- /* @__PURE__ */ jsxRuntime.jsx(
348
- "path",
349
- {
350
- className: "opacity-75",
351
- fill: "currentColor",
352
- d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
353
- }
354
- )
355
- ]
356
- }
357
- ),
358
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-medium", children: "\u63D0\u4EA4\u4E2D..." })
359
- ]
447
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "w-full", "data-testid": "form-container", "x-data": `{ activeTab: 0 }`, children: [
448
+ /* @__PURE__ */ jsxRuntime.jsxs(
449
+ "div",
450
+ {
451
+ id: "form-loading-indicator",
452
+ className: "htmx-indicator fixed top-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 z-50",
453
+ children: [
454
+ /* @__PURE__ */ jsxRuntime.jsxs(
455
+ "svg",
456
+ {
457
+ className: "animate-spin h-4 w-4",
458
+ xmlns: "http://www.w3.org/2000/svg",
459
+ fill: "none",
460
+ viewBox: "0 0 24 24",
461
+ children: [
462
+ /* @__PURE__ */ jsxRuntime.jsx(
463
+ "circle",
464
+ {
465
+ className: "opacity-25",
466
+ cx: "12",
467
+ cy: "12",
468
+ r: "10",
469
+ stroke: "currentColor",
470
+ strokeWidth: "4"
471
+ }
472
+ ),
473
+ /* @__PURE__ */ jsxRuntime.jsx(
474
+ "path",
475
+ {
476
+ className: "opacity-75",
477
+ fill: "currentColor",
478
+ d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
479
+ }
480
+ )
481
+ ]
482
+ }
483
+ ),
484
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-medium", children: "\u63D0\u4EA4\u4E2D..." })
485
+ ]
486
+ }
487
+ ),
488
+ groups && groups.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
489
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: `sticky top-0 z-10 bg-white border-b border-gray-200 shadow-sm ${isDialog ? "px-6" : "-mx-6 px-6 -mt-6"}`, children: /* @__PURE__ */ jsxRuntime.jsx("nav", { className: "flex -mb-px w-full", "aria-label": "Tabs", "data-testid": "form-tabs", children: groups.map((group, index) => /* @__PURE__ */ jsxRuntime.jsx(
490
+ "button",
491
+ {
492
+ type: "button",
493
+ "x-on:click": `activeTab = ${index}`,
494
+ "x-bind:class": `activeTab === ${index} ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'`,
495
+ className: "px-6 py-4 text-sm font-medium border-b-2 transition-colors duration-150 whitespace-nowrap flex-1 text-center",
496
+ "data-testid": `form-tab-${index}`,
497
+ children: group.label
498
+ },
499
+ index
500
+ )) }) }),
501
+ /* @__PURE__ */ jsxRuntime.jsx(
502
+ "form",
503
+ {
504
+ id: finalFormId,
505
+ method: method === "put" ? "post" : method,
506
+ action: submitUrl,
507
+ "hx-boost": "true",
508
+ ...method === "put" ? { "hx-put": submitUrl } : {},
509
+ "hx-indicator": "#form-loading-indicator",
510
+ "data-testid": "form",
511
+ className: isDialog ? "p-6" : "mt-6",
512
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-white rounded-lg border border-gray-200 shadow-sm", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-6", children: groups.map((group, index) => /* @__PURE__ */ jsxRuntime.jsx(
513
+ "div",
514
+ {
515
+ "x-show": `activeTab === ${index}`,
516
+ className: "space-y-6",
517
+ "data-testid": `form-tab-content-${index}`,
518
+ children: group.fields.map((field) => renderFormField(field, initialData, formFieldRenderers))
519
+ },
520
+ index
521
+ )) }) })
522
+ }
523
+ )
524
+ ] }) : (
525
+ /* 平铺模式(向后兼容) */
526
+ /* @__PURE__ */ jsxRuntime.jsx(
527
+ "form",
528
+ {
529
+ id: finalFormId,
530
+ method: method === "put" ? "post" : method,
531
+ action: submitUrl,
532
+ "hx-boost": "true",
533
+ ...method === "put" ? { "hx-put": submitUrl } : {},
534
+ "hx-indicator": "#form-loading-indicator",
535
+ className: "space-y-6",
536
+ "data-testid": "form",
537
+ children: fields && fields.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-white rounded-lg border border-gray-200 shadow-sm", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-6 space-y-6", children: fields.map((field) => renderFormField(field, initialData, formFieldRenderers)) }) })
538
+ }
539
+ )
540
+ )
541
+ ] });
542
+ }
543
+
544
+ // src/utils/form-data-processor.ts
545
+ function preprocessFormData(data, zodSchema) {
546
+ if (!zodSchema) {
547
+ return data;
548
+ }
549
+ const processed = { ...data };
550
+ const def = zodSchema._def;
551
+ const shape = typeof def.shape === "function" ? def.shape() : def.shape;
552
+ if (!shape || typeof shape !== "object") {
553
+ return processed;
554
+ }
555
+ for (const [fieldName, fieldSchema] of Object.entries(shape)) {
556
+ const value = processed[fieldName];
557
+ if (value === void 0) {
558
+ continue;
559
+ }
560
+ if (value === null) {
561
+ processed[fieldName] = void 0;
562
+ continue;
563
+ }
564
+ if (value === "") {
565
+ processed[fieldName] = void 0;
566
+ continue;
567
+ }
568
+ const fieldDef = fieldSchema._def;
569
+ let typeName = fieldDef?.type || fieldDef?.typeName;
570
+ if (typeName === "optional" || typeName === "ZodOptional") {
571
+ const innerType = fieldDef.innerType;
572
+ if (innerType) {
573
+ const innerDef = innerType._def;
574
+ typeName = innerDef?.type || innerDef?.typeName;
575
+ }
576
+ }
577
+ if (typeName === "number" || typeName === "ZodNumber") {
578
+ if (typeof value === "string") {
579
+ const trimmed = value.trim();
580
+ if (trimmed !== "") {
581
+ const numValue = Number(trimmed);
582
+ if (!isNaN(numValue)) {
583
+ processed[fieldName] = numValue;
360
584
  }
361
- ),
362
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-white rounded-lg border border-gray-200 shadow-sm", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-6 space-y-6", children: fields.map((field) => {
363
- const value = getFieldValue(field, initialData);
364
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", "data-testid": `field-${field.name}`, children: [
365
- /* @__PURE__ */ jsxRuntime.jsxs(
366
- "label",
367
- {
368
- htmlFor: field.name,
369
- className: "block text-sm font-semibold text-gray-700",
370
- "data-testid": `label-${field.name}`,
371
- children: [
372
- field.label,
373
- field.required && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-red-500 ml-1", children: "*" })
374
- ]
375
- }
376
- ),
377
- field.type === "textarea" ? /* @__PURE__ */ jsxRuntime.jsx(
378
- "textarea",
379
- {
380
- id: field.name,
381
- name: field.name,
382
- required: field.required,
383
- placeholder: field.placeholder,
384
- rows: 4,
385
- 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",
386
- value,
387
- "data-testid": `input-${field.name}`
388
- },
389
- `${field.name}-${value}`
390
- ) : field.type === "select" ? /* @__PURE__ */ jsxRuntime.jsxs(
391
- "select",
392
- {
393
- id: field.name,
394
- name: field.name,
395
- required: field.required,
396
- className: "w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 bg-white",
397
- "data-testid": `select-${field.name}`,
398
- children: [
399
- !field.required && /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", selected: !value || value === "", children: "\u8BF7\u9009\u62E9" }),
400
- field.options && field.options.length > 0 ? field.options.map((option) => {
401
- const optionValue = String(option.value);
402
- const isSelected = value === optionValue;
403
- return /* @__PURE__ */ jsxRuntime.jsx(
404
- "option",
405
- {
406
- value: optionValue,
407
- selected: isSelected,
408
- children: option.label
409
- },
410
- optionValue
411
- );
412
- }) : /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: true, children: "\u6682\u65E0\u9009\u9879" })
413
- ]
414
- },
415
- `${field.name}-${value || ""}`
416
- ) : /* @__PURE__ */ jsxRuntime.jsx(
417
- "input",
418
- {
419
- type: field.type || "text",
420
- id: field.name,
421
- name: field.name,
422
- required: field.required,
423
- placeholder: field.placeholder,
424
- 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",
425
- value,
426
- "data-testid": `input-${field.name}`
427
- },
428
- `${field.name}-${value}`
429
- ),
430
- field.description && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 mt-1", children: field.description })
431
- ] }, field.name);
432
- }) }) })
433
- ]
585
+ }
586
+ } else if (typeof value === "number") {
587
+ processed[fieldName] = value;
588
+ }
434
589
  }
435
- ) });
590
+ if (typeName === "boolean" || typeName === "ZodBoolean") {
591
+ if (typeof value === "string") {
592
+ const trimmed = value.trim().toLowerCase();
593
+ if (trimmed === "true" || trimmed === "1" || trimmed === "on") {
594
+ processed[fieldName] = true;
595
+ } else if (trimmed === "false" || trimmed === "0" || trimmed === "off" || trimmed === "") {
596
+ processed[fieldName] = false;
597
+ }
598
+ } else if (typeof value === "boolean") {
599
+ processed[fieldName] = value;
600
+ }
601
+ }
602
+ if (typeName === "array" || typeName === "ZodArray") {
603
+ if (typeof value === "string") {
604
+ const trimmed = value.trim();
605
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
606
+ try {
607
+ const parsed = JSON.parse(trimmed);
608
+ processed[fieldName] = parsed;
609
+ } catch (e) {
610
+ }
611
+ } else {
612
+ const parts = trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
613
+ processed[fieldName] = parts;
614
+ }
615
+ }
616
+ }
617
+ if (typeName === "object" || typeName === "ZodObject") {
618
+ if (typeof value === "string" && value.trim() !== "") {
619
+ try {
620
+ const trimmed = value.trim();
621
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
622
+ const parsed = JSON.parse(trimmed);
623
+ processed[fieldName] = parsed;
624
+ }
625
+ } catch (e) {
626
+ }
627
+ }
628
+ }
629
+ if (typeName === "any" || typeName === "ZodAny") {
630
+ if (typeof value === "string" && value.trim() !== "") {
631
+ try {
632
+ const trimmed = value.trim();
633
+ if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
634
+ const parsed = JSON.parse(trimmed);
635
+ processed[fieldName] = parsed;
636
+ }
637
+ } catch (e) {
638
+ }
639
+ }
640
+ }
641
+ }
642
+ return processed;
436
643
  }
437
644
 
438
645
  // src/utils/schema-utils.ts
@@ -459,13 +666,14 @@ function parseFieldSchema(fieldName, fieldSchema) {
459
666
  return null;
460
667
  }
461
668
  const label = getFieldDescription(fieldSchema) || fieldName;
462
- const { type, required, options, innerSchema } = analyzeFieldType(fieldSchema);
669
+ const { type, required, options, innerSchema, step } = analyzeFieldType(fieldSchema);
463
670
  return {
464
671
  name: fieldName,
465
672
  label,
466
673
  type,
467
674
  required,
468
675
  options,
676
+ step,
469
677
  schema: innerSchema || fieldSchema
470
678
  };
471
679
  }
@@ -522,10 +730,20 @@ function analyzeFieldType(schema) {
522
730
  let fieldType = "text";
523
731
  if (def?.checks) {
524
732
  const hasEmailCheck = def.checks.some(
525
- (check) => check.kind === "email"
733
+ (check) => check.format === "email" || check.constructor?.name === "ZodEmail" || check._zod?.def?.format === "email"
526
734
  );
527
735
  if (hasEmailCheck) {
528
736
  fieldType = "email";
737
+ } else {
738
+ const maxLengthCheck = def.checks.find(
739
+ (check) => check.constructor?.name === "$ZodCheckMaxLength" || check._zod?.def?.check === "max_length" || check._zod?.def?.maximum !== void 0
740
+ );
741
+ if (maxLengthCheck) {
742
+ const maxLength = maxLengthCheck._zod?.def?.maximum ?? maxLengthCheck.value ?? maxLengthCheck.maximum;
743
+ if (maxLength !== void 0 && maxLength > 50) {
744
+ fieldType = "textarea";
745
+ }
746
+ }
529
747
  }
530
748
  }
531
749
  return {
@@ -535,10 +753,22 @@ function analyzeFieldType(schema) {
535
753
  };
536
754
  }
537
755
  if (typeName === "number" || typeName === "ZodNumber") {
756
+ let step = void 0;
757
+ if (def?.checks) {
758
+ const hasIntCheck = def.checks.some(
759
+ (check) => check.isInt === true || check.format === "safeint" || check.def?.format === "safeint" || check.kind === "int" || check.constructor?.name === "ZodInt" || check._zod?.def?.check === "int" || check._zod?.def?.kind === "int"
760
+ );
761
+ if (!hasIntCheck) {
762
+ step = "any";
763
+ }
764
+ } else {
765
+ step = "any";
766
+ }
538
767
  return {
539
768
  type: "number",
540
769
  required: true,
541
- innerSchema: schema
770
+ innerSchema: schema,
771
+ step
542
772
  };
543
773
  }
544
774
  if (typeName === "date" || typeName === "ZodDate") {
@@ -603,7 +833,8 @@ function modelFieldsToFormFields(fields) {
603
833
  type: field.type,
604
834
  label: field.label,
605
835
  required: field.required,
606
- options: field.options
836
+ options: field.options,
837
+ step: field.step
607
838
  }));
608
839
  }
609
840
  function getFieldNamesFromFields(fields) {
@@ -621,6 +852,8 @@ var BaseFormFeature = class extends BaseFeature {
621
852
  descriptionGetter;
622
853
  /** 当前请求的表单 ID(用于在 render 和 getActions 之间共享) */
623
854
  currentFormId;
855
+ /** 自定义表单字段渲染器 */
856
+ formFieldRenderers;
624
857
  /**
625
858
  * 获取或生成表单 ID(确保在同一个请求中保持一致)
626
859
  */
@@ -687,59 +920,7 @@ var BaseFormFeature = class extends BaseFeature {
687
920
  if (!this.schema) {
688
921
  return data;
689
922
  }
690
- const processed = { ...data };
691
- for (const key of Object.keys(processed)) {
692
- if (processed[key] === "") {
693
- processed[key] = void 0;
694
- }
695
- }
696
- const def = this.schema._def;
697
- const shape = typeof def.shape === "function" ? def.shape() : def.shape;
698
- if (!shape || typeof shape !== "object") {
699
- return data;
700
- }
701
- for (const [fieldName, fieldSchema] of Object.entries(shape)) {
702
- const value = processed[fieldName];
703
- if (value === void 0 || value === null) {
704
- continue;
705
- }
706
- const fieldDef = fieldSchema._def;
707
- let typeName = fieldDef?.typeName;
708
- let isOptional = false;
709
- if (typeName === "ZodOptional") {
710
- isOptional = true;
711
- const innerType = fieldDef.innerType?._def;
712
- typeName = innerType?.typeName;
713
- }
714
- if (isOptional && typeof value === "string" && value.trim() === "") {
715
- if (fieldName === "authorId") {
716
- imeanServiceEngine.logger.info(`[BaseFormFeature] authorId empty optional -> undefined`);
717
- }
718
- processed[fieldName] = void 0;
719
- continue;
720
- }
721
- if (typeName === "ZodNumber") {
722
- if (typeof value === "string") {
723
- const numValue = Number(value);
724
- if (fieldName === "authorId") {
725
- imeanServiceEngine.logger.info(
726
- `[BaseFormFeature] authorId raw value="${value}", numValue=${numValue}, isOptional=${isOptional}`
727
- );
728
- }
729
- if (!isNaN(numValue)) {
730
- processed[fieldName] = numValue;
731
- }
732
- }
733
- }
734
- if (typeName === "ZodArray") {
735
- if (typeof value === "string") {
736
- const parts = value.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
737
- processed[fieldName] = parts;
738
- }
739
- }
740
- }
741
- imeanServiceEngine.logger.info(`[BaseFormFeature] processed form data: ${JSON.stringify(processed)}`);
742
- return processed;
923
+ return preprocessFormData(data, this.schema);
743
924
  }
744
925
  /**
745
926
  * 处理请求
@@ -749,12 +930,23 @@ var BaseFormFeature = class extends BaseFeature {
749
930
  this.currentFormId = void 0;
750
931
  if (ctx.req.method === "GET") {
751
932
  return this.render(context);
752
- } else if (ctx.req.method === "POST" || ctx.req.method === "PUT") {
933
+ } else if (ctx.req.method === "POST" || ctx.req.method === "PUT" || ctx.req.method === "PATCH") {
934
+ const methodOverride = ctx.req.header("X-HTTP-Method-Override");
935
+ const actualMethod = methodOverride || ctx.req.method;
936
+ const expectedMethod = this.getFormAction() === "edit" ? "PUT" : "POST";
937
+ if (actualMethod.toUpperCase() !== expectedMethod) {
938
+ imeanServiceEngine.logger.warn(
939
+ `[BaseFormFeature] Method mismatch: expected ${expectedMethod}, got ${actualMethod} (request method: ${ctx.req.method}, X-HTTP-Method-Override: ${methodOverride || "none"})`
940
+ );
941
+ }
753
942
  const originalData = { ...context.body };
754
943
  imeanServiceEngine.logger.info(
755
944
  `[BaseFormFeature] Original body data: ${JSON.stringify(originalData)}`
756
945
  );
757
946
  let data = this.preprocessFormData(context.body);
947
+ imeanServiceEngine.logger.info(
948
+ `[BaseFormFeature] Preprocessed data: ${JSON.stringify(data)}`
949
+ );
758
950
  if (!this.schema) {
759
951
  throw new Error("Schema is required for form validation");
760
952
  }
@@ -771,7 +963,10 @@ var BaseFormFeature = class extends BaseFeature {
771
963
  );
772
964
  return this.render(context, originalData);
773
965
  }
774
- const item = await this.handleSubmit(context, parseResult.data);
966
+ const item = await this.handleSubmit(
967
+ context,
968
+ parseResult.data
969
+ );
775
970
  if (!item) {
776
971
  context.sendError(
777
972
  this.getFormAction() === "create" ? "\u521B\u5EFA\u5931\u8D25" : "\u66F4\u65B0\u5931\u8D25",
@@ -789,6 +984,9 @@ var BaseFormFeature = class extends BaseFeature {
789
984
  `[BaseFormFeature] Dialog mode: setting refresh to close dialog and refresh list`
790
985
  );
791
986
  context.setRefresh(true);
987
+ if (context.redirectUrl) {
988
+ context.redirectUrl = void 0;
989
+ }
792
990
  return null;
793
991
  } else {
794
992
  const redirectUrl = this.getSuccessRedirectUrl(context, item);
@@ -801,12 +999,11 @@ var BaseFormFeature = class extends BaseFeature {
801
999
  }
802
1000
  }
803
1001
  formFieldNames;
1002
+ groups;
804
1003
  /**
805
1004
  * 渲染表单页面
806
1005
  */
807
1006
  async render(context, initialData) {
808
- const filteredFields = this.formFieldNames ? filterFieldsByNames(this.fields || [], this.formFieldNames) : this.fields || [];
809
- const fields = modelFieldsToFormFields(filteredFields);
810
1007
  let formData;
811
1008
  if (this.getFormAction() === "edit") {
812
1009
  if (initialData) {
@@ -828,6 +1025,50 @@ var BaseFormFeature = class extends BaseFeature {
828
1025
  }
829
1026
  const method = this.getFormAction() === "create" ? "post" : "put";
830
1027
  const formId = this.getFormId(context);
1028
+ if (this.groups && this.groups.length > 0) {
1029
+ if (!this.schema) {
1030
+ throw new Error("Schema is required when using groups");
1031
+ }
1032
+ const schema = this.schema;
1033
+ const groupSchemas = this.groups.map((group) => {
1034
+ const pickObject = group.fields.reduce(
1035
+ (acc, fieldName) => {
1036
+ acc[fieldName] = true;
1037
+ return acc;
1038
+ },
1039
+ {}
1040
+ );
1041
+ return {
1042
+ label: group.label,
1043
+ schema: schema.pick(pickObject),
1044
+ fields: group.fields
1045
+ };
1046
+ });
1047
+ const groupFields = groupSchemas.map(
1048
+ ({ label, schema: schema2, fields: fieldNames }) => {
1049
+ const groupFields2 = parseSchemaToFields(schema2);
1050
+ const formFields = modelFieldsToFormFields(groupFields2);
1051
+ return {
1052
+ label,
1053
+ fields: formFields
1054
+ };
1055
+ }
1056
+ );
1057
+ return /* @__PURE__ */ jsxRuntime.jsx(
1058
+ FormPage,
1059
+ {
1060
+ groups: groupFields,
1061
+ submitUrl,
1062
+ method,
1063
+ initialData: formData,
1064
+ formId,
1065
+ isDialog: context.isDialog,
1066
+ formFieldRenderers: this.formFieldRenderers
1067
+ }
1068
+ );
1069
+ }
1070
+ const filteredFields = this.formFieldNames ? filterFieldsByNames(this.fields || [], this.formFieldNames) : this.fields || [];
1071
+ const fields = modelFieldsToFormFields(filteredFields);
831
1072
  return /* @__PURE__ */ jsxRuntime.jsx(
832
1073
  FormPage,
833
1074
  {
@@ -835,7 +1076,9 @@ var BaseFormFeature = class extends BaseFeature {
835
1076
  submitUrl,
836
1077
  method,
837
1078
  initialData: formData,
838
- formId
1079
+ formId,
1080
+ isDialog: context.isDialog,
1081
+ formFieldRenderers: this.formFieldRenderers
839
1082
  }
840
1083
  );
841
1084
  }
@@ -944,6 +1187,8 @@ var DefaultCreateFeature = class extends BaseFormFeature {
944
1187
  this.fields = parseSchemaToFields(options.schema);
945
1188
  this.createItem = options.createItem;
946
1189
  this.formFieldNames = options.formFieldNames;
1190
+ this.groups = options.groups;
1191
+ this.formFieldRenderers = options.formFieldRenderers;
947
1192
  }
948
1193
  getFormAction() {
949
1194
  return "create";
@@ -1003,12 +1248,119 @@ var DefaultDeleteFeature = class extends BaseFeature {
1003
1248
  }
1004
1249
  }
1005
1250
  };
1251
+ function Card(props) {
1252
+ const {
1253
+ children,
1254
+ title,
1255
+ className = "",
1256
+ shadow = true,
1257
+ bordered = false,
1258
+ noPadding = false
1259
+ } = props;
1260
+ const baseClasses = "bg-white rounded-lg";
1261
+ const shadowClass = shadow ? "shadow-sm hover:shadow-md transition-shadow" : "";
1262
+ const borderClass = bordered ? "border border-gray-200" : "";
1263
+ const paddingClass = noPadding ? "" : "p-6";
1264
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1265
+ "div",
1266
+ {
1267
+ className: `${baseClasses} ${shadowClass} ${borderClass} ${className}`,
1268
+ children: [
1269
+ title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4 border-b border-gray-200", children: /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "text-lg font-semibold text-gray-900", children: title }) }),
1270
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: noPadding ? "" : paddingClass, children })
1271
+ ]
1272
+ }
1273
+ );
1274
+ }
1275
+ function renderDefaultValue(value) {
1276
+ if (value === null || value === void 0) {
1277
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-gray-400", children: "-" });
1278
+ }
1279
+ if (Array.isArray(value)) {
1280
+ if (value.length === 0) {
1281
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-gray-400", children: "\u6682\u65E0\u6570\u636E" });
1282
+ }
1283
+ if (value.length > 0 && typeof value[0] === "object" && value[0] !== null) {
1284
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-gray-600", children: [
1285
+ "\u5305\u542B ",
1286
+ value.length,
1287
+ " \u9879\uFF08\u5BF9\u8C61\u6570\u7EC4\uFF0C\u5EFA\u8BAE\u4F7F\u7528\u81EA\u5B9A\u4E49\u6E32\u67D3\uFF09"
1288
+ ] });
1289
+ }
1290
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2", children: value.map((item, index) => /* @__PURE__ */ jsxRuntime.jsx(
1291
+ "span",
1292
+ {
1293
+ className: "px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm",
1294
+ children: String(item)
1295
+ },
1296
+ index
1297
+ )) });
1298
+ }
1299
+ if (typeof value === "object") {
1300
+ if (value instanceof Date) {
1301
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { children: value.toLocaleString() });
1302
+ }
1303
+ return /* @__PURE__ */ jsxRuntime.jsx("pre", { className: "bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-x-auto", children: JSON.stringify(value, null, 2) });
1304
+ }
1305
+ if (typeof value === "boolean") {
1306
+ return /* @__PURE__ */ jsxRuntime.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" });
1307
+ }
1308
+ if (typeof value === "number") {
1309
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { children: value.toLocaleString() });
1310
+ }
1311
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { children: String(value) });
1312
+ }
1313
+ function renderField(field, value, item) {
1314
+ let content;
1315
+ if (field.render) {
1316
+ const rendered = field.render(value, item);
1317
+ if (rendered === null || rendered === void 0) {
1318
+ content = /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-gray-400", children: "-" });
1319
+ } else if (typeof rendered === "string" || typeof rendered === "number" || typeof rendered === "boolean") {
1320
+ content = /* @__PURE__ */ jsxRuntime.jsx("span", { children: String(rendered) });
1321
+ } else {
1322
+ content = rendered;
1323
+ }
1324
+ } else {
1325
+ content = renderDefaultValue(value);
1326
+ }
1327
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1328
+ "div",
1329
+ {
1330
+ 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 ",
1331
+ children: [
1332
+ /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx("span", { className: "min-w-0 uppercase sm:normal-case", children: field.label }) }),
1333
+ /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx("div", { className: "min-w-0", children: content }) })
1334
+ ]
1335
+ },
1336
+ field.key
1337
+ );
1338
+ }
1006
1339
  function DetailPage(props) {
1007
- const { item, fields } = props;
1008
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-white border border-gray-300 rounded p-4", children: /* @__PURE__ */ jsxRuntime.jsx("dl", { className: "grid grid-cols-1 gap-4", children: fields.map((field) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1009
- /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "font-semibold text-gray-700", children: field.label }),
1010
- /* @__PURE__ */ jsxRuntime.jsx("dd", { className: "mt-1 text-gray-900", children: String(item[field.key] ?? "-") })
1011
- ] }, field.key)) }) });
1340
+ const { item, fields, groups } = props;
1341
+ if (groups && groups.length > 0) {
1342
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "space-y-6", children: groups.map((group, groupIndex) => /* @__PURE__ */ jsxRuntime.jsx(
1343
+ Card,
1344
+ {
1345
+ title: group.label,
1346
+ shadow: true,
1347
+ bordered: true,
1348
+ noPadding: true,
1349
+ children: /* @__PURE__ */ jsxRuntime.jsx("dl", { className: "divide-y divide-gray-100", children: group.fields.map((field) => {
1350
+ const value = group.values[field.key];
1351
+ return renderField(field, value, item);
1352
+ }) })
1353
+ },
1354
+ groupIndex
1355
+ )) });
1356
+ }
1357
+ if (fields && fields.length > 0) {
1358
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden", children: /* @__PURE__ */ jsxRuntime.jsx("dl", { className: "divide-y divide-gray-100", children: fields.map((field) => {
1359
+ const value = item[field.key];
1360
+ return renderField(field, value, item);
1361
+ }) }) });
1362
+ }
1363
+ return null;
1012
1364
  }
1013
1365
  var DefaultDetailFeature = class extends BaseFeature {
1014
1366
  getItem;
@@ -1016,6 +1368,8 @@ var DefaultDetailFeature = class extends BaseFeature {
1016
1368
  titleGetter;
1017
1369
  descriptionGetter;
1018
1370
  detailFieldNames;
1371
+ fieldRenderers;
1372
+ groups;
1019
1373
  constructor(options) {
1020
1374
  super({
1021
1375
  name: "detail",
@@ -1031,6 +1385,8 @@ var DefaultDetailFeature = class extends BaseFeature {
1031
1385
  this.titleGetter = options.getTitle;
1032
1386
  this.descriptionGetter = options.getDescription;
1033
1387
  this.detailFieldNames = options.detailFieldNames;
1388
+ this.fieldRenderers = options.fieldRenderers;
1389
+ this.groups = options.groups;
1034
1390
  }
1035
1391
  async getTitle(context) {
1036
1392
  if (this.titleGetter) {
@@ -1061,6 +1417,40 @@ var DefaultDetailFeature = class extends BaseFeature {
1061
1417
  if (!item) {
1062
1418
  return context.ctx.json({ error: "Not found" }, 404);
1063
1419
  }
1420
+ if (this.groups && this.groups.length > 0) {
1421
+ if (!this.schema) {
1422
+ throw new Error("Schema is required when using groups");
1423
+ }
1424
+ const schema = this.schema;
1425
+ const groupSchemas = this.groups.map((group) => {
1426
+ const pickObject = group.fields.reduce((acc, fieldName) => {
1427
+ acc[fieldName] = true;
1428
+ return acc;
1429
+ }, {});
1430
+ return {
1431
+ label: group.label,
1432
+ schema: schema.pick(pickObject),
1433
+ fields: group.fields
1434
+ };
1435
+ });
1436
+ const groupFields = groupSchemas.map(({ label, schema: schema2, fields: fieldNames }) => {
1437
+ const groupFields2 = parseSchemaToFields(schema2);
1438
+ const detailFields2 = groupFields2.map((field) => ({
1439
+ key: field.name,
1440
+ label: field.label,
1441
+ render: this.fieldRenderers?.[field.name]
1442
+ }));
1443
+ return {
1444
+ label,
1445
+ fields: detailFields2,
1446
+ values: fieldNames.reduce((acc, fieldName) => {
1447
+ acc[fieldName] = item[fieldName];
1448
+ return acc;
1449
+ }, {})
1450
+ };
1451
+ });
1452
+ return /* @__PURE__ */ jsxRuntime.jsx(DetailPage, { item, groups: groupFields });
1453
+ }
1064
1454
  const detailFields = this.detailFieldNames ? filterFieldsByNames(this.fields || [], this.detailFieldNames) : this.fields || [];
1065
1455
  if (this.detailFieldNames) {
1066
1456
  const systemFields = ["id", "createdAt", "updatedAt"];
@@ -1079,7 +1469,9 @@ var DefaultDetailFeature = class extends BaseFeature {
1079
1469
  const detailFieldNames = getFieldNamesFromFields(detailFields);
1080
1470
  const fields = detailFieldNames.map((fieldName) => ({
1081
1471
  key: fieldName,
1082
- label: getFieldLabelFromFields(this.fields || [], fieldName) || fieldName
1472
+ label: getFieldLabelFromFields(this.fields || [], fieldName) || fieldName,
1473
+ render: this.fieldRenderers?.[fieldName]
1474
+ // 如果有自定义渲染函数则使用
1083
1475
  }));
1084
1476
  return /* @__PURE__ */ jsxRuntime.jsx(DetailPage, { item, fields });
1085
1477
  }
@@ -1157,6 +1549,8 @@ var DefaultEditFeature = class extends BaseFormFeature {
1157
1549
  this.getItem = options.getItem;
1158
1550
  this.updateItem = options.updateItem;
1159
1551
  this.formFieldNames = options.formFieldNames;
1552
+ this.groups = options.groups;
1553
+ this.formFieldRenderers = options.formFieldRenderers;
1160
1554
  }
1161
1555
  getFormAction() {
1162
1556
  return "edit";
@@ -1366,18 +1760,21 @@ function Button(props) {
1366
1760
  disabled ? "opacity-50 cursor-not-allowed" : "",
1367
1761
  className
1368
1762
  ].filter(Boolean).join(" ");
1763
+ const isNewWindow = rest.target === "_blank";
1369
1764
  const htmxAttrs = {};
1370
- if (hxGet) htmxAttrs["hx-get"] = hxGet;
1371
- if (hxPost) htmxAttrs["hx-post"] = hxPost;
1372
- if (hxPut) htmxAttrs["hx-put"] = hxPut;
1373
- if (hxDelete) htmxAttrs["hx-delete"] = hxDelete;
1374
- if (hxTarget) htmxAttrs["hx-target"] = hxTarget;
1375
- if (hxSwap) htmxAttrs["hx-swap"] = hxSwap;
1376
- if (hxPushUrl !== void 0)
1377
- htmxAttrs["hx-push-url"] = hxPushUrl === true ? "true" : hxPushUrl;
1378
- if (hxIndicator) htmxAttrs["hx-indicator"] = hxIndicator;
1379
- if (hxConfirm) htmxAttrs["hx-confirm"] = hxConfirm;
1380
- if (hxHeaders) htmxAttrs["hx-headers"] = hxHeaders;
1765
+ if (!isNewWindow) {
1766
+ if (hxGet) htmxAttrs["hx-get"] = hxGet;
1767
+ if (hxPost) htmxAttrs["hx-post"] = hxPost;
1768
+ if (hxPut) htmxAttrs["hx-put"] = hxPut;
1769
+ if (hxDelete) htmxAttrs["hx-delete"] = hxDelete;
1770
+ if (hxTarget) htmxAttrs["hx-target"] = hxTarget;
1771
+ if (hxSwap) htmxAttrs["hx-swap"] = hxSwap;
1772
+ if (hxPushUrl !== void 0)
1773
+ htmxAttrs["hx-push-url"] = hxPushUrl === true ? "true" : hxPushUrl;
1774
+ if (hxIndicator) htmxAttrs["hx-indicator"] = hxIndicator;
1775
+ if (hxConfirm) htmxAttrs["hx-confirm"] = hxConfirm;
1776
+ if (hxHeaders) htmxAttrs["hx-headers"] = hxHeaders;
1777
+ }
1381
1778
  const href = rest.href ?? hxGet ?? "#";
1382
1779
  const { className: _, ...otherRest } = rest;
1383
1780
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -1392,30 +1789,6 @@ function Button(props) {
1392
1789
  }
1393
1790
  );
1394
1791
  }
1395
- function Card(props) {
1396
- const {
1397
- children,
1398
- title,
1399
- className = "",
1400
- shadow = true,
1401
- bordered = false,
1402
- noPadding = false
1403
- } = props;
1404
- const baseClasses = "bg-white rounded-lg";
1405
- const shadowClass = shadow ? "shadow-sm hover:shadow-md transition-shadow" : "";
1406
- const borderClass = bordered ? "border border-gray-200" : "";
1407
- const paddingClass = noPadding ? "" : "p-6";
1408
- return /* @__PURE__ */ jsxRuntime.jsxs(
1409
- "div",
1410
- {
1411
- className: `${baseClasses} ${shadowClass} ${borderClass} ${className}`,
1412
- children: [
1413
- title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4 border-b border-gray-200", children: /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "text-lg font-semibold text-gray-900", children: title }) }),
1414
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: noPadding ? "" : paddingClass, children })
1415
- ]
1416
- }
1417
- );
1418
- }
1419
1792
  function EmptyState(props) {
1420
1793
  const { message = "\u6682\u65E0\u6570\u636E", children } = props;
1421
1794
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-center py-12", children: children || /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-gray-500 text-sm", children: message }) });
@@ -1599,20 +1972,24 @@ function ActionLink(props) {
1599
1972
  const { action, item } = props;
1600
1973
  const hrefValue = action.href(item);
1601
1974
  const isDelete = action.method === "delete";
1975
+ const isNewWindow = action.target === "_blank";
1602
1976
  const className = `${STYLES.actionLink.base} ${isDelete ? STYLES.actionLink.delete : STYLES.actionLink.default}`;
1603
- return /* @__PURE__ */ jsxRuntime.jsx(
1604
- "a",
1605
- {
1606
- href: hrefValue,
1607
- className,
1608
- "hx-get": !isDelete ? hrefValue : void 0,
1609
- "hx-delete": isDelete ? hrefValue : void 0,
1610
- "hx-confirm": isDelete ? "\u786E\u5B9A\u5220\u9664\u5417\uFF1F" : void 0,
1611
- "data-testid": `table-action-${action.label}`,
1612
- "aria-label": `${action.label}\uFF1A${item.id || item}`,
1613
- children: action.label
1614
- }
1615
- );
1977
+ const linkProps = {
1978
+ href: hrefValue,
1979
+ className,
1980
+ "data-testid": `table-action-${action.label}`,
1981
+ "aria-label": `${action.label}\uFF1A${item.id || item}`
1982
+ };
1983
+ if (isNewWindow) {
1984
+ linkProps.target = "_blank";
1985
+ linkProps.rel = "noopener noreferrer";
1986
+ } else if (isDelete) {
1987
+ linkProps["hx-delete"] = hrefValue;
1988
+ linkProps["hx-confirm"] = "\u786E\u5B9A\u5220\u9664\u5417\uFF1F";
1989
+ } else {
1990
+ linkProps["hx-get"] = hrefValue;
1991
+ }
1992
+ return /* @__PURE__ */ jsxRuntime.jsx("a", { ...linkProps, children: action.label });
1616
1993
  }
1617
1994
  function ActionCell(props) {
1618
1995
  const { actions, item, actionStyle } = props;
@@ -1633,15 +2010,18 @@ function ActionCell(props) {
1633
2010
  ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: STYLES.actionButton, "data-testid": "table-actions", children: actions.map((action, idx) => {
1634
2011
  const hrefValue = action.href(item);
1635
2012
  const isDelete = action.method === "delete";
2013
+ const isNewWindow = action.target === "_blank";
1636
2014
  return /* @__PURE__ */ jsxRuntime.jsx(
1637
2015
  Button,
1638
2016
  {
1639
2017
  variant: isDelete ? "danger" : "secondary",
1640
2018
  size: "sm",
1641
2019
  href: hrefValue,
1642
- "hx-get": !isDelete ? hrefValue : void 0,
2020
+ "hx-get": !isDelete && !isNewWindow ? hrefValue : void 0,
1643
2021
  "hx-delete": isDelete ? hrefValue : void 0,
1644
2022
  "hx-confirm": isDelete ? "\u786E\u5B9A\u5220\u9664\u5417\uFF1F" : void 0,
2023
+ target: isNewWindow ? "_blank" : void 0,
2024
+ rel: isNewWindow ? "noopener noreferrer" : void 0,
1645
2025
  className: action.class,
1646
2026
  "data-testid": `table-action-${action.label}`,
1647
2027
  "aria-label": `${action.label}\uFF1A${item.id || item}`,
@@ -1738,19 +2118,24 @@ function ListPage(props) {
1738
2118
  editPath,
1739
2119
  deletePath,
1740
2120
  listPath,
1741
- filterFields
2121
+ filterFields,
2122
+ openMode
1742
2123
  } = props;
1743
2124
  const actions = [];
1744
2125
  if (detailPath) {
2126
+ const mode = openMode?.detail || "dialog";
1745
2127
  actions.push({
1746
2128
  label: "\u67E5\u770B",
1747
- href: detailPath
2129
+ href: detailPath,
2130
+ target: mode === "newWindow" ? "_blank" : void 0
1748
2131
  });
1749
2132
  }
1750
2133
  if (editPath) {
2134
+ const mode = openMode?.edit || "dialog";
1751
2135
  actions.push({
1752
2136
  label: "\u7F16\u8F91",
1753
- href: editPath
2137
+ href: editPath,
2138
+ target: mode === "newWindow" ? "_blank" : void 0
1754
2139
  });
1755
2140
  }
1756
2141
  if (deletePath) {
@@ -1795,7 +2180,8 @@ function ListPage(props) {
1795
2180
  actions: actions.length > 0 ? actions.map((action) => ({
1796
2181
  label: action.label,
1797
2182
  href: (item) => action.href(item),
1798
- method: action.method
2183
+ method: action.method,
2184
+ target: action.target
1799
2185
  })) : void 0,
1800
2186
  pagination: {
1801
2187
  page: result.page,
@@ -1836,6 +2222,7 @@ var DefaultListFeature = class extends BaseFeature {
1836
2222
  listFieldNames;
1837
2223
  filterSchema;
1838
2224
  columnRenderers;
2225
+ openMode;
1839
2226
  constructor(options) {
1840
2227
  super({
1841
2228
  name: "list",
@@ -1851,6 +2238,7 @@ var DefaultListFeature = class extends BaseFeature {
1851
2238
  this.listFieldNames = options.listFieldNames;
1852
2239
  this.filterSchema = options.filterSchema;
1853
2240
  this.columnRenderers = options.columnRenderers;
2241
+ this.openMode = options.openMode;
1854
2242
  }
1855
2243
  getRoutes() {
1856
2244
  return [{ method: "get", path: "/list" }];
@@ -1871,9 +2259,12 @@ var DefaultListFeature = class extends BaseFeature {
1871
2259
  const prefix = context.prefix || "";
1872
2260
  const basePath = `${prefix}/${model.modelName}`;
1873
2261
  const listPath = `${basePath}/list`;
1874
- const createPath = model.features.get("create") ? `${basePath}/new?dialog=true` : void 0;
1875
- const detailPath = model.features.get("detail") ? (item) => `${basePath}/detail/${item.id}?dialog=true` : void 0;
1876
- const editPath = model.features.get("edit") ? (item) => `${basePath}/edit/${item.id}?dialog=true` : void 0;
2262
+ const createMode = this.openMode?.create || "dialog";
2263
+ const detailMode = this.openMode?.detail || "dialog";
2264
+ const editMode = this.openMode?.edit || "dialog";
2265
+ const createPath = model.features.get("create") ? createMode === "dialog" ? `${basePath}/new?dialog=true` : `${basePath}/new` : void 0;
2266
+ const detailPath = model.features.get("detail") ? (item) => detailMode === "dialog" ? `${basePath}/detail/${item.id}?dialog=true` : `${basePath}/detail/${item.id}` : void 0;
2267
+ const editPath = model.features.get("edit") ? (item) => editMode === "dialog" ? `${basePath}/edit/${item.id}?dialog=true` : `${basePath}/edit/${item.id}` : void 0;
1877
2268
  const deletePath = this.deleteItem && model.features.get("delete") ? (item) => `${basePath}/${item.id}` : void 0;
1878
2269
  return /* @__PURE__ */ jsxRuntime.jsx(
1879
2270
  ListPage,
@@ -1886,7 +2277,8 @@ var DefaultListFeature = class extends BaseFeature {
1886
2277
  editPath,
1887
2278
  deletePath,
1888
2279
  listPath,
1889
- filterFields
2280
+ filterFields,
2281
+ openMode: this.openMode
1890
2282
  }
1891
2283
  );
1892
2284
  }
@@ -1897,11 +2289,22 @@ var DefaultListFeature = class extends BaseFeature {
1897
2289
  const hasCreate = model.features.get("create") !== void 0;
1898
2290
  const actions = [];
1899
2291
  if (hasCreate) {
1900
- actions.push({
1901
- label: "\u65B0\u5EFA",
1902
- hxGet: `${basePath}/new?dialog=true`,
1903
- variant: "primary"
1904
- });
2292
+ const createMode = this.openMode?.create || "dialog";
2293
+ const createUrl = createMode === "dialog" ? `${basePath}/new?dialog=true` : `${basePath}/new`;
2294
+ if (createMode === "newWindow") {
2295
+ actions.push({
2296
+ label: "\u65B0\u5EFA",
2297
+ href: createUrl,
2298
+ variant: "primary",
2299
+ target: "_blank"
2300
+ });
2301
+ } else {
2302
+ actions.push({
2303
+ label: "\u65B0\u5EFA",
2304
+ hxGet: createUrl,
2305
+ variant: "primary"
2306
+ });
2307
+ }
1905
2308
  }
1906
2309
  return actions;
1907
2310
  }
@@ -2307,7 +2710,8 @@ function Dialog(props) {
2307
2710
  className = "",
2308
2711
  size = "lg",
2309
2712
  closeOnBackdropClick = true,
2310
- actions = []
2713
+ actions = [],
2714
+ fixedContentHeight = false
2311
2715
  } = props;
2312
2716
  const sizeClasses = {
2313
2717
  sm: "max-w-md",
@@ -2366,7 +2770,13 @@ function Dialog(props) {
2366
2770
  }
2367
2771
  )
2368
2772
  ] }),
2369
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex-1 overflow-y-auto p-6 bg-gray-50", children }),
2773
+ /* @__PURE__ */ jsxRuntime.jsx(
2774
+ "div",
2775
+ {
2776
+ className: `${fixedContentHeight ? "h-[70vh]" : "flex-1"} overflow-y-auto ${fixedContentHeight ? "p-0" : "p-6"} bg-gray-50`,
2777
+ children
2778
+ }
2779
+ ),
2370
2780
  actions.length > 0 && /* @__PURE__ */ jsxRuntime.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)) })
2371
2781
  ]
2372
2782
  }
@@ -2627,6 +3037,13 @@ function BaseLayout(props) {
2627
3037
  {
2628
3038
  dangerouslySetInnerHTML: {
2629
3039
  __html: `
3040
+ /* \u5BB9\u5668\u67E5\u8BE2\u652F\u6301 - \u5982\u679C Tailwind CDN \u4E0D\u652F\u6301\uFF0C\u4F7F\u7528\u539F\u751F CSS \u5BB9\u5668\u67E5\u8BE2 */
3041
+ @supports (container-type: inline-size) {
3042
+ .\\@container {
3043
+ container-type: inline-size;
3044
+ }
3045
+ }
3046
+
2630
3047
  @keyframes fadeIn {
2631
3048
  from { opacity: 0;}
2632
3049
  to { opacity: 1;}
@@ -2743,7 +3160,8 @@ function renderActionButton2(action, index) {
2743
3160
  submit,
2744
3161
  formId,
2745
3162
  onClick,
2746
- className = ""
3163
+ className = "",
3164
+ target
2747
3165
  } = action;
2748
3166
  if (submit && formId) {
2749
3167
  const variantStyles = {
@@ -2795,18 +3213,21 @@ function renderActionButton2(action, index) {
2795
3213
  } else if (label === "\u53D6\u6D88") {
2796
3214
  testId = "cancel-button";
2797
3215
  }
3216
+ const isNewWindow = target === "_blank";
2798
3217
  return /* @__PURE__ */ jsxRuntime.jsx(
2799
3218
  Button,
2800
3219
  {
2801
3220
  variant,
2802
3221
  href,
2803
- hxGet,
2804
- hxPost,
2805
- hxPut,
2806
- hxDelete,
2807
- hxConfirm: confirm,
3222
+ hxGet: !isNewWindow ? hxGet : void 0,
3223
+ hxPost: !isNewWindow ? hxPost : void 0,
3224
+ hxPut: !isNewWindow ? hxPut : void 0,
3225
+ hxDelete: !isNewWindow ? hxDelete : void 0,
3226
+ hxConfirm: !isNewWindow ? confirm : void 0,
2808
3227
  className,
2809
3228
  "data-testid": testId,
3229
+ target,
3230
+ rel: target === "_blank" ? "noopener noreferrer" : void 0,
2810
3231
  children: label
2811
3232
  },
2812
3233
  index
@@ -3149,9 +3570,9 @@ async function renderResult(ctx, context, result, renderOptions) {
3149
3570
  headers
3150
3571
  );
3151
3572
  }
3152
- if (context.redirectUrl) {
3573
+ if (context.redirectUrl && !context.refresh) {
3153
3574
  imeanServiceEngine.logger.info(
3154
- `[ResponseRenderer] Redirect URL found: ${context.redirectUrl} (isHtmxRequest: ${context.isHtmxRequest})`
3575
+ `[ResponseRenderer] Redirect URL found: ${context.redirectUrl} (isHtmxRequest: ${context.isHtmxRequest}, isDialog: ${context.isDialog})`
3155
3576
  );
3156
3577
  if (context.isHtmxRequest) {
3157
3578
  return ctx.html(/* @__PURE__ */ jsxRuntime.jsx("div", {}), 200, {
@@ -3160,7 +3581,11 @@ async function renderResult(ctx, context, result, renderOptions) {
3160
3581
  } else {
3161
3582
  return ctx.redirect(context.redirectUrl);
3162
3583
  }
3163
- } else {
3584
+ } else if (context.redirectUrl && context.refresh) {
3585
+ imeanServiceEngine.logger.info(
3586
+ `[ResponseRenderer] Both redirect URL and refresh are set, using refresh (isDialog: ${context.isDialog})`
3587
+ );
3588
+ } else if (!context.redirectUrl) {
3164
3589
  imeanServiceEngine.logger.info(
3165
3590
  `[ResponseRenderer] No redirect URL found (result: ${result === null ? "null" : typeof result}, isHtmxRequest: ${context.isHtmxRequest})`
3166
3591
  );
@@ -3211,6 +3636,8 @@ async function renderResult(ctx, context, result, renderOptions) {
3211
3636
  if (context.isDialog) {
3212
3637
  const dialogSize = renderOptions.feature?.dialogSize || "lg";
3213
3638
  const closeOnBackdropClick = renderOptions.feature?.closeOnBackdropClick ?? true;
3639
+ const isFormFeature = renderOptions.feature?.type === "create" || renderOptions.feature?.type === "edit";
3640
+ const fixedContentHeight = isFormFeature;
3214
3641
  return ctx.html(
3215
3642
  /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3216
3643
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -3220,6 +3647,7 @@ async function renderResult(ctx, context, result, renderOptions) {
3220
3647
  size: dialogSize,
3221
3648
  closeOnBackdropClick,
3222
3649
  actions,
3650
+ fixedContentHeight,
3223
3651
  children: result
3224
3652
  }
3225
3653
  ),
@@ -3480,6 +3908,28 @@ function registerPageRoutes(page, options) {
3480
3908
  });
3481
3909
  };
3482
3910
  options.hono[route.method](fullPath, handler);
3911
+ if (route.method === "put" || route.method === "delete") {
3912
+ const postHandler = async (ctx) => {
3913
+ const methodOverride = ctx.req.header("X-HTTP-Method-Override");
3914
+ const expectedMethod = route.method.toUpperCase();
3915
+ if (methodOverride === expectedMethod) {
3916
+ imeanServiceEngine.logger.info(
3917
+ `[HtmxAdminPlugin] Method override detected: POST ${fullPath} -> ${expectedMethod} (feature: ${feature.name})`
3918
+ );
3919
+ return handleRequest(ctx, page, feature, {
3920
+ options: options.options
3921
+ });
3922
+ }
3923
+ imeanServiceEngine.logger.warn(
3924
+ `[HtmxAdminPlugin] POST request to ${fullPath} without matching X-HTTP-Method-Override header (got: ${methodOverride || "none"}, expected: ${expectedMethod})`
3925
+ );
3926
+ return ctx.text("Method Not Allowed", 405);
3927
+ };
3928
+ imeanServiceEngine.logger.info(
3929
+ `[HtmxAdminPlugin] Registering POST route for method override: POST ${fullPath} (actual method: ${route.method.toUpperCase()}, feature: ${feature.name})`
3930
+ );
3931
+ options.hono.post(fullPath, postHandler);
3932
+ }
3483
3933
  }
3484
3934
  }
3485
3935
  }
@@ -3569,6 +4019,434 @@ var HtmxAdminPlugin = class {
3569
4019
  registerHomeRedirect(this.pages, routeOptions);
3570
4020
  }
3571
4021
  };
4022
+ function ObjectEditor(props) {
4023
+ const { value, fieldName, objectSchema } = props;
4024
+ if (!objectSchema) {
4025
+ return /* @__PURE__ */ jsxRuntime.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" });
4026
+ }
4027
+ const fields = parseSchemaToFields(objectSchema);
4028
+ const initialObject = value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
4029
+ fields.forEach((field) => {
4030
+ if (!(field.name in initialObject)) {
4031
+ if (field.type === "number") {
4032
+ initialObject[field.name] = field.required ? 0 : void 0;
4033
+ } else if (field.type === "checkbox") {
4034
+ initialObject[field.name] = field.required ? false : void 0;
4035
+ } else {
4036
+ initialObject[field.name] = field.required ? "" : void 0;
4037
+ }
4038
+ }
4039
+ });
4040
+ const fieldNames = fields.map((f) => f.name);
4041
+ const fieldNamesJson = JSON.stringify(fieldNames);
4042
+ const initialValueJson = JSON.stringify(initialObject);
4043
+ const xDataContent = `{
4044
+ obj: {},
4045
+ init() {
4046
+ const dataAttr = this.$el.getAttribute('data-initial-value');
4047
+ if (dataAttr) {
4048
+ try {
4049
+ this.obj = JSON.parse(dataAttr);
4050
+ } catch (e) {
4051
+ console.error('Failed to parse initial value:', e);
4052
+ this.obj = {};
4053
+ }
4054
+ }
4055
+ const fieldNames = ${fieldNamesJson};
4056
+ fieldNames.forEach(fieldName => {
4057
+ if (!(fieldName in this.obj)) {
4058
+ this.obj[fieldName] = undefined;
4059
+ }
4060
+ });
4061
+ this.updateHiddenField();
4062
+ },
4063
+ updateHiddenField() {
4064
+ const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
4065
+ if (hiddenInput) {
4066
+ hiddenInput.value = JSON.stringify(this.obj);
4067
+ }
4068
+ },
4069
+ updateField(fieldName, value, fieldType, required) {
4070
+ let convertedValue = value;
4071
+ if (fieldType === 'number') {
4072
+ convertedValue = value === '' ? (required ? 0 : undefined) : Number(value);
4073
+ if (isNaN(convertedValue)) convertedValue = required ? 0 : undefined;
4074
+ } else if (fieldType === 'checkbox') {
4075
+ convertedValue = value === 'true' || value === true || value === '1' || value === 1;
4076
+ } else {
4077
+ convertedValue = value || (required ? '' : undefined);
4078
+ }
4079
+ if (convertedValue === undefined && !required) {
4080
+ delete this.obj[fieldName];
4081
+ } else {
4082
+ this.obj[fieldName] = convertedValue;
4083
+ }
4084
+ this.updateHiddenField();
4085
+ }
4086
+ }`;
4087
+ const generateField = (field) => {
4088
+ const fieldId = `${fieldName}-${field.name}`;
4089
+ const fieldNameVar = `obj.${field.name}`;
4090
+ const requiredValue = field.required ? "true" : "false";
4091
+ const fieldNameForJs = JSON.stringify(field.name);
4092
+ let inputElement;
4093
+ if (field.type === "text") {
4094
+ inputElement = html.html`
4095
+ <input
4096
+ type="text"
4097
+ id="${fieldId}"
4098
+ x-bind:value="${fieldNameVar} || ''"
4099
+ x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4100
+ 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"
4101
+ data-testid="${fieldName}-input-${field.name}"
4102
+ ${field.required ? "required" : ""}
4103
+ />
4104
+ `;
4105
+ } else if (field.type === "textarea") {
4106
+ inputElement = html.html`
4107
+ <textarea
4108
+ id="${fieldId}"
4109
+ x-bind:value="${fieldNameVar} || ''"
4110
+ x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4111
+ rows="4"
4112
+ 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"
4113
+ data-testid="${fieldName}-input-${field.name}"
4114
+ ${field.required ? "required" : ""}
4115
+ ></textarea>
4116
+ `;
4117
+ } else if (field.type === "number") {
4118
+ const step = field.step || (field.step === void 0 ? "1" : "any");
4119
+ inputElement = html.html`
4120
+ <input
4121
+ type="number"
4122
+ id="${fieldId}"
4123
+ x-bind:value="${fieldNameVar} != null ? ${fieldNameVar} : ''"
4124
+ x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'number', ${requiredValue})"
4125
+ step="${step}"
4126
+ 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"
4127
+ data-testid="${fieldName}-input-${field.name}"
4128
+ ${field.required ? "required" : ""}
4129
+ />
4130
+ `;
4131
+ } else if (field.type === "date") {
4132
+ inputElement = html.html`
4133
+ <input
4134
+ type="date"
4135
+ id="${fieldId}"
4136
+ x-bind:value="${fieldNameVar} || ''"
4137
+ x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'date', ${requiredValue})"
4138
+ 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"
4139
+ data-testid="${fieldName}-input-${field.name}"
4140
+ ${field.required ? "required" : ""}
4141
+ />
4142
+ `;
4143
+ } else if (field.type === "email") {
4144
+ inputElement = html.html`
4145
+ <input
4146
+ type="email"
4147
+ id="${fieldId}"
4148
+ x-bind:value="${fieldNameVar} || ''"
4149
+ x-on:input="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4150
+ 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"
4151
+ data-testid="${fieldName}-input-${field.name}"
4152
+ ${field.required ? "required" : ""}
4153
+ />
4154
+ `;
4155
+ } else if (field.type === "select" && field.options) {
4156
+ inputElement = html.html`
4157
+ <select
4158
+ id="${fieldId}"
4159
+ x-bind:value="${fieldNameVar} || ''"
4160
+ x-on:change="updateField(${fieldNameForJs}, $event.target.value, 'text', ${requiredValue})"
4161
+ 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"
4162
+ data-testid="${fieldName}-select-${field.name}"
4163
+ ${field.required ? "required" : ""}
4164
+ >
4165
+ ${!field.required ? html.html`<option value="">请选择</option>` : ""}
4166
+ ${field.options.map(
4167
+ (option) => html.html`
4168
+ <option value="${String(option.value)}">${option.label}</option>
4169
+ `
4170
+ )}
4171
+ </select>
4172
+ `;
4173
+ } else if (field.type === "checkbox") {
4174
+ inputElement = html.html`
4175
+ <div class="flex items-center">
4176
+ <input
4177
+ type="checkbox"
4178
+ id="${fieldId}"
4179
+ x-bind:checked="${fieldNameVar} === true || ${fieldNameVar} === 'true' || ${fieldNameVar} === 1 || ${fieldNameVar} === '1'"
4180
+ x-on:change="updateField(${fieldNameForJs}, $event.target.checked, 'checkbox', ${requiredValue})"
4181
+ class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
4182
+ data-testid="${fieldName}-checkbox-${field.name}"
4183
+ />
4184
+ <label for="${fieldId}" class="ml-2 text-sm text-gray-700">
4185
+ ${field.label}
4186
+ </label>
4187
+ </div>
4188
+ `;
4189
+ }
4190
+ return html.html`
4191
+ <div class="space-y-2" data-testid="${fieldName}-field-${field.name}">
4192
+ ${field.type !== "checkbox" ? html.html`
4193
+ <label
4194
+ for="${fieldId}"
4195
+ class="block text-sm font-semibold text-gray-700"
4196
+ data-testid="${fieldName}-label-${field.name}"
4197
+ >
4198
+ ${field.label}
4199
+ ${field.required ? html.html`<span class="text-red-500 ml-1">*</span>` : ""}
4200
+ </label>
4201
+ ` : ""}
4202
+ ${inputElement}
4203
+ </div>
4204
+ `;
4205
+ };
4206
+ return html.html`
4207
+ <div
4208
+ x-data="${xDataContent}"
4209
+ data-initial-value="${initialValueJson}"
4210
+ x-init="init()"
4211
+ class="space-y-4"
4212
+ >
4213
+ <input
4214
+ type="hidden"
4215
+ name="${fieldName}"
4216
+ value=""
4217
+ data-testid="hidden-${fieldName}"
4218
+ />
4219
+ <div class="space-y-4">
4220
+ ${fields.map((field) => generateField(field))}
4221
+ </div>
4222
+ </div>
4223
+ `;
4224
+ }
4225
+ function StringArrayEditor(props) {
4226
+ const {
4227
+ value,
4228
+ fieldName,
4229
+ placeholder = "\u8BF7\u8F93\u5165\u5185\u5BB9",
4230
+ allowEmpty = false
4231
+ } = props;
4232
+ const initialItems = value || [];
4233
+ const initialValueJson = JSON.stringify(initialItems);
4234
+ const xDataContent = `{
4235
+ items: ${initialValueJson},
4236
+ draggedIndex: null,
4237
+ draggedOverIndex: null,
4238
+ fieldName: ${JSON.stringify(fieldName)},
4239
+ placeholder: ${JSON.stringify(placeholder)},
4240
+ allowEmpty: ${allowEmpty},
4241
+ init() {
4242
+ const dataAttr = this.$el.getAttribute('data-initial-value');
4243
+ if (dataAttr) {
4244
+ try {
4245
+ const parsed = JSON.parse(dataAttr);
4246
+ if (Array.isArray(parsed)) {
4247
+ this.items = parsed;
4248
+ } else {
4249
+ this.items = [];
4250
+ }
4251
+ } catch (e) {
4252
+ console.error('Failed to parse initial value:', e);
4253
+ this.items = [];
4254
+ }
4255
+ }
4256
+ this.updateHiddenField();
4257
+ },
4258
+ updateHiddenField() {
4259
+ const hiddenInput = this.$el.querySelector('input[name="${fieldName}"][type="hidden"]');
4260
+ if (hiddenInput) {
4261
+ hiddenInput.value = JSON.stringify(this.items);
4262
+ }
4263
+ },
4264
+ addItem() {
4265
+ this.items.push('');
4266
+ this.updateHiddenField();
4267
+ this.$nextTick(() => {
4268
+ const keyInputs = this.$el.querySelectorAll('input[data-testid*="-input-"]');
4269
+ if (keyInputs.length > 0) {
4270
+ const lastInput = keyInputs[keyInputs.length - 1];
4271
+ if (lastInput && lastInput.focus) {
4272
+ lastInput.focus();
4273
+ }
4274
+ }
4275
+ });
4276
+ },
4277
+ removeItem(index) {
4278
+ this.items.splice(index, 1);
4279
+ this.updateHiddenField();
4280
+ },
4281
+ updateItem(index, value) {
4282
+ this.items[index] = value;
4283
+ this.updateHiddenField();
4284
+ },
4285
+ handleDragStart(index, event) {
4286
+ this.draggedIndex = index;
4287
+ event.dataTransfer.effectAllowed = 'move';
4288
+ event.dataTransfer.setData('text/plain', index.toString());
4289
+ const target = event.currentTarget || event.target.closest('[draggable="true"]');
4290
+ if (target) {
4291
+ target.style.opacity = '0.5';
4292
+ }
4293
+ },
4294
+ handleDragEnd(event) {
4295
+ const target = event.currentTarget || event.target.closest('[draggable="true"]');
4296
+ if (target) {
4297
+ target.style.opacity = '';
4298
+ }
4299
+ this.draggedIndex = null;
4300
+ this.draggedOverIndex = null;
4301
+ },
4302
+ handleDragOver(index, event) {
4303
+ event.preventDefault();
4304
+ event.dataTransfer.dropEffect = 'move';
4305
+ this.draggedOverIndex = index;
4306
+ },
4307
+ handleDragLeave() {
4308
+ this.draggedOverIndex = null;
4309
+ },
4310
+ handleDrop(index, event) {
4311
+ event.preventDefault();
4312
+ if (this.draggedIndex !== null && this.draggedIndex !== index) {
4313
+ const draggedItem = this.items[this.draggedIndex];
4314
+ this.items.splice(this.draggedIndex, 1);
4315
+ this.items.splice(index, 0, draggedItem);
4316
+ this.updateHiddenField();
4317
+ }
4318
+ this.draggedIndex = null;
4319
+ this.draggedOverIndex = null;
4320
+ }
4321
+ }`;
4322
+ return html.html`
4323
+ <div
4324
+ x-data="${xDataContent}"
4325
+ data-initial-value="${initialValueJson}"
4326
+ x-init="init()"
4327
+ class="space-y-3"
4328
+ >
4329
+ <input
4330
+ type="hidden"
4331
+ name="${fieldName}"
4332
+ value=""
4333
+ data-testid="hidden-${fieldName}"
4334
+ />
4335
+ <div class="space-y-3">
4336
+ <!-- 头部:显示数量和添加按钮 -->
4337
+ <div class="flex items-center justify-between">
4338
+ <span class="text-sm text-gray-600">
4339
+ 共 <span x-text="items.length">0</span> 项
4340
+ </span>
4341
+ <button
4342
+ type="button"
4343
+ x-on:click="addItem()"
4344
+ 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"
4345
+ data-testid="${fieldName}-add-button"
4346
+ >
4347
+ <svg
4348
+ class="w-4 h-4"
4349
+ fill="none"
4350
+ stroke="currentColor"
4351
+ viewBox="0 0 24 24"
4352
+ >
4353
+ <path
4354
+ stroke-linecap="round"
4355
+ stroke-linejoin="round"
4356
+ stroke-width="2"
4357
+ d="M12 4v16m8-8H4"
4358
+ />
4359
+ </svg>
4360
+ 添加项
4361
+ </button>
4362
+ </div>
4363
+
4364
+ <!-- 列表项 -->
4365
+ <div class="space-y-2" x-show="items.length > 0">
4366
+ <template x-for="(item, index) in items" x-bind:key="index">
4367
+ <div
4368
+ class="flex items-center gap-2 group"
4369
+ x-bind:class="{
4370
+ 'opacity-50': draggedIndex === index,
4371
+ 'border-blue-300 bg-blue-50': draggedOverIndex === index && draggedIndex !== null && draggedIndex !== index
4372
+ }"
4373
+ draggable="true"
4374
+ x-on:dragstart="handleDragStart(index, $event)"
4375
+ x-on:dragend="handleDragEnd($event)"
4376
+ x-on:dragover="handleDragOver(index, $event)"
4377
+ x-on:dragleave="handleDragLeave()"
4378
+ x-on:drop="handleDrop(index, $event)"
4379
+ x-bind:data-testid="fieldName + '-item-' + index"
4380
+ >
4381
+ <!-- 拖拽手柄 -->
4382
+ <div
4383
+ class="flex-shrink-0 cursor-move text-gray-400 hover:text-gray-600 transition-colors p-1"
4384
+ x-bind:data-testid="fieldName + '-drag-handle-' + index"
4385
+ title="拖拽排序"
4386
+ >
4387
+ <svg
4388
+ class="w-5 h-5"
4389
+ fill="none"
4390
+ stroke="currentColor"
4391
+ viewBox="0 0 24 24"
4392
+ >
4393
+ <path
4394
+ stroke-linecap="round"
4395
+ stroke-linejoin="round"
4396
+ stroke-width="2"
4397
+ d="M4 8h16M4 16h16"
4398
+ />
4399
+ </svg>
4400
+ </div>
4401
+
4402
+ <!-- 输入框 -->
4403
+ <input
4404
+ type="text"
4405
+ x-bind:value="items[index] || ''"
4406
+ x-on:input="updateItem(index, $event.target.value)"
4407
+ x-bind:placeholder="placeholder + ' ' + (index + 1)"
4408
+ 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"
4409
+ x-bind:data-testid="fieldName + '-input-' + index"
4410
+ x-bind:required="!allowEmpty"
4411
+ />
4412
+
4413
+ <!-- 删除按钮 -->
4414
+ <button
4415
+ type="button"
4416
+ x-on:click="removeItem(index)"
4417
+ class="flex-shrink-0 px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
4418
+ x-bind:data-testid="fieldName + '-remove-button-' + index"
4419
+ title="删除此项"
4420
+ >
4421
+ <svg
4422
+ class="w-5 h-5"
4423
+ fill="none"
4424
+ stroke="currentColor"
4425
+ viewBox="0 0 24 24"
4426
+ >
4427
+ <path
4428
+ stroke-linecap="round"
4429
+ stroke-linejoin="round"
4430
+ stroke-width="2"
4431
+ 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"
4432
+ />
4433
+ </svg>
4434
+ </button>
4435
+ </div>
4436
+ </template>
4437
+ </div>
4438
+
4439
+ <!-- 空状态提示 -->
4440
+ <div
4441
+ x-show="items.length === 0"
4442
+ class="text-center py-8 text-gray-400 text-sm border border-dashed border-gray-300 rounded-lg"
4443
+ >
4444
+ 暂无项,点击"添加项"按钮添加
4445
+ </div>
4446
+ </div>
4447
+ </div>
4448
+ `;
4449
+ }
3572
4450
 
3573
4451
  exports.BaseFeature = BaseFeature;
3574
4452
  exports.CustomFeature = CustomFeature;
@@ -3581,7 +4459,9 @@ exports.Dialog = Dialog;
3581
4459
  exports.ErrorAlert = ErrorAlert;
3582
4460
  exports.HtmxAdminPlugin = HtmxAdminPlugin;
3583
4461
  exports.LoadingBar = LoadingBar;
4462
+ exports.ObjectEditor = ObjectEditor;
3584
4463
  exports.PageModel = PageModel;
4464
+ exports.StringArrayEditor = StringArrayEditor;
3585
4465
  exports.checkUserPermission = checkUserPermission;
3586
4466
  exports.getUserInfo = getUserInfo;
3587
4467
  exports.modelNameToPath = modelNameToPath;