Files
label-designer/src/App.tsx
2026-06-13 03:41:20 +08:00

1522 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types';
import {
stripTemplateForStorage,
normalizeTemplate,
DEFAULT_PAPER_CONFIG,
DEFAULT_EXPORT_RANGE,
DEFAULT_CUT_LINE_CONFIG,
exportRangeEquals,
buildPrintablePages,
extractTemplateVariables,
buildAutoVariableMapping,
applyRowVariableMapping,
type PrintableLabel,
} from './utils';
import { LabelDesigner } from './components/LabelDesigner';
import { PropSidebar } from './components/PropSidebar';
import { ExportSidebar } from './components/ExportSidebar';
import { MobileExportView } from './components/MobileExportView';
import { MobileDesignView } from './components/MobileDesignView';
import { LayoutPreview } from './components/PreviewGrid';
import { CanvasLabelImage } from './components/CanvasLabelImage';
import { renderLabelToDataUrl } from './labelRenderer';
import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport';
import { useIsMobile } from './hooks/useIsMobile';
import { useUndoRedo } from './hooks/useUndoRedo';
import { useAppDialog } from './components/AppDialog';
import { AnchoredMenu } from './components/AnchoredMenu';
import {
FileDown,
Code,
Loader2,
Plus,
Upload,
Printer,
Pencil,
Trash2,
MoreHorizontal,
Undo2,
Redo2,
X,
} from 'lucide-react';
import logoUrl from './assets/logo.svg';
const TEMPLATE_PREVIEW_MAX_W = 220;
const TEMPLATE_PREVIEW_MAX_H = 132;
const EMPTY_TEMPLATE_PLACEHOLDER: LabelTemplate = {
id: '__placeholder__',
name: '',
width: 70,
height: 40,
elements: [],
paper: DEFAULT_PAPER_CONFIG,
cutLine: DEFAULT_CUT_LINE_CONFIG,
};
function fitTemplatePreviewSize(widthMm: number, heightMm: number) {
const scale = Math.min(
TEMPLATE_PREVIEW_MAX_W / widthMm,
TEMPLATE_PREVIEW_MAX_H / heightMm
);
return {
displayWidthPx: widthMm * scale,
displayHeightPx: heightMm * scale,
};
}
export default function App() {
const { showAlert, showConfirm } = useAppDialog();
// --- 1. Templates Storage & Initial States ---
const [templates, setTemplates] = useState<LabelTemplate[]>(() => {
try {
const saved = localStorage.getItem('label_templates_list_v2');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
return parsed.map((t: LabelTemplate) => normalizeTemplate(t));
}
}
} catch (e) { }
return [];
});
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(() => {
try {
const savedList = localStorage.getItem('label_templates_list_v2');
const savedId = localStorage.getItem('selected_label_template_id_v2');
if (!savedList || !savedId) return '';
const parsed = JSON.parse(savedList);
if (Array.isArray(parsed) && parsed.some((t: LabelTemplate) => t.id === savedId)) {
return savedId;
}
} catch (e) { }
return '';
});
const hasTemplates = templates.length > 0;
const activeTemplate = useMemo(() => {
if (!hasTemplates) return null;
return templates.find((t) => t.id === selectedTemplateId) ?? templates[0];
}, [templates, selectedTemplateId, hasTemplates]);
const workingTemplate = activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER;
// --- 2. Workflow Step Manager ---
// Step 1: 'template-select' (Templates select/import + Associate excel data sources)
// Step 2: 'template-design' (Visual designer workspace canvas)
// Step 3: 'print-view' (Final pagination & print setting alignments grid)
const [workflowStep, setWorkflowStep] = useState<'template-select' | 'template-design' | 'print-view'>('template-select');
// --- 3. 排版导出会话数据(不持久化,每次进入排版页重新上传) ---
const [importData, setImportData] = useState<ImportData | null>(null);
const [variableColumnMapping, setVariableColumnMapping] = useState<Record<string, string>>({});
const [previewSnapshot, setPreviewSnapshot] = useState<{
pages: (PrintableLabel | null)[][];
gridCols: number;
labelsPerPage: number;
totalPages: number;
selectedCount: number;
paper: PaperConfig;
exportRange: ExportRangeConfig;
} | null>(null);
const [draftExportRange, setDraftExportRange] = useState<ExportRangeConfig>(DEFAULT_EXPORT_RANGE);
const [appliedExportData, setAppliedExportData] = useState<{
fileName: string;
currentSheet: string;
rows: RecordRow[];
mapping: Record<string, string>;
exportRange: ExportRangeConfig;
} | null>(null);
const [previewLayoutStale, setPreviewLayoutStale] = useState(false);
const isMobile = useIsMobile();
const [pdfGenerating, setPdfGenerating] = useState(false);
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
done: 0,
total: 0,
page: 0,
totalPages: 0,
phase: 'render',
});
// --- 5. Selection and Active item states ---
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
const [activeRowIndex, setActiveRowIndex] = useState<number>(0);
// Modal / Inline input values for template creations
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
const [newTmplName, setNewTmplName] = useState<string>('');
const [newTmplW, setNewTmplW] = useState<number>(75);
const [newTmplH, setNewTmplH] = useState<number>(45);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [editTmplName, setEditTmplName] = useState<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
const templateMenuAnchorRef = useRef<HTMLDivElement | null>(null);
const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false);
const headerTemplateMenuRef = useRef<HTMLDivElement | null>(null);
const importTemplateInputRef = useRef<HTMLInputElement | null>(null);
const designHistory = useUndoRedo<LabelTemplate>();
const skipDesignHistoryRef = useRef(false);
const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({
step: '',
templateId: null,
});
const paper = workingTemplate.paper ?? DEFAULT_PAPER_CONFIG;
const cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG;
// Save template list changes不将完整行数据写入模板列表避免 localStorage 膨胀)
const saveTemplatesToStorage = (updatedList: LabelTemplate[]) => {
setTemplates(updatedList);
try {
const stripped = updatedList.map(stripTemplateForStorage);
localStorage.setItem('label_templates_list_v2', JSON.stringify(stripped));
} catch (e) { }
};
// Modify active template elements
const handleTemplateChange = (updated: LabelTemplate) => {
const updatedList = templates.map(t => t.id === updated.id ? updated : t);
saveTemplatesToStorage(updatedList);
};
const handleDesignTemplateChange = useCallback(
(updated: LabelTemplate) => {
if (
!skipDesignHistoryRef.current &&
workflowStep === 'template-design' &&
activeTemplate &&
updated.id === activeTemplate.id
) {
designHistory.record(activeTemplate, updated);
}
skipDesignHistoryRef.current = false;
handleTemplateChange(updated);
},
[workflowStep, activeTemplate, designHistory, templates]
);
const handleDesignUndo = useCallback(() => {
if (!activeTemplate) return;
const previous = designHistory.undo(activeTemplate);
if (!previous) return;
skipDesignHistoryRef.current = true;
handleTemplateChange(previous);
}, [activeTemplate, designHistory, templates]);
const handleDesignRedo = useCallback(() => {
if (!activeTemplate) return;
const next = designHistory.redo(activeTemplate);
if (!next) return;
skipDesignHistoryRef.current = true;
handleTemplateChange(next);
}, [activeTemplate, designHistory, templates]);
useEffect(() => {
if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
const needReset =
designHistorySessionRef.current.step !== 'template-design' ||
designHistorySessionRef.current.templateId !== activeTemplate.id;
if (needReset) {
designHistory.reset();
designHistorySessionRef.current = {
step: workflowStep,
templateId: activeTemplate.id,
};
}
}, [workflowStep, activeTemplate?.id, designHistory]);
useEffect(() => {
if (workflowStep !== 'template-design') return;
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return;
}
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
handleDesignUndo();
} else if (e.key === 'y' || (e.key === 'z' && e.shiftKey) || (e.key === 'Z' && e.shiftKey)) {
e.preventDefault();
handleDesignRedo();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [workflowStep, handleDesignUndo, handleDesignRedo]);
// Save active template selected state and sync its dataset
const handleSelectTemplate = (id: string) => {
setSelectedTemplateId(id);
setSelectedElementIds([]);
try {
localStorage.setItem('selected_label_template_id_v2', id);
} catch (e) { }
setActiveRowIndex(0);
};
useEffect(() => {
if (!hasTemplates) {
if (selectedTemplateId) setSelectedTemplateId('');
if (workflowStep !== 'template-select') setWorkflowStep('template-select');
return;
}
if (!templates.some((t) => t.id === selectedTemplateId)) {
handleSelectTemplate(templates[0].id!);
}
}, [hasTemplates, templates, selectedTemplateId, workflowStep]);
const handlePaperChange = (updated: PaperConfig) => {
if (!activeTemplate) return;
handleTemplateChange({ ...activeTemplate, paper: updated });
};
const handleCutLineChange = (next: CutLineConfig) => {
if (!activeTemplate) return;
handleTemplateChange({ ...activeTemplate, cutLine: next });
};
const openPrintView = (templateId?: string) => {
if (!hasTemplates) return;
if (templateId) {
handleSelectTemplate(templateId);
}
setImportData(null);
setVariableColumnMapping({});
setPreviewSnapshot(null);
setAppliedExportData(null);
setPreviewLayoutStale(false);
setActiveRowIndex(0);
setDraftExportRange(DEFAULT_EXPORT_RANGE);
setWorkflowStep('print-view');
};
const handleDataLoaded = (data: ImportData) => {
setImportData(data);
setPreviewSnapshot(null);
setAppliedExportData(null);
setPreviewLayoutStale(false);
setActiveRowIndex(0);
setDraftExportRange(DEFAULT_EXPORT_RANGE);
if (!activeTemplate) return;
const vars = extractTemplateVariables(activeTemplate);
setVariableColumnMapping(buildAutoVariableMapping(vars, data.columns));
};
const handleClearData = () => {
setImportData(null);
setVariableColumnMapping({});
setPreviewSnapshot(null);
setAppliedExportData(null);
setPreviewLayoutStale(false);
setActiveRowIndex(0);
setDraftExportRange(DEFAULT_EXPORT_RANGE);
};
const handleMappingChange = (mapping: Record<string, string>) => {
setVariableColumnMapping(mapping);
};
// Add customized new template
const handleAddTemplateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTmplName.trim()) {
await showAlert('请先输入合法的模板名', { variant: 'warning' });
return;
}
const newId = `custom_tmpl_${Date.now()}`;
const newTmpl: LabelTemplate = {
id: newId,
name: `${newTmplName}`,
width: Math.max(10, Number(newTmplW) || 70),
height: Math.max(10, Number(newTmplH) || 40),
elements: [
{
id: `desc_welcome`,
type: 'text',
name: '主文字内容',
x: 5,
y: 5,
width: Math.max(10, newTmplW - 10),
height: 10,
content: '{变量}',
fontSize: 12,
textAlign: 'center',
fontWeight: 'bold',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans'
}
],
paper: DEFAULT_PAPER_CONFIG,
cutLine: DEFAULT_CUT_LINE_CONFIG,
};
const list = [...templates, newTmpl];
saveTemplatesToStorage(list);
handleSelectTemplate(newId);
setNewTmplName('');
setShowAddTmplForm(false);
setWorkflowStep('template-design');
};
const closeAddTemplateDialog = () => {
setShowAddTmplForm(false);
setNewTmplName('');
setNewTmplW(75);
setNewTmplH(45);
};
const openEditTemplateDialog = (tmpl: LabelTemplate) => {
setEditingTemplateId(tmpl.id!);
setEditTmplName(tmpl.name);
};
const closeEditTemplateDialog = () => {
setEditingTemplateId(null);
setEditTmplName('');
};
const closeTemplateMenu = () => setTemplateMenuId(null);
const handleEditTemplateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingTemplateId) return;
const trimmed = editTmplName.trim();
if (!trimmed) {
await showAlert('请先输入合法的模板名', { variant: 'warning' });
return;
}
const target = templates.find((t) => t.id === editingTemplateId);
if (!target) {
closeEditTemplateDialog();
return;
}
saveTemplatesToStorage(
templates.map((t) => (t.id === editingTemplateId ? { ...t, name: trimmed } : t))
);
closeEditTemplateDialog();
};
// Export templates list or single active template as standard json
const handleExportTemplate = (tmpl: LabelTemplate) => {
const dataStr = JSON.stringify(normalizeTemplate(tmpl), null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
const exportFileDefaultName = `${tmpl.name?.replace(/\s+/g, '_') || tmpl.id}_${Date.now()}.lad`;
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
};
// Import custom template JSON uploader
const handleImportTemplate = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const parsed = JSON.parse(event.target?.result as string);
if (parsed.width && parsed.height && Array.isArray(parsed.elements)) {
const newId = `imported_${Date.now()}`;
const newTmpl = normalizeTemplate({
id: newId,
name: parsed.name || `导入外部模板_${Date.now()}`,
width: Number(parsed.width) || 70,
height: Number(parsed.height) || 40,
elements: parsed.elements,
variableList: parsed.variableList,
variableDefaults: parsed.variableDefaults,
paper: parsed.paper,
cutLine: parsed.cutLine,
showPdfCutLines: parsed.showPdfCutLines,
});
const list = [...templates, newTmpl];
saveTemplatesToStorage(list);
handleSelectTemplate(newId);
await showAlert('模板读取解析成功,已追加至库中。', { variant: 'success' });
} else {
await showAlert('模板格式错误:外部模板必须至少包含 width, height, 以及 elements 分组', {
variant: 'error',
});
}
} catch (err) {
await showAlert('文件解码失败,请确保您上传的是合法的模板文件。', {
variant: 'error',
});
}
};
reader.readAsText(file);
e.target.value = ''; // clears input
};
// Delete customized template
const handleDeleteTemplate = async (idToDelete: string) => {
const confirmed = await showConfirm('您确定要彻底删除该标签模板吗?此步骤无法逆转。', {
title: '删除模板',
confirmLabel: '删除',
variant: 'error',
});
if (!confirmed) return;
const remaining = templates.filter((t) => t.id !== idToDelete);
saveTemplatesToStorage(remaining);
if (selectedTemplateId === idToDelete) {
if (remaining.length > 0) {
handleSelectTemplate(remaining[0].id!);
} else {
setSelectedTemplateId('');
try {
localStorage.removeItem('selected_label_template_id_v2');
} catch (e) { }
setWorkflowStep('template-select');
}
}
};
const dataColumns = importData?.columns ?? [];
const activeRows = importData?.rows ?? [];
const templateVariables = useMemo(
() => extractTemplateVariables(workingTemplate),
[workingTemplate]
);
const mappedRows = useMemo(
() =>
activeRows.map((row) =>
applyRowVariableMapping(
row,
variableColumnMapping,
templateVariables,
workingTemplate.variableDefaults
)
),
[activeRows, variableColumnMapping, templateVariables, workingTemplate.variableDefaults]
);
const layoutResult = useMemo(
() => buildPrintablePages(mappedRows, paper, workingTemplate, draftExportRange),
[mappedRows, paper, workingTemplate, draftExportRange]
);
const {
pages: printablePages,
gridCols: gridInfoCols,
gridRows: gridInfoRows,
labelsPerPage,
totalPages,
selectedCount,
} = layoutResult;
const gridInfo = useMemo(
() => ({ cols: gridInfoCols, rows: gridInfoRows }),
[gridInfoCols, gridInfoRows]
);
const appliedMappedRows = useMemo(() => {
if (!appliedExportData) return [];
return appliedExportData.rows.map((row) =>
applyRowVariableMapping(
row,
appliedExportData.mapping,
templateVariables,
workingTemplate.variableDefaults
)
);
}, [appliedExportData, templateVariables, workingTemplate.variableDefaults]);
const appliedExportRange = appliedExportData?.exportRange ?? DEFAULT_EXPORT_RANGE;
const appliedLayoutResult = useMemo(
() => buildPrintablePages(appliedMappedRows, paper, workingTemplate, appliedExportRange),
[appliedMappedRows, paper, workingTemplate, appliedExportRange]
);
const dataStale = useMemo(() => {
if (!importData) return appliedExportData !== null;
if (!appliedExportData) return true;
if (
importData.fileName !== appliedExportData.fileName ||
importData.currentSheet !== appliedExportData.currentSheet ||
importData.rows !== appliedExportData.rows
) {
return true;
}
const draftKeys = Object.keys(variableColumnMapping).sort();
const appliedKeys = Object.keys(appliedExportData.mapping).sort();
if (draftKeys.length !== appliedKeys.length) return true;
if (draftKeys.some((key) => variableColumnMapping[key] !== appliedExportData.mapping[key])) {
return true;
}
return !exportRangeEquals(draftExportRange, appliedExportData.exportRange);
}, [importData, variableColumnMapping, appliedExportData, draftExportRange]);
// 移动端导出页实时同步数据,无需手动「应用数据」
useLayoutEffect(() => {
if (!isMobile || workflowStep !== 'print-view') return;
if (!importData || activeRows.length === 0) return;
const nextExportRange = { ...draftExportRange };
setAppliedExportData({
fileName: importData.fileName,
currentSheet: importData.currentSheet,
rows: importData.rows,
mapping: { ...variableColumnMapping },
exportRange: nextExportRange,
});
setPreviewSnapshot({
pages: layoutResult.pages,
gridCols: layoutResult.gridCols,
labelsPerPage: layoutResult.labelsPerPage,
totalPages: layoutResult.totalPages,
selectedCount: layoutResult.selectedCount,
paper,
exportRange: nextExportRange,
});
setPreviewLayoutStale(false);
}, [
isMobile,
workflowStep,
importData,
activeRows,
variableColumnMapping,
draftExportRange,
layoutResult,
paper,
]);
const handleApplyData = async () => {
if (!importData || activeRows.length === 0) {
await showAlert('请先上传 Excel 或 CSV 数据。', { variant: 'warning' });
return;
}
if (selectedCount === 0) {
await showAlert('当前数据范围内没有可预览的标签,请调整范围设置。', { variant: 'warning' });
return;
}
const nextExportRange = { ...draftExportRange };
setAppliedExportData({
fileName: importData.fileName,
currentSheet: importData.currentSheet,
rows: importData.rows,
mapping: { ...variableColumnMapping },
exportRange: nextExportRange,
});
setPreviewSnapshot({
pages: layoutResult.pages,
gridCols: layoutResult.gridCols,
labelsPerPage: layoutResult.labelsPerPage,
totalPages: layoutResult.totalPages,
selectedCount: layoutResult.selectedCount,
paper,
exportRange: nextExportRange,
});
setPreviewLayoutStale(false);
};
const handleRedrawPreview = async () => {
if (!appliedExportData) {
await showAlert('请先应用数据后再重新绘制预览。', { variant: 'warning' });
return;
}
if (appliedLayoutResult.selectedCount === 0) {
await showAlert('当前数据范围内没有可预览的标签,请调整范围设置。', { variant: 'warning' });
return;
}
setPreviewSnapshot({
pages: appliedLayoutResult.pages,
gridCols: appliedLayoutResult.gridCols,
labelsPerPage: appliedLayoutResult.labelsPerPage,
totalPages: appliedLayoutResult.totalPages,
selectedCount: appliedLayoutResult.selectedCount,
paper,
exportRange: appliedExportRange,
});
setPreviewLayoutStale(false);
};
useEffect(() => {
if (previewSnapshot !== null && appliedExportData !== null) {
setPreviewLayoutStale(true);
}
}, [workingTemplate.elements, workingTemplate.width, workingTemplate.height]);
useEffect(() => {
if (workflowStep !== 'print-view') return;
if (!appliedExportData) return;
const layout = buildPrintablePages(
appliedMappedRows,
paper,
workingTemplate,
appliedExportData.exportRange
);
setPreviewSnapshot({
pages: layout.pages,
gridCols: layout.gridCols,
labelsPerPage: layout.labelsPerPage,
totalPages: layout.totalPages,
selectedCount: layout.selectedCount,
paper,
exportRange: appliedExportData.exportRange,
});
setPreviewLayoutStale(false);
}, [
paper,
workflowStep,
appliedExportData,
appliedMappedRows,
workingTemplate.width,
workingTemplate.height,
]);
const pdfExportImage = useMemo(() => {
const hasCode = workingTemplate.elements.some(
(e) => e.type === 'barcode' || e.type === 'qrcode'
);
return hasCode
? { imageFormat: 'png' as const, jsPdfFormat: 'PNG' as const, scale: 10 }
: { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 };
}, [workingTemplate.elements]);
const exportPdf = async () => {
if (!activeTemplate) return;
if (!appliedExportData) {
await showAlert('请先应用数据后再导出 PDF。', { variant: 'warning' });
return;
}
if (appliedLayoutResult.selectedCount === 0) {
await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' });
return;
}
const labelTotal = appliedLayoutResult.selectedCount;
const exportTotalPages = appliedLayoutResult.totalPages;
setPdfGenerating(true);
setPdfProgress({
done: 0,
total: labelTotal,
page: 0,
totalPages: exportTotalPages,
phase: 'render',
});
const safeName = activeTemplate.name.replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 40) || 'labels';
const progressLastUpdate = { current: 0 };
try {
await exportLabelsPdfAsync({
paper,
template: activeTemplate,
printablePages: appliedLayoutResult.pages,
gridCols: appliedLayoutResult.gridCols,
cutLine,
filename: `${safeName}_${exportTotalPages}页.pdf`,
imageFormat: pdfExportImage.jsPdfFormat,
renderLabel: (label) =>
renderLabelToDataUrl({
widthMm: activeTemplate.width,
heightMm: activeTemplate.height,
elements: activeTemplate.elements,
rowData: label.row,
rowIndex: label.sourceIndex,
showBorder: false,
mode: 'output',
scale: pdfExportImage.scale,
imageFormat: pdfExportImage.imageFormat,
jpegQuality: 0.9,
}),
onProgress: (progress) => {
const now = Date.now();
const isDone = progress.done >= progress.total || progress.phase === 'save';
if (isDone || now - progressLastUpdate.current >= 150) {
progressLastUpdate.current = now;
setPdfProgress(progress);
}
},
});
} catch (e) {
console.error('PDF export failed', e);
await showAlert('PDF 生成失败,请重试。', { variant: 'error' });
} finally {
setPdfGenerating(false);
}
};
return (
<div
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
? 'bg-[#323232] text-[#e8e8e8]'
: workflowStep === 'print-view' && isMobile
? 'bg-[#323232] text-[#e8e8e8]'
: 'bg-slate-50 text-slate-800'
}`}
>
{/*
=========================================
SCREEN HEADER
=========================================
*/}
{workflowStep === 'template-select' && (
<header
className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates
? 'mobile-template-header-row'
: ''
} px-4 py-3 md:px-6 md:py-4`}
>
{/* Title logo and slogan description */}
<div
className={`flex items-center gap-3 min-w-0 ${isMobile && workflowStep === 'template-select' && hasTemplates ? 'flex-1' : ''
}`}
>
<img src={logoUrl} alt="" className="w-10 h-10" aria-hidden />
<div className="min-w-0">
<h1 className="text-[18px] font-bold tracking-wider flex items-center gap-2 text-slate-100 uppercase">
<span className="truncate"></span>
</h1>
<p className="text-[11px] text-slate-400">
</p>
</div>
</div>
{workflowStep === 'template-select' && hasTemplates && (
<div
className={`flex items-center gap-2 shrink-0 ${isMobile ? 'ml-auto' : 'flex-wrap'
}`}
>
{isMobile ? (
<>
<input
ref={importTemplateInputRef}
type="file"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
<div ref={headerTemplateMenuRef} className="relative shrink-0">
<button
type="button"
onClick={() => setHeaderTemplateMenuOpen((open) => !open)}
className="mobile-header-menu-btn"
aria-expanded={headerTemplateMenuOpen}
aria-haspopup="menu"
aria-label="更多"
>
<MoreHorizontal className="w-5 h-5 shrink-0" />
</button>
<AnchoredMenu
open={headerTemplateMenuOpen}
onClose={() => setHeaderTemplateMenuOpen(false)}
anchorRef={headerTemplateMenuRef}
className="min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={() => {
setHeaderTemplateMenuOpen(false);
setShowAddTmplForm(true);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<Plus className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setHeaderTemplateMenuOpen(false);
importTemplateInputRef.current?.click();
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<Upload className="w-4 h-4 shrink-0" />
</button>
</AnchoredMenu>
</div>
</>
) : (
<>
<button
type="button"
onClick={() => setShowAddTmplForm(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-semibold whitespace-nowrap transition hover:bg-indigo-800 shadow-sm cursor-pointer"
>
<Plus className="w-4 h-4 shrink-0" />
</button>
<label
className="inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer px-4 py-2 text-xs"
>
<Upload className="w-4 h-4 shrink-0" />
<input
type="file"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
</label>
</>
)}
</div>
)}
</header>
)}
{/* Primary Workspace scroll container */}
<main
className={`no-print flex-1 flex flex-col w-full min-h-0 ${workflowStep === 'template-design' || workflowStep === 'print-view'
? 'overflow-hidden'
: 'overflow-y-auto scrollbar-hide overscroll-contain p-4 md:p-6 max-w-[1720px] mx-auto gap-6'
}`}
>
{/* ========================================================
STEP 1: Template Selection, Import, and Excel binds dashboard
======================================================== */}
{workflowStep === 'template-select' && (
<div className="space-y-4 md:space-y-6">
{hasTemplates ? (
<div>
<h2 className="text-base font-bold text-gray-900"></h2>
<p className="text-xs text-gray-400 mt-1">
{isMobile
? '选择模板 → 上传 Excel → 关联变量 → 排版生成标签'
: '选择模板规格后进行设计与排版导出,或使用右上角按钮新建、导入模板。'}
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-16 md:py-24">
{/* <div className="w-16 h-16 rounded-2xl bg-indigo-50 flex items-center justify-center mb-5 border border-indigo-100">
<img src={logoUrl} alt="" className="w-12 h-12" aria-hidden />
</div> */}
<h2 className="text-lg font-bold text-gray-900"></h2>
<p className="text-sm text-gray-500 mt-2 max-w-md leading-relaxed">
{isMobile
? '导入已有模板,或在电脑端设计标签模板'
: '创建模板开始可视化设计,或导入已有的模板文件。'}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
<button
type="button"
onClick={() => setShowAddTmplForm(true)}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer"
>
<Plus className="w-4 h-4 shrink-0" />
</button>
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
<Upload className="w-4 h-4 shrink-0" />
<input
type="file"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
</label>
</div>
</div>
)}
{hasTemplates && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{templates.map((tmpl) => {
const isActive = tmpl.id === selectedTemplateId;
const { displayWidthPx, displayHeightPx } = fitTemplatePreviewSize(
tmpl.width,
tmpl.height
);
return (
<div
key={tmpl.id}
onClick={() => handleSelectTemplate(tmpl.id!)}
className={`bg-white border rounded-2xl p-5 shadow-xs transition-all flex flex-col cursor-pointer relative overflow-visible group select-none ${isActive
// ? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50/5'
? 'border-gray-200 hover:shadow-md hover:border-indigo-200'
: 'border-gray-200 hover:shadow-md hover:border-indigo-200'
}`}
>
{/* {isActive && (
<div
className="absolute top-0 right-0 py-1.5 px-3 text-[10px] font-bold rounded-bl-xl text-white"
style={{ backgroundColor: '#6366f1' }}
>
当前选中
</div>
)} */}
<div className="space-y-1 mb-3 min-w-0">
<h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug truncate">
{tmpl.name}
</h3>
<p className="text-[10px] text-gray-400 flex items-center gap-1">
<span className="text-[10px] font-mono font-extrabold text-indigo-700 bg-indigo-50 px-2 py-0.5 rounded-lg border border-indigo-100">
{tmpl.width} × {tmpl.height} mm
</span>
</p>
</div>
{/* Preview */}
<div
className="rounded-xl bg-gray-50 p-3 overflow-hidden flex items-center justify-center"
style={{
minHeight: `${TEMPLATE_PREVIEW_MAX_H + 24}px`,
}}
>
<div
className="bg-white shadow-sm shrink-0 overflow-hidden"
style={{
width: `${displayWidthPx}px`,
height: `${displayHeightPx}px`,
}}
>
<CanvasLabelImage
widthMm={tmpl.width}
heightMm={tmpl.height}
elements={tmpl.elements}
rowData={null}
rowIndex={0}
showBorder={true}
variableDefaults={tmpl.variableDefaults ?? null}
/>
</div>
</div>
{/* Actions */}
<div className={`flex flex-wrap items-center gap-2 mt-4 text-xs ${isMobile ? 'mobile-template-actions' : ''}`}>
{!isMobile && (
<button
onClick={(e) => {
e.stopPropagation();
handleSelectTemplate(tmpl.id!);
setWorkflowStep('template-design');
}}
className="shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-gray-50 text-indigo-700 border border-gray-100 hover:border-indigo-300 hover:bg-indigo-50 rounded-lg text-[11px] font-bold transition-colors cursor-pointer"
>
<Code className="w-3.5 h-3.5 shrink-0" />
</button>
)}
{isMobile && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSelectTemplate(tmpl.id!);
setWorkflowStep('template-design');
}}
className="mobile-secondary-btn flex-1 min-w-0 justify-center min-h-[44px] px-3 py-2.5 rounded-xl text-[13px] font-semibold"
>
<Code className="w-4 h-4 shrink-0" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
openPrintView(tmpl.id!);
}}
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white transition shadow-xs cursor-pointer ${isMobile
? 'mobile-primary-btn flex-1 min-w-0 justify-center min-h-[44px] px-4 py-2.5 rounded-xl text-[13px] font-semibold'
: 'shrink-0 whitespace-nowrap px-3 py-1.5 rounded-lg text-[11px] font-bold'
}`}
>
<Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} />
{isMobile ? '生成' : '排版导出'}
</button>
<div
ref={templateMenuId === tmpl.id ? templateMenuAnchorRef : null}
className="relative shrink-0"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setTemplateMenuId((id) => (id === tmpl.id ? null : tmpl.id!));
}}
className={`bg-gray-50 text-gray-600 hover:bg-gray-100 active:bg-gray-200 border border-gray-100 hover:border-gray-300 inline-flex items-center justify-center gap-1 transition cursor-pointer ${isMobile
? 'mobile-icon-btn min-h-[44px] px-3 rounded-xl text-[13px] font-medium'
: 'shrink-0 whitespace-nowrap px-2.5 py-1.5 rounded-lg text-[11px] font-semibold'
}`}
aria-expanded={templateMenuId === tmpl.id}
aria-haspopup="menu"
>
<MoreHorizontal className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'} shrink-0`} />
</button>
<AnchoredMenu
open={templateMenuId === tmpl.id}
onClose={closeTemplateMenu}
anchorRef={templateMenuAnchorRef}
className="min-w-[120px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
closeTemplateMenu();
openEditTemplateDialog(tmpl);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<Pencil className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
closeTemplateMenu();
handleExportTemplate(tmpl);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<FileDown className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
closeTemplateMenu();
void handleDeleteTemplate(tmpl.id!);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-rose-600 hover:bg-rose-50 active:bg-rose-100 cursor-pointer"
>
<Trash2 className="w-4 h-4 shrink-0" />
</button>
</AnchoredMenu>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* ========================================================
STEP 2: The visual drag-and-drop label designer
======================================================== */}
{workflowStep === 'template-design' && isMobile && hasTemplates && activeTemplate && (
<MobileDesignView
template={activeTemplate}
paper={paper}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
onChangeTemplate={handleDesignTemplateChange}
canUndo={designHistory.canUndo}
canRedo={designHistory.canRedo}
onUndo={handleDesignUndo}
onRedo={handleDesignRedo}
onBack={() => setWorkflowStep('template-select')}
onExport={() => openPrintView()}
/>
)}
{workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
<div className="flex-1 flex flex-col min-h-0">
{/* Options bar — Photoshop style */}
<div className="ps-options-bar">
<button
onClick={() => setWorkflowStep('template-select')}
className="text-[#aaa] hover:text-white transition cursor-pointer"
>
</button>
<span className="text-[#666]">|</span>
<span className="font-medium text-[#e8e8e8] truncate max-w-[200px]">
{activeTemplate.name}
</span>
<span className="text-[#666] text-[10px]">
{activeTemplate.width} × {activeTemplate.height} mm
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleDesignUndo}
disabled={!designHistory.canUndo}
title="撤销 (Ctrl+Z)"
className="inline-flex items-center justify-center w-7 h-7 text-[#ccc] hover:text-white hover:bg-[#444] rounded transition cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[#ccc]"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleDesignRedo}
disabled={!designHistory.canRedo}
title="恢复 (Ctrl+Shift+Z / Ctrl+Y)"
className="inline-flex items-center justify-center w-7 h-7 text-[#ccc] hover:text-white hover:bg-[#444] rounded transition cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[#ccc]"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</div>
<span className="hidden md:inline text-[10px] text-[#666]">
Shift/Ctrl+
</span>
<button
onClick={() => openPrintView()}
className="inline-flex items-center gap-1 px-3 py-1 bg-[#31a8ff] hover:bg-[#2890d8] text-white text-[11px] font-semibold transition cursor-pointer"
>
<FileDown className="w-3 h-3" />
</button>
</div>
{/* Workspace: toolbar + canvas + panels */}
<div className="flex-1 flex min-h-0">
<LabelDesigner
template={activeTemplate}
onChange={handleDesignTemplateChange}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
/>
<PropSidebar
template={activeTemplate}
onChangeTemplate={handleDesignTemplateChange}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
activeTab="element"
onChangeTab={() => { }}
paper={paper}
/>
</div>
</div>
)}
{/* ========================================================
STEP 3: Pagination layouts & Printed grids configuration
======================================================== */}
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
<MobileExportView
template={activeTemplate}
templateName={activeTemplate.name ?? '标签'}
templateVariables={templateVariables}
importData={importData}
onDataLoaded={handleDataLoaded}
onClear={handleClearData}
dataColumns={dataColumns}
variableColumnMapping={variableColumnMapping}
onChangeMapping={handleMappingChange}
paper={previewSnapshot?.paper ?? paper}
onChangePaper={handlePaperChange}
rows={mappedRows}
draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange}
cutLine={cutLine}
onChangeCutLine={handleCutLineChange}
printablePages={previewSnapshot?.pages ?? []}
gridCols={previewSnapshot?.gridCols ?? gridInfo.cols}
labelsPerPage={previewSnapshot?.labelsPerPage ?? labelsPerPage}
totalPages={previewSnapshot?.totalPages ?? totalPages}
selectedCount={previewSnapshot?.selectedCount ?? selectedCount}
draftTotalPages={totalPages}
draftSelectedCount={selectedCount}
totalRowCount={activeRows.length}
previewReady={previewSnapshot !== null}
previewLayoutStale={previewLayoutStale}
activeRowIndex={activeRowIndex}
onSelectRowIndex={setActiveRowIndex}
pdfGenerating={pdfGenerating}
onBack={() => setWorkflowStep('template-select')}
onExportPdf={exportPdf}
/>
)}
{workflowStep === 'print-view' && !isMobile && hasTemplates && activeTemplate && (
<div className="flex-1 flex flex-col min-h-0 print-view-shell">
<div className="ps-options-bar">
<button
type="button"
onClick={() => setWorkflowStep('template-select')}
className="text-[#aaa] hover:text-white transition cursor-pointer"
>
</button>
<span className="text-[#666]">|</span>
<span className="font-medium text-[#e8e8e8] truncate max-w-[180px]">
{activeTemplate.name}
</span>
<span className="text-[#666] text-[10px]">
{gridInfo.cols}×{gridInfo.rows} · {totalPages} ·{' '}
{selectedCount < activeRows.length
? `${selectedCount}/${activeRows.length}`
: `${activeRows.length}`}
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => setWorkflowStep('template-design')}
className="inline-flex items-center gap-1 px-2.5 py-1 border border-[#555] hover:border-[#777] text-[#ccc] hover:text-white text-[10px] transition cursor-pointer"
>
</button>
<button
type="button"
onClick={exportPdf}
disabled={
!appliedExportData || appliedLayoutResult.selectedCount === 0 || pdfGenerating
}
className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
: 'bg-[#444] text-[#666] cursor-not-allowed'
}`}
>
{pdfGenerating ? (
<Loader2 className="w-3 h-3 shrink-0 animate-spin" />
) : (
<FileDown className="w-3 h-3 shrink-0" />
)}
{pdfGenerating ? '生成中…' : '生成并保存 PDF'}
</button>
</div>
<div className="flex-1 flex min-h-0 print-view-body overflow-hidden">
<LayoutPreview
paper={previewSnapshot !== null ? previewSnapshot.paper : paper}
template={activeTemplate}
printablePages={previewSnapshot?.pages ?? []}
gridCols={previewSnapshot?.gridCols ?? gridInfo.cols}
labelsPerPage={previewSnapshot?.labelsPerPage ?? labelsPerPage}
totalPages={previewSnapshot?.totalPages ?? totalPages}
selectedCount={previewSnapshot?.selectedCount ?? selectedCount}
totalRowCount={activeRows.length}
draftTotalPages={totalPages}
draftSelectedCount={selectedCount}
previewReady={previewSnapshot !== null}
previewLayoutStale={previewLayoutStale}
cutLine={cutLine}
activeRowIndex={activeRowIndex}
onSelectRowIndex={setActiveRowIndex}
/>
<ExportSidebar
importData={importData}
onDataLoaded={handleDataLoaded}
onClear={handleClearData}
templateName={activeTemplate.name ?? '标签'}
templateVariables={templateVariables}
dataColumns={dataColumns}
variableColumnMapping={variableColumnMapping}
onChangeMapping={handleMappingChange}
paper={paper}
onChangePaper={handlePaperChange}
template={activeTemplate}
rows={mappedRows}
draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange}
cutLine={cutLine}
onChangeCutLine={handleCutLineChange}
previewReady={previewSnapshot !== null}
dataStale={dataStale}
previewLayoutStale={previewLayoutStale}
dataApplied={appliedExportData !== null}
onApplyData={handleApplyData}
onRedrawPreview={handleRedrawPreview}
draftSelectedCount={selectedCount}
/>
</div>
</div>
)}
</main>
{editingTemplateId && (
<div
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
onClick={closeEditTemplateDialog}
role="presentation"
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="重命名模板"
>
<form onSubmit={handleEditTemplateSubmit} className="px-6 py-5 space-y-4">
<div>
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
</label>
<input
type="text"
required
autoFocus
placeholder="请输入模板名称"
value={editTmplName}
onChange={(e) => setEditTmplName(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={closeEditTemplateDialog}
className="px-4 py-2 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-sm font-medium cursor-pointer"
>
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold cursor-pointer"
>
</button>
</div>
</form>
</div>
</div>
)}
{showAddTmplForm && (
<div
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
onClick={closeAddTemplateDialog}
role="presentation"
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="add-template-dialog-title"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3
id="add-template-dialog-title"
className="text-sm font-bold text-gray-900"
>
</h3>
<button
type="button"
onClick={closeAddTemplateDialog}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition cursor-pointer"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleAddTemplateSubmit} className="px-6 py-5 space-y-4">
<div>
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
</label>
<input
type="text"
required
autoFocus
placeholder="如: 电商仓外贴标 70x40"
value={newTmplName}
onChange={(e) => setNewTmplName(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
(mm)
</label>
<input
type="number"
required
min="10"
max="150"
value={newTmplW}
onChange={(e) => setNewTmplW(Number(e.target.value))}
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
(mm)
</label>
<input
type="number"
required
min="10"
max="150"
value={newTmplH}
onChange={(e) => setNewTmplH(Number(e.target.value))}
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={closeAddTemplateDialog}
className="px-4 py-2 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-sm font-medium cursor-pointer"
>
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold cursor-pointer"
>
</button>
</div>
</form>
</div>
</div>
)}
{pdfGenerating && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/70 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl px-8 py-6 flex flex-col items-center gap-4 min-w-[280px]">
<Loader2 className="w-10 h-10 text-indigo-600 animate-spin" />
<div className="text-center">
<p className="text-sm font-bold text-gray-900">
{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}
</p>
<p className="text-xs text-gray-500 mt-1">
{pdfProgress.phase === 'save'
? '即将完成,请稍候'
: `${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}
</p>
</div>
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-600 transition-all duration-200"
style={{
width: pdfProgress.total
? `${(pdfProgress.done / pdfProgress.total) * 100}%`
: '0%',
}}
/>
</div>
</div>
</div>
)}
</div>
);
}