Files
label-designer/src/App.tsx
2026-06-12 19:49:24 +08:00

1183 lines
46 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 } 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 { 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 { useAppDialog } from './components/AppDialog';
import {
FileDown,
Code,
Wand2,
Loader2,
Plus,
Download,
Upload,
Trash2,
X,
} from 'lucide-react';
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 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);
};
// 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} (${newTmplW}x${newTmplH}mm)`,
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);
};
// 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}.json`;
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('JSON格式错误外部模板必须至少包含 width, height, 以及 elements 分组', {
variant: 'error',
});
}
} catch (err) {
await showAlert('文件解码失败,请确保您上传的是合法的 json 模板结构文件。', {
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]);
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={`min-h-screen flex flex-col font-sans ${
workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
? 'bg-[#323232] text-[#e8e8e8] h-screen overflow-hidden'
: workflowStep === 'print-view' && isMobile
? 'bg-slate-100 text-slate-800 h-screen overflow-hidden'
: 'bg-slate-50 text-slate-800'
}`}
>
{/*
=========================================
SCREEN HEADER
=========================================
*/}
{!(isMobile && workflowStep === 'print-view') && (
<header
className={`no-print bg-slate-950 border-b border-slate-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header ${
workflowStep === 'template-design' || workflowStep === 'print-view' ? 'px-4 py-2' : '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">
<div className="p-2 md:p-2.5 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 rounded-xl shadow-inner shrink-0">
<Wand2 className="w-5 h-5" />
</div>
<div className="min-w-0">
<h1 className="text-sm font-bold tracking-wider flex items-center gap-2 text-slate-100 uppercase">
<span className="truncate">{isMobile ? '批量标签排版印刷生成器' : '批量标签排版印刷生成器'}</span>
<span className="text-[9.5px] bg-indigo-600 text-white font-extrabold px-1.5 py-0.5 rounded shadow-sm tracking-normal shrink-0">PDF</span>
</h1>
{workflowStep !== 'template-design' && workflowStep !== 'print-view' && !isMobile && (
<p className="text-[11px] text-slate-400">
Excel A4
</p>
)}
</div>
</div>
{workflowStep === 'template-select' && hasTemplates && !isMobile && (
<div className="flex flex-wrap items-center gap-2 shrink-0">
<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-700 shadow-sm cursor-pointer"
>
<Plus className="w-4 h-4 shrink-0" />
</button>
<label className="inline-flex items-center gap-1.5 px-4 py-2 bg-white/10 hover:bg-white/15 border border-slate-700 text-slate-100 rounded-xl text-xs font-semibold whitespace-nowrap transition shadow-sm cursor-pointer">
<Upload className="w-4 h-4 shrink-0" />
JSON
<input
type="file"
accept=".json"
onChange={handleImportTemplate}
className="hidden"
/>
</label>
</div>
)}
</header>
)}
{/* Primary Workspace scroll container */}
<main
className={`no-print flex-1 flex flex-col w-full ${
workflowStep === 'template-design' || workflowStep === 'print-view'
? 'overflow-hidden min-h-0'
: 'overflow-auto 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 → 关联变量 → 生成 PDF'
: '选择模板规格后进行设计与排版导出,或使用右上角按钮新建、导入模板。'}
</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 text-indigo-500 flex items-center justify-center mb-5 border border-indigo-100">
<Wand2 className="w-8 h-8" />
</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
? '请导入已有模板,或电脑端进行设计。'
: '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
{!isMobile && (
<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" />
JSON
<input
type="file"
accept=".json"
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-hidden 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'
}`}
>
{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 pr-12 mb-3">
<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>
<h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug">{tmpl.name}</h3>
<p className="text-[10px] text-gray-400">{tmpl.elements.length} </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>
)}
<button
onClick={(e) => {
e.stopPropagation();
openPrintView(tmpl.id!);
}}
className={`shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold transition shadow-xs cursor-pointer ${
isMobile ? 'mobile-primary-btn flex-1' : ''
}`}
>
<FileDown className="w-3.5 h-3.5 shrink-0" />
{isMobile ? '上传数据并导出 PDF' : '排版导出'}
</button>
{!isMobile && (
<>
<button
onClick={(e) => {
e.stopPropagation();
handleExportTemplate(tmpl);
}}
className="shrink-0 p-1.5 bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-100 hover:border-gray-300 rounded-lg inline-flex items-center justify-center transition cursor-pointer"
title="导出 JSON"
>
<Download className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteTemplate(tmpl.id!);
}}
className="shrink-0 p-1.5 bg-gray-50 text-rose-500 hover:text-white hover:bg-rose-600 border border-gray-100 rounded-lg inline-flex items-center justify-center transition cursor-pointer"
title="删除模板"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* ========================================================
STEP 2: The visual drag-and-drop label designer
======================================================== */}
{workflowStep === 'template-design' && isMobile && hasTemplates && (
<div className="mobile-design-block bg-[#292929] text-[#e8e8e8] flex-1">
<p className="text-sm text-[#ccc] max-w-[280px] leading-relaxed">
PDF
</p>
<button
type="button"
onClick={() => openPrintView()}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#31a8ff] text-white text-sm font-semibold rounded-lg cursor-pointer"
>
<FileDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setWorkflowStep('template-select')}
className="text-[12px] text-[#999] underline cursor-pointer"
>
</button>
</div>
)}
{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" />
<span className="hidden md:inline text-[10px] text-[#666]">
Shift/Ctrl+ · V · T ·
</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={handleTemplateChange}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
/>
<PropSidebar
template={activeTemplate}
onChangeTemplate={handleTemplateChange}
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={paper}
onChangePaper={handlePaperChange}
rows={mappedRows}
draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange}
cutLine={cutLine}
onChangeCutLine={handleCutLineChange}
dataStale={dataStale}
dataApplied={appliedExportData !== null}
onApplyData={handleApplyData}
appliedSelectedCount={appliedLayoutResult.selectedCount}
appliedTotalPages={appliedLayoutResult.totalPages}
appliedGridCols={appliedLayoutResult.gridCols}
appliedGridRows={appliedLayoutResult.gridRows}
draftSelectedCount={selectedCount}
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>
{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>
);
}