index.tsx 10.4 KB
import React, { useCallback, useEffect, 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 { LabelTemplate, LabelElement } from '../../../types/labelTemplate';
import {
  createDefaultTemplate,
  createDefaultElement,
  defaultValueSourceTypeForElement,
} from '../../../types/labelTemplate';
import type { LocationDto } from '../../../types/location';
import { getLocations } from '../../../services/locationService';
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 [locations, setLocations] = useState<LocationDto[]>([]);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await getLocations({ skipCount: 0, maxResultCount: 500 });
        if (!cancelled) setLocations(res.items ?? []);
      } catch {
        if (!cancelled) setLocations([]);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

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

  const addElement = useCallback((
    type: Parameters<typeof createDefaultElement>[0],
    configOverride?: Partial<Record<string, unknown>>
  ) => {
    // 计算画布中心位置(像素)
    const unitToPx = (value: number, unit: 'cm' | 'inch'): number => {
      return unit === 'cm' ? value * 37.8 : value * 96;
    };
    
    const canvasWidthPx = unitToPx(template.width, template.unit);
    const canvasHeightPx = unitToPx(template.height, template.unit);
    
    // 创建默认元素以获取其尺寸
    const tempEl = createDefaultElement(type, 0, 0);
    
    // 对齐到网格
    const GRID_SIZE = 8;
    const snapToGrid = (value: number): number => {
      return Math.round(value / GRID_SIZE) * GRID_SIZE;
    };
    
    // 计算元素中心对齐到画布中心的位置
    let centerX = (canvasWidthPx - tempEl.width) / 2;
    let centerY = (canvasHeightPx - tempEl.height) / 2;
    
    // 检查是否与现有元素重叠,如果重叠则尝试偏移
    const checkOverlap = (x: number, y: number, width: number, height: number): boolean => {
      return template.elements.some((el) => {
        const elRight = el.x + el.width;
        const elBottom = el.y + el.height;
        const newRight = x + width;
        const newBottom = y + height;
        return !(x >= elRight || newRight <= el.x || y >= elBottom || newBottom <= el.y);
      });
    };
    
    // 如果中心位置重叠,尝试在周围寻找空位
    if (checkOverlap(centerX, centerY, tempEl.width, tempEl.height)) {
      const offset = GRID_SIZE * 2;
      let found = false;
      // 尝试右下方
      for (let tryY = centerY; tryY < canvasHeightPx - tempEl.height && !found; tryY += offset) {
        for (let tryX = centerX; tryX < canvasWidthPx - tempEl.width && !found; tryX += offset) {
          if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) {
            centerX = tryX;
            centerY = tryY;
            found = true;
            break;
          }
        }
      }
      // 如果还没找到,尝试左上方
      if (!found) {
        for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) {
          for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) {
            if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) {
              centerX = tryX;
              centerY = tryY;
              found = true;
              break;
            }
          }
        }
      }
    }
    
    const el = createDefaultElement(type, Math.max(0, snapToGrid(centerX)), Math.max(0, snapToGrid(centerY)));
    if (configOverride && Object.keys(configOverride).length > 0) {
      el.config = { ...el.config, ...configOverride };
    }
    setTemplate((prev) => ({
      ...prev,
      elements: [...prev.elements, el],
    }));
    setSelectedId(el.id);
  }, [template.width, template.height, template.unit, template.elements]);

  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;
      }

      // 转换 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: template.elements.map((el, index) => ({
          id: el.id,
          type: el.type,
          x: el.x,
          y: el.y,
          width: el.width,
          height: el.height,
          rotation: el.rotation,
          border: el.border,
          zIndex: el.zIndex ?? index + 1,
          orderNum: el.orderNum ?? index + 1,
          valueSourceType: el.valueSourceType ?? defaultValueSourceTypeForElement(el.type),
          isRequiredInput: el.isRequiredInput ?? false,
          config: el.config,
        })),
        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 blob = new Blob([JSON.stringify(template, 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] overflow-auto">
            <DialogHeader>
              <DialogTitle>标签预览</DialogTitle>
            </DialogHeader>
            <LabelPreviewOnly template={template} maxWidth={500} />
          </DialogContent>
        </Dialog>
        <PropertiesPanel
          template={template}
          selectedElement={selectedElement}
          onTemplateChange={handleTemplateChange}
          onElementChange={updateElement}
          onDeleteElement={deleteElement}
          locations={locations}
          readOnlyTemplateCode={!!templateId}
        />
      </div>
    </div>
  );
}