index.tsx 11.4 KB
import React, { useCallback, useState } from 'react';
import { Button } from '../../ui/button';
import { ArrowLeft, Save, Download } from 'lucide-react';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '../../ui/dialog';
import type { ElementLibraryCategory, LabelTemplate, LabelElement } from '../../../types/labelTemplate';
import {
  allocateElementName,
  canonicalElementType,
  composeElementTypeForPersist,
  composeLibraryCategoryForPersist,
  createDefaultTemplate,
  createDefaultElement,
  labelElementsToApiPayload,
  resolvedLibraryCategoryForPersist,
  resolvedTypeAddForPersist,
  resolvedValueSourceTypeForSave,
  stripLabelConfigPrefixes,
  valueSourceTypeForLibraryCategory,
} from '../../../types/labelTemplate';
import { ElementsPanel } from './ElementsPanel';
import { LabelCanvas, LabelPreviewOnly } from './LabelCanvas';
import { PropertiesPanel } from './PropertiesPanel';
import { createLabelTemplate, updateLabelTemplate } from '../../../services/labelTemplateService';
import { toast } from 'sonner';

const MIN_SCALE = 0.5;
const MAX_SCALE = 2;
const SCALE_STEP = 0.25;
const DEFAULT_SCALE = 1.0;

interface LabelTemplateEditorProps {
  /** null = 新建,string = 编辑该 id */
  templateId: string | null;
  initialTemplate: LabelTemplate | null;
  onClose: () => void;
  onSaved: () => void;
}

export function LabelTemplateEditor({
  templateId,
  initialTemplate,
  onClose,
  onSaved,
}: LabelTemplateEditorProps) {
  const [template, setTemplate] = useState<LabelTemplate>(() => {
    if (initialTemplate) return { ...initialTemplate };
    return createDefaultTemplate(templateId ?? undefined);
  });
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [scale, setScale] = useState(DEFAULT_SCALE);
  const [previewOpen, setPreviewOpen] = useState(false);

  const selectedElement = template.elements.find((el) => el.id === selectedId) ?? null;

  const addElement = useCallback((
    type: Parameters<typeof createDefaultElement>[0],
    configOverride: Partial<Record<string, unknown>> | undefined,
    libraryCategory: ElementLibraryCategory,
    paletteItemLabel: string,
  ) => {
    let addedId = "";
    setTemplate((prev) => {
      const unitToPx = (value: number, unit: "cm" | "inch"): number =>
        unit === "cm" ? value * 37.8 : value * 96;

      const canvasWidthPx = unitToPx(prev.width, prev.unit);
      const canvasHeightPx = unitToPx(prev.height, prev.unit);

      let el = createDefaultElement(type, 0, 0);
      const GRID_SIZE = 8;
      const snapToGrid = (value: number): number =>
        Math.round(value / GRID_SIZE) * GRID_SIZE;

      let centerX = (canvasWidthPx - el.width) / 2;
      let centerY = (canvasHeightPx - el.height) / 2;

      const checkOverlap = (x: number, y: number, width: number, height: number): boolean =>
        prev.elements.some((o) => {
          const elRight = o.x + o.width;
          const elBottom = o.y + o.height;
          const newRight = x + width;
          const newBottom = y + height;
          return !(x >= elRight || newRight <= o.x || y >= elBottom || newBottom <= o.y);
        });

      if (checkOverlap(centerX, centerY, el.width, el.height)) {
        const offset = GRID_SIZE * 2;
        let found = false;
        for (let tryY = centerY; tryY < canvasHeightPx - el.height && !found; tryY += offset) {
          for (let tryX = centerX; tryX < canvasWidthPx - el.width && !found; tryX += offset) {
            if (!checkOverlap(tryX, tryY, el.width, el.height)) {
              centerX = tryX;
              centerY = tryY;
              found = true;
            }
          }
        }
        if (!found) {
          for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) {
            for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) {
              if (!checkOverlap(tryX, tryY, el.width, el.height)) {
                centerX = tryX;
                centerY = tryY;
                found = true;
              }
            }
          }
        }
      }

      el = {
        ...el,
        x: Math.max(0, snapToGrid(centerX)),
        y: Math.max(0, snapToGrid(centerY)),
      };
      if (configOverride && Object.keys(configOverride).length > 0) {
        el.config = { ...el.config, ...configOverride };
      }
      const elementName = allocateElementName(paletteItemLabel, prev.elements);
      const vst = valueSourceTypeForLibraryCategory(libraryCategory);
      el = {
        ...el,
        type: el.type,
        typeAdd: composeElementTypeForPersist(libraryCategory, paletteItemLabel),
        libraryCategory: composeLibraryCategoryForPersist(libraryCategory, paletteItemLabel),
        valueSourceType: vst,
        elementName,
      };
      addedId = el.id;
      return { ...prev, elements: [...prev.elements, el] };
    });
    setSelectedId(addedId);
  }, [template.width, template.height, template.unit]);

  const updateElement = useCallback((id: string, patch: Partial<LabelElement>) => {
    setTemplate((prev) => ({
      ...prev,
      elements: prev.elements.map((el) =>
        el.id === id ? { ...el, ...patch } : el
      ),
    }));
  }, []);

  const deleteElement = useCallback((id: string) => {
    setTemplate((prev) => ({
      ...prev,
      elements: prev.elements.filter((el) => el.id !== id),
    }));
    setSelectedId(null);
  }, []);

  const handleTemplateChange = useCallback((patch: Partial<LabelTemplate>) => {
    setTemplate((prev) => ({ ...prev, ...patch }));
  }, []);

  const handleSave = useCallback(async () => {
    try {
      const code = (template.id ?? "").trim();
      if (!code) {
        toast.error("Template code is required.", {
          description: "Please enter a template code (e.g. TPL_TEST_001).",
        });
        return;
      }
      if (template.appliedLocation === "SPECIFIED" && !(template.appliedLocationIds?.length ?? 0)) {
        toast.error("Locations required.", {
          description: "When using specified locations, select at least one location.",
        });
        return;
      }

      const emptyName = template.elements.find(
        (el) => !(el.elementName ?? "").trim(),
      );
      if (emptyName) {
        toast.error("Component name required.", {
          description: "Each element must have a non-empty element name.",
        });
        return;
      }

      const optionsWithoutDictionary = template.elements.find((el) => {
        if (el.type !== "TEXT_STATIC") return false;
        const cfg = el.config as Record<string, unknown>;
        if (String(cfg?.inputType ?? "").toLowerCase() !== "options") return false;
        const mid = String(cfg?.multipleOptionId ?? cfg?.MultipleOptionId ?? "").trim();
        return !mid;
      });
      if (optionsWithoutDictionary) {
        toast.error("Option dictionary required.", {
          description:
            "Each Multiple Options element must have an Option dictionary selected in the properties panel.",
        });
        return;
      }

      // 转换 LabelTemplate 到 API 需要的格式(对齐 LabelTemplateCreateInputVo)
      const apiInput = {
        id: code,
        name: template.name,
        labelType: template.labelType,
        unit: template.unit,
        width: template.width,
        height: template.height,
        appliedLocation: template.appliedLocation,
        showRuler: template.showRuler,
        showGrid: template.showGrid ?? true,
        state: true,
        elements: labelElementsToApiPayload(template.elements),
        appliedLocationIds:
          template.appliedLocation === "ALL" ? [] : (template.appliedLocationIds ?? []),
      };

      if (templateId) {
        // 编辑模式:使用 TemplateCode 作为 id
        await updateLabelTemplate(code, apiInput);
        toast.success("Template updated.", {
          description: "The template has been updated successfully.",
        });
      } else {
        // 新建模式
        await createLabelTemplate(apiInput);
        toast.success("Template created.", {
          description: "The template has been created successfully.",
        });
      }
      onSaved();
      onClose();
    } catch (e: any) {
      toast.error("Failed to save template.", {
        description: e?.message ? String(e.message) : "Please try again.",
      });
    }
  }, [template, templateId, onSaved, onClose]);

  const handleExport = useCallback(() => {
    const payload: LabelTemplate = {
      ...template,
      elements: template.elements.map((el) => ({
        ...el,
        type: canonicalElementType(el.type),
        typeAdd: resolvedTypeAddForPersist(el),
        elementName: (el.elementName ?? "").trim(),
        valueSourceType: resolvedValueSourceTypeForSave(el),
        libraryCategory: resolvedLibraryCategoryForPersist(el),
        config: stripLabelConfigPrefixes((el.config ?? {}) as Record<string, unknown>),
      })),
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], {
      type: 'application/json',
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `label-template-${template.id}.json`;
    a.click();
    URL.revokeObjectURL(url);
  }, [template]);

  return (
    <div className="flex flex-col h-full min-h-0">
      {/* Toolbar */}
      <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 bg-white shrink-0">
        <Button variant="outline" size="sm" onClick={onClose}>
          <ArrowLeft className="w-4 h-4 mr-1" />
          Back
        </Button>
        <span className="text-sm font-medium text-gray-700 truncate flex-1">
          {template.name}
        </span>
        <Button size="sm" onClick={handleExport} variant="outline">
          <Download className="w-4 h-4 mr-1" />
          Export JSON
        </Button>
        <Button
          size="sm"
          className="bg-blue-600 hover:bg-blue-700 text-white"
          onClick={handleSave}
        >
          <Save className="w-4 h-4 mr-1" />
          Save
        </Button>
      </div>

      {/* Three columns */}
      <div className="flex flex-1 min-h-0">
        <ElementsPanel onAddElement={addElement} />
        <LabelCanvas
          template={template}
          selectedId={selectedId}
          onSelect={setSelectedId}
          onUpdateElement={updateElement}
          onDeleteElement={deleteElement}
          onTemplateChange={handleTemplateChange}
          scale={scale}
          onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))}
          onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))}
          onPreview={() => setPreviewOpen(true)}
        />
        <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
          <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 overflow-hidden flex flex-col">
            <DialogHeader className="shrink-0 px-6 py-4 border-b bg-white">
              <DialogTitle>Label preview</DialogTitle>
            </DialogHeader>
            <div className="flex-1 min-h-0 overflow-x-auto overflow-y-auto p-4 bg-gray-50">
              <div className="min-w-max">
                <LabelPreviewOnly template={template} maxWidth={0} />
              </div>
            </div>
          </DialogContent>
        </Dialog>
        <PropertiesPanel
          template={template}
          selectedElement={selectedElement}
          onTemplateChange={handleTemplateChange}
          onElementChange={updateElement}
          onDeleteElement={deleteElement}
          readOnlyTemplateCode={!!templateId}
        />
      </div>
    </div>
  );
}