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